From 198584daf8a97bda0b5a1abec04bc5e48f76baf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=9C=8D=E5=8A=A1=E9=83=A8-=E5=8F=B6=E6=9E=97?= =?UTF-8?q?=E7=AB=8B?= <1361666059@qq.com> Date: Wed, 19 Aug 2026 12:45:47 +0800 Subject: [PATCH] feat: add search API key configuration --- AGENTS.md | 13 ++-- README.md | 15 +++- extensions/mcp-config.ts | 57 ++++++++------- extensions/mcp.ts | 9 ++- extensions/search-config.ts | 57 +++++++++++++++ package.json | 2 + search_config.sh | 136 +++++++++++++++++++++++++++++++++++ tests/extension-load.test.ts | 39 ++++++++++ tests/mcp.test.ts | 40 +++++++++-- tests/search-config.test.ts | 67 +++++++++++++++++ 10 files changed, 390 insertions(+), 45 deletions(-) create mode 100644 extensions/search-config.ts create mode 100755 search_config.sh create mode 100644 tests/extension-load.test.ts create mode 100644 tests/search-config.test.ts diff --git a/AGENTS.md b/AGENTS.md index 45eb03c..a910b98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,8 @@ - `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。 - `extensions/tavily-override.ts`:加载 `@tavily/pi-extension`,把上游工具重命名为 `tavily_web_search` / `tavily_web_fetch`,并同步改写 label 与工具提示中的内部名称。 - `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。 -- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa 原始工具映射为统一的来源前缀形式 `exa_web_search`、`exa_web_fetch` 和 `exa_web_search_advanced`,CodeGraph 只暴露 `codegraph_explore`。 +- `search_config.sh` 与 `extensions/search-config.ts`:以交互式或参数方式把三家搜索 key 写入用户级 `search.env`(权限 `600`),并在搜索扩展初始化前加载;显式进程环境变量优先。 +- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式,CodeGraph 只暴露 `codegraph_explore`。 - `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。 - `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。 - `extensions/permission-system.ts`:在权限扩展注册前,将 `config/pi-permission-system.json` 同步为全局权威配置。 @@ -42,7 +43,9 @@ - `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。 - CodeGraph 扩展只配置 Pi 到外部 `codegraph` 命令的 MCP 连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。仓库不执行 `codegraph init`,不创建或管理 `.codegraph/`,也不改动索引、更新或遥测设置。 - CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。 -- Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。 +- 所有 MCP 服务必须合并到 `extensions/mcp.ts` 创建的唯一 `pi-mcp-adapter` 实例;不得新增独立 adapter 扩展入口,否则会重复注册 `--mcp-config`、`/mcp` 等全局接口。根测试必须扫描全部本地扩展入口并强制这一不变量。 +- Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。 +- 搜索 key 默认保存在 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/search.env`,文件必须为 `600` 且不得提交;`search_config.sh` 只输出配置状态,禁止回显 key。 - `pi-context-view` 只观察上下文占用,不参与压缩策略。 - Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier,由 `/fast-mode` 在会话内控制。 - 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`。 @@ -66,7 +69,7 @@ - 需要原生构建的依赖只按锁定版本加入根 `allowScripts`;当前仅允许 Hermes Memory 所需的 `better-sqlite3`,不得批量批准其他 install scripts。 - Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。 - 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。 -- `install.sh`、`update.sh` 和 `uninstall.sh` 必须保持 POSIX `sh` 兼容和可执行权限,并包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。 +- `install.sh`、`update.sh`、`uninstall.sh` 和 `search_config.sh` 必须保持 POSIX `sh` 兼容和可执行权限,并包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。 - 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。 - 未经明确要求,不执行发布、提交、推送、运行安装/卸载脚本或安装到用户 Pi 运行目录等外部写操作。 @@ -101,9 +104,9 @@ ## 验证 -组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用当前固定版本的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。 +组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效、MCP adapter 唯一所有者不变量测试,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用当前固定版本的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。 -安装、升级或卸载脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程 Kitty/Oh My Zsh 安装器、Git 克隆或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。 +安装、升级、卸载或搜索配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n search_config.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。搜索配置测试只能使用虚拟 key 和隔离 HOME,不得把真实 key 写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。 在 `pi-permission-auto-review/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`;涉及权限集成时还要用根目录固定的 `pi-permission-system` 版本验证 authorizer 注册、`allow` / `deny` / `defer` 与 delegation envelope。 diff --git a/README.md b/README.md index 55db40d..adb9715 100644 --- a/README.md +++ b/README.md @@ -155,11 +155,24 @@ FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash ### 在线搜索服务 +运行 `./search_config.sh` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;也可一次无交互配置: + +```bash +./search_config.sh --tavily '...' --exa '...' --keenable '...' +``` + +只传部分参数时保留其他服务的已有值。脚本把 key 原子写入 +`${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/search.env` 并设置权限为 `600`; +`MY_PI_SEARCH_CONFIG` 或 `--config` 可覆盖路径。组合包在搜索扩展初始化前加载该文件, +但调用 Pi 时显式传入的同名环境变量优先。配置文件不属于仓库,不会随安装包提交。 + Tavily 通过 `extensions/tavily-override.ts` 加载官方扩展。override 只改工具身份,不改官方执行逻辑:上游 `web_search` / `web_fetch` 被注册为 `tavily_web_search` / `tavily_web_fetch`,label 和提示词中的内部工具引用也同步带上 Tavily 标识。使用前需设置 `TAVILY_API_KEY`。Tavily 适合广泛发现,但来源可能混杂且输出较长,应从较小的 `max_results` 开始,非必要不返回 raw content。 Keenable 直接加载 npm 包的扩展和 skill,默认无需 key;设置 `KEENABLE_API_KEY` 可提高速率限制。它适合中文内容、`site` 限定和发布日期筛选。搜索结果保持紧凑,只对选中的 URL 调用 `keenable_fetch`。 -`extensions/mcp.ts` 通过单个 `pi-mcp-adapter` 实例同时连接 Exa 与 CodeGraph,避免两个 adapter 重复注册 `--mcp-config`、`/mcp` 和 `/mcp-auth`。其中 Exa 连接 `https://mcp.exa.ai/mcp`,并把 MCP 原始工具映射为统一的来源前缀形式:`exa_web_search`、`exa_web_fetch` 和 `exa_web_search_advanced`。Exa 适合官方文档、技术资料、版本信息和论文;高级搜索必须显式限制结果数量与返回文本长度,避免响应过大。 +`extensions/mcp.ts` 通过单个 `pi-mcp-adapter` 实例同时连接 Exa 与 CodeGraph,避免两个 adapter 重复注册 `--mcp-config`、`/mcp` 和 `/mcp-auth`。其中 Exa 连接 `https://mcp.exa.ai/mcp`,并把 MCP 原始工具映射为统一的来源前缀形式:`exa_web_search`、`exa_web_fetch` 和 `exa_web_search_advanced`。配置 `EXA_API_KEY` 后通过 `x-api-key` 请求头认证,不把 key 放进 URL。Exa 适合官方文档、技术资料、版本信息和论文;高级搜索必须显式限制结果数量与返回文本长度,避免响应过大。 + +根测试会扫描所有本地扩展入口,强制只有 `extensions/mcp.ts` 可以创建或导入 `pi-mcp-adapter`。新增 MCP 服务必须合并进这个共享实例,不能再增加独立 adapter 入口;隔离 Pi 加载仍作为发布前的运行时验证。 ### CodeGraph 只负责连接 diff --git a/extensions/mcp-config.ts b/extensions/mcp-config.ts index 900f2bf..ba39300 100644 --- a/extensions/mcp-config.ts +++ b/extensions/mcp-config.ts @@ -1,33 +1,36 @@ -import type { McpConfig } from "pi-mcp-adapter"; export const EXA_TOOLS = ["web_search_exa", "web_fetch_exa", "web_search_advanced_exa"]; const EXA_MCP_URL = `https://mcp.exa.ai/mcp?tools=${EXA_TOOLS.join(",")}`; -export const MCP_CONFIG: McpConfig = { - settings: { - disableProxyTool: true, - scriptMode: false, - }, - mcpServers: { - exa: { - url: EXA_MCP_URL, - protocolVersion: "auto", - lifecycle: "eager", - directTools: EXA_TOOLS, - includeTools: EXA_TOOLS, - toolPrefix: "none", - approveTools: false, - exposeResources: false, +export function createMcpConfig(env: Record) { + const hasExaApiKey = Boolean(env.EXA_API_KEY?.trim()); + return { + settings: { + disableProxyTool: true, + scriptMode: false, }, - codegraph: { - command: "codegraph", - args: ["serve", "--mcp"], - lifecycle: "keep-alive", - directTools: ["codegraph_explore"], - includeTools: ["codegraph_explore"], - toolPrefix: "none", - approveTools: false, - exposeResources: false, + mcpServers: { + exa: { + url: EXA_MCP_URL, + ...(hasExaApiKey ? { headers: { "x-api-key": "$env:EXA_API_KEY" } } : {}), + protocolVersion: "auto" as const, + lifecycle: "eager" as const, + directTools: EXA_TOOLS, + includeTools: EXA_TOOLS, + toolPrefix: "none" as const, + approveTools: false, + exposeResources: false, + }, + codegraph: { + command: "codegraph", + args: ["serve", "--mcp"], + lifecycle: "keep-alive" as const, + directTools: ["codegraph_explore"], + includeTools: ["codegraph_explore"], + toolPrefix: "none" as const, + approveTools: false, + exposeResources: false, + }, }, - }, -}; + }; +} diff --git a/extensions/mcp.ts b/extensions/mcp.ts index 26dc43a..47a5240 100644 --- a/extensions/mcp.ts +++ b/extensions/mcp.ts @@ -1,13 +1,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createMcpAdapter } from "pi-mcp-adapter"; import { createExaToolApiOverride } from "./exa-tool-prefix.ts"; -import { MCP_CONFIG } from "./mcp-config.ts"; - -const mcpAdapter = createMcpAdapter({ - config: MCP_CONFIG, -}); +import { createMcpConfig } from "./mcp-config.ts"; +import { loadSearchConfigEnv } from "./search-config.ts"; /** Connect Pi to Exa and CodeGraph through one shared MCP adapter instance. */ export default function mcpExtension(pi: ExtensionAPI): void { + const searchEnvironment = loadSearchConfigEnv(); + const mcpAdapter = createMcpAdapter({ config: createMcpConfig(searchEnvironment) }); mcpAdapter(createExaToolApiOverride(pi)); } diff --git a/extensions/search-config.ts b/extensions/search-config.ts new file mode 100644 index 0000000..0134f8b --- /dev/null +++ b/extensions/search-config.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const SEARCH_API_KEY_NAMES = ["TAVILY_API_KEY", "EXA_API_KEY", "KEENABLE_API_KEY"] as const; + +type SearchApiKeyName = (typeof SEARCH_API_KEY_NAMES)[number]; +type SearchEnvironment = Record; + +const SAFE_VALUE = /^[A-Za-z0-9._-]+$/; + +export function getSearchConfigPath(env: SearchEnvironment = process.env): string { + if (env.MY_PI_SEARCH_CONFIG?.trim()) { + return env.MY_PI_SEARCH_CONFIG.trim(); + } + const configHome = env.XDG_CONFIG_HOME?.trim() || join(env.HOME?.trim() || homedir(), ".config"); + return join(configHome, "my-pi", "search.env"); +} + +export function parseSearchConfig(text: string): Partial> { + const parsed: Partial> = {}; + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim().replace(/^export[ \t]+/u, ""); + if (!line || line.startsWith("#")) continue; + const separator = line.indexOf("="); + if (separator < 1) continue; + const name = line.slice(0, separator).trim() as SearchApiKeyName; + const value = line.slice(separator + 1).trim(); + if (!SEARCH_API_KEY_NAMES.includes(name) || !SAFE_VALUE.test(value)) continue; + parsed[name] = value; + } + return parsed; +} + +/** Load package-managed search credentials without overriding explicit process environment values. */ +export function loadSearchConfigEnv( + env: SearchEnvironment = process.env, + configPath = getSearchConfigPath(env), +): Partial> { + let parsed: Partial> = {}; + try { + parsed = parseSearchConfig(readFileSync(configPath, "utf8")); + } catch { + // Missing or unreadable config is equivalent to an empty package-managed config. + } + const effective: Partial> = {}; + for (const name of SEARCH_API_KEY_NAMES) { + if (!env[name]?.trim() && parsed[name]) env[name] = parsed[name]; + if (env[name]?.trim()) effective[name] = env[name]?.trim(); + } + return effective; +} + +/** Load search API keys before provider extensions initialize. */ +export default function searchConfigExtension(): void { + loadSearchConfigEnv(); +} diff --git a/package.json b/package.json index d16d26d..ca992aa 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,13 @@ "install.sh", "update.sh", "uninstall.sh", + "search_config.sh", "README.md", "AGENTS.md" ], "pi": { "extensions": [ + "./extensions/search-config.ts", "./extensions/fff-override.ts", "./extensions/tavily-override.ts", "./node_modules/@keenable/pi-search/src/index.ts", diff --git a/search_config.sh b/search_config.sh new file mode 100755 index 0000000..3124c4f --- /dev/null +++ b/search_config.sh @@ -0,0 +1,136 @@ +#!/bin/sh + +set -u + +say() { + printf '%s\n' "$*" +} + +warn() { + printf '错误: %s\n' "$*" >&2 +} + +usage() { + cat <<'EOF' +用法: + ./search_config.sh + ./search_config.sh --tavily KEY --exa KEY --keenable KEY + +选项: + --tavily KEY 配置 TAVILY_API_KEY + --exa KEY 配置 EXA_API_KEY + --keenable KEY 配置 KEENABLE_API_KEY + --config FILE 覆盖配置文件路径 + -h, --help 显示帮助 + +不传 key 参数时进入隐藏输入的交互模式;传入任意 key 参数时不交互, +未指定的服务保留已有值。配置文件默认是 ~/.config/my-pi/search.env。 +EOF +} + +CONFIG_FILE=${MY_PI_SEARCH_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/search.env} +mode=interactive +tavily_set=0 +exa_set=0 +keenable_set=0 +tavily_value= +exa_value= +keenable_value= + +while [ "$#" -gt 0 ]; do + case $1 in + --tavily) + [ "$#" -ge 2 ] || { warn "--tavily 缺少 KEY"; exit 2; } + tavily_value=$2; tavily_set=1; mode=noninteractive; shift 2 ;; + --exa) + [ "$#" -ge 2 ] || { warn "--exa 缺少 KEY"; exit 2; } + exa_value=$2; exa_set=1; mode=noninteractive; shift 2 ;; + --keenable) + [ "$#" -ge 2 ] || { warn "--keenable 缺少 KEY"; exit 2; } + keenable_value=$2; keenable_set=1; mode=noninteractive; shift 2 ;; + --config) + [ "$#" -ge 2 ] || { warn "--config 缺少 FILE"; exit 2; } + CONFIG_FILE=$2; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) warn "未知参数:$1"; usage >&2; exit 2 ;; + esac +done + +valid_key() { + case $1 in + ''|*[!A-Za-z0-9._-]*) return 1 ;; + *) return 0 ;; + esac +} + +read_existing() { + existing_name=$1 + [ -f "$CONFIG_FILE" ] || return 0 + awk -v name="$existing_name" ' + $1 == "export" { sub(/^export[ \t]+/, "") } + index($0, name "=") == 1 { print substr($0, length(name) + 2); exit } + ' "$CONFIG_FILE" +} + +prompt_key() { + prompt_label=$1 + prompt_current=$2 + if [ ! -t 0 ]; then + warn "交互模式需要终端;请改用 --tavily/--exa/--keenable 参数。" + return 2 + fi + if [ -n "$prompt_current" ]; then + printf '%s API key(回车保留现有值): ' "$prompt_label" >&2 + else + printf '%s API key(回车跳过): ' "$prompt_label" >&2 + fi + stty -echo + IFS= read -r prompt_value || prompt_status=$? + stty echo + printf '\n' >&2 + [ "${prompt_status:-0}" -eq 0 ] || return "$prompt_status" + printf '%s\n' "$prompt_value" +} + +current_tavily=$(read_existing TAVILY_API_KEY) +current_exa=$(read_existing EXA_API_KEY) +current_keenable=$(read_existing KEENABLE_API_KEY) + +if [ "$mode" = interactive ]; then + tavily_value=$(prompt_key Tavily "$current_tavily") || exit $? + exa_value=$(prompt_key Exa "$current_exa") || exit $? + keenable_value=$(prompt_key Keenable "$current_keenable") || exit $? + [ -n "$tavily_value" ] && tavily_set=1 + [ -n "$exa_value" ] && exa_set=1 + [ -n "$keenable_value" ] && keenable_set=1 +fi + +[ "$tavily_set" -eq 1 ] || tavily_value=$current_tavily +[ "$exa_set" -eq 1 ] || exa_value=$current_exa +[ "$keenable_set" -eq 1 ] || keenable_value=$current_keenable + +for key_value in "$tavily_value" "$exa_value" "$keenable_value"; do + [ -z "$key_value" ] || valid_key "$key_value" || { + warn "API key 只能包含字母、数字、点、下划线和连字符。" + exit 2 + } +done + +config_dir=${CONFIG_FILE%/*} +[ "$config_dir" = "$CONFIG_FILE" ] && config_dir=. +umask 077 +mkdir -p "$config_dir" || exit 1 +config_tmp=$(mktemp "$config_dir/.search.env.XXXXXX") || exit 1 +trap 'rm -f "$config_tmp"' EXIT HUP INT TERM +{ + printf '# Generated by my-pi/search_config.sh. Do not commit this file.\n' + [ -z "$tavily_value" ] || printf 'export TAVILY_API_KEY=%s\n' "$tavily_value" + [ -z "$exa_value" ] || printf 'export EXA_API_KEY=%s\n' "$exa_value" + [ -z "$keenable_value" ] || printf 'export KEENABLE_API_KEY=%s\n' "$keenable_value" +} > "$config_tmp" || exit 1 +chmod 600 "$config_tmp" || exit 1 +mv "$config_tmp" "$CONFIG_FILE" || exit 1 +trap - EXIT HUP INT TERM + +say "搜索 API key 已安全写入 ${CONFIG_FILE}(权限 600)。" +say "重启 Pi 后生效;显式环境变量仍优先于该文件。" diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts new file mode 100644 index 0000000..ced7a56 --- /dev/null +++ b/tests/extension-load.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const hasPi = spawnSync("pi", ["--version"], { encoding: "utf8" }).status === 0; + +test("all package extensions load together without global registration conflicts", { skip: !hasPi }, async () => { + const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as { + pi: { extensions: string[] }; + }; + const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-")); + const args = ["--no-extensions"]; + for (const entry of packageJson.pi.extensions) args.push("-e", resolve(repositoryRoot, entry)); + args.push("--help"); + + const result = spawnSync("pi", args, { + encoding: "utf8", + timeout: 60_000, + env: { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + PI_CODING_AGENT_DIR: join(home, ".pi-agent"), + MY_PI_SEARCH_CONFIG: join(home, "missing-search.env"), + PI_OFFLINE: "1", + TAVILY_API_KEY: "", + EXA_API_KEY: "", + KEENABLE_API_KEY: "", + }, + }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 789bf27..effd402 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -2,10 +2,10 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { EXA_TOOLS, MCP_CONFIG } from "../extensions/mcp-config.ts"; +import { createMcpConfig, EXA_TOOLS } from "../extensions/mcp-config.ts"; test("one MCP adapter config contains both Exa and CodeGraph", () => { - assert.deepEqual(MCP_CONFIG, { + assert.deepEqual(createMcpConfig({}), { settings: { disableProxyTool: true, scriptMode: false, @@ -35,13 +35,39 @@ test("one MCP adapter config contains both Exa and CodeGraph", () => { }); }); -test("the package loads only the shared MCP extension entry", async () => { +test("Exa API key is resolved from the environment into a header and never placed in the URL", () => { + const config = createMcpConfig({ EXA_API_KEY: "exa-test-key" }); + assert.deepEqual(config.mcpServers.exa.headers, { "x-api-key": "$env:EXA_API_KEY" }); + assert.equal(config.mcpServers.exa.url?.includes("exa-test-key"), false); + assert.equal(JSON.stringify(config).includes("exa-test-key"), false); +}); + +test("the package has exactly one local MCP adapter owner", async () => { const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { pi: { extensions: string[] }; }; - const localMcpEntries = packageJson.pi.extensions.filter((entry) => - entry === "./extensions/mcp.ts" || entry === "./extensions/exa.ts" || entry === "./extensions/codegraph.ts" - ); + const localEntries = packageJson.pi.extensions.filter((entry) => entry.startsWith("./extensions/")); + const adapterOwners: string[] = []; + for (const entry of localEntries) { + const source = await readFile(new URL(`../${entry.slice(2)}`, import.meta.url), "utf8"); + if (source.includes("createMcpAdapter") || source.includes('from "pi-mcp-adapter"')) { + adapterOwners.push(entry); + } + } + assert.deepEqual(adapterOwners, ["./extensions/mcp.ts"]); +}); - assert.deepEqual(localMcpEntries, ["./extensions/mcp.ts"]); +test("search credentials load before all search providers", async () => { + const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { + pi: { extensions: string[] }; + }; + const entries = packageJson.pi.extensions; + const loaderIndex = entries.indexOf("./extensions/search-config.ts"); + for (const provider of [ + "./extensions/tavily-override.ts", + "./node_modules/@keenable/pi-search/src/index.ts", + "./extensions/mcp.ts", + ]) { + assert.ok(loaderIndex >= 0 && loaderIndex < entries.indexOf(provider), `${provider} must load after search config`); + } }); diff --git a/tests/search-config.test.ts b/tests/search-config.test.ts new file mode 100644 index 0000000..f65adb9 --- /dev/null +++ b/tests/search-config.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { + getSearchConfigPath, + loadSearchConfigEnv, + parseSearchConfig, +} from "../extensions/search-config.ts"; + +test("search config parses only supported safe assignments", () => { + assert.deepEqual( + parseSearchConfig(` +export TAVILY_API_KEY=tvly-test_1 +EXA_API_KEY=exa-test.2 +UNKNOWN_KEY=ignored +KEENABLE_API_KEY=$(not-allowed) +`), + { + TAVILY_API_KEY: "tvly-test_1", + EXA_API_KEY: "exa-test.2", + }, + ); +}); + +test("search config does not override explicit environment values", async () => { + const root = await mkdtemp(join(tmpdir(), "my-pi-search-config-")); + const configPath = join(root, "search.env"); + await import("node:fs/promises").then(({ writeFile }) => + writeFile(configPath, "export TAVILY_API_KEY=file-value\nexport EXA_API_KEY=exa-file\n"), + ); + const env: Record = { TAVILY_API_KEY: "explicit-value" }; + const effective = loadSearchConfigEnv(env, configPath); + assert.equal(env.TAVILY_API_KEY, "explicit-value"); + assert.equal(env.EXA_API_KEY, "exa-file"); + assert.deepEqual(effective, { TAVILY_API_KEY: "explicit-value", EXA_API_KEY: "exa-file" }); +}); + +test("search config path follows XDG_CONFIG_HOME", () => { + assert.equal(getSearchConfigPath({ XDG_CONFIG_HOME: "/tmp/config" }), "/tmp/config/my-pi/search.env"); +}); + +test("search_config.sh supports noninteractive updates and preserves omitted keys", async () => { + const home = await mkdtemp(join(tmpdir(), "my-pi-search-script-")); + const script = new URL("../search_config.sh", import.meta.url); + let result = spawnSync("sh", [script.pathname, "--tavily", "tvly-test", "--exa", "exa-test"], { + env: { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, ".config") }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + + result = spawnSync("sh", [script.pathname, "--keenable", "keen-test"], { + env: { ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, ".config") }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + + const configPath = join(home, ".config", "my-pi", "search.env"); + const content = await readFile(configPath, "utf8"); + assert.match(content, /^export TAVILY_API_KEY=tvly-test$/m); + assert.match(content, /^export EXA_API_KEY=exa-test$/m); + assert.match(content, /^export KEENABLE_API_KEY=keen-test$/m); + assert.equal((await stat(configPath)).mode & 0o777, 0o600); +});