Files
Arcrun/cli/src/commands/parts.ts
T
Claude 2707fca32b feat(arcrun): implement arcrun MVP — open-source AI workflow engine
Phase 1-5 complete per .agents/specs/u6u-core-mvp/:

**Phase 1 — Cherry-pick & cleanup**
- Create arcrun/ from cypher-executor, credentials, builtins, registry
- Remove 9 InkStone Service Bindings (KBDB, REGISTRY, CLINIC_*, AICEO, MINI_ME)
- Rewrite component-loader: 3-layer (builtin → WASM_BUCKET R2 → error)
- Remove autoPublishMissing.ts, proxy.ts (AICEO), execution-logger.ts (KBDB)
- Clean all KV namespace IDs and InkStone internal URLs from config files

**Phase 2 — contract.yaml completeness**
- Add credentials_required to gmail, google_sheets, telegram, line_notify
- Add config_example to all 21 components with annotated field descriptions

**Phase 3 — Credential injection**
- Add credential-injector.ts: AES-GCM decrypt from CREDENTIALS_KV
- Integrate into GraphExecutor before WASM execution
- Structured errors with repair instructions when credential missing

**Phase 4 — CLI (acr)**
- cli/package.json: arcrun package, bin: acr, deps: commander/js-yaml/chalk/ora
- 8 commands: init, creds push, push, run, validate, parts, list, logs
- Standard mode: writes directly to user's CF KV via CF REST API
- acr init: interactive setup with arcrun.dev API Key registration

**Phase 5 — Open source release prep**
- README.md: 5-minute quickstart, component table, workflow YAML syntax
- CONTRIBUTING.md: TinyGo dev env, component scaffolding, submission flow
- Security audit: no InkStone internal URLs/IDs in committed files
- .gitignore: exclude credentials.yaml, .wrangler, *.wasm

https://claude.ai/code/session_01BnCdSLVH8tUed9VrrPavgT
2026-04-16 04:06:25 +00:00

