Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .cursor/hooks/state/continual-learning.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"version": 1,
"lastRunAtMs": 0,
"turnsSinceLastRun": 1,
"lastTranscriptMtimeMs": null,
"lastProcessedGenerationId": "3a831d78-3bfa-4ab1-9df9-5c1ebe9b056d",
"trialStartedAtMs": null
}
1 change: 1 addition & 0 deletions docs/knowledgebase/agent-action-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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 |
| Settings CLI | `SettingsCliTests` | `settings list|get|set` merges into `settings.json` |
| 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 |

Expand Down
3 changes: 2 additions & 1 deletion docs/knowledgebase/agent-native-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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 (file/stdin); OS clipboard `[UI]` |
| `modsync://` open/install link | `--modsync=` / URI argv → handoff | Yes (consume); Settings toggle deferred |
| Run validation | `ValidatePage` or `validate --full` / `--dry-run` | Yes (selection flags differ) |
| Read/write app settings | Settings UI / `settings.json` | Partial — `settings list|get|set` CLI; theme/spoiler UI still `[UI]` |
| Fetch downloads | Wizard / `ScrapeDownloadsButton` | Partial — CLI `install -d` / `convert -d`; status/stop `[UI]` |
| 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 |
Expand All @@ -77,7 +78,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`, `settings`, `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`) |

Expand Down
30 changes: 30 additions & 0 deletions docs/knowledgebase/core-cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,36 @@ dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "Name~AutoGenerateLo

---

### `settings`

Read or write keys in persisted `settings.json` (same file as the GUI `AppSettings` store). Merges into the existing JSON document without removing unrelated GUI-owned keys.

| Flag | Required | Description |
|------|----------|-------------|
| `-a` / `--action` | Yes | `list`, `get`, or `set` |
| `-k` / `--key` | For get/set | Setting key (camelCase JSON names, e.g. `sourcePath`, `managedDeploymentEnabled`) |
| `--value` | For set | New value (`true`/`false`, numbers, JSON literals, or strings). Omit or pass empty to remove the key. |
| `--settings-dir` | No | Settings directory (default: platform AppData / `~/.config/ModSync`) |
| `--json` | No | JSON output for list/get/set |
| `--reveal-secrets` | No | Include sensitive values such as `nexusModsApiKey` (default redacts to `***`) |

**Examples:**

```bash
dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \
settings --action list --json --settings-dir ./tmp/modsync_settings

dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \
settings --action set --key sourcePath --value ./tmp/mod_downloads --settings-dir ./tmp/modsync_settings

dotnet run --project src/ModSync.Core/ModSync.Core.csproj -f net9.0 -- \
settings --action set --key managedDeploymentEnabled --value true --settings-dir ./tmp/modsync_settings
```

**Tests:** `dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~SettingsCliTests"`

---

### `set-nexus-api-key`

Store and optionally validate a Nexus Mods API key.
Expand Down
198 changes: 197 additions & 1 deletion src/ModSync.Core/CLI/ModBuildConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;

Expand Down Expand Up @@ -689,6 +690,28 @@ public class HolopatcherOptions : BaseOptions
public string Arguments { get; set; }
}

[Verb("settings", HelpText = "Get, set, or list persisted settings.json values")]
public class SettingsOptions : BaseOptions
{
[Option('a', "action", Required = true, HelpText = "list|get|set")]
public string Action { get; set; }

[Option('k', "key", Required = false, HelpText = "Setting key (required for get/set)")]
public string Key { get; set; }

[Option("value", Required = false, HelpText = "Value for set (omit or empty to remove key). Supports true/false, numbers, and JSON literals.")]
public string Value { 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/get/set results")]
public bool Json { get; set; }

[Option("reveal-secrets", Required = false, Default = false, HelpText = "Include sensitive values such as nexusModsApiKey in list/get output")]
public bool RevealSecrets { get; set; }
}

public static int Run(string[] args)
{
// Disable keyring BEFORE any Python initialization to prevent pip hanging
Expand All @@ -700,7 +723,7 @@ public static int Run(string[] args)

var parser = new Parser(with => with.HelpWriter = Console.Out);

return parser.ParseArguments<ConvertOptions, MergeOptions, ValidateOptions, InstallOptions, SetNexusApiKeyOptions, InstallPythonDepsOptions, HolopatcherOptions>(args)
return parser.ParseArguments<ConvertOptions, MergeOptions, ValidateOptions, InstallOptions, SetNexusApiKeyOptions, InstallPythonDepsOptions, HolopatcherOptions, SettingsOptions>(args)
.MapResult(
(ConvertOptions opts) => RunConvertAsync(opts).GetAwaiter().GetResult(),
(MergeOptions opts) => RunMergeAsync(opts).GetAwaiter().GetResult(),
Expand All @@ -709,6 +732,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(),
(SettingsOptions opts) => RunSettingsAsync(opts),
errs => 1);
}

Expand Down Expand Up @@ -3369,6 +3393,178 @@ await Logger.LogWarningAsync(
}
}

