diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md
index 7e09c45..5ce011b 100644
--- a/docs/LAUNCHER_ROADMAP.md
+++ b/docs/LAUNCHER_ROADMAP.md
@@ -282,3 +282,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
| L11-2 | **JWT 디코더** ✅ | `jwt` 프리픽스. 클립보드 또는 인라인 토큰 자동 감지(eyJ 시작). 헤더(alg·typ)·페이로드(claims)·서명 유무 분석. exp/iat/nbf 타임스탬프 → 날짜 변환. 만료 D-day·남은 시간 계산. `jwt header` / `jwt payload` 부분 조회. **서명 검증 미지원(분석 전용)** | 높음 |
| L11-3 | **Cron 설명기** ✅ | `cron` 프리픽스. 5필드 표준 cron 표현식 파싱. 한국어 설명 생성(예: "평일 오전 9시 실행"). 다음 5회 실행 시간 계산 + 상대 시간 표시. `@daily/@weekly/@monthly/@hourly` 특수 키워드. 필드별 분석(분·시·일·월·요일). Enter → 표현식 복사 | 중간 |
| L11-4 | **유니코드 조회** ✅ | `unicode` 프리픽스. 문자 직접 입력, `U+XXXX`, `0xXXXX`, 10진수 코드포인트 방식 지원. UTF-8·UTF-16 바이트, HTML 엔티티, 카테고리(Lu/Ll/So 등), 블록명 표시. 한글 음절 초·중·종성 분해. 여러 문자 입력 시 코드포인트 범위 요약 | 중간 |
+
+---
+
+## Phase L12 — 시스템·네트워크·텍스트 도구 (v2.0.4) ✅ 완료
+
+> **방향**: 실무 네트워크 진단·시스템 관리·재미있는 텍스트 변환 확충.
+
+| # | 기능 | 설명 | 우선순위 |
+|---|------|------|----------|
+| L12-1 | **HTTP 요청 테스터** ✅ | `http` 프리픽스. GET/HEAD/POST/PUT/DELETE 메서드. http:// 자동 추가. 상태 코드·응답 시간·Content-Type·주요 헤더 표시. 사내 모드에서 외부 URL 차단(내부 IP만 허용). Enter → 요청 실행 + 결과 클립보드 복사 | 높음 |
+| 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 → 명령 경로 클립보드 복사 | 중간 |
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index 41af001..5a64af5 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -247,6 +247,16 @@ public partial class App : System.Windows.Application
// L11-4: 유니코드 문자 조회 (prefix=unicode)
commandResolver.RegisterHandler(new UnicodeHandler());
+ // ─── Phase L12 핸들러 ─────────────────────────────────────────────────
+ // L12-1: HTTP 요청 테스터 (prefix=http)
+ commandResolver.RegisterHandler(new HttpTesterHandler());
+ // L12-2: hosts 파일 관리 (prefix=hosts)
+ commandResolver.RegisterHandler(new HostsHandler());
+ // L12-3: 모스 부호 변환기 (prefix=morse)
+ commandResolver.RegisterHandler(new MorseHandler());
+ // L12-4: 시작 프로그램 조회 (prefix=startup)
+ commandResolver.RegisterHandler(new StartupHandler());
+
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll();
diff --git a/src/AxCopilot/Handlers/HostsHandler.cs b/src/AxCopilot/Handlers/HostsHandler.cs
new file mode 100644
index 0000000..6126f0e
--- /dev/null
+++ b/src/AxCopilot/Handlers/HostsHandler.cs
@@ -0,0 +1,253 @@
+using System.IO;
+using System.Text;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L12-2: Windows hosts 파일 관리 핸들러. "hosts" 프리픽스로 사용합니다.
+///
+/// 예: hosts → 현재 hosts 파일 항목 목록
+/// hosts search dev → "dev" 포함 항목 필터
+/// hosts open → hosts 파일을 메모장으로 열기
+/// hosts copy → 전체 hosts 내용 클립보드 복사
+/// Enter → 해당 항목을 클립보드에 복사.
+///
+public class HostsHandler : IActionHandler
+{
+ public string? Prefix => "hosts";
+
+ public PluginMetadata Metadata => new(
+ "Hosts",
+ "Windows hosts 파일 뷰어 — 항목 조회 · 검색 · 복사",
+ "1.0",
+ "AX");
+
+ private static readonly string HostsPath =
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),
+ @"drivers\etc\hosts");
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ var entries = ReadHostsEntries();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ var activeCount = entries.Count(e => !e.IsComment);
+ var commentCount = entries.Count(e => e.IsComment && e.IsDisabledEntry);
+
+ items.Add(new LauncherItem(
+ $"hosts 파일 활성 {activeCount}개" + (commentCount > 0 ? $" · 비활성 {commentCount}개" : ""),
+ HostsPath,
+ null,
+ ("copy_path", HostsPath),
+ Symbol: "\uE8D2"));
+
+ items.Add(new LauncherItem("파일 열기 (메모장)", HostsPath, null, ("open", HostsPath), Symbol: "\uE8A5"));
+ items.Add(new LauncherItem("전체 내용 복사", $"{entries.Count}줄", null, ("copy_all", ""), Symbol: "\uE8A5"));
+
+ // 유효 항목 목록
+ foreach (var e in entries.Where(e => !e.IsComment).Take(20))
+ items.Add(MakeEntryItem(e));
+
+ // 비활성 항목 (주석 처리된 IP 항목)
+ var disabled = entries.Where(e => e.IsDisabledEntry).Take(5).ToList();
+ if (disabled.Count > 0)
+ {
+ items.Add(new LauncherItem($"── 비활성 항목 {disabled.Count}개 ──", "", null, null, Symbol: "\uE8D2"));
+ foreach (var e in disabled)
+ items.Add(MakeEntryItem(e));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
+ var sub = parts[0].ToLowerInvariant();
+
+ switch (sub)
+ {
+ case "open":
+ items.Add(new LauncherItem("메모장으로 열기", HostsPath, null, ("open", HostsPath), Symbol: "\uE8A5"));
+ break;
+
+ case "copy":
+ case "copy_all":
+ {
+ var content = ReadHostsRaw();
+ items.Add(new LauncherItem("전체 내용 복사", $"{content.Split('\n').Length}줄 · Enter 복사",
+ null, ("copy", content), Symbol: "\uE8A5"));
+ break;
+ }
+
+ case "search":
+ case "find":
+ {
+ var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : "";
+ if (string.IsNullOrWhiteSpace(keyword))
+ {
+ items.Add(new LauncherItem("검색어 입력", "예: hosts search dev", null, null, Symbol: "\uE783"));
+ break;
+ }
+ var filtered = entries.Where(e =>
+ e.Hostname.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
+ e.IpAddress.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (filtered.Count == 0)
+ items.Add(new LauncherItem("결과 없음", $"'{keyword}' 항목 없음", null, null, Symbol: "\uE946"));
+ else
+ foreach (var e in filtered.Take(20))
+ items.Add(MakeEntryItem(e));
+ break;
+ }
+
+ default:
+ {
+ // 검색어로 처리
+ var keyword = q.ToLowerInvariant();
+ var filtered = entries.Where(e =>
+ e.Hostname.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
+ e.IpAddress.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (filtered.Count == 0)
+ items.Add(new LauncherItem("결과 없음", $"'{q}' 항목 없음", null, null, Symbol: "\uE946"));
+ else
+ foreach (var e in filtered.Take(20))
+ items.Add(MakeEntryItem(e));
+ break;
+ }
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("copy", string text):
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("Hosts", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ break;
+
+ case ("copy_path", string path):
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
+ NotificationService.Notify("Hosts", "경로가 복사되었습니다.");
+ }
+ catch { /* 비핵심 */ }
+ break;
+
+ case ("copy_all", _):
+ try
+ {
+ var content = ReadHostsRaw();
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(content));
+ NotificationService.Notify("Hosts", $"hosts 파일 내용 복사됨 ({content.Split('\n').Length}줄)");
+ }
+ catch { /* 비핵심 */ }
+ break;
+
+ case ("open", string path):
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "notepad.exe",
+ Arguments = $"\"{path}\"",
+ UseShellExecute = false,
+ });
+ }
+ catch (Exception ex)
+ {
+ NotificationService.Notify("Hosts", $"열기 실패: {ex.Message}");
+ }
+ break;
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── hosts 파일 파서 ───────────────────────────────────────────────────────
+
+ private record HostsEntry(string IpAddress, string Hostname, string RawLine,
+ bool IsComment, bool IsDisabledEntry);
+
+ private static List ReadHostsEntries()
+ {
+ var result = new List();
+ string[] lines;
+ try { lines = File.ReadAllLines(HostsPath, Encoding.UTF8); }
+ catch { return result; }
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.Trim();
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ // 순수 주석 (# 으로 시작, IP 없음)
+ if (line.StartsWith('#'))
+ {
+ // 비활성 IP 항목인지 확인 (예: "# 127.0.0.1 example.com")
+ var inner = line[1..].Trim();
+ if (TryParseIpEntry(inner, out var disIp, out var disHost))
+ {
+ result.Add(new HostsEntry(disIp, disHost, rawLine, true, true));
+ }
+ // 순수 주석은 목록에서 제외
+ continue;
+ }
+
+ // IP 항목 (인라인 주석 포함 가능)
+ var withoutComment = line.Contains('#') ? line[..line.IndexOf('#')].Trim() : line;
+ if (TryParseIpEntry(withoutComment, out var ip, out var host))
+ {
+ result.Add(new HostsEntry(ip, host, rawLine, false, false));
+ }
+ }
+ return result;
+ }
+
+ private static bool TryParseIpEntry(string line, out string ip, out string host)
+ {
+ ip = host = "";
+ var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2) return false;
+
+ // 첫 토큰이 IP 주소 형태인지 간단히 확인
+ if (!System.Net.IPAddress.TryParse(parts[0], out _)) return false;
+ ip = parts[0];
+ host = parts[1];
+ return true;
+ }
+
+ private static string ReadHostsRaw()
+ {
+ try { return File.ReadAllText(HostsPath, Encoding.UTF8); }
+ catch { return "(hosts 파일을 읽을 수 없습니다)"; }
+ }
+
+ private static LauncherItem MakeEntryItem(HostsEntry e)
+ {
+ var prefix = e.IsComment ? "# " : "";
+ var icon = e.IsComment ? "\uE946" : "\uE8D2";
+ var subtitle = e.IsComment ? "비활성 항목" : e.IpAddress;
+
+ return new LauncherItem(
+ $"{prefix}{e.Hostname}",
+ subtitle,
+ null,
+ ("copy", $"{e.IpAddress}\t{e.Hostname}"),
+ Symbol: icon);
+ }
+}
diff --git a/src/AxCopilot/Handlers/HttpTesterHandler.cs b/src/AxCopilot/Handlers/HttpTesterHandler.cs
new file mode 100644
index 0000000..3d5039e
--- /dev/null
+++ b/src/AxCopilot/Handlers/HttpTesterHandler.cs
@@ -0,0 +1,191 @@
+using System.Diagnostics;
+using System.Net.Http;
+using System.Text;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L12-1: HTTP 요청 테스터 핸들러. "http" 프리픽스로 사용합니다.
+///
+/// 예: http example.com → GET 요청 (http:// 자동 추가)
+/// http https://api.example → GET 요청 + 응답 코드·시간
+/// http head https://example → HEAD 요청
+/// http post https://example → POST (빈 바디)
+/// http 192.168.1.1 → 내부 IP GET
+/// Enter → 응답 요약을 클립보드에 복사.
+///
+/// ⚠ 외부 URL: 사내 모드 차단. 내부 IP(10./192.168./172.16-31.)는 허용.
+///
+public class HttpTesterHandler : IActionHandler
+{
+ public string? Prefix => "http";
+
+ public PluginMetadata Metadata => new(
+ "HTTP",
+ "HTTP 요청 테스터 — GET · HEAD · POST · 응답 코드",
+ "1.0",
+ "AX");
+
+ private static readonly HttpClient _client = new(new HttpClientHandler
+ {
+ AllowAutoRedirect = true,
+ MaxAutomaticRedirections = 3,
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true,
+ })
+ {
+ Timeout = TimeSpan.FromSeconds(10),
+ };
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem("HTTP 요청 테스터",
+ "예: http example.com / http head https://api / http post https://url",
+ null, null, Symbol: "\uE774"));
+ items.Add(new LauncherItem("http localhost", "로컬 서버 GET", null, null, Symbol: "\uE774"));
+ items.Add(new LauncherItem("http 192.168.1.1", "내부 IP GET", null, null, Symbol: "\uE774"));
+ items.Add(new LauncherItem("http head https://…", "HEAD 요청", null, null, Symbol: "\uE774"));
+ items.Add(new LauncherItem("http post https://…", "POST 요청", null, null, Symbol: "\uE774"));
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
+ string method, url;
+
+ if (parts[0].ToUpperInvariant() is "GET" or "HEAD" or "POST" or "PUT" or "DELETE" or "OPTIONS")
+ {
+ method = parts[0].ToUpperInvariant();
+ url = parts.Length > 1 ? parts[1].Trim() : "";
+ }
+ else
+ {
+ method = "GET";
+ url = q;
+ }
+
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ items.Add(new LauncherItem("URL을 입력하세요", $"예: http {method.ToLower()} https://example.com", null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ // 스키마 자동 추가
+ if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
+ !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ url = "http://" + url;
+ }
+
+ // 사내 모드 확인
+ var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
+ var isInternal = settings?.InternalModeEnabled ?? true;
+ if (isInternal && !IsInternalUrl(url))
+ {
+ items.Add(new LauncherItem(
+ "사내 모드 제한",
+ $"외부 URL '{url}'은 차단됩니다. 설정에서 사외 모드를 활성화하세요.",
+ null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ items.Add(new LauncherItem(
+ $"{method} {TruncateUrl(url)}",
+ "Enter를 눌러 요청 실행",
+ null,
+ ("request", $"{method}|{url}"),
+ Symbol: "\uE774"));
+
+ 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("HTTP", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ return;
+ }
+
+ if (item.Data is not ("request", string reqData)) return;
+
+ var idx = reqData.IndexOf('|');
+ var method = reqData[..idx];
+ var url = reqData[(idx + 1)..];
+
+ NotificationService.Notify("HTTP", $"{method} {TruncateUrl(url)} 요청 중…");
+
+ try
+ {
+ var sw = Stopwatch.StartNew();
+ HttpResponseMessage resp;
+
+ using var reqMsg = new HttpRequestMessage(new HttpMethod(method), url);
+ reqMsg.Headers.TryAddWithoutValidation("User-Agent", "AX-Copilot/2.0 HTTP-Tester");
+
+ resp = await _client.SendAsync(reqMsg, HttpCompletionOption.ResponseHeadersRead, ct);
+ sw.Stop();
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"URL: {url}");
+ sb.AppendLine($"메서드: {method}");
+ sb.AppendLine($"상태 코드: {(int)resp.StatusCode} {resp.ReasonPhrase}");
+ sb.AppendLine($"응답 시간: {sw.ElapsedMilliseconds}ms");
+ sb.AppendLine($"Content-Type: {resp.Content.Headers.ContentType}");
+ sb.AppendLine($"Content-Length: {resp.Content.Headers.ContentLength?.ToString() ?? "unknown"}");
+
+ // 주요 헤더
+ foreach (var h in new[] { "Server", "X-Powered-By", "Cache-Control", "ETag", "Last-Modified" })
+ if (resp.Headers.TryGetValues(h, out var vals))
+ sb.AppendLine($"{h}: {string.Join(", ", vals)}");
+
+ var summary = sb.ToString().TrimEnd();
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(summary));
+
+ var status = (int)resp.StatusCode;
+ var emoji = status < 300 ? "✔" : status < 400 ? "↪" : "✘";
+ NotificationService.Notify("HTTP",
+ $"{emoji} {status} {resp.ReasonPhrase} ({sw.ElapsedMilliseconds}ms) · 결과 복사됨");
+ }
+ catch (TaskCanceledException)
+ {
+ NotificationService.Notify("HTTP", "요청 타임아웃 (10초 초과)");
+ }
+ catch (HttpRequestException ex)
+ {
+ NotificationService.Notify("HTTP", $"요청 오류: {ex.Message}");
+ }
+ catch (Exception ex)
+ {
+ NotificationService.Notify("HTTP", $"오류: {ex.Message}");
+ }
+ }
+
+ // ── 헬퍼 ─────────────────────────────────────────────────────────────────
+
+ private static bool IsInternalUrl(string url)
+ {
+ var lower = url.ToLowerInvariant();
+ return lower.Contains("://localhost") ||
+ lower.Contains("://127.0.0.1") ||
+ lower.Contains("://192.168.") ||
+ lower.Contains("://10.") ||
+ System.Text.RegularExpressions.Regex.IsMatch(lower,
+ @"://172\.(1[6-9]|2\d|3[01])\.");
+ }
+
+ private static string TruncateUrl(string url) =>
+ url.Length > 60 ? url[..60] + "…" : url;
+}
diff --git a/src/AxCopilot/Handlers/MorseHandler.cs b/src/AxCopilot/Handlers/MorseHandler.cs
new file mode 100644
index 0000000..df96edd
--- /dev/null
+++ b/src/AxCopilot/Handlers/MorseHandler.cs
@@ -0,0 +1,251 @@
+using System.Text;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L12-3: 모스 부호 변환기 핸들러. "morse" 프리픽스로 사용합니다.
+///
+/// 예: morse hello → 텍스트 → 모스 부호
+/// morse .- -... -.-. → 모스 부호 → 텍스트 (공백으로 구분)
+/// morse SOS → SOS 모스 부호
+/// morse → 클립보드 자동 감지·변환
+/// Enter → 결과를 클립보드에 복사.
+///
+public class MorseHandler : IActionHandler
+{
+ public string? Prefix => "morse";
+
+ public PluginMetadata Metadata => new(
+ "Morse",
+ "모스 부호 변환기 — 텍스트 ↔ 모스 부호",
+ "1.0",
+ "AX");
+
+ // ── 모스 부호 사전 ────────────────────────────────────────────────────────
+ private static readonly Dictionary TextToMorse = new()
+ {
+ ['A'] = ".-", ['B'] = "-...", ['C'] = "-.-.", ['D'] = "-..",
+ ['E'] = ".", ['F'] = "..-.", ['G'] = "--.", ['H'] = "....",
+ ['I'] = "..", ['J'] = ".---", ['K'] = "-.-", ['L'] = ".-..",
+ ['M'] = "--", ['N'] = "-.", ['O'] = "---", ['P'] = ".--.",
+ ['Q'] = "--.-", ['R'] = ".-.", ['S'] = "...", ['T'] = "-",
+ ['U'] = "..-", ['V'] = "...-", ['W'] = ".--", ['X'] = "-..-",
+ ['Y'] = "-.--", ['Z'] = "--..",
+ ['0'] = "-----", ['1'] = ".----", ['2'] = "..---", ['3'] = "...--",
+ ['4'] = "....-", ['5'] = ".....", ['6'] = "-....", ['7'] = "--...",
+ ['8'] = "---..", ['9'] = "----.",
+ ['.'] = ".-.-.-", [','] = "--..--", ['?'] = "..--..", ['!'] = "-.-.--",
+ ['/'] = "-..-.", ['-'] = "-....-", ['('] = "-.--.", [')'] = "-.--.-",
+ ['@'] = ".--.-.", ['='] = "-...-", ['+'] = ".-.-.", [':'] = "---...",
+ [';'] = "-.-.-.", ['"'] = ".-..-.", ['\''] = ".----.", ['_'] = "..--.-",
+ [' '] = "/",
+ };
+
+ private static readonly Dictionary MorseToText;
+
+ // 번개 부호 / 대문자 표
+ private static readonly string[] ProsignCodes = ["SOS", "AR", "AS", "BT", "KN", "SK"];
+ private static readonly Dictionary ProsignMorse = new()
+ {
+ ["SOS"] = "... --- ...",
+ ["AR"] = ".-.-.",
+ ["AS"] = ".-...",
+ ["BT"] = "-...-",
+ ["KN"] = "-.--.",
+ ["SK"] = "...-.-",
+ };
+
+ static MorseHandler()
+ {
+ MorseToText = TextToMorse
+ .Where(kv => kv.Key != ' ')
+ .ToDictionary(kv => kv.Value, kv => kv.Key);
+ }
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ // 클립보드 자동 감지
+ var clip = GetClipboard();
+ if (!string.IsNullOrWhiteSpace(clip))
+ {
+ if (IsMorseCode(clip))
+ items.AddRange(BuildMorseToText(clip));
+ else if (clip.Length <= 100)
+ items.AddRange(BuildTextToMorse(clip));
+ }
+
+ if (items.Count == 0)
+ {
+ items.Add(new LauncherItem("모스 부호 변환기",
+ "예: morse hello / morse .- -... -.-.",
+ null, null, Symbol: "\uE8C4"));
+ items.Add(new LauncherItem("morse SOS", "SOS 모스 부호", null, null, Symbol: "\uE8C4"));
+ items.Add(new LauncherItem("morse hello", "텍스트 → 모스", null, null, Symbol: "\uE8C4"));
+ items.Add(new LauncherItem("morse .- -...", "모스 → 텍스트", null, null, Symbol: "\uE8C4"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // 프로사인 키워드 우선
+ if (ProsignMorse.TryGetValue(q.ToUpperInvariant(), out var psCode))
+ {
+ items.Add(new LauncherItem(
+ $"{q.ToUpper()} = {psCode}",
+ "모스 부호 프로사인 · Enter 복사",
+ null, ("copy", psCode), Symbol: "\uE8C4"));
+ items.AddRange(BuildTextToMorse(q.ToUpper()));
+ return Task.FromResult>(items);
+ }
+
+ // 모스 부호 입력 감지
+ if (IsMorseCode(q))
+ {
+ items.AddRange(BuildMorseToText(q));
+ }
+ else
+ {
+ items.AddRange(BuildTextToMorse(q));
+ }
+
+ 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("Morse", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 변환 로직 ─────────────────────────────────────────────────────────────
+
+ private static IEnumerable BuildTextToMorse(string text)
+ {
+ var upper = text.ToUpperInvariant();
+ var words = upper.Split(' ');
+ var morseSb = new StringBuilder();
+ var unknown = new List();
+
+ foreach (var word in words)
+ {
+ if (morseSb.Length > 0) morseSb.Append("/ "); // 단어 구분
+ foreach (var ch in word)
+ {
+ if (TextToMorse.TryGetValue(ch, out var code))
+ morseSb.Append(code + " ");
+ else
+ unknown.Add(ch);
+ }
+ }
+
+ var morseStr = morseSb.ToString().TrimEnd();
+ if (string.IsNullOrEmpty(morseStr))
+ {
+ yield return new LauncherItem("변환 불가", "모스 부호에 없는 문자입니다", null, null, Symbol: "\uE783");
+ yield break;
+ }
+
+ yield return new LauncherItem(
+ morseStr.Length > 80 ? morseStr[..80] + "…" : morseStr,
+ $"'{text}' → 모스 부호 · Enter 복사",
+ null,
+ ("copy", morseStr),
+ Symbol: "\uE8C4");
+
+ if (unknown.Count > 0)
+ yield return new LauncherItem("변환 불가 문자",
+ string.Join(" ", unknown.Distinct()), null, null, Symbol: "\uE946");
+
+ // 문자별 표 (최대 10자)
+ var displayText = upper.Replace(" ", "");
+ foreach (var ch in displayText.Take(10))
+ {
+ if (TextToMorse.TryGetValue(ch, out var code) && ch != ' ')
+ yield return new LauncherItem($"{ch} = {code}", "문자별 코드", null, ("copy", code), Symbol: "\uE8C4");
+ }
+ }
+
+ private static IEnumerable BuildMorseToText(string morse)
+ {
+ // "/" 는 단어 구분, 공백은 문자 구분
+ var sb = new StringBuilder();
+ var words = morse.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
+ var unknown = new List();
+
+ foreach (var word in words)
+ {
+ var codes = word.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ foreach (var code in codes)
+ {
+ if (MorseToText.TryGetValue(code, out var ch))
+ sb.Append(ch);
+ else
+ unknown.Add(code);
+ }
+ sb.Append(' ');
+ }
+
+ var result = sb.ToString().Trim();
+ if (string.IsNullOrEmpty(result))
+ {
+ yield return new LauncherItem("변환 실패", "인식할 수 없는 모스 부호입니다", null, null, Symbol: "\uE783");
+ yield break;
+ }
+
+ yield return new LauncherItem(
+ result,
+ $"모스 부호 → '{result}' · Enter 복사",
+ null,
+ ("copy", result),
+ Symbol: "\uE8C4");
+
+ if (unknown.Count > 0)
+ yield return new LauncherItem("인식 불가 코드",
+ string.Join(" ", unknown.Distinct()), null, null, Symbol: "\uE946");
+
+ // 코드별 표 (최대 10개)
+ var codes_ = morse.Trim().Split(new[] { ' ', '/' }, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var code in codes_.Take(10))
+ {
+ if (MorseToText.TryGetValue(code, out var ch))
+ yield return new LauncherItem($"{code} = {ch}", "코드별 문자", null, ("copy", ch.ToString()), Symbol: "\uE8C4");
+ }
+ }
+
+ // ── 헬퍼 ─────────────────────────────────────────────────────────────────
+
+ private static bool IsMorseCode(string s)
+ {
+ // .-/ 문자와 공백만으로 구성되어 있으면 모스 부호로 판단
+ return !string.IsNullOrWhiteSpace(s) &&
+ s.All(c => c is '.' or '-' or '/' or ' ') &&
+ (s.Contains('.') || s.Contains('-'));
+ }
+
+ private static string GetClipboard()
+ {
+ try
+ {
+ return System.Windows.Application.Current.Dispatcher.Invoke(
+ () => Clipboard.ContainsText() ? Clipboard.GetText() : "");
+ }
+ catch { return ""; }
+ }
+}
diff --git a/src/AxCopilot/Handlers/StartupHandler.cs b/src/AxCopilot/Handlers/StartupHandler.cs
new file mode 100644
index 0000000..9bf3a76
--- /dev/null
+++ b/src/AxCopilot/Handlers/StartupHandler.cs
@@ -0,0 +1,230 @@
+using Microsoft.Win32;
+using System.IO;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L12-4: 시작 프로그램 조회 핸들러. "startup" 프리픽스로 사용합니다.
+///
+/// 예: startup → 전체 시작 프로그램 목록
+/// startup search ms → "ms" 포함 항목 필터
+/// startup folder → 시작 프로그램 폴더 열기
+/// Enter → 항목 경로를 클립보드에 복사.
+///
+public class StartupHandler : IActionHandler
+{
+ public string? Prefix => "startup";
+
+ public PluginMetadata Metadata => new(
+ "Startup",
+ "시작 프로그램 조회 — 레지스트리 · 폴더 · 필터",
+ "1.0",
+ "AX");
+
+ // 조회할 레지스트리 키 경로들
+ private static readonly (string Path, string Scope)[] RegKeys =
+ [
+ (@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", "현재 사용자"),
+ (@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", "현재 사용자 (1회)"),
+ (@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run", "현재 사용자 (32bit)"),
+ ];
+
+ private static readonly (string Path, string Scope)[] RegKeysHKLM =
+ [
+ (@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", "모든 사용자"),
+ (@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", "모든 사용자 (1회)"),
+ (@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run", "모든 사용자 (32bit)"),
+ ];
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ var allEntries = CollectAllEntries();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem(
+ $"시작 프로그램 {allEntries.Count}개",
+ "레지스트리 + 시작 폴더",
+ null, null, Symbol: "\uE7FC"));
+
+ items.Add(new LauncherItem("startup folder", "시작 폴더 열기", null, ("open_folder", ""), Symbol: "\uE7FC"));
+
+ // 그룹별 표시
+ var byScope = allEntries.GroupBy(e => e.Scope).OrderBy(g => g.Key);
+ foreach (var group in byScope)
+ {
+ items.Add(new LauncherItem($"── {group.Key} ({group.Count()}개) ──", "", null, null, Symbol: "\uE7FC"));
+ foreach (var e in group.Take(10))
+ items.Add(MakeEntryItem(e));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
+ var sub = parts[0].ToLowerInvariant();
+
+ switch (sub)
+ {
+ case "folder":
+ {
+ items.Add(new LauncherItem("시작 폴더 열기 (현재 사용자)", GetStartupFolderUser(),
+ null, ("open_folder", GetStartupFolderUser()), Symbol: "\uE7FC"));
+ items.Add(new LauncherItem("시작 폴더 열기 (모든 사용자)", GetStartupFolderCommon(),
+ null, ("open_folder", GetStartupFolderCommon()), Symbol: "\uE7FC"));
+ break;
+ }
+
+ case "search":
+ case "find":
+ {
+ var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : "";
+ if (string.IsNullOrWhiteSpace(keyword))
+ {
+ items.Add(new LauncherItem("검색어 입력", "예: startup search teams", null, null, Symbol: "\uE783"));
+ break;
+ }
+ var filtered = allEntries.Where(e =>
+ e.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
+ e.Command.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (filtered.Count == 0)
+ items.Add(new LauncherItem("결과 없음", $"'{keyword}' 항목 없음", null, null, Symbol: "\uE946"));
+ else
+ foreach (var e in filtered)
+ items.Add(MakeEntryItem(e));
+ break;
+ }
+
+ default:
+ {
+ // 검색어로 처리
+ var keyword = q.ToLowerInvariant();
+ var filtered = allEntries.Where(e =>
+ e.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
+ e.Command.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (filtered.Count == 0)
+ items.Add(new LauncherItem("결과 없음", $"'{q}' 항목 없음", null, null, Symbol: "\uE946"));
+ else
+ foreach (var e in filtered.Take(15))
+ items.Add(MakeEntryItem(e));
+ break;
+ }
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("copy", string text):
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("Startup", "클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ break;
+
+ case ("open_folder", string path):
+ var folderPath = string.IsNullOrEmpty(path) ? GetStartupFolderUser() : path;
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = folderPath,
+ UseShellExecute = true,
+ });
+ }
+ catch (Exception ex)
+ {
+ NotificationService.Notify("Startup", $"폴더 열기 실패: {ex.Message}");
+ }
+ break;
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 레지스트리 수집 ────────────────────────────────────────────────────────
+
+ private record StartupEntry(string Name, string Command, string Scope, string Source);
+
+ private static List CollectAllEntries()
+ {
+ var result = new List();
+
+ // HKCU
+ foreach (var (regPath, scope) in RegKeys)
+ result.AddRange(ReadRegistryRun(Registry.CurrentUser, regPath, scope));
+
+ // HKLM
+ foreach (var (regPath, scope) in RegKeysHKLM)
+ result.AddRange(ReadRegistryRun(Registry.LocalMachine, regPath, scope));
+
+ // 시작 폴더 (현재 사용자)
+ result.AddRange(ReadStartupFolder(GetStartupFolderUser(), "현재 사용자 (폴더)"));
+
+ // 시작 폴더 (모든 사용자)
+ result.AddRange(ReadStartupFolder(GetStartupFolderCommon(), "모든 사용자 (폴더)"));
+
+ return result;
+ }
+
+ private static IEnumerable ReadRegistryRun(RegistryKey hive, string path, string scope)
+ {
+ RegistryKey? key;
+ try { key = hive.OpenSubKey(path, writable: false); }
+ catch { yield break; }
+
+ if (key == null) yield break;
+
+ using (key)
+ {
+ foreach (var name in key.GetValueNames())
+ {
+ var cmd = key.GetValue(name)?.ToString() ?? "";
+ yield return new StartupEntry(name, cmd, scope, path);
+ }
+ }
+ }
+
+ private static IEnumerable ReadStartupFolder(string folderPath, string scope)
+ {
+ if (!Directory.Exists(folderPath)) yield break;
+ foreach (var file in Directory.EnumerateFiles(folderPath, "*.lnk"))
+ {
+ yield return new StartupEntry(
+ Path.GetFileNameWithoutExtension(file),
+ file,
+ scope,
+ folderPath);
+ }
+ }
+
+ private static string GetStartupFolderUser() =>
+ Environment.GetFolderPath(Environment.SpecialFolder.Startup);
+
+ private static string GetStartupFolderCommon() =>
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonStartup);
+
+ private static LauncherItem MakeEntryItem(StartupEntry e)
+ {
+ var cmdShort = e.Command.Length > 70 ? e.Command[..70] + "…" : e.Command;
+ return new LauncherItem(
+ e.Name,
+ $"{e.Scope} · {cmdShort}",
+ null,
+ ("copy", e.Command),
+ Symbol: "\uE7FC");
+ }
+}