Initial commit to new repository
This commit is contained in:
222
src/AxCopilot/Services/Agent/ProjectRuleTool.cs
Normal file
222
src/AxCopilot/Services/Agent/ProjectRuleTool.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 프로젝트 개발 지침(AGENTS.md) 관리 도구.
|
||||
/// 작업 폴더의 AGENTS.md 파일에 개발 규칙, 코딩 컨벤션, 설계 원칙을 읽고 쓸 수 있습니다.
|
||||
/// 쓰기 시 사용자 승인을 받습니다.
|
||||
/// </summary>
|
||||
public class ProjectRuleTool : IAgentTool
|
||||
{
|
||||
public string Name => "project_rules";
|
||||
|
||||
public string Description =>
|
||||
"프로젝트 개발 지침(AGENTS.md) 및 규칙(.ax/rules/)을 관리합니다.\n" +
|
||||
"- read: 현재 AGENTS.md 내용을 읽습니다\n" +
|
||||
"- append: 새 규칙/지침을 AGENTS.md에 추가합니다 (사용자 승인 필요)\n" +
|
||||
"- write: AGENTS.md를 새 내용으로 덮어씁니다 (사용자 승인 필요)\n" +
|
||||
"- list_rules: .ax/rules/ 디렉토리의 프로젝트 규칙 파일 목록을 조회합니다\n" +
|
||||
"- read_rule: .ax/rules/ 디렉토리의 특정 규칙 파일을 읽습니다\n" +
|
||||
"사용자가 '개발 지침에 추가해', '규칙을 저장해', 'AGENTS.md에 기록해' 등을 요청하면 이 도구를 사용하세요.";
|
||||
|
||||
public ToolParameterSchema Parameters => new()
|
||||
{
|
||||
Properties = new()
|
||||
{
|
||||
["action"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "read (AGENTS.md 읽기), append (추가), write (전체 덮어쓰기), list_rules (.ax/rules/ 목록), read_rule (규칙 파일 읽기)",
|
||||
Enum = ["read", "append", "write", "list_rules", "read_rule"]
|
||||
},
|
||||
["rule_name"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "read_rule 시 읽을 규칙 파일 이름 (확장자 제외). 예: 'coding-conventions'"
|
||||
},
|
||||
["content"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "append/write 시 저장할 내용. 마크다운 형식을 권장합니다."
|
||||
},
|
||||
["section"] = new ToolProperty
|
||||
{
|
||||
Type = "string",
|
||||
Description = "append 시 섹션 제목. 예: '코딩 컨벤션', '빌드 규칙'. 비어있으면 파일 끝에 추가."
|
||||
},
|
||||
},
|
||||
Required = ["action"]
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
var section = args.TryGetProperty("section", out var s) ? s.GetString() ?? "" : "";
|
||||
var ruleName = args.TryGetProperty("rule_name", out var rn) ? rn.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||
|
||||
var axMdPath = FindAxMd(context.WorkFolder) ?? Path.Combine(context.WorkFolder, "AGENTS.md");
|
||||
|
||||
return action switch
|
||||
{
|
||||
"read" => ReadAxMd(axMdPath),
|
||||
"append" => await AppendAxMdAsync(axMdPath, content, section, context),
|
||||
"write" => await WriteAxMdAsync(axMdPath, content, context),
|
||||
"list_rules" => ListRules(context.WorkFolder),
|
||||
"read_rule" => ReadRule(context.WorkFolder, ruleName),
|
||||
_ => ToolResult.Fail($"지원하지 않는 action: {action}. read, append, write, list_rules, read_rule 중 선택하세요.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ToolResult ReadAxMd(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return ToolResult.Ok($"AGENTS.md 파일이 없습니다.\n경로: {path}\n\n새로 생성하려면 append 또는 write 액션을 사용하세요.");
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(path, Encoding.UTF8);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Ok("AGENTS.md 파일이 비어 있습니다.");
|
||||
|
||||
return ToolResult.Ok($"[AGENTS.md 내용 ({content.Length}자)]\n\n{content}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"AGENTS.md 읽기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> AppendAxMdAsync(string path, string content, string section, AgentContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Fail("추가할 content가 필요합니다.");
|
||||
|
||||
// 사용자 승인
|
||||
var desc = $"AGENTS.md에 개발 지침을 추가합니다:\n{(content.Length > 200 ? content[..200] + "..." : content)}";
|
||||
if (!await context.CheckWritePermissionAsync("project_rules", desc))
|
||||
return ToolResult.Ok("사용자가 AGENTS.md 수정을 거부했습니다.");
|
||||
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// 기존 내용 보존
|
||||
if (File.Exists(path))
|
||||
sb.Append(File.ReadAllText(path, Encoding.UTF8));
|
||||
|
||||
// 구분선 + 섹션 제목
|
||||
if (sb.Length > 0 && !sb.ToString().EndsWith('\n'))
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
|
||||
if (!string.IsNullOrEmpty(section))
|
||||
sb.AppendLine($"## {section}");
|
||||
|
||||
sb.AppendLine(content.Trim());
|
||||
|
||||
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
|
||||
return ToolResult.Ok($"AGENTS.md에 개발 지침이 추가되었습니다.\n경로: {path}\n추가된 내용 ({content.Length}자):\n{content}", path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"AGENTS.md 쓰기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> WriteAxMdAsync(string path, string content, AgentContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Fail("저장할 content가 필요합니다.");
|
||||
|
||||
var desc = $"AGENTS.md를 전체 덮어씁니다 ({content.Length}자):\n{(content.Length > 200 ? content[..200] + "..." : content)}";
|
||||
if (!await context.CheckWritePermissionAsync("project_rules", desc))
|
||||
return ToolResult.Ok("사용자가 AGENTS.md 수정을 거부했습니다.");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, content, Encoding.UTF8);
|
||||
return ToolResult.Ok($"AGENTS.md가 저장되었습니다.\n경로: {path}\n내용 ({content.Length}자)", path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"AGENTS.md 쓰기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult ListRules(string workFolder)
|
||||
{
|
||||
var rules = ProjectRulesService.LoadRules(workFolder);
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
var rulesDir = ProjectRulesService.FindRulesDirectory(workFolder);
|
||||
var suggestedPath = rulesDir ?? Path.Combine(workFolder, ".ax", "rules");
|
||||
return ToolResult.Ok(
|
||||
$"프로젝트 규칙이 없습니다.\n" +
|
||||
$"규칙 파일을 추가하려면 {suggestedPath} 디렉토리에 .md 파일을 생성하세요.\n\n" +
|
||||
"예시 규칙 파일 형식:\n" +
|
||||
"---\nname: 코딩 컨벤션\ndescription: C# 코딩 규칙\napplies-to: \"*.cs\"\nwhen: always\n---\n\n규칙 내용...");
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[프로젝트 규칙 {rules.Count}개]\n");
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
sb.AppendLine($" • {rule.Name}");
|
||||
if (!string.IsNullOrEmpty(rule.Description))
|
||||
sb.AppendLine($" 설명: {rule.Description}");
|
||||
if (!string.IsNullOrEmpty(rule.AppliesTo))
|
||||
sb.AppendLine($" 적용 대상: {rule.AppliesTo}");
|
||||
sb.AppendLine($" 적용 시점: {rule.When}");
|
||||
sb.AppendLine($" 파일: {rule.FilePath}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
return ToolResult.Ok(sb.ToString());
|
||||
}
|
||||
|
||||
private static ToolResult ReadRule(string workFolder, string ruleName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ruleName))
|
||||
return ToolResult.Fail("rule_name 파라미터가 필요합니다.");
|
||||
|
||||
var rules = ProjectRulesService.LoadRules(workFolder);
|
||||
var rule = rules.FirstOrDefault(r =>
|
||||
r.Name.Equals(ruleName, StringComparison.OrdinalIgnoreCase) ||
|
||||
Path.GetFileNameWithoutExtension(r.FilePath).Equals(ruleName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (rule == null)
|
||||
return ToolResult.Fail($"규칙 '{ruleName}'을(를) 찾을 수 없습니다. list_rules로 목록을 확인하세요.");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[규칙: {rule.Name}]");
|
||||
if (!string.IsNullOrEmpty(rule.Description)) sb.AppendLine($"설명: {rule.Description}");
|
||||
if (!string.IsNullOrEmpty(rule.AppliesTo)) sb.AppendLine($"적용 대상: {rule.AppliesTo}");
|
||||
sb.AppendLine($"적용 시점: {rule.When}");
|
||||
sb.AppendLine($"파일: {rule.FilePath}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(rule.Body);
|
||||
return ToolResult.Ok(sb.ToString(), rule.FilePath);
|
||||
}
|
||||
|
||||
/// <summary>AGENTS.md 파일을 작업 폴더에서 최대 3단계 상위까지 탐색합니다 (없으면 AX.md 폴백).</summary>
|
||||
private static string? FindAxMd(string workFolder)
|
||||
{
|
||||
var dir = workFolder;
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dir)) break;
|
||||
var agentsPath = Path.Combine(dir, "AGENTS.md");
|
||||
if (File.Exists(agentsPath)) return agentsPath;
|
||||
var legacyPath = Path.Combine(dir, "AX.md");
|
||||
if (File.Exists(legacyPath)) return legacyPath;
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user