mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
- 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>
264 lines
8.2 KiB
JavaScript
Executable File
264 lines
8.2 KiB
JavaScript
Executable File
#!/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();
|
||
}
|