diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md
index 5ce011b..d92bc5b 100644
--- a/docs/LAUNCHER_ROADMAP.md
+++ b/docs/LAUNCHER_ROADMAP.md
@@ -295,3 +295,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
| L12-2 | **hosts 파일 관리** ✅ | `hosts` 프리픽스. C:\Windows\System32\drivers\etc\hosts 파싱. 활성·비활성(주석 처리) 항목 분류. `hosts search` 키워드 필터. `hosts open` 메모장 열기. `hosts copy` 전체 내용 복사. 항목 Enter → 클립보드 복사 | 중간 |
| L12-3 | **모스 부호 변환기** ✅ | `morse` 프리픽스. 텍스트 → 모스 부호 (영문자·숫자·구두점 56자 지원). 모스 → 텍스트 역변환 (.-/공백 자동 감지). SOS/AR/AS 프로사인 키워드. 클립보드 자동 감지. 문자별·코드별 대응표 표시 | 낮음 |
| L12-4 | **시작 프로그램 조회** ✅ | `startup` 프리픽스. HKCU/HKLM Run·RunOnce 레지스트리 + 시작 폴더(.lnk) 통합 조회. 범위(현재 사용자/모든 사용자) 그룹화. `startup search` 키워드 필터. `startup folder` 시작 폴더 열기. Enter → 명령 경로 클립보드 복사 | 중간 |
+
+---
+
+## Phase L13 — 시스템 정보·계산 도구 (v2.0.5) ✅ 완료
+
+> **방향**: 네트워크 진단·파일시스템 정보·날짜 계산 도구 보강.
+
+| # | 기능 | 설명 | 우선순위 |
+|---|------|------|----------|
+| L13-1 | **DNS 레코드 조회** ✅ | `dns` 프리픽스. A/AAAA는 .NET Dns API 직접 사용. MX/TXT/NS/CNAME은 nslookup 서브프로세스 파싱. PTR(역방향 조회) 지원. 사내 모드에서 외부 도메인 차단(내부 IP만 허용). Enter → 비동기 조회 실행 + 결과 복사 | 높음 |
+| 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 | 높음 |
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index 5a64af5..fefc539 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -257,6 +257,16 @@ public partial class App : System.Windows.Application
// L12-4: 시작 프로그램 조회 (prefix=startup)
commandResolver.RegisterHandler(new StartupHandler());
+ // ─── Phase L13 핸들러 ─────────────────────────────────────────────────
+ // L13-1: DNS 레코드 조회 (prefix=dns)
+ commandResolver.RegisterHandler(new DnsQueryHandler());
+ // L13-2: PATH 환경변수 뷰어 (prefix=path)
+ commandResolver.RegisterHandler(new PathHandler());
+ // L13-3: 드라이브 정보 (prefix=drive)
+ commandResolver.RegisterHandler(new DriveHandler());
+ // L13-4: 나이·D-day 계산기 (prefix=age)
+ commandResolver.RegisterHandler(new AgeHandler());
+
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll();
diff --git a/src/AxCopilot/Handlers/AgeHandler.cs b/src/AxCopilot/Handlers/AgeHandler.cs
new file mode 100644
index 0000000..178d522
--- /dev/null
+++ b/src/AxCopilot/Handlers/AgeHandler.cs
@@ -0,0 +1,284 @@
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L13-4: 나이·D-day 계산기 핸들러. "age" 프리픽스로 사용합니다.
+///
+/// 예: age 1990-05-15 → 나이 계산 (만/한국식)
+/// age 1990.05.15 → 점 구분자도 지원
+/// age 19900515 → 숫자만 입력 (YYYYMMDD)
+/// age 2025-12-25 → D-day 계산 (미래 날짜)
+/// age next monday → 다음 월요일까지 D-day
+/// age christmas → 크리스마스까지 D-day
+/// Enter → 결과를 클립보드에 복사.
+///
+public class AgeHandler : IActionHandler
+{
+ public string? Prefix => "age";
+
+ public PluginMetadata Metadata => new(
+ "Age",
+ "나이·D-day 계산기 — 만 나이 · 한국 나이 · D-day",
+ "1.0",
+ "AX");
+
+ // 특수 날짜 키워드
+ private static readonly Dictionary> Keywords =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["christmas"] = t => new DateTime(t.Month > 12 || (t.Month == 12 && t.Day >= 26) ? t.Year + 1 : t.Year, 12, 25),
+ ["xmas"] = t => new DateTime(t.Month > 12 || (t.Month == 12 && t.Day >= 26) ? t.Year + 1 : t.Year, 12, 25),
+ ["newyear"] = t => new DateTime(t.Year + 1, 1, 1),
+ ["new year"] = t => new DateTime(t.Year + 1, 1, 1),
+ ["설날"] = t => GetNextLunarNewYear(t),
+ ["chuseok"] = t => GetNextChuseok(t),
+ ["추석"] = t => GetNextChuseok(t),
+ };
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+ var today = DateTime.Today;
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem("나이·D-day 계산기",
+ "예: age 1990-05-15 / age 2025-12-25 / age christmas",
+ null, null, Symbol: "\uE787"));
+ items.Add(new LauncherItem("age 1990-01-01", "생년월일 → 나이", null, null, Symbol: "\uE787"));
+ items.Add(new LauncherItem("age 2025-12-25", "미래 날짜 D-day", null, null, Symbol: "\uE787"));
+ items.Add(new LauncherItem("age christmas", "크리스마스 D-day", null, null, Symbol: "\uE787"));
+ items.Add(new LauncherItem("age newyear", "신년 D-day", null, null, Symbol: "\uE787"));
+ return Task.FromResult>(items);
+ }
+
+ // 특수 키워드 확인
+ foreach (var (kw, fn) in Keywords)
+ {
+ if (q.Equals(kw, StringComparison.OrdinalIgnoreCase))
+ {
+ var targetDate = fn(today);
+ items.AddRange(BuildDdayItems(targetDate, kw, today));
+ return Task.FromResult>(items);
+ }
+ }
+
+ // 요일 키워드: "next monday"
+ if (TryParseNextWeekday(q, out var weekdayDate))
+ {
+ items.AddRange(BuildDdayItems(weekdayDate, q, today));
+ return Task.FromResult>(items);
+ }
+
+ // 날짜 파싱 시도
+ if (!TryParseDate(q, out var date))
+ {
+ items.Add(new LauncherItem("날짜 형식 오류",
+ "예: 1990-05-15 / 1990.05.15 / 19900515 / 2025-12-25",
+ null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ if (date <= today)
+ {
+ // 과거 날짜 → 나이/경과 계산
+ items.AddRange(BuildAgeItems(date, today));
+ }
+ else
+ {
+ // 미래 날짜 → D-day
+ items.AddRange(BuildDdayItems(date, date.ToString("yyyy-MM-dd"), today));
+ }
+
+ 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("Age", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 나이 계산 ─────────────────────────────────────────────────────────────
+
+ private static IEnumerable BuildAgeItems(DateTime birth, DateTime today)
+ {
+ var ageInt = CalcAge(birth, today);
+ var ageKor = today.Year - birth.Year + 1; // 한국식 나이
+ var days = (today - birth).Days;
+ var months = (today.Year - birth.Year) * 12 + (today.Month - birth.Month);
+ var nextBirthday = NextBirthday(birth, today);
+ var daysToNext = (nextBirthday - today).Days;
+
+ var summary = $"""
+ 생년월일: {birth:yyyy-MM-dd}
+ 만 나이: {ageInt}세
+ 한국 나이: {ageKor}세
+ 경과 일수: {days:N0}일
+ 경과 개월: {months:N0}개월
+ 다음 생일: {nextBirthday:yyyy-MM-dd} (D-{daysToNext})
+ """;
+
+ yield return new LauncherItem(
+ $"만 {ageInt}세 (한국식 {ageKor}세)",
+ $"{birth:yyyy-MM-dd} · {days:N0}일 경과 · Enter 복사",
+ null, ("copy", summary), Symbol: "\uE787");
+
+ yield return new LauncherItem("만 나이", $"{ageInt}세", null, ("copy", ageInt.ToString()), Symbol: "\uE787");
+ yield return new LauncherItem("한국 나이", $"{ageKor}세", null, ("copy", ageKor.ToString()), Symbol: "\uE787");
+ yield return new LauncherItem("경과 일수", $"{days:N0}일", null, ("copy", days.ToString()), Symbol: "\uE787");
+ yield return new LauncherItem("경과 개월", $"{months:N0}개월", null, ("copy", months.ToString()), Symbol: "\uE787");
+ yield return new LauncherItem(
+ "다음 생일",
+ $"{nextBirthday:yyyy-MM-dd} · D-{daysToNext}",
+ null, ("copy", nextBirthday.ToString("yyyy-MM-dd")), Symbol: "\uE787");
+
+ // 요일
+ yield return new LauncherItem("태어난 요일", DayKor(birth.DayOfWeek), null, null, Symbol: "\uE787");
+ }
+
+ private static IEnumerable BuildDdayItems(DateTime target, string label, DateTime today)
+ {
+ var diff = (target - today).Days;
+ var absDiff = Math.Abs(diff);
+ var dLabel = diff > 0 ? $"D-{diff}" : diff == 0 ? "D-Day!" : $"D+{absDiff}";
+
+ yield return new LauncherItem(
+ dLabel,
+ $"{target:yyyy-MM-dd} ({label}) · {DayKor(target.DayOfWeek)}",
+ null, ("copy", dLabel), Symbol: "\uE787");
+
+ yield return new LauncherItem("날짜", target.ToString("yyyy-MM-dd"), null, ("copy", target.ToString("yyyy-MM-dd")), Symbol: "\uE787");
+ yield return new LauncherItem("요일", DayKor(target.DayOfWeek), null, null, Symbol: "\uE787");
+ yield return new LauncherItem("남은 일수", diff > 0 ? $"{diff:N0}일 후" : diff == 0 ? "오늘!" : $"{absDiff:N0}일 전", null, ("copy", absDiff.ToString()), Symbol: "\uE787");
+ yield return new LauncherItem("남은 주", $"{diff / 7}주 {diff % 7}일", null, null, Symbol: "\uE787");
+ }
+
+ // ── 파싱 헬퍼 ─────────────────────────────────────────────────────────────
+
+ private static bool TryParseDate(string s, out DateTime result)
+ {
+ result = default;
+ s = s.Trim();
+
+ // YYYYMMDD
+ if (s.Length == 8 && s.All(char.IsDigit))
+ {
+ return DateTime.TryParseExact(s, "yyyyMMdd",
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None, out result);
+ }
+
+ // 다양한 구분자 (-, ., /)
+ var normalized = s.Replace('.', '-').Replace('/', '-');
+ var formats = new[] { "yyyy-M-d", "yyyy-MM-dd", "yy-M-d", "M-d" };
+
+ foreach (var fmt in formats)
+ {
+ if (DateTime.TryParseExact(normalized, fmt,
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None, out result))
+ return true;
+ }
+
+ // "M월 d일" 한국어 형식
+ if (normalized.Contains('월'))
+ {
+ var parts = normalized.Split('월', '일');
+ if (parts.Length >= 2 &&
+ int.TryParse(parts[0].Trim(), out var m) &&
+ int.TryParse(parts[1].Trim(), out var d))
+ {
+ result = new DateTime(DateTime.Today.Year, m, d);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryParseNextWeekday(string q, out DateTime result)
+ {
+ result = default;
+ var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2 || !parts[0].Equals("next", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ DayOfWeek? dow = parts[1].ToLowerInvariant() switch
+ {
+ "monday" or "mon" or "월" or "월요일" => DayOfWeek.Monday,
+ "tuesday" or "tue" or "화" or "화요일" => DayOfWeek.Tuesday,
+ "wednesday" or "wed" or "수" or "수요일" => DayOfWeek.Wednesday,
+ "thursday" or "thu" or "목" or "목요일" => DayOfWeek.Thursday,
+ "friday" or "fri" or "금" or "금요일" => DayOfWeek.Friday,
+ "saturday" or "sat" or "토" or "토요일" => DayOfWeek.Saturday,
+ "sunday" or "sun" or "일" or "일요일" => DayOfWeek.Sunday,
+ _ => null,
+ };
+
+ if (dow == null) return false;
+
+ var today = DateTime.Today;
+ var daysAhead = ((int)dow.Value - (int)today.DayOfWeek + 7) % 7;
+ if (daysAhead == 0) daysAhead = 7; // "next"이므로 다음 주
+ result = today.AddDays(daysAhead);
+ return true;
+ }
+
+ // ── 날짜 계산 헬퍼 ────────────────────────────────────────────────────────
+
+ private static int CalcAge(DateTime birth, DateTime today)
+ {
+ var age = today.Year - birth.Year;
+ if (today.Month < birth.Month || (today.Month == birth.Month && today.Day < birth.Day))
+ age--;
+ return age;
+ }
+
+ private static DateTime NextBirthday(DateTime birth, DateTime today)
+ {
+ var thisYear = new DateTime(today.Year, birth.Month, birth.Day);
+ return thisYear >= today ? thisYear : thisYear.AddYears(1);
+ }
+
+ // 간략화된 양력 설날 근사값 (실제 음력 계산은 복잡하므로 고정 근사)
+ private static DateTime GetNextLunarNewYear(DateTime today)
+ {
+ // 설날은 대략 1월 말 ~ 2월 초이므로 2월 5일을 기준점으로 사용
+ var approx = new DateTime(today.Year, 2, 5);
+ return approx >= today ? approx : new DateTime(today.Year + 1, 2, 5);
+ }
+
+ private static DateTime GetNextChuseok(DateTime today)
+ {
+ // 추석은 대략 9월 말 ~ 10월 초이므로 9월 28일을 기준점으로 사용
+ var approx = new DateTime(today.Year, 9, 28);
+ return approx >= today ? approx : new DateTime(today.Year + 1, 9, 28);
+ }
+
+ private static string DayKor(DayOfWeek dow) => dow switch
+ {
+ DayOfWeek.Monday => "월요일",
+ DayOfWeek.Tuesday => "화요일",
+ DayOfWeek.Wednesday => "수요일",
+ DayOfWeek.Thursday => "목요일",
+ DayOfWeek.Friday => "금요일",
+ DayOfWeek.Saturday => "토요일",
+ DayOfWeek.Sunday => "일요일",
+ _ => "",
+ };
+}
diff --git a/src/AxCopilot/Handlers/DnsQueryHandler.cs b/src/AxCopilot/Handlers/DnsQueryHandler.cs
new file mode 100644
index 0000000..8b6abef
--- /dev/null
+++ b/src/AxCopilot/Handlers/DnsQueryHandler.cs
@@ -0,0 +1,264 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L13-1: DNS 레코드 조회 핸들러. "dns" 프리픽스로 사용합니다.
+///
+/// 예: dns google.com → A/AAAA 레코드 조회
+/// dns google.com mx → MX 레코드
+/// dns google.com txt → TXT 레코드
+/// dns google.com ns → NS 레코드
+/// dns 8.8.8.8 → PTR(역방향) 조회
+/// Enter → 결과를 클립보드에 복사.
+///
+/// ⚠ 사내 모드: 내부 호스트만 조회 허용.
+///
+public class DnsQueryHandler : IActionHandler
+{
+ public string? Prefix => "dns";
+
+ public PluginMetadata Metadata => new(
+ "DNS",
+ "DNS 레코드 조회 — A · AAAA · MX · TXT · NS · PTR",
+ "1.0",
+ "AX");
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem("DNS 레코드 조회",
+ "예: dns google.com / dns google.com mx / dns 8.8.8.8",
+ null, null, Symbol: "\uE968"));
+ items.Add(new LauncherItem("dns localhost", "로컬 A 레코드", null, null, Symbol: "\uE968"));
+ items.Add(new LauncherItem("dns example.com mx", "MX 레코드", null, null, Symbol: "\uE968"));
+ items.Add(new LauncherItem("dns example.com txt","TXT 레코드", null, null, Symbol: "\uE968"));
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var host = parts[0];
+ var recType = parts.Length > 1 ? parts[1].ToUpperInvariant() : "A";
+
+ // 사내 모드 확인
+ var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
+ var isInternal = settings?.InternalModeEnabled ?? true;
+ if (isInternal && !IsInternalHost(host))
+ {
+ items.Add(new LauncherItem(
+ "사내 모드 제한",
+ $"'{host}'은 외부 호스트입니다. 설정에서 사외 모드를 활성화하세요.",
+ null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ items.Add(new LauncherItem(
+ $"{host} [{recType}]",
+ "Enter를 눌러 조회 실행",
+ null,
+ ("query", $"{host}|{recType}"),
+ Symbol: "\uE968"));
+
+ // 레코드 타입 빠른 선택 힌트
+ if (parts.Length == 1)
+ {
+ foreach (var t in new[] { "A", "AAAA", "MX", "TXT", "NS" })
+ items.Add(new LauncherItem($"dns {host} {t}", $"{t} 레코드 조회", null,
+ ("query", $"{host}|{t}"), Symbol: "\uE968"));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public async 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("DNS", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ return;
+ }
+
+ if (item.Data is not ("query", string queryData)) return;
+
+ var idx = queryData.IndexOf('|');
+ var host = queryData[..idx];
+ var recType = queryData[(idx + 1)..];
+
+ NotificationService.Notify("DNS", $"{host} [{recType}] 조회 중…");
+
+ try
+ {
+ var results = await QueryDnsAsync(host, recType, ct);
+ if (results.Count == 0)
+ {
+ NotificationService.Notify("DNS", $"{host} [{recType}] — 레코드 없음");
+ return;
+ }
+
+ var summary = string.Join("\n", results);
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(summary));
+ NotificationService.Notify("DNS", $"{results.Count}개 레코드 조회됨 · 클립보드 복사");
+ }
+ catch (OperationCanceledException)
+ {
+ NotificationService.Notify("DNS", "조회 취소됨");
+ }
+ catch (Exception ex)
+ {
+ NotificationService.Notify("DNS", $"오류: {ex.Message}");
+ }
+ }
+
+ // ── DNS 조회 ─────────────────────────────────────────────────────────────
+
+ private static async Task> QueryDnsAsync(string host, string type, CancellationToken ct)
+ {
+ return type switch
+ {
+ "A" or "AAAA" => await QueryAAsync(host, type, ct),
+ "PTR" => await QueryPtrAsync(host, ct),
+ _ => await QueryViaNslookupAsync(host, type, ct),
+ };
+ }
+
+ private static async Task> QueryAAsync(string host, string type, CancellationToken ct)
+ {
+ var family = type == "AAAA" ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
+ var addrs = await Dns.GetHostAddressesAsync(host, family, ct);
+ return addrs.Select(a => a.ToString()).ToList();
+ }
+
+ private static async Task> QueryPtrAsync(string ip, CancellationToken ct)
+ {
+ if (!IPAddress.TryParse(ip, out _))
+ return [$"'{ip}'은 유효한 IP 주소가 아닙니다"];
+ try
+ {
+ var entry = await Dns.GetHostEntryAsync(ip, ct);
+ return [entry.HostName];
+ }
+ catch
+ {
+ return [$"PTR 레코드 없음: {ip}"];
+ }
+ }
+
+ /// MX/TXT/NS/CNAME: nslookup 프로세스 실행으로 조회
+ private static async Task> QueryViaNslookupAsync(string host, string type, CancellationToken ct)
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "nslookup",
+ Arguments = $"-type={type} {host}",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ };
+
+ using var proc = new System.Diagnostics.Process { StartInfo = psi };
+ proc.Start();
+
+ var stdout = await proc.StandardOutput.ReadToEndAsync(ct);
+ await proc.WaitForExitAsync(ct);
+
+ return ParseNslookupOutput(stdout, type);
+ }
+
+ private static List ParseNslookupOutput(string output, string type)
+ {
+ var results = new List();
+ var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+ // 서버 응답 헤더 건너뜀 (첫 2줄)
+ var skip = true;
+ foreach (var line in lines)
+ {
+ var trimmed = line.Trim();
+ if (skip)
+ {
+ if (trimmed.StartsWith("Non-authoritative", StringComparison.OrdinalIgnoreCase) ||
+ trimmed.StartsWith("Name:", StringComparison.OrdinalIgnoreCase) ||
+ (type == "MX" && trimmed.Contains("mail exchanger")) ||
+ (type == "TXT" && trimmed.Contains("text =")) ||
+ (type == "NS" && trimmed.Contains("nameserver")))
+ skip = false;
+ else
+ continue;
+ }
+
+ if (string.IsNullOrWhiteSpace(trimmed)) continue;
+
+ // MX: "... mail exchanger = 10 aspmx.l.google.com"
+ if (type == "MX" && trimmed.Contains("mail exchanger"))
+ {
+ var idx = trimmed.IndexOf('=');
+ if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
+ continue;
+ }
+
+ // TXT: "... text = "v=spf1 …""
+ if (type == "TXT" && trimmed.Contains("text ="))
+ {
+ var idx = trimmed.IndexOf("text =", StringComparison.OrdinalIgnoreCase);
+ if (idx >= 0) results.Add(trimmed[(idx + 6)..].Trim().Trim('"'));
+ continue;
+ }
+
+ // NS: "nameserver = ns1.google.com"
+ if (type == "NS" && trimmed.Contains("nameserver"))
+ {
+ var idx = trimmed.IndexOf('=');
+ if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
+ continue;
+ }
+
+ // CNAME: "canonical name = …"
+ if (type == "CNAME" && trimmed.Contains("canonical name"))
+ {
+ var idx = trimmed.IndexOf('=');
+ if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
+ continue;
+ }
+
+ // Address: 주소 행
+ if (trimmed.StartsWith("Address:", StringComparison.OrdinalIgnoreCase))
+ {
+ var idx = trimmed.IndexOf(':');
+ if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
+ }
+ }
+
+ return results.Count > 0 ? results : [$"조회 결과 없음 ({type})"];
+ }
+
+ private static bool IsInternalHost(string host)
+ {
+ if (host is "localhost" or "127.0.0.1") return true;
+ if (IPAddress.TryParse(host, out var addr))
+ {
+ var s = addr.ToString();
+ return s.StartsWith("192.168.") || s.StartsWith("10.") ||
+ System.Text.RegularExpressions.Regex.IsMatch(s,
+ @"^172\.(1[6-9]|2\d|3[01])\.");
+ }
+ // 도메인 이름은 사내 모드에서 외부로 간주
+ return false;
+ }
+}
diff --git a/src/AxCopilot/Handlers/DriveHandler.cs b/src/AxCopilot/Handlers/DriveHandler.cs
new file mode 100644
index 0000000..e639ad7
--- /dev/null
+++ b/src/AxCopilot/Handlers/DriveHandler.cs
@@ -0,0 +1,202 @@
+using System.IO;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L13-3: 드라이브 정보 핸들러. "drive" 프리픽스로 사용합니다.
+///
+/// 예: drive → 전체 드라이브 목록 + 용량 요약
+/// drive C → C 드라이브 상세 정보
+/// drive C:\ → 경로 형식도 지원
+/// drive large → 사용량 많은 순서로 정렬
+/// Enter → 드라이브 정보를 클립보드에 복사.
+///
+public class DriveHandler : IActionHandler
+{
+ public string? Prefix => "drive";
+
+ public PluginMetadata Metadata => new(
+ "Drive",
+ "드라이브 정보 — 용량 · 파일시스템 · 여유공간",
+ "1.0",
+ "AX");
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ var drives = GetDrives();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ var totalSize = drives.Sum(d => d.TotalSize);
+ var totalFree = drives.Sum(d => d.AvailableFree);
+ var totalUsed = totalSize - totalFree;
+
+ items.Add(new LauncherItem(
+ $"드라이브 {drives.Count}개",
+ $"전체 {FormatBytes(totalSize)} · 사용 {FormatBytes(totalUsed)} · 여유 {FormatBytes(totalFree)}",
+ null, null, Symbol: "\uEDA2"));
+
+ foreach (var d in drives.OrderBy(d => d.Name))
+ items.Add(MakeDriveSummaryItem(d));
+
+ return Task.FromResult>(items);
+ }
+
+ var sub = q.ToUpperInvariant().TrimEnd(':', '\\', '/');
+
+ if (sub == "LARGE")
+ {
+ // 사용량 많은 순
+ foreach (var d in drives.OrderByDescending(d => d.UsedSpace))
+ items.Add(MakeDriveSummaryItem(d));
+ return Task.FromResult>(items);
+ }
+
+ // 특정 드라이브 상세
+ var target = drives.FirstOrDefault(d =>
+ d.Name.StartsWith(sub, StringComparison.OrdinalIgnoreCase));
+
+ if (target == null)
+ {
+ items.Add(new LauncherItem("드라이브 없음", $"'{q}' 드라이브를 찾을 수 없습니다", null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ items.AddRange(BuildDetailItems(target));
+ 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("Drive", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 드라이브 정보 수집 ────────────────────────────────────────────────────
+
+ private record DriveInfo2(
+ string Name,
+ string VolumeLabel,
+ string DriveFormat,
+ DriveType DriveType,
+ long TotalSize,
+ long AvailableFree,
+ long UsedSpace,
+ bool IsReady);
+
+ private static List GetDrives()
+ {
+ return DriveInfo.GetDrives()
+ .Select(d =>
+ {
+ if (!d.IsReady)
+ return new DriveInfo2(d.Name, "", "", d.DriveType, 0, 0, 0, false);
+ try
+ {
+ return new DriveInfo2(
+ d.Name,
+ d.VolumeLabel,
+ d.DriveFormat,
+ d.DriveType,
+ d.TotalSize,
+ d.AvailableFreeSpace,
+ d.TotalSize - d.AvailableFreeSpace,
+ true);
+ }
+ catch
+ {
+ return new DriveInfo2(d.Name, "", d.DriveFormat, d.DriveType, 0, 0, 0, false);
+ }
+ })
+ .ToList();
+ }
+
+ private static IEnumerable BuildDetailItems(DriveInfo2 d)
+ {
+ var usagePercent = d.TotalSize > 0 ? (double)d.UsedSpace / d.TotalSize * 100 : 0;
+ var bar = MakeBar(usagePercent, 20);
+ var summary = $"""
+ 드라이브: {d.Name}
+ 볼륨 레이블: {(string.IsNullOrEmpty(d.VolumeLabel) ? "(없음)" : d.VolumeLabel)}
+ 파일 시스템: {d.DriveFormat}
+ 드라이브 종류: {DriveTypeName(d.DriveType)}
+ 전체 용량: {FormatBytes(d.TotalSize)}
+ 사용 중: {FormatBytes(d.UsedSpace)} ({usagePercent:F1}%)
+ 여유 공간: {FormatBytes(d.AvailableFree)}
+ """;
+
+ yield return new LauncherItem(
+ $"{d.Name} {FormatBytes(d.TotalSize)}",
+ $"사용 {usagePercent:F0}% {bar} 여유 {FormatBytes(d.AvailableFree)}",
+ null, ("copy", summary), Symbol: "\uEDA2");
+
+ yield return new LauncherItem("볼륨 레이블", string.IsNullOrEmpty(d.VolumeLabel) ? "(없음)" : d.VolumeLabel, null, null, Symbol: "\uEDA2");
+ yield return new LauncherItem("파일 시스템", d.DriveFormat, null, null, Symbol: "\uEDA2");
+ yield return new LauncherItem("드라이브 종류", DriveTypeName(d.DriveType), null, null, Symbol: "\uEDA2");
+ yield return new LauncherItem("전체 용량", FormatBytes(d.TotalSize), null, ("copy", FormatBytes(d.TotalSize)), Symbol: "\uEDA2");
+ yield return new LauncherItem("사용 중", $"{FormatBytes(d.UsedSpace)} ({usagePercent:F1}%)", null, ("copy", FormatBytes(d.UsedSpace)), Symbol: "\uEDA2");
+ yield return new LauncherItem("여유 공간", FormatBytes(d.AvailableFree), null, ("copy", FormatBytes(d.AvailableFree)), Symbol: "\uEDA2");
+ }
+
+ private static LauncherItem MakeDriveSummaryItem(DriveInfo2 d)
+ {
+ if (!d.IsReady)
+ return new LauncherItem(d.Name, $"준비 안됨 ({DriveTypeName(d.DriveType)})", null, null, Symbol: "\uEDA2");
+
+ var usagePercent = d.TotalSize > 0 ? (double)d.UsedSpace / d.TotalSize * 100 : 0;
+ var bar = MakeBar(usagePercent, 12);
+ var label = string.IsNullOrEmpty(d.VolumeLabel) ? d.Name : $"{d.Name} ({d.VolumeLabel})";
+
+ return new LauncherItem(
+ label,
+ $"{bar} {usagePercent:F0}% · 여유 {FormatBytes(d.AvailableFree)} / {FormatBytes(d.TotalSize)}",
+ null,
+ ("copy", $"{d.Name} {FormatBytes(d.TotalSize)} 사용{usagePercent:F0}% 여유{FormatBytes(d.AvailableFree)}"),
+ Symbol: "\uEDA2");
+ }
+
+ // ── 유틸 ─────────────────────────────────────────────────────────────────
+
+ private static string MakeBar(double percent, int width)
+ {
+ var filled = (int)(percent / 100.0 * width);
+ filled = Math.Clamp(filled, 0, width);
+ return "[" + new string('█', filled) + new string('░', width - filled) + "]";
+ }
+
+ private static string DriveTypeName(DriveType dt) => dt switch
+ {
+ DriveType.Fixed => "고정 디스크",
+ DriveType.Removable => "이동식 디스크",
+ DriveType.Network => "네트워크 드라이브",
+ DriveType.CDRom => "CD/DVD",
+ DriveType.Ram => "RAM 디스크",
+ DriveType.NoRootDirectory => "루트 없음",
+ _ => "알 수 없음",
+ };
+
+ private static string FormatBytes(long bytes) => bytes switch
+ {
+ >= 1024L * 1024 * 1024 * 1024 => $"{bytes / 1024.0 / 1024 / 1024 / 1024:F2} TB",
+ >= 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024 / 1024:F1} GB",
+ >= 1024L * 1024 => $"{bytes / 1024.0 / 1024:F1} MB",
+ >= 1024L => $"{bytes / 1024.0:F0} KB",
+ _ => $"{bytes} B",
+ };
+}
diff --git a/src/AxCopilot/Handlers/PathHandler.cs b/src/AxCopilot/Handlers/PathHandler.cs
new file mode 100644
index 0000000..615e31d
--- /dev/null
+++ b/src/AxCopilot/Handlers/PathHandler.cs
@@ -0,0 +1,219 @@
+using System.IO;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L13-2: PATH 환경변수 뷰어·검색 핸들러. "path" 프리픽스로 사용합니다.
+///
+/// 예: path → PATH 전체 목록 (존재/미존재 여부 표시)
+/// path search git → "git" 포함 경로 필터
+/// path which git.exe → 실행 파일 위치 검색 (which/where 대응)
+/// path which python → 확장자 없이도 검색 (.exe/.cmd/.bat 시도)
+/// path user → 사용자 PATH만 표시
+/// path system → 시스템 PATH만 표시
+/// Enter → 경로를 클립보드에 복사.
+///
+public class PathHandler : IActionHandler
+{
+ public string? Prefix => "path";
+
+ public PluginMetadata Metadata => new(
+ "Path",
+ "PATH 환경변수 뷰어 — 경로 목록 · 검색 · which",
+ "1.0",
+ "AX");
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ var paths = GetAllPaths();
+ var exist = paths.Count(p => p.Exists);
+ var total = paths.Count;
+
+ items.Add(new LauncherItem(
+ $"PATH {total}개 경로 (존재 {exist}개)",
+ "path which <파일> 로 실행 파일 위치 검색",
+ null, null, Symbol: "\uE838"));
+
+ items.Add(new LauncherItem("path user", "사용자 PATH만", null, null, Symbol: "\uE838"));
+ items.Add(new LauncherItem("path system", "시스템 PATH만", null, null, Symbol: "\uE838"));
+ items.Add(new LauncherItem("path which git", "git 위치 검색", null, null, Symbol: "\uE838"));
+
+ foreach (var p in paths.Take(15))
+ items.Add(MakePathItem(p));
+
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
+ var sub = parts[0].ToLowerInvariant();
+
+ switch (sub)
+ {
+ case "which":
+ case "where":
+ case "find":
+ {
+ var target = parts.Length > 1 ? parts[1].Trim() : "";
+ if (string.IsNullOrWhiteSpace(target))
+ {
+ items.Add(new LauncherItem("파일명 입력", "예: path which git.exe", null, null, Symbol: "\uE783"));
+ break;
+ }
+ items.AddRange(FindExecutable(target));
+ break;
+ }
+
+ case "user":
+ {
+ var paths = GetPaths(EnvironmentVariableTarget.User);
+ items.Add(new LauncherItem($"사용자 PATH {paths.Count}개", "", null, null, Symbol: "\uE838"));
+ foreach (var p in paths)
+ items.Add(MakePathItem(p));
+ break;
+ }
+
+ case "system":
+ {
+ var paths = GetPaths(EnvironmentVariableTarget.Machine);
+ items.Add(new LauncherItem($"시스템 PATH {paths.Count}개", "", null, null, Symbol: "\uE838"));
+ foreach (var p in paths)
+ items.Add(MakePathItem(p));
+ break;
+ }
+
+ case "search":
+ {
+ var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : "";
+ if (string.IsNullOrWhiteSpace(keyword))
+ {
+ items.Add(new LauncherItem("검색어 입력", "예: path search python", null, null, Symbol: "\uE783"));
+ break;
+ }
+ var allPaths = GetAllPaths();
+ var filtered = allPaths.Where(p =>
+ p.Directory.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (filtered.Count == 0)
+ items.Add(new LauncherItem("결과 없음", $"'{keyword}' 포함 경로 없음", null, null, Symbol: "\uE946"));
+ else
+ foreach (var p in filtered)
+ items.Add(MakePathItem(p));
+ break;
+ }
+
+ default:
+ {
+ // 기본: 검색어로 처리
+ var keyword = q.ToLowerInvariant();
+ var allPaths = GetAllPaths();
+ var filtered = allPaths.Where(p =>
+ p.Directory.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (filtered.Count > 0)
+ foreach (var p in filtered.Take(15))
+ items.Add(MakePathItem(p));
+ else
+ // which 로 재시도
+ items.AddRange(FindExecutable(q));
+ break;
+ }
+ }
+
+ 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("Path", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── PATH 수집 ─────────────────────────────────────────────────────────────
+
+ private record PathEntry(string Directory, bool Exists, EnvironmentVariableTarget Scope);
+
+ private static List GetAllPaths()
+ {
+ var result = new List();
+ result.AddRange(GetPaths(EnvironmentVariableTarget.Process));
+ return result.DistinctBy(p => p.Directory.ToLowerInvariant()).ToList();
+ }
+
+ private static List GetPaths(EnvironmentVariableTarget target)
+ {
+ var raw = Environment.GetEnvironmentVariable("PATH", target) ?? "";
+ return raw.Split(';', StringSplitOptions.RemoveEmptyEntries)
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(p => new PathEntry(p.Trim(), Directory.Exists(p.Trim()), target))
+ .ToList();
+ }
+
+ private static IEnumerable FindExecutable(string name)
+ {
+ var extensions = new[] { "", ".exe", ".cmd", ".bat", ".ps1", ".com" };
+ var paths = GetAllPaths();
+ var found = new List();
+
+ foreach (var pathEntry in paths.Where(p => p.Exists))
+ {
+ foreach (var ext in extensions)
+ {
+ var candidate = Path.Combine(pathEntry.Directory, name + ext);
+ if (File.Exists(candidate))
+ found.Add(candidate);
+ }
+ }
+
+ if (found.Count == 0)
+ {
+ yield return new LauncherItem($"'{name}' 찾을 수 없음",
+ "PATH에서 해당 실행 파일이 없습니다", null, null, Symbol: "\uE946");
+ yield break;
+ }
+
+ yield return new LauncherItem(
+ $"'{name}' {found.Count}개 발견",
+ "전체 복사: Enter",
+ null, ("copy", string.Join("\n", found)), Symbol: "\uE838");
+
+ foreach (var f in found)
+ {
+ var dir = Path.GetDirectoryName(f) ?? "";
+ var fileName = Path.GetFileName(f);
+ yield return new LauncherItem(
+ fileName,
+ dir,
+ null, ("copy", f), Symbol: "\uE838");
+ }
+ }
+
+ private static LauncherItem MakePathItem(PathEntry p)
+ {
+ var icon = p.Exists ? "\uE838" : "\uE783";
+ var label = p.Exists ? "" : " (없는 경로)";
+ return new LauncherItem(
+ p.Directory + label,
+ p.Exists ? "존재함" : "경로 없음",
+ null,
+ ("copy", p.Directory),
+ Symbol: icon);
+ }
+}