private static int RunSettingsAsync(SettingsOptions opts)
{
SetVerboseMode(opts.Verbose);

try
{
string action = (opts.Action ?? string.Empty).Trim().ToLowerInvariant();
string settingsDirectory = SettingsFileStore.ResolveSettingsDirectory(opts.SettingsDirectory);

switch (action)
{
case "list":
return SettingsCliList(settingsDirectory, opts.Json, opts.RevealSecrets);
case "get":
return SettingsCliGet(
settingsDirectory,
RequireSettingsKey(opts.Key, "get"),
opts.Json,
opts.RevealSecrets);
case "set":
return SettingsCliSet(
settingsDirectory,
RequireSettingsKey(opts.Key, "set"),
opts.Value,
opts.Json);
default:
Logger.LogError($"Unknown settings action '{opts.Action}'. Use list|get|set.");
return 1;
}
}
catch (Exception ex)
{
Logger.LogError($"Settings command failed: {ex.Message}");
if (opts.Verbose)
{
Logger.LogException(ex);
}

return 1;
}
}

[NotNull]
private static string RequireSettingsKey([CanBeNull] string key, [NotNull] string action)
{
if (!string.IsNullOrWhiteSpace(key))
{
return key.Trim();
}

throw new ArgumentException($"Settings action '{action}' requires --key.");
}

private static int SettingsCliList([NotNull] string settingsDirectory, bool json, bool revealSecrets)
{
JsonObject root = SettingsFileStore.LoadRoot(settingsDirectory);
JsonObject outputRoot = SettingsFileStore.CloneForOutput(root, revealSecrets);

if (json)
{
var payload = new
{
settingsDirectory,
settingsFile = SettingsFileStore.ResolveSettingsFilePath(settingsDirectory),
values = outputRoot,
};
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
return 0;
}

IReadOnlyList<string> keys = SettingsFileStore.ListKeys(outputRoot);
if (keys.Count == 0)
{
Logger.LogAsync($"No settings found in {SettingsFileStore.ResolveSettingsFilePath(settingsDirectory)}").GetAwaiter().GetResult();
return 0;
}

Logger.LogAsync($"Settings ({keys.Count}):").GetAwaiter().GetResult();
foreach (string key in keys)
{
string rendered = FormatSettingsValueForText(outputRoot[key]);
Logger.LogAsync($" {key}={rendered}").GetAwaiter().GetResult();
}

return 0;
}

private static int SettingsCliGet(
[NotNull] string settingsDirectory,
[NotNull] string key,
bool json,
bool revealSecrets)
{
JsonObject root = SettingsFileStore.LoadRoot(settingsDirectory);
if (!SettingsFileStore.TryGetValue(root, key, out JsonNode value))
{
Logger.LogError($"Setting '{key}' was not found.");
return 1;
}

if (!revealSecrets && SettingsFileStore.SensitiveKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
{
value = JsonValue.Create("***");
}

if (json)
{
var payload = new
{
key,
value,
settingsFile = SettingsFileStore.ResolveSettingsFilePath(settingsDirectory),
};
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
return 0;
}

Console.WriteLine(FormatSettingsValueForText(value));
return 0;
}

private static int SettingsCliSet(
[NotNull] string settingsDirectory,
[NotNull] string key,
[CanBeNull] string rawValue,
bool json)
{
JsonObject root = SettingsFileStore.LoadRoot(settingsDirectory);
SettingsFileStore.SetValue(root, key, rawValue);
SettingsFileStore.SaveRoot(settingsDirectory, root);

if (json)
{
JsonObject outputRoot = SettingsFileStore.CloneForOutput(root, revealSecrets: false);
var payload = new
{
key,
value = outputRoot.TryGetPropertyValue(key, out JsonNode node) ? node : null,
settingsFile = SettingsFileStore.ResolveSettingsFilePath(settingsDirectory),
};
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
return 0;
}

Logger.LogAsync($"Updated setting '{key}' in {SettingsFileStore.ResolveSettingsFilePath(settingsDirectory)}").GetAwaiter().GetResult();
return 0;
}

[NotNull]
private static string FormatSettingsValueForText([CanBeNull] JsonNode value)
{
if (value is null)
{
return string.Empty;
}

if (value is JsonValue jsonValue)
{
if (jsonValue.TryGetValue(out string stringValue))
{
return stringValue;
}

if (jsonValue.TryGetValue(out bool boolValue))
{
return boolValue ? "true" : "false";
}
}

return value.ToJsonString();
}

private static async Task<int> RunSetNexusApiKeyAsync(SetNexusApiKeyOptions opts)
{
SetVerboseMode(opts.Verbose);
Expand Down
Loading
Loading