diff --git a/docs/knowledgebase/agent-action-parity.md b/docs/knowledgebase/agent-action-parity.md index 046277b7..05ca58f8 100644 --- a/docs/knowledgebase/agent-action-parity.md +++ b/docs/knowledgebase/agent-action-parity.md @@ -1,6 +1,6 @@ # Agent action parity -`[REPO]` Maps user-visible flows to headless agent capabilities. Label key: **Full** = achievable without desktop; **Partial** = CLI/script with gaps; **UI** = desktop session required; **N/A** = out of scope. Last light refresh: **2026-07-17** (see [agent-native-audit.md](agent-native-audit.md)). +`[REPO]` Maps user-visible flows to headless agent capabilities. Label key: **Full** = achievable without desktop; **Partial** = CLI/script with gaps; **UI** = desktop session required; **N/A** = out of scope. Last light refresh: **2026-07-18** (profile CLI verb). ## Install wizard (primary flow) @@ -18,7 +18,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | 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 | -| 10b | Managed deploy | Opt-in hardlink deploy via active profile | `install --managed` / `--no-managed` / `--profile` (#177); settings toggle still GUI | Full (CLI overrides); Partial (profile CRUD `[UI]`) — [managed-deployment.md](managed-deployment.md) | +| 10b | Managed deploy | Opt-in hardlink deploy via active profile | `install --managed` / `--no-managed` / `--profile` (#177); `profile --action activate`; settings toggle still GUI | Full (CLI overrides + profile CRUD) — [managed-deployment.md](managed-deployment.md) | | 11 | `InstallingPage` | Watch progress | `install` (console progress) | Full — see [install-lifecycle.md](install-lifecycle.md) | | 12 | `BaseInstallCompletePage` | Continue | N/A | Full | | 13+ | Widescreen pages | `WidescreenNoticePage`, `WidescreenModSelectionPage`, `WidescreenInstallingPage`, `WidescreenCompletePage` (dynamic) | No dedicated CLI | UI | @@ -36,7 +36,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | `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 | -| Profiles… (`ProfileManagerDialog`) | Core `ProfileService` files under `{settingsDir}/profiles/`; `install --profile` selects existing; no CLI CRUD | Partial — [install-profiles.md](install-profiles.md) | +| Profiles… (`ProfileManagerDialog`) | `profile --action list|show|create|delete|activate|clone|rename` (+ `--settings-dir`, `--json`); `install --profile` selects existing | Full — [install-profiles.md](install-profiles.md) | | `modsync://` deep link | `--modsync=` or bare URI → handoff / fetch | Full (consume); Settings toggle deferred — [modsync-protocol-handler.md](modsync-protocol-handler.md) | ## Common agent workflows @@ -47,7 +47,8 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | 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) | | Open `modsync://` instruction URL | Launch GUI with `--modsync=` / URI, or rely on OS handler after registration — [modsync-protocol-handler.md](modsync-protocol-handler.md) | -| Managed install with profile | `install … --managed --profile ` (or `--no-managed`) — [managed-deployment.md](managed-deployment.md) | +| Managed install with profile | `profile --action activate --name ` then `install … --managed --profile ` (or `--no-managed`) — [managed-deployment.md](managed-deployment.md) | +| Create/list install profiles | `profile --action create|list|delete|clone|rename` — [core-cli-reference.md](core-cli-reference.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) | @@ -65,6 +66,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG | 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 | +| Profile CLI | `ProfileCliTests` | `profile` verb CRUD + activate persists `activeProfileName` | | 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 | @@ -84,7 +86,7 @@ Wizard order from `src/ModSync.GUI/Dialogs/InstallWizardDialog.axaml.cs` and `AG 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: 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). -11. **Managed / profile CLI** — `install --managed` / `--no-managed` / `--profile` shipped (#177). Profile create/list/delete remains `[UI]` / file edits under `{settingsDir}/profiles/`. See [managed-deployment.md](managed-deployment.md). +11. **Managed / profile CLI** — `install --managed` / `--no-managed` / `--profile` shipped (#177). Profile CRUD via `profile` verb (`list|show|create|delete|activate|clone|rename`). See [managed-deployment.md](managed-deployment.md) and [core-cli-reference.md](core-cli-reference.md). 12. **Validation presentation vs pipeline** — CLI and GUI share `InstallationValidationPipeline`; stage-card UX / copy-report / go-to-first-issue remain `[UI]` ([gui-validation-surfaces.md](gui-validation-surfaces.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 57b0948d..ca1b1e53 100644 --- a/docs/knowledgebase/agent-native-audit.md +++ b/docs/knowledgebase/agent-native-audit.md @@ -11,14 +11,14 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | Principles scored | 8 / 8 | | Weighted average | **73%** | | Headless agent readiness | Strong for Core CLI + tests + guide ingest + FOMOD gate + managed CLI overrides | -| Desktop-only gap | Widescreen/Aspyr, download status UI, profile CRUD UI, ValidatePage presentation polish | +| Desktop-only gap | Widescreen/Aspyr, download status UI, ValidatePage presentation polish | ## Capability snapshot (2026-07-17) | Area | Status | Agent path | Notes | |------|--------|------------|-------| | Managed install (engine + wizard wiring) | Shipped | Settings `managedDeploymentEnabled` + active profile → `ManagedInstallSession` | See [managed-deployment.md](managed-deployment.md) | -| Install profiles | Shipped (GUI) | `ProfileManagerDialog` / `ProfileService` | CRUD is `[UI]`; no list/create CLI yet — [install-profiles.md](install-profiles.md) | +| Install profiles | Shipped | `profile` CLI + `ProfileManagerDialog` / `ProfileService` | CRUD + activate via CLI — [install-profiles.md](install-profiles.md) | | CLI `install --managed` / `--no-managed` / `--profile` | Shipped (#177) | `install --managed --profile ` / `--no-managed` | Fail-closed without resolvable profile — [core-cli-reference.md](core-cli-reference.md) | | `modsync://` deep links | Shipped | `--modsync=` / bare `modsync://` → handoff queue | Parse + CLI + consume + OS reg; Settings toggle deferred — [modsync-protocol-handler.md](modsync-protocol-handler.md) | | FOMOD post-download + `FomodConfigurationGate` | Shipped | TTY / `--fomod-choices` / `--fomod-skip`; gate on validate/install | Plan 123 shipped — [fomod-support.md](fomod-support.md) | @@ -30,7 +30,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | # | Principle | Score | Summary | |---|-----------|-------|---------| -| 1 | **Parity** | 20/25 (80%) | FOMOD CLI + gate, guide ingest, and managed `--managed`/`--profile` (#177) closed major gaps; profile CRUD still GUI-only. | +| 1 | **Parity** | 20/25 (80%) | FOMOD CLI + gate, guide ingest, managed `--managed`/`--profile` (#177), and `profile` CRUD verb closed major gaps. | | 2 | **Granularity** | 16/20 (80%) | CLI verbs are composable; scripts wrap common combos without hiding primitives. | | 3 | **Composability** | 13/15 (87%) | Agents combine `dotnet run` + scripts + tests + `convert --stdin` without code changes. | | 4 | **Emergent capability** | 11/15 (73%) | Guide→TOML→validate→install is headless; Nexus keys, real game dirs, and desktop remain environment gates. | @@ -56,8 +56,8 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | Post-download FOMOD configure | GUI after Fetch Downloads | Yes — CLI TTY / `--fomod-choices` / `--fomod-skip` | | FOMOD configure-before-validate/install gate | GUI + Core `FomodConfigurationGate` | Yes — shared fail-closed gate | | Install mods (classic) | Wizard or `install` | Yes | -| Managed hardlink deploy | Settings + active profile; CLI `--managed` / `--no-managed` / `--profile` (#177) | Yes (install overrides); profile CRUD still GUI | -| Profile save/activate/CRUD | `ProfileManagerDialog` | Partial — Core `ProfileService` exists; no CLI list/create/delete verbs | +| Managed hardlink deploy | Settings + active profile; CLI `--managed` / `--no-managed` / `--profile` (#177) | Yes (install overrides + `profile activate`) | +| Profile save/activate/CRUD | `ProfileManagerDialog` or `profile --action …` | Yes — Core `ProfileService`; CLI list/create/delete/clone/rename/activate | | Mod selection / filters | `ModSelectionPage` UI | Partial — CLI `--select` / `--use-file-selection` | | Widescreen-only install block | Dynamic wizard pages | No — desktop only | | Aspyr notice (K2) | `AspyrNoticePage` | No — desktop only | @@ -67,7 +67,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec **Strengths:** `[REPO]` `ModBuildConverter` covers validate/install/convert/merge; FOMOD Plan 123 + gate; guide paste (#171); `modsync://` Phase 1–2; managed CLI overrides (#177); `install_best_effort.sh` documents a full-build-style headless path. -**Gaps:** `[OPEN]` Profile CRUD and download status remain GUI-centric. Widescreen/Aspyr are `[UI]` only. Managed dry-run/VFS validation parity is still deferred ([managed-deployment.md](managed-deployment.md)). +**Gaps:** `[OPEN]` Download status remains GUI-centric. Widescreen/Aspyr are `[UI]` only. Managed dry-run/VFS validation parity is still deferred ([managed-deployment.md](managed-deployment.md)). **Recommendations (Tier 1):** Keep [agent-action-parity.md](agent-action-parity.md) and [core-cli-reference.md](core-cli-reference.md) current when wizard pages or install flags change. @@ -77,7 +77,7 @@ This product is a **desktop mod installer**, not a web agent host. Scores reflec | Tool layer | Examples | |------------|----------| -| Atomic CLI verbs | `validate`, `install`, `convert`, `merge`, `holopatcher` | +| Atomic CLI verbs | `validate`, `install`, `convert`, `merge`, `profile`, `holopatcher` | | Composition | `install -d --concurrent --best-effort -y`; `convert --stdin --parse-directions` | | Scripts | Thin wrappers (`cli_validate.sh`, `run_headless_tests.sh`, `cli_full_build_pipeline.sh`) | @@ -97,7 +97,7 @@ Agents can assemble workflows from: - `mod-builds` TOMLs / markdown guides at repo root - `modsync://` URLs for instruction fetch/handoff -**Gaps:** No MCP tools inside the app; MCP wrappers in `scripts/agents/mcp_*.sh` are optional IDE tooling, not product features. Profile CRUD still requires GUI or direct JSON under `{settingsDir}/profiles/`. +**Gaps:** No MCP tools inside the app; MCP wrappers in `scripts/agents/mcp_*.sh` are optional IDE tooling, not product features. --- @@ -152,11 +152,10 @@ Files are the interface: instruction TOMLs, ingested drafts under `tmp/`, `tmp/k ## Top agent-native gaps (2026-07-17) -1. **Profile CRUD** — save/activate/clone/rename/delete only via `ProfileManagerDialog` `[UI]`; no `profile` CLI verb (install can select an existing profile via `--profile`). -2. **Download status / stop** — CLI can download; live progress and stop controls are `[UI]` only. -3. **Widescreen + Aspyr flows** — K2 wizard-only pages; no CLI equivalent. -4. **Managed dry-run / VFS validation parity** — deferred `[OPEN]` in [managed-deployment.md](managed-deployment.md); classic VFS validate does not fully model managed staging/deploy. -5. **Validation presentation / machine output** — stage-card UX / copy-report / go-to-first-issue remain `[UI]`; no structured JSON validation report for agents. +1. **Download status / stop** — CLI can download; live progress and stop controls are `[UI]` only. +2. **Widescreen + Aspyr flows** — K2 wizard-only pages; no CLI equivalent. +3. **Managed dry-run / VFS validation parity** — deferred `[OPEN]` in [managed-deployment.md](managed-deployment.md); classic VFS validate does not fully model managed staging/deploy. +4. **Validation presentation / machine output** — stage-card UX / copy-report / go-to-first-issue remain `[UI]`; no structured JSON validation report for agents. Honorable mentions: OS clipboard paste still `[UI]` (stdin/file ingest covers agents); `modsync://` Settings checkbox deferred. @@ -168,7 +167,7 @@ Honorable mentions: OS clipboard paste still `[UI]` (stdin/file ingest covers ag 2. **Tier 1:** Treat GUI-only flows as `[UI]` in plans; check [agent-action-parity.md](agent-action-parity.md) before assuming headless parity. 3. **Tier 2:** When adding wizard steps, add a CLI or script path—or document the gap in the parity matrix. 4. **Tier 2:** Close managed dry-run/VFS validation parity ([plan 004](../plans/2026-07-13-004-managed-deployment-validation-plan.md)). -5. **Tier 3:** Optional: structured JSON validation output; CLI profile list/create/activate verbs. +5. **Tier 3:** Optional: structured JSON validation output. ## Strengths summary diff --git a/docs/knowledgebase/core-cli-reference.md b/docs/knowledgebase/core-cli-reference.md index 589b537f..b0d883ca 100644 --- a/docs/knowledgebase/core-cli-reference.md +++ b/docs/knowledgebase/core-cli-reference.md @@ -183,6 +183,38 @@ dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "Name~AutoGenerateLo --- +### `profile` + +Manage install profiles under `{settingsDir}/profiles/` via `ProfileService` (same storage as `ProfileManagerDialog`). + +| Flag | Required | Description | +|------|----------|-------------| +| `-a` / `--action` | Yes | `list`, `show`, `create`, `delete`, `activate`, `clone`, `rename` | +| `-n` / `--name` | For create/delete/activate/show | Profile name | +| `--from` | For clone/rename | Source profile name (or use `--name` as source) | +| `--to` | For clone/rename | Target profile name | +| `--settings-dir` | No | ModSync settings directory (default: platform AppData / `~/.config/ModSync`) | +| `--json` | No | JSON output for `list` and `show` | + +**Examples:** + +```bash +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + profile --action create --name "K2 Full Build" --settings-dir ./tmp/modsync_settings + +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + profile --action activate --name "K2 Full Build" --settings-dir ./tmp/modsync_settings + +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + profile --action list --json --settings-dir ./tmp/modsync_settings +``` + +`activate` writes `activeProfileName` to `settings.json` (same field used by managed deploy). See [install-profiles.md](install-profiles.md). + +**Tests:** `dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~ProfileCliTests"` + +--- + ### `set-nexus-api-key` Store and optionally validate a Nexus Mods API key. diff --git a/docs/knowledgebase/install-profiles.md b/docs/knowledgebase/install-profiles.md index 5584baa7..91d2373d 100644 --- a/docs/knowledgebase/install-profiles.md +++ b/docs/knowledgebase/install-profiles.md @@ -31,6 +31,23 @@ directories into the existing static `MainConfig` via its instance accessors untouched. The stored `InstructionFilePath` is informational; activation does not auto-load the instruction file. +## CLI + +Headless agents use the `profile` verb on `ModBuildConverter` (same `ProfileService` storage as the GUI): + +```bash +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + profile --action list --settings-dir ./tmp/modsync_settings + +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + profile --action create --name "My Build" --settings-dir ./tmp/modsync_settings + +dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \ + profile --action activate --name "My Build" --settings-dir ./tmp/modsync_settings +``` + +Actions: `list`, `show`, `create`, `delete`, `activate`, `clone`, `rename`. Use `--json` on `list`/`show` for machine-readable output. See [core-cli-reference.md](core-cli-reference.md). + ## GUI `src/ModSync.GUI/Dialogs/ProfileManagerDialog.axaml(.cs)` lists profiles with @@ -50,6 +67,9 @@ the global context menu. capture/apply against real `ModComponent` instances and `MainConfig` (statics saved and restored per test), filename sanitization, corrupt-file skipping. +`src/ModSync.Tests/ProfileCliTests.cs`: CLI `profile` verb create/delete/activate/clone/rename. + ```bash dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~ProfileService" +dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~ProfileCliTests" ``` diff --git a/src/ModSync.Core/CLI/ModBuildConverter.cs b/src/ModSync.Core/CLI/ModBuildConverter.cs index d898b25e..43cf61e3 100644 --- a/src/ModSync.Core/CLI/ModBuildConverter.cs +++ b/src/ModSync.Core/CLI/ModBuildConverter.cs @@ -19,10 +19,12 @@ using ModSync.Core.Services.Download; using ModSync.Core.Services.Fomod; using ModSync.Core.Services.Installation; +using ModSync.Core.Services.Profiles; using ModSync.Core.Services.Settings; using ModSync.Core.Services.Validation; using ModSync.Core.Utility; + using Newtonsoft.Json; namespace ModSync.Core.CLI @@ -689,6 +691,28 @@ public class HolopatcherOptions : BaseOptions public string Arguments { get; set; } } + [Verb("profile", HelpText = "List, create, delete, clone, rename, and activate install profiles")] + public class ProfileOptions : BaseOptions + { + [Option('a', "action", Required = true, HelpText = "list|show|create|delete|activate|clone|rename")] + public string Action { get; set; } + + [Option('n', "name", Required = false, HelpText = "Profile name (create|delete|activate|show)")] + public string Name { get; set; } + + [Option("from", Required = false, HelpText = "Source profile name (clone|rename)")] + public string From { get; set; } + + [Option("to", Required = false, HelpText = "Target profile name (clone|rename)")] + public string To { get; set; } + + [Option("settings-dir", Required = false, HelpText = "ModSync settings directory (default: AppData/ModSync)")] + public string SettingsDirectory { get; set; } + + [Option("json", Required = false, Default = false, HelpText = "Emit JSON for list/show")] + public bool Json { get; set; } + } + public static int Run(string[] args) { // Disable keyring BEFORE any Python initialization to prevent pip hanging @@ -700,7 +724,7 @@ public static int Run(string[] args) var parser = new Parser(with => with.HelpWriter = Console.Out); - return parser.ParseArguments(args) + return parser.ParseArguments(args) .MapResult( (ConvertOptions opts) => RunConvertAsync(opts).GetAwaiter().GetResult(), (MergeOptions opts) => RunMergeAsync(opts).GetAwaiter().GetResult(), @@ -709,6 +733,7 @@ public static int Run(string[] args) (SetNexusApiKeyOptions opts) => RunSetNexusApiKeyAsync(opts).GetAwaiter().GetResult(), (InstallPythonDepsOptions opts) => RunInstallPythonDepsAsync(opts).GetAwaiter().GetResult(), (HolopatcherOptions opts) => RunHolopatcherAsync(opts).GetAwaiter().GetResult(), + (ProfileOptions opts) => RunProfileAsync(opts), errs => 1); } @@ -3369,6 +3394,204 @@ await Logger.LogWarningAsync( } } + private static int RunProfileAsync(ProfileOptions opts) + { + SetVerboseMode(opts.Verbose); + + try + { + string settingsDirectory = string.IsNullOrWhiteSpace(opts.SettingsDirectory) + ? ModSyncSettings.GetSettingsDirectory() + : opts.SettingsDirectory.Trim(); + + var profileService = new ProfileService(settingsDirectory); + string action = (opts.Action ?? string.Empty).Trim().ToLowerInvariant(); + + switch (action) + { + case "list": + return ProfileCliList(profileService, settingsDirectory, opts.Json); + case "show": + return ProfileCliShow(profileService, RequireProfileName(opts.Name, "show"), opts.Json); + case "create": + return ProfileCliCreate(profileService, RequireProfileName(opts.Name, "create")); + case "delete": + return ProfileCliDelete(profileService, RequireProfileName(opts.Name, "delete")); + case "activate": + return ProfileCliActivate(profileService, settingsDirectory, RequireProfileName(opts.Name, "activate")); + case "clone": + return ProfileCliClone( + profileService, + RequireProfileName(opts.From ?? opts.Name, "clone", "--from or --name"), + RequireProfileName(opts.To, "clone", "--to")); + case "rename": + return ProfileCliRename( + profileService, + RequireProfileName(opts.From ?? opts.Name, "rename", "--from or --name"), + RequireProfileName(opts.To, "rename", "--to")); + default: + Logger.LogError($"Unknown profile action '{opts.Action}'. Use list|show|create|delete|activate|clone|rename."); + return 1; + } + } + catch (Exception ex) + { + Logger.LogError($"Profile command failed: {ex.Message}"); + if (opts.Verbose) + { + Logger.LogException(ex); + } + + return 1; + } + } + + [NotNull] + private static string RequireProfileName([CanBeNull] string name, [NotNull] string action, [CanBeNull] string flagHint = null) + { + if (!string.IsNullOrWhiteSpace(name)) + { + return name.Trim(); + } + + string hint = string.IsNullOrWhiteSpace(flagHint) ? "--name" : flagHint; + throw new ArgumentException($"Profile action '{action}' requires {hint}."); + } + + private static int ProfileCliList([NotNull] ProfileService profileService, [NotNull] string settingsDirectory, bool json) + { + List profiles = profileService.ListProfiles(); + ModSyncSettings settings = ModSyncSettings.LoadFromDirectory(settingsDirectory); + string active = settings.ActiveProfileName ?? string.Empty; + + if (json) + { + var payload = profiles.Select(profile => new + { + profile.Name, + profile.KotorDirectory, + profile.ModDirectory, + profile.InstructionFilePath, + ComponentSelectionCount = profile.ComponentSelections?.Count ?? 0, + profile.CreatedUtc, + profile.LastUsedUtc, + IsActive = string.Equals(profile.Name, active, StringComparison.OrdinalIgnoreCase), + }); + Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + return 0; + } + + if (profiles.Count == 0) + { + Logger.LogAsync($"No profiles found in {profileService.ProfilesDirectory}").GetAwaiter().GetResult(); + return 0; + } + + Logger.LogAsync($"Profiles ({profiles.Count}):").GetAwaiter().GetResult(); + foreach (Profile profile in profiles) + { + string marker = string.Equals(profile.Name, active, StringComparison.OrdinalIgnoreCase) ? " *" : string.Empty; + Logger.LogAsync($" - {profile.Name}{marker}").GetAwaiter().GetResult(); + } + + if (!string.IsNullOrWhiteSpace(active)) + { + Logger.LogAsync($"Active profile (settings.json): {active}").GetAwaiter().GetResult(); + } + + return 0; + } + + private static int ProfileCliShow([NotNull] ProfileService profileService, [NotNull] string profileName, bool json) + { + Profile profile = profileService.LoadProfile(profileName); + if (profile is null) + { + Logger.LogError($"Profile '{profileName}' was not found."); + return 1; + } + + if (json) + { + Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(profile, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + return 0; + } + + Logger.LogAsync($"Profile: {profile.Name}").GetAwaiter().GetResult(); + Logger.LogAsync($" KOTOR: {profile.KotorDirectory ?? "(unset)"}").GetAwaiter().GetResult(); + Logger.LogAsync($" Mod workspace: {profile.ModDirectory ?? "(unset)"}").GetAwaiter().GetResult(); + Logger.LogAsync($" Instruction file: {profile.InstructionFilePath ?? "(unset)"}").GetAwaiter().GetResult(); + Logger.LogAsync($" Component selections: {profile.ComponentSelections?.Count ?? 0}").GetAwaiter().GetResult(); + return 0; + } + + private static int ProfileCliCreate([NotNull] ProfileService profileService, [NotNull] string profileName) + { + Profile profile = profileService.CreateProfile(profileName); + Logger.LogAsync($"Created profile '{profile.Name}' under {profileService.ProfilesDirectory}").GetAwaiter().GetResult(); + return 0; + } + + private static int ProfileCliDelete([NotNull] ProfileService profileService, [NotNull] string profileName) + { + if (!profileService.DeleteProfile(profileName)) + { + Logger.LogError($"Profile '{profileName}' was not found."); + return 1; + } + + Logger.LogAsync($"Deleted profile '{profileName}'.").GetAwaiter().GetResult(); + return 0; + } + + private static int ProfileCliActivate( + [NotNull] ProfileService profileService, + [NotNull] string settingsDirectory, + [NotNull] string profileName) + { + Profile profile = profileService.LoadProfile(profileName); + if (profile is null) + { + Logger.LogError($"Profile '{profileName}' was not found."); + return 1; + } + + ModSyncSettings settings = ModSyncSettings.LoadFromDirectory(settingsDirectory); + settings.ActiveProfileName = profile.Name; + settings.SaveManagedDeploymentFieldsToDirectory(settingsDirectory); + profile.LastUsedUtc = DateTime.UtcNow; + profileService.SaveProfile(profile); + Logger.LogAsync($"Activated profile '{profile.Name}' (saved to settings.json).").GetAwaiter().GetResult(); + return 0; + } + + private static int ProfileCliClone( + [NotNull] ProfileService profileService, + [NotNull] string fromName, + [NotNull] string toName) + { + Profile source = profileService.LoadProfile(fromName); + if (source is null) + { + Logger.LogError($"Source profile '{fromName}' was not found."); + return 1; + } + + Profile clone = profileService.CloneProfile(source, toName); + Logger.LogAsync($"Cloned profile '{fromName}' to '{clone.Name}'.").GetAwaiter().GetResult(); + return 0; + } + + private static int ProfileCliRename( + [NotNull] ProfileService profileService, + [NotNull] string fromName, + [NotNull] string toName) + { + Profile renamed = profileService.RenameProfile(fromName, toName); + Logger.LogAsync($"Renamed profile '{fromName}' to '{renamed.Name}'.").GetAwaiter().GetResult(); + return 0; + } + private static async Task RunSetNexusApiKeyAsync(SetNexusApiKeyOptions opts) { SetVerboseMode(opts.Verbose); diff --git a/src/ModSync.Tests/ProfileCliTests.cs b/src/ModSync.Tests/ProfileCliTests.cs new file mode 100644 index 00000000..0bed73d8 --- /dev/null +++ b/src/ModSync.Tests/ProfileCliTests.cs @@ -0,0 +1,134 @@ +// Copyright 2021-2025 ModSync +// 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.IO; +using System.Linq; + +using ModSync.Core.CLI; +using ModSync.Core.Services.Profiles; +using ModSync.Core.Services.Settings; + +using NUnit.Framework; + +namespace ModSync.Tests +{ + [TestFixture] + public sealed class ProfileCliTests + { + private string _settingsDirectory; + + [SetUp] + public void SetUp() + { + _settingsDirectory = Path.Combine(Path.GetTempPath(), "ModSync_ProfileCli_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_settingsDirectory); + } + + [TearDown] + public void TearDown() + { + try + { + if (Directory.Exists(_settingsDirectory)) + { + Directory.Delete(_settingsDirectory, recursive: true); + } + } + catch + { + // Best-effort temp cleanup. + } + } + + [Test] + public void ProfileCli_CreateListDelete_Succeeds() + { + int createExit = ModBuildConverter.Run(new[] + { + "profile", + "--action", "create", + "--name", "Agent Smoke", + "--settings-dir", _settingsDirectory, + }); + + Assert.That(createExit, Is.EqualTo(0)); + + var service = new ProfileService(_settingsDirectory); + Assert.That(service.ListProfiles().Select(p => p.Name), Does.Contain("Agent Smoke")); + + int deleteExit = ModBuildConverter.Run(new[] + { + "profile", + "--action", "delete", + "--name", "Agent Smoke", + "--settings-dir", _settingsDirectory, + }); + + Assert.That(deleteExit, Is.EqualTo(0)); + Assert.That(service.ListProfiles(), Is.Empty); + } + + [Test] + public void ProfileCli_Activate_PersistsActiveProfileName() + { + int createExit = ModBuildConverter.Run(new[] + { + "profile", + "--action", "create", + "--name", "Full Build", + "--settings-dir", _settingsDirectory, + }); + Assert.That(createExit, Is.EqualTo(0)); + + int activateExit = ModBuildConverter.Run(new[] + { + "profile", + "--action", "activate", + "--name", "Full Build", + "--settings-dir", _settingsDirectory, + }); + + Assert.That(activateExit, Is.EqualTo(0)); + ModSyncSettings settings = ModSyncSettings.LoadFromDirectory(_settingsDirectory); + Assert.That(settings.ActiveProfileName, Is.EqualTo("Full Build")); + } + + [Test] + public void ProfileCli_CloneAndRename_Succeeds() + { + ModBuildConverter.Run(new[] + { + "profile", + "--action", "create", + "--name", "Alpha", + "--settings-dir", _settingsDirectory, + }); + + int cloneExit = ModBuildConverter.Run(new[] + { + "profile", + "--action", "clone", + "--from", "Alpha", + "--to", "Beta", + "--settings-dir", _settingsDirectory, + }); + Assert.That(cloneExit, Is.EqualTo(0)); + + int renameExit = ModBuildConverter.Run(new[] + { + "profile", + "--action", "rename", + "--from", "Beta", + "--to", "Gamma", + "--settings-dir", _settingsDirectory, + }); + Assert.That(renameExit, Is.EqualTo(0)); + + var service = new ProfileService(_settingsDirectory); + Assert.That(service.LoadProfile("Gamma"), Is.Not.Null); + Assert.That(service.LoadProfile("Beta"), Is.Null); + } + } +}