[Phase L13] 시스템 정보·계산 도구 핸들러 4종 추가

DnsQueryHandler.cs (신규, ~200줄, prefix=dns):
- A/AAAA: .NET Dns.GetHostAddressesAsync() 직접 호출
- MX/TXT/NS/CNAME: nslookup 서브프로세스 + 출력 파싱
- PTR: Dns.GetHostEntryAsync()로 역방향 조회
- 사내 모드: 내부 IP(192.168/10/172.16-31) 허용, 외부 차단
- Enter → 비동기 실행, 결과 클립보드 복사 + 알림

PathHandler.cs (신규, ~180줄, prefix=path):
- Environment.GetEnvironmentVariable("PATH", Process/User/Machine)
- Directory.Exists 기반 경로 존재 여부 아이콘 구분
- which: .exe/.cmd/.bat/.ps1/.com 확장자 순서 탐색
- DistinctBy로 중복 경로 제거 (대소문자 무시)

DriveHandler.cs (신규, ~170줄, prefix=drive):
- DriveInfo.GetDrives() + IsReady 체크 + try/catch 방어
- █░ 시각적 사용량 바 (MakeBar 12~20칸 가변)
- TB/GB/MB/KB 자동 단위 포맷
- large 서브커맨드: UsedSpace 내림차순 정렬

AgeHandler.cs (신규, ~230줄, prefix=age):
- YYYYMMDD / YYYY-MM-DD / YYYY.MM.DD 형식 파싱
- 만 나이(생일 미경과 시 -1) + 한국식(연도 차이 +1)
- NextBirthday: 올해 생일 지났으면 내년으로 계산
- christmas/newyear 특수 키워드 Dictionary<string, Func>
- "next monday" 형식 다음 요일 D-day 파싱

App.xaml.cs: 4개 핸들러 Phase L13 블록 등록
docs/LAUNCHER_ROADMAP.md: Phase L13 완료 섹션 추가
빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 14:50:39 +09:00
parent 315848f9bc
commit e4e5bf7a7a
6 changed files with 992 additions and 0 deletions

View File

@@ -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 | 높음 |

View File

@@ -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();

View File

@@ -0,0 +1,284 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 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 → 결과를 클립보드에 복사.
/// </summary>
public class AgeHandler : IActionHandler
{
public string? Prefix => "age";
public PluginMetadata Metadata => new(
"Age",
"나이·D-day 계산기 — 만 나이 · 한국 나이 · D-day",
"1.0",
"AX");
// 특수 날짜 키워드
private static readonly Dictionary<string, Func<DateTime, DateTime>> 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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
}
// 요일 키워드: "next monday"
if (TryParseNextWeekday(q, out var weekdayDate))
{
items.AddRange(BuildDdayItems(weekdayDate, q, today));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<LauncherItem> 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<LauncherItem> 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 => "일요일",
_ => "",
};
}

View File

@@ -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;
/// <summary>
/// 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 → 결과를 클립보드에 복사.
///
/// ⚠ 사내 모드: 내부 호스트만 조회 허용.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<List<string>> 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<List<string>> 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<List<string>> 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}"];
}
}
/// <summary>MX/TXT/NS/CNAME: nslookup 프로세스 실행으로 조회</summary>
private static async Task<List<string>> 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<string> ParseNslookupOutput(string output, string type)
{
var results = new List<string>();
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;
}
}

View File

@@ -0,0 +1,202 @@
using System.IO;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L13-3: 드라이브 정보 핸들러. "drive" 프리픽스로 사용합니다.
///
/// 예: drive → 전체 드라이브 목록 + 용량 요약
/// drive C → C 드라이브 상세 정보
/// drive C:\ → 경로 형식도 지원
/// drive large → 사용량 많은 순서로 정렬
/// Enter → 드라이브 정보를 클립보드에 복사.
/// </summary>
public class DriveHandler : IActionHandler
{
public string? Prefix => "drive";
public PluginMetadata Metadata => new(
"Drive",
"드라이브 정보 — 용량 · 파일시스템 · 여유공간",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
items.AddRange(BuildDetailItems(target));
return Task.FromResult<IEnumerable<LauncherItem>>(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<DriveInfo2> 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<LauncherItem> 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",
};
}

View File

@@ -0,0 +1,219 @@
using System.IO;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 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 → 경로를 클립보드에 복사.
/// </summary>
public class PathHandler : IActionHandler
{
public string? Prefix => "path";
public PluginMetadata Metadata => new(
"Path",
"PATH 환경변수 뷰어 — 경로 목록 · 검색 · which",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<PathEntry> GetAllPaths()
{
var result = new List<PathEntry>();
result.AddRange(GetPaths(EnvironmentVariableTarget.Process));
return result.DistinctBy(p => p.Directory.ToLowerInvariant()).ToList();
}
private static List<PathEntry> 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<LauncherItem> FindExecutable(string name)
{
var extensions = new[] { "", ".exe", ".cmd", ".bat", ".ps1", ".com" };
var paths = GetAllPaths();
var found = new List<string>();
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);
}
}