SDT/Cli/CliApplication.cs
stan44 d5a74be368 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
2026-03-29 22:22:48 -05:00

909 lines
38 KiB
C#

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<int> 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<int> LaunchInteractiveAsync(IReadOnlyList<string> 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-<ProjectName>.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<int> 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 <workflow> [--json] [--project-root <path>] [--env-profile <id>] [--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<int> 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 <profile> [--json] [--project-root <path>] [--env-profile <id>] [--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 <path>]");
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<int> ExecuteBridgeAsync(CliInvocation invocation)
{
if (!string.Equals(invocation.Subcommand, "--stdio", StringComparison.OrdinalIgnoreCase) &&
!invocation.HasOption("--stdio"))
{
return WriteValidationError("Usage: sdt bridge --stdio [--project-root <path>]");
}
var server = new BridgeStdioServer(ResolveProjectRoot(invocation));
return await server.RunAsync();
}
private static async Task<int> 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 <path>]");
}
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<int> 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 <path>]"),
};
}
private static async Task<int> 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<int> 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 <path>]"),
};
}
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 <profile> [--json] [--project-root <path>]");
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<int> 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 <workflow> --id <favoriteId> [--label <label>] [--project-root <path>]");
var loaded = LoadProject(invocation);
if (loaded is null)
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
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 workspaceLoaded = WorkspaceLoader.FindAndLoad(loaded.ProjectRoot);
if (workspaceLoaded is null)
return WriteValidationError("No workspace could be discovered for favorites.");
var (workspace, workspaceRoot) = workspaceLoaded.Value;
var requestedId = invocation.GetOption("--id");
var favoriteId = string.IsNullOrWhiteSpace(requestedId)
? EnsureUniqueFavoriteId(workspace, GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow))
: requestedId!;
var label = invocation.GetOption("--label") ?? GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow);
var projectPath = Path.GetRelativePath(workspaceRoot, loaded.ProjectRoot);
var existing = workspace.Favorites.FirstOrDefault(f => string.Equals(f.Id, favoriteId, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
workspace.Favorites.Remove(existing);
workspace.Favorites.Add(new WorkspaceFavorite
{
Id = favoriteId,
ProjectPath = projectPath,
WorkflowId = workflow.Id,
Label = label
});
WorkspaceLoader.Save(workspaceRoot, workspace);
if (invocation.HasOption("--json"))
{
WriteJson(new
{
contractVersion = HeadlessExecutionService.ContractVersion,
workspaceRoot,
favorite = new { id = favoriteId, label, workflowId = workflow.Id, projectPath }
});
}
else
{
Console.WriteLine($"Pinned favorite '{favoriteId}' -> {workflow.Id}");
}
return 0;
}
private static async Task<int> ExecuteFavoriteRunAsync(CliInvocation invocation)
{
if (invocation.Positionals.Count == 0)
return WriteValidationError("Usage: sdt favorite run <favoriteId> [--json] [--project-root <path>] [--env-profile <id>] [--non-interactive] [--auto-install]");
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 favorite = workspace.Favorites.FirstOrDefault(f =>
string.Equals(GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId), invocation.Positionals[0], StringComparison.OrdinalIgnoreCase));
if (favorite is null)
return WriteValidationError($"Favorite '{invocation.Positionals[0]}' was not found.");
var favoriteRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, favorite);
var loaded = ConfigLoader.FindAndLoad(favoriteRoot);
if (loaded is null)
return WriteValidationError($"Favorite project could not be loaded: {favoriteRoot}");
var service = new HeadlessExecutionService();
return await service.RunWorkflowAsync(
loaded,
new HeadlessRunRequest(
WorkflowId: favorite.WorkflowId,
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 ExecuteConfig(CliInvocation invocation)
{
var subcommand = string.IsNullOrWhiteSpace(invocation.Subcommand)
? string.Empty
: invocation.Subcommand!;
return subcommand.ToLowerInvariant() switch
{
"migrate-preview" => ExecuteConfigMigratePreview(invocation),
"migrate-apply" => ExecuteConfigMigrateApply(invocation),
_ => WriteValidationError("Usage: sdt config [migrate-preview|migrate-apply] [--json] [--project-root <path>]"),
};
}
private static int ExecuteConfigMigratePreview(CliInvocation invocation)
{
var configPath = ConfigLoader.FindConfigPath(ResolveProjectRoot(invocation));
if (string.IsNullOrWhiteSpace(configPath))
return WriteValidationError("No project config found for migration preview.");
var result = ConfigLoader.WriteLegacyMigrationPreview(configPath, invocation.GetOption("--output"));
if (invocation.HasOption("--json"))
WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, result });
else
Console.WriteLine(result.Message + (string.IsNullOrWhiteSpace(result.ConfigPath) ? string.Empty : $" {result.ConfigPath}"));
return result.Success ? 0 : ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
}
private static int ExecuteConfigMigrateApply(CliInvocation invocation)
{
var configPath = ConfigLoader.FindConfigPath(ResolveProjectRoot(invocation));
if (string.IsNullOrWhiteSpace(configPath))
return WriteValidationError("No project config found for migration.");
var result = ConfigLoader.ApplyLegacyTargetMigration(configPath, createBackup: true);
if (invocation.HasOption("--json"))
WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, result });
else
Console.WriteLine(result.Message + (string.IsNullOrWhiteSpace(result.BackupPath) ? string.Empty : $" Backup: {result.BackupPath}"));
return result.Success ? 0 : ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
}
private static async Task<int> ExecuteExplainAsync(CliInvocation invocation)
{
if (invocation.Positionals.Count == 0)
return WriteValidationError("Usage: sdt explain <task> [--json] [--project-root <path>] [--env-profile <id>]");
var selector = invocation.Positionals[0];
if (string.Equals(selector, "setup", StringComparison.OrdinalIgnoreCase))
return await ExecuteSetupPlanAsync(invocation);
if (string.Equals(selector, "doctor", StringComparison.OrdinalIgnoreCase))
return await ExecuteDoctorAsync(new CliInvocation("doctor", "run", [], invocation.Options));
var workspaceLoaded = WorkspaceLoader.FindAndLoad(ResolveProjectRoot(invocation));
if (workspaceLoaded is not null)
{
var favorite = workspaceLoaded.Value.Config.Favorites.FirstOrDefault(f =>
string.Equals(GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId), selector, StringComparison.OrdinalIgnoreCase));
if (favorite is not null)
{
var resolvedRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceLoaded.Value.WorkspaceRoot, favorite);
var payload = new
{
contractVersion = HeadlessExecutionService.ContractVersion,
task = selector,
type = "favorite",
projectRoot = resolvedRoot,
workflowId = favorite.WorkflowId,
label = favorite.Label,
runCommand = $"sdt favorite run {GuidedTaskCatalog.EnsureFavoriteId(favorite, favorite.WorkflowId)} --project-root \"{ResolveProjectRoot(invocation)}\""
};
if (invocation.HasOption("--json"))
WriteJson(payload);
else
{
Console.WriteLine($"Favorite {selector}");
Console.WriteLine($"- project root: {resolvedRoot}");
Console.WriteLine($"- workflow: {favorite.WorkflowId}");
}
return 0;
}
}
var loaded = LoadProject(invocation);
if (loaded is null)
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
var normalized = WorkflowModelBuilder.Normalize(loaded.Config, ResolveLegacyMode(), new RequirementResolver());
var workflow = GuidedTaskCatalog.FindWorkflow(normalized.Workflows, selector);
if (workflow is not null)
{
var requirementResolver = new RequirementResolver();
var tools = workflow.Steps
.SelectMany(step => requirementResolver.Resolve(step))
.Select(req => req.Tool)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var payload = new
{
contractVersion = HeadlessExecutionService.ContractVersion,
task = selector,
type = "workflow",
projectRoot = loaded.ProjectRoot,
envProfile = invocation.GetOption("--env-profile") ?? loaded.Config.EnvProfiles?.Active,
workflow = new
{
id = workflow.Id,
label = GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow),
description = workflow.Description,
category = GuidedTaskCatalog.GetWorkflowCategory(workflow),
aliases = workflow.Aliases,
tags = workflow.Tags,
steps = workflow.Steps.Select(step => new
{
id = step.Id,
label = step.Label,
command = step.Command,
action = step.Action,
workingDir = step.WorkingDir
})
},
toolsChecked = tools,
runCommand = $"sdt run {workflow.Id} --project-root \"{loaded.ProjectRoot}\""
};
if (invocation.HasOption("--json"))
WriteJson(payload);
else
{
Console.WriteLine($"Workflow {GuidedTaskCatalog.GetWorkflowDisplayLabel(workflow)}");
Console.WriteLine($"- project root: {loaded.ProjectRoot}");
Console.WriteLine($"- description: {workflow.Description}");
Console.WriteLine($"- tools checked: {(tools.Count == 0 ? "(none)" : string.Join(", ", tools))}");
Console.WriteLine($"- run command: sdt run {workflow.Id} --project-root \"{loaded.ProjectRoot}\"");
}
return 0;
}
var profile = GuidedTaskCatalog.FindDebugProfile(loaded.Config.Debug?.Profiles ?? [], selector);
if (profile is not null)
{
var payload = new
{
contractVersion = HeadlessExecutionService.ContractVersion,
task = selector,
type = "debug",
projectRoot = loaded.ProjectRoot,
envProfile = invocation.GetOption("--env-profile") ?? loaded.Config.EnvProfiles?.Active,
profile = new
{
id = profile.Id,
label = GuidedTaskCatalog.GetDebugDisplayLabel(profile),
description = profile.Description,
category = GuidedTaskCatalog.GetDebugCategory(profile),
aliases = profile.Aliases,
tags = profile.Tags,
command = profile.Command,
args = profile.Args,
workingDir = profile.WorkingDir
},
runCommand = $"sdt debug {profile.Id} --project-root \"{loaded.ProjectRoot}\""
};
if (invocation.HasOption("--json"))
WriteJson(payload);
else
{
Console.WriteLine($"Debug {GuidedTaskCatalog.GetDebugDisplayLabel(profile)}");
Console.WriteLine($"- project root: {loaded.ProjectRoot}");
Console.WriteLine($"- description: {profile.Description}");
Console.WriteLine($"- run command: sdt debug {profile.Id} --project-root \"{loaded.ProjectRoot}\"");
}
return 0;
}
return WriteValidationError($"Task '{selector}' was not found as a workflow, debug profile, or favorite.");
}
private static int ExecuteHelp()
{
Console.WriteLine("""
SDT
Guided commands:
sdt setup plan
sdt setup apply --safe
sdt doctor run
sdt env list
sdt env use <profile>
sdt favorite list
sdt favorite pin <workflow> --id <favoriteId>
sdt favorite run <favoriteId>
sdt explain <task>
Expert commands:
sdt run <workflow>
sdt debug <profile>
sdt workspace scan
sdt bridge --stdio
sdt config migrate-preview
sdt config migrate-apply
Common flags:
--project-root <path>
--env-profile <id>
--json
--non-interactive
--auto-install
--brief
--plain
""");
return 0;
}
private static LoadedProjectConfig? LoadProject(CliInvocation invocation)
{
try
{
return ConfigLoader.FindAndLoad(ResolveProjectRoot(invocation));
}
catch (Exception ex)
{
WriteJson(new { contractVersion = HeadlessExecutionService.ContractVersion, success = false, message = ex.Message });
return null;
}
}
private static string ResolveProjectRoot(CliInvocation invocation)
=> invocation.GetOption("--project-root") ?? Directory.GetCurrentDirectory();
private static int WriteValidationError(string message)
{
WriteJson(new
{
contractVersion = HeadlessExecutionService.ContractVersion,
success = false,
message
});
return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed);
}
private static void WriteJson(object payload)
=> Console.WriteLine(JsonSerializer.Serialize(payload, JsonOptions));
private static LegacyMode ResolveLegacyMode()
{
var raw = Environment.GetEnvironmentVariable("SDT_LEGACY_MODE");
return string.Equals(raw, "compat", StringComparison.OrdinalIgnoreCase)
? LegacyMode.Compat
: LegacyMode.Strict;
}
private static string EnsureUniqueFavoriteId(WorkspaceConfig workspace, string baseValue)
{
var slug = GuidedTaskCatalog.Slugify(baseValue);
var candidate = slug;
var counter = 2;
while (workspace.Favorites.Any(f => string.Equals(GuidedTaskCatalog.EnsureFavoriteId(f, f.WorkflowId), candidate, StringComparison.OrdinalIgnoreCase)))
{
candidate = $"{slug}-{counter}";
counter++;
}
return candidate;
}
}