From 8cf025e14dfb05607136ea61c651107e1883170c Mon Sep 17 00:00:00 2001 From: lacvet Date: Tue, 14 Apr 2026 18:34:05 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF=EC=8A=A4=ED=82=AC=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=A0=9C=EC=96=B4=EC=99=80=20inline=20shell=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=EC=9E=A5=EC=B9=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스킬 시스템 설정에 프로젝트 스킬 탐색, 플러그인 스킬 탐색, 레거시 command 스킬 호환, inline shell 허용 여부와 시간/출력 제한을 추가하고 일반 설정 및 AX Agent 설정 UI에 연결했다. SkillService는 로드 시그니처에 실제 스킬 파일 수와 최근 수정 시각을 반영하도록 보강해 같은 폴더라도 스킬 파일이 바뀌면 다음 로드 요청에서 자동으로 재탐색되도록 정리했다. inline shell 실행기는 설정 기반 비활성화, timeout, 최대 출력 길이 제한을 적용하고 스킬 편집기/갤러리는 lazy prompt body 경로와 ReloadFromCurrentSettings()를 사용하도록 맞췄다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase4b\\ -p:IntermediateOutputPath=obj\\verify_phase4b\\ (경고 0 / 오류 0) 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase4b_tests\\ -p:IntermediateOutputPath=obj\\verify_phase4b_tests\\ (통과 18, 기존 WorkspaceContextGeneratorTests nullable 경고 1건 유지) --- README.md | 8 ++ docs/DEVELOPMENT.md | 8 ++ src/AxCopilot/Models/AppSettings.cs | 18 ++++ src/AxCopilot/Services/Agent/SkillService.cs | 70 +++++++++++++--- src/AxCopilot/ViewModels/SettingsViewModel.cs | 54 ++++++++++++ src/AxCopilot/Views/AgentSettingsWindow.xaml | 82 +++++++++++++++++++ .../Views/AgentSettingsWindow.xaml.cs | 12 +++ src/AxCopilot/Views/SettingsWindow.xaml | 78 ++++++++++++++++++ src/AxCopilot/Views/SkillEditorWindow.xaml.cs | 2 +- .../Views/SkillGalleryWindow.xaml.cs | 6 +- 10 files changed, 323 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9085f8a..f6fe1ca 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-14 18:33 (KST) +- 스킬 소스 정책과 inline shell 안전장치를 추가로 정리했습니다. 프로젝트 스킬 자동 탐색, 플러그인 스킬 탐색, 레거시 command 스킬 호환, inline shell 허용 여부와 시간/출력 제한을 설정으로 제어할 수 있습니다. +- 스킬 재탐색도 더 민감하게 바꿨습니다. 이제 로드 시그니처가 소스 디렉터리뿐 아니라 실제 스킬 파일 수와 최근 수정 시각을 함께 반영해, 같은 폴더라도 파일이 바뀌면 다음 로드 요청에서 자동으로 다시 읽습니다. +- 스킬 편집기와 갤러리도 lazy prompt body 경로를 사용하도록 맞췄습니다. 파일형 스킬은 미리보기/편집 시점에 실제 본문을 읽고, inline shell은 설정에서 막을 수 있으며 시간 초과와 출력 길이 제한도 적용됩니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase4b\\ -p:IntermediateOutputPath=obj\\verify_phase4b\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase4b_tests\\ -p:IntermediateOutputPath=obj\\verify_phase4b_tests\\` 통과 18 +- 참고: 테스트 빌드 중 기존 파일 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs`의 nullable 경고 1건은 유지됩니다. + - 업데이트: 2026-04-14 18:22 (KST) - 스킬 소스 계층을 더 확장했습니다. 이제 기본/사용자/추가 폴더뿐 아니라 작업 폴더 상위 경로의 `.claude/skills`, 플러그인 내부 스킬 폴더, `.claude/commands` 기반의 레거시 markdown command까지 함께 읽어 번들/프로젝트/플러그인/공용/레거시 스킬로 분류합니다. - 스킬 로딩 방식도 가볍게 정리했습니다. 파일형 스킬은 본문을 처음부터 모두 메모리에 올리지 않고, 실제 호출·미리보기 시점에 body를 읽어 lazy하게 조립합니다. 인자 모델은 `arguments`와 `argument-hint`를 함께 해석해 위치 인자 치환과 누락 인자 안내를 같이 처리합니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ec6516d..0f9b239 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -816,3 +816,11 @@ UI 디자인 대규모 리팩토링 등 위험 작업 전 기록한 안전 복 > - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase3\\ -p:IntermediateOutputPath=obj\\verify_phase3\\` 경고 0 / 오류 0 > - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase3_tests\\ -p:IntermediateOutputPath=obj\\verify_phase3_tests\\` 통과 18 > - 참고: 테스트 프로젝트의 기존 nullable 경고 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76)` 1건은 유지됩니다. +> 업데이트: 2026-04-14 18:33 (KST) +> - 스킬 정책 제어를 추가했습니다. `LlmSettings`에 `enableProjectSkillDiscovery`, `enablePluginSkillDiscovery`, `enableLegacyCommandSkills`, `enableSkillInlineShell`, `skillInlineShellTimeoutSeconds`, `skillInlineShellMaxOutputChars`를 추가하고 일반 설정/AX Agent 설정 UI에 연결했습니다. +> - 스킬 로드 시그니처는 이제 소스 디렉터리 목록뿐 아니라 실제 스킬 파일 수와 최근 수정 시각을 함께 반영합니다. 같은 폴더 구성이라도 파일 내용이 바뀌면 다음 로드 요청에서 재탐색됩니다. +> - inline shell 실행기는 설정 기반 비활성화, timeout, 출력 길이 제한을 적용하도록 보강했습니다. 비활성 상태나 시간 초과는 프롬프트 안에서 식별 가능한 안내 문자열로 반환합니다. +> - `SkillEditorWindow`와 `SkillGalleryWindow`는 lazy prompt body 경로를 사용하도록 맞췄고, 설정 변경 후 `ReloadFromCurrentSettings()`를 통해 현재 스킬 소스를 다시 읽도록 정리했습니다. +> - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase4b\\ -p:IntermediateOutputPath=obj\\verify_phase4b\\` 경고 0 / 오류 0 +> - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase4b_tests\\ -p:IntermediateOutputPath=obj\\verify_phase4b_tests\\` 통과 18 +> - 참고: 테스트 프로젝트의 기존 nullable 경고 `src/AxCopilot.Tests/Services/WorkspaceContextGeneratorTests.cs(76)` 1건은 유지됩니다. diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index a049e44..8c153e0 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -1296,6 +1296,24 @@ public class LlmSettings [JsonPropertyName("additionalSkillFolders")] public List AdditionalSkillFolders { get; set; } = new(); + [JsonPropertyName("enableProjectSkillDiscovery")] + public bool EnableProjectSkillDiscovery { get; set; } = true; + + [JsonPropertyName("enablePluginSkillDiscovery")] + public bool EnablePluginSkillDiscovery { get; set; } = true; + + [JsonPropertyName("enableLegacyCommandSkills")] + public bool EnableLegacyCommandSkills { get; set; } = true; + + [JsonPropertyName("enableSkillInlineShell")] + public bool EnableSkillInlineShell { get; set; } = true; + + [JsonPropertyName("skillInlineShellTimeoutSeconds")] + public int SkillInlineShellTimeoutSeconds { get; set; } = 8; + + [JsonPropertyName("skillInlineShellMaxOutputChars")] + public int SkillInlineShellMaxOutputChars { get; set; } = 4000; + /// 슬래시 명령어 팝업 한 번에 표시할 최대 항목 수 (3~20). 기본 7. [JsonPropertyName("slashPopupPageSize")] public int SlashPopupPageSize { get; set; } = 7; diff --git a/src/AxCopilot/Services/Agent/SkillService.cs b/src/AxCopilot/Services/Agent/SkillService.cs index 415cdbb..bc25321 100644 --- a/src/AxCopilot/Services/Agent/SkillService.cs +++ b/src/AxCopilot/Services/Agent/SkillService.cs @@ -23,6 +23,13 @@ public static class SkillService /// 로드된 스킬 목록. public static IReadOnlyList Skills => _skills; + public static void ReloadFromCurrentSettings(string? projectRoot = null) + { + var app = System.Windows.Application.Current as App; + var llm = app?.SettingsService?.Settings.Llm; + LoadSkills(llm?.SkillsFolderPath, projectRoot ?? llm?.WorkFolder, llm?.AdditionalSkillFolders); + } + /// 스킬 폴더에서 *.skill.md / SKILL.md 파일을 로드합니다. public static void LoadSkills(string? customFolder = null, string? projectRoot = null, IEnumerable? additionalFolders = null) { @@ -30,7 +37,7 @@ public static class SkillService var normalizedProjectRoot = NormalizeExistingDirectory(projectRoot); var normalizedAdditionalFolders = NormalizeDistinctDirectories(additionalFolders); var sources = BuildSkillSources(normalizedCustomFolder, normalizedProjectRoot, normalizedAdditionalFolders).ToList(); - var loadSignature = string.Join("|", sources.Select(source => $"{source.Kind}:{source.Scope}:{source.Directory}")); + var loadSignature = ComputeLoadSignature(sources); if (_skills.Count > 0 && string.Equals(_lastLoadSignature, loadSignature, StringComparison.OrdinalIgnoreCase)) return; @@ -324,6 +331,8 @@ public static class SkillService string? projectRoot, IEnumerable additionalFolders) { + var app = System.Windows.Application.Current as App; + var llm = app?.SettingsService?.Settings.Llm; var sources = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -344,18 +353,44 @@ public static class SkillService foreach (var folder in additionalFolders) AddSource(folder, "additional"); - foreach (var folder in EnumeratePluginSkillFolders()) - AddSource(folder, "plugin"); + if (llm?.EnablePluginSkillDiscovery ?? true) + { + foreach (var folder in EnumeratePluginSkillFolders()) + AddSource(folder, "plugin"); + } - foreach (var folder in EnumerateProjectSkillFolders(projectRoot)) - AddSource(folder, "project"); + if (llm?.EnableProjectSkillDiscovery ?? true) + { + foreach (var folder in EnumerateProjectSkillFolders(projectRoot)) + AddSource(folder, "project"); + } - foreach (var folder in EnumerateLegacyCommandFolders(projectRoot)) - AddSource(folder, "legacy", SkillSourceKind.LegacyCommand); + if (llm?.EnableLegacyCommandSkills ?? true) + { + foreach (var folder in EnumerateLegacyCommandFolders(projectRoot)) + AddSource(folder, "legacy", SkillSourceKind.LegacyCommand); + } return sources; } + private static string ComputeLoadSignature(IEnumerable sources) + { + var parts = new List(); + foreach (var source in sources) + { + var files = source.Kind == SkillSourceKind.LegacyCommand + ? EnumerateLegacyCommandFiles(source.Directory).ToList() + : EnumerateSkillFiles(source.Directory).ToList(); + var latestWrite = files.Count == 0 + ? Directory.GetLastWriteTimeUtc(source.Directory).Ticks + : files.Max(file => File.GetLastWriteTimeUtc(file).Ticks); + parts.Add($"{source.Kind}:{source.Scope}:{source.Directory}:{files.Count}:{latestWrite}"); + } + + return string.Join("|", parts); + } + private static IEnumerable EnumerateProjectSkillFolders(string? projectRoot) { foreach (var root in EnumerateAncestorDirectories(projectRoot)) @@ -1332,8 +1367,17 @@ public static class SkillService string workFolder, CancellationToken ct) { + var app = System.Windows.Application.Current as App; + var llm = app?.SettingsService?.Settings.Llm; + if (llm is { EnableSkillInlineShell: false }) + return "[inline-shell disabled by settings]"; + + var timeoutSeconds = Math.Clamp(llm?.SkillInlineShellTimeoutSeconds ?? 8, 1, 30); + var maxOutputChars = Math.Clamp(llm?.SkillInlineShellMaxOutputChars ?? 4000, 200, 20000); try { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); var startInfo = shellKind == "powershell" ? new System.Diagnostics.ProcessStartInfo { @@ -1362,14 +1406,18 @@ public static class SkillService using var process = new System.Diagnostics.Process { StartInfo = startInfo }; process.Start(); - var stdout = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false); - var stderr = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false); - await process.WaitForExitAsync(ct).ConfigureAwait(false); + var stdout = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token).ConfigureAwait(false); + var stderr = await process.StandardError.ReadToEndAsync(timeoutCts.Token).ConfigureAwait(false); + await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); var output = string.IsNullOrWhiteSpace(stdout) ? stderr : stdout; if (string.IsNullOrWhiteSpace(output)) return "[inline-shell: no output]"; output = output.Trim(); - return output.Length > 4000 ? output[..4000] : output; + return output.Length > maxOutputChars ? output[..maxOutputChars] : output; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return $"[inline-shell timeout] exceeded {timeoutSeconds}s"; } catch (Exception ex) { diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index 6a89432..5a24a38 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -546,6 +546,48 @@ public class SettingsViewModel : INotifyPropertyChanged set { _additionalSkillFoldersText = value; OnPropertyChanged(); } } + private bool _enableProjectSkillDiscovery = true; + public bool EnableProjectSkillDiscovery + { + get => _enableProjectSkillDiscovery; + set { _enableProjectSkillDiscovery = value; OnPropertyChanged(); } + } + + private bool _enablePluginSkillDiscovery = true; + public bool EnablePluginSkillDiscovery + { + get => _enablePluginSkillDiscovery; + set { _enablePluginSkillDiscovery = value; OnPropertyChanged(); } + } + + private bool _enableLegacyCommandSkills = true; + public bool EnableLegacyCommandSkills + { + get => _enableLegacyCommandSkills; + set { _enableLegacyCommandSkills = value; OnPropertyChanged(); } + } + + private bool _enableSkillInlineShell = true; + public bool EnableSkillInlineShell + { + get => _enableSkillInlineShell; + set { _enableSkillInlineShell = value; OnPropertyChanged(); } + } + + private int _skillInlineShellTimeoutSeconds = 8; + public int SkillInlineShellTimeoutSeconds + { + get => _skillInlineShellTimeoutSeconds; + set { _skillInlineShellTimeoutSeconds = Math.Clamp(value, 1, 30); OnPropertyChanged(); } + } + + private int _skillInlineShellMaxOutputChars = 4000; + public int SkillInlineShellMaxOutputChars + { + get => _skillInlineShellMaxOutputChars; + set { _skillInlineShellMaxOutputChars = Math.Clamp(value, 200, 20000); OnPropertyChanged(); } + } + private int _slashPopupPageSize = 6; public int SlashPopupPageSize { @@ -1217,6 +1259,12 @@ public class SettingsViewModel : INotifyPropertyChanged _enableForkSkillDelegationEnforcement = llm.EnableForkSkillDelegationEnforcement; _skillsFolderPath = llm.SkillsFolderPath; _additionalSkillFoldersText = string.Join(Environment.NewLine, llm.AdditionalSkillFolders ?? new List()); + _enableProjectSkillDiscovery = llm.EnableProjectSkillDiscovery; + _enablePluginSkillDiscovery = llm.EnablePluginSkillDiscovery; + _enableLegacyCommandSkills = llm.EnableLegacyCommandSkills; + _enableSkillInlineShell = llm.EnableSkillInlineShell; + _skillInlineShellTimeoutSeconds = Math.Clamp(llm.SkillInlineShellTimeoutSeconds, 1, 30); + _skillInlineShellMaxOutputChars = Math.Clamp(llm.SkillInlineShellMaxOutputChars, 200, 20000); _slashPopupPageSize = llm.SlashPopupPageSize > 0 ? Math.Clamp(llm.SlashPopupPageSize, 3, 10) : 6; _maxFavoriteSlashCommands = llm.MaxFavoriteSlashCommands > 0 ? Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30) : 10; _maxRecentSlashCommands = llm.MaxRecentSlashCommands > 0 ? Math.Clamp(llm.MaxRecentSlashCommands, 5, 50) : 20; @@ -1668,6 +1716,12 @@ public class SettingsViewModel : INotifyPropertyChanged s.Llm.EnableForkSkillDelegationEnforcement = _enableForkSkillDelegationEnforcement; s.Llm.SkillsFolderPath = _skillsFolderPath; s.Llm.AdditionalSkillFolders = ParseAdditionalSkillFolders(_additionalSkillFoldersText); + s.Llm.EnableProjectSkillDiscovery = _enableProjectSkillDiscovery; + s.Llm.EnablePluginSkillDiscovery = _enablePluginSkillDiscovery; + s.Llm.EnableLegacyCommandSkills = _enableLegacyCommandSkills; + s.Llm.EnableSkillInlineShell = _enableSkillInlineShell; + s.Llm.SkillInlineShellTimeoutSeconds = _skillInlineShellTimeoutSeconds; + s.Llm.SkillInlineShellMaxOutputChars = _skillInlineShellMaxOutputChars; s.Llm.SlashPopupPageSize = _slashPopupPageSize; s.Llm.MaxFavoriteSlashCommands = _maxFavoriteSlashCommands; s.Llm.MaxRecentSlashCommands = _maxRecentSlashCommands; diff --git a/src/AxCopilot/Views/AgentSettingsWindow.xaml b/src/AxCopilot/Views/AgentSettingsWindow.xaml index 5bbaf32..0f5e6bb 100644 --- a/src/AxCopilot/Views/AgentSettingsWindow.xaml +++ b/src/AxCopilot/Views/AgentSettingsWindow.xaml @@ -869,6 +869,54 @@ Grid.Column="1" Style="{StaticResource ToggleSwitch}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -888,6 +936,40 @@ Foreground="{DynamicResource PrimaryText}" FontSize="12"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/SkillEditorWindow.xaml.cs b/src/AxCopilot/Views/SkillEditorWindow.xaml.cs index 5d0af9a..5f7eee5 100644 --- a/src/AxCopilot/Views/SkillEditorWindow.xaml.cs +++ b/src/AxCopilot/Views/SkillEditorWindow.xaml.cs @@ -482,7 +482,7 @@ public partial class SkillEditorWindow : Window try { File.WriteAllText(savePath, content, System.Text.Encoding.UTF8); - SkillService.LoadSkills(); + SkillService.ReloadFromCurrentSettings(); StatusText.Text = $"✓ 저장 완료: {Path.GetFileName(savePath)}"; StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99)); diff --git a/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs b/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs index 9d446d4..c55ba84 100644 --- a/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs +++ b/src/AxCopilot/Views/SkillGalleryWindow.xaml.cs @@ -279,7 +279,7 @@ public partial class SkillGalleryWindow : Window var editor = new SkillEditorWindow(skill) { Owner = this }; if (editor.ShowDialog() == true) { - SkillService.LoadSkills(); + SkillService.ReloadFromCurrentSettings(); BuildCategoryFilter(); RenderSkills(); } @@ -307,7 +307,7 @@ public partial class SkillGalleryWindow : Window destPath = Path.Combine(destFolder, $"{srcName}_copy{counter++}.skill.md"); File.Copy(skill.FilePath, destPath); - SkillService.LoadSkills(); + SkillService.ReloadFromCurrentSettings(); BuildCategoryFilter(); RenderSkills(); } @@ -342,7 +342,7 @@ public partial class SkillGalleryWindow : Window try { File.Delete(skill.FilePath); - SkillService.LoadSkills(); + SkillService.ReloadFromCurrentSettings(); BuildCategoryFilter(); RenderSkills(); }