Initial commit to new repository
This commit is contained in:
207
src/AxCopilot/Services/Agent/BuildRunTool.cs
Normal file
207
src/AxCopilot/Services/Agent/BuildRunTool.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 프로젝트 빌드/테스트 실행 도구.
|
||||
/// 작업 폴더의 프로젝트 타입을 자동 감지하고 적절한 빌드/테스트 명령을 실행합니다.
|
||||
/// 사내 환경에서 설치된 도구만 사용하며, 빌더를 직접 설치하지 않습니다.
|
||||
/// </summary>
|
||||
public class BuildRunTool : IAgentTool
|
||||
{
|
||||
public string Name => "build_run";
|
||||
public string Description =>
|
||||
"Detect project type and run build/test commands. " +
|
||||
"Supports: .NET (dotnet), Maven (mvn), Gradle, Node.js (npm), Python (pytest), CMake, Make. " +
|
||||
"Actions: detect (show project type), build, test, run, custom (run arbitrary command with longer timeout).";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Action to perform: detect, build, test, run, lint, format, custom",
|
||||
Enum = ["detect", "build", "test", "run", "lint", "format", "custom"],
|
||||
},
|
||||
["command"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Custom command to execute (required for action='custom')",
|
||||
},
|
||||
["project_path"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Subdirectory within work folder (optional, defaults to work folder root)",
|
||||
},
|
||||
},
|
||||
Required = ["action"]
|
||||
};
|
||||
|
||||
// ProcessTool과 동일한 위험 명령 패턴
|
||||
private static readonly string[] DangerousPatterns =
|
||||
[
|
||||
"format ", "del /s", "rd /s", "rmdir /s", "rm -rf",
|
||||
"Remove-Item -Recurse -Force",
|
||||
"Stop-Computer", "Restart-Computer",
|
||||
"shutdown", "taskkill /f",
|
||||
"reg delete", "reg add",
|
||||
"net user", "net localgroup",
|
||||
"schtasks /create", "schtasks /delete",
|
||||
];
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "detect";
|
||||
var customCmd = args.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : "";
|
||||
var subPath = args.TryGetProperty("project_path", out var pp) ? pp.GetString() ?? "" : "";
|
||||
|
||||
var workDir = context.WorkFolder;
|
||||
if (!string.IsNullOrEmpty(subPath))
|
||||
workDir = Path.Combine(workDir, subPath);
|
||||
|
||||
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
|
||||
return ToolResult.Fail($"작업 폴더가 유효하지 않습니다: {workDir}");
|
||||
|
||||
// 프로젝트 타입 감지
|
||||
var project = DetectProjectType(workDir);
|
||||
|
||||
if (action == "detect")
|
||||
{
|
||||
if (project == null)
|
||||
return ToolResult.Ok($"프로젝트 감지 실패: {workDir}\n알려진 프로젝트 마커 파일이 없습니다.");
|
||||
return ToolResult.Ok(
|
||||
$"프로젝트 감지 완료:\n" +
|
||||
$" 타입: {project.Type}\n" +
|
||||
$" 마커: {project.Marker}\n" +
|
||||
$" 빌드: {project.BuildCommand}\n" +
|
||||
$" 테스트: {project.TestCommand}\n" +
|
||||
$" 실행: {project.RunCommand}\n" +
|
||||
$" 린트: {(string.IsNullOrEmpty(project.LintCommand) ? "(미지원)" : project.LintCommand)}\n" +
|
||||
$" 포맷: {(string.IsNullOrEmpty(project.FormatCommand) ? "(미지원)" : project.FormatCommand)}\n" +
|
||||
$" 경로: {workDir}");
|
||||
}
|
||||
|
||||
// 실행할 명령 결정
|
||||
string? command;
|
||||
if (action == "custom")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(customCmd))
|
||||
return ToolResult.Fail("custom 액션에는 command 파라미터가 필요합니다.");
|
||||
command = customCmd;
|
||||
}
|
||||
else if (project == null)
|
||||
{
|
||||
return ToolResult.Fail("프로젝트 타입을 감지할 수 없습니다. action='custom'으로 직접 명령을 지정하세요.");
|
||||
}
|
||||
else
|
||||
{
|
||||
command = action switch
|
||||
{
|
||||
"build" => project.BuildCommand,
|
||||
"test" => project.TestCommand,
|
||||
"run" => project.RunCommand,
|
||||
"lint" => string.IsNullOrEmpty(project.LintCommand) ? null : project.LintCommand,
|
||||
"format" => string.IsNullOrEmpty(project.FormatCommand) ? null : project.FormatCommand,
|
||||
_ => project.BuildCommand,
|
||||
};
|
||||
if (command == null)
|
||||
return ToolResult.Fail($"이 프로젝트 타입({project.Type})에서 '{action}' 작업은 지원되지 않습니다.");
|
||||
}
|
||||
|
||||
// 위험 명령 검사
|
||||
foreach (var pattern in DangerousPatterns)
|
||||
{
|
||||
if (command.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Fail($"차단된 명령 패턴: {pattern}");
|
||||
}
|
||||
|
||||
// 쓰기 권한 확인
|
||||
if (!await context.CheckWritePermissionAsync(Name, workDir))
|
||||
return ToolResult.Fail("빌드 실행 권한이 거부되었습니다.");
|
||||
|
||||
// 명령 실행 (ProcessTool 패턴)
|
||||
var app = System.Windows.Application.Current as App;
|
||||
var timeout = app?.SettingsService?.Settings.Llm.Code.BuildTimeout ?? 120;
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("cmd.exe", $"/C {command}")
|
||||
{
|
||||
WorkingDirectory = workDir,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(timeout));
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc == null) return ToolResult.Fail("프로세스 시작 실패");
|
||||
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync(cts.Token);
|
||||
|
||||
await proc.WaitForExitAsync(cts.Token);
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
// 출력 제한 (8000자)
|
||||
if (stdout.Length > 8000) stdout = stdout[..8000] + "\n... (출력 잘림)";
|
||||
if (stderr.Length > 4000) stderr = stderr[..4000] + "\n... (출력 잘림)";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[{action}] {command}");
|
||||
sb.AppendLine($"[Exit code: {proc.ExitCode}]");
|
||||
if (!string.IsNullOrWhiteSpace(stdout)) sb.AppendLine(stdout);
|
||||
if (!string.IsNullOrWhiteSpace(stderr)) sb.AppendLine($"[stderr]\n{stderr}");
|
||||
|
||||
return proc.ExitCode == 0
|
||||
? ToolResult.Ok(sb.ToString())
|
||||
: ToolResult.Fail(sb.ToString());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ToolResult.Fail($"빌드 타임아웃 ({timeout}초 초과): {command}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"실행 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private record ProjectInfo(string Type, string Marker, string BuildCommand, string TestCommand, string RunCommand, string LintCommand, string FormatCommand);
|
||||
|
||||
private static ProjectInfo? DetectProjectType(string dir)
|
||||
{
|
||||
if (Directory.GetFiles(dir, "*.sln").Length > 0)
|
||||
return new(".NET Solution", "*.sln", "dotnet build", "dotnet test", "dotnet run", "dotnet format --verify-no-changes", "dotnet format");
|
||||
if (Directory.GetFiles(dir, "*.csproj").Length > 0)
|
||||
return new(".NET Project", "*.csproj", "dotnet build", "dotnet test", "dotnet run", "dotnet format --verify-no-changes", "dotnet format");
|
||||
if (File.Exists(Path.Combine(dir, "pom.xml")))
|
||||
return new("Maven", "pom.xml", "mvn compile", "mvn test", "mvn exec:java", "mvn checkstyle:check", "");
|
||||
if (Directory.GetFiles(dir, "build.gradle*").Length > 0)
|
||||
return new("Gradle", "build.gradle", "gradle build", "gradle test", "gradle run", "gradle check", "");
|
||||
if (File.Exists(Path.Combine(dir, "package.json")))
|
||||
return new("Node.js", "package.json", "npm run build", "npm test", "npm start", "npx eslint .", "npx prettier --write .");
|
||||
if (File.Exists(Path.Combine(dir, "CMakeLists.txt")))
|
||||
return new("CMake", "CMakeLists.txt", "cmake --build build", "ctest --test-dir build", "", "", "");
|
||||
if (File.Exists(Path.Combine(dir, "pyproject.toml")))
|
||||
return new("Python (pyproject)", "pyproject.toml", "python -m build", "python -m pytest", "python -m", "python -m ruff check .", "python -m black .");
|
||||
if (File.Exists(Path.Combine(dir, "setup.py")))
|
||||
return new("Python (setup.py)", "setup.py", "python setup.py build", "python -m pytest", "python", "python -m ruff check .", "python -m black .");
|
||||
if (File.Exists(Path.Combine(dir, "Makefile")))
|
||||
return new("Make", "Makefile", "make", "make test", "make run", "make lint", "make format");
|
||||
if (Directory.GetFiles(dir, "*.py").Length > 0)
|
||||
return new("Python Scripts", "*.py", "", "python -m pytest", "python", "python -m ruff check .", "python -m black .");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user