diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md
index 79ac318..34152fd 100644
--- a/docs/LAUNCHER_ROADMAP.md
+++ b/docs/LAUNCHER_ROADMAP.md
@@ -230,3 +230,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
| 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` 핸들러(포트·프로세스 조회)와 역할 분리 | 중간 |
+
+---
+
+## Phase L8 — 파일·시스템 유틸리티 강화 (v2.0.1) ✅ 완료
+
+> **방향**: 파일 무결성 검증, 아카이브 조작, 시스템 이벤트 진단, SSH 원격 접속을 런처에서 바로 처리.
+
+| # | 기능 | 설명 | 우선순위 |
+|---|------|------|----------|
+| L8-1 | **파일 해시 검증** ✅ | `hash` 프리픽스. MD5/SHA1/SHA256/SHA512 비동기 계산. 경로 미입력 시 클립보드 파일 경로 자동 감지. `hash check <기대값>` 클립보드 해시 비교. 결과 클립보드 복사 | 높음 |
+| L8-2 | **아카이브 관리** ✅ | `zip` 프리픽스. System.IO.Compression 기반. `zip <경로>` 파일 목록 미리보기(최대 20개). `zip extract` 동일/지정 폴더 압축 해제. `zip folder <폴더>` 폴더→zip 압축. 클립보드 경로 자동 감지 | 중간 |
+| L8-3 | **시스템 이벤트 로그** ✅ | `evt` 프리픽스. 최근 24시간 System+Application 로그 조회. `evt error`/`evt warn`/`evt app`/`evt sys`/`evt <키워드>` 필터. EventLogEntry.InstanceId 기반. 이벤트 상세 클립보드 복사 | 중간 |
+| L8-4 | **SSH 퀵 커넥트** ✅ | `ssh` 프리픽스. `SshHostEntry` 모델 + `AppSettings.SshHosts` 영속화. `ssh add user@host[:port]` 저장. `ssh del <이름>` 삭제. Enter → Windows Terminal(wt.exe)/PuTTY/PowerShell 순 폴백 실행. 직접 `user@host` 입력 즉시 연결 지원 | 높음 |
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index b100401..57b40df 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -207,6 +207,16 @@ public partial class App : System.Windows.Application
// L7-4: 네트워크 진단 (prefix=net)
commandResolver.RegisterHandler(new NetDiagHandler());
+ // ─── Phase L8 핸들러 ──────────────────────────────────────────────────
+ // L8-1: 파일 해시 검증 (prefix=hash)
+ commandResolver.RegisterHandler(new FileHashHandler());
+ // L8-2: 아카이브 관리 (prefix=zip)
+ commandResolver.RegisterHandler(new ZipHandler());
+ // L8-3: 시스템 이벤트 로그 (prefix=evt)
+ commandResolver.RegisterHandler(new EventLogHandler());
+ // L8-4: SSH 퀵 커넥트 (prefix=ssh)
+ commandResolver.RegisterHandler(new SshHandler(settings));
+
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll();
diff --git a/src/AxCopilot/Handlers/EventLogHandler.cs b/src/AxCopilot/Handlers/EventLogHandler.cs
new file mode 100644
index 0000000..efece55
--- /dev/null
+++ b/src/AxCopilot/Handlers/EventLogHandler.cs
@@ -0,0 +1,157 @@
+using System.Diagnostics;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L8-3: 시스템 이벤트 로그 핸들러. "evt" 프리픽스로 사용합니다.
+///
+/// 예: evt → 최근 오류/경고 이벤트 (System + Application 합산)
+/// evt error → 오류(Error) 이벤트만
+/// evt warn → 경고(Warning) 이벤트만
+/// evt app → Application 로그
+/// evt sys → System 로그
+/// evt <키워드> → 소스 또는 메시지에 키워드 포함된 이벤트
+/// Enter → 이벤트 내용을 클립보드에 복사.
+///
+public class EventLogHandler : IActionHandler
+{
+ public string? Prefix => "evt";
+
+ public PluginMetadata Metadata => new(
+ "EventLog",
+ "Windows 이벤트 로그 — 오류 · 경고 · 소스별 조회",
+ "1.0",
+ "AX");
+
+ private const int MaxItems = 20;
+ private const int LookbackH = 24; // 최근 24시간
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim().ToLowerInvariant();
+ var items = new List();
+
+ try
+ {
+ // 필터 결정
+ var logName = "System";
+ var level = EventLogEntryType.Error; // 기본 오류
+ bool allLevel = false;
+ string keyword = "";
+
+ if (q == "app") { logName = "Application"; allLevel = false; }
+ else if (q == "sys") { logName = "System"; allLevel = false; }
+ else if (q == "error") { allLevel = false; level = EventLogEntryType.Error; }
+ else if (q is "warn" or "warning") { allLevel = false; level = EventLogEntryType.Warning; }
+ else if (string.IsNullOrEmpty(q)) { allLevel = false; /* Error 기본 */ }
+ else { keyword = q; allLevel = true; }
+
+ // System + Application 병합 또는 단일 로그
+ var logNames = string.IsNullOrEmpty(q) || q is "error" or "warn" or "warning"
+ ? new[] { "System", "Application" }
+ : logName == "Application" ? new[] { "Application" } : new[] { logName };
+
+ var entries = new List();
+ var cutoff = DateTime.Now.AddHours(-LookbackH);
+
+ foreach (var ln in logNames)
+ {
+ try
+ {
+ using var log = new EventLog(ln);
+ for (int i = log.Entries.Count - 1; i >= 0 && entries.Count < MaxItems * 2; i--)
+ {
+ var entry = log.Entries[i];
+ if (entry.TimeGenerated < cutoff) break;
+
+ bool matchLevel = allLevel
+ ? true
+ : entry.EntryType == level;
+
+ bool matchKeyword = string.IsNullOrEmpty(keyword)
+ ? true
+ : (entry.Source?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true
+ || entry.Message?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true);
+
+ if (matchLevel && matchKeyword)
+ entries.Add(entry);
+ }
+ }
+ catch { /* 특정 로그 접근 실패 시 무시 */ }
+ }
+
+ if (entries.Count == 0)
+ {
+ items.Add(new LauncherItem(
+ "이벤트 없음",
+ $"최근 {LookbackH}시간 내 해당 이벤트가 없습니다",
+ null, null, Symbol: "\uE73E"));
+ return Task.FromResult>(items);
+ }
+
+ // 정렬 (최신 순) 및 중복 제거
+ var sorted = entries
+ .OrderByDescending(e => e.TimeGenerated)
+ .Take(MaxItems)
+ .ToList();
+
+ foreach (var entry in sorted)
+ {
+ var icon = entry.EntryType == EventLogEntryType.Error ? "\uE783" :
+ entry.EntryType == EventLogEntryType.Warning ? "\uE7BA" : "\uE946";
+ var level2 = entry.EntryType == EventLogEntryType.Error ? "오류" :
+ entry.EntryType == EventLogEntryType.Warning ? "경고" : "정보";
+
+ var msg = entry.Message ?? "";
+ if (msg.Length > 80) msg = msg[..80].Replace('\n', ' ').Replace('\r', ' ') + "…";
+
+ items.Add(new LauncherItem(
+ $"[{level2}] {entry.Source}",
+ $"{entry.TimeGenerated:MM-dd HH:mm} · {msg}",
+ null,
+ ("copy_event", FormatEvent(entry)),
+ Symbol: icon));
+ }
+ }
+ catch (Exception ex)
+ {
+ items.Add(new LauncherItem(
+ "이벤트 로그 접근 실패",
+ ex.Message,
+ null, null, Symbol: "\uE783"));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is ("copy_event", string eventText))
+ {
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(
+ () => Clipboard.SetText(eventText));
+ NotificationService.Notify("EventLog", "이벤트 정보를 클립보드에 복사했습니다.");
+ }
+ catch { /* 비핵심 */ }
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 헬퍼 ────────────────────────────────────────────────────────────────
+
+ private static string FormatEvent(EventLogEntry e) =>
+ $"""
+ [이벤트 ID] {e.InstanceId}
+ [시각] {e.TimeGenerated:yyyy-MM-dd HH:mm:ss}
+ [유형] {e.EntryType}
+ [소스] {e.Source}
+ [메시지]
+ {e.Message}
+ """;
+}
diff --git a/src/AxCopilot/Handlers/FileHashHandler.cs b/src/AxCopilot/Handlers/FileHashHandler.cs
new file mode 100644
index 0000000..8761435
--- /dev/null
+++ b/src/AxCopilot/Handlers/FileHashHandler.cs
@@ -0,0 +1,273 @@
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L8-1: 파일 해시 검증 핸들러. "hash" 프리픽스로 사용합니다.
+///
+/// 예: hash → 사용법 안내
+/// hash C:\file.zip → SHA256 (기본) 계산
+/// hash md5 C:\file.zip → MD5 계산
+/// hash sha1 C:\file.zip → SHA1 계산
+/// hash sha512 C:\file.zip → SHA512 계산
+/// hash check <기대값> → 클립보드의 해시값과 비교
+/// 경로 미입력 시 클립보드에서 파일 경로 자동 감지.
+/// Enter → 해시 결과를 클립보드에 복사.
+///
+public class FileHashHandler : IActionHandler
+{
+ public string? Prefix => "hash";
+
+ public PluginMetadata Metadata => new(
+ "FileHash",
+ "파일 해시 검증 — MD5 · SHA1 · SHA256 · SHA512",
+ "1.0",
+ "AX");
+
+ private static readonly string[] Algos = ["md5", "sha1", "sha256", "sha512"];
+
+ public async Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ // 클립보드에 파일 경로가 있으면 자동 감지
+ var clipPath = GetClipboardFilePath();
+ if (!string.IsNullOrEmpty(clipPath))
+ {
+ items.Add(new LauncherItem(
+ $"SHA256: {Path.GetFileName(clipPath)}",
+ clipPath,
+ null,
+ ("compute", "sha256", clipPath),
+ Symbol: "\uE8C4"));
+
+ foreach (var algo in Algos)
+ items.Add(new LauncherItem(
+ $"hash {algo}",
+ $"{algo.ToUpperInvariant()} 계산",
+ null,
+ ("compute", algo, clipPath),
+ Symbol: "\uE8C4"));
+ }
+ else
+ {
+ items.Add(new LauncherItem(
+ "파일 해시 계산",
+ "hash <경로> 또는 hash md5|sha1|sha256|sha512 <경로>",
+ null, null, Symbol: "\uE8C4"));
+ items.Add(new LauncherItem(
+ "hash check <기대 해시값>",
+ "클립보드의 해시와 비교 검증",
+ null, null, Symbol: "\uE73E"));
+ }
+ return items;
+ }
+
+ // "check " — 클립보드 해시 비교
+ if (q.StartsWith("check ", StringComparison.OrdinalIgnoreCase))
+ {
+ var expected = q[6..].Trim();
+ var clipText = GetClipboardText()?.Trim();
+ if (!string.IsNullOrEmpty(clipText) && !string.IsNullOrEmpty(expected))
+ {
+ var match = expected.Equals(clipText, StringComparison.OrdinalIgnoreCase);
+ items.Add(new LauncherItem(
+ match ? "✓ 해시 일치" : "✗ 해시 불일치",
+ $"기대값: {Truncate(expected, 40)}",
+ null, null,
+ Symbol: match ? "\uE73E" : "\uE711"));
+ if (!match)
+ items.Add(new LauncherItem(
+ "클립보드",
+ Truncate(clipText, 60),
+ null, null, Symbol: "\uE8C8"));
+ }
+ else
+ {
+ items.Add(new LauncherItem(
+ "비교 대상 없음",
+ "먼저 해시 계산 결과를 클립보드에 복사하세요",
+ null, null, Symbol: "\uE783"));
+ }
+ return items;
+ }
+
+ // 알고리즘 + 경로 파싱
+ string algo2 = "sha256";
+ string filePath = q;
+
+ var parts = q.Split(' ', 2);
+ if (parts.Length == 2 && Algos.Contains(parts[0].ToLowerInvariant()))
+ {
+ algo2 = parts[0].ToLowerInvariant();
+ filePath = parts[1].Trim().Trim('"');
+ }
+ else
+ {
+ // 알고리즘 없이 경로만 → 모든 알고리즘 표시
+ filePath = q.Trim('"');
+ }
+
+ if (!File.Exists(filePath))
+ {
+ // 클립보드 경로 시도
+ var clipPath = GetClipboardFilePath();
+ if (!string.IsNullOrEmpty(clipPath) && File.Exists(clipPath))
+ filePath = clipPath;
+ else
+ {
+ items.Add(new LauncherItem(
+ "파일을 찾을 수 없음",
+ filePath,
+ null, null, Symbol: "\uE783"));
+ return items;
+ }
+ }
+
+ var fileName = Path.GetFileName(filePath);
+ var fileSize = new FileInfo(filePath).Length;
+ var sizeMb = fileSize / 1024.0 / 1024.0;
+
+ if (algo2 == "sha256" && parts.Length == 1)
+ {
+ // 경로만 입력 → 모든 알고리즘 항목 표시
+ items.Add(new LauncherItem(
+ fileName,
+ $"{sizeMb:F1} MB",
+ null, null, Symbol: "\uE8F4"));
+
+ foreach (var a in Algos)
+ {
+ items.Add(new LauncherItem(
+ a.ToUpperInvariant(),
+ "계산 중... (Enter로 실행)",
+ null,
+ ("compute", a, filePath),
+ Symbol: "\uE8C4"));
+ }
+ }
+ else
+ {
+ // 특정 알고리즘 계산
+ items.Add(new LauncherItem(
+ $"계산 중: {algo2.ToUpperInvariant()}",
+ $"{fileName} ({sizeMb:F1} MB)",
+ null,
+ ("compute", algo2, filePath),
+ Symbol: "\uE8C4"));
+
+ try
+ {
+ var hash = await ComputeHashAsync(filePath, algo2, ct);
+ items.Clear();
+ items.Add(new LauncherItem(
+ hash,
+ $"{algo2.ToUpperInvariant()} · {fileName}",
+ null,
+ ("copy", hash),
+ Symbol: "\uE8C4"));
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ items.Add(new LauncherItem("해시 계산 실패", ex.Message, null, null, Symbol: "\uE783"));
+ }
+ }
+
+ return items;
+ }
+
+ public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("copy", string hash):
+ TryCopyToClipboard(hash);
+ NotificationService.Notify("FileHash", "해시를 클립보드에 복사했습니다.");
+ break;
+
+ case ("compute", string algo, string filePath):
+ try
+ {
+ var hash = await ComputeHashAsync(filePath, algo, ct);
+ TryCopyToClipboard(hash);
+ NotificationService.Notify(
+ $"{algo.ToUpperInvariant()} 완료",
+ $"{Path.GetFileName(filePath)}: {Truncate(hash, 32)}…");
+ }
+ catch (Exception ex)
+ {
+ NotificationService.Notify("FileHash 오류", ex.Message);
+ }
+ break;
+ }
+ }
+
+ // ── 헬퍼 ────────────────────────────────────────────────────────────────
+
+ private static async Task ComputeHashAsync(
+ string filePath, string algo, CancellationToken ct)
+ {
+ using HashAlgorithm hasher = algo.ToLowerInvariant() switch
+ {
+ "md5" => MD5.Create(),
+ "sha1" => SHA1.Create(),
+ "sha512" => SHA512.Create(),
+ _ => SHA256.Create(),
+ };
+
+ await using var stream = File.OpenRead(filePath);
+ var hashBytes = await Task.Run(() => hasher.ComputeHash(stream), ct);
+ return Convert.ToHexString(hashBytes).ToLowerInvariant();
+ }
+
+ private static string? GetClipboardFilePath()
+ {
+ try
+ {
+ string? text = null;
+ System.Windows.Application.Current.Dispatcher.Invoke(() =>
+ {
+ if (Clipboard.ContainsText())
+ text = Clipboard.GetText()?.Trim().Trim('"');
+ });
+ return !string.IsNullOrEmpty(text) && File.Exists(text) ? text : null;
+ }
+ catch { return null; }
+ }
+
+ private static string? GetClipboardText()
+ {
+ try
+ {
+ string? text = null;
+ System.Windows.Application.Current.Dispatcher.Invoke(() =>
+ {
+ if (Clipboard.ContainsText()) text = Clipboard.GetText();
+ });
+ return text;
+ }
+ catch { return null; }
+ }
+
+ private static void TryCopyToClipboard(string text)
+ {
+ try
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ }
+ catch { /* 비핵심 */ }
+ }
+
+ private static string Truncate(string s, int max) =>
+ s.Length <= max ? s : s[..max];
+}
diff --git a/src/AxCopilot/Handlers/SshHandler.cs b/src/AxCopilot/Handlers/SshHandler.cs
new file mode 100644
index 0000000..ee94856
--- /dev/null
+++ b/src/AxCopilot/Handlers/SshHandler.cs
@@ -0,0 +1,341 @@
+using System.Diagnostics;
+using System.Windows;
+using AxCopilot.Models;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L8-4: SSH 퀵 커넥트 핸들러. "ssh" 프리픽스로 사용합니다.
+///
+/// 예: ssh → 저장된 SSH 호스트 목록
+/// ssh dev → 이름/호스트에 "dev" 포함된 항목 필터
+/// ssh add user@host → 빠른 호스트 추가 (이름 = host, 포트 22)
+/// ssh add name user@host:22 → 이름 지정하여 추가
+/// ssh del <이름> → 호스트 삭제
+/// Enter → Windows Terminal(ssh) 또는 PuTTY로 연결.
+/// 사내 모드에서도 항상 사용 가능 (SSH는 내부 서버 접속이 주 용도).
+///
+public class SshHandler : IActionHandler
+{
+ private readonly SettingsService _settings;
+
+ public SshHandler(SettingsService settings) { _settings = settings; }
+
+ public string? Prefix => "ssh";
+
+ public PluginMetadata Metadata => new(
+ "SSH",
+ "SSH 퀵 커넥트 — 호스트 저장 · 빠른 연결",
+ "1.0",
+ "AX");
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+ var hosts = _settings.Settings.SshHosts;
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ if (hosts.Count == 0)
+ {
+ items.Add(new LauncherItem(
+ "SSH 호스트 없음",
+ "ssh add user@host 또는 ssh add 이름 user@host:22",
+ null, null, Symbol: "\uE968"));
+ }
+ else
+ {
+ foreach (var h in hosts)
+ items.Add(MakeHostItem(h));
+ }
+ items.Add(new LauncherItem(
+ "ssh add user@host",
+ "새 호스트 추가",
+ null, null, Symbol: "\uE710"));
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
+ var sub = parts[0].ToLowerInvariant();
+
+ // ── add ─────────────────────────────────────────────────────────
+ if (sub == "add")
+ {
+ SshHostEntry? entry = null;
+
+ if (parts.Length == 2)
+ {
+ // ssh add user@host[:port]
+ entry = ParseUserHost(parts[1]);
+ if (entry != null) entry.Name = entry.Host;
+ }
+ else if (parts.Length == 3)
+ {
+ // ssh add user@host[:port]
+ entry = ParseUserHost(parts[2]);
+ if (entry != null) entry.Name = parts[1];
+ }
+
+ if (entry != null)
+ {
+ items.Add(new LauncherItem(
+ $"추가: {entry.Name}",
+ $"{entry.User}@{entry.Host}:{entry.Port}",
+ null,
+ ("add", entry),
+ Symbol: "\uE710"));
+ }
+ else
+ {
+ items.Add(new LauncherItem(
+ "형식: ssh add user@host[:port]",
+ "또는: ssh add 이름 user@host:22",
+ null, null, Symbol: "\uE783"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // ── del ─────────────────────────────────────────────────────────
+ if (sub == "del" || sub == "delete" || sub == "rm")
+ {
+ var nameQuery = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "";
+ var toDelete = hosts
+ .Where(h => h.Name.Contains(nameQuery, StringComparison.OrdinalIgnoreCase)
+ || h.Host.Contains(nameQuery, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (toDelete.Count == 0)
+ {
+ items.Add(new LauncherItem("삭제 대상 없음", nameQuery, null, null, Symbol: "\uE783"));
+ }
+ else
+ {
+ foreach (var h in toDelete)
+ items.Add(new LauncherItem(
+ $"삭제: {h.Name}",
+ $"{h.User}@{h.Host}:{h.Port}",
+ null,
+ ("del", h.Id),
+ Symbol: "\uE74D"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // ── 검색 (이름 / 호스트 / 사용자 / 메모) ─────────────────────────
+ var filtered = hosts.Where(h =>
+ h.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
+ || h.Host.Contains(q, StringComparison.OrdinalIgnoreCase)
+ || h.User.Contains(q, StringComparison.OrdinalIgnoreCase)
+ || h.Note.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (filtered.Count > 0)
+ {
+ foreach (var h in filtered)
+ items.Add(MakeHostItem(h));
+ }
+ else
+ {
+ // 직접 접속 시도 (user@host 형식)
+ var entry = ParseUserHost(q);
+ if (entry != null)
+ {
+ items.Add(new LauncherItem(
+ $"연결: {entry.User}@{entry.Host}:{entry.Port}",
+ "Enter → Windows Terminal로 연결",
+ null,
+ ("connect", entry),
+ Symbol: "\uE968"));
+ items.Add(new LauncherItem(
+ $"저장 후 연결",
+ $"이름: {entry.Host}",
+ null,
+ ("add_connect", entry),
+ Symbol: "\uE710"));
+ }
+ else
+ {
+ items.Add(new LauncherItem("호스트를 찾을 수 없음", q, null, null, Symbol: "\uE783"));
+ }
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("connect", SshHostEntry h):
+ ConnectSsh(h);
+ break;
+
+ case ("add", SshHostEntry h):
+ _settings.Settings.SshHosts.RemoveAll(e =>
+ e.Host.Equals(h.Host, StringComparison.OrdinalIgnoreCase)
+ && e.User.Equals(h.User, StringComparison.OrdinalIgnoreCase)
+ && e.Port == h.Port);
+ _settings.Settings.SshHosts.Add(h);
+ _settings.Save();
+ NotificationService.Notify("SSH", $"'{h.Name}' 호스트를 저장했습니다.");
+ break;
+
+ case ("add_connect", SshHostEntry h):
+ if (string.IsNullOrEmpty(h.Name)) h.Name = h.Host;
+ _settings.Settings.SshHosts.Add(h);
+ _settings.Save();
+ ConnectSsh(h);
+ break;
+
+ case ("del", string id):
+ var removed = _settings.Settings.SshHosts
+ .RemoveAll(e => e.Id == id);
+ if (removed > 0)
+ {
+ _settings.Save();
+ NotificationService.Notify("SSH", "호스트를 삭제했습니다.");
+ }
+ break;
+
+ // 호스트 항목 직접 Enter
+ case SshHostEntry host:
+ ConnectSsh(host);
+ break;
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 헬퍼 ────────────────────────────────────────────────────────────────
+
+ private static LauncherItem MakeHostItem(SshHostEntry h)
+ {
+ var portStr = h.Port != 22 ? $":{h.Port}" : "";
+ return new LauncherItem(
+ h.Name,
+ $"{h.User}@{h.Host}{portStr}{(string.IsNullOrEmpty(h.Note) ? "" : " · " + h.Note)}",
+ null,
+ h,
+ Symbol: "\uE968");
+ }
+
+ /// Windows Terminal 또는 PuTTY로 SSH 연결을 시작합니다.
+ private static void ConnectSsh(SshHostEntry h)
+ {
+ try
+ {
+ // Windows Terminal (wt.exe) 우선
+ var wtPath = FindExecutable("wt.exe");
+ if (!string.IsNullOrEmpty(wtPath))
+ {
+ var portArgs = h.Port != 22 ? $" -p {h.Port}" : "";
+ var userHost = string.IsNullOrEmpty(h.User)
+ ? h.Host : $"{h.User}@{h.Host}";
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = wtPath,
+ Arguments = $"ssh {userHost}{portArgs}",
+ UseShellExecute = true,
+ });
+ return;
+ }
+
+ // PuTTY 대체
+ var puttyPath = FindExecutable("putty.exe");
+ if (!string.IsNullOrEmpty(puttyPath))
+ {
+ var userHost = string.IsNullOrEmpty(h.User)
+ ? h.Host : $"{h.User}@{h.Host}";
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = puttyPath,
+ Arguments = $"-ssh {userHost} -P {h.Port}",
+ UseShellExecute = true,
+ });
+ return;
+ }
+
+ // PowerShell 폴백
+ var portArgs2 = h.Port != 22 ? $" -p {h.Port}" : "";
+ var cmd = string.IsNullOrEmpty(h.User)
+ ? $"ssh {h.Host}{portArgs2}"
+ : $"ssh {h.User}@{h.Host}{portArgs2}";
+
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = "powershell.exe",
+ Arguments = $"-NoExit -Command \"{cmd}\"",
+ UseShellExecute = true,
+ });
+ }
+ catch (Exception ex)
+ {
+ NotificationService.Notify("SSH 오류", ex.Message);
+ }
+ }
+
+ /// PATH 및 일반 설치 경로에서 실행 파일을 찾습니다.
+ private static string? FindExecutable(string exe)
+ {
+ // PATH 검색
+ var envPath = Environment.GetEnvironmentVariable("PATH") ?? "";
+ foreach (var dir in envPath.Split(';', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var full = System.IO.Path.Combine(dir.Trim(), exe);
+ if (System.IO.File.Exists(full)) return full;
+ }
+
+ // 일반 설치 경로
+ var candidates = new[]
+ {
+ System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Microsoft\\WindowsApps", exe),
+ System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), exe),
+ System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), exe),
+ };
+
+ return candidates.FirstOrDefault(System.IO.File.Exists);
+ }
+
+ /// user@host:port 형식을 파싱합니다.
+ private static SshHostEntry? ParseUserHost(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return null;
+
+ string user = "";
+ string host = s;
+ int port = 22;
+
+ // user@host 파싱
+ var atIdx = s.IndexOf('@');
+ if (atIdx >= 0)
+ {
+ user = s[..atIdx];
+ host = s[(atIdx + 1)..];
+ }
+
+ // host:port 파싱
+ var colonIdx = host.LastIndexOf(':');
+ if (colonIdx >= 0 && int.TryParse(host[(colonIdx + 1)..], out var p))
+ {
+ port = p;
+ host = host[..colonIdx];
+ }
+
+ if (string.IsNullOrEmpty(host)) return null;
+
+ return new SshHostEntry
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = host,
+ Host = host,
+ Port = port,
+ User = user,
+ };
+ }
+}
diff --git a/src/AxCopilot/Handlers/ZipHandler.cs b/src/AxCopilot/Handlers/ZipHandler.cs
new file mode 100644
index 0000000..7b97c94
--- /dev/null
+++ b/src/AxCopilot/Handlers/ZipHandler.cs
@@ -0,0 +1,303 @@
+using System.IO;
+using System.IO.Compression;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L8-2: 아카이브 관리 핸들러. "zip" 프리픽스로 사용합니다.
+///
+/// 예: zip → 사용법 안내
+/// zip C:\archive.zip → zip 내 파일 목록 미리보기
+/// zip list C:\archive.zip → 파일 목록 (클립보드 복사)
+/// zip extract C:\archive.zip → 같은 폴더에 압축 해제
+/// zip extract C:\a.zip C:\target → 지정 폴더에 압축 해제
+/// zip folder C:\MyFolder → 폴더를 zip으로 압축
+/// 경로 미입력 시 클립보드 경로 자동 감지.
+///
+public class ZipHandler : IActionHandler
+{
+ public string? Prefix => "zip";
+
+ public PluginMetadata Metadata => new(
+ "Zip",
+ "아카이브 관리 — zip 목록 · 압축 해제 · 폴더 압축",
+ "1.0",
+ "AX");
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ var clipPath = GetClipboardPath();
+ if (!string.IsNullOrEmpty(clipPath))
+ {
+ var isZip = clipPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
+ var isFolder = Directory.Exists(clipPath);
+
+ if (isZip && File.Exists(clipPath))
+ {
+ var info = GetZipInfo(clipPath);
+ items.Add(new LauncherItem(
+ Path.GetFileName(clipPath),
+ $"{info.Count}개 파일 · {info.TotalSizeMb:F1} MB (압축 전)",
+ null, null, Symbol: "\uE8B7"));
+
+ items.Add(new LauncherItem(
+ "zip list",
+ "파일 목록 표시 및 클립보드 복사",
+ null, ("list", clipPath), Symbol: "\uE8A4"));
+
+ items.Add(new LauncherItem(
+ "zip extract",
+ $"압축 해제 → {Path.GetDirectoryName(clipPath)}",
+ null, ("extract", clipPath, ""), Symbol: "\uE8B7"));
+ }
+ else if (isFolder)
+ {
+ var outputPath = clipPath.TrimEnd('\\', '/') + ".zip";
+ items.Add(new LauncherItem(
+ "zip folder",
+ $"{Path.GetFileName(clipPath)} → {Path.GetFileName(outputPath)}",
+ null, ("compress", clipPath, outputPath), Symbol: "\uE8B7"));
+ }
+ }
+
+ items.Add(new LauncherItem(
+ "zip <경로>",
+ "zip 파일 목록 미리보기",
+ null, null, Symbol: "\uE8B7"));
+ items.Add(new LauncherItem(
+ "zip extract <경로>",
+ "압축 해제",
+ null, null, Symbol: "\uE8B7"));
+ items.Add(new LauncherItem(
+ "zip folder <폴더>",
+ "폴더를 zip으로 압축",
+ null, null, Symbol: "\uE8B7"));
+
+ return Task.FromResult>(items);
+ }
+
+ // 서브커맨드 파싱
+ var parts = q.Split(' ', 3);
+ var sub = parts[0].ToLowerInvariant();
+
+ // "extract" 서브커맨드
+ if (sub == "extract" || sub == "unzip")
+ {
+ var zipPath = parts.Length >= 2 ? parts[1].Trim('"') : "";
+ var targetDir = parts.Length >= 3 ? parts[2].Trim('"') : "";
+
+ if (string.IsNullOrEmpty(zipPath))
+ {
+ var clip = GetClipboardPath();
+ if (!string.IsNullOrEmpty(clip) && File.Exists(clip)) zipPath = clip;
+ }
+
+ if (!File.Exists(zipPath))
+ {
+ items.Add(new LauncherItem("파일을 찾을 수 없음", zipPath, null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ var dest = string.IsNullOrEmpty(targetDir)
+ ? Path.Combine(Path.GetDirectoryName(zipPath)!,
+ Path.GetFileNameWithoutExtension(zipPath))
+ : targetDir;
+
+ var info = GetZipInfo(zipPath);
+ items.Add(new LauncherItem(
+ $"압축 해제 — {info.Count}개 파일",
+ $"→ {dest}",
+ null,
+ ("extract", zipPath, dest),
+ Symbol: "\uE8B7"));
+
+ return Task.FromResult>(items);
+ }
+
+ // "folder" 또는 "compress" 서브커맨드
+ if (sub == "folder" || sub == "compress")
+ {
+ var srcFolder = parts.Length >= 2 ? parts[1].Trim('"') : "";
+ if (!Directory.Exists(srcFolder))
+ {
+ var clip = GetClipboardPath();
+ if (!string.IsNullOrEmpty(clip) && Directory.Exists(clip)) srcFolder = clip;
+ else
+ {
+ items.Add(new LauncherItem("폴더를 찾을 수 없음", srcFolder, null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+ }
+
+ var outputZip = parts.Length >= 3
+ ? parts[2].Trim('"')
+ : srcFolder.TrimEnd('\\', '/') + ".zip";
+
+ var fileCount = Directory.GetFiles(srcFolder, "*", SearchOption.AllDirectories).Length;
+ items.Add(new LauncherItem(
+ $"압축 — {fileCount}개 파일",
+ $"{Path.GetFileName(srcFolder)} → {Path.GetFileName(outputZip)}",
+ null,
+ ("compress", srcFolder, outputZip),
+ Symbol: "\uE8B7"));
+
+ return Task.FromResult>(items);
+ }
+
+ // "list" 서브커맨드 또는 직접 경로 입력
+ var zipFilePath = (sub == "list" && parts.Length >= 2)
+ ? parts[1].Trim('"')
+ : q.Trim('"');
+
+ if (!File.Exists(zipFilePath))
+ {
+ var clip = GetClipboardPath();
+ zipFilePath = (!string.IsNullOrEmpty(clip) && File.Exists(clip)) ? clip : zipFilePath;
+ }
+
+ if (!File.Exists(zipFilePath))
+ {
+ items.Add(new LauncherItem("파일을 찾을 수 없음", zipFilePath, null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ // 목록 미리보기
+ try
+ {
+ using var archive = ZipFile.OpenRead(zipFilePath);
+ var entries = archive.Entries.OrderBy(e => e.FullName).ToList();
+ var totalSize = entries.Sum(e => e.Length);
+
+ items.Add(new LauncherItem(
+ Path.GetFileName(zipFilePath),
+ $"{entries.Count}개 항목 · {totalSize / 1024.0 / 1024.0:F1} MB",
+ null,
+ ("list", zipFilePath),
+ Symbol: "\uE8B7"));
+
+ items.Add(new LauncherItem(
+ "압축 해제 →",
+ Path.Combine(Path.GetDirectoryName(zipFilePath)!,
+ Path.GetFileNameWithoutExtension(zipFilePath)),
+ null,
+ ("extract", zipFilePath, ""),
+ Symbol: "\uE8B7"));
+
+ foreach (var entry in entries.Take(20))
+ {
+ items.Add(new LauncherItem(
+ entry.FullName,
+ $"{entry.Length / 1024.0:F0} KB",
+ null,
+ ("copy_entry", entry.FullName),
+ Symbol: entry.FullName.EndsWith('/') ? "\uED25" : "\uE8A5"));
+ }
+
+ if (entries.Count > 20)
+ items.Add(new LauncherItem(
+ $"… +{entries.Count - 20}개 더",
+ "전체 목록: 첫 항목 Enter → 클립보드 복사",
+ null, null, Symbol: "\uE712"));
+ }
+ catch (Exception ex)
+ {
+ items.Add(new LauncherItem("zip 읽기 실패", ex.Message, null, null, Symbol: "\uE783"));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ switch (item.Data)
+ {
+ case ("list", string zipPath):
+ try
+ {
+ using var archive = ZipFile.OpenRead(zipPath);
+ var list = string.Join("\n", archive.Entries.Select(e => e.FullName));
+ TryCopyToClipboard(list);
+ NotificationService.Notify("Zip", $"{archive.Entries.Count}개 항목을 클립보드에 복사했습니다.");
+ }
+ catch (Exception ex)
+ {
+ NotificationService.Notify("Zip 오류", ex.Message);
+ }
+ break;
+
+ case ("extract", string zipPath, string targetDir):
+ await Task.Run(() =>
+ {
+ var dest = string.IsNullOrEmpty(targetDir)
+ ? Path.Combine(Path.GetDirectoryName(zipPath)!,
+ Path.GetFileNameWithoutExtension(zipPath))
+ : targetDir;
+
+ ZipFile.ExtractToDirectory(zipPath, dest, overwriteFiles: true);
+ NotificationService.Notify("압축 해제 완료", dest);
+ }, ct);
+ break;
+
+ case ("compress", string srcFolder, string outputZip):
+ await Task.Run(() =>
+ {
+ if (File.Exists(outputZip)) File.Delete(outputZip);
+ ZipFile.CreateFromDirectory(srcFolder, outputZip);
+ var sizeMb = new FileInfo(outputZip).Length / 1024.0 / 1024.0;
+ NotificationService.Notify(
+ "압축 완료",
+ $"{Path.GetFileName(outputZip)} ({sizeMb:F1} MB)");
+ }, ct);
+ break;
+
+ case ("copy_entry", string entryName):
+ TryCopyToClipboard(entryName);
+ break;
+ }
+ }
+
+ // ── 헬퍼 ────────────────────────────────────────────────────────────────
+
+ private static (int Count, double TotalSizeMb) GetZipInfo(string zipPath)
+ {
+ try
+ {
+ using var archive = ZipFile.OpenRead(zipPath);
+ return (archive.Entries.Count, archive.Entries.Sum(e => e.Length) / 1024.0 / 1024.0);
+ }
+ catch { return (0, 0); }
+ }
+
+ private static string? GetClipboardPath()
+ {
+ try
+ {
+ string? text = null;
+ System.Windows.Application.Current.Dispatcher.Invoke(() =>
+ {
+ if (Clipboard.ContainsText())
+ text = Clipboard.GetText()?.Trim().Trim('"');
+ });
+ 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/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs
index c870c12..6639e3c 100644
--- a/src/AxCopilot/Models/AppSettings.Models.cs
+++ b/src/AxCopilot/Models/AppSettings.Models.cs
@@ -431,6 +431,37 @@ public class MacroStep
public int DelayMs { get; set; } = 500;
}
+// ─── SSH 퀵 커넥트 호스트 ─────────────────────────────────────────────────────
+
+///
+/// SSH 퀵 커넥트 호스트 항목. ssh 핸들러로 저장·연결합니다.
+///
+public class SshHostEntry
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+
+ /// 표시 이름 (예: "dev-server", "prod-web")
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ /// 호스트 주소 (IP 또는 도메인)
+ [JsonPropertyName("host")]
+ public string Host { get; set; } = "";
+
+ /// SSH 포트. 기본값 22.
+ [JsonPropertyName("port")]
+ public int Port { get; set; } = 22;
+
+ /// SSH 사용자명
+ [JsonPropertyName("user")]
+ public string User { get; set; } = "";
+
+ /// 메모 (예: "운영서버", "DB 전용")
+ [JsonPropertyName("note")]
+ public string Note { get; set; } = "";
+}
+
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
public class ReminderSettings
diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs
index cc1ddd7..250e4c8 100644
--- a/src/AxCopilot/Models/AppSettings.cs
+++ b/src/AxCopilot/Models/AppSettings.cs
@@ -130,6 +130,10 @@ public class AppSettings
[JsonPropertyName("macros")]
public List Macros { get; set; } = new();
+ /// SSH 퀵 커넥트 호스트 목록. ssh 핸들러로 관리합니다.
+ [JsonPropertyName("ssh_hosts")]
+ public List SshHosts { get; set; } = new();
+
[JsonPropertyName("llm")]
public LlmSettings Llm { get; set; } = new();
}