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