Files
my-pi/scripts/search-auth.mjs
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f9a7f743dd feat: 接入 Pi 内置 codemode,并修复全面审查发现的问题
- codemode:Tool Search 常驻(alwaysEnabled)、权限 allow(嵌套调用仍逐个过权限门)、
  tools.jsonc 登记使用规则;RTK 跳过 parentToolCallId 嵌套调用,避免丢失 bash
  structuredContent;Tool Search 目录过滤 hidden / codemode(script-only)exposure;
  扩展加载测试加载 builtin:codemode 并断言其常驻激活
- 搜索 key 不再经命令行参数传递:search-auth.mjs 改为 --set-stdin(先读校验再加锁),
  configure.sh 交互输入经管道、非交互用 --<provider> -
- CodeGraph 客户端监听子进程 stdin error,避免 EPIPE 未处理导致崩溃
- 移除 PI_TOOL_SEARCH/HERMES_MEMORY/RTK_CONFIG_PATH 环境变量入口,测试改用工厂 configPath 注入
- pi-subagents 固定 vitest / coverage-istanbul 4.1.10,修复 npm ls 版本冲突
- 补 pi-web-search / pi-worktree UPSTREAM.md 与 pi-minimal-footer AGENTS.md,
  files 白名单补齐并加测试;.codegraph/.gitignore 移出跟踪
- detect_kitty 优先识别官方安装目录

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 23:18:34 -07:00

