From c23c0544433a999121fe077b10700a3bed43f3fc Mon Sep 17 00:00:00 2001 From: "huzijie.sea" Date: Mon, 16 Mar 2026 07:50:03 +0800 Subject: [PATCH] feat: add fuzzy matching for edit tool and git mirror caching for repo operations; implement headless mode and runtime AI config --- packages/cli/package.json | 2 +- packages/cli/scripts/build-cli.js | 33 +++ packages/cli/src/agent/Agent.ts | 24 +- packages/cli/src/agent/types.ts | 6 + packages/cli/src/blade.tsx | 17 +- packages/cli/src/cli/config.ts | 28 ++ packages/cli/src/cli/types.ts | 5 + packages/cli/src/commands/headless.ts | 243 ++++++++++++++++++ packages/cli/src/commands/stats.ts | 121 +++++++++ packages/cli/src/plugins/PluginInstaller.ts | 6 +- packages/cli/src/skills/SkillInstaller.ts | 25 +- packages/cli/src/tools/builtin/file/edit.ts | 10 +- .../src/tools/builtin/file/editCorrector.ts | 159 ++++++++---- packages/cli/src/utils/git.ts | 58 +++++ packages/cli/src/utils/packageInfo.ts | 27 +- 15 files changed, 692 insertions(+), 72 deletions(-) create mode 100644 packages/cli/scripts/build-cli.js create mode 100644 packages/cli/src/commands/headless.ts create mode 100644 packages/cli/src/commands/stats.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 02298f60..dac4bbd5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,7 +15,7 @@ "scripts": { "dev": "bun --watch src/blade.tsx", "dev:serve": "bun --watch src/blade.tsx serve --port 4097", - "build": "rm -rf dist && bun run scripts/build.ts", + "build": "rm -rf dist && node scripts/build-cli.js", "start": "bun run dist/blade.js", "test": "node scripts/test.js", "test:all": "vitest run --config vitest.config.ts", diff --git a/packages/cli/scripts/build-cli.js b/packages/cli/scripts/build-cli.js new file mode 100644 index 00000000..e2aff8bd --- /dev/null +++ b/packages/cli/scripts/build-cli.js @@ -0,0 +1,33 @@ +import { execSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const rootDir = process.cwd(); + +function runTsc() { + console.log('Building CLI with TypeScript (npx tsc)...'); + execSync('npx tsc -p tsconfig.json', { + stdio: 'inherit', + }); +} + +function createEntry() { + const distDir = path.join(rootDir, 'dist'); + mkdirSync(distDir, { recursive: true }); + + const entry = `#!/usr/bin/env node +import('./src/blade.js') + .then((m) => (typeof m.main === 'function' ? m.main() : undefined)) + .catch((err) => { + console.error(err); + process.exit(1); + }); +`; + + const entryPath = path.join(distDir, 'blade.js'); + writeFileSync(entryPath, entry, { mode: 0o755 }); + console.log('✓ CLI entry created at dist/blade.js'); +} + +runTsc(); +createEntry(); diff --git a/packages/cli/src/agent/Agent.ts b/packages/cli/src/agent/Agent.ts index 65b650f7..611045f9 100644 --- a/packages/cli/src/agent/Agent.ts +++ b/packages/cli/src/agent/Agent.ts @@ -149,7 +149,23 @@ export class Agent { } private resolveModelConfig(requestedModelId?: string): ModelConfig { - const modelId = requestedModelId && requestedModelId !== 'inherit' ? requestedModelId : undefined; + // 🆕 1. 优先检查运行时选项中是否直接提供了模型配置 + const { provider, model, apiKey, baseUrl } = this.runtimeOptions; + if (provider && model && apiKey) { + return { + id: 'runtime-config', + name: model, + provider, + model, + apiKey, + baseUrl: baseUrl ?? '', + } as ModelConfig; + } + + const modelId = + requestedModelId && requestedModelId !== 'inherit' + ? requestedModelId + : undefined; const modelConfig = modelId ? getModelById(modelId) : getCurrentModel(); if (!modelConfig) { throw new Error(`❌ 模型配置未找到: ${modelId ?? 'current'}`); @@ -208,8 +224,12 @@ export class Agent { await ensureStoreInitialized(); // 1. 检查是否有可用的模型配置 + // 如果运行时直接提供了模型配置,则无需检查 Store 中的模型 const models = getAllModels(); - if (models.length === 0) { + const hasRuntimeConfig = + options.provider && options.model && options.apiKey; + + if (!hasRuntimeConfig && models.length === 0) { throw new Error( '❌ 没有可用的模型配置\n\n' + '请先使用以下命令添加模型:\n' + diff --git a/packages/cli/src/agent/types.ts b/packages/cli/src/agent/types.ts index d5d950cf..38f287ae 100644 --- a/packages/cli/src/agent/types.ts +++ b/packages/cli/src/agent/types.ts @@ -60,6 +60,12 @@ export interface AgentOptions { toolWhitelist?: string[]; // 工具白名单(仅允许指定工具) modelId?: string; + // 临时模型配置(用于 CLI 参数直接传入,绕过 Store) + provider?: string; + model?: string; + apiKey?: string; + baseUrl?: string; + // MCP 配置 mcpConfig?: string[]; // CLI 参数:MCP 配置文件路径或 JSON 字符串数组 strictMcpConfig?: boolean; // CLI 参数:严格模式,仅使用 --mcp-config 指定的配置 diff --git a/packages/cli/src/blade.tsx b/packages/cli/src/blade.tsx index c9f81228..ed71958f 100644 --- a/packages/cli/src/blade.tsx +++ b/packages/cli/src/blade.tsx @@ -19,13 +19,14 @@ import { installCommands } from './commands/install.js'; import { mcpCommands } from './commands/mcp.js'; import { handlePrintMode } from './commands/print.js'; import { serveCommand } from './commands/serve.js'; +import { statsCommand } from './commands/stats.js'; import { updateCommands } from './commands/update.js'; import { webCommand } from './commands/web.js'; import { Logger } from './logging/Logger.js'; import { initializeGracefulShutdown } from './services/GracefulShutdown.js'; import { checkVersionOnStartup } from './services/VersionChecker.js'; import type { AppProps } from './ui/App.js'; -import { AppWrapper as BladeApp } from './ui/App.js'; +import { runHeadlessChat } from './commands/headless.js'; // ⚠️ 关键:在创建任何 logger 之前,先解析 --debug 参数并设置全局配置 // 这样可以确保所有 logger(包括 middleware、commands 中的)都能正确输出到终端 @@ -115,6 +116,7 @@ export async function main() { .command(installCommands) .command(webCommand) .command(serveCommand) + .command(statsCommand) // 自动生成补全(隐藏,避免干扰普通用户) .completion('completion', false) @@ -153,12 +155,20 @@ export async function main() { // 不定义 positional,避免在 --help 中显示 Positionals 部分 }, async (argv) => { - // 启动 UI 模式 // 从 argv._ 中获取额外的参数作为 initialMessage const nonOptionArgs = (argv._ as string[]).slice(1); // 跳过命令名 const initialMessage = nonOptionArgs.length > 0 ? nonOptionArgs.join(' ') : undefined; + // Headless 模式:不渲染 Ink UI,直接通过 Agent 输出结果 + if (argv.headless) { + await runHeadlessChat({ + ...(argv as Record), + initialMessage, + }); + return; + } + // 启动 React UI - 传递所有选项 const appProps = { ...argv, @@ -175,6 +185,9 @@ export async function main() { delete appProps.$0; delete appProps.message; + // 延迟加载 UI 组件,避免在纯 CLI 场景下加载所有 UI 依赖 + const { AppWrapper: BladeApp } = await import('./ui/App.js'); + render(React.createElement(BladeApp, appProps), { patchConsole: true, exitOnCtrlC: false, // 由 useCtrlCHandler 处理(支持智能双击退出) diff --git a/packages/cli/src/cli/config.ts b/packages/cli/src/cli/config.ts index 39430629..dbbf3338 100644 --- a/packages/cli/src/cli/config.ts +++ b/packages/cli/src/cli/config.ts @@ -24,6 +24,12 @@ export const globalOptions = { describe: 'Print response and exit (useful for pipes)', group: 'Output Options:', }, + headless: { + type: 'boolean', + describe: + 'Run in headless (non-TUI) mode, printing responses directly to stdout', + group: 'Output Options:', + }, 'output-format': { alias: ['outputFormat'], type: 'string', @@ -170,6 +176,28 @@ export const globalOptions = { describe: 'Use a specific session ID for the conversation', group: 'Session Options:', }, + provider: { + type: 'string', + describe: 'AI provider to use (e.g. openai, anthropic)', + group: 'AI Options:', + }, + model: { + type: 'string', + describe: 'AI model to use (e.g. gpt-4o, claude-3-5-sonnet)', + group: 'AI Options:', + }, + 'api-key': { + alias: ['apiKey'], + type: 'string', + describe: 'API key for the AI provider', + group: 'AI Options:', + }, + 'base-url': { + alias: ['baseUrl'], + type: 'string', + describe: 'Base URL for the AI provider', + group: 'AI Options:', + }, // TODO: 未实现 - 需要解析 JSON 并配置自定义 Agent agents: { type: 'string', diff --git a/packages/cli/src/cli/types.ts b/packages/cli/src/cli/types.ts index 2d359bba..b043806c 100644 --- a/packages/cli/src/cli/types.ts +++ b/packages/cli/src/cli/types.ts @@ -5,6 +5,7 @@ export interface GlobalOptions { debug?: string; print?: boolean; + headless?: boolean; outputFormat?: 'text' | 'json' | 'stream-json'; includePartialMessages?: boolean; inputFormat?: 'text' | 'stream-json'; @@ -29,6 +30,10 @@ export interface GlobalOptions { settingSources?: string; maxTurns?: number; pluginDir?: string[]; + provider?: string; + model?: string; + apiKey?: string; + baseUrl?: string; } export interface DoctorOptions extends GlobalOptions {} diff --git a/packages/cli/src/commands/headless.ts b/packages/cli/src/commands/headless.ts new file mode 100644 index 00000000..a9f6cefa --- /dev/null +++ b/packages/cli/src/commands/headless.ts @@ -0,0 +1,243 @@ +import { Agent } from '../agent/Agent.js'; +import type { ChatContext, LoopOptions } from '../agent/types.js'; +import { PermissionMode } from '../config/index.js'; +import type { GlobalOptions } from '../cli/types.js'; + +interface HeadlessOptions extends GlobalOptions { + /** 从默认命令解析出的初始消息 */ + initialMessage?: string; +} + +function getToolName(toolCall: unknown): string | undefined { + const anyCall = toolCall as any; + if (anyCall?.function && typeof anyCall.function.name === 'string') { + return anyCall.function.name as string; + } + if (typeof anyCall?.name === 'string') { + return anyCall.name as string; + } + return undefined; +} + +/** + * 在默认命令中启用 headless 模式时调用 + * - 不使用 Ink/React 渲染 + * - 所有对话、工具调用和代码修改建议通过 console.log 输出 + * - 自动确认所有工具调用(包括写操作) + */ +export async function runHeadlessChat(options: HeadlessOptions): Promise { + const { + initialMessage, + outputFormat = 'text', + includePartialMessages, + inputFormat = 'text', + maxTurns, + systemPrompt, + appendSystemPrompt, + mcpConfig, + strictMcpConfig, + permissionMode, + yolo, + sessionId, + provider, + model, + apiKey, + baseUrl, + } = options; + + // 1. 解析输入:优先使用 initialMessage,其次从 stdin 读取 + let input = initialMessage ?? ''; + + if (!input || input.trim() === '') { + if (!process.stdin.isTTY && inputFormat === 'text') { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + input = Buffer.concat(chunks).toString('utf-8').trim(); + } + + if (!input) { + input = 'Hello'; + } + } + + // 2. 计算权限模式:headless 下默认使用 YOLO,确保无需人工确认 + let effectivePermissionMode: PermissionMode; + if (permissionMode === 'autoEdit') { + effectivePermissionMode = PermissionMode.AUTO_EDIT; + } else if (permissionMode === 'plan') { + effectivePermissionMode = PermissionMode.PLAN; + } else if (permissionMode === 'yolo' || yolo) { + effectivePermissionMode = PermissionMode.YOLO; + } else { + // 在 headless 模式下,DEFAULT 也提升为 YOLO,避免交互确认 + effectivePermissionMode = PermissionMode.YOLO; + } + + try { + // 3. 创建 Agent - 使用运行时选项覆盖配置 + const agent = await Agent.create({ + systemPrompt, + appendSystemPrompt, + maxTurns, + permissionMode: effectivePermissionMode, + mcpConfig, + strictMcpConfig, + provider, + model, + apiKey, + baseUrl, + }); + + const chatContext: ChatContext = { + messages: [], + userId: 'cli-headless', + sessionId: + sessionId || + `headless-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}`, + workspaceRoot: process.cwd(), + permissionMode: effectivePermissionMode, + }; + + const useStreaming = outputFormat === 'stream-json' || !!includePartialMessages; + + // 4. 事件回调:统一输出为结构化 JSON,便于脚本消费 + const loopOptions: LoopOptions = { + maxTurns, + stream: useStreaming, + + onTurnStart: ({ turn, maxTurns: configuredMaxTurns }) => { + console.log( + JSON.stringify( + { + type: 'turn_start', + turn, + maxTurns: configuredMaxTurns, + }, + null, + 2 + ) + ); + }, + + onContentDelta: (delta) => { + if (outputFormat === 'stream-json') { + console.log(JSON.stringify({ type: 'content_delta', delta })); + } else if (includePartialMessages) { + process.stdout.write(delta); + } + }, + + onStreamEnd: () => { + if (outputFormat === 'stream-json') { + console.log(JSON.stringify({ type: 'stream_end' })); + } + }, + + onContent: (content) => { + if (outputFormat === 'json') { + console.log( + JSON.stringify( + { + type: 'final', + message: content, + }, + null, + 2 + ) + ); + } else if (outputFormat === 'stream-json') { + console.log(JSON.stringify({ type: 'final', message: content })); + } else { + console.log(content); + } + }, + + onToolStart: (toolCall, toolKind) => { + console.log( + JSON.stringify( + { + type: 'tool_start', + toolName: getToolName(toolCall), + toolKind, + toolCallId: toolCall.id, + }, + null, + 2 + ) + ); + }, + + // 自动批准所有工具调用(包括写入、执行类),避免交互 + onToolApprove: async (toolCall) => { + console.log( + JSON.stringify( + { + type: 'tool_approve', + toolName: getToolName(toolCall), + toolCallId: toolCall.id, + reason: 'auto-approved in headless mode', + }, + null, + 2 + ) + ); + return true; + }, + + onToolResult: async (toolCall, result) => { + console.log( + JSON.stringify( + { + type: 'tool_result', + toolName: getToolName(toolCall), + toolCallId: toolCall.id, + result, + }, + null, + 2 + ) + ); + return result; + }, + + onTokenUsage: (usage) => { + console.log(JSON.stringify({ type: 'token_usage', ...usage })); + }, + + onTurnLimitReached: async ({ turnsCount }) => { + console.log( + JSON.stringify( + { + type: 'turn_limit_reached', + turnsCount, + action: 'auto-continue', + }, + null, + 2 + ) + ); + return { continue: true, reason: 'auto-continue in headless mode' }; + }, + }; + + await agent.chat(input, chatContext, loopOptions); + process.exit(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + JSON.stringify( + { + type: 'error', + message, + }, + null, + 2 + ) + ); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts new file mode 100644 index 00000000..07ca6090 --- /dev/null +++ b/packages/cli/src/commands/stats.ts @@ -0,0 +1,121 @@ +/** + * stats 命令 - 递归统计当前项目代码行数(按文件扩展名分组) + */ + +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { Dirent } from 'node:fs'; +import type { CommandModule } from 'yargs'; +import { FileFilter } from '../utils/filePatterns.js'; + +interface ExtensionStats { + files: number; + lines: number; +} + +async function collectFiles( + rootDir: string, + currentDir: string, + filter: FileFilter, + results: string[] +): Promise { + const entries: Dirent[] = await readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + const relPath = path.relative(rootDir, fullPath).replace(/\\/g, '/'); + + if (entry.isDirectory()) { + // 使用 FileFilter 统一处理忽略目录(包含默认排除和 .gitignore 规则) + if (filter.shouldIgnoreDirectory(relPath) || filter.shouldIgnoreDirectory(entry.name)) { + continue; + } + + await collectFiles(rootDir, fullPath, filter, results); + } else if (entry.isFile()) { + if (filter.shouldIgnore(relPath)) { + continue; + } + + results.push(fullPath); + } + } +} + +export const statsCommand: CommandModule = { + command: 'stats', + describe: 'Show project code statistics by file extension', + builder: (yargs) => + yargs.example([ + ['$0 stats', 'Show code statistics for the current project'], + ]), + handler: async () => { + const cwd = process.cwd(); + + console.log('📊 Project code statistics'); + console.log(`📁 Root: ${cwd}`); + console.log('🔍 Scanning files...'); + + try { + const filter = await FileFilter.create({ + cwd, + useGitignore: true, + gitignoreScanMode: 'recursive', + }); + + const files: string[] = []; + await collectFiles(cwd, cwd, filter, files); + + const stats = new Map(); + let totalFiles = 0; + let totalLines = 0; + + for (const filePath of files) { + const ext = path.extname(filePath).toLowerCase() || '[no-ext]'; + + let content: string; + try { + content = await readFile(filePath, 'utf8'); + } catch { + // 无法读取的文件直接跳过 + continue; + } + + const lineCount = content === '' ? 0 : content.split(/\r\n|\n|\r/).length; + + totalFiles += 1; + totalLines += lineCount; + + const stat = stats.get(ext) ?? { files: 0, lines: 0 }; + stat.files += 1; + stat.lines += lineCount; + stats.set(ext, stat); + } + + // 按行数从多到少排序 + const sortedEntries = Array.from(stats.entries()).sort( + (a, b) => b[1].lines - a[1].lines + ); + + console.log('\n📌 Lines by extension:'); + for (const [ext, stat] of sortedEntries) { + const label = ext === '[no-ext]' ? '(no extension)' : ext; + console.log( + ` ${label.padEnd(14)} ${stat.lines.toString().padStart(8)} lines in ${stat.files.toString().padStart(4)} files` + ); + } + + console.log('\n📦 Summary:'); + console.log(` Total files: ${totalFiles}`); + console.log(` Total lines: ${totalLines}`); + } catch (error) { + console.error( + `❌ Failed to calculate stats: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + process.exit(1); + } + }, +}; + diff --git a/packages/cli/src/plugins/PluginInstaller.ts b/packages/cli/src/plugins/PluginInstaller.ts index 03909900..fa558337 100644 --- a/packages/cli/src/plugins/PluginInstaller.ts +++ b/packages/cli/src/plugins/PluginInstaller.ts @@ -12,6 +12,7 @@ import * as path from 'node:path'; import { logger } from '../logging/Logger.js'; import { isValidPluginDir, parsePluginManifest } from './PluginManifest.js'; import type { PluginManifest } from './types.js'; +import { cloneRepository } from '../utils/git.js'; /** * Installation result @@ -98,10 +99,7 @@ export class PluginInstaller { // Clone the repository logger.info(`Cloning ${gitUrl} to ${pluginPath}...`); try { - execSync(`git clone --depth 1 "${gitUrl}" "${pluginPath}"`, { - stdio: 'pipe', - timeout: 60000, // 60 second timeout - }); + await cloneRepository(gitUrl, pluginPath, { depth: 1 }); } catch (error) { return { success: false, diff --git a/packages/cli/src/skills/SkillInstaller.ts b/packages/cli/src/skills/SkillInstaller.ts index 91132038..deb858ad 100644 --- a/packages/cli/src/skills/SkillInstaller.ts +++ b/packages/cli/src/skills/SkillInstaller.ts @@ -11,6 +11,7 @@ import { homedir } from 'node:os'; import * as path from 'node:path'; import { promisify } from 'node:util'; import { createLogger, LogCategory } from '../logging/Logger.js'; +import { cloneRepository } from '../utils/git.js'; const execAsync = promisify(exec); const logger = createLogger(LogCategory.GENERAL); @@ -80,12 +81,11 @@ export class SkillInstaller { // 确保目录存在 await fs.mkdir(this.skillsDir, { recursive: true, mode: 0o755 }); - // 使用 git clone --depth 1 --filter 克隆指定目录 - // 方法:克隆整个仓库(浅克隆),然后只复制需要的目录 - await execAsync( - `git clone --depth 1 --branch ${branch} --single-branch ${url} "${tempDir}"`, - { timeout: 30000 } - ); + // 使用克隆镜像缓存工具 + await cloneRepository(url, tempDir, { + branch, + depth: 1, + }); // 复制指定的 skill 目录 const sourceDir = path.join(tempDir, 'skills', skillName); @@ -164,10 +164,7 @@ export class SkillInstaller { await fs.mkdir(this.skillsDir, { recursive: true, mode: 0o755 }); - await execAsync( - `git clone --depth 1 "${repoUrl}" "${tempDir}"`, - { timeout: 60000 } - ); + await cloneRepository(repoUrl, tempDir, { depth: 1 }); const skillMdPath = path.join(tempDir, 'SKILL.md'); try { @@ -282,10 +279,10 @@ export class SkillInstaller { // 克隆整个仓库 logger.info('Cloning official skills repository...'); - await execAsync( - `git clone --depth 1 --branch ${branch} --single-branch ${url} "${tempDir}"`, - { timeout: 60000 } - ); + await cloneRepository(url, tempDir, { + branch, + depth: 1, + }); // 获取所有 skills const skillsSourceDir = path.join(tempDir, 'skills'); diff --git a/packages/cli/src/tools/builtin/file/edit.ts b/packages/cli/src/tools/builtin/file/edit.ts index 66e720a2..8cb9a8dd 100644 --- a/packages/cli/src/tools/builtin/file/edit.ts +++ b/packages/cli/src/tools/builtin/file/edit.ts @@ -15,6 +15,7 @@ import { ToolSchemas } from '../../validation/zodSchemas.js'; import { generateDiffSnippetWithMatch } from './diffUtils.js'; import { flexibleMatch, + fuzzyMatch, type MatchResult, MatchStrategy, unescapeString, @@ -466,12 +467,19 @@ function smartMatch(content: string, searchString: string): MatchResult { return { matched: unescaped, strategy: MatchStrategy.UNESCAPE }; } - // 策略 4: 弹性缩进匹配 + // 策略 4: 弹性缩进与空白匹配 const flexible = flexibleMatch(content, searchString); if (flexible) { return { matched: flexible, strategy: MatchStrategy.FLEXIBLE }; } + // 策略 5: 模糊匹配 (Patch 容错算法) + // 仅在多行场景下且唯一高置信度时自动修复 + const fuzzy = fuzzyMatch(content, searchString); + if (fuzzy) { + return { matched: fuzzy, strategy: MatchStrategy.FUZZY }; + } + // 所有策略都失败 return { matched: null, strategy: MatchStrategy.FAILED }; } diff --git a/packages/cli/src/tools/builtin/file/editCorrector.ts b/packages/cli/src/tools/builtin/file/editCorrector.ts index 051ea818..5c4f8a36 100644 --- a/packages/cli/src/tools/builtin/file/editCorrector.ts +++ b/packages/cli/src/tools/builtin/file/editCorrector.ts @@ -15,6 +15,7 @@ export enum MatchStrategy { NORMALIZE_QUOTES = 'normalize_quotes', // 引号标准化匹配 UNESCAPE = 'unescape', // 反转义匹配 FLEXIBLE = 'flexible', // 弹性缩进匹配 + FUZZY = 'fuzzy', // 模糊匹配(允许微小差异) FAILED = 'failed', // 所有策略都失败 } @@ -26,6 +27,19 @@ export interface MatchResult { strategy: MatchStrategy; // 使用的匹配策略 } +/** + * 字符串标准化(用于比较) + */ +function normalizeForComparison(text: string): string { + return text + .trim() + .replace(/\s+/g, ' ') + // 统一双引号 + .replace(/[\u201c\u201d"]/g, '"') + // 统一单引号 + .replace(/[\u2018\u2019']/g, "'"); +} + /** * 反转义字符串 * 修复 LLM 过度转义的问题 @@ -74,70 +88,121 @@ export function unescapeString(input: string): string { /** * 弹性缩进匹配 - * 忽略缩进差异,在文件内容中查找匹配的字符串 - * - * @param content 文件内容 - * @param searchString 要搜索的字符串 - * @returns 匹配到的实际字符串(保持原文件缩进),如果未找到则返回 null - * - * @example - * const content = ' function foo() {\n return 1;\n }'; - * const search = ' function foo() {\n return 1;\n }'; - * flexibleMatch(content, search) // → ' function foo() {\n return 1;\n }' + * 忽略缩进差异,并在标准化行内容后进行匹配 */ -export function flexibleMatch(content: string, searchString: string): string | null { +export function flexibleMatch( + content: string, + searchString: string +): string | null { const searchLines = searchString.split('\n'); - // 如果只有一行,无法使用弹性匹配 + // 如果只有一行,尝试标准化后匹配 if (searchLines.length === 1) { + const searchNorm = normalizeForComparison(searchString); + const contentLines = content.split('\n'); + for (let i = 0; i < contentLines.length; i++) { + if (normalizeForComparison(contentLines[i]) === searchNorm) { + return contentLines[i]; + } + } return null; } - // 1. 提取搜索字符串的第一行缩进 - const firstLine = searchLines[0]; - const indentMatch = firstLine.match(/^(\s+)/); - - if (!indentMatch) { - return null; // 第一行没有缩进,无法使用弹性匹配 - } + // 多行匹配逻辑 + const searchLinesNorm = searchLines.map(normalizeForComparison); + const contentLines = content.split('\n'); - const searchIndent = indentMatch[1]; + for (let i = 0; i <= contentLines.length - searchLines.length; i++) { + const snippet = contentLines.slice(i, i + searchLines.length); + const snippetNorm = snippet.map(normalizeForComparison); + + // 逐行比较标准化后的内容 + let allMatch = true; + for (let j = 0; j < searchLines.length; j++) { + if (snippetNorm[j] !== searchLinesNorm[j]) { + allMatch = false; + break; + } + } - // 2. 去除搜索字符串的缩进 - const deindentedSearchLines = searchLines.map((line) => { - if (line.startsWith(searchIndent)) { - return line.slice(searchIndent.length); + if (allMatch) { + return snippet.join('\n'); } - return line; - }); - const deindentedSearch = deindentedSearchLines.join('\n'); + } + + return null; +} - // 3. 在文件内容中搜索 +/** + * 模糊匹配 (Patch 容错算法) + * 允许少量行由于 LLM 生成时的格式微差(如缺失分号、微小拼写差异)导致的失配 + */ +export function fuzzyMatch( + content: string, + searchString: string, + threshold = 0.95 +): string | null { + const searchLines = searchString.split('\n'); const contentLines = content.split('\n'); - // 尝试在每个可能的位置匹配 - for (let i = 0; i <= contentLines.length - searchLines.length; i++) { - const lineIndentMatch = contentLines[i].match(/^(\s+)/); - const fileIndent = lineIndentMatch ? lineIndentMatch[1] : ''; + // 仅在多行场景下启用窗口模糊匹配 + if (searchLines.length < 2) return null; - // 提取从当前行开始的内容片段(与搜索字符串行数相同) - const snippet = contentLines.slice(i, i + searchLines.length); + let bestMatch: string | null = null; + let highestSim = 0; + let matchCount = 0; - // 去除文件片段的缩进 - const deindentedSnippet = snippet.map((line) => { - if (line.startsWith(fileIndent)) { - return line.slice(fileIndent.length); + for (let i = 0; i <= contentLines.length - searchLines.length; i++) { + const snippet = contentLines.slice(i, i + searchLines.length); + + // 计算多行块的综合相似度 + const similarity = calculateBlockSimilarity(searchLines, snippet); + + if (similarity >= threshold) { + if (similarity > highestSim) { + highestSim = similarity; + bestMatch = snippet.join('\n'); } - return line; - }); - const deindentedContent = deindentedSnippet.join('\n'); - - // 如果去除缩进后完全匹配 - if (deindentedContent === deindentedSearch) { - // 返回原文件中的实际字符串(保持原始缩进) - return snippet.join('\n'); + matchCount++; } } - return null; + // 🔴 关键安全控制:只有找到唯一且高置信度的模糊匹配时才自动纠错 + return matchCount === 1 && highestSim > 0.98 ? bestMatch : null; +} + +/** + * 计算两个代码块的行平均相似度 + */ +function calculateBlockSimilarity(searchLines: string[], snippet: string[]): number { + let totalSim = 0; + for (let i = 0; i < searchLines.length; i++) { + totalSim += calculateLineSimilarity( + normalizeForComparison(searchLines[i]), + normalizeForComparison(snippet[i]) + ); + } + return totalSim / searchLines.length; +} + +/** + * 极简相似度算法 (Jaro-Winkler 风格) + */ +function calculateLineSimilarity(s1: string, s2: string): number { + if (s1 === s2) return 1.0; + if (!s1 || !s2) return 0; + + const longer = s1.length > s2.length ? s1 : s2; + const shorter = s1.length > s2.length ? s2 : s1; + + if (longer.length === 0) return 1.0; + + // 简单的前缀匹配 + 长度比例 + let commonPrefix = 0; + for (let i = 0; i < shorter.length; i++) { + if (s1[i] === s2[i]) commonPrefix++; + else break; + } + + return (commonPrefix / longer.length) * 0.4 + (shorter.length / longer.length) * 0.6; } diff --git a/packages/cli/src/utils/git.ts b/packages/cli/src/utils/git.ts index 0ca7e0b5..eb226eb3 100644 --- a/packages/cli/src/utils/git.ts +++ b/packages/cli/src/utils/git.ts @@ -11,6 +11,8 @@ import { type ExecFileException, execFile, spawn } from 'child_process'; import { promisify } from 'util'; +import * as os from 'os'; +import * as path from 'path'; const execFileAsync = promisify(execFile); @@ -214,6 +216,62 @@ export async function gitCommit( }); } +/** + * 克隆仓库并使用本地镜像缓存 (Git Mirror Cache) + * 显著提升大仓库克隆速度,减少网络依赖 + * + * @param url 远程仓库 URL + * @param targetPath 目标路径 + * @param options 克隆选项 + */ +export async function cloneRepository( + url: string, + targetPath: string, + options: { + branch?: string; + depth?: number; + useMirror?: boolean; + mirrorDir?: string; + } = {} +): Promise { + const { branch, depth, useMirror = true, mirrorDir = path.join(os.homedir(), '.blade/cache/git-mirrors') } = options; + + // 1. 生成镜像路径 (基于 URL 的 hash) + const urlHash = Buffer.from(url).toString('hex').slice(0, 12); + const mirrorPath = path.join(mirrorDir, `${urlHash}.git`); + + if (useMirror) { + // 确保镜像目录存在 + await execFileAsync('mkdir', ['-p', mirrorDir]); + + // 如果镜像不存在,先创建 bare 镜像 + if (!(await isGitRepository(mirrorPath))) { + await execFileAsync('git', ['clone', '--mirror', url, mirrorPath]); + } else { + // 如果镜像已存在,更新它 + await gitExec(mirrorPath, ['remote', 'update', '--prune']); + } + } + + // 2. 从本地镜像克隆 + const args = ['clone']; + if (useMirror) { + args.push('--reference', mirrorPath); + } + if (branch) { + args.push('--branch', branch); + } + if (depth) { + args.push('--depth', String(depth)); + } + args.push(url, targetPath); + + const { code, stderr } = await gitExec(process.cwd(), args); + if (code !== 0) { + throw new Error(`Failed to clone repository: ${stderr}`); + } +} + // ============================================================================ // Composite Functions // ============================================================================ diff --git a/packages/cli/src/utils/packageInfo.ts b/packages/cli/src/utils/packageInfo.ts index 2c332ecd..425f0dc3 100644 --- a/packages/cli/src/utils/packageInfo.ts +++ b/packages/cli/src/utils/packageInfo.ts @@ -1,9 +1,34 @@ /** * Package.json 信息读取工具 * 提供统一的包信息访问接口 + * + * 注意:为兼容纯 Node ESM 运行环境,这里不再使用对 package.json 的静态 import, + * 而是通过 fs 读取并解析 JSON,避免对 `import ... assert { type: "json" }` 的依赖。 */ -import packageJson from '../../package.json'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let packageJsonPath = path.resolve(__dirname, '../../../package.json'); +try { + readFileSync(packageJsonPath, 'utf8'); +} catch (e) { + try { + packageJsonPath = path.resolve(__dirname, '../../package.json'); + readFileSync(packageJsonPath, 'utf8'); + } catch (e2) { + packageJsonPath = path.resolve(__dirname, '../package.json'); + } +} +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + name: string; + version: string; + description: string; +}; export interface PackageInfo { name: string;