From d5a74be3681d6fad0ad7e418a6388e2d5d492f12 Mon Sep 17 00:00:00 2001 From: stan44 Date: Sun, 29 Mar 2026 22:22:48 -0500 Subject: [PATCH] Add guided CLI workflows and config commands - introduce `sdt` subcommands for run, debug, setup, env, favorite, and explain - add project/workspace discovery plus config bootstrap and migration helpers - expand tests for CLI parsing, project role detection, and headless flows --- .gitignore | 2 +- Cli/CliApplication.cs | 908 ++++++++++++++++++ Cli/CliInvocation.cs | 13 + Cli/CliParser.cs | 94 ++ Program.cs | 360 +------ README.md | 7 - scripts/build.py | 55 ++ sdtconfig-DevTool-master.json | 606 ++++++++++++ .../Config/ConfigBootstrapper.cs | 179 +++- src/DevTool.Engine/Config/ConfigLoader.cs | 46 +- src/DevTool.Engine/Config/DevToolConfig.cs | 9 + .../Config/ProjectConfigFileOperations.cs | 23 + .../Config/WorkflowModelBuilder.cs | 2 + src/DevTool.Engine/Config/WorkspaceConfig.cs | 5 + .../Core/ConfigDoctorService.cs | 6 +- src/DevTool.Engine/Core/FailureCard.cs | 6 +- src/DevTool.Engine/Core/GuidedTaskCatalog.cs | 111 +++ .../Core/HeadlessExecutionService.cs | 88 +- .../Core/ProjectRoleDetector.cs | 416 ++++++++ src/DevTool.Host.Bridge/BridgeStdioServer.cs | 4 + src/DevTool.Host.Tui/Tui/App.cs | 120 ++- src/DevTool.Host.Tui/Tui/EventsScreen.cs | 5 +- tests/DevTool.Tests/CliParserTests.cs | 31 + .../DevTool.Tests/ConfigBootstrapperTests.cs | 87 +- tests/DevTool.Tests/HeadlessExecutionTests.cs | 39 +- tests/DevTool.Tests/LegacyModeTests.cs | 17 +- .../DevTool.Tests/ProjectRoleDetectorTests.cs | 98 ++ tests/DevTool.Tests/ScriptSmokeTests.cs | 27 + .../DevTool.Tests/WorkspaceFavoritesTests.cs | 2 + 29 files changed, 2852 insertions(+), 514 deletions(-) create mode 100644 Cli/CliApplication.cs create mode 100644 Cli/CliInvocation.cs create mode 100644 Cli/CliParser.cs create mode 100644 sdtconfig-DevTool-master.json create mode 100644 src/DevTool.Engine/Config/ProjectConfigFileOperations.cs create mode 100644 src/DevTool.Engine/Core/GuidedTaskCatalog.cs create mode 100644 src/DevTool.Engine/Core/ProjectRoleDetector.cs create mode 100644 tests/DevTool.Tests/CliParserTests.cs create mode 100644 tests/DevTool.Tests/ProjectRoleDetectorTests.cs diff --git a/.gitignore b/.gitignore index 1482015..80c9162 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ publish-test/ /node_modules/ /src/DevTool.Host.Gui/TauriShell/node_modules/ output/ - +DevTool/ diff --git a/Cli/CliApplication.cs b/Cli/CliApplication.cs new file mode 100644 index 0000000..ba67484 --- /dev/null +++ b/Cli/CliApplication.cs @@ -0,0 +1,908 @@ +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