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)), "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)), "doctor.run" => Ok(request.Id, await HandleDoctorRunAsync(request.Params, cancellationToken).ConfigureAwait(false)), "setup.plan" => Ok(request.Id, HandleSetupPlan(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 { 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 && !File.Exists(Path.Combine(absoluteCandidate, "devtool.json"))) { 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 { 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 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 { ProjectPath = relativeProject, WorkflowId = workflowId, Label = string.IsNullOrWhiteSpace(label) ? null : label }); } 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 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 LoadedProjectConfig LoadProject(JsonElement @params) { var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); return ConfigLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No devtool.json 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)); } public sealed class BridgeValidationException(string message) : Exception(message);