Files
Arcrun/cli/src/commands/kbdb.ts
T
uncle6me-web 886a8e31d0 feat(kbdb,mcp): KBDB 資料層薄殼 + self-hosted MCP 認證 + cypher KBDB proxy
三件一條鏈(HANDOFF §2/§3b,kbdb-base Phase 9):

A. KBDB MCP 薄殼(9.1):mcp/src/tools/kbdb_data.ts 6 工具
   template/record/query/search,調基本盤 API。鐵律:不給建表/SQL,只 template+slot。

B. MCP self-hosted 認證 401(mcp-account-source §5.5):
   - partner-auth.ts:MULTI_TENANT=false 時 Bearer 明碼直接當 org_namespace,
     繞 KBDB partner 驗證(對齊 cypher 的 opaque-key 模型)。官方 SaaS 行為不變、共用同碼。
   - mcp-setup.ts:把 namespace/api_key 寫進 .mcp.json headers.Authorization。
   - 新增 self-hosted vs SaaS 分支單測(9 tests 綠)。

C. cypher KBDB proxy(9.5)+ CLI 薄殼(9.2):
   - routes/kbdb-proxy.ts 純轉發 /kbdb/* → KBDB 基本盤(KBDB_BASE_URL HTTP fetch,
     不新增 service binding)。讓 CLI(只認證到 cypher)能達獨立 KBDB worker。
   - 租戶隔離:X-Arcrun-API-Key 自動當 owner_id 注入 records/entries(強制覆寫防跨租戶);
     templates 全域共享(虛擬表定義是 schema 非資料)。
   - cli/src/commands/kbdb.ts:acr kbdb template/record/query/search,與 MCP kbdb_* 同能力。
   - kbdb base:entries 加 page_name 過濾(9.3)。

cypher + cli + mcp tsc exit 0。未驗收:端到端需 deploy + KBDB_BASE_URL 可達後實測。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:12:32 +08:00

124 lines
6.0 KiB
TypeScript
Raw 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.
/**
* acr kbdb — KBDB 資料層 CLI 薄殼(kbdb-base Phase 9.2HANDOFF §2
*
* acr kbdb template create <name> --slots a,b,c 建 template(虛擬表定義)
* acr kbdb template list 列出所有 template
* acr kbdb record create <template> --values k=v… 填一筆 record
* acr kbdb record get <record_id> 取單筆 record
* acr kbdb query <template> 列某 template 下本租戶的 records
* acr kbdb search <q> 關鍵字搜尋(本租戶 LIKE)
*
* 薄殼鐵律(rule 07 §5):能力長在基本盤 API,CLI 只做介面轉換 + 暴露,無業務邏輯。
* 透過 cypher 的 KBDB proxy/kbdb/*)達 KBDB——CLI 本來就只認證到 cypherX-Arcrun-API-Key),
* 與 MCP 薄殼(kbdb_data.ts)同一組基本盤能力,差異只來自介面慣例(rule 07 §3.4)。
* KBDB 鐵律:只 template/slot,無建表/SQLproxy 端也不暴露)。
*/
import chalk from 'chalk';
import ora from 'ora';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
/** 取身份 + cypher URL,缺 api_key/namespace 直接退出(與 recipe.ts 一致)。 */
function ctx(): { url: string; apiKey: string } {
const config = loadConfig();
if (!config.api_key) {
console.error(chalk.red('缺少 API Key / namespace,請先執行 acr init(或設 NAMESPACE'));
process.exit(1);
}
return { url: getCypherExecutorUrl(config), apiKey: config.api_key };
}
/** 統一 fetch + JSON 解析 + 失敗印錯(薄殼只暴露,不含業務邏輯)。 */
async function call(method: string, path: string, body?: unknown): Promise<unknown> {
const { url, apiKey } = ctx();
const res = await fetch(`${url}${path}`, {
method,
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data: unknown;
try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
if (!res.ok) {
console.error(chalk.red(`KBDB 失敗(HTTP ${res.status}):${text || '(空回應)'}`));
process.exit(1);
}
return data;
}
/** 把 ["k=v","x=y"] 轉成 { k:"v", x:"y" }。 */
function parseKeyVals(pairs: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const p of pairs) {
const i = p.indexOf('=');
if (i < 0) {
console.error(chalk.red(`--values 格式須為 key=value,收到:"${p}"`));
process.exit(1);
}
out[p.slice(0, i)] = p.slice(i + 1);
}
return out;
}
// ── template ───────────────────────────────────────────────────────────────────
export async function cmdKbdbTemplateCreate(name: string, opts: { slots?: string }): Promise<void> {
const slots = (opts.slots ?? '').split(',').map(s => s.trim()).filter(Boolean);
if (slots.length === 0) {
console.error(chalk.red('需要 --slots a,b,c(至少一個欄位名)'));
process.exit(1);
}
const spinner = ora(`建 template "${name}"`).start();
const data = await call('POST', '/kbdb/templates', { name, slots });
spinner.succeed(chalk.green(`template "${name}" 已建(slots: ${slots.join(', ')}`));
console.log(chalk.gray(JSON.stringify(data, null, 2)));
}
export async function cmdKbdbTemplateList(): Promise<void> {
const data = await call('GET', '/kbdb/templates') as { templates?: Array<{ name: string; slots_json?: string }>; count?: number };
const list = data.templates ?? [];
if (list.length === 0) {
console.log(chalk.yellow('(尚無 template,用 acr kbdb template create <name> --slots … 建一個)'));
return;
}
console.log(chalk.bold(`\n ${list.length} 個 template\n`));
for (const t of list) {
console.log(` ${chalk.cyan(t.name)} ${chalk.gray(t.slots_json ?? '')}`);
}
console.log();
}
// ── record ───────────────────────────────────────────────────────────────────
export async function cmdKbdbRecordCreate(template: string, opts: { values?: string[] }): Promise<void> {
const values = parseKeyVals(opts.values ?? []);
if (Object.keys(values).length === 0) {
console.error(chalk.red('需要 --values slot=內容(可重複),如 --values name=Leo --values email=x@y.com'));
process.exit(1);
}
const spinner = ora(`填 recordtemplate "${template}"`).start();
const data = await call('POST', '/kbdb/records', { template, values });
spinner.succeed(chalk.green(`record 已存入 template "${template}"`));
console.log(chalk.gray(JSON.stringify(data, null, 2)));
}
export async function cmdKbdbRecordGet(recordId: string): Promise<void> {
const data = await call('GET', `/kbdb/records/${encodeURIComponent(recordId)}`);
console.log(chalk.gray(JSON.stringify(data, null, 2)));
}
// ── query / search ─────────────────────────────────────────────────────────────
export async function cmdKbdbQuery(template: string): Promise<void> {
const data = await call('GET', `/kbdb/records/by-template/${encodeURIComponent(template)}`) as { records?: unknown[]; count?: number };
const recs = data.records ?? [];
console.log(chalk.bold(`\n template "${template}" 下 ${recs.length} 筆 record(本租戶):\n`));
console.log(chalk.gray(JSON.stringify(recs, null, 2)));
}
export async function cmdKbdbSearch(q: string): Promise<void> {
const data = await call('GET', `/kbdb/search?q=${encodeURIComponent(q)}`) as { entries?: unknown[]; count?: number; mode?: string };
const hits = data.entries ?? [];
console.log(chalk.bold(`\n "${q}" 命中 ${hits.length} 筆(mode: ${data.mode ?? 'keyword'},本租戶):\n`));
console.log(chalk.gray(JSON.stringify(hits, null, 2)));
}