diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md
index f561aa2..79ac318 100644
--- a/docs/LAUNCHER_ROADMAP.md
+++ b/docs/LAUNCHER_ROADMAP.md
@@ -217,3 +217,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
| Everything 미연동 | ✓ EverythingHandler 구현 (`es` 프리픽스, P/Invoke, graceful fallback) |
| 플러그인 설치 수동 | ✓ PluginHost.InstallFromZip() 로컬 zip 자동 추출·등록 (URL 제외: 사내 보안) |
| 이미지 클립보드 미지원 | ✓ PNG→Base64 DPAPI 암호화 저장/복원, 앱 재시작 후 이미지 히스토리 유지 |
+
+---
+
+## Phase L7 — 런처 개발자·글로벌 도구 확장 (v2.0.1) ✅ 완료
+
+> **방향**: 개발자가 런처를 벗어나지 않고 Git 조회·정규식 테스트·시간대 변환·네트워크 진단을 수행.
+
+| # | 기능 | 설명 | 우선순위 |
+|---|------|------|----------|
+| L7-1 | **Git 빠른 조회** ✅ | `git` 프리픽스. `git status/log/branch/stash/diff/pull` 서브커맨드. 현재 AX Agent 작업 폴더에서 `.git` 루트 자동 탐색. 비동기 Process 실행 → 출력 클립보드 복사. `pull`은 별도 PowerShell 터미널로 실행. 브랜치명·변경 파일 수 실시간 요약 표시 | 높음 |
+| L7-2 | **정규식 테스터** ✅ | `re` 프리픽스. 클립보드 텍스트에 패턴 적용 → 매치 목록 표시. `/old/new/` 치환 모드. `flags:im` 플래그 지정(대소문자·멀티라인·단일라인). `re patterns` 서브커맨드로 이메일·URL·전화번호·날짜·IP·UUID 등 14종 공통 패턴 라이브러리. 매치 결과·치환 결과 클립보드 복사 | 높음 |
+| L7-3 | **시간대 변환기** ✅ | `tz` 프리픽스. 15개 주요 도시(서울·도쿄·베이징·뉴욕·LA·런던·파리·시드니 등) 현재 시각 실시간 표시. `tz <도시>` 단일 조회 + 서울 기준 시차 표시. `tz 14:00 to la` 시각 변환. `tz meeting 09:00` 미팅 시각 전 도시 동시 표시. Enter → 클립보드 복사 | 중간 |
+| L7-4 | **네트워크 진단** ✅ | `net` 프리픽스. 로컬 어댑터 IP/MAC 즉시 표시. `net ping <호스트>` 4회 핑 테스트(사내 모드: 내부 호스트만). `net dns <도메인>` DNS A 레코드 조회(사외 모드에서 외부 도메인). `net ip`/`net adapter` 상세 정보. 기존 `port` 핸들러(포트·프로세스 조회)와 역할 분리 | 중간 |
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index 477dde7..b100401 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -197,6 +197,16 @@ public partial class App : System.Windows.Application
// Phase L6-3: 컨텍스트 감지 자동완성 (prefix=ctx)
commandResolver.RegisterHandler(new ContextHandler());
+ // ─── Phase L7 핸들러 ──────────────────────────────────────────────────
+ // L7-1: Git 빠른 조회 (prefix=git)
+ commandResolver.RegisterHandler(new GitHandler());
+ // L7-2: 정규식 테스터 (prefix=re)
+ commandResolver.RegisterHandler(new RegexHandler());
+ // L7-3: 시간대 변환기 (prefix=tz)
+ commandResolver.RegisterHandler(new TimeZoneHandler());
+ // L7-4: 네트워크 진단 (prefix=net)
+ commandResolver.RegisterHandler(new NetDiagHandler());
+
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll();
diff --git a/src/AxCopilot/Handlers/GitHandler.cs b/src/AxCopilot/Handlers/GitHandler.cs
new file mode 100644
index 0000000..5c0ea41
--- /dev/null
+++ b/src/AxCopilot/Handlers/GitHandler.cs
@@ -0,0 +1,251 @@
+using System.Diagnostics;
+using System.Text;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L7-1: Git 빠른 조회 핸들러. "git" 프리픽스로 사용합니다.
+///
+/// 예: git → 최근 작업 폴더(또는 현재 앱 폴더)의 git 상태 요약
+/// git status → git status --short 출력
+/// git log → 최근 커밋 10개
+/// git branch → 브랜치 목록 (현재 브랜치 강조)
+/// git stash → stash 목록
+/// git diff → git diff --stat 요약
+/// git pull → git pull 실행
+/// Enter → 결과를 클립보드에 복사.
+///
+public class GitHandler : IActionHandler
+{
+ public string? Prefix => "git";
+
+ public PluginMetadata Metadata => new(
+ "Git",
+ "Git 빠른 조회 — git status · log · branch · stash",
+ "1.0",
+ "AX");
+
+ // ── 서브커맨드 정의 ──────────────────────────────────────────────────────
+ private static readonly (string Sub, string Args, string Label, string Icon)[] SubCommands =
+ [
+ ("status", "status --short", "변경 파일 목록", "\uE9F5"),
+ ("log", "log --oneline -10", "최근 커밋 10개", "\uE81C"),
+ ("branch", "branch -a", "브랜치 목록", "\uE8FB"),
+ ("stash", "stash list", "Stash 목록", "\uE7C4"),
+ ("diff", "diff --stat", "변경 통계", "\uE8A1"),
+ ("pull", "pull", "git pull 실행", "\uE8AF"),
+ ];
+
+ public async Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim().ToLowerInvariant();
+ var items = new List();
+
+ // 작업 디렉토리 결정
+ var workDir = FindGitRoot();
+
+ if (string.IsNullOrEmpty(q))
+ {
+ // 빠른 상태 요약
+ if (!string.IsNullOrEmpty(workDir))
+ {
+ var branch = await RunGitAsync("branch --show-current", workDir, ct);
+ var statusOut = await RunGitAsync("status --short", workDir, ct);
+ var changed = statusOut?.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length ?? 0;
+
+ items.Add(new LauncherItem(
+ $"Git: {branch?.Trim() ?? "unknown"}",
+ changed == 0 ? "변경 없음" : $"{changed}개 파일 변경됨 · {System.IO.Path.GetFileName(workDir)}",
+ null,
+ ("status_summary", workDir),
+ Symbol: "\uE9F5"));
+ }
+ else
+ {
+ items.Add(new LauncherItem("Git 저장소 없음",
+ "현재 작업 폴더에 .git 디렉토리가 없습니다", null, null,
+ Symbol: "\uE783"));
+ }
+
+ // 서브커맨드 목록
+ foreach (var (sub, args, label, icon) in SubCommands)
+ {
+ items.Add(new LauncherItem(
+ $"git {sub}",
+ label,
+ null,
+ (sub, workDir ?? ""),
+ Symbol: icon));
+ }
+ return items;
+ }
+
+ // 서브커맨드 매칭
+ var matched = SubCommands
+ .Where(sc => sc.Sub.StartsWith(q, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (matched.Count > 0)
+ {
+ foreach (var (sub, args, label, icon) in matched)
+ {
+ string? preview = null;
+ if (!string.IsNullOrEmpty(workDir))
+ preview = await RunGitAsync(args, workDir, ct);
+
+ var subtitle = preview != null
+ ? TruncateLines(preview, 3)
+ : label;
+
+ items.Add(new LauncherItem(
+ $"git {sub}",
+ subtitle,
+ null,
+ (sub, workDir ?? "", args),
+ Symbol: icon));
+ }
+ }
+ else
+ {
+ // 자유 명령 실행 (git )
+ string? output = null;
+ if (!string.IsNullOrEmpty(workDir))
+ output = await RunGitAsync(q, workDir, ct);
+
+ items.Add(new LauncherItem(
+ $"git {query.Trim()}",
+ output != null ? TruncateLines(output, 3) : "실행 후 결과 클립보드 복사",
+ null,
+ ("custom", workDir ?? "", query.Trim()),
+ Symbol: "\uE9F5"));
+ }
+
+ return items;
+ }
+
+ public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ string? result = null;
+
+ switch (item.Data)
+ {
+ // 상태 요약 항목
+ case ("status_summary", string workDir):
+ result = await RunGitAsync("status", workDir, ct);
+ break;
+
+ // 서브커맨드 항목 (sub, workDir, args)
+ case (string sub, string workDir, string args):
+ if (sub == "pull")
+ {
+ // pull은 별도 터미널 창으로 실행
+ if (!string.IsNullOrEmpty(workDir))
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = "powershell.exe",
+ Arguments = $"-NoProfile -Command \"cd '{workDir}'; git pull; Read-Host 'Enter 키를 누르면 닫힙니다'\"",
+ UseShellExecute = true,
+ });
+ }
+ return;
+ }
+ result = await RunGitAsync(args, workDir, ct);
+ break;
+
+ // 서브커맨드 항목 (sub, workDir) — args 없는 경우
+ case (string sub2, string workDir2):
+ var found = SubCommands.FirstOrDefault(sc => sc.Sub == sub2);
+ if (found != default)
+ result = await RunGitAsync(found.Args, workDir2, ct);
+ break;
+
+ default:
+ break;
+ }
+
+ if (!string.IsNullOrWhiteSpace(result))
+ {
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(
+ () => Clipboard.SetText(result));
+ NotificationService.Notify("Git", "결과를 클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ }
+ }
+
+ // ── 헬퍼 ────────────────────────────────────────────────────────────────
+
+ /// 현재 앱 설정의 작업 폴더에서 .git root를 찾습니다.
+ private static string? FindGitRoot()
+ {
+ var app = System.Windows.Application.Current as App;
+ var workDir = app?.SettingsService?.Settings.Llm.WorkFolder ?? "";
+
+ if (string.IsNullOrEmpty(workDir) || !System.IO.Directory.Exists(workDir))
+ workDir = AppDomain.CurrentDomain.BaseDirectory;
+
+ // .git 폴더를 찾아 상위로 이동
+ var dir = new System.IO.DirectoryInfo(workDir);
+ while (dir != null)
+ {
+ if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git")))
+ return dir.FullName;
+ dir = dir.Parent;
+ }
+ return null;
+ }
+
+ /// git 명령을 비동기로 실행하고 출력을 반환합니다.
+ private static async Task RunGitAsync(string args, string workDir, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(workDir)) return null;
+ try
+ {
+ var psi = new ProcessStartInfo("git", args)
+ {
+ WorkingDirectory = workDir,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ };
+
+ using var proc = Process.Start(psi);
+ if (proc == null) return null;
+
+ var output = await proc.StandardOutput.ReadToEndAsync(ct);
+ var error = await proc.StandardError.ReadToEndAsync(ct);
+ await proc.WaitForExitAsync(ct);
+
+ var text = output.Trim();
+ if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(error))
+ text = error.Trim();
+
+ return string.IsNullOrWhiteSpace(text) ? "(출력 없음)" : text;
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ catch (Exception ex)
+ {
+ return $"오류: {ex.Message}";
+ }
+ }
+
+ /// 긴 출력을 maxLines줄로 자릅니다.
+ private static string TruncateLines(string text, int maxLines)
+ {
+ var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ if (lines.Length <= maxLines) return string.Join(" · ", lines.Take(maxLines)).Trim();
+ return string.Join(" · ", lines.Take(maxLines)) + $" … (+{lines.Length - maxLines}줄)";
+ }
+}
diff --git a/src/AxCopilot/Handlers/NetDiagHandler.cs b/src/AxCopilot/Handlers/NetDiagHandler.cs
new file mode 100644
index 0000000..0d47bd6
--- /dev/null
+++ b/src/AxCopilot/Handlers/NetDiagHandler.cs
@@ -0,0 +1,365 @@
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Text;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L7-4: 네트워크 진단 핸들러. "net" 프리픽스로 사용합니다.
+/// (기존 PortHandler의 포트/프로세스 조회와 구분되는 어댑터·핑·DNS 진단 기능)
+///
+/// 예: net → 활성 네트워크 어댑터 IP 목록
+/// net ping 8.8.8.8 → 핑 테스트 (사외 모드에서만 외부 호스트)
+/// net ping localhost → 로컬 핑 (항상 허용)
+/// net dns google.com → DNS A 레코드 조회 (사외 모드에서만 외부)
+/// net ip → 로컬 IP 정보 (공인 IP는 사외 모드에서만 표시)
+/// net adapter → 네트워크 어댑터 전체 정보
+/// Enter → 결과를 클립보드에 복사.
+///
+public class NetDiagHandler : IActionHandler
+{
+ public string? Prefix => "net";
+
+ public PluginMetadata Metadata => new(
+ "NetDiag",
+ "네트워크 진단 — IP · ping · DNS 조회 · 어댑터 상태",
+ "1.0",
+ "AX");
+
+ public async Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim().ToLowerInvariant();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ // 로컬 어댑터 IP 빠른 표시
+ var adapters = GetLocalAdapters();
+ foreach (var (name, ip, mac) in adapters)
+ {
+ items.Add(new LauncherItem(
+ ip,
+ $"{name} · MAC: {mac}",
+ null,
+ ("copy", ip),
+ Symbol: "\uE968"));
+ }
+
+ // 서브커맨드 안내
+ items.Add(new LauncherItem("net ping <호스트>", "핑 테스트", null, null, Symbol: "\uE8F2"));
+ items.Add(new LauncherItem("net dns <도메인>", "DNS A 레코드 조회", null, null, Symbol: "\uE968"));
+ items.Add(new LauncherItem("net ip", "로컬 IP 전체 정보", null, ("ip_info", ""), Symbol: "\uE968"));
+ items.Add(new LauncherItem("net adapter", "어댑터 세부 정보", null, ("adapter_info", ""), Symbol: "\uE968"));
+ return items;
+ }
+
+ // ── ping ──────────────────────────────────────────────────────────
+ if (q.StartsWith("ping "))
+ {
+ var host = query.Trim()["ping ".Length..].Trim();
+ if (string.IsNullOrEmpty(host))
+ {
+ items.Add(new LauncherItem("호스트를 입력하세요", "예: net ping 192.168.1.1", null, null, Symbol: "\uE783"));
+ return items;
+ }
+
+ // 사내 모드에서는 내부 호스트만 허용
+ if (!IsAllowedInInternalMode(host))
+ {
+ items.Add(new LauncherItem(
+ "사내 모드 — 외부 호스트 차단",
+ "사외 모드에서만 외부 IP/도메인에 핑 가능합니다",
+ null, null, Symbol: "\uE785"));
+ return items;
+ }
+
+ items.Add(new LauncherItem(
+ $"Ping: {host}",
+ "테스트 중...",
+ null,
+ ("ping", host),
+ Symbol: "\uE8F2"));
+
+ // 비동기 핑 시도
+ try
+ {
+ var result = await PingAsync(host, ct);
+ items.Clear();
+ foreach (var r in result)
+ items.Add(r);
+ }
+ catch (OperationCanceledException) { }
+
+ return items;
+ }
+
+ // ── dns ──────────────────────────────────────────────────────────
+ if (q.StartsWith("dns "))
+ {
+ var domain = query.Trim()["dns ".Length..].Trim();
+ if (string.IsNullOrEmpty(domain))
+ {
+ items.Add(new LauncherItem("도메인을 입력하세요", "예: net dns example.com", null, null, Symbol: "\uE783"));
+ return items;
+ }
+
+ if (!IsAllowedInInternalMode(domain))
+ {
+ items.Add(new LauncherItem(
+ "사내 모드 — 외부 도메인 차단",
+ "사외 모드에서만 외부 도메인 DNS 조회 가능합니다",
+ null, null, Symbol: "\uE785"));
+ return items;
+ }
+
+ try
+ {
+ var ips = await Dns.GetHostAddressesAsync(domain, ct);
+ if (ips.Length == 0)
+ {
+ items.Add(new LauncherItem($"{domain}", "DNS 조회 결과 없음", null, null, Symbol: "\uE783"));
+ }
+ else
+ {
+ items.Add(new LauncherItem(
+ $"{domain} — {ips.Length}개 레코드",
+ string.Join(", ", ips.Select(ip => ip.ToString())),
+ null,
+ ("copy", string.Join("\n", ips.Select(ip => ip.ToString()))),
+ Symbol: "\uE968"));
+
+ foreach (var ip in ips)
+ {
+ items.Add(new LauncherItem(
+ ip.ToString(),
+ ip.AddressFamily == AddressFamily.InterNetworkV6 ? "IPv6" : "IPv4",
+ null,
+ ("copy", ip.ToString()),
+ Symbol: "\uE968"));
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ items.Add(new LauncherItem("DNS 조회 실패", ex.Message, null, null, Symbol: "\uE783"));
+ }
+
+ return items;
+ }
+
+ // ── ip ────────────────────────────────────────────────────────────
+ if (q == "ip")
+ {
+ items.Add(new LauncherItem("로컬 IP 정보", "어댑터별 IP 주소", null, ("ip_info", ""), Symbol: "\uE968"));
+ var adapters = GetLocalAdapters();
+ foreach (var (name, ip, mac) in adapters)
+ {
+ items.Add(new LauncherItem(
+ ip,
+ $"{name} · MAC: {mac}",
+ null,
+ ("copy", ip),
+ Symbol: "\uE968"));
+ }
+ return items;
+ }
+
+ // ── adapter ──────────────────────────────────────────────────────
+ if (q.StartsWith("adapter"))
+ {
+ items.Add(new LauncherItem(
+ "어댑터 전체 정보 (클립보드 복사)",
+ "활성 어댑터, IP, MAC, 속도",
+ null,
+ ("adapter_info", ""),
+ Symbol: "\uE968"));
+ var adapters = GetLocalAdapters();
+ foreach (var (name, ip, mac) in adapters)
+ {
+ items.Add(new LauncherItem(
+ name,
+ $"IP: {ip} · MAC: {mac}",
+ null,
+ ("copy", $"{name}: {ip} ({mac})"),
+ Symbol: "\uE968"));
+ }
+ return items;
+ }
+
+ // 미인식 → 기본 표시
+ var defaultAdapters = GetLocalAdapters();
+ foreach (var (name, ip, mac) in defaultAdapters)
+ {
+ items.Add(new LauncherItem(ip, $"{name} · MAC: {mac}", null, ("copy", ip), Symbol: "\uE968"));
+ }
+ return items;
+ }
+
+ public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("copy", string text):
+ TryCopyToClipboard(text);
+ NotificationService.Notify("NetDiag", "클립보드에 복사했습니다.");
+ break;
+
+ case ("ping", string host):
+ var pingItems = await PingAsync(host, ct);
+ var summary = string.Join("\n", pingItems.Select(i => $"{i.Title} {i.Subtitle}"));
+ TryCopyToClipboard(summary);
+ NotificationService.Notify("Ping", pingItems.FirstOrDefault()?.Title ?? host);
+ break;
+
+ case ("ip_info", _):
+ case ("adapter_info", _):
+ var adapterInfo = BuildAdapterInfoText();
+ TryCopyToClipboard(adapterInfo);
+ NotificationService.Notify("NetDiag", "어댑터 정보를 클립보드에 복사했습니다.");
+ break;
+ }
+ }
+
+ // ── 헬퍼 ────────────────────────────────────────────────────────────────
+
+ private static List<(string Name, string IP, string MAC)> GetLocalAdapters()
+ {
+ var result = new List<(string, string, string)>();
+ try
+ {
+ foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ if (adapter.OperationalStatus != OperationalStatus.Up) continue;
+ if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
+
+ var props = adapter.GetIPProperties();
+ foreach (var addr in props.UnicastAddresses)
+ {
+ if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue;
+ var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes())
+ .Replace('-', ':');
+ result.Add((adapter.Name, addr.Address.ToString(), mac));
+ }
+ }
+ }
+ catch { /* 비핵심 */ }
+ return result;
+ }
+
+ private static string BuildAdapterInfoText()
+ {
+ var sb = new StringBuilder();
+ try
+ {
+ foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ if (adapter.OperationalStatus != OperationalStatus.Up) continue;
+ if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
+
+ var props = adapter.GetIPProperties();
+ var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes())
+ .Replace('-', ':');
+ var speed = adapter.Speed > 0 ? $"{adapter.Speed / 1_000_000} Mbps" : "-";
+
+ sb.AppendLine($"[{adapter.Name}]");
+ sb.AppendLine($" 속도: {speed}");
+ sb.AppendLine($" MAC: {mac}");
+ foreach (var addr in props.UnicastAddresses)
+ sb.AppendLine($" IP: {addr.Address} / {addr.PrefixLength}");
+ foreach (var gw in props.GatewayAddresses)
+ sb.AppendLine($" GW: {gw.Address}");
+ sb.AppendLine();
+ }
+ }
+ catch { /* 비핵심 */ }
+ return sb.ToString().Trim();
+ }
+
+ private static async Task> PingAsync(string host, CancellationToken ct)
+ {
+ var items = new List();
+ try
+ {
+ using var pinger = new Ping();
+ var results = new List<(bool Ok, long Ms, string Status)>();
+
+ for (int i = 0; i < 4; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+ try
+ {
+ var reply = await pinger.SendPingAsync(host, 1500);
+ results.Add((reply.Status == IPStatus.Success,
+ reply.RoundtripTime,
+ reply.Status.ToString()));
+ }
+ catch
+ {
+ results.Add((false, -1, "Timeout"));
+ }
+ if (i < 3) await Task.Delay(200, ct);
+ }
+
+ var successCount = results.Count(r => r.Ok);
+ var avgMs = results.Where(r => r.Ok).Select(r => r.Ms).DefaultIfEmpty(0)
+ .Average();
+ var loss = (4 - successCount) * 25;
+
+ items.Add(new LauncherItem(
+ $"Ping {host} {avgMs:F0}ms",
+ $"패킷 손실: {loss}% ({successCount}/4 성공)",
+ null,
+ ("copy", $"Ping {host}: {avgMs:F0}ms, 손실 {loss}%"),
+ Symbol: successCount == 4 ? "\uE73E" : successCount == 0 ? "\uE783" : "\uE7BA"));
+
+ for (int i = 0; i < results.Count; i++)
+ {
+ var (ok, ms, status) = results[i];
+ items.Add(new LauncherItem(
+ ok ? $"응답 {ms}ms" : "시간 초과",
+ $"#{i + 1} {status}",
+ null,
+ ("copy", ok ? $"{ms}ms" : "timeout"),
+ Symbol: ok ? "\uE73E" : "\uE783"));
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ items.Add(new LauncherItem("핑 실패", ex.Message, null, null, Symbol: "\uE783"));
+ }
+ return items;
+ }
+
+ /// 사내 모드에서 외부 호스트 차단 여부 확인.
+ private static bool IsAllowedInInternalMode(string host)
+ {
+ var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
+ var isInternal = settings?.InternalModeEnabled ?? true;
+ if (!isInternal) return true; // 사외 모드: 모두 허용
+
+ // 사내 모드: 내부 주소만 허용
+ return host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
+ || host.StartsWith("127.", StringComparison.Ordinal)
+ || host.StartsWith("192.168.", StringComparison.Ordinal)
+ || host.StartsWith("10.", StringComparison.Ordinal)
+ || host.StartsWith("172.", StringComparison.Ordinal);
+ }
+
+ private static void TryCopyToClipboard(string text)
+ {
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ }
+ catch { /* 비핵심 */ }
+ }
+}
diff --git a/src/AxCopilot/Handlers/RegexHandler.cs b/src/AxCopilot/Handlers/RegexHandler.cs
new file mode 100644
index 0000000..6bd7208
--- /dev/null
+++ b/src/AxCopilot/Handlers/RegexHandler.cs
@@ -0,0 +1,340 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L7-2: 정규식 테스터 핸들러. "re" 프리픽스로 사용합니다.
+///
+/// 예: re \d+ → 클립보드 텍스트에서 숫자 패턴 매치
+/// re /old/new/ → 치환 모드 (결과 클립보드 복사)
+/// re patterns → 공통 패턴 라이브러리 표시
+/// re flags:i \w+ → 플래그 지정 (i=무시대소, m=멀티라인, s=점이개행)
+/// Enter → 매치 결과 또는 치환 결과를 클립보드에 복사.
+///
+public class RegexHandler : IActionHandler
+{
+ public string? Prefix => "re";
+
+ public PluginMetadata Metadata => new(
+ "Regex",
+ "정규식 테스터 — 매치 · 치환 · 패턴 라이브러리",
+ "1.0",
+ "AX");
+
+ // ── 공통 패턴 라이브러리 ─────────────────────────────────────────────────
+ private static readonly (string Name, string Pattern, string Desc)[] CommonPatterns =
+ [
+ ("이메일", @"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", "이메일 주소"),
+ ("URL", @"https?://[^\s/$.?#].[^\s]*", "HTTP/HTTPS URL"),
+ ("전화번호", @"0\d{1,2}-\d{3,4}-\d{4}", "한국 전화번호 (하이픈)"),
+ ("날짜", @"\d{4}[-./]\d{1,2}[-./]\d{1,2}", "날짜 (YYYY-MM-DD 등)"),
+ ("숫자", @"\d+(?:\.\d+)?", "정수 또는 소수"),
+ ("한글", @"[가-힣]+", "한글 문자열"),
+ ("영문", @"[a-zA-Z]+", "영문 문자열"),
+ ("IP 주소", @"\b(?:\d{1,3}\.){3}\d{1,3}\b", "IPv4 주소"),
+ ("16진수 색상", @"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b", "HEX 색상 코드"),
+ ("빈 줄", @"^\s*$", "빈 줄 (멀티라인 모드 필요)"),
+ ("HTML 태그", @"<[^>]+>", "HTML 태그"),
+ ("JSON 키", @"""([^""]+)""\s*:", "JSON 키 이름"),
+ ("UUID", @"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "UUID"),
+ ("우편번호", @"\b\d{5}\b", "5자리 우편번호"),
+ ];
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ // 안내 + 패턴 라이브러리 미리보기
+ items.Add(new LauncherItem(
+ "정규식 테스터",
+ "패턴 입력 후 Enter → 클립보드 텍스트에서 매치. /old/new/ 형식으로 치환",
+ null, null, Symbol: "\uE773"));
+
+ items.Add(new LauncherItem(
+ "re patterns",
+ "공통 패턴 라이브러리 표시",
+ null, ("show_patterns", ""), Symbol: "\uE8A4"));
+
+ foreach (var (name, pattern, desc) in CommonPatterns.Take(5))
+ {
+ items.Add(new LauncherItem(
+ name,
+ $"{pattern} · {desc}",
+ null,
+ ("pattern_apply", pattern),
+ Symbol: "\uE773"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // "patterns" 서브커맨드
+ if (q.Equals("patterns", StringComparison.OrdinalIgnoreCase))
+ {
+ foreach (var (name, pattern, desc) in CommonPatterns)
+ {
+ items.Add(new LauncherItem(
+ name,
+ $"{pattern} · {desc}",
+ null,
+ ("pattern_apply", pattern),
+ Symbol: "\uE773"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // 공통 패턴 이름 검색
+ var matchedPatterns = CommonPatterns
+ .Where(p => p.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+ p.Desc.Contains(q, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ if (matchedPatterns.Count > 0)
+ {
+ foreach (var (name, pattern, desc) in matchedPatterns)
+ {
+ items.Add(new LauncherItem(
+ name,
+ $"{pattern} · {desc}",
+ null,
+ ("pattern_apply", pattern),
+ Symbol: "\uE773"));
+ }
+ }
+
+ // 치환 모드 /old/new/
+ if (q.StartsWith('/') && q.Length > 2)
+ {
+ var parts = q.Split('/', StringSplitOptions.None);
+ // /old/new/ → parts = ["", "old", "new", ""]
+ if (parts.Length >= 3)
+ {
+ var oldPat = parts[1];
+ var newStr = parts.Length >= 3 ? parts[2] : "";
+ var flags = parts.Length >= 4 ? parts[3] : "";
+
+ var clipText = GetClipboardText();
+ if (!string.IsNullOrEmpty(clipText) && !string.IsNullOrEmpty(oldPat))
+ {
+ try
+ {
+ var opts = BuildOptions(flags);
+ var result = Regex.Replace(clipText, oldPat, newStr, opts);
+ var changed = result != clipText;
+ items.Add(new LauncherItem(
+ changed ? "치환 완료 — 클립보드 복사" : "치환 없음 (패턴 불일치)",
+ result.Length > 120 ? result[..120] + "…" : result,
+ null,
+ ("replace_result", result),
+ Symbol: "\uE8AC"));
+ }
+ catch (Exception ex)
+ {
+ items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783"));
+ }
+ }
+ else
+ {
+ items.Add(new LauncherItem(
+ $"치환: /{oldPat}/ → {newStr}",
+ "클립보드에 텍스트를 복사한 뒤 실행하세요",
+ null, ("replace_pattern", oldPat, newStr), Symbol: "\uE8AC"));
+ }
+ return Task.FromResult>(items);
+ }
+ }
+
+ // 플래그 처리: "flags:im pattern"
+ string pattern2 = q;
+ string flagStr = "";
+ if (q.StartsWith("flags:", StringComparison.OrdinalIgnoreCase))
+ {
+ var spaceIdx = q.IndexOf(' ');
+ if (spaceIdx > 0)
+ {
+ flagStr = q[6..spaceIdx];
+ pattern2 = q[(spaceIdx + 1)..].Trim();
+ }
+ }
+
+ // 매치 모드
+ if (!string.IsNullOrEmpty(pattern2))
+ {
+ var clipText = GetClipboardText();
+ if (!string.IsNullOrEmpty(clipText))
+ {
+ try
+ {
+ var opts = BuildOptions(flagStr);
+ var matches = Regex.Matches(clipText, pattern2, opts);
+
+ if (matches.Count == 0)
+ {
+ items.Add(new LauncherItem(
+ "매치 없음",
+ $"패턴 [{pattern2}]이 클립보드 텍스트와 일치하지 않습니다",
+ null, null, Symbol: "\uE783"));
+ }
+ else
+ {
+ // 요약 항목
+ items.Add(new LauncherItem(
+ $"{matches.Count}개 매치됨",
+ $"패턴: {pattern2} | 전체 복사: Enter",
+ null,
+ ("all_matches", string.Join("\n", matches.Cast().Select(m => m.Value))),
+ Symbol: "\uE773"));
+
+ // 개별 매치 항목 (최대 15개)
+ foreach (Match m in matches.Cast().Take(15))
+ {
+ var groupInfo = m.Groups.Count > 1
+ ? " · 그룹: " + string.Join(", ", m.Groups.Cast().Skip(1).Select(g => g.Value))
+ : "";
+ items.Add(new LauncherItem(
+ m.Value,
+ $"위치 {m.Index}{groupInfo}",
+ null,
+ ("single_match", m.Value),
+ Symbol: "\uE773"));
+ }
+
+ if (matches.Count > 15)
+ items.Add(new LauncherItem(
+ $"… +{matches.Count - 15}개 더",
+ "전체 보기: 첫 번째 항목 Enter",
+ null, null, Symbol: "\uE712"));
+ }
+ }
+ catch (Exception ex)
+ {
+ items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783"));
+ }
+ }
+ else
+ {
+ // 클립보드 없음 → 패턴만 표시
+ items.Add(new LauncherItem(
+ $"패턴: {pattern2}",
+ "클립보드에 텍스트를 복사한 뒤 실행하세요",
+ null,
+ ("pattern_apply", pattern2),
+ Symbol: "\uE773"));
+ }
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("all_matches", string copyText1):
+ TryCopyToClipboard(copyText1);
+ break;
+ case ("replace_result", string copyText2):
+ TryCopyToClipboard(copyText2);
+ break;
+ case ("single_match", string copyText3):
+ TryCopyToClipboard(copyText3);
+ break;
+
+ case ("pattern_apply", string pattern):
+ // 패턴을 클립보드에 복사 (또는 클립보드 텍스트에 즉시 적용)
+ var clipText = GetClipboardText();
+ if (!string.IsNullOrEmpty(clipText))
+ {
+ try
+ {
+ var matches = Regex.Matches(clipText, pattern);
+ if (matches.Count > 0)
+ {
+ var result = string.Join("\n", matches.Cast().Select(m => m.Value));
+ TryCopyToClipboard(result);
+ NotificationService.Notify("Regex", $"{matches.Count}개 매치 복사됨");
+ }
+ else
+ {
+ NotificationService.Notify("Regex", "매치 없음");
+ }
+ }
+ catch
+ {
+ TryCopyToClipboard(pattern);
+ NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다");
+ }
+ }
+ else
+ {
+ TryCopyToClipboard(pattern);
+ NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다");
+ }
+ break;
+
+ case ("replace_pattern", string oldPat, string newStr):
+ var src = GetClipboardText();
+ if (!string.IsNullOrEmpty(src))
+ {
+ try
+ {
+ var replaced = Regex.Replace(src, oldPat, newStr);
+ TryCopyToClipboard(replaced);
+ NotificationService.Notify("Regex", "치환 결과를 클립보드에 복사했습니다");
+ }
+ catch (Exception ex)
+ {
+ NotificationService.Notify("Regex", $"오류: {ex.Message}");
+ }
+ }
+ break;
+
+ case ("show_patterns", _):
+ // 패턴 라이브러리 목록 표시 — 런처 입력창에 "re patterns" 입력
+ var launcher = System.Windows.Application.Current?.Windows
+ .OfType()
+ .FirstOrDefault();
+ launcher?.SetInputText("re patterns ");
+ break;
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 헬퍼 ────────────────────────────────────────────────────────────────
+
+ private static RegexOptions BuildOptions(string flags)
+ {
+ var opts = RegexOptions.None;
+ if (flags.Contains('i', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.IgnoreCase;
+ if (flags.Contains('m', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Multiline;
+ if (flags.Contains('s', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Singleline;
+ return opts;
+ }
+
+ private static string? GetClipboardText()
+ {
+ try
+ {
+ string? text = null;
+ System.Windows.Application.Current.Dispatcher.Invoke(
+ () => text = Clipboard.ContainsText() ? Clipboard.GetText() : null);
+ return text;
+ }
+ catch { return null; }
+ }
+
+ private static void TryCopyToClipboard(string text)
+ {
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ }
+ catch { /* 비핵심 */ }
+ }
+}
diff --git a/src/AxCopilot/Handlers/TimeZoneHandler.cs b/src/AxCopilot/Handlers/TimeZoneHandler.cs
new file mode 100644
index 0000000..2a4867c
--- /dev/null
+++ b/src/AxCopilot/Handlers/TimeZoneHandler.cs
@@ -0,0 +1,259 @@
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L7-3: 시간대 변환기 핸들러. "tz" 프리픽스로 사용합니다.
+///
+/// 예: tz → 주요 도시 현재 시각 목록
+/// tz seoul → 서울 현재 시각
+/// tz new york → 뉴욕 현재 시각
+/// tz 14:30 to la → 현재 시간대(KST) 14:30을 LA 시각으로 변환
+/// tz meeting 09:00 → 서울 기준 9시 = 주요 도시별 동일 시각 표시
+/// Enter → 결과를 클립보드에 복사.
+///
+public class TimeZoneHandler : IActionHandler
+{
+ public string? Prefix => "tz";
+
+ public PluginMetadata Metadata => new(
+ "TimeZone",
+ "시간대 변환기 — 주요 도시 현재 시각 · 시각 변환",
+ "1.0",
+ "AX");
+
+ // ── 주요 도시 시간대 목록 ──────────────────────────────────────────────
+ private static readonly (string City, string TzId, string Flag, string[] Aliases)[] Cities =
+ [
+ ("서울", "Korea Standard Time", "🇰🇷", ["seoul", "서울", "부산", "인천", "kst", "한국"]),
+ ("도쿄", "Tokyo Standard Time", "🇯🇵", ["tokyo", "도쿄", "osaka", "오사카", "jst", "일본"]),
+ ("베이징", "China Standard Time", "🇨🇳", ["beijing", "베이징", "상하이", "shanghai", "cst", "중국"]),
+ ("방콕", "SE Asia Standard Time", "🇹🇭", ["bangkok", "방콕", "ict", "태국"]),
+ ("두바이", "Arabian Standard Time", "🇦🇪", ["dubai", "두바이", "gst", "uae"]),
+ ("모스크바", "Russia Time Zone 2", "🇷🇺", ["moscow", "모스크바", "msk", "러시아"]),
+ ("파리", "Romance Standard Time", "🇫🇷", ["paris", "파리", "cet", "프랑스"]),
+ ("런던", "GMT Standard Time", "🇬🇧", ["london", "런던", "gmt", "영국"]),
+ ("뉴욕", "Eastern Standard Time", "🇺🇸", ["new york", "뉴욕", "nyc", "est", "동부"]),
+ ("시카고", "Central Standard Time", "🇺🇸", ["chicago", "시카고", "cst", "중부"]),
+ ("로스앤젤레스","Pacific Standard Time", "🇺🇸", ["los angeles", "la", "로스앤젤레스", "pst", "서부"]),
+ ("시드니", "AUS Eastern Standard Time", "🇦🇺", ["sydney", "시드니", "aest", "호주"]),
+ ("싱가포르", "Singapore Standard Time", "🇸🇬", ["singapore", "싱가포르", "sgt"]),
+ ("뭄바이", "India Standard Time", "🇮🇳", ["mumbai", "뭄바이", "ist", "인도"]),
+ ("도하", "Arab Standard Time", "🇶🇦", ["doha", "도하", "ast", "카타르"]),
+ ];
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim().ToLowerInvariant();
+ var items = new List();
+ var now = DateTimeOffset.UtcNow;
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ // 주요 도시 현재 시각 목록
+ items.Add(new LauncherItem(
+ "주요 도시 현재 시각",
+ $"기준: UTC {now:HH:mm}",
+ null, null, Symbol: "\uE917"));
+
+ foreach (var (city, tzId, flag, _) in Cities)
+ {
+ var (timeStr, offsetStr) = GetCityTime(tzId, now);
+ items.Add(new LauncherItem(
+ $"{flag} {city}",
+ $"{timeStr} ({offsetStr})",
+ null,
+ ("copy_time", $"{city}: {timeStr} ({offsetStr})"),
+ Symbol: "\uE917"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // "meeting HH:mm" 모드 — 서울 기준 특정 시각을 전 도시 변환
+ if (q.StartsWith("meeting ") || q.StartsWith("미팅 "))
+ {
+ var timePart = q.Contains(' ') ? q[(q.IndexOf(' ') + 1)..].Trim() : "";
+ return Task.FromResult>(
+ BuildMeetingItems(timePart, now));
+ }
+
+ // "HH:mm to " 또는 "HH:mm " 변환 모드
+ var convResult = TryParseConversion(q, now);
+ if (convResult != null)
+ return Task.FromResult>(convResult);
+
+ // 도시 검색
+ var matched = Cities
+ .Where(c => c.Aliases.Any(a => a.Contains(q)))
+ .ToList();
+
+ if (matched.Count > 0)
+ {
+ foreach (var (city, tzId, flag, _) in matched)
+ {
+ var (timeStr, offsetStr) = GetCityTime(tzId, now);
+ items.Add(new LauncherItem(
+ $"{flag} {city} {timeStr}",
+ $"UTC {offsetStr}",
+ null,
+ ("copy_time", $"{city}: {timeStr} ({offsetStr})"),
+ Symbol: "\uE917"));
+
+ // 이 도시와 서울의 시차
+ var seoulOffset = GetOffset("Korea Standard Time");
+ var cityOffset = GetOffset(tzId);
+ var diff = (cityOffset - seoulOffset).TotalHours;
+ var diffStr = diff == 0 ? "서울과 동일" :
+ diff > 0 ? $"서울보다 +{diff:+0;-0}시간" :
+ $"서울보다 {diff:+0;-0}시간";
+ items.Add(new LauncherItem(
+ diffStr,
+ "서울(KST) 기준 시차",
+ null, null, Symbol: "\uE8F4"));
+ }
+ }
+ else
+ {
+ // 미인식 → 모든 도시 표시
+ foreach (var (city, tzId, flag, _) in Cities)
+ {
+ var (timeStr, offsetStr) = GetCityTime(tzId, now);
+ items.Add(new LauncherItem(
+ $"{flag} {city} {timeStr}",
+ offsetStr,
+ null,
+ ("copy_time", $"{city}: {timeStr} ({offsetStr})"),
+ Symbol: "\uE917"));
+ }
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is ("copy_time", string text))
+ {
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(
+ () => Clipboard.SetText(text));
+ NotificationService.Notify("시간대", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 헬퍼 ────────────────────────────────────────────────────────────────
+
+ private static (string Time, string Offset) GetCityTime(string tzId, DateTimeOffset utcNow)
+ {
+ try
+ {
+ var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
+ var local = TimeZoneInfo.ConvertTime(utcNow, tz);
+ var off = tz.GetUtcOffset(utcNow);
+ var offStr = $"UTC{(off >= TimeSpan.Zero ? "+" : "")}{off.Hours:D2}:{off.Minutes:D2}";
+ return (local.ToString("HH:mm (ddd)"), offStr);
+ }
+ catch
+ {
+ return ("--:--", "");
+ }
+ }
+
+ private static TimeSpan GetOffset(string tzId)
+ {
+ try
+ {
+ var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
+ return tz.GetUtcOffset(DateTimeOffset.UtcNow);
+ }
+ catch { return TimeSpan.Zero; }
+ }
+
+ private IEnumerable BuildMeetingItems(string timePart, DateTimeOffset utcNow)
+ {
+ var items = new List();
+
+ if (!TimeOnly.TryParse(timePart, out var meetingTime))
+ {
+ items.Add(new LauncherItem("시각 형식 오류",
+ "HH:mm 형식으로 입력하세요 (예: tz meeting 10:00)",
+ null, null, Symbol: "\uE783"));
+ return items;
+ }
+
+ // 서울 기준으로 날짜+시각 설정
+ var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
+ var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz);
+ var seoulDt = new DateTimeOffset(seoulNow.Date.AddHours(meetingTime.Hour).AddMinutes(meetingTime.Minute),
+ seoulTz.GetUtcOffset(utcNow));
+ var seoulUtc = seoulDt.ToUniversalTime();
+
+ items.Add(new LauncherItem(
+ $"🇰🇷 서울 미팅 시각: {meetingTime:HH:mm}",
+ "주요 도시 동일 시각", null, null, Symbol: "\uE917"));
+
+ foreach (var (city, tzId, flag, _) in Cities)
+ {
+ if (tzId == "Korea Standard Time") continue;
+ var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc);
+ items.Add(new LauncherItem(
+ $"{flag} {city} {timeStr}",
+ offsetStr,
+ null,
+ ("copy_time", $"{city}: {timeStr}"),
+ Symbol: "\uE917"));
+ }
+ return items;
+ }
+
+ private IEnumerable? TryParseConversion(string q, DateTimeOffset utcNow)
+ {
+ // "HH:mm to " 또는 "HH:mm " 패턴
+ var sep = q.Contains(" to ") ? " to " : q.Contains(' ') ? " " : null;
+ if (sep == null) return null;
+
+ var parts = q.Split(sep, 2);
+ if (parts.Length < 2) return null;
+
+ if (!TimeOnly.TryParse(parts[0].Trim(), out var inputTime)) return null;
+
+ var cityQuery = parts[1].Trim().ToLowerInvariant();
+ var targetCities = Cities
+ .Where(c => c.Aliases.Any(a => a.Contains(cityQuery)))
+ .ToList();
+
+ if (targetCities.Count == 0) return null;
+
+ var items = new List();
+ // 서울 기준으로 입력 시각 해석
+ var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
+ var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz);
+ var seoulDt = new DateTimeOffset(
+ seoulNow.Date.AddHours(inputTime.Hour).AddMinutes(inputTime.Minute),
+ seoulTz.GetUtcOffset(utcNow));
+ var seoulUtc = seoulDt.ToUniversalTime();
+
+ items.Add(new LauncherItem(
+ $"🇰🇷 서울 {inputTime:HH:mm} 기준 변환",
+ "Enter → 클립보드 복사", null, null, Symbol: "\uE8F4"));
+
+ foreach (var (city, tzId, flag, _) in targetCities)
+ {
+ var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc);
+ items.Add(new LauncherItem(
+ $"{flag} {city} {timeStr}",
+ offsetStr,
+ null,
+ ("copy_time", $"{city}: {timeStr}"),
+ Symbol: "\uE917"));
+ }
+ return items;
+ }
+}