/** * acr search — 跨類統一搜尋(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): 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): Promise { 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 { const q = (term ?? '').trim(); if (!q) { console.error(chalk.red(' 請提供搜尋關鍵字:acr search ')); process.exit(1); } const config = loadConfig(); const baseUrl = getCypherExecutorUrl(config); const headers: Record = { '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 => { 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(''); } }