From d6c7f65d6cba2c3f9572ad262525cf9850b66a99 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 16:46:14 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L21]=20TOML=C2=B7Log=C2=B7PS=C2=B7Key?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=9F=AC=204=EC=A2=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20L21~L23=20=EB=A1=9C=EB=93=9C=EB=A7=B5=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20=EC=88=98=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TomlHandler (prefix=toml, 290줄, partial class): - 외부 라이브러리 없는 경량 TOML 파서 - [section]/[[array-of-tables]] 파싱, 스칼라(bool/int/float/string)/배열/인라인 테이블 - toml validate·keys·sections·get ·stats·flat 서브커맨드 - 점 표기법 경로 조회(GetByPath), 재귀 평탄화(FlattenTable) - CS8602 null 경고 수정(table![s] 명시) LogHandler (prefix=log, 290줄, partial class): - ERROR/WARN/INFO/DEBUG 레벨 자동 감지(키워드 기반) - 클립보드 자동 읽기 또는 log <파일경로> 직접 입력 - error·warn·info·debug·last ·head ·grep <키워드>·stats·exceptions·today 서브커맨드 - 날짜별 분포 통계(yyyy-MM-dd 패턴), 스택트레이스 블록 추출 - [GeneratedRegex] DatePattern, using System.Text 누락 수정 PsHandler (prefix=ps, 200줄): - 68개 PowerShell 명령 내장(file/process/service/network/registry/string/date/pipe 8개 카테고리) - ps <카테고리> 전체 목록, ps <키워드> 전체 검색 - ps run <명령> → Windows Terminal / PowerShell 터미널 직접 실행 - Enter 클립보드 복사, 카테고리 오버뷰 표시 KeyHandler (prefix=key, 190줄): - Windows(26개)·VS Code(29개)·Chrome/Edge(22개)·Vim(23개)·Excel(20개)·Windows Terminal(13개) - 총 133개 단축키 내장 - key <앱> 카테고리 조회, key <키워드> 전체 앱 통합 검색 - Enter → 단축키 클립보드 복사 LAUNCHER_ROADMAP.md: - Phase L21~L23 계획 추가(L22: proc·geo·cargo·pip, L23: k8s·gh·choco·cmp) - L21 모든 항목 ✅ 완료 표시 - 빌드: 경고 0, 오류 0 --- docs/LAUNCHER_ROADMAP.md | 39 +++ src/AxCopilot/App.xaml.cs | 8 + src/AxCopilot/Handlers/KeyHandler.cs | 289 ++++++++++++++++++ src/AxCopilot/Handlers/LogHandler.cs | 402 ++++++++++++++++++++++++++ src/AxCopilot/Handlers/PsHandler.cs | 268 +++++++++++++++++ src/AxCopilot/Handlers/TomlHandler.cs | 372 ++++++++++++++++++++++++ 6 files changed, 1378 insertions(+) create mode 100644 src/AxCopilot/Handlers/KeyHandler.cs create mode 100644 src/AxCopilot/Handlers/LogHandler.cs create mode 100644 src/AxCopilot/Handlers/PsHandler.cs create mode 100644 src/AxCopilot/Handlers/TomlHandler.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index d94fb5a..15fbea2 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -399,3 +399,42 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L20-2 | **랜덤 생성기** ✅ | `rand` 프리픽스. 기본: 1~100 난수. `rand ` / `rand `. `rand str [len] [charset]` 영숫자/alpha/num/hex/special 문자셋. `rand color` HEX+RGB+HSL 랜덤 색상 5개. `rand dice [NdS]` 다면체 주사위(1d6~100d10000). `rand coin` 동전 던지기. `rand pick/shuffle` 항목 선택·셔플. `rand uuid` UUID v4. `rand token` RandomNumberGenerator 보안 토큰. `rand pin [len]` PIN 번호 | 높음 | | L20-3 | **문자열 조작 도구** ✅ | `str` 프리픽스. `str escape/unescape html/url/json/regex` 이스케이프 변환. `str repeat [sep]` 반복. `str pad [left/right/both] [char]` 패딩. `str wrap ` 단어 단위 줄바꿈. `str sort [desc]` 줄 정렬. `str unique` 중복 제거. `str join/split ` 구분자 변환. `str replace ` 치환. `str extract email/url/number/ip` 패턴 추출. `str lines` 줄/단어/문자 통계. [GeneratedRegex] 소스 생성기 | 높음 | | L20-4 | **Unix 파일 권한 계산기** ✅ | `perm` 프리픽스. `perm 755` 8진수→기호(rwxr-xr-x)·소유자/그룹/기타 상세 설명·용도 안내·관련 권한 제안. `perm rwxr-xr-x` 기호→8진수 역변환. `perm +x/-x/+w/-r 644` 비트 수정 연산. `perm umask 022` umask 적용 시 파일(666)/디렉토리(777) 결과 계산. `perm common` 14가지 자주 쓰는 권한 목록. chmod 명령 자동 생성 | 높음 | + +--- + +## Phase L21 — TOML·Log·PowerShell·단축키 도구 (v2.1.0) ✅ 완료 + +> **방향**: 개발자 설정 파일·로그 분석·Windows 자동화·단축키 생산성 강화. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L21-1 | **TOML 파서·분석기** ✅ | `toml` 프리픽스. 클립보드 자동 읽기. 외부 라이브러리 없이 순수 구현. `toml validate` 유효성 검사. `toml keys` 최상위 키 목록. `toml get key.sub` 점 표기법 경로 조회. `toml stats` 줄·키·섹션 통계. `toml flat` 평탄화. YAML/JSON과 같은 패턴의 3번째 설정 파일 형식 지원 | 높음 | +| L21-2 | **로그 파일 분석기** ✅ | `log` 프리픽스. 클립보드 또는 `log <경로>` 파일 경로 입력. ERROR/WARN/INFO/DEBUG 레벨 파싱 + 건수 요약. `log error` 오류 줄만 필터. `log last ` 마지막 N줄 표시(tail). `log grep <키워드>` 키워드 필터. `log stats` 레벨별 통계 + 시간대 분포. 스택트레이스·예외 패턴 자동 감지 | 높음 | +| L21-3 | **PowerShell 명령 생성기** ✅ | `ps` 프리픽스. 자주 쓰는 PowerShell 명령어 빠른 조회·생성·실행. 카테고리별 명령(파일/프로세스/네트워크/서비스/레지스트리/이벤트). `ps <키워드>` 명령 검색. `ps <명령어>` 생성 → Enter 시 PowerShell 터미널 실행. 파이프라인 예시 표시. 원라이너 복사 지원 | 높음 | +| L21-4 | **단축키 참조 사전** ✅ | `key` 프리픽스. Windows/VS Code/Chrome/Vim/Excel 5개 앱 단축키 내장(100개+). `key vscode` 전체 목록. `key vscode find` 키워드 검색. `key win` Windows 단축키. `key chrome` 브라우저 단축키. `key vim` Vim 명령. 단축키 → 기능 설명 양방향 검색. Enter → 단축키 클립보드 복사 | 높음 | + +--- + +## Phase L22 — 프로세스·좌표·Cargo·pip 도구 (v2.1.0) 🔄 예정 + +> **방향**: 시스템 모니터링·지리 계산·언어별 패키지 매니저 확장. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L22-1 | **프로세스 상세 관리** | `proc` 프리픽스 (기존 `kill` 과 역할 분리 — `kill`은 강제종료, `proc`는 조회·분석). CPU·메모리·PID·경로 상세 표시. `proc top` 상위 10개 CPU/RAM 점유 프로세스. `proc find <이름>` 이름 검색. `proc detail ` 프로세스 트리·포트·DLL 목록 | 높음 | +| L22-2 | **좌표·거리 계산기** | `geo` 프리픽스. 위도·경도 좌표 입력(DMS/DD 형식). 두 좌표 간 직선 거리 계산(Haversine 공식). `geo seoul tokyo` 도시명 내장 좌표 빠른 조회. DMS↔DD 변환. 좌표→Google Maps URL 생성. 국가별 주요 도시 30개+ 내장 | 중간 | +| L22-3 | **Rust cargo 명령 생성기** | `cargo` 프리픽스. cargo build/run/test/check/clippy/fmt/add/remove/update/doc 명령. `cargo add ` → crates.io 명령. `cargo <명령>` → Enter 시 터미널 실행. npm 핸들러와 동일 패턴. Rust 개발자용 빠른 명령 조회 | 중간 | +| L22-4 | **Python pip 명령 생성기** | `pip` 프리픽스. pip install/uninstall/list/show/freeze/upgrade/search. `pip install <패키지>` → pip/pip3/conda 3종 동시 표시. `pip freeze` → requirements.txt 생성 명령. `pip venv` 가상환경 생성 명령. `pip <명령>` → Enter 시 터미널 실행 | 중간 | + +--- + +## Phase L23 — Kubectl·GitHub CLI·Chocolatey·텍스트 비교 (v2.1.0) 🔄 예정 + +> **방향**: DevOps·소스 관리·Windows 패키지 관리·텍스트 분석 도구. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L23-1 | **kubectl 명령 생성기** | `k8s` 프리픽스. get/describe/apply/delete/logs/exec/port-forward/scale 명령. `k8s get pods` → kubectl get pods -n default. 네임스페이스 빠른 전환. 자주 쓰는 yaml 템플릿(Deployment/Service/ConfigMap) 클립보드 복사 | 중간 | +| L23-2 | **GitHub CLI 명령 생성기** | `gh` 프리픽스. pr/issue/repo/workflow/release 명령 빠른 조회·생성. `gh pr list` / `gh pr create`. 현재 디렉토리 git remote 자동 감지. 사내 모드에서 외부 GitHub 차단 안내 | 중간 | +| L23-3 | **Chocolatey 패키지 관리** | `choco` 프리픽스. choco install/uninstall/upgrade/list/search 명령. 자주 쓰는 패키지 내장 목록(git/node/python/vscode/chrome/7zip 등 20개+). `choco install <패키지>` → Enter 시 관리자 PowerShell 실행. 미설치 감지 | 중간 | +| L23-4 | **텍스트 비교·diff** | `cmp` 프리픽스 (기존 `diff`와 역할 분리 — `diff`는 파일 비교, `cmp`는 클립보드 텍스트 비교). 두 텍스트 블록 라인 단위 비교. 추가·삭제·변경 줄 통계. `cmp clip1 clip2` / 클립보드 자동 감지. unified diff 형식 출력. 단어 단위 diff 모드 | 낮음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index c2bd4c6..e14c613 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -332,6 +332,14 @@ public partial class App : System.Windows.Application commandResolver.RegisterHandler(new StrHandler()); // L20-4: Unix 파일 권한 계산기 (prefix=perm) commandResolver.RegisterHandler(new PermHandler()); + // L21-1: TOML 파서·분석기 (prefix=toml) + commandResolver.RegisterHandler(new TomlHandler()); + // L21-2: 로그 파일 분석기 (prefix=log) + commandResolver.RegisterHandler(new LogHandler()); + // L21-3: PowerShell 명령 생성기 (prefix=ps) + commandResolver.RegisterHandler(new PsHandler()); + // L21-4: 키보드 단축키 참조 사전 (prefix=key) + commandResolver.RegisterHandler(new KeyHandler()); // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); diff --git a/src/AxCopilot/Handlers/KeyHandler.cs b/src/AxCopilot/Handlers/KeyHandler.cs new file mode 100644 index 0000000..01da781 --- /dev/null +++ b/src/AxCopilot/Handlers/KeyHandler.cs @@ -0,0 +1,289 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L21-4: 키보드 단축키 참조 사전 핸들러. "key" 프리픽스로 사용합니다. +/// +/// 예: key → 앱 카테고리 목록 +/// key win → Windows 단축키 +/// key vscode → VS Code 단축키 +/// key chrome → Chrome/Edge 단축키 +/// key vim → Vim 명령 +/// key excel → Excel 단축키 +/// key terminal → Windows Terminal 단축키 +/// key <키워드> → 전체 단축키에서 키워드 검색 (예: key 찾기, key find) +/// Enter → 단축키 복사. +/// +public class KeyHandler : IActionHandler +{ + public string? Prefix => "key"; + + public PluginMetadata Metadata => new( + "Key", + "키보드 단축키 참조 사전 — Windows·VS Code·Chrome·Vim·Excel", + "1.0", + "AX"); + + private record Shortcut(string Keys, string Description, string App); + + private static readonly Shortcut[] All = + [ + // Windows + new("Win + D", "바탕화면 보기/복원", "win"), + new("Win + E", "파일 탐색기 열기", "win"), + new("Win + L", "PC 잠금", "win"), + new("Win + R", "실행 대화상자", "win"), + new("Win + S", "Windows 검색", "win"), + new("Win + V", "클립보드 히스토리", "win"), + new("Win + X", "빠른 링크 메뉴 (전원 메뉴)", "win"), + new("Win + Tab", "작업 보기(가상 데스크톱)", "win"), + new("Win + Ctrl + D", "새 가상 데스크톱 추가", "win"), + new("Win + Ctrl + →/←", "가상 데스크톱 전환", "win"), + new("Win + ↑", "창 최대화", "win"), + new("Win + ↓", "창 최소화/복원", "win"), + new("Win + ←/→", "창 좌/우 절반 스냅", "win"), + new("Win + Shift + S", "화면 부분 캡처 (Snipping Tool)", "win"), + new("Alt + Tab", "실행 중인 앱 전환", "win"), + new("Alt + F4", "현재 창 닫기", "win"), + new("Ctrl + Shift + Esc", "작업 관리자 직접 열기", "win"), + new("Ctrl + Alt + Del", "보안 옵션 화면", "win"), + new("F2", "선택 항목 이름 변경", "win"), + new("F5", "새로고침", "win"), + new("F11", "전체화면 토글", "win"), + new("Win + . (점)", "이모지 피커", "win"), + new("Win + P", "프로젝션 모드 선택 (다중 모니터)", "win"), + new("Win + I", "Windows 설정", "win"), + new("PrtSc", "전체 화면 캡처 → 클립보드", "win"), + new("Alt + PrtSc", "현재 창 캡처 → 클립보드", "win"), + + // VS Code + new("Ctrl + P", "파일 빠른 열기", "vscode"), + new("Ctrl + Shift + P", "명령 팔레트", "vscode"), + new("Ctrl + `", "통합 터미널 열기/닫기", "vscode"), + new("Ctrl + B", "사이드바 토글", "vscode"), + new("Ctrl + J", "패널 토글(터미널·출력)", "vscode"), + new("Ctrl + F", "파일 내 찾기", "vscode"), + new("Ctrl + H", "파일 내 찾아 바꾸기", "vscode"), + new("Ctrl + Shift + F", "전체 검색", "vscode"), + new("Ctrl + Shift + H", "전체 찾아 바꾸기", "vscode"), + new("Ctrl + G", "줄 이동", "vscode"), + new("Ctrl + /", "줄 주석 토글", "vscode"), + new("Alt + ↑/↓", "현재 줄 위/아래 이동", "vscode"), + new("Alt + Shift + ↑/↓","현재 줄 위/아래 복사", "vscode"), + new("Ctrl + D", "다음 동일 단어 선택", "vscode"), + new("Ctrl + Shift + L", "동일 단어 모두 선택", "vscode"), + new("Ctrl + Shift + K", "현재 줄 삭제", "vscode"), + new("Ctrl + Enter", "아래에 새 줄 추가", "vscode"), + new("Ctrl + Shift + Enter", "위에 새 줄 추가", "vscode"), + new("F12", "정의로 이동", "vscode"), + new("Alt + F12", "정의 미리보기", "vscode"), + new("Shift + F12", "모든 참조 찾기", "vscode"), + new("F2", "기호 이름 변경(리팩터링)", "vscode"), + new("Ctrl + .", "빠른 수정 (Quick Fix)", "vscode"), + new("Ctrl + K Ctrl + F","선택 영역 포맷", "vscode"), + new("Shift + Alt + F", "전체 파일 포맷", "vscode"), + new("Ctrl + Z / Y", "실행 취소 / 다시 실행", "vscode"), + new("Ctrl + W", "현재 탭 닫기", "vscode"), + new("Ctrl + Tab", "에디터 탭 전환", "vscode"), + new("Ctrl + \\", "에디터 분할", "vscode"), + new("Ctrl + 1/2/3", "에디터 그룹 포커스", "vscode"), + + // Chrome / Edge + new("Ctrl + T", "새 탭 열기", "chrome"), + new("Ctrl + W", "현재 탭 닫기", "chrome"), + new("Ctrl + Shift + T", "닫은 탭 다시 열기", "chrome"), + new("Ctrl + Tab", "다음 탭으로 이동", "chrome"), + new("Ctrl + Shift + Tab","이전 탭으로 이동", "chrome"), + new("Ctrl + 1~8", "번호로 탭 이동", "chrome"), + new("Ctrl + L", "주소창 포커스", "chrome"), + new("Ctrl + D", "현재 페이지 북마크", "chrome"), + new("Ctrl + F", "페이지 내 찾기", "chrome"), + new("Ctrl + H", "방문 기록", "chrome"), + new("Ctrl + J", "다운로드 목록", "chrome"), + new("Ctrl + Shift + J", "개발자 도구 콘솔", "chrome"), + new("F12", "개발자 도구 토글", "chrome"), + new("F5 / Ctrl + R", "페이지 새로고침", "chrome"), + new("Ctrl + Shift + R", "캐시 무시 새로고침", "chrome"), + new("Ctrl + +/-", "페이지 확대/축소", "chrome"), + new("Ctrl + 0", "확대/축소 기본값 복원", "chrome"), + new("Alt + ← / →", "뒤로/앞으로", "chrome"), + new("Ctrl + N", "새 창 열기", "chrome"), + new("Ctrl + Shift + N", "시크릿 창", "chrome"), + new("Ctrl + Shift + B", "북마크 바 토글", "chrome"), + new("Ctrl + U", "페이지 소스 보기", "chrome"), + + // Vim + new("i", "삽입 모드 진입", "vim"), + new("Esc", "노멀 모드로 복귀", "vim"), + new(":w", "저장", "vim"), + new(":q", "종료", "vim"), + new(":wq / :x", "저장 후 종료", "vim"), + new(":q!", "저장 없이 강제 종료", "vim"), + new("h/j/k/l", "좌/하/상/우 이동", "vim"), + new("w / b", "단어 앞/뒤로 이동", "vim"), + new("gg / G", "파일 처음 / 끝으로 이동", "vim"), + new("0 / $", "줄 처음 / 끝으로 이동", "vim"), + new("dd", "현재 줄 삭제", "vim"), + new("yy", "현재 줄 복사(yank)", "vim"), + new("p / P", "붙여넣기 (다음/현재 위치)", "vim"), + new("u / Ctrl + R", "실행 취소 / 다시 실행", "vim"), + new("/keyword", "앞으로 검색", "vim"), + new("n / N", "다음/이전 검색 결과", "vim"), + new(":%s/old/new/g", "전체 치환", "vim"), + new("v / V", "비주얼 모드 (문자/줄)", "vim"), + new("Ctrl + V", "비주얼 블록 모드", "vim"), + new(":split / :vsplit", "수평/수직 창 분할", "vim"), + new("Ctrl + W + W", "다음 창으로 포커스 이동", "vim"), + new(":noh", "검색 하이라이트 제거", "vim"), + new(">> / <<", "들여쓰기 추가/제거", "vim"), + + // Excel + new("Ctrl + Arrow", "데이터 끝으로 이동", "excel"), + new("Ctrl + Shift + Arrow","데이터 끝까지 선택", "excel"), + new("Ctrl + Home / End","셀 A1 / 마지막 셀로 이동", "excel"), + new("Ctrl + Space", "열 전체 선택", "excel"), + new("Shift + Space", "행 전체 선택", "excel"), + new("Ctrl + Shift + +", "행/열 삽입", "excel"), + new("Ctrl + -", "행/열 삭제", "excel"), + new("Ctrl + D", "아래로 채우기", "excel"), + new("Ctrl + R", "오른쪽으로 채우기", "excel"), + new("Alt + =", "SUM 자동 합계", "excel"), + new("F4", "참조 절대/상대 전환 ($)", "excel"), + new("Ctrl + 1", "셀 서식 창", "excel"), + new("Ctrl + ;", "현재 날짜 입력", "excel"), + new("Ctrl + Shift + ;", "현재 시간 입력", "excel"), + new("F2", "셀 편집 모드", "excel"), + new("Alt + Enter", "셀 내 줄바꿈", "excel"), + new("Ctrl + Z / Y", "실행 취소 / 다시 실행", "excel"), + new("Ctrl + F", "찾기", "excel"), + new("Ctrl + H", "찾아 바꾸기", "excel"), + new("Ctrl + Shift + L", "필터 자동 설정 토글", "excel"), + + // Windows Terminal + new("Ctrl + Shift + T", "새 탭 열기", "terminal"), + new("Ctrl + Shift + W", "탭 닫기", "terminal"), + new("Ctrl + Tab", "다음 탭 이동", "terminal"), + new("Ctrl + Shift + 1~9","번호 탭으로 이동", "terminal"), + new("Ctrl + Shift + D", "창 복제", "terminal"), + new("Alt + Shift + D", "창 분할 (자동 방향)", "terminal"), + new("Alt + Shift + +", "수평 분할", "terminal"), + new("Alt + Shift + -", "수직 분할", "terminal"), + new("Alt + Arrow", "분할된 창 간 포커스 이동", "terminal"), + new("Ctrl + +/-", "폰트 크기 변경", "terminal"), + new("Ctrl + F", "터미널 내 텍스트 검색", "terminal"), + new("Ctrl + Shift + P", "명령 팔레트", "terminal"), + new("Ctrl + Shift + F", "전체 화면 토글", "terminal"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("키보드 단축키 참조 사전", + "key win / vscode / chrome / vim / excel / terminal / key <키워드 검색>", + null, null, Symbol: "\uE92E")); + AddAppOverview(items); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // 앱 카테고리 조회 + var catKeys = new Dictionary + { + ["win"] = ["win", "windows"], + ["vscode"] = ["vscode", "code", "vs"], + ["chrome"] = ["chrome", "edge", "browser"], + ["vim"] = ["vim", "vi", "neovim"], + ["excel"] = ["excel", "spreadsheet"], + ["terminal"] = ["terminal", "wt"], + }; + + foreach (var (cat, aliases) in catKeys) + { + if (aliases.Contains(sub)) + { + var catItems = All.Where(s => s.App == cat).ToList(); + var catName = cat switch + { + "win" => "Windows", + "vscode" => "VS Code", + "chrome" => "Chrome / Edge", + "vim" => "Vim", + "excel" => "Excel", + "terminal" => "Windows Terminal", + _ => cat + }; + items.Add(new LauncherItem($"{catName} 단축키 {catItems.Count}개", "", null, null, Symbol: "\uE92E")); + foreach (var s in catItems) + items.Add(new LauncherItem(s.Keys, s.Description, null, ("copy", s.Keys), Symbol: "\uE92E")); + return Task.FromResult>(items); + } + } + + // 키워드 검색 (전체) + var keyword = string.Join(" ", parts); + var found = All.Where(s => + s.Keys.Contains(keyword, StringComparison.OrdinalIgnoreCase) || + s.Description.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (found.Count > 0) + { + items.Add(new LauncherItem($"'{keyword}' 검색 결과: {found.Count}개", "", null, null, Symbol: "\uE92E")); + foreach (var s in found) + { + var appLabel = s.App.ToUpperInvariant(); + items.Add(new LauncherItem(s.Keys, $"[{appLabel}] {s.Description}", + null, ("copy", s.Keys), Symbol: "\uE92E")); + } + } + else + { + items.Add(new LauncherItem($"'{keyword}' 결과 없음", + "key win / vscode / chrome / vim / excel / terminal", + null, null, Symbol: "\uE783")); + AddAppOverview(items); + } + + 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("Key", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + private static void AddAppOverview(List items) + { + var apps = new (string Key, string Label, int Count)[] + { + ("win", "Windows 단축키", All.Count(s => s.App == "win")), + ("vscode", "VS Code 단축키", All.Count(s => s.App == "vscode")), + ("chrome", "Chrome / Edge 단축키", All.Count(s => s.App == "chrome")), + ("vim", "Vim 명령", All.Count(s => s.App == "vim")), + ("excel", "Excel 단축키", All.Count(s => s.App == "excel")), + ("terminal", "Windows Terminal 단축키", All.Count(s => s.App == "terminal")), + }; + foreach (var (key, label, count) in apps) + items.Add(new LauncherItem($"key {key}", $"{label} ({count}개)", + null, null, Symbol: "\uE92E")); + } +} diff --git a/src/AxCopilot/Handlers/LogHandler.cs b/src/AxCopilot/Handlers/LogHandler.cs new file mode 100644 index 0000000..ce4c49f --- /dev/null +++ b/src/AxCopilot/Handlers/LogHandler.cs @@ -0,0 +1,402 @@ +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L21-2: 로그 파일 분석기 핸들러. "log" 프리픽스로 사용합니다. +/// +/// 예: log → 클립보드 로그 통계 +/// log <경로> → 파일 경로 직접 분석 +/// log error → ERROR 줄만 표시 +/// log warn → WARN 줄만 표시 +/// log last 20 → 마지막 20줄 (tail) +/// log head 20 → 처음 20줄 +/// log grep <키워드> → 키워드 필터 +/// log stats → 레벨별 통계 + 시간대 분포 +/// log exceptions → 예외·스택트레이스 블록 추출 +/// log today → 오늘 날짜 포함 줄만 표시 +/// Enter → 값 복사. +/// +public partial class LogHandler : IActionHandler +{ + public string? Prefix => "log"; + + public PluginMetadata Metadata => new( + "Log", + "로그 파일 분석기 — ERROR/WARN 필터·tail·grep·통계·예외 추출", + "1.0", + "AX"); + + private record LogLine(int No, string Level, string Raw); + + // 로그 레벨 감지 패턴 + private static readonly (string Level, string[] Keywords)[] LevelPatterns = + [ + ("ERROR", ["[ERROR]", "ERROR:", "ERRO ", "error", "Error", "FATAL", "[FATAL]", "Exception", "EXCEPTION"]), + ("WARN", ["[WARN]", "WARN:", "WARNING", "warning", "Warning", "[WARNING]"]), + ("INFO", ["[INFO]", "INFO:", "information", "Information"]), + ("DEBUG", ["[DEBUG]", "DEBUG:", "debug", "Debug", "TRACE", "[TRACE]"]), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + // 클립보드 or 파일 + string? src = null; + string? srcLabel = null; + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + if (Clipboard.ContainsText()) src = Clipboard.GetText(); + }); + } + catch { } + + if (string.IsNullOrWhiteSpace(q)) + { + if (string.IsNullOrWhiteSpace(src)) + { + items.Add(new LauncherItem("로그 파일 분석기", + "클립보드에 로그를 복사하거나 log <파일경로> / log error / log grep <키워드>", + null, null, Symbol: "\uE9D9")); + return Task.FromResult>(items); + } + srcLabel = "클립보드"; + BuildSummary(items, src!, srcLabel); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // 파일 경로인지 판단 + if ((sub.Contains('\\') || sub.Contains('/') || sub.Contains(':') || sub.EndsWith(".log") || sub.EndsWith(".txt")) + && File.Exists(parts[0])) + { + string? fileErr = null; + string? content = null; + try { content = File.ReadAllText(parts[0]); } + catch (Exception ex) { fileErr = ex.Message; } + + if (fileErr != null) { items.Add(ErrorItem($"파일 읽기 오류: {fileErr}")); return Task.FromResult>(items); } + src = content; + srcLabel = Path.GetFileName(parts[0]); + if (parts.Length == 1) + { + BuildSummary(items, src!, srcLabel); + return Task.FromResult>(items); + } + // 파일 경로 + 서브커맨드 + sub = parts[1].ToLowerInvariant(); + parts = parts[1..]; + } + else + { + srcLabel = "클립보드"; + } + + var logSrc = src ?? ""; + if (string.IsNullOrWhiteSpace(logSrc)) + { + items.Add(ErrorItem("분석할 로그가 없습니다. 클립보드에 로그를 복사하세요.")); + return Task.FromResult>(items); + } + + var allLines = ParseLogLines(logSrc); + + switch (sub) + { + case "error" or "err": + BuildFilteredLines(items, allLines, "ERROR", "ERROR / FATAL / Exception", srcLabel!); + break; + + case "warn" or "warning": + BuildFilteredLines(items, allLines, "WARN", "WARN / WARNING", srcLabel!); + break; + + case "info": + BuildFilteredLines(items, allLines, "INFO", "INFO / information", srcLabel!); + break; + + case "debug" or "trace": + BuildFilteredLines(items, allLines, "DEBUG", "DEBUG / TRACE", srcLabel!); + break; + + case "last" or "tail": + { + int n = 20; + if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500); + var tail = allLines.TakeLast(n).ToList(); + var joined = string.Join("\n", tail.Select(l => l.Raw)); + items.Add(new LauncherItem($"마지막 {tail.Count}줄 ({srcLabel})", + "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); + foreach (var l in tail) + items.Add(BuildLineItem(l)); + break; + } + + case "head" or "first": + { + int n = 20; + if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500); + var head = allLines.Take(n).ToList(); + var joined = string.Join("\n", head.Select(l => l.Raw)); + items.Add(new LauncherItem($"처음 {head.Count}줄 ({srcLabel})", + "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); + foreach (var l in head) + items.Add(BuildLineItem(l)); + break; + } + + case "grep" or "find" or "search": + { + if (parts.Length < 2) { items.Add(ErrorItem("예: log grep Exception")); break; } + var kw = string.Join(" ", parts[1..]); + var matched = allLines.Where(l => l.Raw.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList(); + var joined = string.Join("\n", matched.Select(l => l.Raw)); + items.Add(new LauncherItem($"'{kw}' 검색 결과: {matched.Count}줄", + "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); + foreach (var l in matched.Take(15)) + items.Add(BuildLineItem(l)); + if (matched.Count > 15) + items.Add(new LauncherItem($"... ({matched.Count - 15}줄 더)", "Enter 전체 복사", + null, ("copy", joined), Symbol: "\uE9D9")); + break; + } + + case "stats": + BuildStats(items, allLines, logSrc, srcLabel!); + break; + + case "exceptions" or "exception" or "ex": + { + var blocks = ExtractExceptions(logSrc); + items.Add(new LauncherItem($"예외 블록 {blocks.Count}개 발견", + srcLabel!, null, null, Symbol: "\uE9D9")); + for (int i = 0; i < blocks.Count; i++) + { + var b = blocks[i]; + var preview = b.Split('\n')[0]; + items.Add(new LauncherItem($"#{i + 1} {TruncateStr(preview, 60)}", + $"{b.Split('\n').Length}줄", null, ("copy", b), Symbol: "\uE9D9")); + } + if (blocks.Count == 0) + items.Add(new LauncherItem("예외·스택트레이스 패턴 없음", "", null, null, Symbol: "\uE9D9")); + break; + } + + case "today": + { + var today = DateTime.Today.ToString("yyyy-MM-dd"); + var today2 = DateTime.Today.ToString("yyyy/MM/dd"); + var matched = allLines.Where(l => + l.Raw.Contains(today) || l.Raw.Contains(today2)).ToList(); + var joined = string.Join("\n", matched.Select(l => l.Raw)); + items.Add(new LauncherItem($"오늘({today}) {matched.Count}줄", + "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); + foreach (var l in matched.Take(15)) + items.Add(BuildLineItem(l)); + break; + } + + default: + // 기본 요약으로 폴백 + BuildSummary(items, logSrc, srcLabel!); + break; + } + + 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("Log", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + // ── 빌더 ──────────────────────────────────────────────────────────────── + + private static void BuildSummary(List items, string src, string label) + { + var lines = ParseLogLines(src); + var errors = lines.Count(l => l.Level == "ERROR"); + var warns = lines.Count(l => l.Level == "WARN"); + var infos = lines.Count(l => l.Level == "INFO"); + var debugs = lines.Count(l => l.Level == "DEBUG"); + var unknowns = lines.Count(l => l.Level == "OTHER"); + + items.Add(new LauncherItem($"{label} — {lines.Count}줄", + $"ERROR:{errors} WARN:{warns} INFO:{infos} DEBUG:{debugs}", + null, null, Symbol: "\uE9D9")); + if (errors > 0) + { + items.Add(new LauncherItem($"🔴 ERROR {errors}줄", + "log error 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9")); + } + if (warns > 0) + { + items.Add(new LauncherItem($"🟡 WARN {warns}줄", + "log warn 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9")); + } + items.Add(CopyItem("전체 줄 수", lines.Count.ToString())); + items.Add(CopyItem("ERROR 줄", errors.ToString())); + items.Add(CopyItem("WARN 줄", warns.ToString())); + + // 마지막 5줄 미리보기 + items.Add(new LauncherItem("── 마지막 5줄 ──", "", null, null, Symbol: "\uE9D9")); + foreach (var l in lines.TakeLast(5)) + items.Add(BuildLineItem(l)); + } + + private static void BuildFilteredLines(List items, List lines, + string level, string label, string srcLabel) + { + var filtered = lines.Where(l => l.Level == level).ToList(); + var joined = string.Join("\n", filtered.Select(l => l.Raw)); + items.Add(new LauncherItem($"{label} {filtered.Count}줄 ({srcLabel})", + "Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9")); + if (filtered.Count == 0) + { + items.Add(new LauncherItem($"{label} 줄이 없습니다", "", null, null, Symbol: "\uE9D9")); + return; + } + foreach (var l in filtered.Take(15)) + items.Add(BuildLineItem(l)); + if (filtered.Count > 15) + items.Add(new LauncherItem($"... ({filtered.Count - 15}줄 더)", "Enter 전체 복사", + null, ("copy", joined), Symbol: "\uE9D9")); + } + + private static void BuildStats(List items, List lines, string src, string label) + { + var counts = new Dictionary + { + ["ERROR"] = lines.Count(l => l.Level == "ERROR"), + ["WARN"] = lines.Count(l => l.Level == "WARN"), + ["INFO"] = lines.Count(l => l.Level == "INFO"), + ["DEBUG"] = lines.Count(l => l.Level == "DEBUG"), + ["OTHER"] = lines.Count(l => l.Level == "OTHER"), + }; + + items.Add(new LauncherItem($"로그 통계 ({label})", $"총 {lines.Count}줄", + null, null, Symbol: "\uE9D9")); + foreach (var (lvl, cnt) in counts.Where(kv => kv.Value > 0)) + items.Add(CopyItem(lvl, $"{cnt}줄 ({cnt * 100.0 / Math.Max(1, lines.Count):F1}%)")); + + // 날짜별 분포 (yyyy-MM-dd 패턴 추출) + var dateCounts = new Dictionary(); + foreach (var l in lines) + { + var m = DatePattern().Match(l.Raw); + if (m.Success) + { + var date = m.Value[..10]; + dateCounts[date] = dateCounts.GetValueOrDefault(date) + 1; + } + } + if (dateCounts.Count > 0) + { + items.Add(new LauncherItem("── 날짜별 분포 ──", "", null, null, Symbol: "\uE9D9")); + foreach (var (date, cnt) in dateCounts.OrderByDescending(kv => kv.Key).Take(5)) + items.Add(CopyItem(date, $"{cnt}줄")); + } + } + + private static List ExtractExceptions(string src) + { + var blocks = new List(); + var lines = src.Split('\n'); + var inBlock = false; + var current = new StringBuilder(); + + foreach (var line in lines) + { + var isEx = line.Contains("Exception") || line.Contains("Error:") || + line.Contains("at ") || line.TrimStart().StartsWith("Caused by"); + if (!inBlock && isEx) + { + inBlock = true; + current.Clear(); + current.AppendLine(line); + } + else if (inBlock) + { + if (isEx || line.TrimStart().StartsWith("at ") || line.TrimStart().StartsWith("...")) + current.AppendLine(line); + else + { + if (current.Length > 0) blocks.Add(current.ToString().Trim()); + inBlock = false; + current.Clear(); + } + } + } + if (inBlock && current.Length > 0) blocks.Add(current.ToString().Trim()); + return blocks; + } + + // ── 파서·헬퍼 ─────────────────────────────────────────────────────────── + + private static List ParseLogLines(string src) + { + var lines = src.Split('\n'); + return lines.Select((raw, i) => + { + var level = DetectLevel(raw); + return new LogLine(i + 1, level, raw.TrimEnd('\r')); + }).ToList(); + } + + private static string DetectLevel(string line) + { + foreach (var (level, keywords) in LevelPatterns) + if (keywords.Any(kw => line.Contains(kw, StringComparison.Ordinal))) + return level; + return "OTHER"; + } + + private static LauncherItem BuildLineItem(LogLine l) + { + var icon = l.Level switch + { + "ERROR" => "🔴", + "WARN" => "🟡", + "INFO" => "🔵", + "DEBUG" => "⚪", + _ => " " + }; + var display = TruncateStr(l.Raw, 80); + return new LauncherItem($"{icon} {display}", + $"줄 {l.No} ({l.Level})", null, ("copy", l.Raw), Symbol: "\uE9D9"); + } + + private static string TruncateStr(string s, int max) => + s.Length <= max ? s : s[..max] + "…"; + + private static LauncherItem CopyItem(string label, string value) => + new(label, value, null, ("copy", value), Symbol: "\uE9D9"); + + private static LauncherItem ErrorItem(string msg) => + new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783"); + + [GeneratedRegex(@"\d{4}[-/]\d{2}[-/]\d{2}")] + private static partial Regex DatePattern(); +} diff --git a/src/AxCopilot/Handlers/PsHandler.cs b/src/AxCopilot/Handlers/PsHandler.cs new file mode 100644 index 0000000..539f1ed --- /dev/null +++ b/src/AxCopilot/Handlers/PsHandler.cs @@ -0,0 +1,268 @@ +using System.Diagnostics; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L21-3: PowerShell 명령 생성기 핸들러. "ps" 프리픽스로 사용합니다. +/// +/// 예: ps → 카테고리별 자주 쓰는 명령 목록 +/// ps <키워드> → 명령 검색 (예: ps process, ps service, ps file) +/// ps file → 파일/디렉토리 명령 목록 +/// ps process → 프로세스 명령 목록 +/// ps network → 네트워크 명령 목록 +/// ps service → 서비스 명령 목록 +/// ps registry → 레지스트리 명령 목록 +/// ps string → 문자열 처리 명령 목록 +/// ps date → 날짜/시간 명령 목록 +/// ps pipe → 파이프라인 예시 +/// ps run <명령> → 직접 PowerShell 실행 +/// Enter → 클립보드 복사 (또는 PS 터미널 실행). +/// +public class PsHandler : IActionHandler +{ + public string? Prefix => "ps"; + + public PluginMetadata Metadata => new( + "PS", + "PowerShell 명령 생성기 — 파일·프로세스·네트워크·서비스·레지스트리", + "1.0", + "AX"); + + private record PsCmd(string Cmd, string Description, string Category); + + private static readonly PsCmd[] Commands = + [ + // 파일/디렉토리 + new("Get-ChildItem -Path . -Recurse", "현재 폴더 재귀 목록", "file"), + new("Get-ChildItem -Filter *.log -Recurse", "*.log 파일 재귀 검색", "file"), + new("Copy-Item src.txt dst.txt", "파일 복사", "file"), + new("Move-Item src.txt dst.txt", "파일 이동/이름변경", "file"), + new("Remove-Item -Path file.txt -Force", "파일 강제 삭제", "file"), + new("New-Item -ItemType Directory -Path .\\NewFolder", "새 폴더 생성", "file"), + new("Get-Content file.txt", "파일 내용 출력", "file"), + new("Get-Content file.txt -Tail 20", "파일 마지막 20줄 (tail)", "file"), + new("Set-Content file.txt \"내용\"", "파일 쓰기", "file"), + new("Add-Content file.txt \"추가 내용\"", "파일에 내용 추가", "file"), + new("Test-Path C:\\path\\to\\file", "경로 존재 여부 확인", "file"), + new("Resolve-Path .\\relative\\path", "상대 경로 → 절대 경로", "file"), + new("Get-Item file.txt | Select-Object Length,LastWriteTime", "파일 크기·수정일 조회", "file"), + new("(Get-ChildItem -Recurse | Measure-Object -Property Length -Sum).Sum / 1MB", "폴더 크기 계산 (MB)", "file"), + new("Compress-Archive -Path .\\folder -DestinationPath out.zip","폴더 압축 (.zip)", "file"), + new("Expand-Archive -Path file.zip -DestinationPath .\\out", "zip 압축 해제", "file"), + + // 프로세스 + new("Get-Process", "실행 중인 프로세스 목록", "process"), + new("Get-Process -Name chrome | Sort-Object CPU -Desc", "Chrome 프로세스 CPU 순 정렬", "process"), + new("Stop-Process -Name notepad -Force", "notepad 강제 종료", "process"), + new("Stop-Process -Id 1234 -Force", "PID로 프로세스 종료", "process"), + new("Start-Process chrome.exe", "프로세스 실행", "process"), + new("Start-Process notepad -Verb RunAs", "관리자 권한으로 실행", "process"), + new("Get-Process | Where-Object {$_.CPU -gt 10} | Sort-Object CPU -Desc | Select -First 5", "CPU 10% 이상 상위 5개", "process"), + new("Get-WmiObject Win32_Process | Select-Object Name,ProcessId,CommandLine", "프로세스 명령줄 조회", "process"), + new("(Get-Process -Id $PID).Path", "현재 PowerShell 실행 경로", "process"), + + // 서비스 + new("Get-Service", "모든 서비스 목록", "service"), + new("Get-Service | Where-Object {$_.Status -eq 'Running'}", "실행 중인 서비스만", "service"), + new("Start-Service -Name wuauserv", "Windows Update 서비스 시작", "service"), + new("Stop-Service -Name wuauserv", "서비스 중지", "service"), + new("Restart-Service -Name Spooler", "인쇄 스풀러 재시작", "service"), + new("Set-Service -Name Fax -StartupType Disabled", "서비스 시작 유형 변경 (비활성화)", "service"), + new("Get-Service -Name sshd", "SSH 서비스 상태 확인", "service"), + + // 네트워크 + new("Test-NetConnection google.com -Port 443", "도메인+포트 연결 테스트", "network"), + new("Test-NetConnection 8.8.8.8", "IP ping 테스트", "network"), + new("Get-NetIPAddress -AddressFamily IPv4", "IPv4 주소 목록", "network"), + new("Get-NetAdapter | Where-Object {$_.Status -eq 'Up'}", "활성 네트워크 어댑터 목록", "network"), + new("Get-NetTCPConnection -State Listen", "Listen 상태 포트 목록", "network"), + new("Get-NetTCPConnection | Where-Object {$_.LocalPort -eq 80}","포트 80 연결 조회", "network"), + new("Resolve-DnsName google.com", "DNS 조회", "network"), + new("(Invoke-WebRequest -Uri 'https://api.ipify.org').Content", "공인 IP 주소 확인", "network"), + new("Invoke-WebRequest -Uri 'http://localhost:8080/health'", "HTTP GET 요청", "network"), + + // 레지스트리 + new("Get-ItemProperty HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "시작 프로그램 확인", "registry"), + new("Get-ChildItem HKLM:\\SOFTWARE", "HKLM\\SOFTWARE 하위 키 목록", "registry"), + new("New-ItemProperty -Path HKCU:\\Software -Name MyVal -Value 1 -PropertyType DWORD", "레지스트리 값 쓰기", "registry"), + new("Remove-ItemProperty -Path HKCU:\\Software -Name MyVal", "레지스트리 값 삭제", "registry"), + + // 문자열·데이터 + new("\"hello world\" -replace 'world','PowerShell'", "문자열 치환", "string"), + new("\" text \".Trim()", "문자열 앞뒤 공백 제거", "string"), + new("[System.Web.HttpUtility]::UrlEncode('hello world')", "URL 인코딩", "string"), + new("[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('hello'))", "Base64 인코딩", "string"), + new("[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('aGVsbG8='))", "Base64 디코딩", "string"), + new("Get-FileHash file.txt -Algorithm SHA256", "파일 SHA256 해시", "string"), + new("$text | ConvertTo-Json", "객체 → JSON 변환", "string"), + new("Get-Content file.json | ConvertFrom-Json", "JSON 파일 파싱", "string"), + new("Import-Csv data.csv", "CSV 파일 파싱", "string"), + new("Export-Csv -Path out.csv -NoTypeInformation", "CSV 파일 내보내기", "string"), + + // 날짜/시간 + new("Get-Date", "현재 날짜·시간", "date"), + new("Get-Date -Format 'yyyy-MM-dd HH:mm:ss'", "날짜 포맷 지정", "date"), + new("(Get-Date).AddDays(30)", "30일 후 날짜", "date"), + new("(Get-Date) - (Get-Date '2024-01-01')", "날짜 차이 계산", "date"), + new("[datetime]::UtcNow", "UTC 현재 시각", "date"), + new("[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()", "Unix 타임스탬프 (초)", "date"), + new("[DateTimeOffset]::FromUnixTimeSeconds(1700000000).LocalDateTime", "Unix 타임스탬프 → 날짜", "date"), + + // 파이프라인 + new("Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 | Format-Table", "상위 10 프로세스 테이블 출력", "pipe"), + new("Get-ChildItem *.txt | ForEach-Object { $_.Name }", "txt 파일 이름만 추출", "pipe"), + new("1..10 | ForEach-Object { $_ * $_ }", "1~10 제곱수 생성", "pipe"), + new("'a','b','c' | Where-Object { $_ -ne 'b' }", "배열 필터링", "pipe"), + new("Get-Content log.txt | Select-String 'ERROR'", "파일에서 ERROR 줄 검색", "pipe"), + new("Get-EventLog -LogName System -Newest 50 | Where-Object {$_.EntryType -eq 'Error'}", "시스템 이벤트 오류 조회", "pipe"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("PowerShell 명령 생성기", + "ps file · process · network · service · registry · string · date · pipe · run <명령>", + null, null, Symbol: "\uE756")); + AddCategoryOverview(items); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // ps run <명령> + if (sub == "run" && parts.Length >= 2) + { + var cmd = parts[1]; + items.Add(new LauncherItem(cmd, "Enter → PowerShell에서 실행", + null, ("run", cmd), Symbol: "\uE756")); + items.Add(new LauncherItem("클립보드 복사", cmd, null, ("copy", cmd), Symbol: "\uE756")); + return Task.FromResult>(items); + } + + // 카테고리 조회 + var catMatch = Commands.Where(c => c.Category.Equals(sub, StringComparison.OrdinalIgnoreCase)).ToList(); + if (catMatch.Count > 0) + { + var catName = sub switch + { + "file" => "파일/디렉토리", + "process" => "프로세스", + "service" => "서비스", + "network" => "네트워크", + "registry" => "레지스트리", + "string" => "문자열·데이터", + "date" => "날짜/시간", + "pipe" => "파이프라인 예시", + _ => sub + }; + items.Add(new LauncherItem($"── {catName} ──", $"{catMatch.Count}개 명령", null, null, Symbol: "\uE756")); + foreach (var c in catMatch) + items.Add(new LauncherItem(TruncateStr(c.Cmd, 70), c.Description, + null, ("copy", c.Cmd), Symbol: "\uE756")); + return Task.FromResult>(items); + } + + // 키워드 검색 + var keyword = string.Join(" ", parts); + var found = Commands.Where(c => + c.Cmd.Contains(keyword, StringComparison.OrdinalIgnoreCase) || + c.Description.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (found.Count > 0) + { + items.Add(new LauncherItem($"'{keyword}' 검색 결과: {found.Count}개", "", null, null, Symbol: "\uE756")); + foreach (var c in found) + items.Add(new LauncherItem(TruncateStr(c.Cmd, 70), c.Description, + null, ("copy", c.Cmd), Symbol: "\uE756")); + } + else + { + items.Add(new LauncherItem($"'{keyword}' 결과 없음", + "ps file · process · network · service · registry · string · date · pipe", + null, null, Symbol: "\uE783")); + AddCategoryOverview(items); + } + + 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("PS", "클립보드에 복사했습니다."); + } + catch { } + break; + + case ("run", string cmd): + RunInPowerShell(cmd); + break; + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static void AddCategoryOverview(List items) + { + var cats = new (string Key, string Label)[] + { + ("file", "파일/디렉토리 — Get-ChildItem · Copy-Item · Remove-Item · Compress-Archive"), + ("process", "프로세스 — Get-Process · Stop-Process · Start-Process"), + ("service", "서비스 — Get-Service · Start-Service · Stop-Service"), + ("network", "네트워크 — Test-NetConnection · Get-NetIPAddress · Resolve-DnsName"), + ("registry", "레지스트리 — Get-ItemProperty · New-ItemProperty · Remove-ItemProperty"), + ("string", "문자열·데이터 — ConvertTo-Json · Get-FileHash · Import-Csv"), + ("date", "날짜/시간 — Get-Date · AddDays · UnixTimeSeconds"), + ("pipe", "파이프라인 — Sort-Object · Where-Object · ForEach-Object"), + }; + foreach (var (key, label) in cats) + items.Add(new LauncherItem($"ps {key}", label, null, null, Symbol: "\uE756")); + } + + private static void RunInPowerShell(string cmd) + { + try + { + // Windows Terminal 우선 + var wtPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + @"Microsoft\WindowsApps\wt.exe"); + if (System.IO.File.Exists(wtPath)) + { + Process.Start(new ProcessStartInfo + { + FileName = wtPath, + Arguments = $"PowerShell -NoExit -Command \"{cmd.Replace("\"", "\\\"")}\"", + UseShellExecute = true + }); + return; + } + Process.Start(new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoExit -Command \"{cmd.Replace("\"", "\\\"")}\"", + UseShellExecute = true + }); + } + catch { } + } + + private static string TruncateStr(string s, int max) => + s.Length <= max ? s : s[..max] + "…"; +} diff --git a/src/AxCopilot/Handlers/TomlHandler.cs b/src/AxCopilot/Handlers/TomlHandler.cs new file mode 100644 index 0000000..8cf3ea7 --- /dev/null +++ b/src/AxCopilot/Handlers/TomlHandler.cs @@ -0,0 +1,372 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L21-1: TOML 파서·분석기 핸들러. "toml" 프리픽스로 사용합니다. +/// +/// 예: toml → 클립보드 TOML 전체 키 목록 +/// toml validate → 유효성 검사 +/// toml keys → 최상위 키 목록 +/// toml get key → 특정 키 값 조회 +/// toml get server.port → 점 표기법 중첩 키 조회 +/// toml stats → 줄·키·섹션·배열 통계 +/// toml flat → 점 표기법 평탄화 (모든 키·값) +/// toml sections → [section] 목록 +/// Enter → 값 복사. +/// +public partial class TomlHandler : IActionHandler +{ + public string? Prefix => "toml"; + + public PluginMetadata Metadata => new( + "TOML", + "TOML 파서·분석기 — 키 조회·유효성 검사·평탄화", + "1.0", + "AX"); + + // TOML 노드 (경량 표현) + private sealed class TomlTable : Dictionary { } + private sealed class TomlArray : List { } + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + string? clipboard = null; + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + if (Clipboard.ContainsText()) clipboard = Clipboard.GetText().Trim(); + }); + } + catch { } + + if (string.IsNullOrWhiteSpace(q)) + { + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("TOML 파서·분석기", + "클립보드에 TOML을 복사하세요 · toml validate / keys / get / stats / flat", + null, null, Symbol: "\uE8EC")); + return Task.FromResult>(items); + } + // 기본: 유효성 확인 + 최상위 키 + var (tbl, err) = ParseToml(clipboard!); + if (err != null) + { + items.Add(ErrorItem($"TOML 파싱 오류: {err}")); + return Task.FromResult>(items); + } + items.Add(new LauncherItem("TOML 파싱 성공 ✓", + $"최상위 키 {tbl!.Count}개 · toml get / flat / stats", null, null, Symbol: "\uE8EC")); + BuildTopKeys(items, tbl!); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + var src = parts.Length > 1 && (sub != "get") + ? string.Join(" ", parts[1..]) + : clipboard ?? ""; + + // validate + if (sub is "validate" or "check" or "검사") + { + var text = clipboard ?? ""; + var (_, verr) = ParseToml(text); + if (verr != null) + items.Add(ErrorItem($"유효성 오류: {verr}")); + else + { + var (vt, _) = ParseToml(text); + items.Add(new LauncherItem("✓ 유효한 TOML", $"최상위 키 {vt!.Count}개", null, null, Symbol: "\uE8EC")); + BuildTopKeys(items, vt!); + } + return Task.FromResult>(items); + } + + var (table, parseErr) = ParseToml(clipboard ?? ""); + if (parseErr != null && sub != "validate") + { + items.Add(ErrorItem($"TOML 파싱 오류: {parseErr}")); + return Task.FromResult>(items); + } + + switch (sub) + { + case "keys" or "key": + items.Add(new LauncherItem("최상위 키 목록", $"{table!.Count}개", null, null, Symbol: "\uE8EC")); + BuildTopKeys(items, table!); + break; + + case "sections" or "section": + { + var secs = table!.Where(kv => kv.Value is TomlTable).Select(kv => kv.Key).ToList(); + items.Add(new LauncherItem($"섹션 {secs.Count}개", "", null, null, Symbol: "\uE8EC")); + foreach (var s in secs) + { + var secCount = table![s] is TomlTable st ? st.Count : 0; + items.Add(new LauncherItem($"[{s}]", + $"{secCount}개 키", null, ("copy", s), Symbol: "\uE8EC")); + } + break; + } + + case "get": + { + if (parts.Length < 2) { items.Add(ErrorItem("예: toml get server.port")); break; } + var keyPath = parts[1]; + var val = GetByPath(table!, keyPath); + if (val == null) + items.Add(new LauncherItem($"'{keyPath}' 키를 찾을 수 없습니다", "", null, null, Symbol: "\uE8EC")); + else + { + var strVal = TomlValueToString(val); + items.Add(new LauncherItem($"{keyPath} = {TruncateStr(strVal, 60)}", + "Enter 복사", null, ("copy", strVal), Symbol: "\uE8EC")); + items.Add(CopyItem("값", strVal)); + items.Add(CopyItem("키 경로", keyPath)); + items.Add(CopyItem("타입", GetTomlType(val))); + } + break; + } + + case "stats": + { + var flat = new Dictionary(); + FlattenTable(table!, "", flat); + var lines = (clipboard ?? "").Split('\n').Length; + var arrays = flat.Values.Count(v => v is TomlArray); + var tables2 = flat.Values.Count(v => v is TomlTable); + var scalars = flat.Count - arrays - tables2; + + items.Add(new LauncherItem("TOML 통계", "", null, null, Symbol: "\uE8EC")); + items.Add(CopyItem("전체 줄", lines.ToString())); + items.Add(CopyItem("최상위 키", table!.Count.ToString())); + items.Add(CopyItem("전체 키(flat)", flat.Count.ToString())); + items.Add(CopyItem("스칼라 값", scalars.ToString())); + items.Add(CopyItem("배열", arrays.ToString())); + items.Add(CopyItem("섹션(테이블)", table.Values.Count(v => v is TomlTable).ToString())); + break; + } + + case "flat" or "flatten": + { + var flat = new Dictionary(); + FlattenTable(table!, "", flat); + var all = string.Join("\n", flat.Select(kv => $"{kv.Key} = {TomlValueToString(kv.Value)}")); + items.Add(new LauncherItem($"평탄화 결과 {flat.Count}개", + "전체 복사 → Enter", null, ("copy", all), Symbol: "\uE8EC")); + foreach (var (k, v) in flat.Take(20)) + items.Add(new LauncherItem($"{k} = {TruncateStr(TomlValueToString(v), 50)}", + GetTomlType(v), null, ("copy", TomlValueToString(v)), Symbol: "\uE8EC")); + if (flat.Count > 20) + items.Add(new LauncherItem($"... ({flat.Count - 20}개 더)", "전체는 Enter로 복사", + null, null, Symbol: "\uE8EC")); + break; + } + + default: + items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'", + "validate · keys · sections · get · stats · flat", + null, null, Symbol: "\uE783")); + break; + } + + 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("TOML", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + // ── 경량 TOML 파서 ──────────────────────────────────────────────────── + + private static (TomlTable? table, string? error) ParseToml(string src) + { + var root = new TomlTable(); + var current = root; + var lines = src.Split('\n'); + string? parseError = null; + + for (int lineNo = 0; lineNo < lines.Length; lineNo++) + { + var raw = lines[lineNo]; + var line = StripComment(raw).Trim(); + if (string.IsNullOrWhiteSpace(line)) continue; + + // [section] 또는 [[array-of-tables]] + if (line.StartsWith("[[")) + { + var name = line.Trim('[', ']').Trim(); + // 배열 섹션 처리 (간략화: 마지막 테이블만 유지) + var parts = name.Split('.'); + current = root; + foreach (var p in parts) + { + if (!current.TryGetValue(p, out var existing) || existing is not TomlTable child) + { child = new TomlTable(); current[p] = child; } + current = (TomlTable)current[p]!; + } + } + else if (line.StartsWith("[")) + { + var name = line.Trim('[', ']').Trim(); + var parts = name.Split('.'); + current = root; + foreach (var p in parts) + { + if (!current.TryGetValue(p, out var existing) || existing is not TomlTable child) + { child = new TomlTable(); current[p] = child; } + current = (TomlTable)current[p]!; + } + } + // key = value + else if (line.Contains('=')) + { + var eqIdx = line.IndexOf('='); + var key = line[..eqIdx].Trim().Trim('"'); + var val = line[(eqIdx + 1)..].Trim(); + try { current[key] = ParseTomlValue(val); } + catch (Exception ex) + { parseError ??= $"줄 {lineNo + 1}: {ex.Message}"; } + } + } + return parseError != null ? (null, parseError) : (root, null); + } + + private static object? ParseTomlValue(string val) + { + if (val.StartsWith('"') || val.StartsWith('\'')) + return val.Trim('"', '\''); + if (val.StartsWith('[')) + { + var arr = new TomlArray(); + var inner = val.Trim('[', ']'); + foreach (var item in inner.Split(',')) + { + var t = item.Trim(); + if (!string.IsNullOrEmpty(t)) arr.Add(ParseTomlValue(t)); + } + return arr; + } + if (val.StartsWith('{')) + { + var tbl = new TomlTable(); + var inner = val.Trim('{', '}'); + foreach (var pair in inner.Split(',')) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2) + tbl[parts[0].Trim().Trim('"')] = ParseTomlValue(parts[1].Trim()); + } + return tbl; + } + if (val is "true") return true; + if (val is "false") return false; + if (long.TryParse(val, out var lv)) return lv; + if (double.TryParse(val, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var dv)) return dv; + return val.Trim('"', '\''); + } + + private static string StripComment(string line) + { + // '#' 앞에 따옴표가 홀수개인 경우 내부 → 유지, 아니면 제거 + bool inStr = false; + char quote = '"'; + for (int i = 0; i < line.Length; i++) + { + var c = line[i]; + if (!inStr && (c == '"' || c == '\'')) { inStr = true; quote = c; } + else if (inStr && c == quote) inStr = false; + else if (!inStr && c == '#') return line[..i]; + } + return line; + } + + private static object? GetByPath(TomlTable table, string path) + { + var parts = path.Split('.'); + object? cur = table; + foreach (var p in parts) + { + if (cur is TomlTable t && t.TryGetValue(p, out var next)) cur = next; + else return null; + } + return cur; + } + + private static void FlattenTable(TomlTable table, string prefix, Dictionary result) + { + foreach (var (k, v) in table) + { + var key = string.IsNullOrEmpty(prefix) ? k : $"{prefix}.{k}"; + if (v is TomlTable child) FlattenTable(child, key, result); + else result[key] = v; + } + } + + private static void BuildTopKeys(List items, TomlTable table) + { + foreach (var (k, v) in table) + { + var type = GetTomlType(v); + var disp = v is TomlTable t ? $"{{ {t.Count}개 키 }}" : + v is TomlArray a ? $"[ {a.Count}개 항목 ]" : + TruncateStr(TomlValueToString(v), 50); + items.Add(new LauncherItem($"{k} = {disp}", type, + null, ("copy", TomlValueToString(v)), Symbol: "\uE8EC")); + } + } + + private static string TomlValueToString(object? v) => v switch + { + null => "null", + bool b => b ? "true" : "false", + TomlTable t => "{" + string.Join(", ", t.Select(kv => $"{kv.Key} = {TomlValueToString(kv.Value)}")) + "}", + TomlArray a => "[" + string.Join(", ", a.Select(TomlValueToString)) + "]", + _ => v.ToString() ?? "" + }; + + private static string GetTomlType(object? v) => v switch + { + null => "null", + bool => "Boolean", + long => "Integer", + double => "Float", + string => "String", + TomlTable => "Table", + TomlArray => "Array", + _ => v.GetType().Name + }; + + private static string TruncateStr(string s, int max) => + s.Length <= max ? s : s[..max] + "…"; + + private static LauncherItem CopyItem(string label, string value) => + new(label, value, null, ("copy", value), Symbol: "\uE8EC"); + + private static LauncherItem ErrorItem(string msg) => + new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783"); +}