283 lines
11 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 parts — 列出所有可用零件(按類型分組,含統計與 author)
* acr parts scaffold <component> — 輸出 config 範本
* acr parts publish <component> — 提交零件至公眾 registry
*/
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import chalk from 'chalk';
import ora from 'ora';
import { loadConfig } from '../lib/config.js';
const REGISTRY_URL = 'https://registry.arcrun.dev';
interface ComponentInfo {
canonical_id: string;
display_name: string;
category: string;
description: string;
author?: string;
total_runs?: number;
success_rate?: number;
avg_duration_ms?: number;
visibility?: 'public' | 'author_only';
credentials_required?: Array<{ key: string; type: string; inject_as: string }>;
}
export async function cmdParts(): Promise<void> {
const spinner = ora('從 registry.arcrun.dev 取得零件清單').start();
let components: ComponentInfo[] = [];
try {
const res = await fetch(`${REGISTRY_URL}/components`);
if (res.ok) {
const data = await res.json() as { components: ComponentInfo[] };
components = data.components ?? [];
}
spinner.stop();
} catch {
spinner.stop();
console.log(chalk.yellow(' 無法連線 registry.arcrun.dev,顯示本地零件清單\n'));
}
if (components.length === 0) {
// fallback:顯示本地 registry 目錄中的零件
components = loadLocalComponents();
}
// 依 category 分組
const grouped: Record<string, ComponentInfo[]> = {};
for (const comp of components) {
const cat = comp.category ?? 'other';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(comp);
}
const categoryLabels: Record<string, string> = {
api: '整合類(Integration',
logic: '控制類(Control Flow',
data: '資料類(Data',
ai: 'AI 類',
other: '其他',
};
console.log(chalk.bold('\n arcrun 零件庫\n'));
for (const [cat, comps] of Object.entries(grouped)) {
console.log(chalk.bold.underline(` ${categoryLabels[cat] ?? cat}`));
for (const comp of comps) {
const isAuthorOnly = comp.visibility === 'author_only';
const tag = isAuthorOnly ? chalk.yellow(' [待審核]') : '';
let statsLine = '';
if (!isAuthorOnly && comp.total_runs !== undefined) {
const rate = ((comp.success_rate ?? 1) * 100).toFixed(1);
const runs = comp.total_runs.toLocaleString();
const ms = Math.round(comp.avg_duration_ms ?? 0);
statsLine = chalk.gray(` ★ ${rate}% 成功 | ${runs} 次執行 | 平均 ${ms}ms`);
}
const authorStr = comp.author ? chalk.gray(` by ${comp.author}`) : '';
const credStr = comp.credentials_required?.length
? chalk.yellow(` 🔑 需要 ${comp.credentials_required.map(c => c.key).join(', ')}`)
: '';
console.log(` • ${chalk.cyan(comp.canonical_id.padEnd(20))}${comp.display_name}${tag}${authorStr}${credStr}`);
if (statsLine) console.log(statsLine);
}
console.log('');
}
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
console.log(chalk.gray(' 使用 acr parts publish <component> 提交零件至公眾庫\n'));
}
export async function cmdPartsScaffold(componentId: string): Promise<void> {
// 優先從本地 registry 讀取 contract.yaml
const localContract = loadLocalContract(componentId);
if (!localContract) {
// 嘗試從 registry.arcrun.dev 取得
try {
const res = await fetch(`${REGISTRY_URL}/components/${componentId}/contract`);
if (!res.ok) {
console.error(chalk.red(`零件 "${componentId}" 不存在,執行 acr parts 查看可用清單`));
process.exit(1);
}
const data = await res.json() as { config_example?: string; credentials_required?: unknown[] };
printScaffold(componentId, data.config_example, data.credentials_required as ComponentInfo['credentials_required']);
} catch {
console.error(chalk.red(`無法取得 "${componentId}" 的 contract,請確認零件名稱`));
process.exit(1);
}
return;
}
const configExample = extractYamlField(localContract, 'config_example');
const credsRequired = extractCredentialsRequired(localContract);
printScaffold(componentId, configExample, credsRequired);
}
function printScaffold(
componentId: string,
configExample?: string,
credsRequired?: ComponentInfo['credentials_required'],
): void {
console.log(chalk.bold(`\n ${componentId} — workflow.yaml config 範本\n`));
if (configExample) {
console.log(chalk.cyan(' # 貼入 workflow.yaml 的 config: 區塊'));
console.log(configExample.split('\n').map(l => ` ${l}`).join('\n'));
} else {
console.log(chalk.yellow(' (無 config_example,請參考文檔)'));
}
if (credsRequired && credsRequired.length > 0) {
console.log(chalk.bold('\n credentials.yaml 範本(加入後執行 acr creds push\n'));
for (const cred of credsRequired) {
console.log(chalk.cyan(` # ${cred.type}${cred.inject_as} 欄位自動注入)`));
console.log(` ${cred.key}: "your-${cred.type}-token"\n`);
}
}
}
export async function cmdPartsPublish(componentDir: string, options: { status?: string }): Promise<void> {
if (options.status) {
// 查詢審核進度
try {
const res = await fetch(`${REGISTRY_URL}/submit/status/${options.status}`);
const data = await res.json() as { status: string; visibility?: string; failed_step?: string; reason?: string; approved_at?: string };
console.log(chalk.bold(`\n 提交狀態:${options.status}\n`));
console.log(` 狀態:${data.status}`);
if (data.visibility) console.log(` Visibility${data.visibility}`);
if (data.failed_step) console.log(chalk.red(` 失敗步驟:${data.failed_step}`));
if (data.reason) console.log(chalk.red(` 原因:${data.reason}`));
if (data.approved_at) console.log(chalk.green(` 核准時間:${data.approved_at}`));
} catch (e) {
console.error(chalk.red(`查詢失敗:${e instanceof Error ? e.message : e}`));
}
return;
}
const config = loadConfig();
if (!config.api_key) {
console.error(chalk.red('缺少 API Key,請執行 acr init'));
process.exit(1);
}
// 讀取零件目錄
const contractPath = join(componentDir, 'component.contract.yaml');
const mainGoPath = join(componentDir, 'main.go');
const wasmName = componentDir.split('/').pop() ?? componentDir;
const wasmPath = join(componentDir, `${wasmName}.wasm`);
if (!existsSync(contractPath)) {
console.error(chalk.red(`找不到 ${contractPath}`));
process.exit(1);
}
if (!existsSync(wasmPath)) {
console.error(chalk.red(`找不到 ${wasmPath}(請先編譯:tinygo build -o ${wasmName}.wasm -target wasi .`));
process.exit(1);
}
const spinner = ora('提交零件至 registry.arcrun.dev').start();
const formData = new FormData();
formData.append('contract', new Blob([readFileSync(contractPath)], { type: 'application/yaml' }), 'component.contract.yaml');
if (existsSync(mainGoPath)) {
formData.append('source', new Blob([readFileSync(mainGoPath)], { type: 'text/plain' }), 'main.go');
}
formData.append('wasm', new Blob([readFileSync(wasmPath)], { type: 'application/wasm' }), `${wasmName}.wasm`);
try {
const res = await fetch(`${REGISTRY_URL}/submit`, {
method: 'POST',
headers: { 'X-Arcrun-API-Key': config.api_key },
body: formData,
});
if (!res.ok) {
const err = await res.text();
spinner.fail(chalk.red(`提交失敗(${res.status}):${err.slice(0, 200)}`));
process.exit(1);
}
const data = await res.json() as { submission_id: string; status: string; visibility?: string };
spinner.succeed(chalk.green(`✓ 提交成功`));
console.log(`\n Submission ID${chalk.cyan(data.submission_id)}`);
console.log(` 狀態:${data.status}`);
if (data.visibility) console.log(` Visibility${data.visibility}`);
console.log(chalk.gray(`\n 查詢進度:acr parts publish --status ${data.submission_id}\n`));
} catch (e) {
spinner.fail(chalk.red(`提交失敗:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
function loadLocalComponents(): ComponentInfo[] {
// 嘗試從相對路徑尋找 registry/components
const dirs = [
join(process.cwd(), 'registry/components'),
join(process.cwd(), '../registry/components'),
];
for (const dir of dirs) {
if (existsSync(dir)) {
const components: ComponentInfo[] = [];
const { readdirSync } = require('node:fs');
for (const name of readdirSync(dir)) {
const contractPath = join(dir, name, 'component.contract.yaml');
if (existsSync(contractPath)) {
const raw = readFileSync(contractPath, 'utf8');
const canonical_id = extractYamlScalar(raw, 'canonical_id') ?? name;
const display_name = extractYamlScalar(raw, 'display_name') ?? name;
const category = extractYamlScalar(raw, 'category') ?? 'other';
const description = extractYamlScalar(raw, 'description') ?? '';
components.push({ canonical_id, display_name, category, description });
}
}
return components;
}
}
return [];
}
function loadLocalContract(componentId: string): string | null {
const dirs = [
join(process.cwd(), `registry/components/${componentId}/component.contract.yaml`),
join(process.cwd(), `../registry/components/${componentId}/component.contract.yaml`),
];
for (const p of dirs) {
if (existsSync(p)) return readFileSync(p, 'utf8');
}
return null;
}
function extractYamlScalar(yaml: string, key: string): string | undefined {
const m = yaml.match(new RegExp(`^${key}:\\s*["']?([^"'\\n]+)["']?`, 'm'));
return m?.[1]?.trim();
}
function extractYamlField(yaml: string, field: string): string | undefined {
const m = yaml.match(new RegExp(`^${field}:\\s*\\|\\n((?:[ \\t]+[^\\n]*\\n?)*)`, 'm'));
return m?.[1];
}
function extractCredentialsRequired(yaml: string): ComponentInfo['credentials_required'] {
const section = yaml.match(/credentials_required:\s*([\s\S]*?)(?=\n\w|\n#|$)/);
if (!section) return [];
const items: ComponentInfo['credentials_required'] = [];
const blocks = section[1].split(/\n - /).slice(1);
for (const block of blocks) {
const key = block.match(/key:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
const type = block.match(/type:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
const inject_as = block.match(/inject_as:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
if (key && type && inject_as) {
items!.push({ key, type, inject_as });
}
}
return items;
}