diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md
index d92bc5b..c95aa82 100644
--- a/docs/LAUNCHER_ROADMAP.md
+++ b/docs/LAUNCHER_ROADMAP.md
@@ -308,3 +308,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
| L13-2 | **PATH 환경변수** ✅ | `path` 프리픽스. Process/User/Machine 세 범위 통합 조회. 경로 존재 여부 아이콘 표시. `path which <파일>` .exe/.cmd/.bat/.ps1 확장자 자동 시도. `path user/system` 범위별 표시. `path search` 키워드 필터 | 높음 |
| L13-3 | **드라이브 정보** ✅ | `drive` 프리픽스. DriveInfo.GetDrives() 기반 전체 드라이브 목록. 고정/이동식/네트워크/CD 드라이브 종류 구분. █░ 시각적 사용량 바 그래프. `drive C` 특정 드라이브 상세. `drive large` 사용량 많은 순 정렬. TB/GB/MB/KB 자동 단위 | 중간 |
| L13-4 | **나이·D-day 계산기** ✅ | `age` 프리픽스. YYYY-MM-DD / YYYYMMDD / M.d 형식 파싱. 과거 날짜 → 만 나이·한국 나이·경과 일수·다음 생일 D-day. 미래 날짜 → D-day·남은 주 계산. `age christmas/newyear` 특수 키워드. `age next monday` 다음 요일까지 D-day | 높음 |
+
+---
+
+## Phase L14 — 네트워크·계산·시스템 도구 (v2.0.6) ✅ 완료
+
+> **방향**: IT 관리·개발 실무 도구 보강 — WoL, 레지스트리 조회, 비용 계산, 폰트 검색.
+
+| # | 기능 | 설명 | 우선순위 |
+|---|------|------|----------|
+| L14-1 | **Wake-on-LAN** ✅ | `wol` 프리픽스. MAC 주소(AA:BB:CC:DD:EE:FF, 대시, 구분자 없음) 매직 패킷 전송. 포트 9+7 브로드캐스트(255.255.255.255). `wol save 이름 MAC`으로 호스트 저장(`wol_hosts.json`). `wol delete 이름` 삭제. 저장 항목 목록에서 Enter → 즉시 전송 | 중간 |
+| L14-2 | **레지스트리 조회** ✅ | `reg` 프리픽스. HKCU/HKLM/HKCR/HKU/HKCC 모든 하이브 지원. 하위 키·값 목록 표시. 값 타입(REG_SZ/DWORD/BINARY/MULTI_SZ) 포맷 출력. 9개 즐겨찾기 경로 빠른 접근. `reg search` 즐겨찾기 필터. 조회 전용(쓰기/삭제 없음) | 높음 |
+| L14-3 | **팁·할인·분할 계산기** ✅ | `tip` 프리픽스. 금액만 입력 시 10/15/18/20/25% 팁 전체 표시. `tip 금액 %` 특정 팁. `tip 금액 % 인원` 팁+분할. `tip 금액 off %` 할인가 계산. `tip 금액 vat` VAT 포함/역산. `tip 금액 / 인원` 균등 분할. 100원 단위 올림 계산 | 높음 |
+| L14-4 | **시스템 폰트 목록** ✅ | `font` 프리픽스. `Fonts.SystemFontFamilies` WPF API 기반. 설치 폰트 전체 목록 캐시(최초 1회 로드). `font 맑은/nanum/mono` 키워드 필터. 한글·나눔·코딩·Arial·Times 그룹 힌트. 검색 결과 전체 일괄 복사 지원 | 중간 |
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index fefc539..bd53c91 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -267,6 +267,16 @@ public partial class App : System.Windows.Application
// L13-4: 나이·D-day 계산기 (prefix=age)
commandResolver.RegisterHandler(new AgeHandler());
+ // ─── Phase L14 핸들러 ─────────────────────────────────────────────────
+ // L14-1: Wake-on-LAN (prefix=wol)
+ commandResolver.RegisterHandler(new WolHandler());
+ // L14-2: 레지스트리 조회 (prefix=reg)
+ commandResolver.RegisterHandler(new RegHandler());
+ // L14-3: 팁·할인·분할 계산기 (prefix=tip)
+ commandResolver.RegisterHandler(new TipHandler());
+ // L14-4: 시스템 폰트 목록 (prefix=font)
+ commandResolver.RegisterHandler(new FontHandler());
+
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll();
diff --git a/src/AxCopilot/Handlers/FontHandler.cs b/src/AxCopilot/Handlers/FontHandler.cs
new file mode 100644
index 0000000..4db0d8a
--- /dev/null
+++ b/src/AxCopilot/Handlers/FontHandler.cs
@@ -0,0 +1,136 @@
+using System.Windows;
+using System.Windows.Media;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L14-4: 시스템 폰트 목록·검색 핸들러. "font" 프리픽스로 사용합니다.
+///
+/// 예: font → 설치된 폰트 전체 목록
+/// font 맑은 → "맑은" 포함 폰트 검색
+/// font malgun → 영문 이름으로 검색
+/// font mono → "mono" 포함 폰트 목록
+/// font nanum → 나눔 폰트 목록
+/// Enter → 폰트 이름을 클립보드에 복사.
+///
+public class FontHandler : IActionHandler
+{
+ public string? Prefix => "font";
+
+ public PluginMetadata Metadata => new(
+ "Font",
+ "시스템 폰트 목록 — 검색 · 이름 복사",
+ "1.0",
+ "AX");
+
+ // 폰트 목록 캐시 (최초 1회 로드)
+ private static List? _fontCache;
+ private static readonly object _lock = new();
+
+ private static List GetFonts()
+ {
+ if (_fontCache != null) return _fontCache;
+ lock (_lock)
+ {
+ if (_fontCache != null) return _fontCache;
+ try
+ {
+ _fontCache = Fonts.SystemFontFamilies
+ .Select(f => f.Source)
+ .OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+ catch
+ {
+ _fontCache = new List();
+ }
+ return _fontCache;
+ }
+ }
+
+ // 주목할 만한 폰트 그룹 키워드
+ private static readonly (string Label, string Keyword)[] FontGroups =
+ [
+ ("한글 폰트", "malgun"),
+ ("나눔 폰트", "nanum"),
+ ("코딩용 폰트", "mono"),
+ ("Arial 계열", "arial"),
+ ("Times 계열", "times"),
+ ("Consolas", "consolas"),
+ ];
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+ var fonts = GetFonts();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem(
+ $"설치된 폰트 {fonts.Count}개",
+ "font <검색어> 로 필터링",
+ null, null, Symbol: "\uE8D2"));
+
+ // 그룹 힌트
+ foreach (var (label, kw) in FontGroups)
+ {
+ var cnt = fonts.Count(f => f.Contains(kw, StringComparison.OrdinalIgnoreCase));
+ if (cnt > 0)
+ items.Add(new LauncherItem(label, $"{cnt}개 · font {kw}", null, null, Symbol: "\uE8D2"));
+ }
+
+ // 첫 15개 표시
+ foreach (var f in fonts.Take(15))
+ items.Add(MakeFontItem(f));
+
+ return Task.FromResult>(items);
+ }
+
+ // 검색
+ var filtered = fonts
+ .Where(f => f.Contains(q, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (filtered.Count == 0)
+ {
+ items.Add(new LauncherItem("결과 없음",
+ $"'{q}' 포함 폰트가 없습니다", null, null, Symbol: "\uE946"));
+ }
+ else
+ {
+ items.Add(new LauncherItem(
+ $"'{q}' 검색 결과 {filtered.Count}개",
+ "전체 복사: 첫 항목 Enter",
+ null,
+ ("copy", string.Join("\n", filtered)),
+ Symbol: "\uE8D2"));
+
+ foreach (var f in filtered.Take(30))
+ items.Add(MakeFontItem(f));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is ("copy", string text))
+ {
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(
+ () => Clipboard.SetText(text));
+ NotificationService.Notify("Font", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ }
+ return Task.CompletedTask;
+ }
+
+ private static LauncherItem MakeFontItem(string fontName) =>
+ new(fontName, "폰트 이름 · Enter 복사", null, ("copy", fontName), Symbol: "\uE8D2");
+}
diff --git a/src/AxCopilot/Handlers/RegHandler.cs b/src/AxCopilot/Handlers/RegHandler.cs
new file mode 100644
index 0000000..785f2d9
--- /dev/null
+++ b/src/AxCopilot/Handlers/RegHandler.cs
@@ -0,0 +1,205 @@
+using Microsoft.Win32;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L14-2: Windows 레지스트리 빠른 조회 핸들러. "reg" 프리픽스로 사용합니다.
+///
+/// 예: reg HKCU\Software\Microsoft → 키 하위 값 목록
+/// reg HKLM\SOFTWARE\Microsoft → HKLM 조회
+/// reg search DisplayName → 값 이름으로 검색 (제한적)
+/// reg HKCU\...\Run → 실행 항목 조회
+/// Enter → 값을 클립보드에 복사.
+///
+/// 쓰기/삭제 기능 없음 — 조회 전용.
+///
+public class RegHandler : IActionHandler
+{
+ public string? Prefix => "reg";
+
+ public PluginMetadata Metadata => new(
+ "Reg",
+ "레지스트리 조회 — HKCU · HKLM 키·값 빠른 조회",
+ "1.0",
+ "AX");
+
+ // 자주 쓰는 즐겨찾기 경로
+ private static readonly (string Label, string Path)[] Favorites =
+ [
+ ("현재 사용자 Run", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"),
+ ("모든 사용자 Run", @"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"),
+ ("설치된 프로그램 (32bit)", @"HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
+ ("설치된 프로그램 (64bit)", @"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
+ ("환경 변수 (사용자)", @"HKCU\Environment"),
+ ("환경 변수 (시스템)", @"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
+ ("Internet Explorer 설정", @"HKCU\SOFTWARE\Microsoft\Internet Explorer\Main"),
+ ("Windows 테마", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes"),
+ ("탐색기 설정", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced"),
+ ];
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem("레지스트리 조회",
+ "예: reg HKCU\\Software\\Microsoft / reg HKLM\\SOFTWARE\\...",
+ null, null, Symbol: "\uE8BE"));
+ items.Add(new LauncherItem("── 즐겨찾기 ──", "", null, null, Symbol: "\uE8BE"));
+ foreach (var (label, path) in Favorites)
+ items.Add(new LauncherItem(label, path, null, ("query", path), Symbol: "\uE8BE"));
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
+ var sub = parts[0].ToLowerInvariant();
+
+ if (sub == "search" || sub == "find")
+ {
+ var keyword = parts.Length > 1 ? parts[1] : "";
+ if (string.IsNullOrWhiteSpace(keyword))
+ {
+ items.Add(new LauncherItem("검색어 입력", "예: reg search DisplayName", null, null, Symbol: "\uE783"));
+ }
+ else
+ {
+ // 즐겨찾기 경로에서 해당 이름 검색
+ items.Add(new LauncherItem($"'{keyword}' 즐겨찾기에서 검색", "일치하는 경로:", null, null, Symbol: "\uE8BE"));
+ foreach (var (label, path) in Favorites.Where(f =>
+ f.Label.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
+ f.Path.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
+ {
+ items.Add(new LauncherItem(label, path, null, ("query", path), Symbol: "\uE8BE"));
+ }
+ }
+ return Task.FromResult>(items);
+ }
+
+ // 레지스트리 경로 조회
+ var regPath = q;
+ items.AddRange(QueryRegistry(regPath));
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("copy", string text):
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("Reg", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ break;
+
+ case ("query", string path):
+ // 이미 GetItemsAsync에서 처리됨 — 재조회용 트리거 (런처 재갱신 불가 시 알림만)
+ NotificationService.Notify("Reg", $"조회: {path.Split('\\').Last()}");
+ break;
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 레지스트리 조회 ──────────────────────────────────────────────────────
+
+ private static IEnumerable QueryRegistry(string path)
+ {
+ var (hive, subKey) = SplitPath(path);
+ if (hive == null)
+ {
+ yield return new LauncherItem("형식 오류",
+ "HKCU 또는 HKLM으로 시작하는 경로를 입력하세요", null, null, Symbol: "\uE783");
+ yield break;
+ }
+
+ RegistryKey? key = null;
+ string? openError = null;
+ try { key = hive.OpenSubKey(subKey, writable: false); }
+ catch (Exception ex) { openError = ex.Message; }
+
+ if (openError != null)
+ {
+ yield return new LauncherItem("접근 오류", openError, null, null, Symbol: "\uE783");
+ yield break;
+ }
+
+ if (key == null)
+ {
+ yield return new LauncherItem("키 없음", $"'{path}' 키가 존재하지 않습니다", null, null, Symbol: "\uE946");
+ yield break;
+ }
+
+ using (key)
+ {
+ // 하위 키 목록
+ var subKeys = key.GetSubKeyNames();
+ if (subKeys.Length > 0)
+ {
+ yield return new LauncherItem($"하위 키 {subKeys.Length}개", path, null, null, Symbol: "\uE8BE");
+ foreach (var sk in subKeys.Take(10))
+ {
+ var fullPath = path.TrimEnd('\\') + @"\" + sk;
+ yield return new LauncherItem($"[{sk}]", fullPath, null, ("copy", fullPath), Symbol: "\uE8BE");
+ }
+ }
+
+ // 값 목록
+ var valueNames = key.GetValueNames();
+ if (valueNames.Length > 0)
+ {
+ yield return new LauncherItem($"── 값 {valueNames.Length}개 ──", "", null, null, Symbol: "\uE8BE");
+ foreach (var vn in valueNames.Take(20))
+ {
+ var val = key.GetValue(vn);
+ var valStr = FormatValue(val);
+ var display = string.IsNullOrEmpty(vn) ? "(기본값)" : vn;
+ yield return new LauncherItem(
+ display,
+ valStr.Length > 80 ? valStr[..80] + "…" : valStr,
+ null, ("copy", valStr), Symbol: "\uE8BE");
+ }
+ }
+
+ if (subKeys.Length == 0 && valueNames.Length == 0)
+ yield return new LauncherItem("빈 키", "하위 키와 값이 없습니다", null, null, Symbol: "\uE946");
+ }
+ }
+
+ private static (RegistryKey? Hive, string SubKey) SplitPath(string path)
+ {
+ path = path.Replace('/', '\\');
+ var sep = path.IndexOf('\\');
+ var hiveStr = sep >= 0 ? path[..sep].ToUpperInvariant() : path.ToUpperInvariant();
+ var sub = sep >= 0 ? path[(sep + 1)..] : "";
+
+ var hive = hiveStr switch
+ {
+ "HKCU" or "HKEY_CURRENT_USER" => Registry.CurrentUser,
+ "HKLM" or "HKEY_LOCAL_MACHINE" => Registry.LocalMachine,
+ "HKCR" or "HKEY_CLASSES_ROOT" => Registry.ClassesRoot,
+ "HKU" or "HKEY_USERS" => Registry.Users,
+ "HKCC" or "HKEY_CURRENT_CONFIG" => Registry.CurrentConfig,
+ _ => null,
+ };
+ return (hive, sub);
+ }
+
+ private static string FormatValue(object? val) => val switch
+ {
+ null => "(null)",
+ string s => s,
+ int i => $"{i} (0x{i:X8})",
+ long l => $"{l} (0x{l:X16})",
+ byte[] bytes => BitConverter.ToString(bytes).Replace("-", " "),
+ string[] arr => string.Join(" | ", arr),
+ _ => val.ToString() ?? "",
+ };
+}
diff --git a/src/AxCopilot/Handlers/TipHandler.cs b/src/AxCopilot/Handlers/TipHandler.cs
new file mode 100644
index 0000000..c95b63b
--- /dev/null
+++ b/src/AxCopilot/Handlers/TipHandler.cs
@@ -0,0 +1,242 @@
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L14-3: 팁·할인·분할 계산기 핸들러. "tip" 프리픽스로 사용합니다.
+///
+/// 예: tip 50000 → 50,000원에 대한 팁 퍼센트별 계산
+/// tip 50000 15 → 15% 팁 계산
+/// tip 50000 / 4 → 4명 분할
+/// tip 50000 15 4 → 15% 팁 포함 4명 분할
+/// tip 50000 off 20 → 20% 할인가 계산
+/// tip 50000 vat → 부가가치세 10% 계산
+/// Enter → 결과를 클립보드에 복사.
+///
+public class TipHandler : IActionHandler
+{
+ public string? Prefix => "tip";
+
+ public PluginMetadata Metadata => new(
+ "Tip",
+ "팁·할인·분할 계산기 — 팁 % · 할인 · VAT · 인원 분할",
+ "1.0",
+ "AX");
+
+ private static readonly int[] DefaultTipRates = [10, 15, 18, 20, 25];
+ private static readonly int[] DefaultDiscountRates = [5, 10, 15, 20, 30, 50];
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem("팁·할인·분할 계산기",
+ "예: tip 50000 / tip 50000 15 / tip 50000 off 20 / tip 50000 / 4",
+ null, null, Symbol: "\uE8F0"));
+ items.Add(new LauncherItem("tip 50000", "50,000원 팁 계산", null, null, Symbol: "\uE8F0"));
+ items.Add(new LauncherItem("tip 50000 15 4", "15% 팁 + 4명 분할", null, null, Symbol: "\uE8F0"));
+ items.Add(new LauncherItem("tip 50000 off 20", "20% 할인가", null, null, Symbol: "\uE8F0"));
+ items.Add(new LauncherItem("tip 50000 vat", "VAT 10% 계산", null, null, Symbol: "\uE8F0"));
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+
+ // 금액 파싱 (쉼표 제거)
+ if (!TryParseAmount(parts[0], out var amount))
+ {
+ items.Add(new LauncherItem("금액 형식 오류",
+ "예: tip 50000 또는 tip 50,000", null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ // 서브커맨드 분기
+ if (parts.Length >= 2)
+ {
+ var sub2 = parts[1].ToLowerInvariant();
+
+ // 할인: tip 50000 off 20
+ if (sub2 is "off" or "discount" or "할인")
+ {
+ var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? r : 10;
+ items.AddRange(BuildDiscountItems(amount, (double)rate));
+ return Task.FromResult>(items);
+ }
+
+ // VAT: tip 50000 vat [rate]
+ if (sub2 is "vat" or "세금" or "tax")
+ {
+ var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? (double)r : 10.0;
+ items.AddRange(BuildVatItems(amount, rate));
+ return Task.FromResult>(items);
+ }
+
+ // 분할: tip 50000 / 4
+ if (sub2 == "/" && parts.Length >= 3 && TryParseAmount(parts[2], out var people2))
+ {
+ items.AddRange(BuildSplitItems(amount, 0, (int)people2));
+ return Task.FromResult>(items);
+ }
+
+ // 팁%: tip 50000 15 또는 tip 50000 15 4
+ if (TryParseAmount(parts[1], out var tipRate))
+ {
+ var people = parts.Length >= 3 && TryParseAmount(parts[2], out var p) ? (int)p : 1;
+ items.AddRange(BuildTipItems(amount, (double)tipRate, people));
+ return Task.FromResult>(items);
+ }
+ }
+
+ // 기본: 팁 퍼센트별 목록
+ items.AddRange(BuildDefaultTipItems(amount));
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is ("copy", string text))
+ {
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(
+ () => Clipboard.SetText(text));
+ NotificationService.Notify("Tip", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 계산 빌더 ─────────────────────────────────────────────────────────────
+
+ private static IEnumerable BuildDefaultTipItems(decimal amount)
+ {
+ yield return new LauncherItem(
+ $"원금 {FormatKrw(amount)}",
+ "팁 퍼센트별 합계",
+ null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
+
+ foreach (var rate in DefaultTipRates)
+ {
+ var tip = amount * rate / 100;
+ var total = amount + tip;
+ yield return new LauncherItem(
+ $"{rate}% → {FormatKrw(total)}",
+ $"팁 {FormatKrw(tip)} · 합계 {FormatKrw(total)}",
+ null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
+ }
+
+ // 분할 미리보기
+ yield return new LauncherItem($"2명 분할", FormatKrw(amount / 2), null, ("copy", FormatKrw(amount / 2)), Symbol: "\uE8F0");
+ yield return new LauncherItem($"4명 분할", FormatKrw(amount / 4), null, ("copy", FormatKrw(amount / 4)), Symbol: "\uE8F0");
+ }
+
+ private static IEnumerable BuildTipItems(decimal amount, double tipPct, int people)
+ {
+ var tip = amount * (decimal)tipPct / 100;
+ var total = amount + tip;
+ var perPerson = people > 1 ? total / people : total;
+
+ yield return new LauncherItem(
+ $"합계 {FormatKrw(total)}",
+ $"원금 {FormatKrw(amount)} + 팁 {tipPct}% ({FormatKrw(tip)})",
+ null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
+
+ yield return new LauncherItem("원금", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
+ yield return new LauncherItem($"팁 {tipPct}%", FormatKrw(tip), null, ("copy", FormatKrw(tip)), Symbol: "\uE8F0");
+ yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
+
+ if (people > 1)
+ {
+ yield return new LauncherItem(
+ $"{people}명 분할",
+ $"1인당 {FormatKrw(perPerson)}",
+ null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
+ }
+ }
+
+ private static IEnumerable BuildDiscountItems(decimal amount, double discountPct)
+ {
+ var discount = amount * (decimal)discountPct / 100;
+ var discounted = amount - discount;
+
+ yield return new LauncherItem(
+ $"할인가 {FormatKrw(discounted)}",
+ $"{discountPct}% 할인 (할인액 {FormatKrw(discount)})",
+ null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0");
+
+ yield return new LauncherItem("원가", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
+ yield return new LauncherItem($"할인 {discountPct}%", FormatKrw(discount), null, ("copy", FormatKrw(discount)), Symbol: "\uE8F0");
+ yield return new LauncherItem("할인가", FormatKrw(discounted), null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0");
+
+ // 다른 할인율 비교
+ yield return new LauncherItem("── 할인율 비교 ──", "", null, null, Symbol: "\uE8F0");
+ foreach (var rate in DefaultDiscountRates.Where(r => r != (int)discountPct))
+ {
+ var d = amount * rate / 100;
+ yield return new LauncherItem($"{rate}% → {FormatKrw(amount - d)}",
+ $"할인 {FormatKrw(d)}", null, ("copy", FormatKrw(amount - d)), Symbol: "\uE8F0");
+ }
+ }
+
+ private static IEnumerable BuildVatItems(decimal amount, double vatRate)
+ {
+ var vat = amount * (decimal)vatRate / 100;
+ var withVat = amount + vat;
+ var exVat = amount / (1 + (decimal)vatRate / 100);
+ var vatOnly = amount - exVat;
+
+ yield return new LauncherItem(
+ $"VAT 포함 {FormatKrw(withVat)}",
+ $"VAT {vatRate}% ({FormatKrw(vat)})",
+ null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0");
+
+ yield return new LauncherItem($"입력액 (VAT 별도)", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
+ yield return new LauncherItem($"VAT {vatRate}%", FormatKrw(vat), null, ("copy", FormatKrw(vat)), Symbol: "\uE8F0");
+ yield return new LauncherItem("VAT 포함 합계", FormatKrw(withVat), null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0");
+ yield return new LauncherItem("── 역산 (VAT 포함가 입력 시) ──", "", null, null, Symbol: "\uE8F0");
+ yield return new LauncherItem("공급가액 (VAT 제외)", $"{FormatKrw(exVat)}", null, ("copy", FormatKrw(exVat)), Symbol: "\uE8F0");
+ yield return new LauncherItem("VAT 금액", $"{FormatKrw(vatOnly)}", null, ("copy", FormatKrw(vatOnly)), Symbol: "\uE8F0");
+ }
+
+ private static IEnumerable BuildSplitItems(decimal amount, double tipPct, int people)
+ {
+ if (people <= 0) people = 1;
+ var tip = tipPct > 0 ? amount * (decimal)tipPct / 100 : 0;
+ var total = amount + tip;
+ var perPerson = total / people;
+ var rounded = Math.Ceiling(perPerson / 100) * 100; // 100원 단위 올림
+
+ yield return new LauncherItem(
+ $"{people}명 분할 1인 {FormatKrw(perPerson)}",
+ $"합계 {FormatKrw(total)}",
+ null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
+
+ yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
+ yield return new LauncherItem($"1인 (정확)", FormatKrw(perPerson), null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
+ yield return new LauncherItem($"1인 (100원↑)", FormatKrw(rounded), null, ("copy", FormatKrw(rounded)), Symbol: "\uE8F0");
+ }
+
+ // ── 헬퍼 ─────────────────────────────────────────────────────────────────
+
+ private static bool TryParseAmount(string s, out decimal result)
+ {
+ result = 0;
+ s = s.Replace(",", "").Replace("원", "").Trim();
+ return decimal.TryParse(s, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out result);
+ }
+
+ private static string FormatKrw(decimal amount)
+ {
+ if (amount == Math.Floor(amount))
+ return $"{amount:N0}원";
+ return $"{amount:N2}원";
+ }
+}
diff --git a/src/AxCopilot/Handlers/WolHandler.cs b/src/AxCopilot/Handlers/WolHandler.cs
new file mode 100644
index 0000000..68845ff
--- /dev/null
+++ b/src/AxCopilot/Handlers/WolHandler.cs
@@ -0,0 +1,260 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L14-1: Wake-on-LAN 핸들러. "wol" 프리픽스로 사용합니다.
+///
+/// 예: wol → 저장된 호스트 목록
+/// wol AA:BB:CC:DD:EE:FF → 매직 패킷 전송
+/// wol AA-BB-CC-DD-EE-FF → 대시 구분자도 지원
+/// wol AABBCCDDEEFF → 구분자 없는 형식
+/// wol save PC-1 AA:BB:CC:DD:EE:FF → 호스트 저장
+/// wol delete PC-1 → 저장 항목 삭제
+/// Enter → 매직 패킷 전송.
+///
+public class WolHandler : IActionHandler
+{
+ public string? Prefix => "wol";
+
+ public PluginMetadata Metadata => new(
+ "WoL",
+ "Wake-on-LAN — 매직 패킷 전송 · 호스트 관리",
+ "1.0",
+ "AX");
+
+ private static readonly string StorePath = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "AxCopilot", "wol_hosts.json");
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+ var hosts = LoadHosts();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ if (hosts.Count == 0)
+ {
+ items.Add(new LauncherItem("Wake-on-LAN",
+ "예: wol AA:BB:CC:DD:EE:FF / wol save 이름 AA:BB:CC:...",
+ null, null, Symbol: "\uE823"));
+ }
+ else
+ {
+ items.Add(new LauncherItem($"저장된 호스트 {hosts.Count}개",
+ "Enter → 매직 패킷 전송", null, null, Symbol: "\uE823"));
+ foreach (var h in hosts)
+ items.Add(MakeHostItem(h));
+ }
+ items.Add(new LauncherItem("wol save 이름 MAC", "호스트 저장", null, null, Symbol: "\uE823"));
+ items.Add(new LauncherItem("wol AA:BB:CC:…", "직접 전송", null, null, Symbol: "\uE823"));
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var sub = parts[0].ToLowerInvariant();
+
+ switch (sub)
+ {
+ case "save":
+ case "add":
+ {
+ if (parts.Length < 3)
+ {
+ items.Add(new LauncherItem("형식 오류", "예: wol save PC-이름 AA:BB:CC:DD:EE:FF", null, null, Symbol: "\uE783"));
+ break;
+ }
+ var name = parts[1];
+ var mac = parts[2];
+ if (!TryParseMac(mac, out var macBytes))
+ {
+ items.Add(new LauncherItem("MAC 형식 오류", $"'{mac}'은 유효한 MAC 주소가 아닙니다", null, null, Symbol: "\uE783"));
+ break;
+ }
+ items.Add(new LauncherItem(
+ $"저장: {name} ({mac})",
+ "Enter → 저장",
+ null, ("save", $"{name}|{mac}"), Symbol: "\uE823"));
+ break;
+ }
+
+ case "delete":
+ case "del":
+ case "remove":
+ {
+ var target = parts.Length > 1 ? string.Join(" ", parts.Skip(1)) : "";
+ var found = hosts.FirstOrDefault(h =>
+ h.Name.Equals(target, StringComparison.OrdinalIgnoreCase));
+ if (found == null)
+ items.Add(new LauncherItem("없는 항목", $"'{target}'을 찾을 수 없습니다", null, null, Symbol: "\uE783"));
+ else
+ items.Add(new LauncherItem($"삭제: {found.Name}", found.Mac, null, ("delete", found.Name), Symbol: "\uE74D"));
+ break;
+ }
+
+ default:
+ {
+ // MAC 주소 직접 입력
+ if (TryParseMac(q, out _))
+ {
+ items.Add(new LauncherItem(
+ $"매직 패킷 전송: {q}",
+ "Enter → 브로드캐스트 전송 (255.255.255.255:9)",
+ null, ("send", q), Symbol: "\uE823"));
+ }
+ // 저장된 호스트 이름 검색
+ else
+ {
+ var filtered = hosts.Where(h =>
+ h.Name.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
+ if (filtered.Count == 0)
+ items.Add(new LauncherItem("결과 없음",
+ $"'{q}' 항목 없음. MAC 형식: AA:BB:CC:DD:EE:FF", null, null, Symbol: "\uE946"));
+ else
+ foreach (var h in filtered)
+ items.Add(MakeHostItem(h));
+ }
+ break;
+ }
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("send", string mac):
+ SendMagicPacket(mac);
+ break;
+
+ case ("wake", string mac):
+ SendMagicPacket(mac);
+ break;
+
+ case ("save", string data):
+ {
+ var idx = data.IndexOf('|');
+ var name = data[..idx];
+ var mac = data[(idx + 1)..];
+ var hosts = LoadHosts();
+ hosts.RemoveAll(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ hosts.Add(new WolHost { Name = name, Mac = mac });
+ SaveHosts(hosts);
+ NotificationService.Notify("WoL", $"'{name}' ({mac}) 저장됨");
+ break;
+ }
+
+ case ("delete", string name):
+ {
+ var hosts = LoadHosts();
+ hosts.RemoveAll(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ SaveHosts(hosts);
+ NotificationService.Notify("WoL", $"'{name}' 삭제됨");
+ break;
+ }
+
+ case ("copy", string text):
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("WoL", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ break;
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 매직 패킷 전송 ────────────────────────────────────────────────────────
+
+ private static void SendMagicPacket(string mac)
+ {
+ if (!TryParseMac(mac, out var macBytes))
+ {
+ NotificationService.Notify("WoL", $"MAC 형식 오류: {mac}");
+ return;
+ }
+
+ // 매직 패킷: 0xFF × 6 + MAC × 16
+ var packet = new byte[102];
+ for (var i = 0; i < 6; i++)
+ packet[i] = 0xFF;
+ for (var i = 1; i <= 16; i++)
+ Array.Copy(macBytes, 0, packet, i * 6, 6);
+
+ try
+ {
+ using var udp = new UdpClient();
+ udp.EnableBroadcast = true;
+ udp.Send(packet, packet.Length, new IPEndPoint(IPAddress.Broadcast, 9));
+ // 포트 7도 함께 전송 (일부 장치)
+ udp.Send(packet, packet.Length, new IPEndPoint(IPAddress.Broadcast, 7));
+ NotificationService.Notify("WoL", $"매직 패킷 전송됨 → {mac}");
+ }
+ catch (Exception ex)
+ {
+ NotificationService.Notify("WoL", $"전송 실패: {ex.Message}");
+ }
+ }
+
+ // ── 파싱·저장 헬퍼 ────────────────────────────────────────────────────────
+
+ private static bool TryParseMac(string s, out byte[] bytes)
+ {
+ bytes = [];
+ s = s.Trim().Replace(":", "").Replace("-", "").Replace(".", "");
+ if (s.Length != 12) return false;
+ try
+ {
+ bytes = Enumerable.Range(0, 6)
+ .Select(i => Convert.ToByte(s.Substring(i * 2, 2), 16))
+ .ToArray();
+ return true;
+ }
+ catch { return false; }
+ }
+
+ private static LauncherItem MakeHostItem(WolHost h) =>
+ new(h.Name, h.Mac, null, ("wake", h.Mac), Symbol: "\uE823");
+
+ // ── 영속 스토리지 ─────────────────────────────────────────────────────────
+
+ private class WolHost
+ {
+ [JsonPropertyName("name")] public string Name { get; set; } = "";
+ [JsonPropertyName("mac")] public string Mac { get; set; } = "";
+ }
+
+ private static List LoadHosts()
+ {
+ try
+ {
+ if (!System.IO.File.Exists(StorePath)) return new();
+ var json = System.IO.File.ReadAllText(StorePath);
+ return JsonSerializer.Deserialize>(json) ?? new();
+ }
+ catch { return new(); }
+ }
+
+ private static void SaveHosts(List hosts)
+ {
+ try
+ {
+ System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(StorePath)!);
+ System.IO.File.WriteAllText(StorePath,
+ JsonSerializer.Serialize(hosts, new JsonSerializerOptions { WriteIndented = true }));
+ }
+ catch { /* 비핵심 */ }
+ }
+}