264 lines
8.2 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Read-modify-write the tavily/exa/keenable api_key credentials in Pi's
* auth.json. Preserves every other provider entry (OAuth tokens included),
* writes atomically with a lock directory, and keeps the file mode 600.
*
* Usage:
* node search-auth.mjs --set-stdin tavily (reads the key from stdin)
* node search-auth.mjs --unset tavily [--unset exa]
* node search-auth.mjs --migrate [search.env path]
* node search-auth.mjs --list
* node search-auth.mjs --has tavily (exit 0 when set, 1 otherwise)
*
* --auth FILE overrides the auth.json location (default:
* $PI_CODING_AGENT_DIR/auth.json or ~/.pi/agent/auth.json).
*/
import {
chmodSync,
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { randomBytes } from "node:crypto";
const PROVIDERS = ["tavily", "exa", "keenable"];
const ENV_TO_PROVIDER = {
TAVILY_API_KEY: "tavily",
EXA_API_KEY: "exa",
KEENABLE_API_KEY: "keenable",
};
const SAFE_VALUE = /^[A-Za-z0-9._-]+$/;
const LOCK_RETRIES = 50;
const LOCK_DELAY_MS = 100;
function die(message) {
console.error(`错误: ${message}`);
process.exit(1);
}
function usage() {
console.log(`用法:
node search-auth.mjs --set-stdin <provider> (从标准输入读取 key,每次只能配置一个 provider)
node search-auth.mjs --unset <provider> [--unset ...]
node search-auth.mjs --migrate [search.env 路径]
node search-auth.mjs --list
node search-auth.mjs --has <provider>
选项:
--auth FILE 覆盖 auth.json 路径(默认 $PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json)`);
}
function defaultAuthPath(env = process.env) {
const agentDir = env.PI_CODING_AGENT_DIR?.trim() || join(env.HOME?.trim() || homedir(), ".pi", "agent");
return join(agentDir, "auth.json");
}
function defaultSearchEnvPath(env = process.env) {
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");
}
function parseArgs(argv) {
const result = { setStdin: undefined, unsets: new Set(), migrate: undefined, list: false, has: undefined, authPath: undefined };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--set") {
die("--set 已移除:key 不得出现在命令行参数中;请改用 --set-stdin 从标准输入提供。");
} else if (arg === "--set-stdin") {
const provider = argv[++i];
if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`);
if (result.setStdin) die("每次调用最多一个 --set-stdin。");
result.setStdin = provider;
} else if (arg === "--unset") {
const provider = argv[++i];
if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`);
result.unsets.add(provider);
} else if (arg === "--migrate") {
const next = argv[i + 1];
result.migrate = next && !next.startsWith("--") ? (i++, next) : true;
} else if (arg === "--list") {
result.list = true;
} else if (arg === "--has") {
const provider = argv[++i];
if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`);
result.has = provider;
} else if (arg === "--auth") {
result.authPath = argv[++i];
if (!result.authPath) die("--auth 缺少 FILE");
} else if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
} else {
die(`未知参数:${arg}`);
}
}
return result;
}
function acquireLock(authPath) {
mkdirSync(dirname(authPath), { recursive: true });
const lockDir = `${authPath}.lock`;
for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
try {
mkdirSync(lockDir);
return () => {
try {
rmSync(lockDir, { recursive: true });
} catch {}
};
} catch (error) {
if (error.code !== "EEXIST") throw error;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_DELAY_MS);
}
}
die(`无法获取 ${lockDir} 锁;可能有其他进程正在写 auth.json(残留锁目录可手动删除)。`);
}
function readAuthData(authPath) {
let raw;
try {
raw = readFileSync(authPath, "utf8");
} catch (error) {
if (error.code === "ENOENT") return {};
throw error;
}
let data;
try {
data = JSON.parse(raw);
} catch {
die(`auth.json 不是合法 JSON,已中止以免破坏现有凭据:${authPath}`);
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
die(`auth.json 顶层必须是对象,已中止以免破坏现有凭据:${authPath}`);
}
return data;
}
function writeAuthData(authPath, data) {
mkdirSync(dirname(authPath), { recursive: true });
const temp = join(dirname(authPath), `.auth.json.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
try {
const fd = openSync(temp, "w", 0o600);
writeFileSync(fd, `${JSON.stringify(data, null, 2)}\n`);
closeSync(fd);
renameSync(temp, authPath);
chmodSync(authPath, 0o600);
} finally {
rmSync(temp, { force: true });
}
}
function hasKey(data, provider) {
const entry = data[provider];
return Boolean(entry && typeof entry === "object" && !Array.isArray(entry) && entry.type === "api_key" && entry.key);
}
function setKey(data, provider, key) {
const existing = data[provider];
const preserved =
existing && typeof existing === "object" && !Array.isArray(existing) && existing.type === "api_key"
? existing
: {};
data[provider] = { ...preserved, type: "api_key", key };
}
function parseSearchEnv(text) {
const parsed = {};
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();
const value = line.slice(separator + 1).trim();
const provider = ENV_TO_PROVIDER[name];
if (!provider || !SAFE_VALUE.test(value)) continue;
parsed[provider] = value;
}
return parsed;
}
function validateKey(provider, key) {
if (!key || /[\r\n]/u.test(key)) die(`${provider} 的 key 不能为空且不能包含换行。`);
}
const args = parseArgs(process.argv.slice(2));
const authPath = args.authPath ?? defaultAuthPath();
if (args.list) {
const data = readAuthData(authPath);
for (const provider of PROVIDERS) {
console.log(`${provider}: ${hasKey(data, provider) ? "已配置" : "未配置"}`);
}
process.exit(0);
}
if (args.has) {
process.exit(hasKey(readAuthData(authPath), args.has) ? 0 : 1);
}
const mutations = (args.setStdin ? 1 : 0) + args.unsets.size + (args.migrate ? 1 : 0);
if (mutations === 0) {
usage();
process.exit(2);
}
const stdinKey = args.setStdin ? readFileSync(0, "utf8").trim() : undefined;
if (args.setStdin) validateKey(args.setStdin, stdinKey);
const release = acquireLock(authPath);
try {
const data = readAuthData(authPath);
const changed = [];
for (const provider of args.unsets) {
if (provider in data) {
delete data[provider];
changed.push(`已移除 ${provider}`);
}
}
if (args.setStdin) {
setKey(data, args.setStdin, stdinKey);
changed.push(`已写入 ${args.setStdin}`);
}
if (args.migrate) {
const source = args.migrate === true ? defaultSearchEnvPath() : args.migrate;
if (!existsSync(source)) die(`迁移来源不存在:${source}`);
const parsed = parseSearchEnv(readFileSync(source, "utf8"));
const imported = [];
const skipped = [];
for (const provider of PROVIDERS) {
if (!parsed[provider]) continue;
if (provider in data) {
skipped.push(provider);
continue;
}
setKey(data, provider, parsed[provider]);
imported.push(provider);
}
if (imported.length) changed.push(`已迁移 ${imported.join(", ")}`);
if (skipped.length) changed.push(`已跳过(auth.json 中已存在):${skipped.join(", ")}`);
if (!imported.length && !skipped.length) changed.push("search.env 中没有可迁移的 key");
}
if (changed.length === 0) {
console.log("auth.json 无需修改。");
} else {
writeAuthData(authPath, data);
for (const line of changed) console.log(line);
console.log(`搜索凭据已写入 ${authPath}(权限 600),重启 Pi 后生效。`);
}
} finally {
release();
}