using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
///
/// Markdown (.md) 문서를 생성하는 내장 스킬.
/// sections 배열로 구조화된 콘텐츠 블록(heading/paragraph/table/list/callout/code/quote/divider/toc)을 지원합니다.
/// frontmatter, toc 옵션으로 메타데이터와 목차를 자동 생성할 수 있습니다.
/// content 파라미터로 기존 방식의 원시 마크다운도 계속 지원합니다.
///
public class MarkdownSkill : IAgentTool
{
public string Name => "markdown_create";
public string Description =>
"Create a Markdown (.md) document. " +
"REQUIRED: 'content' (raw markdown string). " +
"Alternative: set content=\"\" and provide 'sections' array for structured blocks. " +
"NEVER call this tool with only title — you MUST include content (or sections). " +
"Use 'sections' for structured content (heading/paragraph/table/list/callout/code/quote/divider/toc). " +
"Use 'frontmatter' for YAML metadata. Use 'toc' to auto-generate a table of contents.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "출력 파일 경로 (.md). 작업 폴더 기준 상대 경로." },
["title"] = new() { Type = "string", Description = "문서 제목. 제공 시 최상단에 '# 제목' 헤딩을 추가합니다." },
["content"] = new() { Type = "string", Description = "[REQUIRED unless 'sections' is provided] 원시 마크다운 내용. 문서의 주요 내용을 여기에 작성하세요. " +
"sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요." },
["sections"] = new()
{
Type = "array",
Description =
"구조화된 콘텐츠 블록 배열. 각 객체는 'type' 필드를 가집니다. " +
"타입: " +
"'heading' {level:1-6, text} — 제목, " +
"'paragraph' {text} — 단락, " +
"'table' {headers:[], rows:[[]]} — 표, " +
"'list' {items:[], ordered:false} — 목록, " +
"'callout' {style:'info|warning|tip|danger', text} — 콜아웃 블록, " +
"'code' {language:'python', code:'...'} — 코드 블록, " +
"'quote' {text, author} — 인용구, " +
"'divider' — 구분선, " +
"'toc' — 이 위치에 목차 삽입."
},
["frontmatter"] = new()
{
Type = "object",
Description = "YAML 프론트매터 메타데이터 키-값 쌍. 예: {\"author\": \"홍길동\", \"date\": \"2026-04-07\"}."
},
["toc"] = new() { Type = "boolean", Description = "true이면 문서 상단(제목 다음)에 목차를 자동 생성합니다." },
["encoding"] = new() { Type = "string", Description = "파일 인코딩: 'utf-8' (기본값) 또는 'euc-kr'." },
},
Required = ["content"]
};
public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
// ── 필수 파라미터 ──────────────────────────────────────────────────
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl)
&& sectionsEl.ValueKind == JsonValueKind.Array
&& sectionsEl.GetArrayLength() > 0;
var hasContent = args.SafeTryGetProperty("content", out var contentEl)
&& contentEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(contentEl.SafeGetString());
var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
if (!hasSections && !hasContent)
{
return ToolResult.Fail(
"필수 파라미터 누락: 'content' (마크다운 문자열) 또는 'sections' (배열) 중 하나는 반드시 제공해야 합니다.\n" +
"다시 호출할 때는 title 외에 반드시 content를 포함하세요. 예:\n" +
"{\"name\":\"markdown_create\",\"arguments\":{\"title\":\"...\",\"content\":\"## 개요\\n...\\n\\n## 상세\\n...\"}}\n" +
"sections 배열을 사용하려면 content=\"\"로 두고 sections에 최소 1개 이상의 블록을 제공하세요.");
}
// path 미제공 시 title에서 자동 생성
string path;
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.SafeGetString()!;
}
else
{
var baseTitle = (args.SafeTryGetProperty("title", out var autoT) ? autoT.SafeGetString() : null) ?? "document";
var safe = System.Text.RegularExpressions.Regex.Replace(baseTitle, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".md";
}
var title = args.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
var useToc = args.SafeTryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
// ── 경로 처리 ──────────────────────────────────────────────────────
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
fullPath += ".md";
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
try
{
Encoding fileEncoding;
try { fileEncoding = Encoding.GetEncoding(encodingName); }
catch { fileEncoding = new UTF8Encoding(false); }
var sb = new StringBuilder();
// ── YAML 프론트매터 ────────────────────────────────────────────
if (hasFrontmatter)
{
sb.AppendLine("---");
foreach (var prop in frontEl.EnumerateObject())
{
var val = prop.Value.ValueKind == JsonValueKind.String
? prop.Value.SafeGetString() ?? ""
: prop.Value.ToString();
// 값에 특수 문자가 있으면 인용
if (val.Contains(':') || val.Contains('#') || val.StartsWith('"'))
val = $"\"{val.Replace("\"", "\\\"")}\"";
sb.AppendLine($"{prop.Name}: {val}");
}
sb.AppendLine("---");
sb.AppendLine();
}
// ── 제목 ───────────────────────────────────────────────────────
if (!string.IsNullOrEmpty(title))
{
sb.AppendLine($"# {title}");
sb.AppendLine();
}
if (hasSections)
{
// ── sections 모드 ──────────────────────────────────────────
// 먼저 heading 목록 수집 (TOC 생성용)
var headings = CollectHeadings(sectionsEl);
// 문서 상단 TOC (toc:true 옵션)
if (useToc && headings.Count > 0)
{
RenderToc(sb, headings);
sb.AppendLine();
}
// 섹션 렌더링
foreach (var section in sectionsEl.EnumerateArray())
{
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
var type = typeEl.SafeGetString()?.ToLowerInvariant() ?? "";
switch (type)
{
case "heading":
RenderHeading(sb, section);
break;
case "paragraph":
RenderParagraph(sb, section);
break;
case "table":
RenderTable(sb, section);
break;
case "list":
RenderList(sb, section);
break;
case "callout":
RenderCallout(sb, section);
break;
case "code":
RenderCode(sb, section);
break;
case "quote":
RenderQuote(sb, section);
break;
case "divider":
sb.AppendLine("---");
sb.AppendLine();
break;
case "toc":
// 인라인 TOC 삽입
if (headings.Count > 0) RenderToc(sb, headings);
sb.AppendLine();
break;
}
}
}
else
{
// ── 하위 호환: 원시 content 모드 ──────────────────────────
if (useToc)
{
// content에서 헤딩 파싱하여 TOC 생성
var raw = contentEl.SafeGetString() ?? "";
var headings = ParseHeadingsFromContent(raw);
if (headings.Count > 0)
{
RenderToc(sb, headings);
sb.AppendLine();
}
}
sb.Append(contentEl.SafeGetString() ?? "");
}
// ── 파일 쓰기 ──────────────────────────────────────────────────
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(fullPath, sb.ToString(), fileEncoding, ct);
var lines = sb.ToString().Split('\n').Length;
return ToolResult.Ok(
$"Markdown 문서 저장 완료: {fullPath} ({lines}줄, 인코딩: {encodingName})",
fullPath);
}
catch (Exception ex)
{
return ToolResult.Fail($"Markdown 문서 저장 중 오류가 발생했습니다: {ex.Message}");
}
}
// ── 헤딩 수집 (sections 배열에서) ────────────────────────────────────────
private static List<(int Level, string Text)> CollectHeadings(JsonElement sections)
{
var list = new List<(int, string)>();
foreach (var s in sections.EnumerateArray())
{
if (!s.SafeTryGetProperty("type", out var t) || t.SafeGetString() != "heading") continue;
var level = s.SafeTryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
var text = s.SafeTryGetProperty("text", out var xEl) ? xEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrEmpty(text))
list.Add((Math.Clamp(level, 1, 6), text));
}
return list;
}
// ── 헤딩 파싱 (원시 마크다운에서) ────────────────────────────────────────
private static List<(int Level, string Text)> ParseHeadingsFromContent(string content)
{
var list = new List<(int, string)>();
foreach (var line in content.Split('\n'))
{
var m = Regex.Match(line, @"^(#{1,6})\s+(.+)");
if (m.Success)
list.Add((m.Groups[1].Length, m.Groups[2].Value.Trim()));
}
return list;
}
// ── TOC 렌더링 ────────────────────────────────────────────────────────────
private static void RenderToc(StringBuilder sb, List<(int Level, string Text)> headings)
{
sb.AppendLine("## 목차");
sb.AppendLine();
foreach (var (level, text) in headings)
{
// level 1은 들여쓰기 없음, level 2 → 2스페이스, 이후 2씩 추가
var indent = level <= 1 ? "" : new string(' ', (level - 1) * 2);
var anchor = HeadingToAnchor(text);
sb.AppendLine($"{indent}- [{text}](#{anchor})");
}
sb.AppendLine();
}
// ── GitHub-style 앵커 생성 ────────────────────────────────────────────────
private static string HeadingToAnchor(string text)
{
// 소문자 변환, 영문/숫자/한글 이외 공백 → '-', 연속 '-' 정리
var anchor = text.ToLowerInvariant();
anchor = Regex.Replace(anchor, @"[^\w\uAC00-\uD7A3\s-]", "");
anchor = Regex.Replace(anchor, @"\s+", "-");
anchor = Regex.Replace(anchor, @"-+", "-").Trim('-');
return anchor;
}
// ── 개별 섹션 렌더러 ──────────────────────────────────────────────────────
private static void RenderHeading(StringBuilder sb, JsonElement s)
{
var level = s.SafeTryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
sb.AppendLine($"{new string('#', level)} {text}");
sb.AppendLine();
}
private static void RenderParagraph(StringBuilder sb, JsonElement s)
{
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
if (!string.IsNullOrWhiteSpace(text))
{
sb.AppendLine(text);
sb.AppendLine();
}
}
private static void RenderTable(StringBuilder sb, JsonElement s)
{
if (!s.SafeTryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
return;
if (!s.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return;
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.SafeGetString() ?? "")).ToList();
if (headers.Count == 0) return;
// 헤더 행
sb.AppendLine("| " + string.Join(" | ", headers) + " |");
// 구분 행
sb.AppendLine("| " + string.Join(" | ", headers.Select(_ => "---")) + " |");
// 데이터 행
foreach (var row in rowsEl.EnumerateArray())
{
if (row.ValueKind != JsonValueKind.Array) continue;
var cells = row.EnumerateArray()
.Select(c => EscapeTableCell(c.ValueKind == JsonValueKind.Null ? "" : c.ToString()))
.ToList();
// 열 수 맞추기
while (cells.Count < headers.Count) cells.Add("");
sb.AppendLine("| " + string.Join(" | ", cells.Take(headers.Count)) + " |");
}
sb.AppendLine();
}
private static string EscapeTableCell(string value)
=> value.Replace("|", "\\|").Replace("\n", " ").Replace("\r", "");
private static void RenderList(StringBuilder sb, JsonElement s)
{
if (!s.SafeTryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
return;
var ordered = s.SafeTryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
int idx = 1;
foreach (var item in itemsEl.EnumerateArray())
{
var text = item.ValueKind == JsonValueKind.String ? item.SafeGetString() ?? "" : item.ToString();
// 들여쓰기 보존 (앞에 공백/탭이 있으면 그대로)
var prefix = ordered ? $"{idx++}. " : "- ";
if (text.StartsWith(" ") || text.StartsWith("\t"))
sb.AppendLine(text); // 하위 항목: 이미 들여쓰기 포함
else
sb.AppendLine($"{prefix}{text}");
}
sb.AppendLine();
}
private static void RenderCallout(StringBuilder sb, JsonElement s)
{
var style = s.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
// 표준 GitHub/Obsidian 콜아웃 형식
sb.AppendLine($"> [!{style}]");
foreach (var line in text.Split('\n'))
sb.AppendLine($"> {line}");
sb.AppendLine();
}
private static void RenderCode(StringBuilder sb, JsonElement s)
{
var lang = s.SafeTryGetProperty("language", out var lEl) ? lEl.SafeGetString() ?? "" : "";
var code = s.SafeTryGetProperty("code", out var cEl) ? cEl.SafeGetString() ?? "" : "";
sb.AppendLine($"```{lang}");
sb.AppendLine(code);
sb.AppendLine("```");
sb.AppendLine();
}
private static void RenderQuote(StringBuilder sb, JsonElement s)
{
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
var author = s.SafeTryGetProperty("author", out var aEl) ? aEl.SafeGetString() : null;
foreach (var line in text.Split('\n'))
sb.AppendLine($"> {line}");
if (!string.IsNullOrEmpty(author))
sb.AppendLine($">");
sb.AppendLine($"> — {author}");
sb.AppendLine();
}
}