Files
Arcrun/cli/src/commands/search.ts
T
uncle6me-web 2aa26a5bdd feat(cli): acr search 跨類統一搜尋 + acr parts 去 hardcode recipe(issue #13 根治)
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>
2026-06-29 13:08:11 +08:00

171 lines
7.2 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 search <term> — 跨類統一搜尋(component / recipe / auth-recipe / workflow
*
* 為什麼存在(issue #13leo 2026-06-29):
* 舊世界要找一個能力(如 "telegram")必須**先決定**「它是零件?recipe?還是 workflow?」才能查對的清單。
* 但查的人(人或 AI)在查到答案前**根本不知道**它屬哪一類 →「先知道分類」成了查詢的前置條件,倒因為果。
* leo:「連我都會查錯表。」所以這不是粗心,是搜尋設計缺陷。
* 本指令一次掃全部類別、依類別分組回 counts + 命中 → 查的人不必先選表,看一眼就知道 telegram 是 recipe。
*
* 薄殼原則(rule 07 §2):本指令只做 fan-out 呼叫既有清單來源 + 過濾 + 分組 + 印出(介面層的暴露/格式化)。
* - component:靜態(BUILTIN_COMPONENTSPR-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('');
}
}