diff --git a/Journal.DevTool/Config/ConfigLoader.cs b/Journal.DevTool/Config/ConfigLoader.cs
new file mode 100644
index 0000000..471003d
--- /dev/null
+++ b/Journal.DevTool/Config/ConfigLoader.cs
@@ -0,0 +1,44 @@
+using System.Text.Json;
+
+namespace Sdt.Config;
+
+public static class ConfigLoader
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ AllowTrailingCommas = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ };
+
+ ///
+ /// Walks up from (or CWD) until it finds devtool.json.
+ /// Returns null if not found.
+ ///
+ public static (DevToolConfig Config, string ProjectRoot)? FindAndLoad(string? startDir = null)
+ {
+ var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory());
+ while (dir is not null)
+ {
+ var candidate = Path.Combine(dir.FullName, "devtool.json");
+ if (File.Exists(candidate))
+ {
+ try
+ {
+ var json = File.ReadAllText(candidate);
+ var config = JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidOperationException("devtool.json deserialized to null.");
+ return (config, dir.FullName);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ $"Failed to parse devtool.json at {candidate}: {ex.Message}", ex);
+ }
+ }
+ dir = dir.Parent!;
+ }
+ return null;
+ }
+}
diff --git a/Journal.DevTool/Config/DevToolConfig.cs b/Journal.DevTool/Config/DevToolConfig.cs
new file mode 100644
index 0000000..fbb91a5
--- /dev/null
+++ b/Journal.DevTool/Config/DevToolConfig.cs
@@ -0,0 +1,86 @@
+namespace Sdt.Config;
+
+public sealed class DevToolConfig
+{
+ public string Name { get; init; } = "SDT Project";
+ public string Version { get; init; } = "0.1.0";
+ public List Targets { get; init; } = [];
+ public List Env { get; init; } = [];
+ public ToolchainConfig? Toolchains { get; init; }
+}
+
+public sealed class BuildTarget
+{
+ public string Id { get; init; } = "";
+ public string Label { get; init; } = "";
+ public string Description { get; init; } = "";
+ public string Group { get; init; } = "General";
+
+ /// Executable name. Null = virtual aggregator (runs DependsOn only).
+ public string? Command { get; init; }
+
+ public List Args { get; init; } = [];
+
+ /// Working directory relative to project root.
+ public string WorkingDir { get; init; } = ".";
+
+ public List DependsOn { get; init; } = [];
+}
+
+public sealed class EnvVarDef
+{
+ public string Key { get; init; } = "";
+ public string Description { get; init; } = "";
+
+ [System.Text.Json.Serialization.JsonPropertyName("default")]
+ public string DefaultValue { get; init; } = "";
+
+ /// If non-empty, shown as a dropdown. Otherwise free-text input.
+ public List Options { get; init; } = [];
+}
+
+// ── Toolchain config ──────────────────────────────────────────────────────────
+
+public sealed class ToolchainConfig
+{
+ public PythonToolchain? Python { get; init; }
+ public NodeToolchain? Node { get; init; }
+}
+
+public sealed class PythonToolchain
+{
+ /// Python executable (e.g. "python3.14", "python").
+ public string Executable { get; init; } = "python";
+
+ /// Windows-specific override (e.g. "py" when using the launcher).
+ public string? WindowsExecutable { get; init; }
+
+ /// Optional version flag to pass (e.g. "-3.14" for py launcher).
+ public string? LauncherVersion { get; init; }
+
+ /// Venv directory relative to project root.
+ public string VenvDir { get; init; } = ".venv";
+
+ public List Profiles { get; init; } = [];
+
+ /// Optional path to a pip wrapper script (relative to project root).
+ public string? PipScript { get; init; }
+}
+
+public sealed class PythonProfile
+{
+ public string Id { get; init; } = "";
+ public string Label { get; init; } = "";
+ public string RequirementsFile { get; init; } = "";
+ public string? ExtraIndexUrl { get; init; }
+ public List PostInstallCommands { get; init; } = [];
+}
+
+public sealed class NodeToolchain
+{
+ /// Package manager: "npm", "pnpm", or "yarn".
+ public string PackageManager { get; init; } = "npm";
+
+ /// Working directory for the frontend (relative to project root).
+ public string WorkingDir { get; init; } = ".";
+}
diff --git a/Journal.DevTool/Config/WorkspaceConfig.cs b/Journal.DevTool/Config/WorkspaceConfig.cs
new file mode 100644
index 0000000..fce9cb7
--- /dev/null
+++ b/Journal.DevTool/Config/WorkspaceConfig.cs
@@ -0,0 +1,19 @@
+namespace Sdt.Config;
+
+public sealed class WorkspaceConfig
+{
+ public string Name { get; init; } = "SDT Workspace";
+ public List Projects { get; init; } = [];
+}
+
+public sealed class WorkspaceProject
+{
+ public string Name { get; init; } = "";
+ public string Description { get; init; } = "";
+
+ ///
+ /// Relative path from the sdt-workspace.json directory to the project root
+ /// (the directory containing devtool.json).
+ ///
+ public string Path { get; init; } = "";
+}
diff --git a/Journal.DevTool/Config/WorkspaceLoader.cs b/Journal.DevTool/Config/WorkspaceLoader.cs
new file mode 100644
index 0000000..a01f324
--- /dev/null
+++ b/Journal.DevTool/Config/WorkspaceLoader.cs
@@ -0,0 +1,52 @@
+using System.Text.Json;
+
+namespace Sdt.Config;
+
+public static class WorkspaceLoader
+{
+ private const string FileName = "sdt-workspace.json";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ AllowTrailingCommas = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ };
+
+ ///
+ /// Walks up from (or CWD) to find sdt-workspace.json.
+ /// Returns null if not found.
+ ///
+ public static (WorkspaceConfig Config, string WorkspaceRoot)? FindAndLoad(string? startDir = null)
+ {
+ var dir = new DirectoryInfo(startDir ?? Directory.GetCurrentDirectory());
+ while (dir is not null)
+ {
+ var candidate = Path.Combine(dir.FullName, FileName);
+ if (File.Exists(candidate))
+ {
+ try
+ {
+ var json = File.ReadAllText(candidate);
+ var config = JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidOperationException($"{FileName} deserialized to null.");
+ return (config, dir.FullName);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ $"Failed to parse {FileName} at {candidate}: {ex.Message}", ex);
+ }
+ }
+ dir = dir.Parent!;
+ }
+ return null;
+ }
+
+ ///
+ /// Resolves the absolute project root for a workspace project entry.
+ ///
+ public static string ResolveProjectRoot(string workspaceRoot, WorkspaceProject project)
+ => Path.GetFullPath(Path.Combine(workspaceRoot, project.Path));
+}
diff --git a/Journal.DevTool/Journal.DevTool.csproj b/Journal.DevTool/Journal.DevTool.csproj
new file mode 100644
index 0000000..5d8f248
--- /dev/null
+++ b/Journal.DevTool/Journal.DevTool.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ sdt
+ Sdt
+ false
+
+
+
+
+
+
+
diff --git a/Journal.DevTool/Program.cs b/Journal.DevTool/Program.cs
new file mode 100644
index 0000000..a132fc3
--- /dev/null
+++ b/Journal.DevTool/Program.cs
@@ -0,0 +1,57 @@
+using Sdt.Config;
+using Sdt.Tui;
+using Spectre.Console;
+
+// ── Workspace + project discovery ────────────────────────────────────────────
+
+var workspaceResult = WorkspaceLoader.FindAndLoad();
+var projectResult = ConfigLoader.FindAndLoad();
+
+if (projectResult is null)
+{
+ AnsiConsole.MarkupLine($"[bold {Theme.Red}]SDT:[/] [{Theme.Amber}]No devtool.json found[/] in current directory or any parent.");
+ AnsiConsole.MarkupLine(Theme.Faint("Create a devtool.json in your project root to get started."));
+ return 1;
+}
+
+// ── Main run loop (handles workspace project switching) ───────────────────────
+
+var (currentConfig, currentRoot) = projectResult.Value;
+var (workspace, workspaceRoot) = workspaceResult.HasValue
+ ? (workspaceResult.Value.Config, workspaceResult.Value.WorkspaceRoot)
+ : ((WorkspaceConfig?)null, (string?)null);
+
+try
+{
+ while (true)
+ {
+ var app = new App(currentConfig, currentRoot, workspace, workspaceRoot);
+ var result = await app.RunAsync();
+
+ if (result.Reason == AppExitReason.Quit)
+ break;
+
+ // User switched projects — reload config from new root
+ if (result.Reason == AppExitReason.SwitchProject && result.NewProjectRoot is not null)
+ {
+ var loaded = ConfigLoader.FindAndLoad(result.NewProjectRoot);
+ if (loaded is null)
+ {
+ AnsiConsole.MarkupLine(Theme.Fail($"No devtool.json found at: {result.NewProjectRoot}"));
+ AnsiConsole.MarkupLine(Theme.Faint("Press any key to stay on current project..."));
+ Console.ReadKey(intercept: true);
+ continue; // go back to current app
+ }
+
+ (currentConfig, currentRoot) = loaded.Value;
+ }
+ }
+
+ return 0;
+}
+catch (Exception ex)
+{
+ AnsiConsole.MarkupLine(Theme.Fail($"Fatal: {ex.Message}"));
+ AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything);
+ return 1;
+}
diff --git a/Journal.DevTool/Runner/ProcessRunner.cs b/Journal.DevTool/Runner/ProcessRunner.cs
new file mode 100644
index 0000000..33516cc
--- /dev/null
+++ b/Journal.DevTool/Runner/ProcessRunner.cs
@@ -0,0 +1,60 @@
+using System.Diagnostics;
+
+namespace Sdt.Runner;
+
+public sealed record RunResult(int ExitCode, TimeSpan Elapsed)
+{
+ public bool Success => ExitCode == 0;
+}
+
+public static class ProcessRunner
+{
+ ///
+ /// Runs a command with the given args, streaming stdout/stderr via .
+ /// onOutput receives (line, isStderr).
+ ///
+ public static async Task RunAsync(
+ string command,
+ IEnumerable args,
+ string workingDir,
+ Action onOutput,
+ CancellationToken cancellationToken = default)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = command,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WorkingDirectory = workingDir,
+ };
+
+ foreach (var arg in args)
+ psi.ArgumentList.Add(arg);
+
+ var sw = Stopwatch.StartNew();
+
+ using var process = new Process { StartInfo = psi };
+ process.Start();
+
+ var stdoutTask = DrainAsync(process.StandardOutput, line => onOutput(line, false), cancellationToken);
+ var stderrTask = DrainAsync(process.StandardError, line => onOutput(line, true), cancellationToken);
+
+ await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+
+ sw.Stop();
+ return new RunResult(process.ExitCode, sw.Elapsed);
+ }
+
+ private static async Task DrainAsync(StreamReader reader, Action emit, CancellationToken ct)
+ {
+ string? line;
+ while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) is not null
+ && !ct.IsCancellationRequested)
+ {
+ emit(line);
+ }
+ }
+}
diff --git a/Journal.DevTool/Runner/TargetRunner.cs b/Journal.DevTool/Runner/TargetRunner.cs
new file mode 100644
index 0000000..b4c670e
--- /dev/null
+++ b/Journal.DevTool/Runner/TargetRunner.cs
@@ -0,0 +1,41 @@
+using Sdt.Config;
+
+namespace Sdt.Runner;
+
+public static class TargetRunner
+{
+ ///
+ /// Returns the ordered list of real (non-virtual) steps needed to execute ,
+ /// respecting DependsOn chains. Each step appears at most once.
+ ///
+ public static List ResolvePlan(
+ BuildTarget target,
+ IReadOnlyDictionary allTargets)
+ {
+ var visited = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var plan = new List();
+ Visit(target, allTargets, visited, plan);
+ return plan;
+ }
+
+ private static void Visit(
+ BuildTarget target,
+ IReadOnlyDictionary allTargets,
+ HashSet visited,
+ List plan)
+ {
+ if (!visited.Add(target.Id))
+ return;
+
+ // Recurse into dependencies first (topological order)
+ foreach (var depId in target.DependsOn)
+ {
+ if (allTargets.TryGetValue(depId, out var dep))
+ Visit(dep, allTargets, visited, plan);
+ }
+
+ // Virtual aggregator targets (null Command) are just dependency collectors
+ if (target.Command is not null)
+ plan.Add(target);
+ }
+}
diff --git a/Journal.DevTool/Tui/App.cs b/Journal.DevTool/Tui/App.cs
new file mode 100644
index 0000000..3ad0118
--- /dev/null
+++ b/Journal.DevTool/Tui/App.cs
@@ -0,0 +1,311 @@
+using Sdt.Config;
+using Sdt.Runner;
+using Spectre.Console;
+
+namespace Sdt.Tui;
+
+/// Thin wrapper used in Spectre.Console selection prompts.
+internal sealed record MenuItem(string Display, string Value);
+
+public enum AppExitReason { Quit, SwitchProject }
+public sealed record AppResult(AppExitReason Reason, string? NewProjectRoot = null);
+
+public sealed class App
+{
+ private DevToolConfig _config;
+ private string _projectRoot;
+ private readonly WorkspaceConfig? _workspace;
+ private readonly string? _workspaceRoot;
+
+ private IReadOnlyDictionary TargetMap =>
+ _config.Targets.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
+
+ public App(
+ DevToolConfig config,
+ string projectRoot,
+ WorkspaceConfig? workspace = null,
+ string? workspaceRoot = null)
+ {
+ _config = config;
+ _projectRoot = projectRoot;
+ _workspace = workspace;
+ _workspaceRoot = workspaceRoot;
+ }
+
+ public async Task RunAsync()
+ {
+ while (true)
+ {
+ AnsiConsole.Clear();
+ RenderBanner();
+
+ var choice = ShowMainMenu();
+
+ switch (choice)
+ {
+ case "__env__":
+ EditEnvironment();
+ break;
+
+ case "__toolchains__":
+ await new ToolchainScreen(_config, _projectRoot).RunAsync();
+ break;
+
+ case "__workspace__":
+ if (_workspace is not null && _workspaceRoot is not null)
+ {
+ var switcher = new WorkspaceScreen(_workspace, _workspaceRoot, _projectRoot);
+ var newRoot = switcher.SelectProject();
+ if (newRoot is not null)
+ return new AppResult(AppExitReason.SwitchProject, newRoot);
+ }
+ break;
+
+ case "__quit__":
+ AnsiConsole.MarkupLine("\n" + Theme.Faint("Later.") + "\n");
+ return new AppResult(AppExitReason.Quit);
+
+ default:
+ var targetMap = TargetMap;
+ if (targetMap.TryGetValue(choice, out var target))
+ await RunTargetAsync(target, targetMap);
+ break;
+ }
+
+ if (choice != "__quit__")
+ {
+ AnsiConsole.MarkupLine("\n" + Theme.Faint("Press any key to return to the menu..."));
+ Console.ReadKey(intercept: true);
+ }
+ }
+ }
+
+ // ── Banner ────────────────────────────────────────────────────────────────
+
+ private void RenderBanner()
+ {
+ // Phosphor green figlet
+ AnsiConsole.Write(new FigletText("SDT").Color(Theme.GreenColor));
+
+ // Project + workspace info line
+ var wsInfo = _workspace is not null
+ ? $" [{Theme.GreenDim}]∙ {Markup.Escape(_workspace.Name)}[/]"
+ : string.Empty;
+
+ AnsiConsole.Write(
+ new Rule($"[bold {Theme.GreenBold}]{Markup.Escape(_config.Name)}[/] [{Theme.GreenDim}]v{Markup.Escape(_config.Version)}[/]{wsInfo}")
+ .RuleStyle(Theme.DimStyle));
+
+ AnsiConsole.MarkupLine(Theme.Faint($"root: {_projectRoot}") + "\n");
+ }
+
+ // ── Main menu ─────────────────────────────────────────────────────────────
+
+ private string ShowMainMenu()
+ {
+ var prompt = new SelectionPrompt