From 2aa26a5bdd37092271ca7cbce81661ea05c1aea6 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Mon, 29 Jun 2026 13:08:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20acr=20search=20=E8=B7=A8=E9=A1=9E?= =?UTF-8?q?=E7=B5=B1=E4=B8=80=E6=90=9C=E5=B0=8B=20+=20acr=20parts=20?= =?UTF-8?q?=E5=8E=BB=20hardcode=20recipe=EF=BC=88issue=20#13=20=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 新增(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) --- cli/src/commands/parts.ts | 94 +++++--------------- cli/src/commands/search.ts | 170 +++++++++++++++++++++++++++++++++++++ cli/src/index.ts | 7 ++ 3 files changed, 200 insertions(+), 71 deletions(-) create mode 100644 cli/src/commands/search.ts diff --git a/cli/src/commands/parts.ts b/cli/src/commands/parts.ts index 33a3536..3416ead 100644 --- a/cli/src/commands/parts.ts +++ b/cli/src/commands/parts.ts @@ -1,7 +1,17 @@ /** - * acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev) + * acr parts — 列出所有可用「零件(component)」(內建清單,不依賴 registry.arcrun.dev) * acr parts scaffold — 輸出 config 範本(可直接貼入 workflow.yaml) * acr parts publish — 提交零件至公眾 registry(Phase 5,封測後) + * + * ⚠️ 分類原則(2026-06-29,issue #13 / component-gatekeeping W3): + * - **零件(component)= 靜態清單**:WASM,只能走 GitHub PR + 人 merge 新增(mindset §4 人類閘門), + * 固定慢增 → 用 BUILTIN_COMPONENTS hardcode 反映真實,正確。 + * - **recipe / auth-recipe / workflow = 動態,存在 store**:任何人 `acr recipe push` 即新增 → + * **絕不可 hardcode 在這裡**(會「submitted = invisible」+ 誤導查錯表)。它們各有動態清單: + * recipe → `acr recipe list`(GET /recipes)|auth-recipe → `acr auth-recipe list`(GET /auth-recipes)|workflow → `acr list`(GET /webhooks/named) + * - **跨類找東西用 `acr search `**(fan-out 上述 4 個來源,不必先知道它是哪一類)。 + * 歷史教訓:本檔曾把 gmail_send/telegram_send/notion 等 5 個 recipe hardcode 進零件清單 → + * 誤導「telegram 是零件 / 走不同機制」。已移除(W3.1)。 */ import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -25,7 +35,7 @@ interface ComponentDef { credentials_required?: CredentialRequirement[]; } -const BUILTIN_COMPONENTS: ComponentDef[] = [ +export const BUILTIN_COMPONENTS: ComponentDef[] = [ // ── 控制類(Logic) ──────────────────────────────────────────────────────── { canonical_id: 'if_control', @@ -193,72 +203,9 @@ const BUILTIN_COMPONENTS: ComponentDef[] = [ body: key: "{{value}}"`, }, - { - canonical_id: 'gmail_send', - display_name: 'Gmail Send', - category: 'api', - description: '透過 recipe 寄送 Gmail(recipe gmail_send,auth: google service_account)。注意:Gmail 讀取尚無對應 recipe(待 seed 補 gmail_list)。', - config_example: -` # 寄信用內建 recipe gmail_send;body 帶 raw(base64url MIME) - mail_node: - component: gmail_send - method: POST - body: - raw: "{{mime_base64url}}"`, - }, - { - canonical_id: 'google_sheets_append', - display_name: 'Google Sheets Append', - category: 'api', - description: '透過 recipe 追加一列到 Sheets(recipe google_sheets_append;讀取用 google_sheets_read。auth: google service_account)', - config_example: -` # 追加用內建 recipe google_sheets_append(讀取改用 google_sheets_read) - sheet_node: - component: google_sheets_append - method: POST - _path: "/v4/spreadsheets/{{spreadsheet_id}}/values/Sheet1!A:B:append?valueInputOption=RAW" - body: - values: - - ["{{name}}", "{{email}}"]`, - }, - { - canonical_id: 'telegram_send', - display_name: 'Telegram Send', - category: 'api', - description: '透過 recipe 發送 Telegram 訊息(recipe telegram_send,auth: static_key,token 注入 URL path)', - config_example: -` # 內建 recipe telegram_send(token 由 auth 注入 URL,不寫在這) - tg_node: - component: telegram_send - chat_id: "123456789" - text: "{{message}}"`, - }, - { - canonical_id: 'line_notify_send', - display_name: 'LINE Notify', - category: 'api', - description: '透過 recipe 發送 LINE Notify 通知(recipe line_notify_send,auth: static_key Bearer)', - config_example: -` # 內建 recipe line_notify_send(POST notify,body form-urlencoded) - line_node: - component: line_notify_send - method: POST - body: - message: "{{notification}}"`, - }, - { - canonical_id: 'notion', - display_name: 'Notion', - category: 'api', - description: '透過 recipe 操作 Notion API(需先 acr recipe push)', - config_example: -` # 先上傳 recipe:acr recipe push notion.yaml - notion_node: - component: rec_xxxxxxxx # acr recipe push 後得到的 hash - database_id: "{{db_id}}" - properties: - Name: "{{title}}"`, - }, + // ⚠️ 此處曾 hardcode gmail_send / google_sheets_append / telegram_send / line_notify_send / notion + // 這 5 個是 **recipe(動態,存 store)不是零件(component)**,已於 W3.1 移除(issue #13 根治)。 + // 要找它們:`acr search `(跨類)或 `acr recipe list` / `acr auth-recipe list`(動態清單)。 ]; // ── 指令實作 ────────────────────────────────────────────────────────────────── @@ -277,7 +224,7 @@ export async function cmdParts(): Promise { grouped[comp.category].push(comp); } - console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件)\n`)); + console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件 / component,靜態 PR-only)\n`)); for (const cat of ['logic', 'data', 'ai', 'api']) { const comps = grouped[cat]; @@ -294,8 +241,13 @@ export async function cmdParts(): Promise { } console.log(chalk.gray(' 使用 acr parts scaffold 取得 config 範本')); - console.log(chalk.gray(' 第三方服務整合(Notion/Slack/GitHub 等):acr auth-recipe list')); - console.log(chalk.gray(' API 整合類若需打自訂服務,請用 acr recipe push 建立 recipe\n')); + console.log(''); + console.log(chalk.bold(' 零件之外(動態,存在 store,不在上面這份靜態清單):')); + console.log(chalk.gray(' • API recipe(打外部服務) acr recipe list')); + console.log(chalk.gray(' • 第三方服務認證(auth-recipe) acr auth-recipe list')); + console.log(chalk.gray(' • 已部署的 workflow acr list')); + console.log(chalk.cyan(' • 不確定某能力是哪一類? acr search <關鍵字> ← 跨類一次搜,免先選表')); + console.log(''); } export async function cmdPartsScaffold(componentId: string): Promise { diff --git a/cli/src/commands/search.ts b/cli/src/commands/search.ts new file mode 100644 index 0000000..28a8a03 --- /dev/null +++ b/cli/src/commands/search.ts @@ -0,0 +1,170 @@ +/** + * 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(''); + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 35c4bef..6229957 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -20,6 +20,7 @@ import { cmdValidate } from './commands/validate.js'; import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js'; import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete, cmdRecipeSearch, cmdRecipePull, cmdRecipeSubmitP } from './commands/recipe.js'; import { cmdList } from './commands/list.js'; +import { cmdSearch } from './commands/search.js'; import { cmdLogs } from './commands/logs.js'; import { cmdUpdate } from './commands/update.js'; import { cmdInstallHarness } from './commands/install-harness.js'; @@ -205,6 +206,12 @@ program .description('列出 CF KV 中所有已部署的 workflow') .action(() => cmdList()); +// acr search — 跨類統一搜尋(component / recipe / auth-recipe / workflow),免先選表 +program + .command('search ') + .description('跨類搜尋能力(零件/recipe/auth-recipe/workflow),一次掃全部、依類別回 counts+命中') + .action((term: string) => cmdSearch(term)); + // acr logs program .command('logs ')