2aa26a5bdd
leo 2026-06-29 重構根因:「telegram 與 google 不一致」其實是查錯表——舊世界要找一個能力 必須先決定它是 component/recipe/workflow 才查對清單,但查到答案前根本不知道屬哪類 → 「先知道分類」成了查詢前置,倒因為果(leo:連我都會查錯表)。這是搜尋設計缺陷,非粗心。 兩部分互補修法: 1. acr parts 清理(W3.1/W3.3):移除 5 個 hardcode 的 recipe(gmail_send/google_sheets_append/ telegram_send/line_notify_send/notion)——它們是 recipe(動態,存 store)不是零件(component)。 只留真 WASM 零件。檔頭寫死分流原則 + 移除處留註解防再 hardcode。footer 改指向動態清單 + acr search。 2. acr search <term> 新增(W3.2,正向修法):cli/src/commands/search.ts。fan-out 4 來源—— component(靜態 BUILTIN_COMPONENTS) + recipe(GET /recipes) + auth-recipe(GET /auth-recipes) + workflow(GET /webhooks/named),依類別回 counts+命中。各來源獨立 try(離線降級指路、不連坐)。 查的人不必先選表 → 結構性消除「查錯表」。 薄殼合規(rule 07 §2):search 只 fan-out 既有清單端點 + 過濾 + 分組 + 印出(介面層暴露/格式化), 不在介面層拼裝能力。component 靜態(PR-only)、recipe/auth/workflow 動態(store) 分流明確、不再 conflate。 實證(對 leo21c 跑,read-only GET): - acr search telegram → component:0 recipe:1 auth-recipe:0 workflow:1(一眼看出 telegram 是 recipe) - acr parts → 只剩 http_request 等真零件,5 個 recipe 已不在 - tsc 全綠、build 通過 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
7.2 KiB
TypeScript
171 lines
7.2 KiB
TypeScript
/**
|
||
* acr search <term> — 跨類統一搜尋(component / recipe / auth-recipe / workflow)
|
||
*
|
||
* 為什麼存在(issue #13,leo 2026-06-29):
|
||
* 舊世界要找一個能力(如 "telegram")必須**先決定**「它是零件?recipe?還是 workflow?」才能查對的清單。
|
||
* 但查的人(人或 AI)在查到答案前**根本不知道**它屬哪一類 →「先知道分類」成了查詢的前置條件,倒因為果。
|
||
* leo:「連我都會查錯表。」所以這不是粗心,是搜尋設計缺陷。
|
||
* 本指令一次掃全部類別、依類別分組回 counts + 命中 → 查的人不必先選表,看一眼就知道 telegram 是 recipe。
|
||
*
|
||
* 薄殼原則(rule 07 §2):本指令只做 fan-out 呼叫既有清單來源 + 過濾 + 分組 + 印出(介面層的暴露/格式化)。
|
||
* - component:靜態(BUILTIN_COMPONENTS,PR-only,複用 parts.ts 同一份,不另存)。
|
||
* - recipe / auth-recipe / workflow:**動態**,從 store 端點即時抓(GET /recipes、/auth-recipes、/webhooks/named)。
|
||
* 不在介面層拼裝任何能力;過濾是對 API 回傳值做的純客戶端格式化(§2.3 允許)。
|
||
*/
|
||
import chalk from 'chalk';
|
||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||
import { BUILTIN_COMPONENTS } from './parts.js';
|
||
|
||
interface Hit {
|
||
id: string; // canonical_id / service / workflow name
|
||
label: string; // display_name / 描述用名稱
|
||
detail?: string; // description(截斷)
|
||
}
|
||
|
||
interface CategoryResult {
|
||
key: 'component' | 'recipe' | 'auth-recipe' | 'workflow';
|
||
title: string;
|
||
dynamic: boolean; // 是否來自 store(動態)
|
||
listHint: string; // 列全部的指令
|
||
hits: Hit[];
|
||
error?: string; // 該來源抓取失敗(離線/服務不可用)→ 降級顯示,不中斷其他類
|
||
}
|
||
|
||
/** term 命中:id / label / detail 任一含 term(不分大小寫) */
|
||
function matches(term: string, ...fields: Array<string | undefined>): boolean {
|
||
const t = term.toLowerCase();
|
||
return fields.some((f) => (f ?? '').toLowerCase().includes(t));
|
||
}
|
||
|
||
function trim(s: string | undefined, n = 70): string | undefined {
|
||
if (!s) return undefined;
|
||
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
||
}
|
||
|
||
async function fetchJson(url: string, headers: Record<string, string>): Promise<unknown> {
|
||
const res = await fetch(url, { method: 'GET', headers });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
export async function cmdSearch(term: string): Promise<void> {
|
||
const q = (term ?? '').trim();
|
||
if (!q) {
|
||
console.error(chalk.red(' 請提供搜尋關鍵字:acr search <term>'));
|
||
process.exit(1);
|
||
}
|
||
|
||
const config = loadConfig();
|
||
const baseUrl = getCypherExecutorUrl(config);
|
||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
|
||
|
||
// ── component(靜態,本地,永不失敗)──
|
||
const componentRes: CategoryResult = {
|
||
key: 'component',
|
||
title: '零件 component(靜態 · PR-only · WASM)',
|
||
dynamic: false,
|
||
listHint: 'acr parts',
|
||
hits: BUILTIN_COMPONENTS
|
||
.filter((c) => matches(q, c.canonical_id, c.display_name, c.description))
|
||
.map((c) => ({ id: c.canonical_id, label: c.display_name, detail: trim(c.description) })),
|
||
};
|
||
|
||
// ── recipe / auth-recipe / workflow(動態,store;各自獨立 try,互不連坐)──
|
||
const dynamicSpecs: Array<{
|
||
key: CategoryResult['key'];
|
||
title: string;
|
||
listHint: string;
|
||
url: string;
|
||
extract: (data: unknown) => Hit[];
|
||
}> = [
|
||
{
|
||
key: 'recipe',
|
||
title: 'API recipe(動態 · store · 打外部服務)',
|
||
listHint: 'acr recipe list',
|
||
url: `${baseUrl}/recipes`,
|
||
extract: (data) => {
|
||
const arr = (data as { recipes?: Array<{ canonical_id: string; display_name?: string; description?: string }> }).recipes ?? [];
|
||
return arr
|
||
.filter((r) => matches(q, r.canonical_id, r.display_name, r.description))
|
||
.map((r) => ({ id: r.canonical_id, label: r.display_name ?? r.canonical_id, detail: trim(r.description) }));
|
||
},
|
||
},
|
||
{
|
||
key: 'auth-recipe',
|
||
title: '第三方服務認證 auth-recipe(動態 · store)',
|
||
listHint: 'acr auth-recipe list',
|
||
url: `${baseUrl}/auth-recipes`,
|
||
extract: (data) => {
|
||
const arr = (data as { recipes?: Array<{ service: string; display_name?: string; description?: string }> }).recipes ?? [];
|
||
return arr
|
||
.filter((r) => matches(q, r.service, r.display_name, r.description))
|
||
.map((r) => ({ id: r.service, label: r.display_name ?? r.service, detail: trim(r.description) }));
|
||
},
|
||
},
|
||
{
|
||
key: 'workflow',
|
||
title: '已部署 workflow(動態 · store)',
|
||
listHint: 'acr list',
|
||
url: `${baseUrl}/webhooks/named`,
|
||
extract: (data) => {
|
||
const arr = (data as { workflows?: Array<{ name: string; description?: string }> }).workflows ?? [];
|
||
return arr
|
||
.filter((w) => matches(q, w.name, w.description))
|
||
.map((w) => ({ id: w.name, label: w.name, detail: trim(w.description) }));
|
||
},
|
||
},
|
||
];
|
||
|
||
const dynamicResults = await Promise.all(
|
||
dynamicSpecs.map(async (spec): Promise<CategoryResult> => {
|
||
try {
|
||
const data = await fetchJson(spec.url, headers);
|
||
return { key: spec.key, title: spec.title, dynamic: true, listHint: spec.listHint, hits: spec.extract(data) };
|
||
} catch (e) {
|
||
return { key: spec.key, title: spec.title, dynamic: true, listHint: spec.listHint, hits: [], error: e instanceof Error ? e.message : String(e) };
|
||
}
|
||
}),
|
||
);
|
||
|
||
const all: CategoryResult[] = [componentRes, ...dynamicResults];
|
||
|
||
// ── 印出:先一行 counts 摘要(看一眼就知道屬哪類),再各類明細 ──
|
||
console.log(chalk.bold(`\n 搜尋「${q}」\n`));
|
||
|
||
const countLine = all
|
||
.map((r) => {
|
||
const n = r.error ? '?' : String(r.hits.length);
|
||
const c = r.error ? chalk.yellow(`${r.key}:${n}`) : (r.hits.length > 0 ? chalk.green(`${r.key}:${n}`) : chalk.gray(`${r.key}:0`));
|
||
return c;
|
||
})
|
||
.join(' ');
|
||
console.log(' ' + countLine + '\n');
|
||
|
||
let anyHit = false;
|
||
for (const r of all) {
|
||
if (r.error) {
|
||
console.log(chalk.yellow(` ${r.title} — 無法讀取(${r.error});離線時改用 ${chalk.cyan(r.listHint)}`));
|
||
console.log('');
|
||
continue;
|
||
}
|
||
if (r.hits.length === 0) continue;
|
||
anyHit = true;
|
||
console.log(chalk.bold.underline(` ${r.title}`));
|
||
for (const h of r.hits) {
|
||
const label = h.label && h.label !== h.id ? ` ${h.label}` : '';
|
||
console.log(` ${chalk.cyan(h.id.padEnd(24))}${label}`);
|
||
if (h.detail) console.log(chalk.gray(` ${h.detail}`));
|
||
}
|
||
console.log('');
|
||
}
|
||
|
||
if (!anyHit && !all.some((r) => r.error)) {
|
||
console.log(chalk.yellow(` 四類都沒有命中「${q}」。`));
|
||
console.log(chalk.gray(' • 想新增打外部服務的能力 → acr recipe push(建 recipe)'));
|
||
console.log(chalk.gray(' • 服務認證設定 → acr auth-recipe list 找對應服務'));
|
||
console.log(chalk.gray(' • 零件(WASM)只能走 GitHub PR 新增(稀有)'));
|
||
console.log('');
|
||
}
|
||
}
|