[Phase L8] 파일·시스템 유틸리티 핸들러 4종 추가

FileHashHandler.cs (200줄, prefix=hash):
- MD5/SHA1/SHA256/SHA512 비동기 해시 계산
- 클립보드 파일 경로 자동 감지
- hash check <기대값>으로 클립보드 해시 비교

ZipHandler.cs (260줄, prefix=zip):
- System.IO.Compression 기반 목록·추출·압축
- zip list: 파일 목록 미리보기 (최대 20개)
- zip extract: 동일/지정 폴더 압축 해제
- zip folder: 폴더→zip 압축

EventLogHandler.cs (165줄, prefix=evt):
- System+Application 로그 최근 24시간 조회
- evt error/warn/app/sys/<키워드> 필터
- InstanceId 기반 (EventID deprecated 경고 수정)
- 이벤트 상세 클립보드 복사

SshHandler.cs (270줄, prefix=ssh):
- SshHostEntry 모델 + AppSettings.SshHosts 영속화
- ssh add user@host[:port], ssh del <이름>
- Windows Terminal/PuTTY/PowerShell 순 폴백 연결
- 직접 user@host 입력 즉시 연결 지원

AppSettings.Models.cs: SshHostEntry 클래스 추가
AppSettings.cs: SshHosts 프로퍼티 추가
App.xaml.cs: Phase L8 핸들러 4종 등록
docs/LAUNCHER_ROADMAP.md: Phase L8 섹션 추가 

빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 14:06:24 +09:00
parent 7ca34d2ef4
commit 212ed9519e
8 changed files with 1132 additions and 0 deletions

View File

@@ -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` 입력 즉시 연결 지원 | 높음 |

View File

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

View File

@@ -0,0 +1,157 @@
using System.Diagnostics;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L8-3: 시스템 이벤트 로그 핸들러. "evt" 프리픽스로 사용합니다.
///
/// 예: evt → 최근 오류/경고 이벤트 (System + Application 합산)
/// evt error → 오류(Error) 이벤트만
/// evt warn → 경고(Warning) 이벤트만
/// evt app → Application 로그
/// evt sys → System 로그
/// evt <키워드> → 소스 또는 메시지에 키워드 포함된 이벤트
/// Enter → 이벤트 내용을 클립보드에 복사.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
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<EventLogEntry>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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}
""";
}

View File

@@ -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;
/// <summary>
/// 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 → 해시 결과를 클립보드에 복사.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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 <hashValue>" — 클립보드 해시 비교
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<string> 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];
}

View File

@@ -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;
/// <summary>
/// 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는 내부 서버 접속이 주 용도).
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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 <name> 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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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");
}
/// <summary>Windows Terminal 또는 PuTTY로 SSH 연결을 시작합니다.</summary>
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);
}
}
/// <summary>PATH 및 일반 설치 경로에서 실행 파일을 찾습니다.</summary>
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);
}
/// <summary>user@host:port 형식을 파싱합니다.</summary>
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,
};
}
}

View File

@@ -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;
/// <summary>
/// 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으로 압축
/// 경로 미입력 시 클립보드 경로 자동 감지.
/// </summary>
public class ZipHandler : IActionHandler
{
public string? Prefix => "zip";
public PluginMetadata Metadata => new(
"Zip",
"아카이브 관리 — zip 목록 · 압축 해제 · 폴더 압축",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
var 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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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 { /* 비핵심 */ }
}
}

View File

@@ -431,6 +431,37 @@ public class MacroStep
public int DelayMs { get; set; } = 500;
}
// ─── SSH 퀵 커넥트 호스트 ─────────────────────────────────────────────────────
/// <summary>
/// SSH 퀵 커넥트 호스트 항목. ssh 핸들러로 저장·연결합니다.
/// </summary>
public class SshHostEntry
{
[JsonPropertyName("id")]
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>표시 이름 (예: "dev-server", "prod-web")</summary>
[JsonPropertyName("name")]
public string Name { get; set; } = "";
/// <summary>호스트 주소 (IP 또는 도메인)</summary>
[JsonPropertyName("host")]
public string Host { get; set; } = "";
/// <summary>SSH 포트. 기본값 22.</summary>
[JsonPropertyName("port")]
public int Port { get; set; } = 22;
/// <summary>SSH 사용자명</summary>
[JsonPropertyName("user")]
public string User { get; set; } = "";
/// <summary>메모 (예: "운영서버", "DB 전용")</summary>
[JsonPropertyName("note")]
public string Note { get; set; } = "";
}
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
public class ReminderSettings

View File

@@ -130,6 +130,10 @@ public class AppSettings
[JsonPropertyName("macros")]
public List<MacroEntry> Macros { get; set; } = new();
/// <summary>SSH 퀵 커넥트 호스트 목록. ssh 핸들러로 관리합니다.</summary>
[JsonPropertyName("ssh_hosts")]
public List<SshHostEntry> SshHosts { get; set; } = new();
[JsonPropertyName("llm")]
public LlmSettings Llm { get; set; } = new();
}