using System.Text.Json; using System.Text.Json.Serialization; using Sdt.Config; using Sdt.Core; namespace Sdt.Bridge; public sealed class BridgeStdioServer { private readonly JsonSerializerOptions _json; private readonly string? _startupProjectRoot; private readonly RunEventLogReader _eventReader = new(); private readonly ConfigDoctorService _doctor = new(new ToolProbeService(), new RequirementResolver()); private readonly SetupWizardConfigService _setupConfigService = new(new RequirementResolver()); private readonly ConfigDoctorAutoFixService _doctorFixes = new(); public BridgeStdioServer(string? startupProjectRoot = null) { _startupProjectRoot = startupProjectRoot; _json = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; _json.Converters.Add(new JsonStringEnumConverter()); } public async Task RunAsync(CancellationToken cancellationToken = default) { while (!cancellationToken.IsCancellationRequested) { var line = await Console.In.ReadLineAsync(cancellationToken).ConfigureAwait(false); if (line is null) break; if (string.IsNullOrWhiteSpace(line)) continue; BridgeResponseEnvelope response; try { var request = JsonSerializer.Deserialize(line, _json); if (request is null || string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.Method)) { response = Error(null, "bad_request", "Invalid bridge request envelope."); } else { response = await HandleAsync(request, cancellationToken).ConfigureAwait(false); } } catch (JsonException jex) { response = Error(null, "bad_json", $"Invalid JSON: {jex.Message}"); } catch (Exception ex) { response = Error(null, "internal_error", ex.Message); } Console.Out.WriteLine(JsonSerializer.Serialize(response, _json)); await Console.Out.FlushAsync(cancellationToken).ConfigureAwait(false); } return 0; } private async Task HandleAsync(BridgeRequestEnvelope request, CancellationToken cancellationToken) { try { return request.Method switch { "workspace.get" => Ok(request.Id, HandleWorkspaceGet(request.Params)), "workspace.add" => Ok(request.Id, HandleWorkspaceAdd(request.Params)), "favorites.list" => Ok(request.Id, HandleFavoritesList(request.Params)), "favorites.toggle" => Ok(request.Id, HandleFavoritesToggle(request.Params)), "favorites.removeMany" => Ok(request.Id, HandleFavoritesRemoveMany(request.Params)), "history.list" => Ok(request.Id, HandleHistoryList(request.Params)), "events.listFiles" => Ok(request.Id, HandleEventsListFiles(request.Params)), "events.readFile" => Ok(request.Id, HandleEventsReadFile(request.Params)), "envProfiles.list" => Ok(request.Id, HandleEnvProfilesList(request.Params)), "envProfiles.resolve" => Ok(request.Id, HandleEnvProfilesResolve(request.Params)), "envProfiles.setActive" => Ok(request.Id, HandleEnvProfilesSetActive(request.Params)), "envProfiles.saveProfile" => Ok(request.Id, HandleEnvProfilesSaveProfile(request.Params)), "envVars.list" => Ok(request.Id, HandleEnvVarsList(request.Params)), "envVars.save" => Ok(request.Id, HandleEnvVarsSave(request.Params)), "doctor.run" => Ok(request.Id, await HandleDoctorRunAsync(request.Params, cancellationToken).ConfigureAwait(false)), "setup.plan" => Ok(request.Id, HandleSetupPlan(request.Params)), "setup.autofixDirs" => Ok(request.Id, HandleSetupAutofixDirs(request.Params)), "setup.migrateLegacy" => Ok(request.Id, HandleSetupMigrateLegacy(request.Params)), "setup.applyRecommendedConfig" => Ok(request.Id, HandleSetupApplyRecommendedConfig(request.Params)), _ => Error(request.Id, "method_not_found", $"Unsupported bridge method: {request.Method}") }; } catch (BridgeValidationException vex) { return Error(request.Id, "validation_failed", vex.Message); } catch (Exception ex) { return Error(request.Id, "method_failed", ex.Message); } } private object HandleWorkspaceGet(JsonElement @params) { var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); var (workspace, workspaceRoot) = loaded; var currentRoot = ConfigLoader.FindAndLoad(startDir)?.ProjectRoot ?? Path.GetFullPath(startDir); var inventory = WorkspaceLoader.ScanInventory(workspaceRoot, currentRoot, workspace); return new { workspaceRoot, currentProjectRoot = currentRoot, configuredProjects = workspace.Projects.Select(project => new { name = project.Name, description = project.Description, path = project.Path, tags = project.Tags, toolFamilies = project.ToolFamilies, disabled = project.Disabled, detectedBy = project.DetectedBy, lastValidatedUtc = project.LastValidatedUtc, resolvedRoot = WorkspaceLoader.ResolveProjectRoot(workspaceRoot, project) }), favorites = workspace.Favorites.Select(f => new { id = GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId), projectPath = f.ProjectPath, workflowId = f.WorkflowId, label = f.Label, resolvedProjectRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f) }), knownProjects = inventory.KnownProjects, candidates = inventory.Candidates, scanStats = inventory.Snapshot.ScanStats }; } private object HandleWorkspaceAdd(JsonElement @params) { var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); var candidatePath = GetRequiredString(@params, "candidatePath"); var initConfig = GetBool(@params, "initializeConfig") ?? false; var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); var (workspace, workspaceRoot) = loaded; var absoluteCandidate = Path.GetFullPath(candidatePath); if (!Directory.Exists(absoluteCandidate)) throw new BridgeValidationException($"Candidate path does not exist: {absoluteCandidate}"); if (initConfig && ConfigLoader.FindConfigPath(absoluteCandidate) is null) { var scan = ConfigBootstrapper.Scan(absoluteCandidate); var generated = ConfigBootstrapper.BuildDefaultConfig(scan); ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false); } var existing = workspace.Projects.Any(p => string.Equals( WorkspaceLoader.ResolveProjectRoot(workspaceRoot, p), absoluteCandidate, StringComparison.OrdinalIgnoreCase)); if (!existing) { var relPath = Path.GetRelativePath(workspaceRoot, absoluteCandidate); workspace.Projects.Add(new WorkspaceProject { Name = Path.GetFileName(absoluteCandidate), Description = initConfig ? "Added via GUI bridge (initialized)." : "Added via GUI bridge.", Path = relPath, Tags = [], ToolFamilies = [], Disabled = false, DetectedBy = "inventory", LastValidatedUtc = DateTimeOffset.UtcNow, }); WorkspaceLoader.Save(workspaceRoot, workspace); } return HandleWorkspaceGet(@params); } private object HandleFavoritesList(JsonElement @params) { var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); var (workspace, workspaceRoot) = loaded; return workspace.Favorites.Select(f => new { id = GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId), projectPath = f.ProjectPath, workflowId = f.WorkflowId, label = f.Label, resolvedProjectRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f) }).ToList(); } private object HandleFavoritesToggle(JsonElement @params) { var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); var projectPath = GetRequiredString(@params, "favoriteProjectPath"); var workflowId = GetRequiredString(@params, "workflowId"); var label = GetString(@params, "label"); var id = GetString(@params, "id"); var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); var (workspace, workspaceRoot) = loaded; var absoluteProject = Path.GetFullPath(projectPath); var relativeProject = Path.IsPathRooted(projectPath) ? Path.GetRelativePath(workspaceRoot, absoluteProject) : projectPath; var existing = workspace.Favorites.FirstOrDefault(f => string.Equals(WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f), absoluteProject, StringComparison.OrdinalIgnoreCase) && string.Equals(f.WorkflowId, workflowId, StringComparison.OrdinalIgnoreCase)); if (existing is not null) { workspace.Favorites.Remove(existing); } else { workspace.Favorites.Add(new WorkspaceFavorite { Id = string.IsNullOrWhiteSpace(id) ? GuidedTaskCatalog.Slugify(label ?? workflowId) : id, ProjectPath = relativeProject, WorkflowId = workflowId, Label = string.IsNullOrWhiteSpace(label) ? null : label }); } WorkspaceLoader.Save(workspaceRoot, workspace); return HandleFavoritesList(@params); } private object HandleFavoritesRemoveMany(JsonElement @params) { var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); var (workspace, workspaceRoot) = loaded; if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty("items", out var itemsProp) || itemsProp.ValueKind != JsonValueKind.Array) { throw new BridgeValidationException("Missing required parameter 'items'."); } var items = new List<(string ProjectPath, string WorkflowId)>(); foreach (var item in itemsProp.EnumerateArray()) { var path = item.TryGetProperty("projectPath", out var pp) && pp.ValueKind == JsonValueKind.String ? pp.GetString() : null; var workflowId = item.TryGetProperty("workflowId", out var wf) && wf.ValueKind == JsonValueKind.String ? wf.GetString() : null; if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(workflowId)) continue; items.Add((Path.GetFullPath(path), workflowId!)); } if (items.Count == 0) return HandleFavoritesList(@params); workspace.Favorites.RemoveAll(f => { var resolved = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f); return items.Any(i => string.Equals(i.ProjectPath, resolved, StringComparison.OrdinalIgnoreCase) && string.Equals(i.WorkflowId, f.WorkflowId, StringComparison.OrdinalIgnoreCase)); }); WorkspaceLoader.Save(workspaceRoot, workspace); return HandleFavoritesList(@params); } private object HandleHistoryList(JsonElement @params) { var projectRoot = ResolveProjectRootForProjectScopedMethod(@params); var limit = GetInt(@params, "limit") ?? 50; return _eventReader.ListRunHistory(projectRoot, Math.Clamp(limit, 1, 500)); } private object HandleEventsListFiles(JsonElement @params) { var projectRoot = ResolveProjectRootForProjectScopedMethod(@params); return _eventReader.ListEventFiles(projectRoot); } private object HandleEventsReadFile(JsonElement @params) { var projectRoot = ResolveProjectRootForProjectScopedMethod(@params); var filePath = GetRequiredString(@params, "filePath"); var absolute = Path.GetFullPath(filePath); var eventsRoot = Path.GetFullPath(Path.Combine(projectRoot, ".sdt", "events")); if (!absolute.StartsWith(eventsRoot, StringComparison.OrdinalIgnoreCase)) throw new BridgeValidationException("Event file path must be under .sdt/events."); return _eventReader.ReadEvents(absolute); } private object HandleEnvProfilesList(JsonElement @params) { var loaded = LoadProject(@params); var envProfiles = loaded.Config.EnvProfiles; return new { active = envProfiles?.Active, profiles = envProfiles?.Profiles ?? [] }; } private object HandleEnvProfilesResolve(JsonElement @params) { var loaded = LoadProject(@params); var profileId = GetString(@params, "envProfile"); var effective = EnvProfileService.ResolveEffectiveEnv(loaded.Config, profileId); return new { selected = string.IsNullOrWhiteSpace(profileId) ? loaded.Config.EnvProfiles?.Active : profileId, values = effective }; } private object HandleEnvProfilesSetActive(JsonElement @params) { var loaded = LoadProject(@params); var profileId = GetRequiredString(@params, "profileId"); var envProfiles = loaded.Config.EnvProfiles ?? new EnvProfilesConfig { Active = profileId, Profiles = [] }; var profileExists = envProfiles.Profiles.Any(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)); if (!profileExists) throw new BridgeValidationException($"Profile '{profileId}' does not exist."); var updated = new DevToolConfig { Name = loaded.Config.Name, Version = loaded.Config.Version, Targets = loaded.Config.Targets, Workflows = loaded.Config.Workflows, Env = loaded.Config.Env, EnvProfiles = new EnvProfilesConfig { Active = profileId, Profiles = envProfiles.Profiles }, Toolchains = loaded.Config.Toolchains, Tooling = loaded.Config.Tooling, Project = loaded.Config.Project, Debug = loaded.Config.Debug }; var save = SaveConfigWithBackup(loaded.ProjectRoot, updated); return new { projectRoot = loaded.ProjectRoot, profileId, save, envProfiles = new { active = profileId, profiles = envProfiles.Profiles } }; } private object HandleEnvProfilesSaveProfile(JsonElement @params) { var loaded = LoadProject(@params); if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty("profile", out var profileProp) || profileProp.ValueKind != JsonValueKind.Object) { throw new BridgeValidationException("Missing required parameter 'profile'."); } var id = profileProp.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String ? idProp.GetString() : null; if (string.IsNullOrWhiteSpace(id)) throw new BridgeValidationException("Profile id is required."); var description = profileProp.TryGetProperty("description", out var dProp) && dProp.ValueKind == JsonValueKind.String ? dProp.GetString() ?? "" : ""; var inherits = new List(); if (profileProp.TryGetProperty("inherits", out var inhProp) && inhProp.ValueKind == JsonValueKind.Array) { foreach (var entry in inhProp.EnumerateArray()) { if (entry.ValueKind == JsonValueKind.String) { var value = entry.GetString(); if (!string.IsNullOrWhiteSpace(value)) inherits.Add(value!); } } } var values = new Dictionary(StringComparer.OrdinalIgnoreCase); if (profileProp.TryGetProperty("values", out var valuesProp) && valuesProp.ValueKind == JsonValueKind.Object) { foreach (var kvp in valuesProp.EnumerateObject()) { if (kvp.Value.ValueKind == JsonValueKind.String) values[kvp.Name] = kvp.Value.GetString() ?? ""; } } var envProfiles = loaded.Config.EnvProfiles ?? new EnvProfilesConfig { Active = id!, Profiles = [] }; var profiles = envProfiles.Profiles.ToList(); var existingIndex = profiles.FindIndex(p => string.Equals(p.Id, id, StringComparison.OrdinalIgnoreCase)); var newProfile = new EnvProfileDefinition { Id = id!, Description = description, Inherits = inherits, Values = values, }; if (existingIndex >= 0) profiles[existingIndex] = newProfile; else profiles.Add(newProfile); var active = string.IsNullOrWhiteSpace(envProfiles.Active) ? id! : envProfiles.Active; var updated = new DevToolConfig { Name = loaded.Config.Name, Version = loaded.Config.Version, Targets = loaded.Config.Targets, Workflows = loaded.Config.Workflows, Env = loaded.Config.Env, EnvProfiles = new EnvProfilesConfig { Active = active, Profiles = profiles }, Toolchains = loaded.Config.Toolchains, Tooling = loaded.Config.Tooling, Project = loaded.Config.Project, Debug = loaded.Config.Debug }; var save = SaveConfigWithBackup(loaded.ProjectRoot, updated); return new { projectRoot = loaded.ProjectRoot, profile = newProfile, save, envProfiles = new { active, profiles } }; } private async Task HandleDoctorRunAsync(JsonElement @params, CancellationToken cancellationToken) { var loaded = LoadProject(@params); var report = await _doctor.RunAsync(loaded.Config, loaded.ProjectRoot, cancellationToken).ConfigureAwait(false); return report; } private object HandleSetupPlan(JsonElement @params) { var loaded = LoadProject(@params); var report = _doctor.RunAsync(loaded.Config, loaded.ProjectRoot).GetAwaiter().GetResult(); var update = _setupConfigService.ApplyRecommendedDefaults(loaded.Config); var missingDirs = _doctorFixes.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot); return new { projectRoot = loaded.ProjectRoot, doctor = new { failCount = report.Checks.Count(c => c.Status == DoctorStatus.Fail), warnCount = report.Checks.Count(c => c.Status == DoctorStatus.Warn), checks = report.Checks }, plan = new[] { new { id = "doctor", label = "Run config doctor", mode = "read-only", count = report.Checks.Count }, new { id = "autofix-dirs", label = "Create missing working directories", mode = "preview", count = missingDirs.Count }, new { id = "legacy-migration", label = "Migrate legacy targets -> workflows", mode = "preview", count = loaded.Config.Targets.Count > 0 ? 1 : 0 }, new { id = "recommended-config", label = "Apply recommended config enhancements", mode = "preview", count = update.Changes.Count }, }, recommendedChanges = update.Changes }; } private object HandleEnvVarsList(JsonElement @params) { var loaded = LoadProject(@params); return new { projectRoot = loaded.ProjectRoot, env = loaded.Config.Env }; } private object HandleEnvVarsSave(JsonElement @params) { var loaded = LoadProject(@params); if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty("env", out var envProp) || envProp.ValueKind != JsonValueKind.Array) { throw new BridgeValidationException("Missing required parameter 'env'."); } var env = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var item in envProp.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) continue; var key = item.TryGetProperty("key", out var keyProp) && keyProp.ValueKind == JsonValueKind.String ? keyProp.GetString() : null; if (string.IsNullOrWhiteSpace(key)) throw new BridgeValidationException("Each env var requires a non-empty 'key'."); if (!seen.Add(key!)) throw new BridgeValidationException($"Duplicate env key detected: '{key}'."); var description = item.TryGetProperty("description", out var descProp) && descProp.ValueKind == JsonValueKind.String ? descProp.GetString() ?? "" : ""; var defaultValue = item.TryGetProperty("default", out var defProp) && defProp.ValueKind == JsonValueKind.String ? defProp.GetString() ?? "" : ""; var options = new List(); if (item.TryGetProperty("options", out var optionsProp) && optionsProp.ValueKind == JsonValueKind.Array) { foreach (var opt in optionsProp.EnumerateArray()) { if (opt.ValueKind == JsonValueKind.String) options.Add(opt.GetString() ?? ""); } } env.Add(new EnvVarDef { Key = key!, Description = description, DefaultValue = defaultValue, Options = options }); } var updated = new DevToolConfig { Name = loaded.Config.Name, Version = loaded.Config.Version, Targets = loaded.Config.Targets, Workflows = loaded.Config.Workflows, Env = env, EnvProfiles = loaded.Config.EnvProfiles, Toolchains = loaded.Config.Toolchains, Tooling = loaded.Config.Tooling, Project = loaded.Config.Project, Debug = loaded.Config.Debug }; var save = SaveConfigWithBackup(loaded.ProjectRoot, updated); return new { projectRoot = loaded.ProjectRoot, env, save }; } private object HandleSetupAutofixDirs(JsonElement @params) { var loaded = LoadProject(@params); var missingDirs = _doctorFixes.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot); var result = _doctorFixes.CreateMissingWorkingDirectories(missingDirs); return new { projectRoot = loaded.ProjectRoot, missingDirectories = missingDirs, result }; } private object HandleSetupMigrateLegacy(JsonElement @params) { var loaded = LoadProject(@params); var result = _doctorFixes.ApplyLegacyMigration(loaded.ProjectRoot); return new { projectRoot = loaded.ProjectRoot, result }; } private object HandleSetupApplyRecommendedConfig(JsonElement @params) { var loaded = LoadProject(@params); var update = _setupConfigService.ApplyRecommendedDefaults(loaded.Config); if (update.Changes.Count == 0) { return new { projectRoot = loaded.ProjectRoot, changes = update.Changes, applied = false, message = "No recommended config changes were needed." }; } var save = SaveConfigWithBackup(loaded.ProjectRoot, update.Config); return new { projectRoot = loaded.ProjectRoot, changes = update.Changes, applied = save.Success, save }; } private LoadedProjectConfig LoadProject(JsonElement @params) { var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); return ConfigLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No project config found for project."); } private string ResolveProjectRootForProjectScopedMethod(JsonElement @params) { var explicitRoot = GetString(@params, "projectRoot"); if (!string.IsNullOrWhiteSpace(explicitRoot)) return Path.GetFullPath(explicitRoot); return LoadProject(@params).ProjectRoot; } private static string? GetString(JsonElement @params, string name) { if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop)) return null; return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null; } private static string GetRequiredString(JsonElement @params, string name) { var value = GetString(@params, name); if (string.IsNullOrWhiteSpace(value)) throw new BridgeValidationException($"Missing required parameter '{name}'."); return value; } private static bool? GetBool(JsonElement @params, string name) { if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop)) return null; return prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False ? prop.GetBoolean() : null; } private static int? GetInt(JsonElement @params, string name) { if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop)) return null; return prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var i) ? i : null; } private static BridgeResponseEnvelope Ok(string? id, object result) => new(id, true, result, null); private static BridgeResponseEnvelope Error(string? id, string code, string message, object? details = null) => new(id, false, null, new BridgeErrorEnvelope(code, message, details)); private static LegacyMigrationApplyResult SaveConfigWithBackup(string projectRoot, DevToolConfig config) { try { var path = ConfigLoader.FindConfigPath(projectRoot); if (string.IsNullOrWhiteSpace(path)) return new LegacyMigrationApplyResult(false, "Could not find project config for saving."); var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}"; File.Copy(path, backup, overwrite: false); File.WriteAllText(path, ConfigBootstrapper.ToJson(config)); return new LegacyMigrationApplyResult(true, "Saved updated project config.", BackupPath: backup, ConfigPath: path); } catch (Exception ex) { return new LegacyMigrationApplyResult(false, ex.Message); } } } public sealed class BridgeValidationException(string message) : Exception(message);