diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md
index 939a1e0..e78abc1 100644
--- a/docs/LAUNCHER_ROADMAP.md
+++ b/docs/LAUNCHER_ROADMAP.md
@@ -87,7 +87,7 @@
---
-## Phase L3 — 차세대 런처 (v2.0) — 차기 개발
+## Phase L3 — 차세대 런처 (v2.0) — 진행 중 / 일부 완료
> **방향**: 경쟁 런처(Raycast 1500+ 확장, PowerToys Run)의 에코시스템 수준을 참고하되,
> 사내 보안/오프라인 환경에서 동작하는 자체 완결형 기능으로 구현.
@@ -95,11 +95,11 @@
| # | 기능 | 설명 | 우선순위 | 교차 |
|---|------|------|----------|------|
-| L3-1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 레지스트리 기반 탐색/설치/업데이트 인앱 갤러리 | 높음 | → Agent 18-2 |
-| L3-2 | **웹 검색 AI 요약** | ? 검색 결과를 AI가 요약하여 런처에 표시 | 중간 | → Agent 18-6 |
-| L3-3 | **AI 스니펫** | `;email {수신자} {주제}` → LLM이 이메일 초안 자동 생성. 기존 스니펫에 AI 확장 | 중간 | → Agent 18-3 |
-| L3-4 | **파라미터 퀵링크** | `jira {티켓번호}` → URL 템플릿 변수 치환 (사내 JIRA/Confluence 등) | 중간 | → Agent 18-4 |
-| L3-5 | **파일 태그 시스템** | 파일에 사용자 태그 부여, 태그 기반 검색 | 중간 | — |
+| ✅ L3-1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 레지스트리 기반 탐색/설치/업데이트 인앱 갤러리 | 높음 | → Agent 18-2 |
+| ✅ L3-2 | **웹 검색 AI 요약** | ? 검색 결과를 AI가 요약하여 런처에 표시 | 중간 | → Agent 18-6 |
+| ✅ L3-3 | **AI 스니펫** | `;email {수신자} {주제}` → LLM이 이메일 초안 자동 생성. 기존 스니펫에 AI 확장 | 중간 | → Agent 18-3 |
+| ✅ L3-4 | **파라미터 퀵링크** | `jira {티켓번호}` → URL 템플릿 변수 치환 (사내 JIRA/Confluence 등) | 중간 | → Agent 18-4 |
+| ✅ L3-5 | **파일 태그 시스템** | 파일에 사용자 태그 부여, `tag` 프리픽스로 태그 기반 검색. `file_tags.json` 로컬 저장 | 중간 | — |
| L3-6 | **오프라인 AI (로컬 SLM)** | ONNX Runtime + phi-3, 서버 없이 번역/요약 | 낮음 | → Agent 18-5 |
| L3-7 | **다중 디스플레이** | 모니터별 런처/독 바 위치 기억 | 낮음 | — |
| L3-8 | **알림 센터 통합** | Windows 알림과 연동 | 낮음 | — |
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index 2f40050..b23618d 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -166,6 +166,8 @@ public partial class App : System.Windows.Application
var snippetTemplateSvc = new SnippetTemplateService(settings, _sharedLlm);
commandResolver.RegisterHandler(new AiSnippetHandler(settings, snippetTemplateSvc));
commandResolver.RegisterHandler(new WebSearchSummaryHandler(settings, _sharedLlm));
+ // Phase L3-5: 파일 태그 시스템
+ commandResolver.RegisterHandler(new TagHandler());
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
diff --git a/src/AxCopilot/Handlers/TagHandler.cs b/src/AxCopilot/Handlers/TagHandler.cs
new file mode 100644
index 0000000..41809d2
--- /dev/null
+++ b/src/AxCopilot/Handlers/TagHandler.cs
@@ -0,0 +1,264 @@
+using System.Diagnostics;
+using System.IO;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// Phase L3-5: 파일 태그 핸들러. "tag" 프리픽스로 사용합니다.
+/// 파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.
+///
+/// 사용법:
+/// tag → 전체 태그 목록 (태그명 + 파일 수)
+/// tag work → "work" 태그가 부여된 파일 목록
+/// tag add work C:\path\file.docx → 파일에 "work" 태그 추가
+/// tag del work C:\path\file.docx → 파일에서 "work" 태그 제거
+/// tag clear C:\path\file.docx → 파일의 모든 태그 제거
+///
+public class TagHandler : IActionHandler
+{
+ public string? Prefix => "tag";
+
+ public PluginMetadata Metadata => new(
+ "FileTag",
+ "파일 태그 — tag",
+ "1.0",
+ "AX",
+ "파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.");
+
+ private static FileTagService Tags => FileTagService.Instance;
+
+ // ─── GetItemsAsync ───────────────────────────────────────────────────────
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+
+ // ── add 명령: tag add <태그> <경로> ────────────────────────────────
+ if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
+ {
+ var rest = q[4..].Trim();
+ var spaceIdx = rest.IndexOf(' ');
+ if (spaceIdx > 0)
+ {
+ var tag = rest[..spaceIdx].Trim();
+ var path = rest[(spaceIdx + 1)..].Trim();
+ return Task.FromResult>(
+ [
+ new LauncherItem(
+ $"태그 추가: [{tag}] → {Path.GetFileName(path)}",
+ $"{path} · Enter로 추가",
+ null,
+ ValueTuple.Create("__TAG_ADD__", tag, path),
+ Symbol: Symbols.Tag)
+ ]);
+ }
+ return HelpItems("tag add [태그] [경로]", "예: tag add work C:\\project\\report.docx");
+ }
+
+ // ── del 명령: tag del <태그> <경로> ────────────────────────────────
+ if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
+ {
+ var rest = q[4..].Trim();
+ var spaceIdx = rest.IndexOf(' ');
+ if (spaceIdx > 0)
+ {
+ var tag = rest[..spaceIdx].Trim();
+ var path = rest[(spaceIdx + 1)..].Trim();
+ return Task.FromResult>(
+ [
+ new LauncherItem(
+ $"태그 제거: [{tag}] ← {Path.GetFileName(path)}",
+ $"{path} · Enter로 제거",
+ null,
+ ValueTuple.Create("__TAG_DEL__", tag, path),
+ Symbol: Symbols.Delete)
+ ]);
+ }
+ return HelpItems("tag del [태그] [경로]", "예: tag del work C:\\project\\report.docx");
+ }
+
+ // ── clear 명령: tag clear <경로> ───────────────────────────────────
+ if (q.StartsWith("clear ", StringComparison.OrdinalIgnoreCase))
+ {
+ var path = q[6..].Trim();
+ if (!string.IsNullOrEmpty(path))
+ {
+ var currentTags = Tags.GetTags(path);
+ var tagList = currentTags.Count > 0
+ ? string.Join(", ", currentTags.Select(t => $"[{t}]"))
+ : "태그 없음";
+ return Task.FromResult>(
+ [
+ new LauncherItem(
+ $"태그 전체 제거: {Path.GetFileName(path)}",
+ $"{tagList} · Enter로 모두 삭제",
+ null,
+ ValueTuple.Create("__TAG_CLEAR__", path),
+ Symbol: Symbols.Delete)
+ ]);
+ }
+ return HelpItems("tag clear [경로]", "예: tag clear C:\\project\\report.docx");
+ }
+
+ // ── 태그 검색 또는 전체 목록 ─────────────────────────────────────────
+ return string.IsNullOrEmpty(q)
+ ? BuildTagListItems()
+ : BuildTagSearchItems(q);
+ }
+
+ // ─── ExecuteAsync ────────────────────────────────────────────────────────
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ // 태그 추가
+ if (item.Data is ValueTuple addCmd &&
+ addCmd.Item1 == "__TAG_ADD__")
+ {
+ Tags.AddTag(addCmd.Item3, addCmd.Item2);
+ NotificationService.Notify("AX Copilot",
+ $"태그 추가: [{addCmd.Item2}] → {Path.GetFileName(addCmd.Item3)}");
+ return Task.CompletedTask;
+ }
+
+ // 태그 제거
+ if (item.Data is ValueTuple delCmd &&
+ delCmd.Item1 == "__TAG_DEL__")
+ {
+ Tags.RemoveTag(delCmd.Item3, delCmd.Item2);
+ NotificationService.Notify("AX Copilot",
+ $"태그 제거: [{delCmd.Item2}] ← {Path.GetFileName(delCmd.Item3)}");
+ return Task.CompletedTask;
+ }
+
+ // 전체 태그 제거
+ if (item.Data is ValueTuple clearCmd &&
+ clearCmd.Item1 == "__TAG_CLEAR__")
+ {
+ Tags.ClearTags(clearCmd.Item2);
+ NotificationService.Notify("AX Copilot",
+ $"태그 전체 제거: {Path.GetFileName(clearCmd.Item2)}");
+ return Task.CompletedTask;
+ }
+
+ // 파일/폴더 열기
+ if (item.Data is string filePath)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ LogService.Warn($"[TagHandler] 파일 열기 실패: {ex.Message}");
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ // ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
+
+ private static Task> BuildTagListItems()
+ {
+ var allTags = Tags.GetAllTags();
+ if (allTags.Count == 0)
+ {
+ return Task.FromResult>(
+ [
+ new LauncherItem(
+ "등록된 태그가 없습니다",
+ "tag add [태그] [경로]로 태그를 추가하세요",
+ null, null, Symbol: Symbols.Info),
+ new LauncherItem(
+ "사용법 보기",
+ "tag add work C:\\path\\file.docx / tag work / tag clear C:\\path",
+ null, null, Symbol: Symbols.Info),
+ ]);
+ }
+
+ var items = allTags
+ .OrderByDescending(kv => kv.Value)
+ .ThenBy(kv => kv.Key)
+ .Take(12)
+ .Select(kv => new LauncherItem(
+ $"[{kv.Key}]",
+ $"{kv.Value}개 파일 · tag {kv.Key}로 파일 목록 보기",
+ null, null,
+ Symbol: Symbols.Tag))
+ .ToList();
+
+ return Task.FromResult>(items);
+ }
+
+ private static Task> BuildTagSearchItems(string q)
+ {
+ var allTags = Tags.GetAllTags();
+
+ // 입력 q와 prefix match되는 태그 먼저, 그 다음 contains 순
+ var matchedTags = allTags.Keys
+ .Where(t => t.Contains(q, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(t => t.StartsWith(q, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
+ .ThenBy(t => t)
+ .ToList();
+
+ if (!matchedTags.Any())
+ {
+ return Task.FromResult>(
+ [
+ new LauncherItem(
+ $"[{q}] 태그에 해당하는 파일 없음",
+ "tag add [태그] [경로]로 태그를 추가하세요",
+ null, null, Symbol: Symbols.Info)
+ ]);
+ }
+
+ var items = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var tag in matchedTags.Take(2))
+ {
+ var files = Tags.GetFilesByTag(tag);
+ foreach (var path in files.Take(6))
+ {
+ if (!seen.Add(path)) continue;
+
+ var isDir = Directory.Exists(path);
+ var isFile = File.Exists(path);
+ var symbol = isDir ? Symbols.Folder
+ : isFile ? Symbols.File
+ : Symbols.Warning;
+ var hint = isDir ? "폴더 열기"
+ : isFile ? "파일 열기"
+ : "경로를 찾을 수 없음";
+
+ items.Add(new LauncherItem(
+ Path.GetFileName(path),
+ $"[{tag}] · {path} · {hint}",
+ null, path,
+ Symbol: symbol));
+ }
+ }
+
+ if (!items.Any())
+ {
+ items.Add(new LauncherItem(
+ $"[{q}] 태그 파일 접근 불가",
+ "파일이 삭제되었거나 경로가 변경되었을 수 있습니다",
+ null, null, Symbol: Symbols.Warning));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ private static Task> HelpItems(string usage, string example) =>
+ Task.FromResult>(
+ [
+ new LauncherItem(
+ $"사용법: {usage}",
+ example,
+ null, null, Symbol: Symbols.Info)
+ ]);
+}
diff --git a/src/AxCopilot/Services/FileTagService.cs b/src/AxCopilot/Services/FileTagService.cs
new file mode 100644
index 0000000..dfa4f11
--- /dev/null
+++ b/src/AxCopilot/Services/FileTagService.cs
@@ -0,0 +1,155 @@
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace AxCopilot.Services;
+
+///
+/// Phase L3-5: 파일 태그 시스템 서비스.
+/// 파일·폴더에 사용자 정의 태그를 부여하고 태그 기반 검색을 지원합니다.
+/// 데이터는 %APPDATA%\AxCopilot\file_tags.json에 저장됩니다.
+///
+public class FileTagService
+{
+ private static readonly string TagFile = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "AxCopilot", "file_tags.json");
+
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ WriteIndented = true,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ /// key = 정규화된 파일 경로, value = 태그 집합
+ private Dictionary> _data =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ private bool _loaded;
+
+ // ─── 싱글턴 ─────────────────────────────────────────────────────────────
+ private static FileTagService? _instance;
+ public static FileTagService Instance => _instance ??= new FileTagService();
+ private FileTagService() { }
+
+ // ─── 공개 API ────────────────────────────────────────────────────────────
+
+ /// 파일에 태그를 추가합니다.
+ public void AddTag(string path, string tag)
+ {
+ EnsureLoaded();
+ path = NormalizePath(path);
+ tag = NormalizeTag(tag);
+ if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(tag)) return;
+
+ if (!_data.TryGetValue(path, out var tags))
+ {
+ tags = new HashSet(StringComparer.OrdinalIgnoreCase);
+ _data[path] = tags;
+ }
+ tags.Add(tag);
+ Save();
+ }
+
+ /// 파일에서 태그를 제거합니다.
+ public void RemoveTag(string path, string tag)
+ {
+ EnsureLoaded();
+ path = NormalizePath(path);
+ tag = NormalizeTag(tag);
+ if (!_data.TryGetValue(path, out var tags)) return;
+ tags.Remove(tag);
+ if (tags.Count == 0) _data.Remove(path);
+ Save();
+ }
+
+ /// 파일의 모든 태그를 제거합니다.
+ public void ClearTags(string path)
+ {
+ EnsureLoaded();
+ path = NormalizePath(path);
+ _data.Remove(path);
+ Save();
+ }
+
+ /// 파일의 태그 목록을 반환합니다.
+ public IReadOnlyList GetTags(string path)
+ {
+ EnsureLoaded();
+ path = NormalizePath(path);
+ return _data.TryGetValue(path, out var tags)
+ ? tags.OrderBy(t => t).ToList()
+ : Array.Empty();
+ }
+
+ /// 특정 태그가 부여된 파일 경로 목록을 반환합니다.
+ public IReadOnlyList GetFilesByTag(string tag)
+ {
+ EnsureLoaded();
+ tag = NormalizeTag(tag);
+ return _data
+ .Where(kv => kv.Value.Contains(tag))
+ .Select(kv => kv.Key)
+ .OrderBy(p => p)
+ .ToList();
+ }
+
+ /// 등록된 모든 태그와 각 파일 수를 반환합니다.
+ public IReadOnlyDictionary GetAllTags()
+ {
+ EnsureLoaded();
+ return _data
+ .SelectMany(kv => kv.Value)
+ .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.Count());
+ }
+
+ /// 경로에 태그가 하나 이상 있는지 확인합니다.
+ public bool HasTags(string path)
+ {
+ EnsureLoaded();
+ path = NormalizePath(path);
+ return _data.TryGetValue(path, out var tags) && tags.Count > 0;
+ }
+
+ // ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
+
+ private void EnsureLoaded()
+ {
+ if (_loaded) return;
+ _loaded = true;
+ try
+ {
+ if (!File.Exists(TagFile)) return;
+ var raw = JsonSerializer.Deserialize>>(
+ File.ReadAllText(TagFile), JsonOpts);
+ if (raw != null)
+ {
+ _data = raw.ToDictionary(
+ kv => kv.Key,
+ kv => new HashSet(kv.Value, StringComparer.OrdinalIgnoreCase),
+ StringComparer.OrdinalIgnoreCase);
+ }
+ }
+ catch (Exception ex) { LogService.Warn($"[FileTagService] 로드 실패: {ex.Message}"); }
+ }
+
+ private void Save()
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(TagFile)!);
+ var raw = _data.ToDictionary(
+ kv => kv.Key,
+ kv => kv.Value.OrderBy(t => t).ToList());
+ File.WriteAllText(TagFile, JsonSerializer.Serialize(raw, JsonOpts));
+ }
+ catch (Exception ex) { LogService.Warn($"[FileTagService] 저장 실패: {ex.Message}"); }
+ }
+
+ private static string NormalizePath(string path)
+ => path.Trim().TrimEnd('\\', '/');
+
+ private static string NormalizeTag(string tag)
+ => tag.Trim().ToLowerInvariant().Replace(" ", "-");
+}
diff --git a/src/AxCopilot/Themes/Symbols.cs b/src/AxCopilot/Themes/Symbols.cs
index ae5466f..b6c4a9a 100644
--- a/src/AxCopilot/Themes/Symbols.cs
+++ b/src/AxCopilot/Themes/Symbols.cs
@@ -130,4 +130,7 @@ internal static class Symbols
public const string RenameIcon = "\uE8AC"; // 이름 변경 (Rename와 동일)
public const string MonitorIcon = "\uE7F4"; // 시스템 모니터 (Computer와 동일)
public const string ScaffoldIcon = "\uE8F1"; // 스캐폴딩 (프로젝트 구조)
+
+ // ─── Phase L3-5 태그 시스템 ────────────────────────────────────────────────
+ public const string Tag = "\uEAB4"; // 파일 태그 (레이블)
}
diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.cs b/src/AxCopilot/ViewModels/LauncherViewModel.cs
index 3eec816..05d0021 100644
--- a/src/AxCopilot/ViewModels/LauncherViewModel.cs
+++ b/src/AxCopilot/ViewModels/LauncherViewModel.cs
@@ -147,6 +147,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
{ "snap", ("스냅", Symbols.SnapLayout, "#B45309") },
{ "cap", ("캡처", Symbols.CaptureIcon, "#BE185D") },
{ "help", ("도움말", Symbols.Info, "#6B7280") },
+ // ─── Phase L3-5 파일 태그 ──────────────────────────────────────────────
+ { "tag", ("태그", Symbols.Tag, "#6366F1") },
};
// ─── 설정 기능 토글 (런처 실동작 연결) ──────────────────────────────────