- 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
909 lines
38 KiB
C#
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;
|
|
}
|
|
}
|