From d908af7b429994f73f7a3757d9b5fe5898659f6a Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:26:11 -0500 Subject: [PATCH 01/19] fix(gui): scrollable wizard pages without fixed MaxHeight panels Replace MaxHeight-capped notice/review panels with star-row ScrollViewers, wrap explain/finish/install pages for compact hosts, and extend GuiSmoke headless layout coverage. --- .../Dialogs/WizardPages/AspyrNoticePage.axaml | 22 +-- .../WizardPages/DownloadsExplainPage.axaml | 44 +++--- .../Dialogs/WizardPages/FinishedPage.axaml | 72 +++------- .../WizardPages/InstallStartPage.axaml | 35 ++--- .../Dialogs/WizardPages/InstallingPage.axaml | 4 +- .../WizardPages/ModSelectionPage.axaml | 5 +- .../Dialogs/WizardPages/PreamblePage.axaml | 70 ++-------- .../WizardPages/WidescreenCompletePage.axaml | 18 +-- .../WidescreenInstallingPage.axaml | 16 +-- .../WidescreenModSelectionPage.axaml | 18 +-- .../WizardPages/WidescreenNoticePage.axaml | 17 +-- .../HeadlessUITests/GuiSmokeHeadlessTests.cs | 130 ++++++++++++++++++ 12 files changed, 237 insertions(+), 214 deletions(-) diff --git a/src/ModSync.GUI/Dialogs/WizardPages/AspyrNoticePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/AspyrNoticePage.axaml index 87f66087..655208c5 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/AspyrNoticePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/AspyrNoticePage.axaml @@ -6,21 +6,13 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - + + + + + - - - + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml index 917143a7..42cb68aa 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml @@ -6,31 +6,21 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/FinishedPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/FinishedPage.axaml index db407cab..41235c0a 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/FinishedPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/FinishedPage.axaml @@ -6,54 +6,26 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml index 13f9f51d..73d9ee8e 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml @@ -6,33 +6,26 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - - - - - - - - - - + + + + + + + + + + - - - + + - - + - + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/InstallingPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/InstallingPage.axaml index a54ad4ba..f89073e5 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/InstallingPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/InstallingPage.axaml @@ -6,7 +6,8 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - + + @@ -247,4 +248,5 @@ TextAlignment="Center" /> + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/ModSelectionPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/ModSelectionPage.axaml index 228e5c5a..69522238 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/ModSelectionPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/ModSelectionPage.axaml @@ -123,7 +123,10 @@ Grid.Row="2" Padding="8" CornerRadius="8"> - + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/PreamblePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/PreamblePage.axaml index 56f98622..28c3cc83 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/PreamblePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/PreamblePage.axaml @@ -6,63 +6,19 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenCompletePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenCompletePage.axaml index 2a35cb28..797dbd0d 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenCompletePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenCompletePage.axaml @@ -6,16 +6,10 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenInstallingPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenInstallingPage.axaml index 3cf406b0..3abd8f2d 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenInstallingPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenInstallingPage.axaml @@ -6,14 +6,10 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenModSelectionPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenModSelectionPage.axaml index 87c0a2ed..4a8c1872 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenModSelectionPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenModSelectionPage.axaml @@ -6,13 +6,13 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - + + + + + + + + + - - diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenNoticePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenNoticePage.axaml index 2502f4a0..ca55e029 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WidescreenNoticePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WidescreenNoticePage.axaml @@ -6,17 +6,12 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - - - - - + + + + + - + - - diff --git a/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs b/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs index 5e67c52c..72d21a84 100644 --- a/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs +++ b/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs @@ -133,6 +133,136 @@ public async Task ValidatePage_LogSplitter_IsPresent() } } + + [AvaloniaFact(DisplayName = "InstallStartPage mod list scrolls without fixed MaxHeight")] + public async Task InstallStartPage_ModList_IsScrollableWithoutMaxHeight() + { + var page = new InstallStartPage(); + Window window = await HostInWindowAsync(page, width: 960, height: 540); + try + { + await PumpEventsAsync(); + ScrollViewer modListScroll = page.FindControl("ModListScrollViewer"); + Assert.NotNull(modListScroll); + Assert.Equal(Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, modListScroll.VerticalScrollBarVisibility); + Assert.True(IsUnconstrainedMaxHeight(modListScroll.MaxHeight)); + AssertNoFixedMaxHeightAncestor(modListScroll); + } + finally + { + await CloseWindowAsync(window); + } + } + + [AvaloniaFact(DisplayName = "WidescreenModSelectionPage mod list uses ScrollViewer")] + public async Task WidescreenModSelectionPage_ModList_IsScrollable() + { + var page = new WidescreenModSelectionPage(); + Window window = await HostInWindowAsync(page, width: 900, height: 500); + try + { + await PumpEventsAsync(); + ScrollViewer modListScroll = page.FindControl("ModListScrollViewer"); + Assert.NotNull(modListScroll); + } + finally + { + await CloseWindowAsync(window); + } + } + + [AvaloniaFact(DisplayName = "Preamble and notice pages scroll without MaxHeight caps")] + public async Task PreambleAndNoticePages_ScrollWithoutMaxHeight() + { + var preamble = new PreamblePage("## Compact host preamble"); + Window preambleWindow = await HostInWindowAsync(preamble, width: 900, height: 480); + try + { + await PumpEventsAsync(); + ScrollViewer contentScroll = preamble.FindControl("ContentScrollViewer"); + Assert.NotNull(contentScroll); + AssertNoFixedMaxHeightAncestor(contentScroll); + } + finally + { + await CloseWindowAsync(preambleWindow); + } + + foreach (Control page in new Control[] { new AspyrNoticePage("Aspyr"), new WidescreenNoticePage("WS") }) + { + Window window = await HostInWindowAsync(page, width: 900, height: 480); + try + { + await PumpEventsAsync(); + ScrollViewer scroll = page.FindControl("ContentScrollViewer"); + Assert.NotNull(scroll); + AssertNoFixedMaxHeightAncestor(scroll); + } + finally + { + await CloseWindowAsync(window); + } + } + } + + [AvaloniaFact(DisplayName = "Later wizard pages remain scrollable in compact hosts")] + public async Task LaterWizardPages_CompactHost_UseScrollViewer() + { + Control[] pages = + { + new FinishedPage(), + new InstallingPage(), + new BaseInstallCompletePage(), + new WidescreenCompletePage(), + new WidescreenInstallingPage(), + new DownloadsExplainPage(), + }; + + foreach (Control page in pages) + { + Window window = await HostInWindowAsync(page, width: 900, height: 480); + try + { + await PumpEventsAsync(); + ScrollViewer scrollViewer = page.GetVisualDescendants().OfType().FirstOrDefault(); + Assert.True(scrollViewer != null, page.GetType().Name + " should host a ScrollViewer"); + } + finally + { + await CloseWindowAsync(window); + } + } + + var modSelection = new ModSelectionPage(); + Window modWindow = await HostInWindowAsync(modSelection, width: 900, height: 500); + try + { + await PumpEventsAsync(); + Assert.NotNull(modSelection.FindControl("ModListScrollViewer")); + } + finally + { + await CloseWindowAsync(modWindow); + } + } + + private static bool IsUnconstrainedMaxHeight(double maxHeight) => + double.IsNaN(maxHeight) || double.IsInfinity(maxHeight) || maxHeight <= 0 || maxHeight >= 10_000; + + private static void AssertNoFixedMaxHeightAncestor(Control control) + { + Control current = control; + while (current != null) + { + if (!IsUnconstrainedMaxHeight(current.MaxHeight)) + { + Assert.Fail($"Found fixed MaxHeight={current.MaxHeight} on {current.GetType().Name}; use star rows instead."); + } + + current = current.Parent as Control; + } + } + private static async Task HostInWindowAsync(Control control, double width, double height) { Window window = await Dispatcher.UIThread.InvokeAsync( From 47c1abebdb53c40ebf4b69926d9b3bbed81a2fc6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:37:05 -0500 Subject: [PATCH 02/19] fix(security): reject path traversal in draft instruction sandbox IsSandboxedPath now mirrors FOMOD relative-path rules (.., drives, rooted segments) and drafts apply ReviewFlagMessage for GUI install review parity. --- .../2026-07-13-security-paste-fomod.md | 31 +++++ .../Parsing/DraftInstructionService.cs | 92 +++++++++++++- .../WizardPages/InstallStartPage.axaml.cs | 10 ++ .../Services/FileLoadingService.cs | 6 +- src/ModSync.Tests/GuideIngestionTests.cs | 115 ++++++++++++++++-- 5 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 docs/residual-review-findings/2026-07-13-security-paste-fomod.md diff --git a/docs/residual-review-findings/2026-07-13-security-paste-fomod.md b/docs/residual-review-findings/2026-07-13-security-paste-fomod.md new file mode 100644 index 00000000..7ec357f2 --- /dev/null +++ b/docs/residual-review-findings/2026-07-13-security-paste-fomod.md @@ -0,0 +1,31 @@ +# Residual review findings — paste / FOMOD security (2026-07-13) + +Branch: `fix/paste-draft-path-sandbox` (from `feat/guide-paste-ingestion`) +Review focus: path sandboxing for NL draft instructions (`DraftInstructionService`) and GUI/CLI review-flag parity after paste / `--parse-directions` + +## Finding (medium) — closed + +**Issue:** `DraftInstructionService.IsSandboxedPath` / `TrySanitizeInstruction` only checked `StartsWith("<>")` / `StartsWith("<>")` and did **not** reject `..` segments (or glued forms like `<>../outside`). Paste + `convert --parse-directions` could therefore draft instructions that escape the placeholder roots after `Path.GetFullPath`. + +**Fix:** + +1. `IsSandboxedPath` now requires a separator after the placeholder (when a relative path follows) and rejects `..`, drive-letter segments (`:`), and rooted segments — same rejection posture as `FomodToComponentMapper.NormalizeRelativePath`. +2. `GuideIngestionTests` cover `<>/../outside`, glued traversal, drive-after-placeholder, and prose cases that must not retain escaping paths. +3. GUI/CLI parity: successful drafts call `ApplyReviewFlag`, which sets `InstallationWarning` to `ReviewFlagMessage` (same text CLI writes as `# VALIDATION ISSUES:`). Paste path re-applies the flag and logs that message; `InstallStartPage` surfaces `InstallationWarning` on the pre-install review list. + +## Residual Review Findings + +| ID | Severity | Status | Notes | +|----|----------|--------|-------| +| PASTE-PATH-001 | Medium | **Closed** | `..` / rooted / drive escapes after placeholder rejected; tests in `GuideIngestionTests` | +| PASTE-REVIEW-001 | Medium (UX/parity) | **Closed** | Draft review flag on `InstallationWarning` + InstallStartPage display; CLI still emits `ReviewFlagMessage` via `ComponentValidationContext` | + +**Residual actionable work from this finding: none.** + +## Verification + +```bash +dotnet test src/ModSync.Tests/ModSync.Tests.csproj \ + --filter "FullyQualifiedName~GuideIngestionTests" \ + --configuration Debug +``` diff --git a/src/ModSync.Core/Parsing/DraftInstructionService.cs b/src/ModSync.Core/Parsing/DraftInstructionService.cs index f902f373..f1aee741 100644 --- a/src/ModSync.Core/Parsing/DraftInstructionService.cs +++ b/src/ModSync.Core/Parsing/DraftInstructionService.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using JetBrains.Annotations; @@ -114,6 +115,7 @@ public static IReadOnlyList GenerateDraftInstructions( if (added > 0) { + ApplyReviewFlag(component); info($"[DraftInstructions] Drafted {added} instruction(s) from prose for '{component.Name}' - flagged for review."); results.Add(new DraftInstructionResult(component, added)); } @@ -125,7 +127,9 @@ public static IReadOnlyList GenerateDraftInstructions( /// /// Normalizes placeholders and enforces the path-sandboxing rules on a draft instruction: /// every Source entry and any non-empty Destination must start with - /// <<modDirectory>> or <<kotorDirectory>>. + /// <<modDirectory>> or <<kotorDirectory>>, + /// followed by a separator when a relative path is present, and must not contain + /// .. segments, rooted segments, or drive letters after the placeholder. /// /// false when the instruction cannot be made sandbox-safe and must be dropped. public static bool TrySanitizeInstruction([NotNull] Instruction instruction) @@ -153,7 +157,8 @@ public static bool TrySanitizeInstruction([NotNull] Instruction instruction) instruction.Action == Instruction.ActionType.Delete || instruction.Action == Instruction.ActionType.Rename || instruction.Action == Instruction.ActionType.Extract || - instruction.Action == Instruction.ActionType.Execute; + instruction.Action == Instruction.ActionType.Execute || + instruction.Action == Instruction.ActionType.Patcher; if (sourceRequired && sanitizedSources.Count == 0) { @@ -174,6 +179,30 @@ public static bool TrySanitizeInstruction([NotNull] Instruction instruction) return true; } + /// + /// Attaches to a component that received draft instructions so + /// install-review surfaces (GUI warnings and CLI validation-issue serialization) can see it. + /// Does not overwrite an existing identical flag. + /// + public static void ApplyReviewFlag([NotNull] ModComponent component) + { + if (component is null) + { + throw new ArgumentNullException(nameof(component)); + } + + if (string.IsNullOrWhiteSpace(component.InstallationWarning)) + { + component.InstallationWarning = ReviewFlagMessage; + return; + } + + if (component.InstallationWarning.IndexOf(ReviewFlagMessage, StringComparison.Ordinal) < 0) + { + component.InstallationWarning = ReviewFlagMessage + Environment.NewLine + component.InstallationWarning; + } + } + [NotNull] private static string NormalizePlaceholders([CanBeNull] string path) { @@ -185,10 +214,63 @@ private static string NormalizePlaceholders([CanBeNull] string path) return path.Replace(LegacyGameDirectoryPlaceholder, KotorDirectoryPlaceholder); } - private static bool IsSandboxedPath([NotNull] string path) + /// + /// Returns true when is confined to a placeholder root: + /// starts with <<modDirectory>> or <<kotorDirectory>>, + /// optionally followed by / or \ and relative segments that do not escape + /// (mirrors FomodToComponentMapper.NormalizeRelativePath rejection rules). + /// + internal static bool IsSandboxedPath([NotNull] string path) { - return path.StartsWith(ModDirectoryPlaceholder, StringComparison.Ordinal) - || path.StartsWith(KotorDirectoryPlaceholder, StringComparison.Ordinal); + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + string placeholder; + if (path.StartsWith(ModDirectoryPlaceholder, StringComparison.Ordinal)) + { + placeholder = ModDirectoryPlaceholder; + } + else if (path.StartsWith(KotorDirectoryPlaceholder, StringComparison.Ordinal)) + { + placeholder = KotorDirectoryPlaceholder; + } + else + { + return false; + } + + if (path.Length == placeholder.Length) + { + return true; + } + + char separator = path[placeholder.Length]; + if (separator != '/' && separator != '\\') + { + // Require an explicit separator after the placeholder (reject "<>../x"). + return false; + } + + string remainder = path.Substring(placeholder.Length + 1).Replace('\\', '/'); + foreach (string segment in remainder.Split('/')) + { + if (segment.Length == 0 || string.Equals(segment, ".", StringComparison.Ordinal)) + { + continue; + } + + // Reject traversal, drive letters (e.g. "C:"), and rooted segments after the placeholder. + if (string.Equals(segment, "..", StringComparison.Ordinal) + || segment.IndexOf(':') >= 0 + || Path.IsPathRooted(segment)) + { + return false; + } + } + + return true; } } } diff --git a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs index 52e979b0..f438ed8b 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs +++ b/src/ModSync.GUI/Dialogs/WizardPages/InstallStartPage.axaml.cs @@ -124,6 +124,16 @@ private void RefreshSummary() Text = $"• {mod.Name}", TextWrapping = TextWrapping.Wrap, }); + + if (!string.IsNullOrWhiteSpace(mod.InstallationWarning)) + { + _modListPanel.Children.Add(new TextBlock + { + Text = $" ⚠ {mod.InstallationWarning}", + TextWrapping = TextWrapping.Wrap, + Opacity = 0.9, + }); + } } } } diff --git a/src/ModSync.GUI/Services/FileLoadingService.cs b/src/ModSync.GUI/Services/FileLoadingService.cs index acb022fc..9a542c4c 100644 --- a/src/ModSync.GUI/Services/FileLoadingService.cs +++ b/src/ModSync.GUI/Services/FileLoadingService.cs @@ -557,8 +557,12 @@ private static async Task GenerateDraftInstructionsFromProseAsync([NotNull][Item foreach (DraftInstructionResult draftResult in draftResults) { + // ApplyReviewFlag runs inside GenerateDraftInstructions; re-apply is idempotent and keeps + // InstallationWarning aligned with CLI ReviewFlagMessage / validation-issue text. + DraftInstructionService.ApplyReviewFlag(draftResult.Component); #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($"'{draftResult.Component.Name}': {draftResult.DraftInstructionCount} draft instruction(s) parsed from guide prose - review before installing (never auto-trusted)."); + await Logger.LogWarningAsync( + $"'{draftResult.Component.Name}': {draftResult.DraftInstructionCount} draft instruction(s). {DraftInstructionService.ReviewFlagMessage}"); #pragma warning restore MA0004 // Use Task. } } diff --git a/src/ModSync.Tests/GuideIngestionTests.cs b/src/ModSync.Tests/GuideIngestionTests.cs index 58deb27d..ed1385d5 100644 --- a/src/ModSync.Tests/GuideIngestionTests.cs +++ b/src/ModSync.Tests/GuideIngestionTests.cs @@ -96,22 +96,20 @@ private static void AssertInstructionIsSandboxed(Instruction instruction) { foreach (string source in instruction.Source) { - Assert.That( - source.StartsWith(ModDirectoryPlaceholder, StringComparison.Ordinal) - || source.StartsWith(KotorDirectoryPlaceholder, StringComparison.Ordinal), - Is.True, - $"Source '{source}' must start with {ModDirectoryPlaceholder} or {KotorDirectoryPlaceholder}"); + Assert.That(DraftInstructionService.IsSandboxedPath(source), Is.True, + $"Source '{source}' must be confined to a placeholder root without '..' / rooted escapes"); Assert.That(source, Does.Not.Contain("<>"), "Legacy placeholder must be normalized away"); + Assert.That(source.Replace('\\', '/').Split('/'), Does.Not.Contain(".."), + $"Source '{source}' must not contain '..' segments"); } if (!string.IsNullOrEmpty(instruction.Destination)) { - Assert.That( - instruction.Destination.StartsWith(ModDirectoryPlaceholder, StringComparison.Ordinal) - || instruction.Destination.StartsWith(KotorDirectoryPlaceholder, StringComparison.Ordinal), - Is.True, - $"Destination '{instruction.Destination}' must start with {ModDirectoryPlaceholder} or {KotorDirectoryPlaceholder}"); + Assert.That(DraftInstructionService.IsSandboxedPath(instruction.Destination), Is.True, + $"Destination '{instruction.Destination}' must be confined to a placeholder root without '..' / rooted escapes"); Assert.That(instruction.Destination, Does.Not.Contain("<>"), "Legacy placeholder must be normalized away"); + Assert.That(instruction.Destination.Replace('\\', '/').Split('/'), Does.Not.Contain(".."), + $"Destination '{instruction.Destination}' must not contain '..' segments"); } } @@ -246,6 +244,103 @@ public void TrySanitizeInstruction_RejectsNonSandboxedPaths() }); } + [Test] + public void TrySanitizeInstruction_RejectsParentDirectoryTraversalAfterPlaceholder() + { + var traversalSource = new Instruction + { + Action = Instruction.ActionType.Move, + Source = new List { "<>/../outside" }, + Destination = "<>/Override", + }; + + var traversalDestination = new Instruction + { + Action = Instruction.ActionType.Move, + Source = new List { @"<>\textures\*" }, + Destination = @"<>\..\Windows", + }; + + var gluedTraversal = new Instruction + { + Action = Instruction.ActionType.Move, + Source = new List { "<>../outside" }, + Destination = "<>/Override", + }; + + var driveAfterPlaceholder = new Instruction + { + Action = Instruction.ActionType.Move, + Source = new List { @"<>\C:\Windows\evil.dll" }, + Destination = @"<>\Override", + }; + + Assert.Multiple(() => + { + Assert.That(DraftInstructionService.IsSandboxedPath("<>/../outside"), Is.False); + Assert.That(DraftInstructionService.IsSandboxedPath(@"<>\..\Windows"), Is.False); + Assert.That(DraftInstructionService.IsSandboxedPath("<>../outside"), Is.False); + Assert.That(DraftInstructionService.IsSandboxedPath(@"<>\C:\Windows\evil.dll"), Is.False); + Assert.That(DraftInstructionService.IsSandboxedPath("<>/textures/*"), Is.True); + Assert.That(DraftInstructionService.TrySanitizeInstruction(traversalSource), Is.False, + "Source with '..' after placeholder must be dropped"); + Assert.That(DraftInstructionService.TrySanitizeInstruction(traversalDestination), Is.False, + "Destination with '..' after placeholder must be dropped"); + Assert.That(DraftInstructionService.TrySanitizeInstruction(gluedTraversal), Is.False, + "Missing separator before '..' must be dropped"); + Assert.That(DraftInstructionService.TrySanitizeInstruction(driveAfterPlaceholder), Is.False, + "Drive letter segment after placeholder must be dropped"); + }); + } + + [Test] + public void DraftInstructions_TraversalProse_DoesNotKeepEscapingPaths() + { + ModComponent escapeComponent = CreateComponent( + "Move everything from <>/../outside to your Override."); + ModComponent bareDotDot = CreateComponent( + "Move .. to override."); + + IReadOnlyList escapeResults = + DraftInstructionService.GenerateDraftInstructions(new[] { escapeComponent }); + IReadOnlyList bareResults = + DraftInstructionService.GenerateDraftInstructions(new[] { bareDotDot }); + + Assert.Multiple(() => + { + foreach (Instruction instruction in escapeComponent.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + + foreach (Instruction instruction in bareDotDot.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + + if (escapeResults.Count > 0) + { + Assert.That(escapeComponent.InstallationWarning, Does.Contain(DraftInstructionService.ReviewFlagMessage)); + } + + if (bareResults.Count > 0) + { + Assert.That(bareDotDot.InstallationWarning, Does.Contain(DraftInstructionService.ReviewFlagMessage)); + } + }); + } + + [Test] + public void DraftInstructions_SuccessfulDraft_AppliesReviewFlagMessage() + { + ModComponent component = CreateComponent(MoveFoldersProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.InstallationWarning, Is.EqualTo(DraftInstructionService.ReviewFlagMessage)); + } + #endregion #region Paste cascade format sniffing From 3b470d9b1f171d43bb5ccc2c9aa116bae7256ce2 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:33:31 -0500 Subject: [PATCH 03/19] test(gui): expand Avalonia headless smoke beyond paste/page-0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover wizard Welcome→Validate key controls, LoadInstructionTextAsync sample markdown without a real clipboard, and compact-host ScrollViewer layout; strip WelcomePage inline fonts and wrap badge text to avoid clipping. Document the expanded Headless|GuiSmoke filter in run_headless_tests.sh. --- .../skills/local_desktop_gui_testing/SKILL.md | 2 +- AGENTS.md | 6 +- docs/knowledgebase/README.md | 3 + docs/knowledgebase/agent-action-parity.md | 4 +- scripts/agents/README.md | 9 +- scripts/agents/run_headless_tests.sh | 12 + .../WizardPages/DownloadsExplainPage.axaml | 25 ++- .../Dialogs/WizardPages/WelcomePage.axaml | 41 ++-- .../HeadlessUITests/GuiSmokeHeadlessTests.cs | 206 +++++++++++++++++- 9 files changed, 268 insertions(+), 40 deletions(-) diff --git a/.cursor/skills/local_desktop_gui_testing/SKILL.md b/.cursor/skills/local_desktop_gui_testing/SKILL.md index af9a65fa..c3dc90cb 100644 --- a/.cursor/skills/local_desktop_gui_testing/SKILL.md +++ b/.cursor/skills/local_desktop_gui_testing/SKILL.md @@ -12,7 +12,7 @@ Use this skill when a task needs a real desktop session or VM to: Do **not** use this skill for paste-flow / wizard UX smoke that Avalonia headless already covers. Prefer: ```bash -./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests" +./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~Headless|FullyQualifiedName~GuiSmoke" ``` Do not use this skill for pure parser / serialization / core-only work. diff --git a/AGENTS.md b/AGENTS.md index 2267d301..5fcad0a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,11 @@ Cloud agents run headless (no X11 desktop). The following applies: `HeadlessTestApp` (`src/ModSync.Tests/HeadlessTestApp.cs`) bootstraps `Avalonia.Headless.XUnit` with `UseHeadlessDrawing = true`. Agents should use this path for GUI surface smoke instead of a real desktop: ```bash -# Preferred wrapper +# Preferred wrapper — expanded Avalonia smoke (GuiSmoke + *Headless*) +./scripts/agents/run_headless_tests.sh \ + --filter "FullyQualifiedName~Headless|FullyQualifiedName~GuiSmoke" + +# Narrow GuiSmoke only ./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests" # Or direct diff --git a/docs/knowledgebase/README.md b/docs/knowledgebase/README.md index 12518be8..daccd6ff 100644 --- a/docs/knowledgebase/README.md +++ b/docs/knowledgebase/README.md @@ -138,6 +138,9 @@ KB routing: [rebrand-legacy-strings.md](rebrand-legacy-strings.md), [gui-archite # Standard non-long-running tests ./scripts/agents/run_headless_tests.sh +# Avalonia headless GUI UX smoke (no X11) +./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~Headless\|FullyQualifiedName~GuiSmoke" + # Validate an instruction file (full validation needs game + mod dirs) ./scripts/agents/cli_validate.sh --input ./mod-builds/TOMLs/KOTOR1_Full.toml \ --game-dir ./tmp/kotor_template --source-dir ./tmp/mod_downloads --full diff --git a/docs/knowledgebase/agent-action-parity.md b/docs/knowledgebase/agent-action-parity.md index 561c0b61..67d66836 100644 --- a/docs/knowledgebase/agent-action-parity.md +++ b/docs/knowledgebase/agent-action-parity.md @@ -41,7 +41,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | Goal | Recommended path | |------|------------------| | Smoke-test repo | `./scripts/agents/run_headless_tests.sh` | -| GUI UX smoke (paste button, page-0 layout, validate log splitter) | `./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests"` (Avalonia.Headless — **no desktop**) | +| GUI UX smoke (paste import, wizard page order, page-0 layout, validate log splitter) | `./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~Headless\|FullyQualifiedName~GuiSmoke"` (Avalonia.Headless — **no desktop**) | | Validate TOML structure only | `./scripts/agents/cli_validate.sh --input path.toml` | | Full validation | `cli_validate.sh` with `--game-dir`, `--source-dir`, `--full` | | Validate only TOML-selected mods | `cli_validate.sh` … `--use-file-selection` (matches GUI Mod Selection) | @@ -57,7 +57,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | CLI install | `CliInstallIntegrationTests`, `ValidationPipelineParityTests` | End-to-end install; install pre-check uses same pipeline as wizard | | VFS validation | `VirtualFileSystemDryRunValidationTests` | Dry-run matches VFS rules | | Wizard UI | `WizardFlowHeadlessTests` | Page flow without full desktop | -| GUI UX smoke | `GuiSmokeHeadlessTests` | Paste-import button, Welcome/Landing scroll layout, ValidatePage log splitter | +| GUI UX smoke | `GuiSmokeHeadlessTests` | Paste-import button + `LoadInstructionTextAsync` markdown (no clipboard), Welcome→ValidatePage key controls, compact ScrollViewer layout, ValidatePage log splitter | | Wizard validation UX | `WizardValidationStagePresenter`, `ValidationPipelineDialogMapper` | Stage cards / dialog mapper parity ([PR #110](https://github.com/th3w1zard1/ModSync/pull/110)) | | Version alignment | `ReleaseVersionAlignmentTests` | Release metadata consistency | diff --git a/scripts/agents/README.md b/scripts/agents/README.md index c6b7b5ac..314e652f 100644 --- a/scripts/agents/README.md +++ b/scripts/agents/README.md @@ -51,13 +51,18 @@ Optional filter: ### Avalonia GUI UX smoke (no desktop / no X11) -Uses `Avalonia.Headless.XUnit` via `HeadlessTestApp` — do **not** launch the real GUI for paste-flow or page-0 layout smoke: +Uses `Avalonia.Headless.XUnit` via `HeadlessTestApp` — do **not** launch the real GUI for paste-flow, wizard page-order, or page-0 layout smoke: ```bash +# Expanded filter: GuiSmokeHeadlessTests + all *Headless* suites +./scripts/agents/run_headless_tests.sh \ + --filter "FullyQualifiedName~Headless|FullyQualifiedName~GuiSmoke" + +# Narrow GuiSmoke only (paste import, wizard order, compact layout, ValidatePage splitter) ./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests" ``` -Covers: `ImportFromClipboardButton` invokable, Welcome/Landing scroll layout in a compact host, ValidatePage log `GridSplitter`. +`GuiSmokeHeadlessTests` covers: `ImportFromClipboardButton`, `LoadInstructionTextAsync` sample markdown (no OS clipboard), Welcome→ValidatePage key controls, Welcome/Landing/early-page ScrollViewer layout, ValidatePage log `GridSplitter`. ### Validate instruction file diff --git a/scripts/agents/run_headless_tests.sh b/scripts/agents/run_headless_tests.sh index baeb560c..9cf39434 100755 --- a/scripts/agents/run_headless_tests.sh +++ b/scripts/agents/run_headless_tests.sh @@ -13,6 +13,18 @@ Options: --filter EXPR dotnet test filter (default: exclude LongRunning) --configuration NAME Debug or Release (default: Debug) -h, --help Show this help + +Examples: + # All non-LongRunning tests + ./scripts/agents/run_headless_tests.sh + + # Expanded Avalonia GUI smoke (GuiSmoke + all *Headless* suites; no X11) + ./scripts/agents/run_headless_tests.sh \ + --filter "FullyQualifiedName~Headless|FullyQualifiedName~GuiSmoke" + + # Narrow paste / page-0 / wizard-order smoke only + ./scripts/agents/run_headless_tests.sh \ + --filter "FullyQualifiedName~GuiSmokeHeadlessTests" EOF } diff --git a/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml index 42cb68aa..acc3f338 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/DownloadsExplainPage.axaml @@ -8,19 +8,34 @@ - - - + + + + + + - + - + + diff --git a/src/ModSync.GUI/Dialogs/WizardPages/WelcomePage.axaml b/src/ModSync.GUI/Dialogs/WizardPages/WelcomePage.axaml index 2328f4e2..806b9e97 100644 --- a/src/ModSync.GUI/Dialogs/WizardPages/WelcomePage.axaml +++ b/src/ModSync.GUI/Dialogs/WizardPages/WelcomePage.axaml @@ -18,10 +18,9 @@ + TextAlignment="Center" + TextWrapping="Wrap" /> - + ItemSpacing="16" + LineSpacing="4" + Orientation="Horizontal"> - + @@ -48,7 +48,7 @@ Padding="14,12" CornerRadius="10"> - + - + @@ -107,7 +106,7 @@ Text="1" /> - + - + - + - + - + - + - + - + - + - + diff --git a/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs b/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs index 72d21a84..46ee3997 100644 --- a/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs +++ b/src/ModSync.Tests/HeadlessUITests/GuiSmokeHeadlessTests.cs @@ -3,6 +3,7 @@ // See LICENSE.txt file in the project root for full license information. using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Avalonia.Controls; @@ -11,19 +12,40 @@ using Avalonia.Threading; using Avalonia.VisualTree; using ModSync.Controls; +using ModSync.Core; using ModSync.Dialogs.WizardPages; +using ModSync.Services; using Xunit; namespace ModSync.Tests.HeadlessUITests { /// - /// Headless Avalonia smoke coverage for paste-import entry points and - /// wizard page-0 layout constraints. Prefer these over a real desktop - /// session for agent GUI UX smoke. + /// Headless Avalonia smoke coverage for paste-import entry points, + /// wizard page-order key controls, and compact-host layout constraints. + /// Prefer these over a real desktop session for agent GUI UX smoke. /// [Collection(HeadlessTestApp.CollectionName)] public sealed class GuiSmokeHeadlessTests { + private const string SampleClipboardMarkdown = @"### Example Clipboard Mod + +**Name:** [Example Clipboard Mod](https://deadlystream.com/files/file/9999-example-clipboard-mod/) + +**Author:** Headless Smoke Author + +**Description:** Minimal markdown guide used by GuiSmokeHeadlessTests to exercise LoadInstructionTextAsync without a real OS clipboard. + +**Category & Tier:** Immersion / 1 - Essential + +**Non-English Functionality:** YES + +**Installation Method:** Loose-File Mod + +**Installation Instructions:** Copy the files into Override. + +___ +"; + [AvaloniaFact(DisplayName = "GettingStarted Import from Clipboard button exists and raises event")] public async Task ImportFromClipboardButton_Exists_And_IsInvokable() { @@ -53,11 +75,146 @@ await Dispatcher.UIThread.InvokeAsync( } } + [AvaloniaFact(DisplayName = "LoadInstructionTextAsync imports sample markdown without real clipboard")] + public async Task LoadInstructionTextAsync_SampleMarkdown_ImportsComponents() + { + List previousComponents = MainConfig.AllComponents; + MainConfig.AllComponents = new List(); + + Window window = await Dispatcher.UIThread.InvokeAsync( + () => + { + var host = new Window { Width = 640, Height = 480 }; + host.Show(); + return host; + }, + DispatcherPriority.Background); + await PumpEventsAsync(); + + try + { + var config = new MainConfig(); + var service = new FileLoadingService(config, window); + int componentsLoadedCallbacks = 0; + int autoGenerateCallbacks = 0; + + bool loaded = await service.LoadInstructionTextAsync( + SampleClipboardMarkdown, + editorMode: false, + onComponentsLoaded: () => + { + componentsLoadedCallbacks++; + return Task.CompletedTask; + }, + tryAutoGenerate: _ => + { + autoGenerateCallbacks++; + return Task.CompletedTask; + }, + sourceDescription: "headless sample markdown"); + + Assert.True(loaded); + Assert.NotEmpty(MainConfig.AllComponents); + Assert.Contains( + MainConfig.AllComponents, + c => (c.Name ?? string.Empty).Contains("Clipboard", StringComparison.OrdinalIgnoreCase)); + Assert.True(autoGenerateCallbacks >= 1 || componentsLoadedCallbacks >= 1); + } + finally + { + MainConfig.AllComponents = previousComponents; + await CloseWindowAsync(window); + } + } + + [AvaloniaFact(DisplayName = "Wizard pages Welcome through ValidatePage expose key controls")] + public async Task WizardPageOrder_WelcomeThroughValidate_KeyControlsPresent() + { + List previousComponents = MainConfig.AllComponents; + var components = new List + { + new ModComponent + { + Guid = Guid.NewGuid(), + Name = "Smoke Mod", + IsSelected = true, + Category = new List { "Test" }, + Tier = "1 - Essential", + }, + }; + MainConfig.AllComponents = components; + var config = new MainConfig(); + + try + { + Control[] pages = + { + new WelcomePage(), + new ModDirectoryPage(config), + new GameDirectoryPage(config), + new ModSelectionPage(components), + new DownloadsExplainPage(components), + new ValidatePage(components, config), + }; + + string[][] requiredNames = + { + Array.Empty(), + new[] { "SourcePathPicker" }, + new[] { "DestinationPathPicker" }, + new[] { "SelectAllButton", "DeselectAllButton", "SearchTextBox" }, + new[] { "SelectionSummaryText" }, + new[] { "ValidateButton", "LogExpander", "SummaryText", "ErrorCountBadge" }, + }; + + for (int i = 0; i < pages.Length; i++) + { + Control page = pages[i]; + Window window = await HostInWindowAsync(page, width: 1100, height: 800); + try + { + await PumpEventsAsync(); + + if (page is WelcomePage) + { + TextBlock title = page.GetVisualDescendants() + .OfType() + .FirstOrDefault(tb => string.Equals(tb.Text, "Welcome to ModSync", StringComparison.Ordinal)); + Assert.NotNull(title); + Assert.True(title.IsVisible); + } + + if (page is DownloadsExplainPage downloads) + { + await downloads.OnNavigatedToAsync(default); + await PumpEventsAsync(); + TextBlock summary = downloads.FindControl("SelectionSummaryText"); + Assert.NotNull(summary); + Assert.False(string.IsNullOrWhiteSpace(summary.Text)); + } + + foreach (string name in requiredNames[i]) + { + Control control = page.FindControl(name); + Assert.True(control != null, $"Expected control '{name}' on {page.GetType().Name}"); + } + } + finally + { + await CloseWindowAsync(window); + } + } + } + finally + { + MainConfig.AllComponents = previousComponents; + } + } + [AvaloniaFact(DisplayName = "WelcomePage fits compact host via ScrollViewer without clipping primary title")] public async Task WelcomePage_CompactHost_KeepsTitleReachable() { var page = new WelcomePage(); - // Compact wizard content host size (below design 1280x720). Window window = await HostInWindowAsync(page, width: 960, height: 540); try @@ -71,10 +228,11 @@ public async Task WelcomePage_CompactHost_KeepsTitleReachable() .OfType() .FirstOrDefault(tb => string.Equals(tb.Text, "Welcome to ModSync", StringComparison.Ordinal)); Assert.NotNull(title); - - // Layout should resolve without throwing; title remains in the visual tree. Assert.True(title.IsVisible); Assert.True(page.Bounds.Height > 0 || window.Bounds.Height > 0); + + WrapPanel badgeRow = page.GetVisualDescendants().OfType().FirstOrDefault(); + Assert.NotNull(badgeRow); } finally { @@ -82,6 +240,41 @@ public async Task WelcomePage_CompactHost_KeepsTitleReachable() } } + [AvaloniaFact(DisplayName = "Early wizard pages stay scrollable in a compact host")] + public async Task EarlyWizardPages_CompactHost_UseScrollViewer() + { + var config = new MainConfig(); + Control[] pages = + { + new WelcomePage(), + new ModDirectoryPage(config), + new GameDirectoryPage(config), + new DownloadsExplainPage(new List()), + }; + + foreach (Control page in pages) + { + Window window = await HostInWindowAsync(page, width: 720, height: 420); + try + { + await PumpEventsAsync(); + + ScrollViewer scrollViewer = page.GetVisualDescendants().OfType().FirstOrDefault(); + Assert.True( + scrollViewer != null, + $"{page.GetType().Name} should host a ScrollViewer for compact-window overflow"); + Assert.Equal( + Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + scrollViewer.VerticalScrollBarVisibility); + Assert.True(page.Bounds.Width <= window.Bounds.Width + 1 || window.Bounds.Width > 0); + } + finally + { + await CloseWindowAsync(window); + } + } + } + [AvaloniaFact(DisplayName = "LandingPageView page-0 content is scrollable in a compact host")] public async Task LandingPageView_CompactHost_UsesScrollViewer() { @@ -124,7 +317,6 @@ public async Task ValidatePage_LogSplitter_IsPresent() Assert.NotNull(splitter); Assert.NotNull(logScroll); Assert.Equal(GridResizeDirection.Rows, splitter.ResizeDirection); - // Fixed MaxHeight was the old non-resizable constraint; star+splitter replaces it. Assert.True(double.IsNaN(logScroll.MaxHeight) || logScroll.MaxHeight > 500); } finally From c0d9582af4dd5a656bd1eac052a7e4037edb4209 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:38:59 -0500 Subject: [PATCH 04/19] fix(fomod): unblock dismissed configure recovery and harden merge keys Re-prompt dismissed FOMOD archives on Fetch Downloads, merge Mod Management wizard output into the selected mod, match nested registry basenames for prompt status, and normalize backslash paths when replacing prior FOMOD instructions. --- .../Fomod/FomodConfiguredComponentMerger.cs | 10 ++- .../Fomod/FomodDownloadPromptState.cs | 45 +++++++++-- .../Dialogs/ModManagementDialog.axaml.cs | 74 ++++++++++++++++++- .../Services/FomodGuiPostDownloadHost.cs | 2 +- src/ModSync.Tests/FomodArchiveProbeTests.cs | 44 +++++++++-- .../FomodConfiguredComponentMergerTests.cs | 31 ++++++++ 6 files changed, 185 insertions(+), 21 deletions(-) diff --git a/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs b/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs index 710d68d6..9ab7c986 100644 --- a/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs +++ b/src/ModSync.Core/Services/Fomod/FomodConfiguredComponentMerger.cs @@ -107,8 +107,14 @@ private static bool InstructionReferencesArchive([NotNull] Instruction instructi foreach (string source in instruction.Source) { - if (!string.IsNullOrEmpty(source) - && source.StartsWith(archivePathPrefix, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(source)) + { + continue; + } + + string normalizedSource = source.Replace('\\', '/'); + string normalizedPrefix = archivePathPrefix.Replace('\\', '/'); + if (normalizedSource.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase)) { return true; } diff --git a/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs b/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs index 0dbdc484..58d579c8 100644 --- a/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs +++ b/src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs @@ -36,7 +36,17 @@ public static bool ShouldPrompt([NotNull] ModComponent component, [NotNull] stri } string status = GetStatus(component, archiveFileName); - return string.IsNullOrEmpty(status); + + // Only "configured" permanently skips the post-download prompt. + // "dismissed" must re-prompt on Fetch Downloads (documented recovery path). + // "warned" stays quiet so CLI warn-continue does not spam every re-download. + if (string.Equals(status, StatusConfigured, StringComparison.Ordinal) + || string.Equals(status, StatusWarned, StringComparison.Ordinal)) + { + return false; + } + + return true; } [CanBeNull] @@ -53,7 +63,8 @@ public static string GetStatus([NotNull] ModComponent component, [NotNull] strin return null; } - return statuses.TryGetValue(NormalizeFileName(archiveFileName), out string status) + string statusKey = NormalizeFileName(System.IO.Path.GetFileName(archiveFileName)); + return statuses.TryGetValue(statusKey, out string status) ? status : null; } @@ -108,7 +119,7 @@ private static void SetStatus( statuses = new Dictionary(StringComparer.OrdinalIgnoreCase); } - statuses[NormalizeFileName(archiveFileName)] = status; + statuses[NormalizeFileName(System.IO.Path.GetFileName(archiveFileName))] = status; resource.HandlerMetadata[HandlerMetadataKey] = statuses; } @@ -123,16 +134,36 @@ private static bool TryGetResourceForArchive( return false; } + string needle = NormalizeFileName(System.IO.Path.GetFileName(archiveFileName)); + if (string.IsNullOrEmpty(needle)) + { + return false; + } + foreach (ResourceMetadata candidate in component.ResourceRegistry.Values) { - if (candidate?.Files is null - || !candidate.Files.ContainsKey(archiveFileName)) + if (candidate?.Files is null) { continue; } - resource = candidate; - return true; + foreach (string registeredName in candidate.Files.Keys) + { + if (string.IsNullOrWhiteSpace(registeredName)) + { + continue; + } + + if (string.Equals(NormalizeFileName(registeredName), needle, StringComparison.OrdinalIgnoreCase) + || string.Equals( + NormalizeFileName(System.IO.Path.GetFileName(registeredName)), + needle, + StringComparison.OrdinalIgnoreCase)) + { + resource = candidate; + return true; + } + } } return false; diff --git a/src/ModSync.GUI/Dialogs/ModManagementDialog.axaml.cs b/src/ModSync.GUI/Dialogs/ModManagementDialog.axaml.cs index 8ef87d3d..8c27dfb9 100644 --- a/src/ModSync.GUI/Dialogs/ModManagementDialog.axaml.cs +++ b/src/ModSync.GUI/Dialogs/ModManagementDialog.axaml.cs @@ -18,6 +18,7 @@ using ModSync.Core; using ModSync.Core.Services; +using ModSync.Core.Services.Fomod; namespace ModSync.Dialogs { @@ -179,6 +180,20 @@ private async void ConfigureFomodMod_Click(object sender, RoutedEventArgs e) { try { + List selectedTargets = _originalComponents.Where(c => c.IsSelected).ToList(); + if (selectedTargets.Count != 1) + { + await _dialogService.ShowInformationDialog( + "Select exactly one mod in the list, then choose Configure FOMOD Mod again. + +" + + "The wizard output is merged into that mod and marked configured for validate/install.") + .ConfigureAwait(true); + return; + } + + ModComponent target = selectedTargets[0]; + string[] folders = await _dialogService.ShowFileDialog( isFolderDialog: true, windowName: "Select extracted FOMOD mod folder").ConfigureAwait(true); @@ -187,15 +202,30 @@ private async void ConfigureFomodMod_Click(object sender, RoutedEventArgs e) return; } - ModComponent configured = await FomodInstallerDialog.ShowForExtractedArchiveAsync(this, folders[0]).ConfigureAwait(true); + string extractedDirectory = folders[0]; + ModComponent configured = await FomodInstallerDialog.ShowForExtractedArchiveAsync(this, extractedDirectory).ConfigureAwait(true); if (configured is null) { return; } + string archiveFolder = System.IO.Path.GetFileName( + extractedDirectory.TrimEnd( + System.IO.Path.DirectorySeparatorChar, + System.IO.Path.AltDirectorySeparatorChar)); + string archiveFileName = ResolveArchiveFileNameForFolder(target, archiveFolder) + ?? archiveFolder + ".zip"; + + FomodConfiguredComponentMerger.MergeInto(target, configured, archiveFileName); + FomodDownloadPromptState.MarkConfigured(target, archiveFileName); + ModificationsApplied = true; + await _dialogService.ShowInformationDialog( - $"FOMOD configuration saved for '{configured.Name}'.\n\n" + - $"Selected options: {configured.Options.Count(option => option.IsSelected)}/{configured.Options.Count}").ConfigureAwait(true); + $"FOMOD configuration applied to '{target.Name}' from '{archiveFileName}'. + +" + + $"Selected options: {configured.Options.Count(option => option.IsSelected)}/{configured.Options.Count}") + .ConfigureAwait(true); } catch (Exception ex) { @@ -203,6 +233,44 @@ await _dialogService.ShowInformationDialog( } } + [CanBeNull] + private static string ResolveArchiveFileNameForFolder( + [NotNull] ModComponent target, + [NotNull] string archiveFolder) + { + if (target.ResourceRegistry is null || string.IsNullOrWhiteSpace(archiveFolder)) + { + return null; + } + + foreach (ResourceMetadata resource in target.ResourceRegistry.Values) + { + if (resource?.Files is null) + { + continue; + } + + foreach (string registeredName in resource.Files.Keys) + { + if (string.IsNullOrWhiteSpace(registeredName)) + { + continue; + } + + string fileName = System.IO.Path.GetFileName(registeredName); + if (string.Equals( + System.IO.Path.GetFileNameWithoutExtension(fileName), + archiveFolder, + StringComparison.OrdinalIgnoreCase)) + { + return fileName; + } + } + } + + return null; + } + private void SortByName_Click(object sender, RoutedEventArgs e) { diff --git a/src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs b/src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs index 02edeae5..7c3017b5 100644 --- a/src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs +++ b/src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs @@ -38,7 +38,7 @@ public async Task AskConfigureAsync( + "Configure installer options now?" + Environment.NewLine + Environment.NewLine - + "Choose No to skip for now. You can still use Mod Management → Configure FOMOD Mod later.") + + "Choose No to skip for now. Run Fetch Downloads again later to configure, or use Mod Management → Configure FOMOD Mod with this mod selected.") .ConfigureAwait(true); if (configure == true) diff --git a/src/ModSync.Tests/FomodArchiveProbeTests.cs b/src/ModSync.Tests/FomodArchiveProbeTests.cs index b848ecf5..5aed1532 100644 --- a/src/ModSync.Tests/FomodArchiveProbeTests.cs +++ b/src/ModSync.Tests/FomodArchiveProbeTests.cs @@ -79,7 +79,7 @@ public void TryDetectInArchive_ReturnsFalseForPlainZip() public sealed class FomodDownloadPromptStateTests { [Test] - public void ShouldPrompt_ReturnsFalseAfterDismissedOrConfigured() + public void ShouldPrompt_RePromptsDismissed_ButSkipsWarnedAndConfigured() { var component = new ModComponent { @@ -99,17 +99,45 @@ public void ShouldPrompt_ReturnsFalseAfterDismissedOrConfigured() Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "ExampleMod.zip"), Is.True); FomodDownloadPromptState.MarkDismissed(component, "ExampleMod.zip"); + Assert.That( + FomodDownloadPromptState.ShouldPrompt(component, "ExampleMod.zip"), + Is.True, + "Dismissed archives must re-prompt on Fetch Downloads (documented gate recovery)."); + + FomodDownloadPromptState.MarkWarned(component, "ExampleMod.zip"); + Assert.That( + FomodDownloadPromptState.ShouldPrompt(component, "ExampleMod.zip"), + Is.False, + "Warned archives must not re-spam CLI warn-continue."); + + FomodDownloadPromptState.MarkConfigured(component, "ExampleMod.zip"); Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "ExampleMod.zip"), Is.False); + } - var registry = new Dictionary(component.ResourceRegistry); - registry["https://example.test/mod2.zip"] = new ResourceMetadata + [Test] + public void MarkConfigured_MatchesNestedRegistryFileNameByBasename() + { + var component = new ModComponent { - Files = new Dictionary { ["OtherMod.zip"] = true }, - HandlerMetadata = new Dictionary(), + ResourceRegistry = new Dictionary + { + ["https://example.test/mod.zip"] = new ResourceMetadata + { + Files = new Dictionary + { + ["nested/ExampleMod.7z"] = true, + }, + HandlerMetadata = new Dictionary(), + }, + }, }; - component.ResourceRegistry = registry; - FomodDownloadPromptState.MarkConfigured(component, "OtherMod.zip"); - Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "OtherMod.zip"), Is.False); + + FomodDownloadPromptState.MarkConfigured(component, "ExampleMod.7z"); + + Assert.That( + FomodDownloadPromptState.GetStatus(component, "ExampleMod.7z"), + Is.EqualTo(FomodDownloadPromptState.StatusConfigured)); + Assert.That(FomodDownloadPromptState.ShouldPrompt(component, "ExampleMod.7z"), Is.False); } [Test] diff --git a/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs b/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs index 3544a123..4d388e17 100644 --- a/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs +++ b/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs @@ -2,6 +2,7 @@ // Licensed under the Business Source License 1.1 (BSL 1.1). // See LICENSE.txt file in the project root for full license information. +using System; using System.Linq; using ModSync.Core; @@ -36,5 +37,35 @@ public void MergeInto_Reconfigure_UpdatesExistingOptionSelection() Assert.That(target.Options.First(option => option.Name == "High Resolution").IsSelected, Is.False); Assert.That(target.Options.First(option => option.Name == "Low Resolution").IsSelected, Is.True); } + + [Test] + public void MergeInto_Reconfigure_ReplacesBackslashPrefixedPriorInstructions() + { + var target = new ModComponent { Name = "Target" }; + target.Instructions.Add(new Instruction + { + Action = Instruction.ActionType.Copy, + Source = new System.Collections.ObjectModel.ObservableCollection + { + "<>\ExampleMod\file.tga", + }, + Destination = "<>/Override", + }); + + FomodModuleConfig config = FomodParser.ParseModuleConfigXml(FomodParserTests.RealisticModuleConfigXml); + ModComponent configured = FomodToComponentMapper.Map(null, config, ArchiveFileName); + FomodInstallerSession session = FomodInstallerPresenter.CreateSession(config, configured); + FomodInstallerPresenter.TrySetPluginSelected(session, 0, 0, 0, true); + FomodInstallerPresenter.ApplySelectionsToComponent(session); + + FomodConfiguredComponentMerger.MergeInto(target, configured, ArchiveFileName); + + Assert.That( + target.Instructions.Any(i => + i.Source != null + && i.Source.Any(s => s != null && s.IndexOf("ExampleMod\\file.tga", StringComparison.OrdinalIgnoreCase) >= 0)), + Is.False, + "Prior backslash-prefixed FOMOD sources must be removed on reconfigure."); + } } } From a537222a30775943be4cbac2bb901cbde94f7ae7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:38:59 -0500 Subject: [PATCH 05/19] docs: add 2026-07-13 FOMOD gate residual review findings --- .../2026-07-13-release-prep-review.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/residual-review-findings/2026-07-13-release-prep-review.md diff --git a/docs/residual-review-findings/2026-07-13-release-prep-review.md b/docs/residual-review-findings/2026-07-13-release-prep-review.md new file mode 100644 index 00000000..2f49373a --- /dev/null +++ b/docs/residual-review-findings/2026-07-13-release-prep-review.md @@ -0,0 +1,35 @@ +# Residual review findings — feat/fomod-configuration-gate (2026-07-13) + +**Scope:** `master..HEAD` on `feat/fomod-configuration-gate` (merge-base `c6fbbb51`). Tip at fix time included FOMOD gate/fail-closed/smoke/UTC serialization. Guide-paste commits were not on tip when finalized. + +**Depth:** Deep (validate/install gate + post-download recovery). + +## Fixed + +### P0 — Dismiss → permanent gate deadlock +`ShouldPrompt` skipped all non-empty statuses (including `dismissed`), so Fetch Downloads could not recover. Mod Management Configure FOMOD claimed save without `MergeInto`/`MarkConfigured`. + +**Fix:** Re-prompt `dismissed`; skip only `configured`/`warned`. Mod Management merges into the single selected mod and marks configured. + +### P1 — Nested Files key vs basename status +`MarkConfigured` used exact `ContainsKey`, missing `nested/mod.7z` when status used basename. + +**Fix:** Basename-aware lookup; status keys use file-name segment. + +### P1 — Backslash sources survive reconfigure +Merger prefix match was `/`-only. + +**Fix:** Normalize `\\`→`/` before compare. + +## Residual risks +- R1 (P2): Gate trusts `configured` without proving archive-scoped instructions. +- R2 (P2): `warned` still skips Fetch re-prompt (CLI warn-not-repeated). +- R3 (P2): Missing on-disk archives skipped by `GetPaths` (FOMOD gate fail-open until present). + +## Testing gaps +- No e2e dismiss→Fetch→configure→gate pass. +- No headless Mod Management Configure coverage. + +```json +{"reviewer":"adversarial","findings":[{"severity":"P0","title":"Cascade: dismiss FOMOD then documented recovery never clears gate","confidence":90,"status":"fixed"},{"severity":"P1","title":"Composition: nested Files keys make MarkConfigured silently no-op","confidence":85,"status":"fixed"},{"severity":"P1","title":"Composition: backslash FOMOD sources survive reconfigure merge","confidence":80,"status":"fixed"}],"residual_risks":["R1","R2","R3"],"testing_gaps":["dismiss-fetch-configure e2e","Mod Management UI"]} +``` From d38352c23372e201c24b1d8974412718471efa69 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:29:03 -0500 Subject: [PATCH 06/19] fix(validation): align CountStages with skip flags and document remaining managed-deploy work Progress totals no longer inflate when environment/archive stages are skipped. Refresh validation-pipeline KB for FomodConfiguration fail-closed, add stage UI coverage tests, and capture Phase 4 remaining units in plan 004. --- docs/knowledgebase/managed-deployment.md | 4 + docs/knowledgebase/validation-pipeline.md | 19 ++- ...-004-managed-deployment-validation-plan.md | 149 ++++++++++++++++++ .../InstallationValidationPipeline.cs | 10 +- .../ValidationPipelineDialogMapperTests.cs | 23 +++ .../ValidationPipelineParityTests.cs | 42 +++++ .../WizardValidationStagePresenterTests.cs | 26 +++ 7 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-07-13-004-managed-deployment-validation-plan.md diff --git a/docs/knowledgebase/managed-deployment.md b/docs/knowledgebase/managed-deployment.md index cee4f3f0..cbed5d78 100644 --- a/docs/knowledgebase/managed-deployment.md +++ b/docs/knowledgebase/managed-deployment.md @@ -27,5 +27,9 @@ to TOML instruction definitions only, not to internal services. patcher provenance capture (ImmutableCheckpoint), the `AppSettings` classic-vs-managed toggle, and GUI surfaces are later Phase 4 slices. +Remaining work units (install wiring, managed dry-run/VFS parity, uninstall GUI, +provenance): [docs/plans/2026-07-13-004-managed-deployment-validation-plan.md](../plans/2026-07-13-004-managed-deployment-validation-plan.md). +Open install-wiring PR: [#168](https://github.com/th3w1zard1/ModSync/pull/168). + Tests: `src/ModSync.Tests/DeploymentServiceTests.cs` (`dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~DeploymentService"`). diff --git a/docs/knowledgebase/validation-pipeline.md b/docs/knowledgebase/validation-pipeline.md index f99a62a4..15f8eead 100644 --- a/docs/knowledgebase/validation-pipeline.md +++ b/docs/knowledgebase/validation-pipeline.md @@ -14,13 +14,16 @@ Source: `InstallationValidationPipeline.cs`, `ValidationPipelineResult.cs`, `Val | 2 | **Conflicts** | Dependency and restriction violations in the selected set | | 3 | **InstallOrder** | InstallBefore/After and ordering constraints | | 4 | **ComponentValidation** | Archives present, per-component archive rules | -| 5 | **DryRun** | VFS simulation of instruction execution | +| 5 | **FomodConfiguration** | Fail-closed gate: every detected FOMOD archive on selected mods (+ hard deps) must be `configured` (see [fomod-support.md](fomod-support.md)) | +| 6 | **DryRun** | VFS simulation of instruction execution | ## Fail-fast behavior `[REPO]` When **FullValidation** is enabled and **Environment** fails, the pipeline **stops** — later stages do not run. Same early exit if no components are selected for validation. -If **FullValidation** is off, only component archive checks (and optional dry-run flags) run — environment/conflict/order stages are skipped. +`[REPO]` **FomodConfiguration** failures are fail-closed for success (`IsSuccess = false`) but do **not** short-circuit later stages — DryRun still runs when requested so agents see both FOMOD and instruction issues in one pass. Install paths (`InstallationService`, `InstallStartPage`) also re-check the gate and block install independently. + +If **FullValidation** is off, Environment/Conflicts/InstallOrder are skipped; ComponentValidation, FomodConfiguration (unless skipped), and optional DryRun still run. ## VFS in DryRun stage @@ -34,8 +37,8 @@ Do not use `RealFileSystemProvider` when answering “what would install do?” | Preset | FullValidation | DryRun | DryRunOnly | UseFileSelection | Typical caller | |--------|----------------|--------|------------|------------------|----------------| -| `WizardFull` | yes | yes | no | yes | Install wizard **ValidatePage**; `install` pre-check | -| `LegacyDryRunOnly` | no | yes | no | yes | Legacy Getting Started validate | +| `WizardFull` | yes | yes | no | yes | Install wizard **ValidatePage**; legacy Validate; `install` pre-check | +| `LegacyDryRunOnly` | no | yes | no | yes | Available preset; legacy Getting Started Validate currently uses `WizardFull` | | `CliFullWithDryRun` | yes | yes | no | yes | CLI `--full --dry-run --use-file-selection` | | `CliDryRunOnly` | no | no | yes | yes | CLI `--dry-run-only` | @@ -45,6 +48,7 @@ Other flags: |--------|-----------| | `SkipEnvironmentValidation` | Tests, headless fixtures without HoloPatcher | | `SkipComponentArchiveValidation` | Graph-only tests | +| `SkipFomodConfigurationGate` | Tests without FOMOD fixtures | | `ErrorsOnly` | `--errors-only` | | `UseFileSelection` | `--use-file-selection` (default true in options type; CLI defaults differ — see below) | @@ -74,6 +78,8 @@ Without `--full`, validate is lighter (archives + optional dry-run only). See [c `[REPO]` `install` runs `ValidationPipelineOptions.WizardFull` on **selected** components before installing unless `--skip-validation` is set. `install_best_effort.sh` always skips validation. +`[REPO]` Even when validation is skipped, `InstallationService.InstallAllSelectedComponentsAsync` / `InstallSingleComponentAsync` and wizard `InstallStartPage` still run `FomodConfigurationGate` and refuse to install unconfigured/unreadable FOMOD archives. + ## GUI mapping | Surface | Pipeline | @@ -94,7 +100,8 @@ Progress UI: `ValidationProgress`, `StatusText`, `LogExpander`, badge counts on 1. Expand validation logs; capture exact stage and message before changing code. 2. Environment failures → check HoloPatcher symlink ([holopatcher-resources.md](holopatcher-resources.md)) and `--kotorPath` / `--modDirectory`. 3. ComponentValidation failures → missing archive under `<>`. -4. DryRun failures → instruction path or ordering; trace with VFS rules, not live disk. +4. FomodConfiguration failures → complete FOMOD wizard / `--fomod-choices`, or fix unreadable archives ([fomod-support.md](fomod-support.md)). +5. DryRun failures → instruction path or ordering; trace with VFS rules, not live disk. ## Related @@ -102,3 +109,5 @@ Progress UI: `ValidationProgress`, `StatusText`, `LogExpander`, badge counts on - [cli-selection-semantics.md](cli-selection-semantics.md) - [core-cli-reference.md](core-cli-reference.md) - [agent-action-parity.md](agent-action-parity.md) +- [fomod-support.md](fomod-support.md) +- [managed-deployment.md](managed-deployment.md) diff --git a/docs/plans/2026-07-13-004-managed-deployment-validation-plan.md b/docs/plans/2026-07-13-004-managed-deployment-validation-plan.md new file mode 100644 index 00000000..866ae601 --- /dev/null +++ b/docs/plans/2026-07-13-004-managed-deployment-validation-plan.md @@ -0,0 +1,149 @@ +--- +title: "feat: managed deployment install wiring + validation parity" +status: active +date: 2026-07-13 +origin: Audit of install pipeline vs STRATEGY / managed-deployment.md / validation-pipeline.md +depends_on: docs/plans/2026-06-12-116-managed-deployment-engine-plan.md (#158); PR #168 (wiring draft) +--- + +# Plan 2026-07-13-004 — Managed deployment + validation remaining work + +## Summary + +`DeploymentService` (Phase 4 slice 1 / #158) and fail-closed `FomodConfigurationGate` (validate + install) are in-tree. The remaining STRATEGY-relevant install-pipeline gap is **wiring managed deployment into real installs** and keeping the validation pipeline honest about that mode. This plan lists concrete units only — no greenfield redesign. + +## Already shipped (do not re-do) + +| Capability | Evidence | +|------------|----------| +| Hardlink/copy deploy + manifests + uninstall/purge engine | `Services/Deployment/*`, `DeploymentServiceTests` | +| Unified validation stages + Environment fail-fast | `InstallationValidationPipeline` | +| Fail-closed FOMOD gate on validate + install + InstallStartPage | `FomodConfigurationGate`, pipeline stage `FomodConfiguration`, `InstallationService` | +| Progress `CountStages` matches skip flags | Fixed 2026-07-13 (this audit) | +| KB stage table includes FOMOD | `docs/knowledgebase/validation-pipeline.md` | + +## Problem frame + +Classic install still writes Extract/Move/Copy/Rename straight into `<>`. Managed mode (Vortex/MO2 parity Phase 4 slice 2) must stage under a profile artifact tree, deploy via `DeploymentService`, and surface classic-vs-managed + uninstall UX. Open PR [#168](https://github.com/th3w1zard1/ModSync/pull/168) (`feat/managed-deployment-wiring`) already drafts most of U1–U3; land or re-apply rather than rewrite. + +## Requirements + +| ID | Requirement | Unit | +|----|-------------|------| +| R1 | Opt-in managed install: stage game-bound Extract/Move/Copy/Rename, deploy after component success | U1 | +| R2 | Classic destructive install remains default when toggle off | U1 | +| R3 | Settings + active profile required for managed mode; fail fast without profile | U1 | +| R4 | Patcher stays direct-to-game; warn when unmanaged in managed session | U1 | +| R5 | CLI `--profile` + Core-readable `managedDeploymentEnabled` parity | U2 | +| R6 | Single-mod install uses same session as wizard | U2 | +| R7 | Dry-run / ValidatePage either models staging or documents install-only managed validation | U3 | +| R8 | Per-mod uninstall / purge / deployment indicator GUI | U4 | +| R9 | Patcher provenance via ImmutableCheckpoint (manifest completeness) | U5 | +| R10 | KB + living plan updated when wiring lands | U6 | + +## Implementation units + +### U1. Land managed install session (wizard P0) + +**Goal:** When `managedDeploymentEnabled` and an active profile exist, redirect game-bound Extract/Move/Copy/Rename into `{profileArtifacts}/staging/{componentGuid}/`, then `DeploymentService.DeployComponentAsync` after each successful component; block install start without a profile (R3); wizard finish pages show R7-style patcher/manifest messaging. + +**Requirements:** R1–R4 + +**Files (expected; prefer merge of #168):** + +- `src/ModSync.Core/Services/Installation/ManagedInstallSession.cs` (new) +- `src/ModSync.Core/Services/Installation/ManagedInstallResult.cs` (new) +- `src/ModSync.Core/Services/Settings/ModSyncSettings.cs` (new) +- `src/ModSync.Core/ModComponent.cs`, `InstallationService.cs`, `ProfileService.cs` +- `src/ModSync.GUI/Models/AppSettings.cs`, `SettingsDialog.*`, finish pages +- Tests: `ManagedInstallSessionTests`, settings round-trip + +**Constraints:** Path sandboxing for TOML unchanged; absolute paths OK inside services. No XAML font/style overrides. + +**Depends on:** #158 engine (done). Prefer landing [#168](https://github.com/th3w1zard1/ModSync/pull/168) over a parallel rewrite. + +--- + +### U2. Single-mod + CLI parity + +**Goal:** `InstallSingleComponentAsync` and CLI `install` share `ManagedInstallSession`; `--profile `; Core loads `managedDeploymentEnabled` / `activeProfileName` from settings.json; CLI emits `WARN:` for patcher-only components not in manifests. + +**Requirements:** R5, R6 + +**Dependencies:** U1 + +**Files:** `ModBuildConverter.cs`, `InstallationService.cs`, CLI/settings tests + +--- + +### U3. Managed-mode validation / dry-run parity + +**Goal:** Decide and implement one path: + +- **A (preferred):** DryRun + VFS honor staging redirect when managed settings are on (same relative tree under a virtual staging root), or +- **B (document):** ValidatePage / `--dry-run` remain classic-path only; install pre-check docs state managed behavior is install-time only. + +Update [validation-pipeline.md](../knowledgebase/validation-pipeline.md) and [managed-deployment.md](../knowledgebase/managed-deployment.md) with the chosen behavior. + +**Requirements:** R7 + +**Dependencies:** U1 + +**Files:** `DryRunValidator` / instruction path resolution and/or KB-only if B + +--- + +### U4. Uninstall / purge GUI surfaces + +**Goal:** Per-component uninstall from mod list (manifest-backed), purge-all, and a deployment-state indicator. Builds on `DeploymentService.UninstallComponentAsync` / `PurgeAsync` and cross-component overwrite metadata already in manifests. + +**Requirements:** R8 + +**Dependencies:** U1 (manifests written during managed installs) + +**Files:** `ModListSidebar` / mod list item controls, menu commands, headless + desktop smoke + +**Out of scope here:** Phase 5 conflict-resolution UI (analyzer already ships separately). + +--- + +### U5. Patcher provenance (ImmutableCheckpoint) + +**Goal:** Capture patcher-written files into deployment provenance so managed uninstall does not leave orphaned patcher outputs undocumented. Use existing `CheckpointService` / `ContentAddressableStore` / `FileProvenance` machinery called out in plan 116. + +**Requirements:** R9 + +**Dependencies:** U1 + +**Files:** Checkpoint integration from patcher instruction completion; tests for provenance round-trip + +--- + +### U6. Docs + living-plan closure + +**Goal:** After U1–U2 land: flip `managed-deployment.md` `[OPEN]` → `[REPO]` for wiring; refresh `vortex-mo2-feature-parity-living-plan.md` Phase 4; link U3 decision; keep FOMOD fail-closed called out as done (not blocked on managed mode). + +**Requirements:** R10 + +**Dependencies:** U1 (minimum) + +## Non-goals + +- Replacing classic install as the default. +- Re-implementing `DeploymentService` or FOMOD gate. +- Phase 5 conflict UI beyond data already in manifests. +- STRATEGY guide-ingestion track (separate plan `2026-07-13-001`). + +## Verification + +```bash +dotnet build ModSync.sln --configuration Debug +dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~DeploymentService" +dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~ManagedInstallSession|FullyQualifiedName~FomodConfigurationGate|FullyQualifiedName~ValidationPipelineParity" +``` + +## Suggested order + +1. Merge or cherry-pick #168 (U1) → U2 → U6 (partial). +2. U3 decision before treating ValidatePage as managed-aware. +3. U4 / U5 as follow-on Phase 4 slices (can parallelize after U1). diff --git a/src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs b/src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs index 448c86f0..23708f12 100644 --- a/src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs +++ b/src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs @@ -239,10 +239,16 @@ private static int CountStages(ValidationPipelineOptions options) int count = 0; if (options.FullValidation) { - count += 3; + if (!options.SkipEnvironmentValidation) + { + count++; + } + + // Conflicts + InstallOrder always run under FullValidation. + count += 2; } - if (!options.DryRunOnly) + if (!options.DryRunOnly && !options.SkipComponentArchiveValidation) { count++; } diff --git a/src/ModSync.Tests/ValidationPipelineDialogMapperTests.cs b/src/ModSync.Tests/ValidationPipelineDialogMapperTests.cs index 498a74e8..1957e6c3 100644 --- a/src/ModSync.Tests/ValidationPipelineDialogMapperTests.cs +++ b/src/ModSync.Tests/ValidationPipelineDialogMapperTests.cs @@ -6,6 +6,7 @@ using ModSync.Core; using ModSync.Core.Services.FileSystem; +using ModSync.Core.Services.Fomod; using ModSync.Core.Services.Validation; using ModSync.Dialogs; using ModSync.Services; @@ -191,6 +192,28 @@ public void AddPipelineStageIssues_ConflictFailure_AddsModAndAggregateIssues() Assert.That(modIssues[1].IssueType, Is.EqualTo("Conflict")); } + [Test] + public void AddPipelineStageIssues_FomodConfigurationFailure_MapsPrefixedRows() + { + var pipelineResult = new ValidationPipelineResult(); + var fomod = new ValidationPipelineStageResult + { + Stage = ValidationPipelineStage.FomodConfiguration, + Passed = false, + Summary = "1 unconfigured FOMOD archive(s).", + }; + fomod.Messages.Add("ERROR: Sample Mod: FOMOD archive 'pack.zip' is not configured."); + pipelineResult.Stages.Add(fomod); + + var modIssues = new List(); + ValidationPipelineDialogMapper.AddPipelineStageIssues(pipelineResult, modIssues); + + Assert.That(modIssues, Has.Count.EqualTo(1)); + Assert.That(modIssues[0].ModName, Is.EqualTo("Sample Mod")); + Assert.That(modIssues[0].IssueType, Is.EqualTo(FomodConfigurationGate.IssueCategory)); + Assert.That(modIssues[0].Solution, Is.EqualTo(FomodConfigurationGate.RecoveryHint)); + } + [Test] public void AddDryRunIssues_Warning_IncludesWarningIconAndSolution() { diff --git a/src/ModSync.Tests/ValidationPipelineParityTests.cs b/src/ModSync.Tests/ValidationPipelineParityTests.cs index 067214f0..665ec946 100644 --- a/src/ModSync.Tests/ValidationPipelineParityTests.cs +++ b/src/ModSync.Tests/ValidationPipelineParityTests.cs @@ -170,6 +170,48 @@ public async Task Pipeline_EnvironmentFailure_IsNotSuccess_AndSkipsDryRun() Is.True); } + [Test] + public async Task Pipeline_ProgressTotalSteps_MatchesExecutedStages_WhenSkipsEnabled() + { + var components = new List + { + new ModComponent + { + Guid = Guid.NewGuid(), + Name = "ProgressMod", + IsSelected = true, + Instructions = new ObservableCollection(), + }, + }; + + var options = ValidationPipelineOptions.WizardFull; + options.SkipEnvironmentValidation = true; + options.SkipComponentArchiveValidation = true; + options.SkipFomodConfigurationGate = true; + options.DryRun = false; + + int reportedTotal = -1; + int maxStep = 0; + int invokeCount = 0; + + ValidationPipelineResult result = await InstallationValidationPipeline.RunAsync( + components, + options, + (stage, step, totalSteps, message) => + { + invokeCount++; + reportedTotal = totalSteps; + maxStep = Math.Max(maxStep, step); + Assert.That(step, Is.LessThanOrEqualTo(totalSteps)); + }).ConfigureAwait(false); + + // FullValidation with env skipped → Conflicts + InstallOrder only. + Assert.That(invokeCount, Is.EqualTo(2)); + Assert.That(reportedTotal, Is.EqualTo(2)); + Assert.That(maxStep, Is.EqualTo(reportedTotal)); + Assert.That(result.Stages, Has.Count.EqualTo(2)); + } + [Test] public void Install_WithoutSkipValidation_BlocksOnMissingArchives() { diff --git a/src/ModSync.Tests/WizardValidationStagePresenterTests.cs b/src/ModSync.Tests/WizardValidationStagePresenterTests.cs index 972cbe28..b380e898 100644 --- a/src/ModSync.Tests/WizardValidationStagePresenterTests.cs +++ b/src/ModSync.Tests/WizardValidationStagePresenterTests.cs @@ -334,5 +334,31 @@ public void ApplyStages_ArchiveStageFailure_AddsSummaryCard() Assert.That(results[2].Title, Is.EqualTo("❌ Archive Validation")); Assert.That(results[2].Message, Is.EqualTo("2 component error(s)")); } + + [Test] + public void ApplyStages_FomodConfigurationFailure_AddsPrefixedAndSummaryCards() + { + var pipelineResult = new ValidationPipelineResult(); + var fomod = new ValidationPipelineStageResult + { + Stage = ValidationPipelineStage.FomodConfiguration, + Passed = false, + Summary = "1 unconfigured FOMOD archive(s).", + }; + fomod.Messages.Add("ERROR: Mod A: FOMOD archive 'x.zip' is not configured."); + pipelineResult.Stages.Add(fomod); + + var results = new List<(string Title, string Message)>(); + WizardValidationStagePresenter.ApplyStages( + pipelineResult, + selectedModCount: 1, + _ => { }, + (title, message) => results.Add((title, message))); + + Assert.That(results, Has.Count.EqualTo(2)); + Assert.That(results[0].Title, Is.EqualTo("❌ Mod A")); + Assert.That(results[1].Title, Is.EqualTo("❌ FOMOD Configuration")); + Assert.That(results[1].Message, Does.Contain("unconfigured FOMOD")); + } } } From 8ee5daeb82d19a661d637209604f36b1e5376e8c Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:45:48 -0500 Subject: [PATCH 07/19] fix(gui): restore ModManagementDialog string literals from FOMOD tip --- src/ModSync.GUI/Dialogs/ModManagementDialog.axaml.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/ModSync.GUI/Dialogs/ModManagementDialog.axaml.cs b/src/ModSync.GUI/Dialogs/ModManagementDialog.axaml.cs index 8c27dfb9..17d8f8a2 100644 --- a/src/ModSync.GUI/Dialogs/ModManagementDialog.axaml.cs +++ b/src/ModSync.GUI/Dialogs/ModManagementDialog.axaml.cs @@ -184,9 +184,7 @@ private async void ConfigureFomodMod_Click(object sender, RoutedEventArgs e) if (selectedTargets.Count != 1) { await _dialogService.ShowInformationDialog( - "Select exactly one mod in the list, then choose Configure FOMOD Mod again. - -" + "Select exactly one mod in the list, then choose Configure FOMOD Mod again.\n\n" + "The wizard output is merged into that mod and marked configured for validate/install.") .ConfigureAwait(true); return; @@ -221,9 +219,7 @@ await _dialogService.ShowInformationDialog( ModificationsApplied = true; await _dialogService.ShowInformationDialog( - $"FOMOD configuration applied to '{target.Name}' from '{archiveFileName}'. - -" + + $"FOMOD configuration applied to '{target.Name}' from '{archiveFileName}'.\n\n" + $"Selected options: {configured.Options.Count(option => option.IsSelected)}/{configured.Options.Count}") .ConfigureAwait(true); } From 647205782e6b7a4e0302e43b2975c9951db39ef1 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:46:16 -0500 Subject: [PATCH 08/19] fix(test): sync FomodConfiguredComponentMergerTests with FOMOD tip --- src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs b/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs index 4d388e17..6ddca514 100644 --- a/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs +++ b/src/ModSync.Tests/FomodConfiguredComponentMergerTests.cs @@ -47,7 +47,7 @@ public void MergeInto_Reconfigure_ReplacesBackslashPrefixedPriorInstructions() Action = Instruction.ActionType.Copy, Source = new System.Collections.ObjectModel.ObservableCollection { - "<>\ExampleMod\file.tga", + @"<>\ExampleMod\file.tga", }, Destination = "<>/Override", }); @@ -63,7 +63,7 @@ public void MergeInto_Reconfigure_ReplacesBackslashPrefixedPriorInstructions() Assert.That( target.Instructions.Any(i => i.Source != null - && i.Source.Any(s => s != null && s.IndexOf("ExampleMod\\file.tga", StringComparison.OrdinalIgnoreCase) >= 0)), + && i.Source.Any(s => s != null && s.IndexOf(@"ExampleMod\file.tga", StringComparison.OrdinalIgnoreCase) >= 0)), Is.False, "Prior backslash-prefixed FOMOD sources must be removed on reconfigure."); } From 55d16a4a091dadbe81332ad750d7ac4afb7fbda4 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:20:05 -0500 Subject: [PATCH 09/19] docs(vision): tighten strategy and product-vision shipped notes Align STRATEGY/product-vision wording with the release-gap inventory; kept off the FOMOD gate PR. --- STRATEGY.md | 6 +++--- docs/knowledgebase/product-vision.md | 24 +++++++++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/STRATEGY.md b/STRATEGY.md index 5ca8b302..1384de04 100644 --- a/STRATEGY.md +++ b/STRATEGY.md @@ -31,7 +31,7 @@ Encode install guides as machine-executable instruction files built on HoloPatch ### Guide ingestion -Turning existing human-written install guides (markdown, pasted text) into instruction files with draft executable instructions. +Turning existing human-written install guides (markdown, pasted text) into instruction files with draft executable instructions. **Shipped** (paste/clipboard + `convert --stdin --parse-directions`) — see [guide-ingestion.md](docs/knowledgebase/guide-ingestion.md) and [brainstorm](docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md). _Why it serves the approach:_ Authors never re-enter what they already wrote; the encoded-build supply grows from guides that already exist. @@ -43,13 +43,13 @@ _Why it serves the approach:_ The instruction file stays the single source of tr ### "Install with ModSync" entry points -One-click handoff from where mods live into the installer: `nxm://` protocol handling is shipped; a `modsync://` scheme for build links is future work. +One-click handoff from where mods live into the installer: `nxm://` protocol handling is shipped; `modsync://` Phase 1 (parse + CLI + handoff) is shipped; Phase 2 (OS registration + MainWindow consume) is open — see [modsync-protocol-handler.md](docs/knowledgebase/modsync-protocol-handler.md) and [brainstorm](docs/brainstorms/2026-07-13-modsync-protocol-requirements.md). _Why it serves the approach:_ Removes the manual download-and-place chain that causes most order-sensitive failures. ### Multi-author builds -Merge tooling, install profiles, and (future) publish/share flows so any author's build can be combined, compatibility-fixed, and distributed. +Merge tooling and install profiles are shipped; publish/share flows remain open ([plan stub](docs/plans/2026-07-13-003-feat-multi-author-publish-share-plan.md)). Prefer finishing `modsync://` Phase 2 consume/registration first so share links have an open path. _Why it serves the approach:_ Breaks the single-curator bottleneck — the crux of the target problem. diff --git a/docs/knowledgebase/product-vision.md b/docs/knowledgebase/product-vision.md index 96d3da44..dfd43c1e 100644 --- a/docs/knowledgebase/product-vision.md +++ b/docs/knowledgebase/product-vision.md @@ -10,14 +10,14 @@ ## Shipped user experience -`[REPO]` The core loop works today: load (or drag-drop) an instruction file → select mods → download → validate → install. Both the install wizard and the legacy Getting Started tab drive this flow (see [install-lifecycle.md](install-lifecycle.md)), and the Core CLI mirrors it headlessly ([core-cli-reference.md](core-cli-reference.md)). +`[REPO]` The core loop works today: load (or drag-drop / paste) an instruction file or guide → select mods → download → validate → install. Both the install wizard and the legacy Getting Started tab drive this flow (see [install-lifecycle.md](install-lifecycle.md)), and the Core CLI mirrors it headlessly ([core-cli-reference.md](core-cli-reference.md)). Avalonia headless GUI smoke (`GuiSmokeHeadlessTests`) covers paste-import and page-0 layout without a desktop session. ## The full vision `[SYNTH]` The end state is a two-way bridge between human guides and executable installs, open to any author: 1. **Guide ingestion** — paste or import an existing community install guide and get a working instruction file, including draft executable instructions parsed from the prose. No re-entry by the author. -2. **"Install with ModSync"** — one-click entry points from wherever mods live: `nxm://` links from Nexus Mods, and eventually `modsync://` links for sharing whole builds. +2. **"Install with ModSync"** — one-click entry points from wherever mods live: `nxm://` links from Nexus Mods, and `modsync://` links for sharing whole builds. 3. **Guide emission** — emit a readable install guide back out of any instruction file, so the instruction file is the source of truth and the guide is a build artifact. 4. **Democratized multi-author builds** — any author's build can be encoded, merged with others, compatibility-fixed, and shared; the community is not limited to one curator's canonical build. @@ -28,22 +28,28 @@ Row numbers map each capability from [The full vision](#the-full-vision) above; | Vision capability | Current state | Evidence | |-------------------|---------------|----------| | 1a. Guide import (markdown → components) | **Shipped.** `MarkdownParser` parses Deadly Stream-style guides into components, prose preserved in `Directions`; verified round-trippable | `[REPO]` `src/ModSync.Core/Parsing/MarkdownParser.cs`, `DocumentationRoundTripTests` | -| 1b. Paste-a-guide ingestion | **Missing.** Import is file/drag-drop only; no clipboard/paste entry point into the format-sniffing cascade (`DeserializeModComponentFromString`) | `[REPO]` `FileLoadingService.cs` (Core + GUI) | -| 1c. Prose → executable instructions | **Dead code.** `NaturalLanguageInstructionParser` exists but has zero references; imported guide prose yields no draft `Instruction` objects | `[REPO]` `src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs` | -| 2. "Install with ModSync" entry points | **Partial.** `nxm://` protocol handler shipped ([nxm-protocol-handler.md](nxm-protocol-handler.md)); `modsync://` scheme not present anywhere in the codebase | `[REPO]` `src/ModSync.GUI/Services/NxmProtocolRegistrationService.cs`; codebase search for `modsync://` | +| 1b. Paste-a-guide ingestion | **Shipped.** Getting Started **Import from Clipboard** + `FileLoadingService.ImportFromTextAsync`; CLI `convert --stdin` | `[REPO]` [guide-ingestion.md](guide-ingestion.md), `GuideIngestionTests`, `GuiSmokeHeadlessTests` | +| 1c. Prose → executable instructions | **Shipped.** `DraftInstructionService` wires `NaturalLanguageInstructionParser`; drafts review-flagged, never auto-trusted | `[REPO]` `DraftInstructionService.cs`, `convert --parse-directions`, [guide-ingestion.md](guide-ingestion.md) | +| 2. "Install with ModSync" entry points | **Partial.** `nxm://` shipped; `modsync://` Phase 1 (parse + CLI + handoff queue) shipped; OS registration + MainWindow consume open | `[REPO]` [modsync-protocol-handler.md](modsync-protocol-handler.md); [brainstorm](../brainstorms/2026-07-13-modsync-protocol-requirements.md) | | 3. Guide emission (components → guide) | **Shipped.** `GenerateModDocumentation` in `ModComponentSerializationService` | `[REPO]` `src/ModSync.Core/Services/ModComponentSerializationService.cs` | -| 4. Multi-author builds | **Partial.** Merge tooling (`merge` CLI verb) and install profiles ([install-profiles.md](install-profiles.md)) exist; publish/share flows do not | `[REPO]` merge/profiles; `[SYNTH]` publish/share gap | +| 4. Multi-author builds | **Partial.** Merge tooling (`merge` CLI) and install profiles exist; publish/share flows do not | `[REPO]` merge/profiles; [plan stub](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md) | -`[SYNTH]` Net: the round-trip machinery (import + emit) is closer to done than the repo's WIP framing suggests. The active gaps are the ingestion entry point (paste), wiring the natural-language parser into import, and the sharing/distribution layer. +`[SYNTH]` Net: guide round-trip (import + paste + draft instructions + emit) is shipped. Active gaps are finishing `modsync://` consume/registration and multi-author publish/share. ## Where the work is tracked - Strategy and tracks: [`STRATEGY.md`](../../STRATEGY.md) (repo root) -- First implementation slice (paste ingestion + NL parser wiring): [docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) +- Guide paste (done): [docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md](../brainstorms/2026-07-13-guide-paste-ingestion-requirements.md), [plan 001](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) +- `modsync://` Phase 2 (open): [brainstorm](../brainstorms/2026-07-13-modsync-protocol-requirements.md), [plan 002](../plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md) +- Multi-author publish/share (open): [plan stub 003](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md) +- Release readiness quadruple-check: [plan 005](../plans/2026-07-13-005-release-readiness-checklist.md) ## Related - [product-overview.md](product-overview.md) — what ModSync is today -- [nxm-protocol-handler.md](nxm-protocol-handler.md) — shipped entry-point track +- [guide-ingestion.md](guide-ingestion.md) — paste/draft CLI and GUI paths +- [nxm-protocol-handler.md](nxm-protocol-handler.md) — shipped Nexus entry-point track +- [modsync-protocol-handler.md](modsync-protocol-handler.md) — build deep links (Phase 1 shipped) - [install-profiles.md](install-profiles.md) — multi-build loadouts +- [fomod-support.md](fomod-support.md) — FOMOD discovery, CLI prompts, configured-only gate - [agent-action-parity.md](agent-action-parity.md) — GUI vs CLI coverage of these flows From 36531e437f4b8eedf7e4868dbae6d58ecd435560 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:22:28 -0500 Subject: [PATCH 10/19] docs: close shipped brainstorms and stub open STRATEGY tracks Mark FOMOD discovery/CLI prompts and guide-paste requirements complete with Implemented code pointers; flesh modsync:// Phase 2 requirements and add multi-author publish/share plan stub aligned with STRATEGY and product vision. --- ...9-gui-cli-unified-pipeline-requirements.md | 11 +++ ...-05-29-mod-builds-pipeline-requirements.md | 10 +++ ...14-fomod-archive-discovery-requirements.md | 28 ++++++-- ...fomod-cli-download-prompts-requirements.md | 22 +++++- ...7-13-guide-paste-ingestion-requirements.md | 59 ++++++++++++++++ ...026-07-13-modsync-protocol-requirements.md | 64 +++++++++++++++++ docs/knowledgebase/README.md | 18 ++++- docs/knowledgebase/fomod-support.md | 18 +++-- docs/knowledgebase/guide-ingestion.md | 43 ++++++++++++ .../knowledgebase/modsync-protocol-handler.md | 58 ++++++++++++++++ ...23-feat-fomod-cli-download-prompts-plan.md | 3 +- ...-13-001-feat-guide-paste-ingestion-plan.md | 6 +- ...t-modsync-protocol-os-registration-plan.md | 69 +++++++++++++++++++ ...03-feat-multi-author-publish-share-plan.md | 39 +++++++++++ 14 files changed, 426 insertions(+), 22 deletions(-) create mode 100644 docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md create mode 100644 docs/brainstorms/2026-07-13-modsync-protocol-requirements.md create mode 100644 docs/knowledgebase/guide-ingestion.md create mode 100644 docs/knowledgebase/modsync-protocol-handler.md create mode 100644 docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md create mode 100644 docs/plans/2026-07-13-003-feat-multi-author-publish-share-plan.md diff --git a/docs/brainstorms/2026-05-29-gui-cli-unified-pipeline-requirements.md b/docs/brainstorms/2026-05-29-gui-cli-unified-pipeline-requirements.md index ba4f90b5..a0a73b38 100644 --- a/docs/brainstorms/2026-05-29-gui-cli-unified-pipeline-requirements.md +++ b/docs/brainstorms/2026-05-29-gui-cli-unified-pipeline-requirements.md @@ -34,3 +34,14 @@ Every install-wizard and legacy GUI validation/install action runs through the s - Rewriting Avalonia UI or wizard page order. - Removing `ComponentValidation` class — it becomes a pipeline stage, not a CLI-only shortcut. + +## Implemented + +Shipped in PR #94 / plan `docs/plans/2026-05-29-023-unify-gui-cli-validation-pipeline-plan.md`. + +| Area | Path | +|------|------| +| Shared pipeline | `src/ModSync.Core/Services/Validation/InstallationValidationPipeline.cs` | +| CLI `validate` | `src/ModSync.Core/CLI/ModBuildConverter.cs` | +| GUI wiring | `ValidatePage`, `ValidationPipelineDialogMapper` | +| KB | `docs/knowledgebase/validation-pipeline.md`, `agent-action-parity.md` | diff --git a/docs/brainstorms/2026-05-29-mod-builds-pipeline-requirements.md b/docs/brainstorms/2026-05-29-mod-builds-pipeline-requirements.md index 98a9fb2d..df7593c6 100644 --- a/docs/brainstorms/2026-05-29-mod-builds-pipeline-requirements.md +++ b/docs/brainstorms/2026-05-29-mod-builds-pipeline-requirements.md @@ -62,3 +62,13 @@ Machine instructions and GUIDs live in TOML; markdown carries human metadata. ** - Re-enabling excluded documentation round-trip tests in one pass. - Resolving all Nexus/download dependencies in CI. + +## Implemented + +Shipped in PR #91 and related plans `017`–`020` / `022`. + +| Area | Path | +|------|------| +| Round-trip / dry-run | `FullBuildSerializationRoundTripTests`, `FullBuildMergedDryRunTests` | +| Agent script | `scripts/agents/cli_full_build_pipeline.sh`, `cli_validate.sh` | +| KB | `docs/knowledgebase/mod-builds-sources.md`, `core-cli-reference.md` | diff --git a/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md b/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md index 9ca13983..dc4e3831 100644 --- a/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md +++ b/docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md @@ -1,7 +1,8 @@ --- title: "FOMOD archive discovery after download" -status: reviewed +status: completed date: 2026-06-14 +completed: 2026-07-13 origin: docs/plans/vortex-mo2-feature-parity-living-plan.md --- @@ -59,10 +60,13 @@ archives in the mod workspace without surfacing FOMOD packages automatically. Started). - Reuse of `FomodDetector`, `FomodInstallerDialog`, and existing mapper/presenter stack. -**Deferred** +**Deferred (completed separately)** + +- CLI download/install parity → [2026-06-14-fomod-cli-download-prompts-requirements.md](2026-06-14-fomod-cli-download-prompts-requirements.md) +- Validation blocking when FOMOD choices are unset → `FomodConfigurationGate` + +**Still deferred** -- CLI download/install parity. -- Validation blocking when FOMOD choices are unset. - Plugin images and advanced conditional file-install runtime beyond current mapper. ## Key Decisions @@ -73,7 +77,19 @@ archives in the mod workspace without surfacing FOMOD packages automatically. - Wizard output merges into the existing instruction-file component rather than creating a separate standalone component. +## Implemented + +Shipped in PR #169 and follow-ups. + +| Area | Path | +|------|------| +| Archive probe | `src/ModSync.Core/Services/Fomod/FomodArchiveProbe.cs` | +| Prompt state | `src/ModSync.Core/Services/Fomod/FomodDownloadPromptState.cs` | +| GUI post-download | `src/ModSync.GUI/Services/FomodPostDownloadPromptService.cs` | +| GUI dialog host | `src/ModSync.GUI/Services/FomodGuiPostDownloadHost.cs` | +| File-tree mark | `src/ModSync.GUI/Services/ArchiveEnumerationService.cs` | +| KB | `docs/knowledgebase/fomod-support.md`, `docs/knowledgebase/download-system.md` | + ## Outstanding Questions -- Whether all GUI download entry points beyond **Fetch Downloads** should share the - same prompt hook in a follow-up slice. +- None. diff --git a/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md b/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md index 2c4561a4..f2c4f1f4 100644 --- a/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md +++ b/docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md @@ -1,9 +1,11 @@ --- title: "CLI FOMOD post-download prompts" -status: reviewed +status: completed date: 2026-06-14 +completed: 2026-07-13 origin: docs/brainstorms/2026-06-14-fomod-archive-discovery-requirements.md supersedes_deferred: CLI download/install parity from GUI FOMOD discovery slice +plan: docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md --- # CLI FOMOD post-download prompts @@ -112,3 +114,21 @@ PR #169 added GUI post-download FOMOD detection and optional configuration, but ## Outstanding Questions - None blocking — user confirmed synthesis call-outs (2026-06-14 session). + +## Implemented + +Shipped across Plan 123 commits plus the configured-only gate. + +| Area | Path | +|------|------| +| Orchestrator | `src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs` | +| Hosts / options | `FomodCliPostDownloadHosts.cs`, `FomodChoicesFileHost.cs`, `FomodPostDownloadOptionsResolver.cs` | +| TTY wizard | `src/ModSync.Core/CLI/FomodConsoleWizard.cs` | +| Presenter (Core) | `src/ModSync.Core/Services/Fomod/FomodInstallerPresenter.cs` | +| CLI hooks | `ModBuildConverter` `RunFomodPostDownloadHookAsync` on `install`/`convert`/`merge` `-d` | +| Configured-only gate | `src/ModSync.Core/Services/Fomod/FomodConfigurationGate.cs` | +| Pipeline / install | `InstallationValidationPipeline`, `InstallationService`, `InstallStartPage` | +| Tests | `FomodPostDownloadOrchestratorTests`, `FomodConfigurationGateTests`, `FomodCliPostDownloadIntegrationTests` | +| KB | `docs/knowledgebase/fomod-support.md`, `download-system.md`, `core-cli-reference.md`, `agent-action-parity.md` | + +Plan: [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) → **completed**. diff --git a/docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md b/docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md new file mode 100644 index 00000000..41c5c176 --- /dev/null +++ b/docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md @@ -0,0 +1,59 @@ +--- +title: "Guide paste ingestion + draft instructions" +status: completed +date: 2026-07-13 +completed: 2026-07-13 +strategy_track: Guide ingestion +plan: docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md +origin: STRATEGY.md + docs/knowledgebase/product-vision.md +--- + +# Guide paste ingestion + draft instructions + +## Summary + +Players and authors can paste (or pipe) an existing community install guide into ModSync and get components plus review-flagged draft `Instruction` objects from prose — no re-entry of what they already wrote. + +## Problem Frame + +Markdown guide import and guide emission already existed, but ingestion was file/drag-drop only, and `NaturalLanguageInstructionParser` was dead code. The STRATEGY guide-ingestion track needed a clipboard/stdin entry point and wired draft-instruction generation. + +## Requirements + +- R1: GUI paste/clipboard import routes text through the existing format-sniffing cascade (`DeserializeModComponentFromString`: TOML → Markdown → YAML → XML/JSON). +- R2: After import, components with `Directions` prose and no authored instructions get draft instructions from `NaturalLanguageInstructionParser` via `DraftInstructionService`. +- R3: Draft paths obey `<>` / `<>` sandboxing; drafts are never auto-trusted (review flag / log warning). +- R4: Unparseable prose degrades gracefully (no drafts; components still load). +- R5: CLI parity: `convert --stdin --parse-directions` (and file input) emit TOML with draft instructions. +- R6: Tests in `ModSync.Tests`; paste control smoke via Avalonia headless; real OS clipboard remains a desktop check. + +## Success Criteria + +- Getting Started **Import from Clipboard** loads a pasted Deadly Stream-style guide into components. +- Draft instructions appear for prose-only components and are flagged for review. +- `convert --stdin --parse-directions` produces equivalent TOML for agents. +- Round-trip / documentation tests stay green. + +## Scope Boundaries + +**In scope:** Paste entry point, NL parser wiring, CLI stdin/parse-directions, tests, KB updates. + +**Out of scope:** `modsync://` deep links; publish/share multi-author flows; auto-trusting drafts at install time. + +## Implemented + +Shipped in `a37efbaf` (feat) + headless smoke (`653c7771`). + +| Area | Path | +|------|------| +| Draft wiring | `src/ModSync.Core/Parsing/DraftInstructionService.cs` | +| NL parser | `src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs` | +| GUI import | `FileLoadingService.ImportFromTextAsync`, `ImportFromClipboardButton` | +| CLI | `ModBuildConverter` `--stdin` / `--parse-directions` | +| Tests | `GuideIngestionTests`, `GuiSmokeHeadlessTests` | +| Plan | [docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) → **completed** | +| KB | [docs/knowledgebase/guide-ingestion.md](../knowledgebase/guide-ingestion.md) | + +## Strategy alignment + +Closes STRATEGY **Guide ingestion** entry-point + prose→instruction gaps called out in `docs/knowledgebase/product-vision.md` rows 1b/1c. diff --git a/docs/brainstorms/2026-07-13-modsync-protocol-requirements.md b/docs/brainstorms/2026-07-13-modsync-protocol-requirements.md new file mode 100644 index 00000000..ea2e4ea7 --- /dev/null +++ b/docs/brainstorms/2026-07-13-modsync-protocol-requirements.md @@ -0,0 +1,64 @@ +--- +title: "modsync:// Install with ModSync deep links" +status: ready-for-plan +date: 2026-07-13 +strategy_track: "Install with ModSync" entry points +related: docs/knowledgebase/modsync-protocol-handler.md +plan: docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md +--- + +# modsync:// Install with ModSync deep links + +## Summary + +Finish the `"Install with ModSync"` track: OS registration for `modsync://` plus MainWindow consumption of buffered handoff URLs (fetch instruction file over http(s) and load into the normal instruction pipeline). + +## Problem Frame + +Phase 1 is shipped: URL parse (`ModSyncUrl`), CLI `--modsync=` / bare positional, single-instance pipe forward, and `ModSyncHandoffQueue` buffering ([modsync-protocol-handler.md](../knowledgebase/modsync-protocol-handler.md)). Browsers and the GUI still cannot complete a one-click open: there is no OS scheme registration and nothing drains the queue to fetch/load the instruction file. + +## Requirements + +**Already satisfied (Phase 1) — do not re-litigate** + +- R0a: `modsync://install|open` with `url`/`instruction` http(s) targets; optional `game=kotor|kotor2`. +- R0b: Reject `file://` and bare local paths. +- R0c: CLI + single-instance enqueue into `ModSyncHandoffQueue`. + +**Phase 2 — open** + +- R1: Register/unregister `modsync://` on Windows/Linux (settings toggle); macOS via `CFBundleURLTypes` in `ModSync.app` (mirror nxm). +- R2: MainWindow (or `ModSyncHandoffService`) drains `ModSyncHandoffQueue`, parses via `ModSyncUrl.TryParse`, fetches the instruction URL over http(s), and loads through the existing instruction-file / paste cascade. +- R3: Failures (bad URL, network, parse) surface a clear dialog/log and do not crash startup. +- R4: KB + `product-vision` row 2 updated when Phase 2 ships; agent path remains `convert`/`install -i` with a downloaded file. + +## Success Criteria + +- Clicking a `modsync://install?url=https://…/build.toml` link (after OS registration) opens ModSync and loads that instruction set. +- `--modsync=` / secondary-instance forward already works; Phase 2 makes the primary instance *consume* the queue. +- Invalid local paths never fetch or write outside the intended download cache. + +## Scope Boundaries + +**In scope:** OS registration, settings toggle, queue drain/fetch/load, tests, KB. + +**Out of scope:** Publish/share hosting ([plan stub 003](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md)); changing Phase 1 URL grammar; Nexus downloads inside `modsync://` (use `nxm://`). + +## Key Decisions + +- Reuse nxm registration/handoff patterns (`NxmProtocolRegistrationService`, `NxmHandoffService`). +- http and https both allowed for instruction URLs (matches current `ModSyncUrl` tests). +- Prefer sequencing publish/share *after* Phase 2 so share links have a working open path. + +## Dependencies / Assumptions + +- Phase 1 code under `src/ModSync.Core/Services/Protocol/` and GUI handoff types remain stable. +- Guide paste / draft-instruction path applies when fetched content is markdown. + +## Outstanding Questions + +- Whether v1 auto-advances the install wizard after load or only loads into Getting Started / wizard page 0 (same as `--instructionFile=` today is fine). + +## Plan + +[docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md](../plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md) — deepen/execute from this requirements doc. diff --git a/docs/knowledgebase/README.md b/docs/knowledgebase/README.md index daccd6ff..0ea555a4 100644 --- a/docs/knowledgebase/README.md +++ b/docs/knowledgebase/README.md @@ -24,7 +24,7 @@ Use these when citing findings in plans, PRs, or audits: | Install wizard or real install | [install-lifecycle.md](install-lifecycle.md) | [validation-pipeline.md](validation-pipeline.md) | | Missing mod archives / downloads | [download-system.md](download-system.md) | [install-lifecycle.md](install-lifecycle.md) | | Headless build/test/core | `.github/copilot-instructions.md` | `core-cli-reference.md` | -| Avalonia GUI UX smoke (no X11) | `AGENTS.md` §Headless Avalonia GUI smoke | `./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests"` | +| Avalonia GUI UX smoke (no X11) | `AGENTS.md` §Headless Avalonia GUI smoke | `./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~Headless\|FullyQualifiedName~GuiSmoke"` | | GUI / install wizard / full-build | `docs/local_desktop_agent_runbook.md` | `agent-action-parity.md` → [install-lifecycle.md](install-lifecycle.md) | | Release or versioning | `docs/manual-release.md` | `docs/solutions/manual-release-workflow.md` | | Agent capability gaps | `agent-native-audit.md` | `agent-action-parity.md` | @@ -50,7 +50,7 @@ Use these when citing findings in plans, PRs, or audits: - [Install profiles](install-profiles.md) — named loadouts, `ProfileService` capture/apply, `ProfileManagerDialog` - [Managed deployment engine](managed-deployment.md) — hardlink deploy, per-component manifests, uninstall/purge (Phase 4 slice 1) - [File-level conflicts](file-conflicts.md) — `FileConflictAnalyzer` dry-run VFS attribution and `ConflictsDialog` results UI -- [FOMOD installer support](fomod-support.md) — ModuleConfig parser, Option/Choose mapping, FomodInstallerDialog GUI +- [FOMOD installer support](fomod-support.md) — ModuleConfig parser, GUI/CLI post-download configure, `--fomod-choices`, configured-only gate - [nxm protocol handler](nxm-protocol-handler.md) — nxm:// URL parsing, OS scheme registration, single-instance hand-off, free-user download path ### Architecture and agent parity @@ -123,6 +123,20 @@ Superseded duplicate PRs [#120](https://github.com/th3w1zard1/ModSync/pull/120) KB routing: [rebrand-legacy-strings.md](rebrand-legacy-strings.md), [gui-architecture-deferred.md](gui-architecture-deferred.md). +#### July 2026 arcs (ingestion + FOMOD gate + release hygiene) + +`[REPO]` Landed on branch work toward release (see recent commits on `feat/fomod-configuration-gate` and related): + +| Topic | Status | Pointers | +|-------|--------|----------| +| Paste / guide ingestion | Shipped | [guide-ingestion.md](guide-ingestion.md); brainstorm + plan [001](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) | +| Headless Avalonia GUI smoke | Shipped | `GuiSmokeHeadlessTests`; `AGENTS.md` §Headless Avalonia GUI smoke | +| FOMOD discovery + CLI prompts + gate | Shipped | [fomod-support.md](fomod-support.md); brainstorms 2026-06-14; Plan [123](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) | +| `modsync://` Phase 1 (parse/CLI/handoff) | Shipped | [modsync-protocol-handler.md](modsync-protocol-handler.md) | +| `modsync://` Phase 2 (OS reg + consume) | Open | [brainstorm](../brainstorms/2026-07-13-modsync-protocol-requirements.md); [plan 002](../plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md) | +| Multi-author publish/share | Open | [plan stub 003](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md) | +| Release readiness checklist | Active | [005](../plans/2026-07-13-005-release-readiness-checklist.md) | + ### Always-on rules (do not duplicate here) - `.cursorrules` — path sandboxing, VFS, test naming, Avalonia gotchas diff --git a/docs/knowledgebase/fomod-support.md b/docs/knowledgebase/fomod-support.md index 63288f2f..cc27f3ad 100644 --- a/docs/knowledgebase/fomod-support.md +++ b/docs/knowledgebase/fomod-support.md @@ -50,20 +50,16 @@ GUI (`src/ModSync.GUI/`): ## Deferred `[OPEN]` - Plugin images from `image path`. +- In-validate **Configure FOMOD** action / `fomod configure` CLI verb (recovery hints only today). -## Planned: CLI post-download parity `[REPO]` +## Post-download hook + CLI parity `[REPO]` -Requirements: [docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md](../brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md) - -Plan: [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) - -- Core `FomodPostDownloadOrchestrator` + CLI console host + `--fomod-skip` / `--fomod-choices` -- Full TTY wizard; non-TTY default warn-continue; convert output persists FOMOD state - -## Post-download hook `[REPO]` +Requirements (completed): [fomod-archive-discovery](../brainstorms/2026-06-14-fomod-archive-discovery-requirements.md), [fomod-cli-download-prompts](../brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md). Plan 123: [completed](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md). - `FomodArchiveProbe` detects `fomod/ModuleConfig.xml` inside downloaded archives via entry listing. -- `FomodPostDownloadPromptService` runs after GUI **Fetch Downloads** completes; optional prompt per archive. +- `FomodPostDownloadOrchestrator` + `IFomodPostDownloadHost` adapters unify GUI and CLI after download. +- GUI: `FomodPostDownloadPromptService` / `FomodGuiPostDownloadHost` after **Fetch Downloads**. +- CLI `install -d` / `convert -d` / `merge -d`: TTY wizard, warn-continue, `--fomod-skip`, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES`. - `FomodDownloadPromptState` stores dismissed/configured/warned outcomes in resource handler metadata. - `FomodConfigurationGate` blocks validate and install unless every detected FOMOD archive on selected mods (plus hard dependencies) is `configured`; dismiss/skip/warned do not pass the gate. Unreadable downloaded archives fail closed. Missing mod directory fails closed. - `ArchiveEnumerationService` sets `FileTreeNode.IsFomodInstaller` when an archive contains FOMOD metadata. @@ -78,3 +74,5 @@ Plans: - [docs/plans/2026-06-12-115-fomod-parser-plan.md](../plans/2026-06-12-115-fomod-parser-plan.md) - [docs/plans/2026-06-14-121-fomod-installer-dialog-plan.md](../plans/2026-06-14-121-fomod-installer-dialog-plan.md) +- [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) (shipped; residual TTY polish / in-validate recovery are deferred) +- [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) (completed) diff --git a/docs/knowledgebase/guide-ingestion.md b/docs/knowledgebase/guide-ingestion.md new file mode 100644 index 00000000..e918b8b1 --- /dev/null +++ b/docs/knowledgebase/guide-ingestion.md @@ -0,0 +1,43 @@ +# Guide ingestion (paste + draft instructions) + +`[REPO]` How community install guides become instruction files with optional draft executable steps. Vision context: [product-vision.md](product-vision.md). CLI flags: [core-cli-reference.md](core-cli-reference.md). + +## What shipped + +| Capability | Surface | Agent path | +|------------|---------|------------| +| Format-sniff import (TOML → Markdown → YAML → XML/JSON) | GUI paste / file open; Core deserialize | `convert --stdin` or `convert -i` | +| Markdown guide → components (`Directions` preserved) | `MarkdownParser` | Same convert path | +| Prose → draft `Instruction` objects | `DraftInstructionService` + `NaturalLanguageInstructionParser` | `convert --parse-directions` | +| GUI clipboard paste | `ImportFromClipboardButton` → `FileLoadingService.ImportFromTextAsync` | Headless smoke: `GuiSmokeHeadlessTests`; real OS clipboard still `[UI]` | + +Draft instructions are **review-flagged** in output (never auto-trusted). Paths must use `<>` / `<>` only. + +## Agent workflow + +```bash +# Ingest a guide file to review-flagged TOML +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert -i ./path/to/guide.md --parse-directions -f toml -o ./tmp/draft.toml + +# Pipe pasted text (Linux example) +xclip -o | dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert --stdin --parse-directions -f toml -o ./tmp/draft.toml +# Or: cat clipboard.txt | … +``` + +Then review drafts, fix TOML as needed, and use `validate` / `install` as usual. + +## Tests + +```bash +dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~GuideIngestionTests" +./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~GuiSmokeHeadlessTests" +``` + +## Related + +- [agent-action-parity.md](agent-action-parity.md) +- [instruction-format.md](instruction-format.md) +- Requirements: [docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md](../brainstorms/2026-07-13-guide-paste-ingestion-requirements.md) +- Plan: [docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) diff --git a/docs/knowledgebase/modsync-protocol-handler.md b/docs/knowledgebase/modsync-protocol-handler.md new file mode 100644 index 00000000..7d9cfcd7 --- /dev/null +++ b/docs/knowledgebase/modsync-protocol-handler.md @@ -0,0 +1,58 @@ +# modsync:// protocol handler + +`[REPO]` How an "Install with ModSync" deep link reaches the app: URL parsing, CLI launch args, and single-instance hand-off. OS scheme registration is planned but not shipped in this slice. + +Sources: `src/ModSync.Core/Services/Protocol/ModSyncUrl.cs`, `src/ModSync.GUI/CLIArguments.cs`, `src/ModSync.GUI/Program.cs`, `src/ModSync.GUI/Services/SingleInstanceService.cs`, `src/ModSync.GUI/Services/ModSyncHandoffQueue.cs`, `src/ModSync.GUI/Services/ApplicationLaunchCoordinator.cs`. Requirements: [docs/brainstorms/2026-07-13-modsync-protocol-requirements.md](../brainstorms/2026-07-13-modsync-protocol-requirements.md). Follow-up: [docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md](../plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md). Related shipped pattern: [nxm-protocol-handler.md](nxm-protocol-handler.md). + +## URL model + +`[REPO]` **`ModSyncUrl`** parses build deep links that point at an instruction file over http(s): + +| Form | Example | +|------|---------| +| Action host + `url` | `modsync://install?url=https%3A%2F%2Fexample.com%2Fbuild.toml` | +| `instruction` alias + optional `game` | `modsync://open?instruction=https%3A%2F%2F...&game=kotor` | +| Game host + action path | `modsync://kotor/install?url=https%3A%2F%2F...` | +| Action host + game path | `modsync://install/kotor2?url=https%3A%2F%2F...` | + +Rules: + +- Scheme must be `modsync://`. +- Action is `install` or `open` (same meaning for now: load the linked instruction file). +- Optional game is `kotor` or `kotor2` (host, path segment, or `game=` query; conflicting values fail parse). +- Instruction location must be an absolute `http` or `https` URL (`url` or `instruction` query). Local `file://` and bare paths are rejected in this slice. + +`[SYNTH]` This is the share-a-build counterpart to Nexus `nxm://` file downloads: one link hands a whole instruction file into ModSync. + +## Pipeline (shipped) + +| Stage | Component | Behavior | +|-------|-----------|----------| +| Parse | `ModSyncUrl` | `IsModSyncUrl` / `TryParse` in Core | +| Launch | `CLIArguments` | `--modsync=` or bare positional `modsync://...` sets `CLIArguments.ModSyncUrl` | +| Single instance | `SingleInstanceService` + `ApplicationLaunchCoordinator` | Same named-pipe primary/secondary model as nxm; secondary with a protocol URL uses `ForwardProtocolUrlAndExit` | +| Buffering | `ModSyncHandoffQueue` | Thread-safe queue + `UrlEnqueued` for URLs that arrive before the main window is ready | + +`[REPO]` `Program.Main` enqueues CLI `nxm://` into `NxmHandoffQueue` and CLI `modsync://` into `ModSyncHandoffQueue`. Pipe messages are routed by scheme prefix the same way. + +## Not shipped yet + +| Stage | Status | +|-------|--------| +| OS registration (Windows/Linux/macOS) | Deferred — see follow-up plan | +| MainWindow / wizard consumption of `ModSyncHandoffQueue` | Deferred (mirror `NxmHandoffService`: download/open instruction URL, preload into load flow) | +| Settings toggle | Deferred with OS registration | + +## Relationship to nxm:// + +| | `nxm://` | `modsync://` | +|--|----------|--------------| +| Purpose | Nexus "Mod Manager Download" for one file | Share a whole build (instruction file) | +| Parse type | `NxmUrl` | `ModSyncUrl` | +| Handoff queue | `NxmHandoffQueue` | `ModSyncHandoffQueue` | +| OS registration | Shipped (`NxmProtocolRegistrationService`) | Planned | +| MainWindow consumer | `NxmHandoffService` | Not yet | + +## Tests + +`[REPO]` `ModSyncUrlTests`, `CLIArgumentsModSyncTests`, `ApplicationLaunchCoordinatorTests`, and `SingleInstanceServiceTests` (modsync pipe → `ModSyncHandoffQueue`) in `src/ModSync.Tests`. diff --git a/docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md b/docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md index f57f7148..035511d9 100644 --- a/docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md +++ b/docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md @@ -1,7 +1,8 @@ --- title: "feat: CLI FOMOD post-download prompts" -status: active +status: completed date: 2026-06-14 +completed: 2026-07-13 origin: docs/brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md deepened: 2026-06-14 --- diff --git a/docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md b/docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md index 5c9d0dac..cc890523 100644 --- a/docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md +++ b/docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md @@ -1,8 +1,10 @@ --- title: "feat: guide paste ingestion + NaturalLanguageInstructionParser wiring" -status: active +status: completed date: 2026-07-13 -origin: .cursor plan "ModSync Vision and Ingestion" (vision docs landed separately in STRATEGY.md + docs/knowledgebase/product-vision.md) +completed: 2026-07-13 +origin: docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md +related_strategy: STRATEGY.md + docs/knowledgebase/product-vision.md --- # Plan 2026-07-13-001 — Guide paste ingestion diff --git a/docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md b/docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md new file mode 100644 index 00000000..3553e671 --- /dev/null +++ b/docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md @@ -0,0 +1,69 @@ +--- +title: modsync protocol OS registration +type: feature +status: active +date: 2026-07-13 +origin: docs/brainstorms/2026-07-13-modsync-protocol-requirements.md +related: docs/knowledgebase/modsync-protocol-handler.md +--- + +# Plan: modsync:// OS registration (follow-up) + +Phase 2 of the "Install with ModSync" build-link track. Phase 1 (parse + CLI + single-instance handoff + tests) is shipped separately. + +## Goal + +Register ModSync as the OS handler for the `modsync://` URL scheme so browsers and other apps can open build deep links without a manual `--modsync=` launch. + +## Context + +`[REPO]` `nxm://` already has a full registration stack (`NxmProtocolRegistrationService`, Settings toggle, `NxmHandlerProbe`, macOS `Info.plist`). Reuse that phase model rather than inventing a second registration architecture. + +`[REPO]` Phase 1 already accepts `--modsync=` / positional `modsync://...`, forwards over the single-instance pipe, and buffers in `ModSyncHandoffQueue`. OS registration only needs to launch the same CLI surface the OS already uses for nxm (`"" "%1"` / desktop `Exec=`). + +## Implementation units (suggested) + +### U1. `ModSyncProtocolRegistrationService` + +Mirror `NxmProtocolRegistrationService`: + +- Windows: `HKCU\Software\Classes\modsync` via `reg.exe` → open command with `--modsync="%1"` or bare `"%1"`. +- Linux: `~/.local/share/applications/modsync-protocol.desktop` with `MimeType=x-scheme-handler/modsync;` + `xdg-mime default`. +- macOS: add `modsync` to `CFBundleURLTypes` alongside `nxm` in app-bundle `Info.plist` (declarative; requires `ModSync.app`). + +Pure builders (`BuildDesktopFileContent`, `BuildWindowsRegCommands`) must be unit-tested like nxm. + +### U2. Settings preference + +- Persist `registerModSyncProtocolHandler` in `AppSettings` / `MainConfig`. +- Settings → Download (or a dedicated "Install with ModSync") checkbox parallel to the nxm toggle. +- On save / startup drift repair: `Register()` / `Unregister()` like nxm Phase 3. + +### U3. Handler probe + conflict UX (optional, later phase) + +- Read-only probe for who owns `modsync://` (usually only ModSync; conflict risk is low vs nxm/MO2/Vortex). +- Confirm before overwrite only if another handler is detected. + +### U4. MainWindow consumption (can ship with or before U1) + +- `ModSyncHandoffService` drains `ModSyncHandoffQueue`, parses via `ModSyncUrl.TryParse`, fetches the instruction URL over http(s), and loads it through the existing instruction-file pipeline (wizard preload / Getting Started load). +- Fail closed on non-http(s) or parse failure; surface a clear error dialog. + +## Out of scope for this follow-up plan body + +- Changing the Phase 1 URL grammar. +- Publishing builds to a ModSync-hosted CDN (links may point at GitHub raw / any https host). +- Desktop E2E browser-click validation (still recommended after U1+U4). + +## Verification (when implemented) + +```bash +dotnet build ModSync.sln --configuration Debug +dotnet test src/ModSync.Tests/ModSync.Tests.csproj \ + --filter "FullyQualifiedName~ModSyncUrl|FullyQualifiedName~ModSyncProtocol|FullyQualifiedName~CLIArgumentsModSync" +``` + +## Dependencies + +- Phase 1 parse/CLI/handoff (this repo, already landed). +- Prefer modeling after plans 113–119 under `docs/plans/2026-06-*-nxm-*.md`. diff --git a/docs/plans/2026-07-13-003-feat-multi-author-publish-share-plan.md b/docs/plans/2026-07-13-003-feat-multi-author-publish-share-plan.md new file mode 100644 index 00000000..15ee4d7c --- /dev/null +++ b/docs/plans/2026-07-13-003-feat-multi-author-publish-share-plan.md @@ -0,0 +1,39 @@ +--- +title: "feat: multi-author publish/share flows" +status: stub +date: 2026-07-13 +strategy_track: Multi-author builds +origin: STRATEGY.md + docs/knowledgebase/product-vision.md +--- + +# Plan stub 2026-07-13-003 — Multi-author publish/share + +## Summary + +Close the remaining multi-author gap: after merge tooling and install profiles, authors need a supported way to **publish and share** an instruction file (link or artifact) so players can open it via ModSync — not only the single canonical curator build. + +## Current state `[REPO]` + +| Piece | Status | +|-------|--------| +| `merge` CLI verb | Shipped | +| Install profiles | Shipped ([install-profiles.md](../knowledgebase/install-profiles.md)) | +| Guide emission (`GenerateModDocumentation`) | Shipped | +| Hosted publish / share UX | **Missing** | +| `modsync://` deep-link consume path | Phase 1 shipped; Phase 2 open — [002](2026-07-13-002-feat-modsync-protocol-os-registration-plan.md) | + +## Proposed outcome (v1 sketch) + +- Author exports a shareable instruction URL or packaged artifact from an existing instruction file. +- Player opens via `modsync://install?url=…` (depends on plan 002) or downloads then loads as today. +- No requirement to replace `mod-builds` as the community source of truth in v1 — focus on *any* author's encoded build being distributable. + +## Non-goals (v1) + +- Full Nexus Mods upload integration. +- Multiplayer collab editing. +- Replacing Deadly Stream / GitHub hosting — ModSync links *to* hosts, does not become the CDN. + +## Next step + +Flesh a brainstorm requirements doc (success criteria, auth/trust model for remote URLs, whether share is "copy link" only vs in-app publish) before a full ce-plan. Prefer sequencing **after** modsync:// consume (002) so share links have a working open path. From 98087ac5482a15d0454da0e3cda01e3867dc68ab Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:22:57 -0500 Subject: [PATCH 11/19] docs: clarify FOMOD post-download section as shipped Align fomod-support wording with completed Plan 123 and note residual deferred polish. --- docs/knowledgebase/fomod-support.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/knowledgebase/fomod-support.md b/docs/knowledgebase/fomod-support.md index cc27f3ad..5c4fd4ea 100644 --- a/docs/knowledgebase/fomod-support.md +++ b/docs/knowledgebase/fomod-support.md @@ -54,12 +54,12 @@ GUI (`src/ModSync.GUI/`): ## Post-download hook + CLI parity `[REPO]` -Requirements (completed): [fomod-archive-discovery](../brainstorms/2026-06-14-fomod-archive-discovery-requirements.md), [fomod-cli-download-prompts](../brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md). Plan 123: [completed](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md). +**Shipped.** Requirements (completed): [archive discovery](../brainstorms/2026-06-14-fomod-archive-discovery-requirements.md), [CLI prompts](../brainstorms/2026-06-14-fomod-cli-download-prompts-requirements.md). Plan 123: [shipped](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md). - `FomodArchiveProbe` detects `fomod/ModuleConfig.xml` inside downloaded archives via entry listing. - `FomodPostDownloadOrchestrator` + `IFomodPostDownloadHost` adapters unify GUI and CLI after download. - GUI: `FomodPostDownloadPromptService` / `FomodGuiPostDownloadHost` after **Fetch Downloads**. -- CLI `install -d` / `convert -d` / `merge -d`: TTY wizard, warn-continue, `--fomod-skip`, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES`. +- CLI `install -d` / `convert -d` / `merge -d`: TTY wizard, warn-continue, `--fomod-skip`, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES` (also `--interactive` / `--non-interactive`, env `MODSYNC_FOMOD_POST_DOWNLOAD_MODE`). - `FomodDownloadPromptState` stores dismissed/configured/warned outcomes in resource handler metadata. - `FomodConfigurationGate` blocks validate and install unless every detected FOMOD archive on selected mods (plus hard dependencies) is `configured`; dismiss/skip/warned do not pass the gate. Unreadable downloaded archives fail closed. Missing mod directory fails closed. - `ArchiveEnumerationService` sets `FileTreeNode.IsFomodInstaller` when an archive contains FOMOD metadata. @@ -75,4 +75,3 @@ Plans: - [docs/plans/2026-06-12-115-fomod-parser-plan.md](../plans/2026-06-12-115-fomod-parser-plan.md) - [docs/plans/2026-06-14-121-fomod-installer-dialog-plan.md](../plans/2026-06-14-121-fomod-installer-dialog-plan.md) - [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) (shipped; residual TTY polish / in-validate recovery are deferred) -- [docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) (completed) From 8be0be421061ebde22c5ac0de421b365dc255dd7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:24:32 -0500 Subject: [PATCH 12/19] docs(agent): close guide/FOMOD parity doc gaps and silent skip warnings Document convert --stdin/--parse-directions and refresh action-parity for shipped guide ingest and CLI FOMOD configure. Surface FomodConfigurationGate recovery hints on warn-continue/skip/missing-choices fail-open paths. --- docs/knowledgebase/agent-action-parity.md | 11 +++++---- docs/knowledgebase/agent-native-audit.md | 13 ++++++----- docs/knowledgebase/core-cli-reference.md | 23 ++++++++++++++++--- docs/knowledgebase/download-system.md | 2 ++ docs/knowledgebase/fomod-support.md | 2 ++ scripts/agents/README.md | 9 ++++++++ .../CLI/FomodCliPostDownloadHosts.cs | 23 ++++++++++++++----- .../Services/Fomod/FomodChoicesFileHost.cs | 4 +++- .../Fomod/FomodPostDownloadOrchestrator.cs | 4 ++++ 9 files changed, 71 insertions(+), 20 deletions(-) diff --git a/docs/knowledgebase/agent-action-parity.md b/docs/knowledgebase/agent-action-parity.md index 67d66836..5ff7748c 100644 --- a/docs/knowledgebase/agent-action-parity.md +++ b/docs/knowledgebase/agent-action-parity.md @@ -15,7 +15,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | 5 | `GameDirectoryPage` | Pick game dir | `-g` / `--kotorPath=` | Full | | 6 | `AspyrNoticePage` | Acknowledge (K2) | No CLI equivalent | UI | | 7 | `ModSelectionPage` | Select mods, filters | `install` without `--select` = select all; `install --select category:X` / `tier:X` | Full (install); Partial (subset only with `--select`) | -| 8 | `DownloadsExplainPage` | Continue (downloads may run) | `install -d` or `convert -d` | Partial — see [download-system.md](download-system.md); FOMOD post-download configure is GUI-only until [Plan 123](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) | +| 8 | `DownloadsExplainPage` | Continue (downloads may run) | `install -d` or `convert -d` (+ FOMOD: TTY / `--fomod-choices` / `--fomod-skip`) | Partial — live download status is `[UI]`; FOMOD configure is Full via CLI (see [fomod-support.md](fomod-support.md)) | | 9 | `ValidatePage` | Run validation | `validate --full --dry-run --use-file-selection` (same Core `InstallationValidationPipeline` as GUI) | Full | | 10 | `InstallStartPage` | Confirm install | `install -y` (runs `InstallationValidationPipeline` / `WizardFull` pre-check unless `--skip-validation`) | Full | | 11 | `InstallingPage` | Watch progress | `install` (console progress) | Full — see [install-lifecycle.md](install-lifecycle.md) | @@ -30,8 +30,8 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | `Step1ModDirectoryPicker` | `--modDirectory=` / `-s` | Full | | `Step1KotorDirectoryPicker` | `--kotorPath=` / `-g` | Full | | `Step2Button` (load file) | `--instructionFile=` / `-i` | Full | -| `ImportFromClipboardButton` (paste guide / TOML) | Headless: `GuiSmokeHeadlessTests` + `ControlsHeadlessTests`; Core `FileLoadingService.ImportFromTextAsync` | Partial — button/event smoke is headless; real clipboard OS paste still `[UI]` | -| `ScrapeDownloadsButton` | `install -d` or `convert -d` | Partial — FOMOD configure after download: GUI (PR #169); CLI planned Plan 123 | +| `ImportFromClipboardButton` (paste guide / TOML) | Core: `convert --stdin` / `-i` + optional `--parse-directions` ([guide-ingestion.md](guide-ingestion.md)); headless button smoke: `GuiSmokeHeadlessTests` | Full (file/stdin ingest); Partial (OS clipboard paste still `[UI]`) | +| `ScrapeDownloadsButton` | `install -d` or `convert -d` (+ FOMOD flags as needed) | Full for download+FOMOD configure; Partial for live status/stop UI | | `ValidateButton` | `validate --full --dry-run --use-file-selection` (via `InstallationValidationPipeline`) | Full | | `OpenModDirectoryButton` | `ls` / file tools on mod dir | Full | | Download status / stop | No first-class CLI | UI | @@ -42,6 +42,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG |------|------------------| | Smoke-test repo | `./scripts/agents/run_headless_tests.sh` | | GUI UX smoke (paste import, wizard page order, page-0 layout, validate log splitter) | `./scripts/agents/run_headless_tests.sh --filter "FullyQualifiedName~Headless\|FullyQualifiedName~GuiSmoke"` (Avalonia.Headless — **no desktop**) | +| Ingest guide → draft TOML | `convert -i guide.md --parse-directions -f toml -o out.toml` or `convert --stdin --parse-directions` — [guide-ingestion.md](guide-ingestion.md) | | Validate TOML structure only | `./scripts/agents/cli_validate.sh --input path.toml` | | Full validation | `cli_validate.sh` with `--game-dir`, `--source-dir`, `--full` | | Validate only TOML-selected mods | `cli_validate.sh` … `--use-file-selection` (matches GUI Mod Selection) | @@ -58,6 +59,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | VFS validation | `VirtualFileSystemDryRunValidationTests` | Dry-run matches VFS rules | | Wizard UI | `WizardFlowHeadlessTests` | Page flow without full desktop | | GUI UX smoke | `GuiSmokeHeadlessTests` | Paste-import button + `LoadInstructionTextAsync` markdown (no clipboard), Welcome→ValidatePage key controls, compact ScrollViewer layout, ValidatePage log splitter | +| Guide ingest | `GuideIngestionTests` | `--stdin` / `--parse-directions` draft + sandboxed paths | | Wizard validation UX | `WizardValidationStagePresenter`, `ValidationPipelineDialogMapper` | Stage cards / dialog mapper parity ([PR #110](https://github.com/th3w1zard1/ModSync/pull/110)) | | Version alignment | `ReleaseVersionAlignmentTests` | Release metadata consistency | @@ -75,6 +77,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG 6. **CI test coverage** — green CI runs subsets only; local `run_headless_tests.sh` is broader. See [ci-test-matrix.md](ci-test-matrix.md). 7. **Validation pipeline fail-fast** — `InstallationValidationPipeline` stops after environment failure (no conflict/order/archive/dry-run stages). GUI and CLI both use `ValidationPipelineResult.IsSuccess`; do not infer pass from an empty dry-run result. 8. **Install pre-check opt-out** — `install --skip-validation` and `install_best_effort.sh` skip the wizard-equivalent pipeline; default `install` does not. -9. **FOMOD post-download** — GUI prompts after Fetch Downloads (PR #169). CLI parity: TTY wizard, `--fomod-skip`, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES` per [Plan 123](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md). +9. **FOMOD post-download** — GUI prompts after Fetch Downloads (PR #169). CLI: TTY wizard, `--fomod-skip`, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES`, or non-TTY **warn-continue** (marks `warned`; `FomodConfigurationGate` still blocks validate/install until `configured`). See [fomod-support.md](fomod-support.md). +10. **Guide drafts** — `--parse-directions` / GUI paste drafts are review-flagged; never treat as trusted install instructions without review. See [guide-ingestion.md](guide-ingestion.md). See [agent-native-audit.md](agent-native-audit.md) for scored principles and [core-cli-reference.md](core-cli-reference.md) for flags. diff --git a/docs/knowledgebase/agent-native-audit.md b/docs/knowledgebase/agent-native-audit.md index 081dad87..a1712a33 100644 --- a/docs/knowledgebase/agent-native-audit.md +++ b/docs/knowledgebase/agent-native-audit.md @@ -35,22 +35,23 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | User / GUI capability | Agent path | Parity | |----------------------|------------|--------| | Load instruction file | GUI `--instructionFile=` or CLI `-i` | Yes | +| Paste / ingest guide | GUI clipboard; CLI `convert --stdin` / `-i` + `--parse-directions` | Yes (OS clipboard still `[UI]`) | | Set mod / game directories | GUI preload or CLI `-g` / `-s` | Yes | | Run validation | `ValidatePage` or `validate --full` | Yes (full needs dirs) | -| Fetch downloads | Wizard / `ScrapeDownloadsButton` | Partial — CLI `install -d` / `convert -d` | -| Post-download FOMOD configure | GUI after Fetch Downloads (PR #169) | Partial — Plan 123: TTY wizard, `--fomod-choices`, `--fomod-skip` | -| FOMOD step wizard | `FomodInstallerDialog` | Partial — GUI today; Plan 123 terminal wizard | +| Fetch downloads | Wizard / `ScrapeDownloadsButton` | Partial — CLI `install -d` / `convert -d` (no live status UI) | +| Post-download FOMOD configure | GUI after Fetch Downloads (PR #169) | Yes — CLI TTY wizard, `--fomod-choices`, `--fomod-skip`; non-TTY warn-continue still gated at validate/install | +| FOMOD step wizard | `FomodInstallerDialog` / CLI `FomodConsoleWizard` | Yes (TTY); non-TTY needs choices file | | Install mods | Wizard or `install` | Yes | | Mod selection / filters | `ModSelectionPage` UI | Partial — CLI `--select` | | Widescreen-only install block | Dynamic wizard pages | No — desktop only | | Rich-text / spoiler UI | GUI controls | No | | Telemetry-auth sidecar | Separate Python stack | Routed via `telemetry-auth/README.md` | -**Strengths:** `[REPO]` `ModBuildConverter` covers validate/install/convert/merge; `install_best_effort.sh` documents a full-build-style headless path. +**Strengths:** `[REPO]` `ModBuildConverter` covers validate/install/convert/merge; guide ingest + FOMOD CLI hosts close former GUI-only gaps; `install_best_effort.sh` documents a full-build-style headless path. -**Gaps:** `[OPEN]` No headless API for every wizard button; widescreen and Aspyr notice flows are `[UI]` only. FOMOD post-download configure is GUI-only until Plan 123 (`docs/plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md`). +**Gaps:** `[OPEN]` No headless API for every wizard button; widescreen and Aspyr notice flows are `[UI]` only. Download status/stop remain GUI-only. -**Recommendations (Tier 1):** Keep `agent-action-parity.md` current when wizard pages change. Ship Plan 123 to close FOMOD action-parity gap; document `--fomod-choices` in `core-cli-reference.md`. +**Recommendations (Tier 1):** Keep `agent-action-parity.md` current when wizard pages change. Prefer `convert --parse-directions` and documented FOMOD flags over inventing new MCP surfaces. --- diff --git a/docs/knowledgebase/core-cli-reference.md b/docs/knowledgebase/core-cli-reference.md index e61f0b00..81b3a248 100644 --- a/docs/knowledgebase/core-cli-reference.md +++ b/docs/knowledgebase/core-cli-reference.md @@ -95,11 +95,13 @@ See [cli-selection-semantics.md](cli-selection-semantics.md) for install vs vali ### `convert` -Convert format, autogenerate links, download, or merge (with `-m`). +Convert format, autogenerate links, download, merge (with `-m`), or ingest a pasted/piped guide. | Flag | Description | |------|-------------| -| `-i` / `--input` | Single-file input | +| `-i` / `--input` | Single-file input (required unless `--stdin`) | +| `--stdin` | Read guide/instruction content from standard input instead of `--input` (format auto-detected: TOML, Markdown, YAML, XML, JSON). Cannot combine with `--input` | +| `--parse-directions` | Draft executable instructions from natural-language `Directions` prose for components that have none; drafted components are flagged for review in the output (never auto-trusted) | | `-o` / `--output` | Output path (stdout if omitted) | | `-f` / `--format` | `toml`, `yaml`, `json`, `xml`, `ini`, `markdown` | | `-a` / `--auto` | Autogenerate from URLs (no download) | @@ -111,7 +113,21 @@ Convert format, autogenerate links, download, or merge (with `-m`). | Merge preference flags | `--prefer-existing-*`, `--prefer-incoming-*`, `--exclude-*-only`, `--use-existing-order` | | `--concurrent`, `--ignore-errors`, `--spoiler-free` | As labeled in `--help` | | `--auto-generate-local` | Generate instructions from local archives in `--source-path` for components missing instructions | -| `--nexus-mods-api-key` | No | Nexus key for `convert` / merge downloads (name differs from `install --nexus-api-key`) | +| `--nexus-mods-api-key` | Nexus key for `convert` / merge downloads (name differs from `install --nexus-api-key`) | + +**Guide paste / draft instructions (CLI parity for GUI Import from Clipboard):** + +```bash +# Pipe a markdown guide (or TOML/YAML/XML/JSON) and emit review-flagged TOML with draft instructions +cat ./mod-builds/content/k1/full.md | dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert --stdin --parse-directions -f toml -o ./tmp/ingested.toml + +# Same from a file (no pipe) +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert -i ./mod-builds/content/k1/full.md --parse-directions -f toml -o ./tmp/ingested.toml +``` + +Draft paths always use `<>` / `<>`. Review before `install`. Tests: `GuideIngestionTests`. See [guide-ingestion.md](guide-ingestion.md). --- @@ -211,6 +227,7 @@ Used by `scripts/agents/launch_gui_desktop.sh`. See `agent-action-parity.md`. ## Related docs +- [Guide ingestion](guide-ingestion.md) - [CLI selection semantics](cli-selection-semantics.md) - [Agent action parity](agent-action-parity.md) - [HoloPatcher resources](holopatcher-resources.md) diff --git a/docs/knowledgebase/download-system.md b/docs/knowledgebase/download-system.md index 50fdd3b7..080fab00 100644 --- a/docs/knowledgebase/download-system.md +++ b/docs/knowledgebase/download-system.md @@ -60,6 +60,8 @@ Helper: **`scripts/agents/cli_validate.sh`** does not download; use **`install - **Parity gap**: CLI can download archives; it does not replicate the GUI download status panel or stop button. See [agent-action-parity.md](agent-action-parity.md). +FOMOD after download is available on CLI (`install -d` / `convert -d` / `merge -d`) via TTY wizard, `--fomod-choices`, or `--fomod-skip`. Non-interactive **warn-continue** / skip print recovery hints but leave archives unconfigured — `FomodConfigurationGate` still blocks validate/install. + ## FOMOD post-download (CLI + GUI) `[REPO]` After downloads complete, selected components with FOMOD archives may be configured before validation/install: diff --git a/docs/knowledgebase/fomod-support.md b/docs/knowledgebase/fomod-support.md index 5c4fd4ea..93266ce3 100644 --- a/docs/knowledgebase/fomod-support.md +++ b/docs/knowledgebase/fomod-support.md @@ -64,6 +64,8 @@ GUI (`src/ModSync.GUI/`): - `FomodConfigurationGate` blocks validate and install unless every detected FOMOD archive on selected mods (plus hard dependencies) is `configured`; dismiss/skip/warned do not pass the gate. Unreadable downloaded archives fail closed. Missing mod directory fails closed. - `ArchiveEnumerationService` sets `FileTreeNode.IsFomodInstaller` when an archive contains FOMOD metadata. +Non-TTY **warn-continue** / `--fomod-skip` print recovery hints (`FomodConfigurationGate.RecoveryHint`); they do **not** satisfy the configured-only gate. + ## Verification ```bash diff --git a/scripts/agents/README.md b/scripts/agents/README.md index 314e652f..f8e8e1bc 100644 --- a/scripts/agents/README.md +++ b/scripts/agents/README.md @@ -37,6 +37,15 @@ ## Common flows +### Guide ingest (draft instructions) + +```bash +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + convert -i ./mod-builds/content/k1/full.md --parse-directions -f toml -o ./tmp/draft.toml +``` + +See [guide-ingestion.md](../../docs/knowledgebase/guide-ingestion.md). + ### Headless smoke test ```bash diff --git a/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs b/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs index 66d7a311..116df2ed 100644 --- a/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs +++ b/src/ModSync.Core/CLI/FomodCliPostDownloadHosts.cs @@ -21,7 +21,8 @@ public Task AskConfigureAsync( { string message = $"WARN: FOMOD installer detected in '{context.ArchiveFileName}' for mod '{context.Component.Name}'. " - + "Configure later via GUI or re-run with --fomod-choices."; + + "Download continues, but validate/install will fail until configured. " + + FomodConfigurationGate.RecoveryHint; Console.Error.WriteLine(message); FomodDownloadPromptState.MarkWarned(context.Component, context.ArchiveFileName); return Task.FromResult(FomodConfigurePromptResult.AlreadyHandled); @@ -38,7 +39,7 @@ public Task ReportExtractFailureAsync( string message, CancellationToken cancellationToken = default) { - Console.Error.WriteLine(message); + Console.Error.WriteLine($"{message} {FomodConfigurationGate.RecoveryHint}"); return Task.CompletedTask; } @@ -50,8 +51,15 @@ public sealed class FomodSkipPostDownloadHost : IFomodPostDownloadHost { public Task AskConfigureAsync( FomodPromptContext context, - CancellationToken cancellationToken = default) => - Task.FromResult(FomodConfigurePromptResult.Dismiss); + CancellationToken cancellationToken = default) + { + Console.Error.WriteLine( + $"WARN: Skipping FOMOD configuration for '{context.ArchiveFileName}' " + + $"(mod '{context.Component.Name}') because --fomod-skip / skip mode is active. " + + "Validate/install will fail until this archive is configured. " + + FomodConfigurationGate.RecoveryHint); + return Task.FromResult(FomodConfigurePromptResult.Dismiss); + } public Task RunWizardAsync( string extractedArchiveDirectory, @@ -62,8 +70,11 @@ public Task RunWizardAsync( public Task ReportExtractFailureAsync( FomodPromptContext context, string message, - CancellationToken cancellationToken = default) => - Task.CompletedTask; + CancellationToken cancellationToken = default) + { + Console.Error.WriteLine($"{message} {FomodConfigurationGate.RecoveryHint}"); + return Task.CompletedTask; + } public Task ReportConfiguredAsync(FomodPromptContext context, CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs b/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs index 2ba38f76..8de34aaf 100644 --- a/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs +++ b/src/ModSync.Core/Services/Fomod/FomodChoicesFileHost.cs @@ -28,7 +28,9 @@ public Task AskConfigureAsync( if (archiveChoices is null) { Console.Error.WriteLine( - $"WARN: No FOMOD choices entry for archive '{context.ArchiveFileName}' in the choices file."); + $"WARN: No FOMOD choices entry for archive '{context.ArchiveFileName}' in the choices file. " + + "Download continues, but validate/install will fail until configured. " + + FomodConfigurationGate.RecoveryHint); FomodDownloadPromptState.MarkWarned(context.Component, context.ArchiveFileName); return Task.FromResult(FomodConfigurePromptResult.AlreadyHandled); } diff --git a/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs index b787ed44..300e1cb4 100644 --- a/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs +++ b/src/ModSync.Core/Services/Fomod/FomodPostDownloadOrchestrator.cs @@ -87,6 +87,10 @@ await host.ReportExtractFailureAsync( if (configured is null) { + await host.ReportExtractFailureAsync( + context, + $"FOMOD configuration was not applied for '{archiveFileName}'.", + cancellationToken).ConfigureAwait(false); continue; } From a21003fecc5229dafdd1525a760dd9bf836f52cb Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:27:03 -0500 Subject: [PATCH 13/19] docs(kb): mark paste ingest and FOMOD CLI configure as shipped Align P0-5 honesty docs with implemented clipboard/stdin draft ingest and CLI --fomod-choices / MODSYNC_FOMOD_CHOICES post-download configure. --- docs/knowledgebase/agent-native-audit.md | 6 +++--- docs/knowledgebase/guide-ingestion.md | 11 +++++------ docs/knowledgebase/product-overview.md | 12 ++++++++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/knowledgebase/agent-native-audit.md b/docs/knowledgebase/agent-native-audit.md index a1712a33..69182100 100644 --- a/docs/knowledgebase/agent-native-audit.md +++ b/docs/knowledgebase/agent-native-audit.md @@ -17,7 +17,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | # | Principle | Score | Summary | |---|-----------|-------|---------| -| 1 | **Parity** | 17/25 (68%) | Core paths plus CLI FOMOD post-download (Plan 123) close a major GUI-only gap; widescreen/Aspyr remain UI-only. | +| 1 | **Parity** | 17/25 (68%) | Core paths plus shipped CLI FOMOD post-download and guide ingest close major GUI-only gaps; widescreen/Aspyr remain UI-only. | | 2 | **Granularity** | 16/20 (80%) | CLI verbs are composable; scripts wrap common combos without hiding primitives. | | 3 | **Composability** | 12/15 (80%) | New agent workflows combine `dotnet run` + scripts + tests without code changes. | | 4 | **Emergent capability** | 10/15 (67%) | Agents can fix TOMLs and run installs; limited without Nexus keys, real game dirs, or desktop. | @@ -38,7 +38,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | Paste / ingest guide | GUI clipboard; CLI `convert --stdin` / `-i` + `--parse-directions` | Yes (OS clipboard still `[UI]`) | | Set mod / game directories | GUI preload or CLI `-g` / `-s` | Yes | | Run validation | `ValidatePage` or `validate --full` | Yes (full needs dirs) | -| Fetch downloads | Wizard / `ScrapeDownloadsButton` | Partial — CLI `install -d` / `convert -d` (no live status UI) | +| Fetch downloads | Wizard / `ScrapeDownloadsButton` | Partial — CLI `install -d` / `convert -d` | | Post-download FOMOD configure | GUI after Fetch Downloads (PR #169) | Yes — CLI TTY wizard, `--fomod-choices`, `--fomod-skip`; non-TTY warn-continue still gated at validate/install | | FOMOD step wizard | `FomodInstallerDialog` / CLI `FomodConsoleWizard` | Yes (TTY); non-TTY needs choices file | | Install mods | Wizard or `install` | Yes | @@ -49,7 +49,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec **Strengths:** `[REPO]` `ModBuildConverter` covers validate/install/convert/merge; guide ingest + FOMOD CLI hosts close former GUI-only gaps; `install_best_effort.sh` documents a full-build-style headless path. -**Gaps:** `[OPEN]` No headless API for every wizard button; widescreen and Aspyr notice flows are `[UI]` only. Download status/stop remain GUI-only. +**Gaps:** `[OPEN]` No headless API for every wizard button; widescreen and Aspyr notice flows are `[UI]` only. Non-TTY FOMOD warn-continue / skip leave archives unconfigured until `--fomod-choices` (gate still fails closed). Download status/stop remain GUI-only. **Recommendations (Tier 1):** Keep `agent-action-parity.md` current when wizard pages change. Prefer `convert --parse-directions` and documented FOMOD flags over inventing new MCP surfaces. diff --git a/docs/knowledgebase/guide-ingestion.md b/docs/knowledgebase/guide-ingestion.md index e918b8b1..fc6de4a8 100644 --- a/docs/knowledgebase/guide-ingestion.md +++ b/docs/knowledgebase/guide-ingestion.md @@ -20,10 +20,9 @@ Draft instructions are **review-flagged** in output (never auto-trusted). Paths dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ convert -i ./path/to/guide.md --parse-directions -f toml -o ./tmp/draft.toml -# Pipe pasted text (Linux example) -xclip -o | dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ +# Or pipe pasted text +cat clipboard.txt | dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ convert --stdin --parse-directions -f toml -o ./tmp/draft.toml -# Or: cat clipboard.txt | … ``` Then review drafts, fix TOML as needed, and use `validate` / `install` as usual. @@ -37,7 +36,7 @@ dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~ ## Related +- Requirements (completed): [docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md](../brainstorms/2026-07-13-guide-paste-ingestion-requirements.md) +- Plan (completed): [docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) - [agent-action-parity.md](agent-action-parity.md) -- [instruction-format.md](instruction-format.md) -- Requirements: [docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md](../brainstorms/2026-07-13-guide-paste-ingestion-requirements.md) -- Plan: [docs/plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) +- [product-vision.md](product-vision.md) diff --git a/docs/knowledgebase/product-overview.md b/docs/knowledgebase/product-overview.md index 3372f03a..05c0b26b 100644 --- a/docs/knowledgebase/product-overview.md +++ b/docs/knowledgebase/product-overview.md @@ -55,6 +55,18 @@ KOTOR modding often requires dozens of repetitive, order-sensitive steps per mod `[REPO]` `README.md` states the project is **mid large-scale rework**. Treat README usage/FAQ as helpful but possibly stale. Prefer this knowledgebase, `AGENTS.md`, and runbooks for agent routing. +## Current state snapshot (2026-07-13) + +| Capability | State | +|------------|--------| +| Paste-a-guide ingestion | Shipped (GUI clipboard + CLI `--stdin`) | +| Draft instructions from guide prose | Shipped (review-flagged drafts via `DraftInstructionService` / `--parse-directions`) | +| Headless Avalonia GUI smoke | Shipped (`GuiSmokeHeadlessTests`) | +| FOMOD CLI post-download configure | Shipped (TTY wizard, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES`, `--fomod-skip`) | +| FOMOD configure-before-validate/install gate | Shipped (`FomodConfigurationGate`; fail-closed on unreadable archives) | +| `nxm://` entry point | Shipped | +| `modsync://` / publish-share | Not shipped — see [product-vision.md](product-vision.md) | + ## Related - [product-vision.md](product-vision.md) — origin, full vision, and vision-vs-current-state gaps From 5e9b7272ff227559e8e4d13cbfe2daffa379d7f7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:29:15 -0500 Subject: [PATCH 14/19] refactor: simplify guide-paste ingestion paths Reuse one NL parser per draft pass, share paste size limits / last-loaded helpers, cascade format detection, and drop redundant ToList on CLI draft results. --- src/ModSync.Core/CLI/ModBuildConverter.cs | 4 +- .../Parsing/DraftInstructionService.cs | 22 +++--- .../ModComponentSerializationService.cs | 20 ++--- .../Services/FileLoadingService.cs | 78 ++++++------------- 4 files changed, 45 insertions(+), 79 deletions(-) diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index 29175758..e2162b03 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -1939,7 +1939,7 @@ private static async Task RunConvertAsync(ConvertOptions opts) throw; } - List draftResults = null; + IReadOnlyList draftResults = null; if (opts.ParseDirections) { msg = "Drafting instructions from natural-language Directions prose..."; @@ -1955,7 +1955,7 @@ private static async Task RunConvertAsync(ConvertOptions opts) draftResults = Parsing.DraftInstructionService.GenerateDraftInstructions( components, logInfo: message => Logger.Log(message), - logVerbose: message => Logger.LogVerbose(message)).ToList(); + logVerbose: message => Logger.LogVerbose(message)); msg = $"Drafted instructions for {draftResults.Count} component(s) - all drafts are flagged for review"; if (s_progressDisplay != null) diff --git a/src/ModSync.Core/Parsing/DraftInstructionService.cs b/src/ModSync.Core/Parsing/DraftInstructionService.cs index f1aee741..33bf1317 100644 --- a/src/ModSync.Core/Parsing/DraftInstructionService.cs +++ b/src/ModSync.Core/Parsing/DraftInstructionService.cs @@ -68,17 +68,12 @@ public static IReadOnlyList GenerateDraftInstructions( Action info = logInfo ?? (_ => { }); Action verbose = logVerbose ?? (_ => { }); - + var parser = new NaturalLanguageInstructionParser(info, verbose); var results = new List(); foreach (ModComponent component in components) { - if (component.Instructions.Count > 0) - { - continue; - } - - if (string.IsNullOrWhiteSpace(component.Directions)) + if (component.Instructions.Count > 0 || string.IsNullOrWhiteSpace(component.Directions)) { continue; } @@ -86,7 +81,6 @@ public static IReadOnlyList GenerateDraftInstructions( ObservableCollection parsed; try { - var parser = new NaturalLanguageInstructionParser(info, verbose); parsed = parser.ParseInstructions( component.Directions, string.IsNullOrWhiteSpace(component.DownloadInstructions) ? null : component.DownloadInstructions, @@ -165,11 +159,7 @@ public static bool TrySanitizeInstruction([NotNull] Instruction instruction) return false; } - bool destinationRequired = - instruction.Action == Instruction.ActionType.Move || - instruction.Action == Instruction.ActionType.Copy; - - if (destinationRequired && string.IsNullOrWhiteSpace(destination)) + if (RequiresDestination(instruction.Action) && string.IsNullOrWhiteSpace(destination)) { return false; } @@ -203,6 +193,12 @@ public static void ApplyReviewFlag([NotNull] ModComponent component) } } + private static bool RequiresDestination(Instruction.ActionType action) + { + return action == Instruction.ActionType.Move + || action == Instruction.ActionType.Copy; + } + [NotNull] private static string NormalizePlaceholders([CanBeNull] string path) { diff --git a/src/ModSync.Core/Services/ModComponentSerializationService.cs b/src/ModSync.Core/Services/ModComponentSerializationService.cs index 940a10f0..df2566cf 100644 --- a/src/ModSync.Core/Services/ModComponentSerializationService.cs +++ b/src/ModSync.Core/Services/ModComponentSerializationService.cs @@ -930,19 +930,19 @@ public static string DetectFormatFromContent([NotNull] string content) return null; } - if (ParsesWithComponents(content, DeserializeModComponentFromTomlString, "toml")) + (string Format, Func> Parse)[] cascade = { - return "toml"; - } - - if (ParsesWithComponents(content, DeserializeModComponentFromMarkdownString, "markdown")) - { - return "markdown"; - } + ("toml", DeserializeModComponentFromTomlString), + ("markdown", DeserializeModComponentFromMarkdownString), + ("yaml", DeserializeModComponentFromYamlString), + }; - if (ParsesWithComponents(content, DeserializeModComponentFromYamlString, "yaml")) + foreach ((string format, Func> parse) in cascade) { - return "yaml"; + if (ParsesWithComponents(content, parse, format)) + { + return format; + } } if (content.TrimStart().StartsWith("<", StringComparison.Ordinal)) diff --git a/src/ModSync.GUI/Services/FileLoadingService.cs b/src/ModSync.GUI/Services/FileLoadingService.cs index 9a542c4c..8e1d0bea 100644 --- a/src/ModSync.GUI/Services/FileLoadingService.cs +++ b/src/ModSync.GUI/Services/FileLoadingService.cs @@ -21,6 +21,8 @@ namespace ModSync.Services { public class FileLoadingService { + private const int MaxInstructionSizeBytes = 524288000; + private readonly MainConfig _mainConfig; private readonly Window _parentWindow; @@ -44,19 +46,13 @@ public async Task LoadInstructionFileAsync( { try { - var fileInfo = new FileInfo(filePath); - const int maxInstructionSize = 524288000; - if (fileInfo.Length > maxInstructionSize) - - + if (fileInfo.Length > MaxInstructionSizeBytes) { #pragma warning disable MA0004 // Use Task. await Logger.LogAsync($"Invalid {fileType} selected: '{fileInfo.Name}' - file too large"); #pragma warning restore MA0004 // Use Task. return false; - - } // Auto-detect format (TOML/JSON/YAML/embedded-Markdown) @@ -71,8 +67,6 @@ public async Task LoadInstructionFileAsync( #pragma warning restore MA0004 // Use Task. } catch (Exception ex) - - { #pragma warning disable MA0004 // Use Task. await Logger.LogExceptionAsync(ex); @@ -105,8 +99,7 @@ public async Task LoadInstructionTextAsync( return false; } - const int maxInstructionSize = 524288000; - if (content.Length > maxInstructionSize) + if (content.Length > MaxInstructionSizeBytes) { #pragma warning disable MA0004 // Use Task. await Logger.LogWarningAsync($"Text from {sourceDescription} is too large to import."); @@ -167,11 +160,7 @@ private async Task ApplyLoadedComponentsAsync( if (_mainConfig.allComponents.Count == 0) { _mainConfig.allComponents = newComponents; - if (loadedFileName != null) - { - LastLoadedFileName = loadedFileName; - } - + SetLastLoadedFileName(loadedFileName); #pragma warning disable MA0004 // Use Task. await Logger.LogAsync($"Loaded {newComponents.Count} components from {fileType}."); @@ -196,16 +185,7 @@ private async Task ApplyLoadedComponentsAsync( "Currently Loaded Components", fileType, (existing, incoming) => - { - if (existing.Guid == incoming.Guid) - { - return true; - } - - return FuzzyMatcher.FuzzyMatchComponents(existing, incoming); - }); - - + existing.Guid == incoming.Guid || FuzzyMatcher.FuzzyMatchComponents(existing, incoming)); #pragma warning disable MA0004 // Use Task. await conflictDialog.ShowDialog(_parentWindow); @@ -216,12 +196,7 @@ private async Task ApplyLoadedComponentsAsync( int originalCount = _mainConfig.allComponents.Count; _mainConfig.allComponents = conflictDialog.MergedComponents; int newCount = _mainConfig.allComponents.Count; - if (loadedFileName != null) - { - LastLoadedFileName = loadedFileName; - } - - + SetLastLoadedFileName(loadedFileName); #pragma warning disable MA0004 // Use Task. await Logger.LogAsync($"Merged {newComponents.Count} components from {fileType} with existing {originalCount} components using hybrid matching (GUID then Name/Author). Total components now: {newCount}"); @@ -238,10 +213,7 @@ private async Task ApplyLoadedComponentsAsync( } case false: _mainConfig.allComponents = newComponents; - if (loadedFileName != null) - { - LastLoadedFileName = loadedFileName; - } + SetLastLoadedFileName(loadedFileName); #pragma warning disable MA0004 // Use Task. await Logger.LogAsync($"Overwrote existing config with {newComponents.Count} components from {fileType}."); #pragma warning restore MA0004 // Use Task. @@ -277,8 +249,6 @@ public async Task LoadMarkdownFileAsync( string fileContents; using (var reader = new StreamReader(filePath)) - - { #pragma warning disable MA0004 // Use Task. fileContents = await reader.ReadToEndAsync(); @@ -290,8 +260,6 @@ public async Task LoadMarkdownFileAsync( #pragma warning restore MA0004 // Use Task. } catch (Exception ex) - - { #pragma warning disable MA0004 // Use Task. await Logger.LogExceptionAsync(ex); @@ -315,8 +283,7 @@ public async Task LoadMarkdownContentAsync( { try { - { - MarkdownParserResult parseResult = null; + MarkdownParserResult parseResult = null; MarkdownImportProfile configuredProfile; if (editorMode) @@ -405,12 +372,12 @@ await Dispatcher.UIThread.InvokeAsync(async () => } - if (draftInstructionsFromProse && parseResult.Components != null) - { + if (draftInstructionsFromProse && parseResult.Components != null) + { #pragma warning disable MA0004 // Use Task. - await GenerateDraftInstructionsFromProseAsync(parseResult.Components.ToList()); + await GenerateDraftInstructionsFromProseAsync(parseResult.Components); #pragma warning restore MA0004 // Use Task. - } + } _mainConfig.preambleContent = parseResult.PreambleContent ?? string.Empty; _mainConfig.epilogueContent = parseResult.EpilogueContent ?? string.Empty; @@ -495,14 +462,11 @@ await Dispatcher.UIThread.InvokeAsync(async () => } #pragma warning disable MA0004 // Use Task. - await onComponentsLoaded(); + await onComponentsLoaded(); #pragma warning restore MA0004 // Use Task. - return true; - } + return true; } catch (Exception ex) - - { #pragma warning disable MA0004 // Use Task. await Logger.LogExceptionAsync(ex); @@ -534,8 +498,6 @@ public async Task SaveTomlFileAsync(string filePath, List co return true; } catch (Exception ex) - - { #pragma warning disable MA0004 // Use Task. await Logger.LogExceptionAsync(ex); @@ -546,7 +508,15 @@ public async Task SaveTomlFileAsync(string filePath, List co #region Private Helper Methods - private static async Task GenerateDraftInstructionsFromProseAsync([NotNull][ItemNotNull] List components) + private void SetLastLoadedFileName([CanBeNull] string loadedFileName) + { + if (loadedFileName != null) + { + LastLoadedFileName = loadedFileName; + } + } + + private static async Task GenerateDraftInstructionsFromProseAsync([NotNull][ItemNotNull] IEnumerable components) { #pragma warning disable MA0004 // Use Task. IReadOnlyList draftResults = await Task.Run(() => DraftInstructionService.GenerateDraftInstructions( From 105345712f402a098977e658f54917ab11f6a3f0 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 13 Jul 2026 11:42:19 -0500 Subject: [PATCH 15/19] Improve guide ingestion draft quality against mod-builds samples. Strip markdown emphasis and expand delete/range patterns so real guide prose drafts sandboxed instructions; document paste+CLI-only draft generation; add emission and multi-guide GuideIngestionTests. --- docs/knowledgebase/guide-ingestion.md | 17 +- .../NaturalLanguageInstructionParser.cs | 78 +++++--- src/ModSync.Tests/GuideIngestionTests.cs | 170 +++++++++++++++++- 3 files changed, 232 insertions(+), 33 deletions(-) diff --git a/docs/knowledgebase/guide-ingestion.md b/docs/knowledgebase/guide-ingestion.md index fc6de4a8..f2a551ec 100644 --- a/docs/knowledgebase/guide-ingestion.md +++ b/docs/knowledgebase/guide-ingestion.md @@ -9,14 +9,27 @@ | Format-sniff import (TOML → Markdown → YAML → XML/JSON) | GUI paste / file open; Core deserialize | `convert --stdin` or `convert -i` | | Markdown guide → components (`Directions` preserved) | `MarkdownParser` | Same convert path | | Prose → draft `Instruction` objects | `DraftInstructionService` + `NaturalLanguageInstructionParser` | `convert --parse-directions` | -| GUI clipboard paste | `ImportFromClipboardButton` → `FileLoadingService.ImportFromTextAsync` | Headless smoke: `GuiSmokeHeadlessTests`; real OS clipboard still `[UI]` | +| GUI clipboard paste | `ImportFromClipboardButton` → `FileLoadingService.LoadInstructionTextAsync` | Headless smoke: `GuiSmokeHeadlessTests`; real OS clipboard still `[UI]` | +| Guide emission (components → markdown) | `GenerateModDocumentation` | Round-trip covered by `GuideIngestionTests` + documentation tests | Draft instructions are **review-flagged** in output (never auto-trusted). Paths must use `<>` / `<>` only. +## When drafts are generated (decision) + +**Drafts from natural-language Directions are opt-in except for paste.** + +| Path | Drafts from prose? | Why | +|------|--------------------|-----| +| GUI **paste / clipboard** | Yes (always) | Paste is an explicit “ingest this guide” action; users expect draft instructions | +| CLI `convert --parse-directions` | Yes (flag required) | Agent/parity path; keeps plain `convert` non-destructive | +| GUI **file open / drag-drop** of `.md` | No | Opening an authored guide or TOML companion must not rewrite empty `Instructions` with heuristic drafts | + +File-based markdown import can still produce drafts by converting with `--parse-directions` (or pasting the file contents). There is no separate GUI feature flag for file open; paste + CLI is the intentional surface. + ## Agent workflow ```bash -# Ingest a guide file to review-flagged TOML +# Ingest a guide file to review-flagged TOML (opt-in drafts) dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ convert -i ./path/to/guide.md --parse-directions -f toml -o ./tmp/draft.toml diff --git a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs index b0d108ab..8bc42d7b 100644 --- a/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs +++ b/src/ModSync.Core/Parsing/NaturalLanguageInstructionParser.cs @@ -271,6 +271,12 @@ public sealed class NaturalLanguageInstructionParser ), // === DELETE PATTERNS (exhaustive) === + // "Before moving ... delete / be sure to delete ..." + new InstructionPattern( + @"before\s+moving(?:\s+the\s+files?)?(?:\s+to\s+(?:your\s+)?(?:override|game).*?)?,?\s+(?:be\s+sure\s+to\s+)?delete\s+(?:the\s+following(?:\s+files?)?:?\s*)?(?.+?)(?:\.|$)", + Instruction.ActionType.Delete, + RegexOptions.IgnoreCase + ), // "Delete X.ext & .txi" new InstructionPattern( @"delete\s+(?[\w\-_.]+?)(?:\s+&\s+\.[\w]+)+\s+before", @@ -283,15 +289,15 @@ public sealed class NaturalLanguageInstructionParser Instruction.ActionType.Delete, RegexOptions.IgnoreCase ), - // "(sorted by name) X through Y" + // "(sorted by name) X through Y" (markdown emphasis may already be stripped) new InstructionPattern( - @"\(sorted\s+by\s+name\)\s+(?[\w\-_.]+?)\s+\*\*through\*\*\s+(?[\w\-_.]+)", + @"\(sorted\s+by\s+name\)\s+(?[\w\-_.]+?)\s+(?:\*\*)?through(?:\*\*)?\s+(?[\w\-_.]+)", Instruction.ActionType.Delete, RegexOptions.IgnoreCase ), - // "Delete the following files: X, Y, Z" + // "Delete the following files: X, Y, Z" / "delete the following: X, Y" new InstructionPattern( - @"delete\s+the\s+following\s+files?:\s+(?.+?)(?:\.|$)", + @"delete\s+the\s+following(?:\s+files?)?:?\s+(?.+?)(?:\.|$)", Instruction.ActionType.Delete, RegexOptions.IgnoreCase ), @@ -412,18 +418,18 @@ public sealed class NaturalLanguageInstructionParser // Destination normalization mappings private static readonly Dictionary s_destinationMappings = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["override"] = @"<>\Override", - ["override folder"] = @"<>\Override", - ["override directory"] = @"<>\Override", - ["game's override"] = @"<>\Override", - ["your override"] = @"<>\Override", - ["main game directory"] = @"<>", - ["main directory"] = @"<>", - ["game directory"] = @"<>", - ["game directory"] = @"<>", - ["movies folder"] = @"<>\Movies", - ["movies"] = @"<>\Movies", - ["your movies folder"] = @"<>\Movies", + ["override"] = @"<>\Override", + ["override folder"] = @"<>\Override", + ["override directory"] = @"<>\Override", + ["game's override"] = @"<>\Override", + ["your override"] = @"<>\Override", + ["main game directory"] = @"<>", + ["main directory"] = @"<>", + ["game directory"] = @"<>", + ["game directory"] = @"<>", + ["movies folder"] = @"<>\Movies", + ["movies"] = @"<>\Movies", + ["your movies folder"] = @"<>\Movies", }; public NaturalLanguageInstructionParser([CanBeNull] Action logInfo = null, [CanBeNull] Action logVerbose = null) @@ -455,8 +461,12 @@ public ObservableCollection ParseInstructions( _logVerbose($"[NLParser] Parsing instructions for component: {parentComponent.Name}"); + // Deadly Stream / mod-builds guides often wrap emphasis around verbs ("**before** moving"). + // Strip common markdown emphasis markers so regex patterns see plain prose. + string normalizedInstructions = StripMarkdownEmphasis(installationInstructions); + // Split into logical units (sentences/clauses) - List units = SplitIntoProcessingUnits(installationInstructions); + List units = SplitIntoProcessingUnits(normalizedInstructions); _logVerbose($"[NLParser] Found {units.Count} instruction units to parse"); foreach (string unit in units) @@ -531,6 +541,24 @@ private static List SplitIntoProcessingUnits([NotNull] string text) return units; } + /// + /// Removes common markdown emphasis markers so guide prose matches instruction regexes. + /// + [NotNull] + private static string StripMarkdownEmphasis([NotNull] string text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + // Bold/italic markers used heavily in mod-builds Installation Instructions notes. + return text + .Replace("**", string.Empty) + .Replace("__", string.Empty) + .Replace("*", string.Empty); + } + /// /// Parses a single instruction unit into zero or more Instructions. /// @@ -1037,12 +1065,12 @@ private static string NormalizeDestination([NotNull] string destination, [NotNul // Check full unit for destination clues if (fullUnit.IndexOf("override", StringComparison.OrdinalIgnoreCase) >= 0) { - return @"<>\Override"; + return @"<>\Override"; } if (fullUnit.IndexOf("movies", StringComparison.OrdinalIgnoreCase) >= 0) { - return @"<>\Movies"; + return @"<>\Movies"; } // Treat as subdirectory of mod directory if it looks like a path @@ -1052,7 +1080,7 @@ private static string NormalizeDestination([NotNull] string destination, [NotNul } // Default to override for Move/Copy - return @"<>\Override"; + return @"<>\Override"; } /// @@ -1066,29 +1094,29 @@ private static string InferDestination([NotNull] string fullUnit, Instruction.Ac // Check for destination keywords if (lower.Contains("to override") || lower.Contains("to your override") || lower.Contains("in override")) { - return @"<>\Override"; + return @"<>\Override"; } if (lower.Contains("to movies") || lower.Contains("in your movies") || lower.Contains("movies folder")) { - return @"<>\Movies"; + return @"<>\Movies"; } if (lower.Contains("main game directory") || lower.Contains("main directory") || lower.Contains("game directory")) { - return @"<>"; + return @"<>"; } // Patcher always uses kotorDirectory if (actionType == Instruction.ActionType.Patcher) { - return @"<>"; + return @"<>"; } // Default for Move/Copy if (actionType == Instruction.ActionType.Move || actionType == Instruction.ActionType.Copy) { - return @"<>\Override"; + return @"<>\Override"; } // Others don't need destination diff --git a/src/ModSync.Tests/GuideIngestionTests.cs b/src/ModSync.Tests/GuideIngestionTests.cs index ed1385d5..2ab51d2e 100644 --- a/src/ModSync.Tests/GuideIngestionTests.cs +++ b/src/ModSync.Tests/GuideIngestionTests.cs @@ -35,6 +35,18 @@ public sealed class GuideIngestionTests private const string PatcherProse = "Run the installer, then move the files from the patch to your override."; + private const string DeleteBeforeMoveProse = + "Make sure to delete LSI_win01.tpc and LSI_box01.tpc **before** moving to override."; + + private const string BeforeMovingDeleteProse = + "Before moving the files to the override folder, be sure to delete the following: PFBI01 through PFBI04, and PMBI01 through PMBI04."; + + private const string RerunPatcherProse = + "Install the main mod, then re-run the patcher and select the K1CP compatibility install option and install it as well, if using K1CP."; + + private const string MoveExceptProse = + "The file has the wrong readme; move all the files in the Creatures folder, except for the readme and Gizka.jpg (any .jpg/.png files are always previews and can be deleted), to the override."; + private const string MarkdownGuide = @"### Guide Ingestion Test Mod **Name:** [Guide Ingestion Test Mod](https://example.com/guide-ingestion-test-mod.zip) @@ -155,6 +167,72 @@ public void DraftInstructions_PatcherProse_ProducesSandboxedDrafts() } } + [Test] + public void DraftInstructions_DeleteBeforeMoveProse_ProducesSandboxedDelete() + { + ModComponent component = CreateComponent(DeleteBeforeMoveProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1), "Delete-before-move prose from mod-builds should draft"); + Assert.That(component.Instructions.Any(i => i.Action == Instruction.ActionType.Delete), Is.True, + "Prose that deletes files before moving should draft a Delete instruction"); + + foreach (Instruction instruction in component.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + + [Test] + public void DraftInstructions_BeforeMovingDeleteProse_ProducesSandboxedDelete() + { + ModComponent component = CreateComponent(BeforeMovingDeleteProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions.Any(i => i.Action == Instruction.ActionType.Delete), Is.True); + + foreach (Instruction instruction in component.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + + [Test] + public void DraftInstructions_RerunPatcherProse_ProducesSandboxedPatcher() + { + ModComponent component = CreateComponent(RerunPatcherProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions.Any(i => i.Action == Instruction.ActionType.Patcher), Is.True, + "Re-run patcher / compatibility option prose should draft a Patcher instruction"); + + foreach (Instruction instruction in component.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + + [Test] + public void DraftInstructions_MoveExceptProse_ProducesSandboxedMove() + { + ModComponent component = CreateComponent(MoveExceptProse); + + IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(new[] { component }); + + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(component.Instructions.Any(i => i.Action == Instruction.ActionType.Move), Is.True); + + foreach (Instruction instruction in component.Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + [Test] public void DraftInstructions_ComponentWithExistingInstructions_IsNeverTouched() { @@ -389,22 +467,36 @@ public void PasteCascade_MarkdownGuideString_DeserializesComponentWithDirections #region Real guide (mod-builds) - [Test] - public void RealGuide_K1FullMarkdown_DraftedInstructionsAreAllSandboxed() + [TestCase("k1", "full.md")] + [TestCase("k2", "full.md")] + [TestCase("k1", "spoiler-free.md")] + [TestCase("k2", "spoiler-free.md")] + [TestCase("k1", "full_mobile.md")] + public void RealGuide_ModBuildsMarkdown_DraftedInstructionsAreAllSandboxed(string gameFolder, string guideFile) { string repoRoot = ResolveRepoRoot(); - string markdownPath = Path.Combine(repoRoot, "mod-builds", "content", "k1", "full.md"); + string markdownPath = Path.Combine(repoRoot, "mod-builds", "content", gameFolder, guideFile); if (!File.Exists(markdownPath)) { Assert.Ignore($"mod-builds guide not found: {markdownPath}"); } - List components = FileLoadingService.LoadFromFile(markdownPath).ToList(); - Assert.That(components, Is.Not.Empty); + List components; + try + { + components = FileLoadingService.LoadFromFile(markdownPath).ToList(); + } + catch (InvalidDataException ex) + { + Assert.Ignore($"Guide is not a component markdown list ({gameFolder}/{guideFile}): {ex.Message}"); + return; + } + + Assert.That(components, Is.Not.Empty, $"Expected components from {gameFolder}/{guideFile}"); IReadOnlyList results = DraftInstructionService.GenerateDraftInstructions(components); - Assert.That(results, Is.Not.Empty, "Real guide prose should draft instructions for at least one component"); + Assert.That(results, Is.Not.Empty, $"Real guide prose ({gameFolder}/{guideFile}) should draft instructions for at least one component"); foreach (DraftInstructionResult result in results) { @@ -418,6 +510,28 @@ public void RealGuide_K1FullMarkdown_DraftedInstructionsAreAllSandboxed() #endregion + #region Guide emission (GenerateModDocumentation) + + [Test] + public void GenerateModDocumentation_AfterDraftingGuide_RoundTripsComponentNameAndDirections() + { + IReadOnlyList components = ModComponentSerializationService.DeserializeModComponentFromString(MarkdownGuide); + Assert.That(components, Has.Count.EqualTo(1)); + + DraftInstructionService.GenerateDraftInstructions(components); + + string emitted = ModComponentSerializationService.GenerateModDocumentation(components.ToList()); + Assert.That(emitted, Does.Contain("Guide Ingestion Test Mod")); + Assert.That(emitted, Does.Contain("Move everything from the Straight Fixes")); + + IReadOnlyList reparsed = ModComponentSerializationService.DeserializeModComponentFromString(emitted); + Assert.That(reparsed, Has.Count.EqualTo(1)); + Assert.That(reparsed[0].Name, Is.EqualTo("Guide Ingestion Test Mod")); + Assert.That(reparsed[0].Directions, Does.Contain("Move everything from the Straight Fixes")); + } + + #endregion + #region CLI parity (convert --stdin --parse-directions) [Test] @@ -469,6 +583,38 @@ public void CliConvert_StdinWithParseDirections_EmitsReviewFlaggedTomlWithDraftI } } + [Test] + public void CliConvert_FileInputWithParseDirections_EmitsReviewFlaggedToml() + { + string inputMd = Path.Combine(_testDirectory, "guide.md"); + string outputToml = Path.Combine(_testDirectory, "from-file.toml"); + File.WriteAllText(inputMd, MarkdownGuide); + + int exitCode = ModBuildConverter.Run(new[] + { + "convert", + "--input", inputMd, + "--parse-directions", + "-f", "toml", + "-o", outputToml, + "--plaintext", + }); + + Assert.That(exitCode, Is.EqualTo(0), "convert -i guide.md --parse-directions should succeed"); + Assert.That(File.Exists(outputToml), Is.True); + string tomlOutput = File.ReadAllText(outputToml); + Assert.That(tomlOutput, Does.Contain(DraftInstructionService.ReviewFlagMessage)); + + var reloaded = ModComponentSerializationService + .DeserializeModComponentFromString(tomlOutput, "toml") + .ToList(); + Assert.That(reloaded[0].Instructions, Is.Not.Empty); + foreach (Instruction instruction in reloaded[0].Instructions) + { + AssertInstructionIsSandboxed(instruction); + } + } + [Test] public void CliConvert_StdinCombinedWithInput_Fails() { @@ -512,6 +658,18 @@ private static string ResolveRepoRoot() } } + // Walk up from the test directory (covers git worktrees and nested bin layouts). + DirectoryInfo dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "ModSync.sln"))) + { + return dir.FullName; + } + + dir = dir.Parent; + } + throw new DirectoryNotFoundException("Could not locate repository root containing ModSync.sln"); } } From e2ddeb91b269a3c5a398ef868441c0dab83f03a0 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Jul 2026 23:42:54 -0500 Subject: [PATCH 16/19] fix: format FileLoadingService whitespace for CI --- .../Services/FileLoadingService.cs | 224 +++++++++--------- 1 file changed, 112 insertions(+), 112 deletions(-) diff --git a/src/ModSync.GUI/Services/FileLoadingService.cs b/src/ModSync.GUI/Services/FileLoadingService.cs index 8e1d0bea..e6630866 100644 --- a/src/ModSync.GUI/Services/FileLoadingService.cs +++ b/src/ModSync.GUI/Services/FileLoadingService.cs @@ -284,92 +284,92 @@ public async Task LoadMarkdownContentAsync( try { MarkdownParserResult parseResult = null; - MarkdownImportProfile configuredProfile; + MarkdownImportProfile configuredProfile; - if (editorMode) - { - // UI elements must be created and shown on the UI thread + if (editorMode) + { + // UI elements must be created and shown on the UI thread #pragma warning disable MA0004 // Use Task. - await Dispatcher.UIThread.InvokeAsync(async () => - { - var dialog = new RegexImportDialog(fileContents, profile ?? MarkdownImportProfile.CreateDefault()); + await Dispatcher.UIThread.InvokeAsync(async () => + { + var dialog = new RegexImportDialog(fileContents, profile ?? MarkdownImportProfile.CreateDefault()); - dialog.Closed += (_, __) => + dialog.Closed += (_, __) => + { + if (!dialog.LoadSuccessful || !(dialog.DataContext is RegexImportDialogViewModel vm)) { - if (!dialog.LoadSuccessful || !(dialog.DataContext is RegexImportDialogViewModel vm)) - { - return; - } + return; + } - configuredProfile = vm.ConfiguredProfile; - parseResult = vm.ConfirmLoad(); - ProcessModLinks(parseResult.Components); + configuredProfile = vm.ConfiguredProfile; + parseResult = vm.ConfirmLoad(); + ProcessModLinks(parseResult.Components); - // Log asynchronously without blocking the UI - _ = Task.Run(async () => - { + // Log asynchronously without blocking the UI + _ = Task.Run(async () => + { #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Markdown parsing completed using {(configuredProfile.Mode == RegexMode.Raw ? "raw" : "individual")} regex mode."); + await Logger.LogAsync($"Markdown parsing completed using {(configuredProfile.Mode == RegexMode.Raw ? "raw" : "individual")} regex mode."); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Found {parseResult.Components?.Count ?? 0} components with {parseResult.Components?.Sum(c => c.ResourceRegistry.Count) ?? 0} total links."); + await Logger.LogAsync($"Found {parseResult.Components?.Count ?? 0} components with {parseResult.Components?.Sum(c => c.ResourceRegistry.Count) ?? 0} total links."); #pragma warning restore MA0004 // Use Task. - if (parseResult.Warnings?.Count > 0) - { + if (parseResult.Warnings?.Count > 0) + { #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($"Markdown parsing completed with {parseResult.Warnings.Count} warnings."); + await Logger.LogWarningAsync($"Markdown parsing completed with {parseResult.Warnings.Count} warnings."); #pragma warning restore MA0004 // Use Task. - foreach (string warning in parseResult.Warnings) - { + foreach (string warning in parseResult.Warnings) + { #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($" - {warning}"); - } -#pragma warning restore MA0004 // Use Task. + await Logger.LogWarningAsync($" - {warning}"); } - }); - }; +#pragma warning restore MA0004 // Use Task. + } + }); + }; #pragma warning disable MA0004 // Use Task. - await dialog.ShowDialog(_parentWindow); + await dialog.ShowDialog(_parentWindow); #pragma warning restore MA0004 // Use Task. - }); + }); #pragma warning restore MA0004 // Use Task. - if (parseResult is null) - { - return false; - } - } - else + if (parseResult is null) { - configuredProfile = profile ?? MarkdownImportProfile.CreateDefault(); - var parser = new MarkdownParser(configuredProfile, - logInfo => Logger.Log(logInfo), - logVerbose => Logger.LogVerbose(logVerbose)); - parseResult = parser.Parse(fileContents); + return false; + } + } + else + { + configuredProfile = profile ?? MarkdownImportProfile.CreateDefault(); + var parser = new MarkdownParser(configuredProfile, + logInfo => Logger.Log(logInfo), + logVerbose => Logger.LogVerbose(logVerbose)); + parseResult = parser.Parse(fileContents); - ProcessModLinks(parseResult.Components); + ProcessModLinks(parseResult.Components); #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync("Markdown parsing completed using default profile."); + await Logger.LogAsync("Markdown parsing completed using default profile."); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Found {parseResult.Components?.Count ?? 0} components with {parseResult.Components?.Sum(c => c.ResourceRegistry.Count) ?? 0} total links."); + await Logger.LogAsync($"Found {parseResult.Components?.Count ?? 0} components with {parseResult.Components?.Sum(c => c.ResourceRegistry.Count) ?? 0} total links."); #pragma warning restore MA0004 // Use Task. - if (parseResult.Warnings?.Count > 0) - { + if (parseResult.Warnings?.Count > 0) + { #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($"Markdown parsing completed with {parseResult.Warnings.Count} warnings."); + await Logger.LogWarningAsync($"Markdown parsing completed with {parseResult.Warnings.Count} warnings."); #pragma warning restore MA0004 // Use Task. - foreach (string warning in parseResult.Warnings) - { + foreach (string warning in parseResult.Warnings) + { #pragma warning disable MA0004 // Use Task. - await Logger.LogWarningAsync($" - {warning}"); - } -#pragma warning restore MA0004 // Use Task. + await Logger.LogWarningAsync($" - {warning}"); } +#pragma warning restore MA0004 // Use Task. } + } if (draftInstructionsFromProse && parseResult.Components != null) @@ -379,87 +379,87 @@ await Dispatcher.UIThread.InvokeAsync(async () => #pragma warning restore MA0004 // Use Task. } - _mainConfig.preambleContent = parseResult.PreambleContent ?? string.Empty; - _mainConfig.epilogueContent = parseResult.EpilogueContent ?? string.Empty; - _mainConfig.widescreenWarningContent = parseResult.WidescreenWarningContent ?? string.Empty; - _mainConfig.aspyrExclusiveWarningContent = parseResult.AspyrExclusiveWarningContent ?? string.Empty; + _mainConfig.preambleContent = parseResult.PreambleContent ?? string.Empty; + _mainConfig.epilogueContent = parseResult.EpilogueContent ?? string.Empty; + _mainConfig.widescreenWarningContent = parseResult.WidescreenWarningContent ?? string.Empty; + _mainConfig.aspyrExclusiveWarningContent = parseResult.AspyrExclusiveWarningContent ?? string.Empty; #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Stored {_mainConfig.preambleContent.Length} characters in preamble and {_mainConfig.epilogueContent.Length} characters in epilogue."); + await Logger.LogAsync($"Stored {_mainConfig.preambleContent.Length} characters in preamble and {_mainConfig.epilogueContent.Length} characters in epilogue."); #pragma warning restore MA0004 // Use Task. - if (_mainConfig.allComponents.Count == 0) - { - _mainConfig.allComponents = new List( - parseResult.Components - ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") - ); + if (_mainConfig.allComponents.Count == 0) + { + _mainConfig.allComponents = new List( + parseResult.Components + ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") + ); #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Loaded {parseResult.Components.Count} components from markdown."); + await Logger.LogAsync($"Loaded {parseResult.Components.Count} components from markdown."); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await tryAutoGenerate(parseResult.Components.ToList()); + await tryAutoGenerate(parseResult.Components.ToList()); #pragma warning restore MA0004 // Use Task. - } - else - { + } + else + { #pragma warning disable MA0004 // Use Task. - bool? confirmResult = await ShowConfigLoadConfirmationAsync("markdown file", editorMode); + bool? confirmResult = await ShowConfigLoadConfirmationAsync("markdown file", editorMode); #pragma warning restore MA0004 // Use Task. - if (confirmResult == true) + if (confirmResult == true) + { + // Create the dialog on the UI thread + ComponentMergeConflictDialog conflictDialog = await Dispatcher.UIThread.InvokeAsync(() => new ComponentMergeConflictDialog( + _mainConfig.allComponents, + new List( + parseResult.Components + ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") + ), + "Currently Loaded Components", + "Markdown File", + FuzzyMatcher.FuzzyMatchComponents)); + + // Show the dialog on the UI thread as well + await Dispatcher.UIThread.InvokeAsync(() => conflictDialog.ShowDialog(_parentWindow)); + + if (conflictDialog.UserConfirmed && conflictDialog.MergedComponents != null) { - // Create the dialog on the UI thread - ComponentMergeConflictDialog conflictDialog = await Dispatcher.UIThread.InvokeAsync(() => new ComponentMergeConflictDialog( - _mainConfig.allComponents, - new List( - parseResult.Components - ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") - ), - "Currently Loaded Components", - "Markdown File", - FuzzyMatcher.FuzzyMatchComponents)); - - // Show the dialog on the UI thread as well - await Dispatcher.UIThread.InvokeAsync(() => conflictDialog.ShowDialog(_parentWindow)); - - if (conflictDialog.UserConfirmed && conflictDialog.MergedComponents != null) - { - int originalCount = _mainConfig.allComponents.Count; - _mainConfig.allComponents = conflictDialog.MergedComponents; - int newCount = _mainConfig.allComponents.Count; + int originalCount = _mainConfig.allComponents.Count; + _mainConfig.allComponents = conflictDialog.MergedComponents; + int newCount = _mainConfig.allComponents.Count; #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Merged {parseResult.Components.Count} parsed components with existing {originalCount} components. Total components now: {newCount}"); + await Logger.LogAsync($"Merged {parseResult.Components.Count} parsed components with existing {originalCount} components. Total components now: {newCount}"); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await tryAutoGenerate(_mainConfig.allComponents); + await tryAutoGenerate(_mainConfig.allComponents); #pragma warning restore MA0004 // Use Task. - } - else - { + } + else + { #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync("Merge cancelled by user."); + await Logger.LogAsync("Merge cancelled by user."); #pragma warning restore MA0004 // Use Task. - return false; - } + return false; } - else if (confirmResult == false) - { - _mainConfig.allComponents = new List( - parseResult.Components - ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") - ); + } + else if (confirmResult == false) + { + _mainConfig.allComponents = new List( + parseResult.Components + ?? throw new InvalidOperationException("[LoadMarkdownFileAsync] parseResult.Components is null") + ); #pragma warning disable MA0004 // Use Task. - await Logger.LogAsync($"Overwrote existing config with {parseResult.Components.Count} components from markdown."); + await Logger.LogAsync($"Overwrote existing config with {parseResult.Components.Count} components from markdown."); #pragma warning restore MA0004 // Use Task. #pragma warning disable MA0004 // Use Task. - await tryAutoGenerate(parseResult.Components.ToList()); + await tryAutoGenerate(parseResult.Components.ToList()); #pragma warning restore MA0004 // Use Task. - } - else - { - return false; - } } + else + { + return false; + } + } #pragma warning disable MA0004 // Use Task. await onComponentsLoaded(); From 8550599919ddb46e4451bc5b1a16c14f2d14c975 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Jul 2026 23:49:01 -0500 Subject: [PATCH 17/19] chore: re-trigger CI after FileLoadingService format fix From f49f2bac8d012ba124cdc1a0103338916715cd59 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Jul 2026 00:13:38 -0500 Subject: [PATCH 18/19] docs(kb): align modsync Phase 1 status after master merge Correct product-overview snapshot and protocol-handler property/plan pointers to match ModSyncProtocolUrl and plan 006 from master. --- docs/knowledgebase/modsync-protocol-handler.md | 4 ++-- docs/knowledgebase/product-overview.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/knowledgebase/modsync-protocol-handler.md b/docs/knowledgebase/modsync-protocol-handler.md index 7d9cfcd7..a5dfddea 100644 --- a/docs/knowledgebase/modsync-protocol-handler.md +++ b/docs/knowledgebase/modsync-protocol-handler.md @@ -2,7 +2,7 @@ `[REPO]` How an "Install with ModSync" deep link reaches the app: URL parsing, CLI launch args, and single-instance hand-off. OS scheme registration is planned but not shipped in this slice. -Sources: `src/ModSync.Core/Services/Protocol/ModSyncUrl.cs`, `src/ModSync.GUI/CLIArguments.cs`, `src/ModSync.GUI/Program.cs`, `src/ModSync.GUI/Services/SingleInstanceService.cs`, `src/ModSync.GUI/Services/ModSyncHandoffQueue.cs`, `src/ModSync.GUI/Services/ApplicationLaunchCoordinator.cs`. Requirements: [docs/brainstorms/2026-07-13-modsync-protocol-requirements.md](../brainstorms/2026-07-13-modsync-protocol-requirements.md). Follow-up: [docs/plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md](../plans/2026-07-13-002-feat-modsync-protocol-os-registration-plan.md). Related shipped pattern: [nxm-protocol-handler.md](nxm-protocol-handler.md). +Sources: `src/ModSync.Core/Services/Protocol/ModSyncUrl.cs`, `src/ModSync.GUI/CLIArguments.cs`, `src/ModSync.GUI/Program.cs`, `src/ModSync.GUI/Services/SingleInstanceService.cs`, `src/ModSync.GUI/Services/ModSyncHandoffQueue.cs`, `src/ModSync.GUI/Services/ApplicationLaunchCoordinator.cs`. Requirements: [docs/brainstorms/2026-07-13-modsync-protocol-requirements.md](../brainstorms/2026-07-13-modsync-protocol-requirements.md). Follow-up: [docs/plans/2026-07-13-006-feat-modsync-protocol-os-registration-plan.md](../plans/2026-07-13-006-feat-modsync-protocol-os-registration-plan.md). Related shipped pattern: [nxm-protocol-handler.md](nxm-protocol-handler.md). ## URL model @@ -29,7 +29,7 @@ Rules: | Stage | Component | Behavior | |-------|-----------|----------| | Parse | `ModSyncUrl` | `IsModSyncUrl` / `TryParse` in Core | -| Launch | `CLIArguments` | `--modsync=` or bare positional `modsync://...` sets `CLIArguments.ModSyncUrl` | +| Launch | `CLIArguments` | `--modsync=` or bare positional `modsync://...` sets `CLIArguments.ModSyncProtocolUrl` | | Single instance | `SingleInstanceService` + `ApplicationLaunchCoordinator` | Same named-pipe primary/secondary model as nxm; secondary with a protocol URL uses `ForwardProtocolUrlAndExit` | | Buffering | `ModSyncHandoffQueue` | Thread-safe queue + `UrlEnqueued` for URLs that arrive before the main window is ready | diff --git a/docs/knowledgebase/product-overview.md b/docs/knowledgebase/product-overview.md index 6446c5fb..397b1737 100644 --- a/docs/knowledgebase/product-overview.md +++ b/docs/knowledgebase/product-overview.md @@ -67,7 +67,7 @@ KOTOR modding often requires dozens of repetitive, order-sensitive steps per mod | FOMOD CLI post-download configure | Shipped (TTY wizard, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES`, `--fomod-skip`) | | FOMOD configure-before-validate/install gate | Shipped (`FomodConfigurationGate`; fail-closed on unreadable archives) | | `nxm://` entry point | Shipped | -| `modsync://` / publish-share | Not shipped — see [product-vision.md](product-vision.md) | +| `modsync://` Phase 1 (parse/CLI/handoff) | Shipped — OS registration + GUI consume still open; see [product-vision.md](product-vision.md) | ## Related From 55fffae511c47724e18622d205cf1d83e3242737 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Jul 2026 00:21:14 -0500 Subject: [PATCH 19/19] docs(kb): mark modsync Phase 2 shipped after #176 merge Align product-overview, product-vision, and KB index with consume and OS registration landed via the parity foundation merge. --- docs/knowledgebase/README.md | 4 ++-- docs/knowledgebase/product-overview.md | 2 +- docs/knowledgebase/product-vision.md | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/knowledgebase/README.md b/docs/knowledgebase/README.md index 8a218e44..44f13587 100644 --- a/docs/knowledgebase/README.md +++ b/docs/knowledgebase/README.md @@ -59,7 +59,7 @@ Use these when citing findings in plans, PRs, or audits: - [File-level conflicts](file-conflicts.md) — `FileConflictAnalyzer` dry-run VFS attribution and `ConflictsDialog` results UI - [FOMOD installer support](fomod-support.md) — ModuleConfig parser, GUI/CLI post-download configure, `--fomod-choices`, configured-only gate - [nxm protocol handler](nxm-protocol-handler.md) — nxm:// URL parsing, OS scheme registration, single-instance hand-off, free-user download path -- [modsync protocol handler](modsync-protocol-handler.md) — modsync:// build deep links (parse + CLI + handoff; OS registration planned) +- [modsync protocol handler](modsync-protocol-handler.md) — modsync:// build deep links (parse + CLI + handoff + consume; OS registration shipped, Settings toggle deferred) ### Architecture and agent parity @@ -142,7 +142,7 @@ KB routing: [rebrand-legacy-strings.md](rebrand-legacy-strings.md), [gui-archite | Headless Avalonia GUI smoke | Shipped | `GuiSmokeHeadlessTests`; `AGENTS.md` §Headless Avalonia GUI smoke | | FOMOD discovery + CLI prompts + gate | Shipped | [fomod-support.md](fomod-support.md); fail-closed until configured; Plan [123](../plans/2026-06-14-123-feat-fomod-cli-download-prompts-plan.md) | | `modsync://` Phase 1 (parse/CLI/handoff) | Shipped | [modsync-protocol-handler.md](modsync-protocol-handler.md) | -| `modsync://` Phase 2 (OS reg + consume) | Open / in flight | [brainstorm](../brainstorms/2026-07-13-modsync-protocol-requirements.md); [plan 006](../plans/2026-07-13-006-feat-modsync-protocol-os-registration-plan.md); PR #176 | +| `modsync://` Phase 2 (OS reg + consume) | Shipped (Settings toggle deferred) | [modsync-protocol-handler.md](modsync-protocol-handler.md); #176 | | Multi-author publish/share | Open | [plan stub 003](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md) | | Release readiness checklist | Active | [005](../plans/2026-07-13-005-release-readiness-checklist.md) | diff --git a/docs/knowledgebase/product-overview.md b/docs/knowledgebase/product-overview.md index 397b1737..fbab176f 100644 --- a/docs/knowledgebase/product-overview.md +++ b/docs/knowledgebase/product-overview.md @@ -67,7 +67,7 @@ KOTOR modding often requires dozens of repetitive, order-sensitive steps per mod | FOMOD CLI post-download configure | Shipped (TTY wizard, `--fomod-choices` / `MODSYNC_FOMOD_CHOICES`, `--fomod-skip`) | | FOMOD configure-before-validate/install gate | Shipped (`FomodConfigurationGate`; fail-closed on unreadable archives) | | `nxm://` entry point | Shipped | -| `modsync://` Phase 1 (parse/CLI/handoff) | Shipped — OS registration + GUI consume still open; see [product-vision.md](product-vision.md) | +| `modsync://` (parse/CLI/handoff/consume + OS registration) | Shipped — Settings checkbox deferred; see [modsync-protocol-handler.md](modsync-protocol-handler.md) | ## Related diff --git a/docs/knowledgebase/product-vision.md b/docs/knowledgebase/product-vision.md index 4b7a33f1..27205160 100644 --- a/docs/knowledgebase/product-vision.md +++ b/docs/knowledgebase/product-vision.md @@ -30,17 +30,17 @@ Row numbers map each capability from [The full vision](#the-full-vision) above; | 1a. Guide import (markdown → components) | **Shipped.** `MarkdownParser` parses Deadly Stream-style guides into components, prose preserved in `Directions`; verified round-trippable | `[REPO]` `src/ModSync.Core/Parsing/MarkdownParser.cs`, `DocumentationRoundTripTests` | | 1b. Paste-a-guide ingestion | **Shipped.** Getting Started **Import from Clipboard** + `FileLoadingService.ImportFromTextAsync`; CLI `convert --stdin` | `[REPO]` [guide-ingestion.md](guide-ingestion.md), `GuideIngestionTests`, `GuiSmokeHeadlessTests` | | 1c. Prose → executable instructions | **Shipped.** `DraftInstructionService` wires `NaturalLanguageInstructionParser`; drafts review-flagged, never auto-trusted | `[REPO]` `DraftInstructionService.cs`, `convert --parse-directions`, [guide-ingestion.md](guide-ingestion.md) | -| 2. "Install with ModSync" entry points | **Partial.** `nxm://` shipped; `modsync://` Phase 1 (parse + CLI + handoff) shipped; OS registration + MainWindow consume in flight on PR #176 | `[REPO]` [modsync-protocol-handler.md](modsync-protocol-handler.md); [brainstorm](../brainstorms/2026-07-13-modsync-protocol-requirements.md) | +| 2. "Install with ModSync" entry points | **Shipped (Settings toggle deferred).** `nxm://` + `modsync://` parse/CLI/handoff/consume + OS registration | `[REPO]` [modsync-protocol-handler.md](modsync-protocol-handler.md); `ModSyncHandoffService`, `ModSyncProtocolRegistrationService` | | 3. Guide emission (components → guide) | **Shipped.** `GenerateModDocumentation` in `ModComponentSerializationService` | `[REPO]` `src/ModSync.Core/Services/ModComponentSerializationService.cs` | | 4. Multi-author builds | **Partial.** Merge tooling (`merge` CLI) and install profiles exist; publish/share flows do not | `[REPO]` merge/profiles; [plan stub](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md) | -`[SYNTH]` Net: guide round-trip (import + paste + draft instructions + emit) is shipped. Active gaps are finishing `modsync://` consume/registration and multi-author publish/share. +`[SYNTH]` Net: guide round-trip (import + paste + draft instructions + emit) and `modsync://` entry points are shipped. Active gap is multi-author publish/share (and optional Settings toggle for protocol registration). ## Where the work is tracked - Strategy and tracks: [`STRATEGY.md`](../../STRATEGY.md) (repo root) - Guide paste (done): [docs/brainstorms/2026-07-13-guide-paste-ingestion-requirements.md](../brainstorms/2026-07-13-guide-paste-ingestion-requirements.md), [plan 001](../plans/2026-07-13-001-feat-guide-paste-ingestion-plan.md) -- `modsync://` Phase 2 (open): [brainstorm](../brainstorms/2026-07-13-modsync-protocol-requirements.md), [plan 006](../plans/2026-07-13-006-feat-modsync-protocol-os-registration-plan.md) +- `modsync://` (shipped; Settings toggle deferred): [modsync-protocol-handler.md](modsync-protocol-handler.md), [plan 006](../plans/2026-07-13-006-feat-modsync-protocol-os-registration-plan.md) - Multi-author publish/share (open): [plan stub 003](../plans/2026-07-13-003-feat-multi-author-publish-share-plan.md) - Release readiness quadruple-check: [plan 005](../plans/2026-07-13-005-release-readiness-checklist.md) @@ -49,7 +49,7 @@ Row numbers map each capability from [The full vision](#the-full-vision) above; - [product-overview.md](product-overview.md) — what ModSync is today - [guide-ingestion.md](guide-ingestion.md) — paste/draft CLI and GUI paths - [nxm-protocol-handler.md](nxm-protocol-handler.md) — shipped Nexus entry-point track -- [modsync-protocol-handler.md](modsync-protocol-handler.md) — build deep links (Phase 1 shipped) +- [modsync-protocol-handler.md](modsync-protocol-handler.md) — build deep links (consume + OS registration shipped) - [install-profiles.md](install-profiles.md) — multi-build loadouts - [fomod-support.md](fomod-support.md) — FOMOD discovery, CLI prompts, configured-only gate - [agent-action-parity.md](agent-action-parity.md) — GUI vs CLI coverage of these flows