using System.Text.Json; using System.Text.Json.Serialization; using Sdt.Bridge; using Sdt.Config; using Sdt.Core; using Sdt.Tui; namespace Sdt.Cli; public sealed class CliApplication { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true, }; static CliApplication() { JsonOptions.Converters.Add(new JsonStringEnumConverter()); } public async Task RunAsync(string[] args) { var invocation = CliParser.Parse(args); return invocation.Command.ToLowerInvariant() switch { "interactive" => await LaunchInteractiveAsync([]), "init" => await LaunchInteractiveAsync(["init"]), "run" => await ExecuteRunAsync(invocation), "debug" => await ExecuteDebugAsync(invocation), "workspace" => ExecuteWorkspace(invocation), "bridge" => await ExecuteBridgeAsync(invocation), "doctor" => await ExecuteDoctorAsync(invocation), "setup" => await ExecuteSetupAsync(invocation), "env" => ExecuteEnv(invocation), "favorite" => await ExecuteFavoriteAsync(invocation), "config" => ExecuteConfig(invocation), "explain" => await ExecuteExplainAsync(invocation), "help" => ExecuteHelp(), _ => WriteValidationError($"Unknown command '{invocation.Command}'. Run `sdt help`."), }; } private static async Task LaunchInteractiveAsync(IReadOnlyList cliArgs) { var workspaceResult = WorkspaceLoader.FindAndLoad(); var projectResult = ConfigLoader.FindAndLoad(); var forceInit = cliArgs.Any(a => string.Equals(a, "init", StringComparison.OrdinalIgnoreCase) || string.Equals(a, "--init", StringComparison.OrdinalIgnoreCase)); if (forceInit) { var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory()); var generated = ConfigBootstrapper.BuildDefaultConfig(scan); var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false); Spectre.Console.AnsiConsole.MarkupLine(Theme.Ok($"Initialized config at {path}")); projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot); } if (projectResult is null) { Spectre.Console.AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No SDT project config found[/] (expected `sdtconfig-*.json` or `devtool.json`) in current directory or any parent."); var bootstrap = Spectre.Console.AnsiConsole.Confirm( $"[{Theme.Amber}]Generate a default SDT project config for this project now?[/]", defaultValue: true); if (!bootstrap) { Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint("Create an sdtconfig-.json (or devtool.json) in your project root to get started.")); return 1; } var scan = ConfigBootstrapper.Scan(Directory.GetCurrentDirectory()); Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint($"Detected project root: {scan.ProjectRoot}")); if (scan.ToolFamilies.Count > 0) Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint($"Detected tool families: {string.Join(", ", scan.ToolFamilies)}")); var generated = ConfigBootstrapper.BuildDefaultConfig(scan); var preview = ConfigBootstrapper.ToJson(generated); var previewPanel = new Spectre.Console.Panel(Spectre.Console.Markup.Escape(preview)) { Header = new Spectre.Console.PanelHeader("Generated project config preview") }; previewPanel.BorderStyle = Theme.DimStyle; Spectre.Console.AnsiConsole.Write(previewPanel); var confirmWrite = Spectre.Console.AnsiConsole.Confirm( $"[{Theme.Amber}]Write generated project config to {scan.ProjectRoot}?[/]", defaultValue: true); if (!confirmWrite) return 1; var path = ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false); Spectre.Console.AnsiConsole.MarkupLine(Theme.Ok($"Created {path}")); projectResult = ConfigLoader.FindAndLoad(scan.ProjectRoot); if (projectResult is null) { Spectre.Console.AnsiConsole.MarkupLine(Theme.Fail("Generated config could not be reloaded.")); return 1; } } var currentLoaded = projectResult; var (workspace, workspaceRoot) = workspaceResult.HasValue ? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot) : ((WorkspaceConfig?)null, (string?)null); string? pendingWorkflowId = null; while (true) { var app = new App( currentLoaded.Config, currentLoaded.ProjectRoot, currentLoaded.Warnings, workspace, workspaceRoot, pendingWorkflowId); pendingWorkflowId = null; var result = await app.RunAsync(); if (result.Reason == AppExitReason.Quit) break; if (result.Reason == AppExitReason.SwitchProject && result.NewProjectRoot is not null) { LoadedProjectConfig? loaded; try { loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot); } catch (Exception ex) { Spectre.Console.AnsiConsole.MarkupLine(Theme.Fail(ex.Message)); Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project...")); Console.ReadKey(intercept: true); continue; } if (loaded is null) { Spectre.Console.AnsiConsole.MarkupLine(Theme.Fail($"No SDT project config found at: {result.NewProjectRoot}")); Spectre.Console.AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project...")); Console.ReadKey(intercept: true); continue; } currentLoaded = loaded; pendingWorkflowId = result.RunWorkflowId; } } return 0; } private static async Task ExecuteRunAsync(CliInvocation invocation) { var loaded = LoadProject(invocation); if (loaded is null) return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); if (invocation.Positionals.Count == 0) return WriteValidationError("Missing workflow id or alias. Usage: sdt run [--json] [--project-root ] [--env-profile ] [--non-interactive] [--auto-install]"); var normalized = WorkflowModelBuilder.Normalize(loaded.Config, ResolveLegacyMode(), new RequirementResolver()); var workflow = GuidedTaskCatalog.FindWorkflow(normalized.Workflows, invocation.Positionals[0]); if (workflow is null) return WriteValidationError($"Workflow '{invocation.Positionals[0]}' was not found."); var service = new HeadlessExecutionService(); return await service.RunWorkflowAsync( loaded, new HeadlessRunRequest( WorkflowId: workflow.Id, ProjectRoot: loaded.ProjectRoot, EnvProfile: invocation.GetOption("--env-profile"), NonInteractive: invocation.HasOption("--non-interactive") || RuntimePolicy.IsNonInteractive(), AutoInstall: invocation.HasOption("--auto-install"), JsonOutput: invocation.HasOption("--json"))); } private static async Task ExecuteDebugAsync(CliInvocation invocation) { var loaded = LoadProject(invocation); if (loaded is null) return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); if (invocation.Positionals.Count == 0) return WriteValidationError("Missing debug profile id or alias. Usage: sdt debug [--json] [--project-root ] [--env-profile ] [--non-interactive] [--auto-install]"); var profile = GuidedTaskCatalog.FindDebugProfile(loaded.Config.Debug?.Profiles ?? [], invocation.Positionals[0]); if (profile is null) return WriteValidationError($"Debug profile '{invocation.Positionals[0]}' was not found."); var service = new HeadlessExecutionService(); return await service.RunDebugAsync( loaded, new HeadlessDebugRequest( ProfileId: profile.Id, ProjectRoot: loaded.ProjectRoot, EnvProfile: invocation.GetOption("--env-profile"), NonInteractive: invocation.HasOption("--non-interactive") || RuntimePolicy.IsNonInteractive(), AutoInstall: invocation.HasOption("--auto-install"), JsonOutput: invocation.HasOption("--json"))); } private static int ExecuteWorkspace(CliInvocation invocation) { if (!string.Equals(invocation.Subcommand, "scan", StringComparison.OrdinalIgnoreCase)) return WriteValidationError("Usage: sdt workspace scan [--json] [--project-root ]"); var startDir = ResolveProjectRoot(invocation); var asJson = invocation.HasOption("--json"); var workspaceLoaded = WorkspaceLoader.FindAndLoad(startDir); if (workspaceLoaded is null) { WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, success = false, message = "No workspace could be discovered." }); return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); } var (workspace, workspaceRoot) = workspaceLoaded.Value; var currentRoot = ConfigLoader.FindAndLoad(startDir)?.ProjectRoot ?? startDir; var scan = WorkspaceLoader.ScanInventory(workspaceRoot, currentRoot, workspace); if (asJson) { WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, success = true, workspaceRoot = scan.WorkspaceRoot, knownProjects = scan.KnownProjects, candidates = scan.Candidates, snapshot = scan.Snapshot }); } else { Console.WriteLine($"Workspace root: {scan.WorkspaceRoot}"); Console.WriteLine($"Known projects: {scan.KnownProjects.Count}"); Console.WriteLine($"Candidates: {scan.Candidates.Count}"); } return 0; } private static async Task ExecuteBridgeAsync(CliInvocation invocation) { if (!string.Equals(invocation.Subcommand, "--stdio", StringComparison.OrdinalIgnoreCase) && !invocation.HasOption("--stdio")) { return WriteValidationError("Usage: sdt bridge --stdio [--project-root ]"); } var server = new BridgeStdioServer(ResolveProjectRoot(invocation)); return await server.RunAsync(); } private static async Task ExecuteDoctorAsync(CliInvocation invocation) { if (invocation.Subcommand is not null && !string.Equals(invocation.Subcommand, "run", StringComparison.OrdinalIgnoreCase)) { return WriteValidationError("Usage: sdt doctor [run] [--json] [--project-root ]"); } var loaded = LoadProject(invocation); if (loaded is null) return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); var doctor = new ConfigDoctorService(new ToolProbeService(), new RequirementResolver()); var report = await doctor.RunAsync(loaded.Config, loaded.ProjectRoot); if (invocation.HasOption("--json")) { WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, projectRoot = loaded.ProjectRoot, failCount = report.Checks.Count(c => c.Status == DoctorStatus.Fail), warnCount = report.Checks.Count(c => c.Status == DoctorStatus.Warn), checks = report.Checks }); } else { Console.WriteLine($"Doctor: {loaded.ProjectRoot}"); foreach (var check in report.Checks) Console.WriteLine($"- [{check.Status}] {check.Name}: {check.Detail}"); } return report.HasFailures ? ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed) : 0; } private static async Task ExecuteSetupAsync(CliInvocation invocation) { var subcommand = string.IsNullOrWhiteSpace(invocation.Subcommand) ? "plan" : invocation.Subcommand!; if (string.Equals(subcommand, "doctor", StringComparison.OrdinalIgnoreCase)) return await ExecuteDoctorAsync(new CliInvocation("doctor", "run", invocation.Positionals, invocation.Options)); return subcommand.ToLowerInvariant() switch { "plan" => await ExecuteSetupPlanAsync(invocation), "apply" => await ExecuteSetupApplyAsync(invocation), _ => WriteValidationError("Usage: sdt setup [plan|apply|doctor] [--json] [--project-root ]"), }; } private static async Task ExecuteSetupPlanAsync(CliInvocation invocation) { var loaded = LoadProject(invocation); if (loaded is null) return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); var doctor = new ConfigDoctorService(new ToolProbeService(), new RequirementResolver()); var report = await doctor.RunAsync(loaded.Config, loaded.ProjectRoot); var updater = new SetupWizardConfigService(new RequirementResolver()); var update = updater.ApplyRecommendedDefaults(loaded.Config); var fixer = new ConfigDoctorAutoFixService(); var missingDirs = fixer.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot); var payload = new { contractVersion = HeadlessExecutionService.ContractVersion, 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 }, safePlan = new { createMissingWorkingDirectories = missingDirs, recommendedConfigChanges = update.Changes }, suggestedCommands = new[] { "sdt doctor run", "sdt setup apply --safe", "sdt config migrate-preview" } }; if (invocation.HasOption("--json")) WriteJson(payload); else { Console.WriteLine($"Setup plan for {loaded.ProjectRoot}"); Console.WriteLine($"- doctor failures: {report.Checks.Count(c => c.Status == DoctorStatus.Fail)}"); Console.WriteLine($"- doctor warnings: {report.Checks.Count(c => c.Status == DoctorStatus.Warn)}"); Console.WriteLine($"- missing directories: {missingDirs.Count}"); Console.WriteLine($"- recommended config changes: {update.Changes.Count}"); } return 0; } private static async Task ExecuteSetupApplyAsync(CliInvocation invocation) { var loaded = LoadProject(invocation); if (loaded is null) return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); var safeOnly = invocation.HasOption("--safe") || !invocation.HasOption("--migrate-legacy"); var fixer = new ConfigDoctorAutoFixService(); var updater = new SetupWizardConfigService(new RequirementResolver()); var missingDirs = fixer.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot); var dirResult = fixer.CreateMissingWorkingDirectories(missingDirs); var update = updater.ApplyRecommendedDefaults(loaded.Config); var saveResult = update.Changes.Count == 0 ? new LegacyMigrationApplyResult(true, "No config changes needed.") : ProjectConfigFileOperations.SaveWithBackup(loaded.ProjectRoot, update.Config); LegacyMigrationApplyResult? migrationResult = null; if (!safeOnly && loaded.Config.Targets.Count > 0) { var configPath = ConfigLoader.FindConfigPath(loaded.ProjectRoot); if (!string.IsNullOrWhiteSpace(configPath)) migrationResult = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true); } var payload = new { contractVersion = HeadlessExecutionService.ContractVersion, projectRoot = loaded.ProjectRoot, safe = safeOnly, createdDirectories = dirResult.CreatedDirectories, configSaved = saveResult.Success, backupPath = saveResult.BackupPath, recommendedChanges = update.Changes, legacyMigration = migrationResult }; if (invocation.HasOption("--json")) WriteJson(payload); else { Console.WriteLine($"Setup apply for {loaded.ProjectRoot}"); Console.WriteLine($"- created directories: {dirResult.CreatedDirectories}"); Console.WriteLine($"- config changes: {update.Changes.Count}"); if (!string.IsNullOrWhiteSpace(saveResult.BackupPath)) Console.WriteLine($"- backup: {saveResult.BackupPath}"); if (migrationResult is not null) Console.WriteLine($"- legacy migration: {migrationResult.Message}"); } if (!dirResult.Success || !saveResult.Success || (migrationResult is not null && !migrationResult.Success)) return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); return 0; } private static int ExecuteEnv(CliInvocation invocation) { var subcommand = string.IsNullOrWhiteSpace(invocation.Subcommand) ? "list" : invocation.Subcommand!; return subcommand.ToLowerInvariant() switch { "list" => ExecuteEnvList(invocation), "use" => ExecuteEnvUse(invocation), _ => WriteValidationError("Usage: sdt env [list|use] [--json] [--project-root ]"), }; } private static int ExecuteEnvList(CliInvocation invocation) { var loaded = LoadProject(invocation); if (loaded is null) return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); var payload = new { contractVersion = HeadlessExecutionService.ContractVersion, projectRoot = loaded.ProjectRoot, activeProfile = loaded.Config.EnvProfiles?.Active, profiles = loaded.Config.EnvProfiles?.Profiles ?? [], env = loaded.Config.Env }; if (invocation.HasOption("--json")) WriteJson(payload); else { Console.WriteLine($"Environment for {loaded.ProjectRoot}"); Console.WriteLine($"- active profile: {loaded.Config.EnvProfiles?.Active ?? "(none)"}"); foreach (var profile in loaded.Config.EnvProfiles?.Profiles ?? []) Console.WriteLine($"- profile {profile.Id}: {profile.Description}"); } return 0; } private static int ExecuteEnvUse(CliInvocation invocation) { var loaded = LoadProject(invocation); if (loaded is null) return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); if (invocation.Positionals.Count == 0) return WriteValidationError("Usage: sdt env use [--json] [--project-root ]"); var profileId = invocation.Positionals[0]; var envProfiles = loaded.Config.EnvProfiles; if (envProfiles is null || !envProfiles.Profiles.Any(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase))) return WriteValidationError($"Env profile '{profileId}' was not found."); 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 = ProjectConfigFileOperations.SaveWithBackup(loaded.ProjectRoot, updated); if (invocation.HasOption("--json")) { WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, projectRoot = loaded.ProjectRoot, activeProfile = profileId, save }); } else { Console.WriteLine($"Active env profile: {profileId}"); if (!string.IsNullOrWhiteSpace(save.BackupPath)) Console.WriteLine($"Backup: {save.BackupPath}"); } return save.Success ? 0 : ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); } private static async Task ExecuteFavoriteAsync(CliInvocation invocation) { var subcommand = string.IsNullOrWhiteSpace(invocation.Subcommand) ? "list" : invocation.Subcommand!; return subcommand.ToLowerInvariant() switch { "list" => ExecuteFavoriteList(invocation), "pin" => ExecuteFavoritePin(invocation), "run" => await ExecuteFavoriteRunAsync(invocation), _ => WriteValidationError("Usage: sdt favorite [list|pin|run] ..."), }; } private static int ExecuteFavoriteList(CliInvocation invocation) { var workspaceLoaded = WorkspaceLoader.FindAndLoad(ResolveProjectRoot(invocation)); if (workspaceLoaded is null) return WriteValidationError("No workspace could be discovered for favorites."); var (workspace, workspaceRoot) = workspaceLoaded.Value; var payload = 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(); if (invocation.HasOption("--json")) WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, workspaceRoot, favorites = payload }); else foreach (var favorite in payload) Console.WriteLine($"{favorite.id}: {favorite.label ?? favorite.workflowId} ({favorite.resolvedProjectRoot})"); return 0; } private static int ExecuteFavoritePin(CliInvocation invocation) { if (invocation.Positionals.Count == 0) return WriteValidationError("Usage: sdt favorite pin --id [--label