Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
using System.IO;
using System.Text;
using System.Text.Json;
namespace AxCopilot.Services.Agent;
/// <summary>파일의 특정 부분을 수정하는 도구 (old_string → new_string 패턴).</summary>
public class FileEditTool : IAgentTool
{
public string Name => "file_edit";
public string Description => "Edit a file by replacing an exact string match. Set replace_all=true to replace all occurrences; otherwise old_string must be unique.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "File path to edit" },
["old_string"] = new() { Type = "string", Description = "Exact string to find and replace" },
["new_string"] = new() { Type = "string", Description = "Replacement string" },
["replace_all"] = new() { Type = "boolean", Description = "Replace all occurrences (default false). If false, old_string must be unique." },
},
Required = ["path", "old_string", "new_string"]
};
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var path = args.GetProperty("path").GetString() ?? "";
var oldStr = args.GetProperty("old_string").GetString() ?? "";
var newStr = args.GetProperty("new_string").GetString() ?? "";
var replaceAll = args.TryGetProperty("replace_all", out var ra) && ra.GetBoolean();
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!File.Exists(fullPath))
return ToolResult.Fail($"파일이 존재하지 않습니다: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
var read = await TextFileCodec.ReadAllTextAsync(fullPath, ct);
var content = read.Text;
var count = CountOccurrences(content, oldStr);
if (count == 0)
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.");
if (!replaceAll && count > 1)
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
// Diff Preview: 변경 내용을 컨텍스트와 함께 표시
var diffPreview = GenerateDiff(content, oldStr, newStr, fullPath);
var updated = content.Replace(oldStr, newStr);
var writeEncoding = TextFileCodec.ResolveWriteEncoding(read.Encoding, read.HasBom);
await TextFileCodec.WriteAllTextAsync(fullPath, updated, writeEncoding, ct);
var msg = replaceAll && count > 1
? $"파일 수정 완료: {fullPath} ({count}곳 전체 교체)"
: $"파일 수정 완료: {fullPath}";
return ToolResult.Ok($"{msg}\n\n{diffPreview}", fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"파일 수정 실패: {ex.Message}");
}
}
/// <summary>변경 전/후 diff를 생성합니다 (unified diff 스타일).</summary>
private static string GenerateDiff(string content, string oldStr, string newStr, string filePath)
{
var lines = content.Split('\n');
var matchIdx = content.IndexOf(oldStr, StringComparison.Ordinal);
if (matchIdx < 0) return "";
// 변경 시작 줄 번호 계산
var startLine = content[..matchIdx].Count(c => c == '\n');
var oldLines = oldStr.Split('\n');
var newLines = newStr.Split('\n');
var sb = new StringBuilder();
var fileName = Path.GetFileName(filePath);
sb.AppendLine($"--- {fileName} (before)");
sb.AppendLine($"+++ {fileName} (after)");
// 컨텍스트 라인 수
const int ctx = 2;
var ctxStart = Math.Max(0, startLine - ctx);
var ctxEnd = Math.Min(lines.Length - 1, startLine + oldLines.Length - 1 + ctx);
sb.AppendLine($"@@ -{ctxStart + 1},{ctxEnd - ctxStart + 1} @@");
// 앞쪽 컨텍스트
for (int i = ctxStart; i < startLine; i++)
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
// 삭제 라인
foreach (var line in oldLines)
sb.AppendLine($"-{line.TrimEnd('\r')}");
// 추가 라인
foreach (var line in newLines)
sb.AppendLine($"+{line.TrimEnd('\r')}");
// 뒤쪽 컨텍스트
var afterEnd = startLine + oldLines.Length;
for (int i = afterEnd; i <= ctxEnd && i < lines.Length; i++)
sb.AppendLine($" {lines[i].TrimEnd('\r')}");
return sb.ToString().TrimEnd();
}
private static int CountOccurrences(string text, string search)
{
if (string.IsNullOrEmpty(search)) return 0;
int count = 0, idx = 0;
while ((idx = text.IndexOf(search, idx, StringComparison.Ordinal)) != -1)
{
count++;
idx += search.Length;
}
return count;
}
}