Compare commits

...

5 Commits

Author SHA1 Message Date
Carlos Zamora
7ab10aba3b Add support for OhMyPosh, .wt.json, and shell integration management 2026-03-05 14:16:42 -08:00
Carlos Zamora
911683c561 Add support for fragment extension management 2026-03-04 14:49:45 -08:00
Carlos Zamora
55b80d16c4 Improve UX flow of modifying settings; remove extra tools 2026-03-04 13:25:18 -08:00
Carlos Zamora
73f5bb032b Add a simple MCP server for WT settings 2026-03-02 19:24:32 -08:00
Carlos Zamora
9f7d3fe179 [GH Copilot] Add copilot-instructions.md 2026-03-02 13:58:34 -08:00
19 changed files with 3793 additions and 0 deletions

152
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,152 @@
# Copilot Instructions for Windows Terminal
## Build & Test
### Building
**Important:** `Set-MsBuildDevEnvironment` (or `.\tools\razzle.cmd`) must be run first in each new terminal session to put the VS 2022 MSBuild on your PATH.
**Full solution (OpenConsole + Terminal):**
```powershell
Import-Module .\tools\OpenConsole.psm1
Set-MsBuildDevEnvironment
Invoke-OpenConsoleBuild
```
**Cmd** (after `.\tools\razzle.cmd`):
```cmd
bcz
```
### Building Windows Terminal from the CLI
Use MSBuild solution-level targets to build specific Terminal projects without opening Visual Studio. This is the recommended way to verify compilation from the command line.
```powershell
Import-Module .\tools\OpenConsole.psm1
Set-MsBuildDevEnvironment
# Build TerminalAppLib — compiles TerminalCore, TerminalControl, SettingsModel, SettingsEditor, and TerminalApp
# This is the best single target for verifying most Terminal changes compile (~16s incremental)
msbuild OpenConsole.slnx /t:"Terminal\TerminalAppLib" /p:Configuration=Debug /p:Platform=x64 /p:AppxBundle=false /p:GenerateAppxPackageOnBuild=false /m
# Build the full CascadiaPackage (all Terminal components + packaging, ~27s incremental)
msbuild OpenConsole.slnx /t:"Terminal\CascadiaPackage" /p:Configuration=Debug /p:Platform=x64 /p:AppxBundle=false /p:GenerateAppxPackageOnBuild=false /m
# Build just the SettingsModel library
msbuild OpenConsole.slnx /t:"Terminal\Settings\Microsoft_Terminal_Settings_ModelLib" /p:Configuration=Debug /p:Platform=x64 /p:AppxBundle=false /m
```
Target names are derived from slnx folder paths and project file names (dots become underscores). Common targets:
- `Terminal\TerminalAppLib` — Terminal app library (depends on most other Terminal projects)
- `Terminal\CascadiaPackage` — Full Terminal package (all projects)
- `Terminal\Settings\Microsoft_Terminal_Settings_ModelLib` — Settings model only
- `Terminal\Settings\Microsoft_Terminal_Settings_Editor` — Settings editor only
**Deploy after building** (requires the CascadiaPackage target):
```powershell
& "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\DeployAppRecipe.exe" src\cascadia\CascadiaPackage\bin\x64\Debug\CascadiaPackage.build.appxrecipe
```
### Running Tests
**PowerShell:**
```powershell
Import-Module .\tools\OpenConsole.psm1
Invoke-OpenConsoleTests # all unit tests
Invoke-OpenConsoleTests -Test terminalCore # single test project
Invoke-OpenConsoleTests -Test unitSettingsModel # settings model tests
Invoke-OpenConsoleTests -Test til # TIL library tests
```
Valid `-Test` values: `host`, `interactivityWin32`, `terminal`, `adapter`, `feature`, `uia`, `textbuffer`, `til`, `types`, `terminalCore`, `terminalApp`, `localTerminalApp`, `unitSettingsModel`, `unitControl`, `winconpty`.
**Cmd** (after `.\tools\razzle.cmd`):
```cmd
runut.cmd # all unit tests
te.exe MyTests.dll /name:*TestMethodPattern* # single test by name pattern
runut *Tests.dll /name:TextBufferTests::TestMethod /waitForDebugger # debug a test
```
### Code Formatting
C++ formatting uses clang-format (config in `.clang-format`, Microsoft style base, no column limit). Run:
```powershell
Invoke-CodeFormat # PowerShell
runformat # Cmd
```
XAML formatting uses XamlStyler (config in `XamlStyler.json`).
## Architecture
This repo contains two products sharing core components:
- **Windows Terminal** (`src/cascadia/`) — modern terminal app using WinUI/XAML, C++/WinRT
- **Windows Console Host** (`src/host/`) — classic `conhost.exe`, built in C/C++ with Win32
### Key shared libraries
| Path | Purpose |
|------|---------|
| `src/buffer/out/` | Text buffer (UTF-16/UTF-8 storage) |
| `src/renderer/` | Rendering abstraction (base, DX, GDI engines) |
| `src/terminal/parser/` | VT sequence state machine & parser |
| `src/terminal/adapter/` | Converts VT verbs to console API calls |
| `src/types/` | Shared types (Viewport, ColorFix, etc.) |
| `src/til/` | Terminal Implementation Library — header-only utilities (math, color, string, enumset, rect) |
### Windows Terminal layers (`src/cascadia/`)
| Layer | Purpose |
|-------|---------|
| `TerminalCore` | Core terminal instance (`Terminal` class) — buffer, VT parsing, input. No UI dependency |
| `TerminalControl` | UWP/XAML control wrapping TerminalCore, DX renderer, input translation |
| `TerminalApp` | Application logic: tabs, panes, command palette, settings UI hosting |
| `TerminalConnection` | Backend connections (ConPTY, Azure Cloud Shell, SSH) |
| `TerminalSettingsModel` | Settings parsing, serialization, profile/scheme/action model |
| `TerminalSettingsEditor` | Settings UI pages (XAML + ViewModel pattern) |
| `WindowsTerminal` | Win32 EXE host: XAML Islands, window chrome, non-client area |
| `Remoting` | Multi-instance coordination (Monarch/Peasant pattern) |
| `CascadiaPackage` | MSIX packaging project |
## Key Conventions
### C++ Style
- Follow existing style in modified code; use Modern C++ and the [C++ Core Guidelines](https://github.com/isocpp/CppCoreGuidelines) for new code
- Use [WIL](https://github.com/Microsoft/wil) smart pointers and result macros (`RETURN_IF_FAILED`, `THROW_IF_FAILED`, `LOG_IF_FAILED`, etc.) for Win32/NT API interaction
- Prefer `HRESULT` over `NTSTATUS`. Functions that always succeed should not return a status code
- Do not let exceptions leak from new code into old (non-exception-safe) code. Encapsulate exception behaviors within classes
- Private members prefixed with `_` (e.g., `_somePrivateMethod()`, `_myField`)
### C++/WinRT & XAML Patterns
- Use `WINRT_PROPERTY(type, name)` for simple getters/setters, `WINRT_OBSERVABLE_PROPERTY` for properties with `PropertyChanged` events (defined in `src/cascadia/inc/cppwinrt_utils.h`)
- Use `TYPED_EVENT` macro for event declarations
- Use `safe_void_coroutine` instead of `winrt::fire_and_forget` — it logs exceptions via `LOG_CAUGHT_EXCEPTION()` instead of silently terminating
- Be mindful of C++/WinRT [strong/weak references](https://docs.microsoft.com/en-us/windows/uwp/cpp-and-winrt-apis/weak-references) and [concurrency](https://docs.microsoft.com/en-us/windows/uwp/cpp-and-winrt-apis/concurrency) in `TerminalApp` code
### Settings Editor UI Pages
Each page consists of coordinated files:
- `.idl` — WinRT interface definition
- `.xaml` — UI layout (uses `x:Bind` to ViewModel)
- `.h` / `.cpp` — Page code-behind (inherits generated `*T<>` base, calls `InitializeComponent()`)
- `*ViewModel.idl` / `.h` / `.cpp` — ViewModel with properties and logic
Navigation uses string tags (defined in `NavConstants.h`) dispatched in `MainPage.cpp`.
### Settings Model (`TerminalSettingsModel`)
- Settings types use an `IInheritable` pattern for layered defaults (profile inherits from defaults)
- IDL files define WinRT projections; C++ headers use macros like `WINRT_PROPERTY` for implementation
- JSON serialization uses `JsonUtils.h` helpers
### Test Organization
- Unit tests: `ut_*` subdirectories (e.g., `src/host/ut_host/`, `src/cascadia/UnitTests_TerminalCore/`)
- Feature tests: `ft_*` subdirectories (e.g., `src/host/ft_api/`)
- Local app tests: `src/cascadia/LocalTests_TerminalApp/` (requires AppContainer)
- Tests use TAEF (Test Authoring and Execution Framework) with `WexTestClass.h`, not Visual Studio Test
- Test classes use `TEST_CLASS()` and `TEST_METHOD()` macros
### Code Organization
- Package new components as libraries with clean interfaces
- Place shared interfaces in `inc/` folders
- Structure related libraries together (e.g., `terminal/parser` + `terminal/adapter`)

View File

@@ -1056,5 +1056,7 @@
<Build Solution="Fuzzing|x64" Project="false" />
<Build Solution="Fuzzing|x86" Project="false" />
</Project>
<Project Path="src/tools/wt.mcp/wt.mcp.csproj" Id="14ae7ba4-7592-4d77-bad1-5fcb0ace7399">
</Project>
</Folder>
</Solution>

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
"description": "<your description here>",
"name": "io.github.<your GitHub username here>/<your repo name>",
"version": "0.1.0-beta",
"packages": [
{
"registryType": "nuget",
"identifier": "<your package ID here>",
"version": "0.1.0-beta",
"transport": {
"type": "stdio"
},
"packageArguments": [],
"environmentVariables": []
}
],
"repository": {
"url": "https://github.com/<your GitHub username here>/<your repo name>",
"source": "github"
}
}

View File

@@ -0,0 +1,210 @@
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// Shared helpers for Windows Terminal fragment extension paths and I/O.
/// </summary>
internal static class FragmentHelper
{
private static readonly JsonSerializerOptions s_writeOptions = new() { WriteIndented = true };
/// <summary>
/// Returns the user-scope fragments directory:
/// %LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments
/// </summary>
public static string GetUserFragmentsRoot()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "Microsoft", "Windows Terminal", "Fragments");
}
/// <summary>
/// Returns the machine-scope fragments directory:
/// %PROGRAMDATA%\Microsoft\Windows Terminal\Fragments
/// </summary>
public static string GetMachineFragmentsRoot()
{
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
return Path.Combine(programData, "Microsoft", "Windows Terminal", "Fragments");
}
/// <summary>
/// Returns the full path to a specific fragment file.
/// </summary>
public static string GetFragmentPath(string appName, string fileName)
{
if (!fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
fileName += ".json";
}
return Path.Combine(GetUserFragmentsRoot(), appName, fileName);
}
/// <summary>
/// Finds a fragment file by searching user-scope first, then machine-scope.
/// Returns the full path if found, or null if not found in either location.
/// </summary>
public static string? FindFragmentPath(string appName, string fileName)
{
if (!fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
fileName += ".json";
}
var userPath = Path.Combine(GetUserFragmentsRoot(), appName, fileName);
if (File.Exists(userPath))
{
return userPath;
}
var machinePath = Path.Combine(GetMachineFragmentsRoot(), appName, fileName);
if (File.Exists(machinePath))
{
return machinePath;
}
return null;
}
/// <summary>
/// Lists all installed fragments from both user and machine scopes.
/// Returns (scope, appName, fileName, fullPath) tuples.
/// </summary>
public static List<(string Scope, string AppName, string FileName, string FullPath)> ListAllFragments()
{
var results = new List<(string, string, string, string)>();
ScanFragmentRoot(GetUserFragmentsRoot(), "user", results);
ScanFragmentRoot(GetMachineFragmentsRoot(), "machine", results);
return results;
}
/// <summary>
/// Reads a fragment file and returns its content as a pretty-printed JSON string.
/// Returns (content, error).
/// </summary>
public static (string? Content, string? Error) ReadFragment(string appName, string fileName)
{
var path = FindFragmentPath(appName, fileName);
if (path is null)
{
return (null, $"Fragment not found for {appName}/{fileName} in user or machine scope.");
}
return ReadFragmentAt(path);
}
/// <summary>
/// Reads all fragment files for a given app name across both scopes.
/// Returns a list of (fileName, fullPath, content) tuples.
/// </summary>
public static List<(string FileName, string FullPath, string Content)> ReadAllFragmentsForApp(string appName)
{
var results = new List<(string, string, string)>();
var roots = new[] { GetUserFragmentsRoot(), GetMachineFragmentsRoot() };
foreach (var root in roots)
{
var appDir = Path.Combine(root, appName);
if (!Directory.Exists(appDir))
{
continue;
}
foreach (var file in Directory.GetFiles(appDir, "*.json"))
{
var (content, _) = ReadFragmentAt(file);
if (content is not null)
{
results.Add((Path.GetFileName(file), file, content));
}
}
}
return results;
}
private static (string? Content, string? Error) ReadFragmentAt(string path)
{
var json = File.ReadAllText(path);
// Pretty-print for consistent output
try
{
var doc = JsonNode.Parse(json);
if (doc is not null)
{
return (doc.ToJsonString(s_writeOptions), null);
}
}
catch
{
// Fall through and return raw content
}
return (json, null);
}
/// <summary>
/// Writes a fragment file, creating the directory if needed.
/// </summary>
public static string WriteFragment(string appName, string fileName, string content)
{
var path = GetFragmentPath(appName, fileName);
var directory = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(directory);
// Back up if overwriting
if (File.Exists(path))
{
File.Copy(path, path + ".bak", overwrite: true);
}
File.WriteAllText(path, content);
return path;
}
/// <summary>
/// Pretty-prints a JSON string.
/// </summary>
public static string PrettyPrint(string json)
{
try
{
var doc = JsonNode.Parse(json);
if (doc is not null)
{
return doc.ToJsonString(s_writeOptions);
}
}
catch
{
// Return as-is if parsing fails
}
return json;
}
private static void ScanFragmentRoot(
string root,
string scope,
List<(string Scope, string AppName, string FileName, string FullPath)> results)
{
if (!Directory.Exists(root))
{
return;
}
foreach (var appDir in Directory.GetDirectories(root))
{
var appName = Path.GetFileName(appDir);
foreach (var file in Directory.GetFiles(appDir, "*.json"))
{
results.Add((scope, appName, Path.GetFileName(file), file));
}
}
}
}

View File

@@ -0,0 +1,239 @@
using System.Text.RegularExpressions;
/// <summary>
/// Helpers for detecting and interacting with Oh My Posh installations.
/// </summary>
internal static partial class OhMyPoshHelper
{
// Well-known install locations for Oh My Posh on Windows
private static readonly string[] s_knownPaths =
[
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "oh-my-posh", "bin", "oh-my-posh.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "oh-my-posh", "bin", "oh-my-posh.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "oh-my-posh", "bin", "oh-my-posh.exe"),
];
/// <summary>
/// Finds the oh-my-posh executable. Checks well-known install locations first,
/// then falls back to where.exe PATH lookup. The MCP server process may not have
/// the same PATH as the user's interactive shell.
/// </summary>
public static string? FindExecutable()
{
// Check well-known locations first (most reliable — PATH may differ for this process)
foreach (var knownPath in s_knownPaths)
{
if (File.Exists(knownPath))
{
return knownPath;
}
}
// Fall back to where.exe PATH search
var result = ProcessHelper.Run("where.exe", "oh-my-posh");
if (result is null || result.ExitCode != 0 || string.IsNullOrWhiteSpace(result.Stdout))
{
return null;
}
var firstLine = result.Stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0].Trim();
return File.Exists(firstLine) ? firstLine : null;
}
/// <summary>
/// Gets the Oh My Posh version string, or null if not installed.
/// Uses the resolved executable path rather than relying on PATH.
/// </summary>
public static string? GetVersion()
{
var exePath = FindExecutable();
if (exePath is null)
{
return null;
}
try
{
var info = System.Diagnostics.FileVersionInfo.GetVersionInfo(exePath);
var version = (info.ProductVersion ?? info.FileVersion)?.Trim('\r', '\n', '\0', ' ');
return string.IsNullOrEmpty(version) ? null : version;
}
catch
{
return null;
}
}
/// <summary>
/// Returns the themes directory path. Checks POSH_THEMES_PATH env var first,
/// then derives from executable location, then checks the default install path.
/// </summary>
public static string? GetThemesDirectory()
{
var envPath = Environment.GetEnvironmentVariable("POSH_THEMES_PATH");
if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
{
return envPath;
}
// Derive from executable location: .../oh-my-posh/bin/oh-my-posh.exe → .../oh-my-posh/themes
var exePath = FindExecutable();
if (exePath is not null)
{
var binDir = Path.GetDirectoryName(exePath);
if (binDir is not null)
{
var themesFromExe = Path.Combine(Path.GetDirectoryName(binDir)!, "themes");
if (Directory.Exists(themesFromExe))
{
return themesFromExe;
}
}
}
// Default location: %LOCALAPPDATA%\Programs\oh-my-posh\themes
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var defaultPath = Path.Combine(localAppData, "Programs", "oh-my-posh", "themes");
if (Directory.Exists(defaultPath))
{
return defaultPath;
}
return null;
}
/// <summary>
/// Lists all available theme files in the themes directory.
/// Returns (name, fullPath) tuples.
/// </summary>
public static List<(string Name, string FullPath)> ListThemes()
{
var themesDir = GetThemesDirectory();
if (themesDir is null)
{
return [];
}
var themes = new List<(string, string)>();
foreach (var ext in new[] { "*.omp.json", "*.omp.yaml", "*.omp.toml" })
{
foreach (var file in Directory.GetFiles(themesDir, ext))
{
var name = Path.GetFileName(file);
// Strip the .omp.{ext} suffix for a clean name
var cleanName = name;
if (cleanName.Contains(".omp."))
{
cleanName = cleanName[..cleanName.IndexOf(".omp.")];
}
themes.Add((cleanName, file));
}
}
themes.Sort((a, b) => string.Compare(a.Item1, b.Item1, StringComparison.OrdinalIgnoreCase));
return themes;
}
/// <summary>
/// Attempts to find the user's current OMP config path by scanning the PowerShell profile.
/// Returns the config file path if found, null otherwise.
/// </summary>
public static string? DetectConfigFromProfile()
{
var profilePath = GetPowerShellProfilePath();
if (profilePath is null || !File.Exists(profilePath))
{
return null;
}
var content = File.ReadAllText(profilePath);
return ExtractConfigPath(content);
}
/// <summary>
/// Extracts the OMP config path from shell profile content.
/// Looks for patterns like: oh-my-posh init pwsh --config 'path'
/// </summary>
internal static string? ExtractConfigPath(string profileContent)
{
// Match: oh-my-posh init <shell> --config <path>
// The path may be quoted with single quotes, double quotes, or unquoted
// Match new syntax first, then fall back to legacy
var match = ConfigPathRegex().Match(profileContent);
if (!match.Success)
{
match = LegacyConfigPathRegex().Match(profileContent);
}
if (match.Success)
{
var configPath = match.Groups["path"].Value.Trim('\'', '"');
// Expand environment variables
configPath = Environment.ExpandEnvironmentVariables(configPath);
// Expand ~ to user profile
if (configPath.StartsWith('~'))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
configPath = Path.Combine(home, configPath[1..].TrimStart('/', '\\'));
}
// Expand $env: variables
configPath = ExpandPowerShellEnvVars(configPath);
return configPath;
}
return null;
}
/// <summary>
/// Returns the PowerShell profile path ($PROFILE equivalent).
/// </summary>
public static string? GetPowerShellProfilePath()
{
var docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
if (string.IsNullOrEmpty(docs))
{
return null;
}
// Check PowerShell 7+ first, then Windows PowerShell
var pwsh7 = Path.Combine(docs, "PowerShell", "Microsoft.PowerShell_profile.ps1");
if (File.Exists(pwsh7))
{
return pwsh7;
}
var pwsh5 = Path.Combine(docs, "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1");
if (File.Exists(pwsh5))
{
return pwsh5;
}
// Return the PowerShell 7 path as the default even if it doesn't exist yet
return pwsh7;
}
private static string ExpandPowerShellEnvVars(string path)
{
// Expand $env:VAR_NAME patterns
return PsEnvVarRegex().Replace(path, match =>
{
var varName = match.Groups[1].Value;
return Environment.GetEnvironmentVariable(varName) ?? match.Value;
});
}
[GeneratedRegex(@"\$env:(\w+)", RegexOptions.IgnoreCase)]
private static partial Regex PsEnvVarRegex();
// New syntax: oh-my-posh init pwsh --config <path>
[GeneratedRegex(@"oh-my-posh\s+init\s+\w+\s+--config\s+(?<path>'[^']+'|""[^""]+""|[^\s|;]+)", RegexOptions.IgnoreCase)]
private static partial Regex ConfigPathRegex();
// Old syntax: oh-my-posh --init --shell pwsh --config <path>
[GeneratedRegex(@"oh-my-posh\s+--init\s+--shell\s+\w+\s+--config\s+(?<path>'[^']+'|""[^""]+""|[^\s|;]+)", RegexOptions.IgnoreCase)]
private static partial Regex LegacyConfigPathRegex();
}

View File

@@ -0,0 +1,75 @@
using System.Diagnostics;
using System.Text;
/// <summary>
/// Runs subprocesses with a timeout and async I/O to avoid deadlocks.
/// All MCP server subprocess calls should use this helper.
/// </summary>
internal static class ProcessHelper
{
/// <summary>
/// Runs a process and returns its stdout, stderr, and exit code.
/// Returns null if the process fails to start, times out, or throws.
/// Reads stdout and stderr asynchronously to avoid buffer deadlocks.
/// </summary>
public static ProcessResult? Run(
string fileName,
string arguments,
int timeoutMs = 5000,
Encoding? outputEncoding = null)
{
try
{
var psi = new ProcessStartInfo(fileName, arguments)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (outputEncoding is not null)
{
psi.StandardOutputEncoding = outputEncoding;
}
using var proc = Process.Start(psi);
if (proc is null)
{
return null;
}
// Read both streams asynchronously to avoid deadlock and allow
// WaitForExit to enforce the timeout before streams block us
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
var stderrTask = proc.StandardError.ReadToEndAsync();
if (!proc.WaitForExit(timeoutMs))
{
// Process exceeded timeout — kill it
try
{
proc.Kill(entireProcessTree: true);
}
catch
{
// Best effort
}
return null;
}
// Process exited in time — collect output
var stdout = stdoutTask.GetAwaiter().GetResult();
var stderr = stderrTask.GetAwaiter().GetResult();
return new ProcessResult(stdout, stderr, proc.ExitCode);
}
catch
{
return null;
}
}
internal record ProcessResult(string Stdout, string Stderr, int ExitCode);
}

View File

@@ -0,0 +1,147 @@
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// Validates Windows Terminal fragment JSON structure.
/// </summary>
internal static class SchemaValidator
{
/// <summary>
/// Validates a fragment JSON string. Fragments have a subset structure
/// (profiles, schemes, actions) so we do structural validation.
/// </summary>
public static List<string> ValidateFragment(string json)
{
var errors = new List<string>();
JsonNode? doc;
try
{
doc = JsonNode.Parse(json);
}
catch (JsonException ex)
{
errors.Add($"Invalid JSON: {ex.Message}");
return errors;
}
if (doc is not JsonObject root)
{
errors.Add("Fragment must be a JSON object.");
return errors;
}
// The product silently ignores unknown keys, so flag these as warnings
// rather than hard errors (still included in the returned list)
var knownKeys = new HashSet<string> { "profiles", "schemes", "actions" };
foreach (var key in root.Select(p => p.Key))
{
if (!knownKeys.Contains(key))
{
errors.Add($"Warning: unknown top-level key \"{key}\" will be ignored. Fragments support: profiles, schemes, actions.");
}
}
// Validate profiles structure
// The product accepts "profiles" as either a direct array or an object
// with a "list" key (same as settings.json)
if (root["profiles"] is JsonNode profilesNode)
{
JsonArray? profileArray = null;
if (profilesNode is JsonArray directArray)
{
profileArray = directArray;
}
else if (profilesNode is JsonObject profilesObj && profilesObj["list"] is JsonArray listArray)
{
profileArray = listArray;
}
else
{
errors.Add("\"profiles\" must be an array or an object with a \"list\" array.");
}
if (profileArray is not null)
{
for (var i = 0; i < profileArray.Count; i++)
{
var profile = profileArray[i];
if (profile is not JsonObject profileObj)
{
errors.Add($"profiles[{i}]: expected an object.");
continue;
}
var hasUpdates = profileObj.ContainsKey("updates");
var hasGuid = profileObj.ContainsKey("guid");
if (!hasUpdates && !hasGuid)
{
errors.Add($"profiles[{i}]: must have either \"guid\" (new profile) or \"updates\" (modify existing).");
}
}
}
}
// Validate schemes structure
if (root["schemes"] is JsonNode schemesNode)
{
if (schemesNode is JsonArray schemeArray)
{
for (var i = 0; i < schemeArray.Count; i++)
{
var scheme = schemeArray[i];
if (scheme is not JsonObject schemeObj)
{
errors.Add($"schemes[{i}]: expected an object.");
continue;
}
if (!schemeObj.ContainsKey("name"))
{
errors.Add($"schemes[{i}]: missing required \"name\" property.");
}
}
}
else
{
errors.Add("\"schemes\" must be an array.");
}
}
// Validate actions structure
if (root["actions"] is JsonNode actionsNode)
{
if (actionsNode is JsonArray actionArray)
{
for (var i = 0; i < actionArray.Count; i++)
{
var action = actionArray[i];
if (action is not JsonObject actionObj)
{
errors.Add($"actions[{i}]: expected an object.");
continue;
}
// Actions need either a command or nested commands
if (!actionObj.ContainsKey("command") && !actionObj.ContainsKey("commands"))
{
errors.Add($"actions[{i}]: must have \"command\" or \"commands\".");
}
// Fragments cannot define keybindings
if (actionObj.ContainsKey("keys"))
{
errors.Add($"actions[{i}]: fragments cannot define \"keys\" (keybindings). Only actions are supported.");
}
}
}
else
{
errors.Add("\"actions\" must be an array.");
}
}
return errors;
}
}

View File

@@ -0,0 +1,333 @@
using Json.Patch;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// Shared helpers for reading and writing Windows Terminal settings.json
/// as a mutable JsonNode tree.
/// </summary>
internal static class SettingsHelper
{
private static readonly JsonSerializerOptions s_writeOptions = new() { WriteIndented = true };
// A fixed namespace UUID for generating deterministic profile GUIDs
private static readonly Guid s_profileNamespace =
Guid.Parse("7f4015d0-3f34-5137-9fdc-0c66a1866a30");
/// <summary>
/// Generates a deterministic GUID (UUID v5 / SHA-1) from the given name.
/// Uses a fixed namespace so the same name always produces the same GUID.
/// </summary>
public static string GenerateDeterministicGuid(string name)
{
// RFC 4122 UUID v5: SHA-1 hash of (namespace bytes ++ name bytes)
var nsBytes = s_profileNamespace.ToByteArray();
// .NET stores the first 3 fields in little-endian; convert to big-endian (network order)
Array.Reverse(nsBytes, 0, 4);
Array.Reverse(nsBytes, 4, 2);
Array.Reverse(nsBytes, 6, 2);
var nameBytes = Encoding.UTF8.GetBytes(name);
var input = new byte[nsBytes.Length + nameBytes.Length];
Buffer.BlockCopy(nsBytes, 0, input, 0, nsBytes.Length);
Buffer.BlockCopy(nameBytes, 0, input, nsBytes.Length, nameBytes.Length);
var hash = SHA1.HashData(input);
// Set version (5) and variant (RFC 4122)
hash[6] = (byte)((hash[6] & 0x0F) | 0x50);
hash[8] = (byte)((hash[8] & 0x3F) | 0x80);
// Convert back to little-endian for .NET Guid constructor
Array.Reverse(hash, 0, 4);
Array.Reverse(hash, 4, 2);
Array.Reverse(hash, 6, 2);
var guid = new Guid(hash.AsSpan(0, 16));
return $"{{{guid}}}";
}
/// <summary>
/// Resolves the release channel to use. If a release is provided, returns it.
/// Otherwise, returns the most-preview installed channel (Canary > Preview > Stable),
/// excluding Dev unless no other channel is found. Returns null if nothing is installed.
/// </summary>
public static TerminalRelease? ResolveRelease(TerminalRelease? release = null)
{
if (release is not null)
{
return release;
}
if (TerminalRelease.Canary.IsInstalled())
{
return TerminalRelease.Canary;
}
if (TerminalRelease.Preview.IsInstalled())
{
return TerminalRelease.Preview;
}
if (TerminalRelease.Stable.IsInstalled())
{
return TerminalRelease.Stable;
}
return null;
}
/// <summary>
/// Loads settings.json as a mutable JsonNode tree.
/// Returns (doc, error) — if error is non-null, doc is null.
/// </summary>
public static (JsonNode? Doc, string? Error) LoadSettings(TerminalRelease release)
{
var path = release.GetSettingsJsonPath();
if (!File.Exists(path))
{
return (null, $"Settings file not found at: {path}");
}
var json = File.ReadAllText(path);
var doc = JsonNode.Parse(json);
if (doc is null)
{
return (null, "Failed to parse settings.json.");
}
return (doc, null);
}
/// <summary>
/// Saves the JsonNode tree back to settings.json, creating a .bak backup first.
/// </summary>
public static string SaveSettings(JsonNode doc, TerminalRelease release)
{
var path = release.GetSettingsJsonPath();
if (File.Exists(path))
{
File.Copy(path, path + ".bak", overwrite: true);
}
File.WriteAllText(path, doc.ToJsonString(s_writeOptions));
return path;
}
/// <summary>
/// Parses and applies a JSON Patch (RFC 6902) to settings.json in memory.
/// Returns (beforeJson, afterJson, error). If error is non-null, the other values are null.
/// </summary>
public static (string? Before, string? After, string? Error) ApplyPatch(TerminalRelease release, string patchJson)
{
var path = release.GetSettingsJsonPath();
if (!File.Exists(path))
{
return (null, null, $"Settings file not found at: {path}");
}
JsonPatch patch;
try
{
patch = JsonSerializer.Deserialize<JsonPatch>(patchJson)
?? throw new JsonException("Patch deserialized to null.");
}
catch (JsonException ex)
{
return (null, null, $"Invalid JSON Patch document: {ex.Message}");
}
var original = File.ReadAllText(path);
var doc = JsonNode.Parse(original);
if (doc is null)
{
return (null, null, "Failed to parse current settings.json.");
}
// Pretty-print the original for consistent diffing
var before = doc.ToJsonString(s_writeOptions);
var result = patch.Apply(doc);
if (!result.IsSuccess)
{
return (null, null, $"Patch failed: {result.Error}");
}
var after = result.Result!.ToJsonString(s_writeOptions);
return (before, after, null);
}
/// <summary>
/// Produces a unified diff between two strings, with a configurable number of context lines.
/// </summary>
public static string UnifiedDiff(string before, string after, string label, int contextLines = 3)
{
var oldLines = before.Split('\n');
var newLines = after.Split('\n');
// Simple LCS-based diff
var diff = new StringBuilder();
diff.AppendLine($"--- a/{label}");
diff.AppendLine($"+++ b/{label}");
// Build edit script: match, delete, insert
var edits = ComputeEdits(oldLines, newLines);
// Group into hunks with context
var hunks = GroupIntoHunks(edits, oldLines, newLines, contextLines);
foreach (var hunk in hunks)
{
diff.Append(hunk);
}
return diff.ToString();
}
private enum EditKind { Equal, Delete, Insert }
private record struct Edit(EditKind Kind, int OldIndex, int NewIndex);
private static List<Edit> ComputeEdits(string[] oldLines, string[] newLines)
{
// Myers-like forward scan using a simple LCS approach
var oldLen = oldLines.Length;
var newLen = newLines.Length;
// Build LCS table (optimized for typical settings files which are small)
var dp = new int[oldLen + 1, newLen + 1];
for (var i = oldLen - 1; i >= 0; i--)
{
for (var j = newLen - 1; j >= 0; j--)
{
if (oldLines[i] == newLines[j])
{
dp[i, j] = dp[i + 1, j + 1] + 1;
}
else
{
dp[i, j] = Math.Max(dp[i + 1, j], dp[i, j + 1]);
}
}
}
// Walk the table to produce edits
var edits = new List<Edit>();
int oi = 0, ni = 0;
while (oi < oldLen && ni < newLen)
{
if (oldLines[oi] == newLines[ni])
{
edits.Add(new Edit(EditKind.Equal, oi, ni));
oi++;
ni++;
}
else if (dp[oi + 1, ni] >= dp[oi, ni + 1])
{
edits.Add(new Edit(EditKind.Delete, oi, -1));
oi++;
}
else
{
edits.Add(new Edit(EditKind.Insert, -1, ni));
ni++;
}
}
while (oi < oldLen)
{
edits.Add(new Edit(EditKind.Delete, oi++, -1));
}
while (ni < newLen)
{
edits.Add(new Edit(EditKind.Insert, -1, ni++));
}
return edits;
}
private static List<string> GroupIntoHunks(List<Edit> edits, string[] oldLines, string[] newLines, int ctx)
{
var hunks = new List<string>();
var changeIndices = new List<int>();
for (var i = 0; i < edits.Count; i++)
{
if (edits[i].Kind != EditKind.Equal)
{
changeIndices.Add(i);
}
}
if (changeIndices.Count == 0)
{
return hunks;
}
// Group nearby changes into hunks
var groups = new List<(int Start, int End)>();
var gStart = changeIndices[0];
var gEnd = changeIndices[0];
for (var i = 1; i < changeIndices.Count; i++)
{
if (changeIndices[i] - gEnd <= ctx * 2 + 1)
{
gEnd = changeIndices[i];
}
else
{
groups.Add((gStart, gEnd));
gStart = changeIndices[i];
gEnd = changeIndices[i];
}
}
groups.Add((gStart, gEnd));
foreach (var (start, end) in groups)
{
var hunkStart = Math.Max(0, start - ctx);
var hunkEnd = Math.Min(edits.Count - 1, end + ctx);
// Calculate line numbers for the hunk header
int oldStart = 0, oldCount = 0, newStart = 0, newCount = 0;
var firstOld = true;
var firstNew = true;
var body = new StringBuilder();
for (var i = hunkStart; i <= hunkEnd; i++)
{
var edit = edits[i];
switch (edit.Kind)
{
case EditKind.Equal:
if (firstOld) { oldStart = edit.OldIndex + 1; firstOld = false; }
if (firstNew) { newStart = edit.NewIndex + 1; firstNew = false; }
oldCount++;
newCount++;
body.AppendLine($" {oldLines[edit.OldIndex]}");
break;
case EditKind.Delete:
if (firstOld) { oldStart = edit.OldIndex + 1; firstOld = false; }
oldCount++;
body.AppendLine($"-{oldLines[edit.OldIndex]}");
break;
case EditKind.Insert:
if (firstNew) { newStart = edit.NewIndex + 1; firstNew = false; }
newCount++;
body.AppendLine($"+{newLines[edit.NewIndex]}");
break;
}
}
if (firstOld) { oldStart = 1; }
if (firstNew) { newStart = 1; }
hunks.Add($"@@ -{oldStart},{oldCount} +{newStart},{newCount} @@\n{body}");
}
return hunks;
}
}

View File

@@ -0,0 +1,56 @@
using System.ComponentModel;
/// <summary>
/// Represents the different release channels of Windows Terminal.
/// </summary>
internal enum TerminalRelease
{
[Description("Windows Terminal (Stable)")]
Stable,
[Description("Windows Terminal Preview")]
Preview,
[Description("Windows Terminal Canary")]
Canary,
[Description("Windows Terminal Dev")]
Dev
}
internal static class TerminalReleaseExtensions
{
/// <summary>
/// Returns the path to settings.json for the given release channel.
/// </summary>
public static string GetSettingsJsonPath(this TerminalRelease release)
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return release switch
{
TerminalRelease.Stable => Path.Combine(localAppData, @"Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json"),
TerminalRelease.Preview => Path.Combine(localAppData, @"Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json"),
TerminalRelease.Canary => Path.Combine(localAppData, @"Packages\Microsoft.WindowsTerminalCanary_8wekyb3d8bbwe\LocalState\settings.json"),
TerminalRelease.Dev => Path.Combine(localAppData, @"Packages\Microsoft.WindowsTerminalDev_8wekyb3d8bbwe\LocalState\settings.json"),
_ => throw new ArgumentOutOfRangeException(nameof(release))
};
}
/// <summary>
/// Returns true if the given release channel has a settings.json on disk.
/// </summary>
public static bool IsInstalled(this TerminalRelease release)
{
return File.Exists(release.GetSettingsJsonPath());
}
/// <summary>
/// Returns the most-preview installed channel.
/// Delegates to <see cref="SettingsHelper.ResolveRelease"/>.
/// </summary>
public static TerminalRelease? DetectDefaultChannel()
{
return SettingsHelper.ResolveRelease();
}
}

View File

@@ -0,0 +1,171 @@
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// Helpers for finding, reading, and validating wt.json snippet files.
/// wt.json files add per-directory snippets to Windows Terminal's suggestions pane.
/// </summary>
internal static class WtJsonHelper
{
private static readonly JsonSerializerOptions s_writeOptions = new() { WriteIndented = true };
/// <summary>
/// Searches for a wt.json file starting from the given directory and walking
/// up parent directories (same lookup behavior as Windows Terminal).
/// Returns the full path if found, null otherwise.
/// </summary>
public static string? FindWtJson(string? startDirectory = null)
{
var dir = startDirectory ?? Directory.GetCurrentDirectory();
while (!string.IsNullOrEmpty(dir))
{
var candidate = Path.Combine(dir, ".wt.json");
if (File.Exists(candidate))
{
return candidate;
}
var parent = Directory.GetParent(dir)?.FullName;
if (parent == dir)
{
break;
}
dir = parent;
}
return null;
}
/// <summary>
/// Reads a wt.json file and returns its pretty-printed content.
/// Returns (content, error).
/// </summary>
public static (string? Content, string? Error) ReadWtJson(string path)
{
if (!File.Exists(path))
{
return (null, $"File not found: {path}");
}
try
{
var json = File.ReadAllText(path);
var doc = JsonNode.Parse(json);
if (doc is not null)
{
return (doc.ToJsonString(s_writeOptions), null);
}
return (json, null);
}
catch (JsonException ex)
{
return (null, $"Failed to parse wt.json: {ex.Message}");
}
}
/// <summary>
/// Validates the structure of a wt.json file.
/// Returns a list of validation errors (empty = valid).
/// </summary>
public static List<string> Validate(string json)
{
var errors = new List<string>();
JsonNode? doc;
try
{
doc = JsonNode.Parse(json);
}
catch (JsonException ex)
{
errors.Add($"Invalid JSON: {ex.Message}");
return errors;
}
if (doc is not JsonObject root)
{
errors.Add("wt.json must be a JSON object.");
return errors;
}
// Must have $version
if (!root.ContainsKey("$version"))
{
errors.Add("Missing required \"$version\" property.");
}
else if (root["$version"] is not JsonValue)
{
errors.Add("\"$version\" must be a string value.");
}
// Must have snippets array
if (!root.ContainsKey("snippets"))
{
errors.Add("Missing required \"snippets\" array.");
}
else if (root["snippets"] is not JsonArray snippets)
{
errors.Add("\"snippets\" must be an array.");
}
else
{
for (var i = 0; i < snippets.Count; i++)
{
if (snippets[i] is not JsonObject snippet)
{
errors.Add($"snippets[{i}]: expected an object.");
continue;
}
if (!snippet.ContainsKey("name"))
{
errors.Add($"snippets[{i}]: missing required \"name\" property.");
}
if (!snippet.ContainsKey("input"))
{
errors.Add($"snippets[{i}]: missing required \"input\" property.");
}
}
}
return errors;
}
/// <summary>
/// Writes a wt.json file, creating a backup if one already exists.
/// </summary>
public static string WriteWtJson(string path, string content)
{
if (File.Exists(path))
{
File.Copy(path, path + ".bak", overwrite: true);
}
File.WriteAllText(path, content);
return path;
}
/// <summary>
/// Pretty-prints a JSON string.
/// </summary>
public static string PrettyPrint(string json)
{
try
{
var doc = JsonNode.Parse(json);
if (doc is not null)
{
return doc.ToJsonString(s_writeOptions);
}
}
catch
{
// Return as-is
}
return json;
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public static class Program
{
public static async Task Main(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
// Configure all logs to go to stderr (stdout is used for the MCP protocol messages).
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
// Add the MCP services: the transport to use (stdio) and the tools to register.
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<SettingsTools>()
.WithTools<FragmentTools>()
.WithTools<OhMyPoshTools>()
.WithTools<ShellIntegrationTools>()
.WithTools<SnippetTools>();
await builder.Build().RunAsync();
}
}

View File

@@ -0,0 +1,99 @@
# MCP Server
This README was created using the C# MCP server project template.
It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package.
The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine.
However, since it is self-contained, it must be built for each target platform separately.
By default, the template is configured to build for:
* `win-x64`
* `win-arm64`
* `osx-arm64`
* `linux-x64`
* `linux-arm64`
* `linux-musl-x64`
If your users require more platforms to be supported, update the list of runtime identifiers in the project's `<RuntimeIdentifiers />` element.
See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide.
Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey).
## Checklist before publishing to NuGet.org
- Test the MCP server locally using the steps below.
- Update the package metadata in the .csproj file, in particular the `<PackageId>`.
- Update `.mcp/server.json` to declare your MCP server's inputs.
- See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details.
- Pack the project using `dotnet pack`.
The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package).
## Developing locally
To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`.
```json
{
"servers": {
"wt.mcp": {
"type": "stdio",
"command": "dotnet",
"args": [
"run",
"--project",
"<PATH TO PROJECT DIRECTORY>"
]
}
}
}
```
Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers:
- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)
- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers)
## Testing the MCP Server
Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `wt.mcp` MCP server and show you the results.
## Publishing to NuGet.org
1. Run `dotnet pack -c Release` to create the NuGet package
2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key <your-api-key> --source https://api.nuget.org/v3/index.json`
## Using the MCP Server from NuGet.org
Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org.
- **VS Code**: Create a `<WORKSPACE DIRECTORY>/.vscode/mcp.json` file
- **Visual Studio**: Create a `<SOLUTION DIRECTORY>\.mcp.json` file
For both VS Code and Visual Studio, the configuration file uses the following server definition:
```json
{
"servers": {
"wt.mcp": {
"type": "stdio",
"command": "dnx",
"args": [
"<your package ID here>",
"--version",
"<your package version here>",
"--yes"
]
}
}
}
```
## More information
.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP:
- [Official Documentation](https://modelcontextprotocol.io/)
- [Protocol Specification](https://spec.modelcontextprotocol.io/)
- [GitHub Organization](https://github.com/modelcontextprotocol)
- [MCP C# SDK](https://modelcontextprotocol.github.io/csharp-sdk)

View File

@@ -0,0 +1,303 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text;
/// <summary>
/// MCP tools for managing Windows Terminal fragment extensions.
/// Fragments are portable, shareable settings components that can add
/// profiles, color schemes, and actions without modifying settings.json.
/// </summary>
[McpServerToolType]
internal class FragmentTools
{
private const string RestartNotice =
"\n\n⚠ Windows Terminal must be fully restarted for fragment changes to take effect.";
[McpServerTool, Description("""
Lists installed Windows Terminal fragment extensions.
Fragments are found in user-scope (%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments)
and machine-scope (%PROGRAMDATA%\Microsoft\Windows Terminal\Fragments) directories.
If appName is provided, only lists fragments for that app.
""")]
public static string ListFragments(
[Description("Optional app name filter. If provided, only lists fragments for this app.")] string? appName = null)
{
var fragments = FragmentHelper.ListAllFragments();
if (appName is not null)
{
fragments = fragments.Where(f => f.AppName.Equals(appName, StringComparison.OrdinalIgnoreCase)).ToList();
}
if (fragments.Count == 0)
{
var filter = appName is not null ? $" for \"{appName}\"" : "";
return $"No fragments found{filter}.\n\nUser fragments directory: {FragmentHelper.GetUserFragmentsRoot()}\nMachine fragments directory: {FragmentHelper.GetMachineFragmentsRoot()}";
}
var sb = new StringBuilder();
sb.AppendLine($"Found {fragments.Count} fragment(s):\n");
var grouped = fragments.GroupBy(f => f.Scope);
foreach (var group in grouped)
{
sb.AppendLine($"[{group.Key}]");
foreach (var (_, app, file, fullPath) in group)
{
sb.AppendLine($" {app}/{file} ({fullPath})");
}
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
[McpServerTool, Description("""
Reads the contents of fragment extension files for an app.
If fileName is provided, reads that specific file. If omitted, reads all fragment files for the app.
""")]
public static string ReadFragment(
[Description("The app/extension name (folder name under the Fragments directory)")] string appName,
[Description("The fragment file name (e.g. \"profiles.json\"). If omitted, reads all files for this app.")] string? fileName = null)
{
if (fileName is not null)
{
var (content, error) = FragmentHelper.ReadFragment(appName, fileName);
if (error is not null)
{
return error;
}
var path = FragmentHelper.FindFragmentPath(appName, fileName) ?? FragmentHelper.GetFragmentPath(appName, fileName);
return $"Fragment: {appName}/{fileName}\nPath: {path}\n\n{content}";
}
// Read all fragments for this app
var fragments = FragmentHelper.ReadAllFragmentsForApp(appName);
if (fragments.Count == 0)
{
return $"No fragments found for \"{appName}\" in user or machine scope.";
}
var sb = new StringBuilder();
sb.AppendLine($"Found {fragments.Count} fragment(s) for \"{appName}\":\n");
foreach (var (file, fullPath, content) in fragments)
{
sb.AppendLine($"--- {appName}/{file} ({fullPath}) ---");
sb.AppendLine(content);
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
[McpServerTool, Description("""
Previews creating a new fragment extension WITHOUT writing any changes.
Returns the formatted fragment content and validation results.
Always call this before CreateFragment so the user can review the content.
Display the returned preview to the user in your response so they can review it.
Fragments support: profiles (with "guid" for new or "updates" for modifying existing),
schemes (color schemes with a "name"), and actions (with "command", no "keys"/keybindings).
""")]
public static string PreviewCreateFragment(
[Description("The app/extension name (folder name under the Fragments directory)")] string appName,
[Description("The fragment file name (e.g. \"profiles.json\")")] string fileName,
[Description("The full JSON content of the fragment")] string fragmentJson)
{
var prettyJson = FragmentHelper.PrettyPrint(fragmentJson);
var path = FragmentHelper.GetFragmentPath(appName, fileName);
var exists = File.Exists(path);
var validationErrors = SchemaValidator.ValidateFragment(prettyJson);
var sb = new StringBuilder();
sb.AppendLine(exists
? $"⚠ File already exists and will be overwritten: {path}"
: $"Will create: {path}");
sb.AppendLine();
sb.AppendLine(prettyJson);
if (validationErrors.Count > 0)
{
sb.AppendLine();
sb.AppendLine("⚠ Validation issues:");
foreach (var error in validationErrors)
{
sb.AppendLine($" ⚠ {error}");
}
}
else
{
sb.AppendLine();
sb.AppendLine("✓ Validation passed");
}
return sb.ToString().TrimEnd();
}
[McpServerTool, Description("""
Creates a new fragment extension file.
IMPORTANT: Always call PreviewCreateFragment first and show the preview to the user.
After showing the preview, call this tool immediately — do not ask for separate user confirmation.
The client will show its own confirmation dialog for approval.
""")]
public static string CreateFragment(
[Description("The app/extension name (folder name under the Fragments directory)")] string appName,
[Description("The fragment file name (e.g. \"profiles.json\")")] string fileName,
[Description("The full JSON content of the fragment")] string fragmentJson)
{
var prettyJson = FragmentHelper.PrettyPrint(fragmentJson);
// Validate before writing
var validationErrors = SchemaValidator.ValidateFragment(prettyJson);
if (validationErrors.Count > 0)
{
var errors = string.Join("\n", validationErrors.Select(e => $" ✗ {e}"));
return $"Fragment rejected — validation failed:\n{errors}";
}
var path = FragmentHelper.WriteFragment(appName, fileName, prettyJson);
return $"Fragment created: {path}{RestartNotice}";
}
[McpServerTool, Description("""
Previews changes to an existing fragment extension WITHOUT writing any changes.
Returns a unified diff showing exactly what would change.
Always call this before UpdateFragment so the user can review the diff.
IMPORTANT: You MUST display the returned diff inside a ```diff fenced code block.
""")]
public static string PreviewUpdateFragment(
[Description("The app/extension name (folder name under the Fragments directory)")] string appName,
[Description("The fragment file name (e.g. \"profiles.json\")")] string fileName,
[Description("The new full JSON content of the fragment")] string fragmentJson)
{
var path = FragmentHelper.FindFragmentPath(appName, fileName);
if (path is null)
{
return $"Fragment not found for {appName}/{fileName}. Use PreviewCreateFragment/CreateFragment to create a new one.";
}
var (currentContent, error) = FragmentHelper.ReadFragment(appName, fileName);
if (error is not null)
{
return error;
}
var prettyNew = FragmentHelper.PrettyPrint(fragmentJson);
var validationErrors = SchemaValidator.ValidateFragment(prettyNew);
var diff = SettingsHelper.UnifiedDiff(currentContent!, prettyNew, $"{appName}/{fileName}");
if (string.IsNullOrEmpty(diff))
{
return "No changes — the new content is identical to the existing fragment.";
}
var sb = new StringBuilder();
sb.Append(diff);
if (validationErrors.Count > 0)
{
sb.AppendLine();
sb.AppendLine("⚠ Validation issues:");
foreach (var ve in validationErrors)
{
sb.AppendLine($" ⚠ {ve}");
}
}
else
{
sb.AppendLine();
sb.AppendLine("✓ Validation passed");
}
return sb.ToString().TrimEnd();
}
[McpServerTool, Description("""
Updates an existing fragment extension file with new content.
IMPORTANT: Always call PreviewUpdateFragment first and show the diff to the user.
After showing the diff, call this tool immediately — do not ask for separate user confirmation.
The client will show its own confirmation dialog for approval.
""")]
public static string UpdateFragment(
[Description("The app/extension name (folder name under the Fragments directory)")] string appName,
[Description("The fragment file name (e.g. \"profiles.json\")")] string fileName,
[Description("The new full JSON content of the fragment")] string fragmentJson)
{
var path = FragmentHelper.FindFragmentPath(appName, fileName);
if (path is null)
{
return $"Fragment not found for {appName}/{fileName}. Use CreateFragment to create a new one.";
}
var prettyJson = FragmentHelper.PrettyPrint(fragmentJson);
// Validate before writing
var validationErrors = SchemaValidator.ValidateFragment(prettyJson);
if (validationErrors.Count > 0)
{
var errors = string.Join("\n", validationErrors.Select(e => $" ✗ {e}"));
return $"Fragment update rejected — validation failed:\n{errors}";
}
FragmentHelper.WriteFragment(appName, fileName, prettyJson);
return $"Fragment updated: {path}{RestartNotice}";
}
[McpServerTool, Description("""
Deletes a fragment extension file, or all fragments for an app if no fileName is specified.
This action is not reversible (though .bak files may exist from prior updates).
""")]
public static string DeleteFragment(
[Description("The app/extension name (folder name under the Fragments directory)")] string appName,
[Description("The fragment file name. If omitted, deletes all fragments for this app.")] string? fileName = null)
{
if (fileName is not null)
{
var path = FragmentHelper.FindFragmentPath(appName, fileName);
if (path is null)
{
return $"Fragment not found for {appName}/{fileName}";
}
File.Delete(path);
// Clean up the app directory if it's now empty
var appDir = Path.GetDirectoryName(path);
if (appDir is not null && Directory.Exists(appDir) && !Directory.EnumerateFileSystemEntries(appDir).Any())
{
Directory.Delete(appDir);
}
return $"Fragment deleted: {path}{RestartNotice}";
}
// Delete all fragments for this app across both scopes
var deleted = new List<string>();
var roots = new[] { FragmentHelper.GetUserFragmentsRoot(), FragmentHelper.GetMachineFragmentsRoot() };
foreach (var root in roots)
{
var appDir = Path.Combine(root, appName);
if (Directory.Exists(appDir))
{
foreach (var file in Directory.GetFiles(appDir, "*.json"))
{
File.Delete(file);
deleted.Add(file);
}
if (!Directory.EnumerateFileSystemEntries(appDir).Any())
{
Directory.Delete(appDir);
}
}
}
if (deleted.Count == 0)
{
return $"No fragments found for \"{appName}\"";
}
return $"Deleted {deleted.Count} fragment(s) for \"{appName}\":\n{string.Join("\n", deleted)}{RestartNotice}";
}
}

View File

@@ -0,0 +1,421 @@
using Json.Patch;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// MCP tools for managing Oh My Posh prompt theme engine configuration.
/// Oh My Posh is a cross-platform prompt engine that customizes your shell prompt
/// with themes, segments, and blocks.
/// </summary>
[McpServerToolType]
internal class OhMyPoshTools
{
[McpServerTool, Description("""
Checks if Oh My Posh is installed and returns status information including:
version, executable path, themes directory, detected config file path,
and whether it's referenced in the user's PowerShell profile.
""")]
public static string GetOhMyPoshStatus()
{
var sb = new StringBuilder();
var exePath = OhMyPoshHelper.FindExecutable();
if (exePath is null)
{
sb.AppendLine("Oh My Posh is not installed (not found on PATH).");
sb.AppendLine();
sb.AppendLine("Install with: winget install JanDeDobbeleer.OhMyPosh --source winget");
return sb.ToString().TrimEnd();
}
sb.Append($"Oh My Posh is installed: {exePath}");
var version = OhMyPoshHelper.GetVersion();
if (version is not null)
{
sb.Append($" (v{version})");
}
sb.AppendLine();
var themesDir = OhMyPoshHelper.GetThemesDirectory();
if (themesDir is not null)
{
var themeCount = OhMyPoshHelper.ListThemes().Count;
sb.AppendLine($"Themes directory: {themesDir} ({themeCount} themes)");
}
else
{
sb.AppendLine("Themes directory: not found");
}
var configPath = OhMyPoshHelper.DetectConfigFromProfile();
if (configPath is not null)
{
sb.AppendLine($"Config file (from profile): {configPath}");
if (File.Exists(configPath))
{
sb.AppendLine(" ✓ Config file exists");
}
else
{
sb.AppendLine(" ⚠ Config file not found on disk");
}
}
else
{
sb.AppendLine("Config file: not detected in PowerShell profile");
}
var profilePath = OhMyPoshHelper.GetPowerShellProfilePath();
if (profilePath is not null)
{
sb.AppendLine($"PowerShell profile: {profilePath}");
sb.AppendLine(File.Exists(profilePath)
? " ✓ Profile exists"
: " ⚠ Profile does not exist yet");
}
return sb.ToString().TrimEnd();
}
[McpServerTool, Description("""
Lists available Oh My Posh themes from the themes directory.
These are built-in themes that ship with Oh My Posh and can be used as-is
or as a starting point for custom configurations.
""")]
public static string ListOhMyPoshThemes()
{
var themes = OhMyPoshHelper.ListThemes();
if (themes.Count == 0)
{
var themesDir = OhMyPoshHelper.GetThemesDirectory();
return themesDir is null
? "Oh My Posh themes directory not found. Is Oh My Posh installed?"
: $"No themes found in: {themesDir}";
}
var sb = new StringBuilder();
sb.AppendLine($"Available themes ({themes.Count}):\n");
foreach (var (name, fullPath) in themes)
{
sb.AppendLine($" {name}");
}
sb.AppendLine();
sb.AppendLine($"Themes directory: {OhMyPoshHelper.GetThemesDirectory()}");
sb.AppendLine("Visual previews: https://ohmyposh.dev/docs/themes");
sb.AppendLine("Preview in terminal: oh-my-posh print primary --config <theme-path>");
sb.AppendLine("Use ReadOhMyPoshConfig with a theme name to view its configuration.");
return sb.ToString().TrimEnd();
}
[McpServerTool, Description("""
Reads an Oh My Posh configuration file (JSON, YAML, or TOML).
If no path is given, auto-detects the current config from the user's PowerShell profile.
Can also read a built-in theme by name (e.g. "agnoster", "paradox").
""")]
public static string ReadOhMyPoshConfig(
[Description("Path to the config file, or a theme name. If omitted, auto-detects from the user's PowerShell profile.")] string? path = null)
{
var resolvedPath = ResolveConfigPath(path);
if (resolvedPath is null)
{
return path is null
? "Could not auto-detect Oh My Posh config. No config reference found in the PowerShell profile. Provide a path explicitly."
: $"Config file not found: {path}";
}
if (!File.Exists(resolvedPath))
{
return $"Config file not found at: {resolvedPath}";
}
var content = File.ReadAllText(resolvedPath);
// Try to pretty-print if it's JSON
if (resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
try
{
var doc = JsonNode.Parse(content);
if (doc is not null)
{
content = doc.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}
}
catch
{
// Return as-is
}
}
return $"Config: {resolvedPath}\n\n{content}";
}
[McpServerTool, Description("""
Previews a JSON Patch (RFC 6902) against an Oh My Posh config file WITHOUT writing any changes.
Returns a unified diff showing exactly what would change.
Always call this before ApplyOhMyPoshConfigChange so the user can review the diff.
IMPORTANT: You MUST display the returned diff inside a ```diff fenced code block.
Only works with JSON config files (.omp.json).
""")]
public static string PreviewOhMyPoshConfigChange(
[Description("A JSON Patch document (RFC 6902): an array of operations to apply")] string patchJson,
[Description("Path to the config file. If omitted, auto-detects from the user's PowerShell profile.")] string? path = null)
{
var resolvedPath = ResolveConfigPath(path);
if (resolvedPath is null)
{
return path is null
? "Could not auto-detect Oh My Posh config. Provide a path explicitly."
: $"Config file not found: {path}";
}
if (!resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
return $"JSON Patch only works with JSON config files. This file is: {resolvedPath}";
}
if (!File.Exists(resolvedPath))
{
return $"Config file not found at: {resolvedPath}";
}
JsonPatch patch;
try
{
patch = JsonSerializer.Deserialize<JsonPatch>(patchJson)
?? throw new JsonException("Patch deserialized to null.");
}
catch (JsonException ex)
{
return $"Invalid JSON Patch: {ex.Message}";
}
var original = File.ReadAllText(resolvedPath);
var doc = JsonNode.Parse(original);
if (doc is null)
{
return "Failed to parse current config file.";
}
var writeOptions = new JsonSerializerOptions { WriteIndented = true };
var before = doc.ToJsonString(writeOptions);
var result = patch.Apply(doc);
if (!result.IsSuccess)
{
return $"Patch failed: {result.Error}";
}
var after = result.Result!.ToJsonString(writeOptions);
var label = Path.GetFileName(resolvedPath);
var diff = SettingsHelper.UnifiedDiff(before, after, label);
if (string.IsNullOrEmpty(diff))
{
return "No changes — the patch produces identical output.";
}
return diff;
}
[McpServerTool, Description("""
Applies a JSON Patch (RFC 6902) to an Oh My Posh config file and writes the result.
IMPORTANT: Always call PreviewOhMyPoshConfigChange first and show the diff to the user.
After showing the diff, call this tool immediately — do not ask for separate user confirmation.
The client will show its own confirmation dialog for approval.
Only works with JSON config files (.omp.json).
""")]
public static string ApplyOhMyPoshConfigChange(
[Description("A JSON Patch document (RFC 6902): an array of operations to apply")] string patchJson,
[Description("Path to the config file. If omitted, auto-detects from the user's PowerShell profile.")] string? path = null)
{
var resolvedPath = ResolveConfigPath(path);
if (resolvedPath is null)
{
return path is null
? "Could not auto-detect Oh My Posh config. Provide a path explicitly."
: $"Config file not found: {path}";
}
if (!resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
return $"JSON Patch only works with JSON config files. This file is: {resolvedPath}";
}
if (!File.Exists(resolvedPath))
{
return $"Config file not found at: {resolvedPath}";
}
JsonPatch patch;
try
{
patch = JsonSerializer.Deserialize<JsonPatch>(patchJson)
?? throw new JsonException("Patch deserialized to null.");
}
catch (JsonException ex)
{
return $"Invalid JSON Patch: {ex.Message}";
}
var original = File.ReadAllText(resolvedPath);
var doc = JsonNode.Parse(original);
if (doc is null)
{
return "Failed to parse current config file.";
}
var result = patch.Apply(doc);
if (!result.IsSuccess)
{
return $"Patch failed: {result.Error}";
}
var writeOptions = new JsonSerializerOptions { WriteIndented = true };
var patched = result.Result!.ToJsonString(writeOptions);
// Back up before writing
File.Copy(resolvedPath, resolvedPath + ".bak", overwrite: true);
File.WriteAllText(resolvedPath, patched);
return $"Config updated: {resolvedPath}\n\nRestart your shell or run `oh-my-posh init pwsh | Invoke-Expression` to see changes.";
}
[McpServerTool, Description("""
Sets the Oh My Posh theme by updating the user's PowerShell profile.
Accepts a theme name (from built-in themes) or a full path to a config file.
Shows what would change in the profile before applying.
""")]
public static string SetOhMyPoshTheme(
[Description("Theme name (e.g. 'agnoster', 'paradox') or full path to a config file.")] string theme,
[Description("If true, only preview the change without writing. Default is false.")] bool previewOnly = false)
{
var profilePath = OhMyPoshHelper.GetPowerShellProfilePath();
if (profilePath is null)
{
return "Could not determine PowerShell profile path.";
}
// Resolve theme name to a path if it's not already a path
var themePath = theme;
if (!Path.IsPathRooted(theme) && !theme.Contains('.'))
{
// Looks like a theme name — resolve from themes directory
var themes = OhMyPoshHelper.ListThemes();
var match = themes.FirstOrDefault(t => t.Name.Equals(theme, StringComparison.OrdinalIgnoreCase));
if (match != default)
{
themePath = match.FullPath;
}
else
{
return $"Theme \"{theme}\" not found in the themes directory. Use ListOhMyPoshThemes to see available themes.";
}
}
// Use $env:POSH_THEMES_PATH for paths in the themes directory
var themesDir = OhMyPoshHelper.GetThemesDirectory();
var initLine = themesDir is not null && themePath.StartsWith(themesDir, StringComparison.OrdinalIgnoreCase)
? $"oh-my-posh init pwsh --config \"$env:POSH_THEMES_PATH\\{Path.GetFileName(themePath)}\" | Invoke-Expression"
: $"oh-my-posh init pwsh --config '{themePath}' | Invoke-Expression";
string before;
string after;
if (File.Exists(profilePath))
{
before = File.ReadAllText(profilePath);
// Try to replace the existing oh-my-posh init line
var lines = before.Split('\n').ToList();
var replaced = false;
for (var i = 0; i < lines.Count; i++)
{
if (lines[i].TrimStart().StartsWith("oh-my-posh", StringComparison.OrdinalIgnoreCase) &&
lines[i].Contains("init", StringComparison.OrdinalIgnoreCase))
{
lines[i] = initLine;
replaced = true;
break;
}
}
if (!replaced)
{
lines.Add(initLine);
}
after = string.Join('\n', lines);
}
else
{
before = "";
after = initLine + "\n";
}
var diff = SettingsHelper.UnifiedDiff(before, after, Path.GetFileName(profilePath));
if (previewOnly || string.IsNullOrEmpty(diff))
{
if (string.IsNullOrEmpty(diff))
{
return "No changes needed — the profile already uses this theme.";
}
return $"Preview of changes to {profilePath}:\n\n{diff}";
}
// Write the profile
if (File.Exists(profilePath))
{
File.Copy(profilePath, profilePath + ".bak", overwrite: true);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(profilePath)!);
}
File.WriteAllText(profilePath, after);
return $"PowerShell profile updated: {profilePath}\n\nRestart your shell to see the new theme.";
}
/// <summary>
/// Resolves a config path from a user-provided value (which may be null, a theme name, or a full path).
/// </summary>
private static string? ResolveConfigPath(string? path)
{
if (path is null)
{
// Auto-detect from profile
return OhMyPoshHelper.DetectConfigFromProfile();
}
// If it's a full path, use as-is
if (Path.IsPathRooted(path))
{
return path;
}
// Check if it's a theme name
if (!path.Contains('.'))
{
var themes = OhMyPoshHelper.ListThemes();
var match = themes.FirstOrDefault(t => t.Name.Equals(path, StringComparison.OrdinalIgnoreCase));
if (match != default)
{
return match.FullPath;
}
}
// Treat as a relative path
var resolved = Path.GetFullPath(path);
return File.Exists(resolved) ? resolved : null;
}
}

View File

@@ -0,0 +1,137 @@
using Json.Patch;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// MCP tools for reading and writing Windows Terminal settings.json.
/// </summary>
[McpServerToolType]
internal class SettingsTools
{
[McpServerTool, Description("Lists which Windows Terminal release channels are installed on this machine.")]
public static string ListInstalledChannels()
{
var installed = Enum.GetValues<TerminalRelease>()
.Where(r => r.IsInstalled())
.Select(r => $"{r} ({r.GetSettingsJsonPath()})");
var results = installed.ToList();
if (results.Count == 0)
{
return "No Windows Terminal installations found.";
}
var defaultChannel = TerminalReleaseExtensions.DetectDefaultChannel();
return $"Installed channels:\n{string.Join("\n", results)}\n\nDefault channel: {defaultChannel}";
}
[McpServerTool, Description("Returns the path to the settings directory (LocalState) for a Windows Terminal release channel. Other files like state.json and fragments are also stored here.")]
public static string GetSettingsDirectory(
[Description("The release channel. If not specified, the most-preview installed channel is used.")] TerminalRelease? release = null)
{
release ??= TerminalReleaseExtensions.DetectDefaultChannel();
if (release is null)
{
return "No Windows Terminal installations found.";
}
var directory = Path.GetDirectoryName(release.Value.GetSettingsJsonPath());
if (directory is null || !Directory.Exists(directory))
{
return $"Settings directory not found for {release}. Is this Terminal release installed?";
}
var files = Directory.GetFiles(directory)
.Select(Path.GetFileName);
return $"Settings directory for {release}: {directory}\n\nContents:\n{string.Join("\n", files)}";
}
[McpServerTool, Description("Reads the contents of a Windows Terminal settings.json file.")]
public static string ReadSettings(
[Description("The release channel to read settings from. If not specified, the most-preview installed channel is used.")] TerminalRelease? release = null)
{
release ??= TerminalReleaseExtensions.DetectDefaultChannel();
if (release is null)
{
return "No Windows Terminal installations found.";
}
var path = release.Value.GetSettingsJsonPath();
if (!File.Exists(path))
{
return $"Settings file not found at: {path}";
}
return File.ReadAllText(path);
}
[McpServerTool, Description("""
Previews a JSON Patch (RFC 6902) against Windows Terminal settings.json WITHOUT writing any changes.
Returns a unified diff showing exactly what would change.
Always call this before ApplySettingsChange so the user can review the diff.
IMPORTANT: You MUST display the returned diff inside a ```diff fenced code block.
Supported ops: "add", "remove", "replace", "move", "copy", "test".
Example: [{"op": "replace", "path": "/theme", "value": "dark"}]
When adding or modifying keybindings, ALWAYS check the existing keybindings array for conflicts
with the same key combination before applying. Warn the user if a conflict is found.
""")]
public static string PreviewSettingsChange(
[Description("A JSON Patch document (RFC 6902): an array of operations to apply")] string patchJson,
[Description("The release channel. If not specified, the most-preview installed channel is used.")] TerminalRelease? release = null)
{
var resolved = SettingsHelper.ResolveRelease(release);
if (resolved is null)
{
return "No Windows Terminal installations found.";
}
var (before, patched, error) = SettingsHelper.ApplyPatch(resolved.Value, patchJson);
if (error is not null)
{
return error;
}
var diff = SettingsHelper.UnifiedDiff(before!, patched!, $"settings.json ({resolved})");
if (string.IsNullOrEmpty(diff))
{
return "No changes — the patch produces identical output.";
}
return diff;
}
[McpServerTool, Description("""
Applies a JSON Patch (RFC 6902) to a Windows Terminal settings.json file and writes the result.
IMPORTANT: Always call PreviewSettingsChange first and show the diff to the user before calling this tool.
After showing the diff, call this tool immediately — do not ask for separate user confirmation.
The client will show its own confirmation dialog for approval.
""")]
public static string ApplySettingsChange(
[Description("A JSON Patch document (RFC 6902): an array of operations to apply")] string patchJson,
[Description("The release channel. If not specified, the most-preview installed channel is used.")] TerminalRelease? release = null)
{
var resolved = SettingsHelper.ResolveRelease(release);
if (resolved is null)
{
return "No Windows Terminal installations found.";
}
var (before, patched, error) = SettingsHelper.ApplyPatch(resolved.Value, patchJson);
if (error is not null)
{
return error;
}
var path = resolved.Value.GetSettingsJsonPath();
// Back up before writing
File.Copy(path, path + ".bak", overwrite: true);
File.WriteAllText(path, patched!);
return $"Settings updated. Written to: {path}";
}
}

View File

@@ -0,0 +1,823 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text;
using System.Text.Json.Nodes;
/// <summary>
/// MCP tools for checking and configuring shell integration with Windows Terminal.
/// Shell integration uses OSC 133 (FinalTerm) escape sequences to let the terminal
/// understand prompt boundaries, command status, and working directory.
/// </summary>
[McpServerToolType]
internal class ShellIntegrationTools
{
[McpServerTool, Description("""
Reports the current state of shell integration for Windows Terminal.
Checks both terminal-side settings (autoMarkPrompts, showMarksOnScrollbar)
and shell-side configuration (PowerShell profile, Oh My Posh shell_integration).
Use the results to determine what needs to be enabled.
For terminal-side settings, use PreviewSettingsChange/ApplySettingsChange to enable them.
For shell-side changes, use GetShellIntegrationSnippet to get the code to add.
IMPORTANT: Display the FULL output to the user — do not summarize or collapse individual
settings or actions. Each item should be shown explicitly for discoverability.
""")]
public static string GetShellIntegrationStatus(
[Description("The release channel. If not specified, the most-preview installed channel is used.")] TerminalRelease? release = null)
{
var resolved = SettingsHelper.ResolveRelease(release);
var sb = new StringBuilder();
// Terminal-side checks
sb.AppendLine("=== Terminal Settings ===");
if (resolved is null)
{
sb.AppendLine(" No Windows Terminal installations found.");
}
else
{
var (doc, error) = SettingsHelper.LoadSettings(resolved.Value);
if (error is not null)
{
sb.AppendLine($" Could not read settings: {error}");
}
else
{
var defaults = doc!["profiles"]?["defaults"];
var autoMark = defaults?["autoMarkPrompts"]?.GetValue<bool>() ?? false;
sb.AppendLine($" autoMarkPrompts: {(autoMark ? " enabled" : " disabled")}");
if (!autoMark)
{
sb.AppendLine(" → Enable with: patch [{\"op\":\"add\",\"path\":\"/profiles/defaults/autoMarkPrompts\",\"value\":true}]");
}
var showMarks = defaults?["showMarksOnScrollbar"]?.GetValue<bool>() ?? false;
sb.AppendLine($" showMarksOnScrollbar: {(showMarks ? " enabled" : " disabled")}");
if (!showMarks)
{
sb.AppendLine(" → Enable with: patch [{\"op\":\"add\",\"path\":\"/profiles/defaults/showMarksOnScrollbar\",\"value\":true}]");
}
// Build a lookup of action id → keys from the keybindings array
var keybindingsMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (doc!["keybindings"] is JsonArray keybindings)
{
foreach (var kb in keybindings)
{
var id = kb?["id"]?.GetValue<string>();
var keys = kb?["keys"]?.GetValue<string>();
if (id is not null && keys is not null)
{
keybindingsMap[id] = keys;
}
}
}
// Check each shell-integration-related action individually
sb.AppendLine();
sb.AppendLine(" Shell integration actions:");
var shellIntegrationActions = new (string ActionName, string? Direction)[]
{
("scrollToMark", "previous"),
("scrollToMark", "next"),
("selectCommand", null),
("selectOutput", null),
};
if (doc!["actions"] is JsonArray actions)
{
foreach (var (actionName, direction) in shellIntegrationActions)
{
var label = direction is not null ? $"{actionName} ({direction})" : actionName;
var found = false;
string? actionId = null;
foreach (var action in actions)
{
var cmd = action?["command"];
string? actionStr = null;
string? dirStr = null;
if (cmd is JsonValue)
{
actionStr = cmd.GetValue<string>();
}
else if (cmd is JsonObject cmdObj)
{
actionStr = cmdObj["action"]?.GetValue<string>();
dirStr = cmdObj["direction"]?.GetValue<string>();
}
if (actionStr is not null &&
actionStr.Equals(actionName, StringComparison.OrdinalIgnoreCase) &&
(direction is null || (dirStr is not null && dirStr.Equals(direction, StringComparison.OrdinalIgnoreCase))))
{
found = true;
actionId = action?["id"]?.GetValue<string>();
break;
}
}
if (found)
{
var keyInfo = actionId is not null && keybindingsMap.TryGetValue(actionId, out var keys)
? $" → bound to: {keys}"
: " (no keybinding)";
sb.AppendLine($" ✓ {label}{keyInfo}");
}
else
{
sb.AppendLine($" ✗ {label}: not found");
}
}
}
else
{
sb.AppendLine(" (no actions array found)");
}
}
sb.AppendLine($" Channel: {resolved}");
}
sb.AppendLine();
// Shell-side checks
sb.AppendLine("=== PowerShell ===");
CheckPowerShellIntegration(sb);
sb.AppendLine();
sb.AppendLine("=== CMD / Clink ===");
CheckClinkIntegration(sb);
sb.AppendLine();
sb.AppendLine("=== Bash (Windows / Git Bash) ===");
CheckWindowsBashIntegration(sb);
sb.AppendLine();
sb.AppendLine("=== Zsh (Windows / MSYS2) ===");
CheckWindowsZshIntegration(sb);
sb.AppendLine();
sb.AppendLine("=== WSL Distributions ===");
CheckWslIntegration(sb);
return sb.ToString().TrimEnd();
}
/// <summary>
/// Checks the user's PowerShell profile for OMP or manual OSC 133 markers.
/// </summary>
private static void CheckPowerShellIntegration(StringBuilder sb)
{
var profilePath = OhMyPoshHelper.GetPowerShellProfilePath();
if (profilePath is null || !File.Exists(profilePath))
{
sb.AppendLine($" Profile: {profilePath ?? "unknown"}");
sb.AppendLine(" ⚠ Profile does not exist yet");
return;
}
var content = File.ReadAllText(profilePath);
sb.AppendLine($" Profile: {profilePath}");
var hasOmp = content.Contains("oh-my-posh", StringComparison.OrdinalIgnoreCase);
if (hasOmp)
{
sb.AppendLine(" Oh My Posh: ✓ detected in profile");
CheckOmpShellIntegration(content, sb);
}
else
{
sb.AppendLine(" Oh My Posh: not detected");
var hasMarkers = HasOsc133Markers(content, powershell: true);
sb.AppendLine($" Manual shell integration (OSC 133): {(hasMarkers ? " likely present" : " not detected")}");
if (!hasMarkers)
{
sb.AppendLine(" → Use GetShellIntegrationSnippet('powershell') to get the setup code");
}
}
}
/// <summary>
/// Checks if Clink is installed (provides automatic shell integration for CMD).
/// </summary>
private static void CheckClinkIntegration(StringBuilder sb)
{
var clinkPath = FindOnPath("clink");
if (clinkPath is not null)
{
sb.AppendLine($" Clink: ✓ installed ({clinkPath})");
sb.AppendLine(" Shell integration: ✓ automatic (Clink provides FinalTerm sequences natively in Windows Terminal)");
}
else
{
sb.AppendLine(" Clink: ✗ not installed");
sb.AppendLine(" Shell integration requires Clink v1.14.25+ for CMD");
sb.AppendLine(" → Install Clink: https://chrisant996.github.io/clink/");
}
}
/// <summary>
/// Checks ~/.bashrc on Windows (Git Bash / MSYS2) for shell integration markers.
/// </summary>
private static void CheckWindowsBashIntegration(StringBuilder sb)
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var bashrc = Path.Combine(home, ".bashrc");
if (!File.Exists(bashrc))
{
sb.AppendLine($" {bashrc}: not found");
// Check if Git Bash is installed
var gitBash = FindOnPath("bash");
if (gitBash is not null)
{
sb.AppendLine($" bash: ✓ found ({gitBash})");
sb.AppendLine(" → Create ~/.bashrc with shell integration snippet");
sb.AppendLine(" → Use GetShellIntegrationSnippet('bash') to get the setup code");
}
else
{
sb.AppendLine(" bash: not found on PATH (Git Bash or MSYS2 not detected)");
}
return;
}
var content = File.ReadAllText(bashrc);
sb.AppendLine($" {bashrc}: ✓ found");
var hasMarkers = HasOsc133Markers(content, powershell: false);
sb.AppendLine($" Shell integration (OSC 133): {(hasMarkers ? " likely present" : " not detected")}");
if (!hasMarkers)
{
sb.AppendLine(" → Use GetShellIntegrationSnippet('bash') to get the setup code");
}
}
/// <summary>
/// Checks ~/.zshrc on Windows (MSYS2 / Git) for shell integration markers.
/// </summary>
private static void CheckWindowsZshIntegration(StringBuilder sb)
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var zshrc = Path.Combine(home, ".zshrc");
if (!File.Exists(zshrc))
{
sb.AppendLine($" {zshrc}: not found");
var zshPath = FindOnPath("zsh");
if (zshPath is not null)
{
sb.AppendLine($" zsh: ✓ found ({zshPath})");
sb.AppendLine(" → Create ~/.zshrc with shell integration snippet");
sb.AppendLine(" → Use GetShellIntegrationSnippet('zsh') to get the setup code");
}
else
{
sb.AppendLine(" zsh: not found on PATH");
}
return;
}
var content = File.ReadAllText(zshrc);
sb.AppendLine($" {zshrc}: ✓ found");
var hasMarkers = HasOsc133Markers(content, powershell: false);
sb.AppendLine($" Shell integration (OSC 133): {(hasMarkers ? " likely present" : " not detected")}");
if (!hasMarkers)
{
sb.AppendLine(" → Use GetShellIntegrationSnippet('zsh') to get the setup code");
}
}
/// <summary>
/// Enumerates WSL distributions and checks their shell profiles for integration markers.
/// </summary>
private static void CheckWslIntegration(StringBuilder sb)
{
var distros = GetWslDistributions();
if (distros is null)
{
sb.AppendLine(" WSL: not available (wsl.exe not found or returned an error)");
return;
}
if (distros.Count == 0)
{
sb.AppendLine(" WSL: no distributions installed");
return;
}
sb.AppendLine($" Found {distros.Count} distribution(s):\n");
foreach (var distro in distros)
{
sb.AppendLine($" [{distro}]");
// Check .bashrc inside the WSL distro
var bashrcContent = ReadWslFile(distro, "~/.bashrc");
var zshrcContent = ReadWslFile(distro, "~/.zshrc");
if (bashrcContent is not null)
{
var hasBashMarkers = HasOsc133Markers(bashrcContent, powershell: false);
sb.AppendLine($" ~/.bashrc: ✓ found");
sb.AppendLine($" bash shell integration: {(hasBashMarkers ? " likely present" : " not detected")}");
}
else
{
sb.AppendLine(" ~/.bashrc: not found or not readable");
}
if (zshrcContent is not null)
{
var hasZshMarkers = HasOsc133Markers(zshrcContent, powershell: false);
sb.AppendLine($" ~/.zshrc: ✓ found");
sb.AppendLine($" zsh shell integration: {(hasZshMarkers ? " likely present" : " not detected")}");
}
// Check if OMP is present in either profile
var ompInBash = bashrcContent?.Contains("oh-my-posh", StringComparison.OrdinalIgnoreCase) ?? false;
var ompInZsh = zshrcContent?.Contains("oh-my-posh", StringComparison.OrdinalIgnoreCase) ?? false;
if (ompInBash || ompInZsh)
{
var shell = ompInBash ? "bash" : "zsh";
sb.AppendLine($" Oh My Posh: ✓ detected in {shell} profile");
}
sb.AppendLine();
}
}
/// <summary>
/// Checks whether an OMP config has shell_integration enabled.
/// </summary>
private static void CheckOmpShellIntegration(string profileContent, StringBuilder sb)
{
var configPath = OhMyPoshHelper.ExtractConfigPath(profileContent);
if (configPath is null || !File.Exists(configPath))
{
sb.AppendLine(" OMP config: could not locate or read");
return;
}
if (!configPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($" OMP config: {configPath} (non-JSON — cannot auto-check shell_integration)");
return;
}
try
{
var configJson = File.ReadAllText(configPath);
var configDoc = JsonNode.Parse(configJson);
var shellIntegration = configDoc?["shell_integration"]?.GetValue<bool>() ?? false;
sb.AppendLine($" OMP shell_integration: {(shellIntegration ? " enabled" : " disabled")}");
if (!shellIntegration)
{
sb.AppendLine(" → Enable in OMP config: set \"shell_integration\": true");
sb.AppendLine(" → Or use GetShellIntegrationSnippet('omp') for details");
}
}
catch
{
sb.AppendLine(" OMP shell_integration: could not parse config");
}
}
/// <summary>
/// Checks if content contains OSC 133 shell integration markers.
/// </summary>
private static bool HasOsc133Markers(string content, bool powershell)
{
if (!content.Contains("133", StringComparison.Ordinal))
{
return false;
}
if (powershell)
{
// PowerShell escape sequences
return content.Contains("`e]", StringComparison.OrdinalIgnoreCase) ||
content.Contains("\\x1b]", StringComparison.Ordinal) ||
content.Contains("\\e]", StringComparison.Ordinal) ||
content.Contains("\x1b]", StringComparison.Ordinal);
}
// Bash/Zsh escape sequences
return content.Contains("\\e]", StringComparison.Ordinal) ||
content.Contains("\\x1b]", StringComparison.Ordinal) ||
content.Contains("\\033]", StringComparison.Ordinal) ||
content.Contains("\x1b]", StringComparison.Ordinal);
}
/// <summary>
/// Finds an executable on the PATH using where.exe.
/// </summary>
private static string? FindOnPath(string executable)
{
var result = ProcessHelper.Run("where.exe", executable);
if (result is null || result.ExitCode != 0 || string.IsNullOrWhiteSpace(result.Stdout))
{
return null;
}
return result.Stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0].Trim();
}
/// <summary>
/// Lists installed WSL distributions. Returns null if WSL is not available.
/// </summary>
private static List<string>? GetWslDistributions()
{
// WSL can be slow to enumerate — give it a generous timeout
var result = ProcessHelper.Run("wsl.exe", "--list --quiet", timeoutMs: 10000, outputEncoding: System.Text.Encoding.Unicode);
if (result is null || result.ExitCode != 0)
{
return null;
}
var distros = result.Stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim().TrimEnd('\0', '\r'))
.Where(l => !string.IsNullOrWhiteSpace(l))
.ToList();
return distros;
}
/// <summary>
/// Reads a file from inside a WSL distribution. Returns null on failure.
/// </summary>
private static string? ReadWslFile(string distro, string filePath)
{
var result = ProcessHelper.Run("wsl.exe", $"-d {distro} cat {filePath}", timeoutMs: 10000);
return result is not null && result.ExitCode == 0 ? result.Stdout : null;
}
[McpServerTool, Description("""
Returns the shell integration code snippet for a given shell.
The snippet adds OSC 133 (FinalTerm) escape sequences that let Windows Terminal
understand prompt boundaries, track command status, and enable features like
scrolling between commands and selecting command output.
For Oh My Posh users, returns the simpler config change to enable shell_integration
in the OMP config instead of the manual prompt function.
Display the returned snippet to the user as a code block so they can copy it
into their shell profile.
""")]
public static string GetShellIntegrationSnippet(
[Description("The shell to generate a snippet for: 'powershell', 'bash', 'zsh', 'cmd', or 'omp' (Oh My Posh).")] string shell)
{
var sb = new StringBuilder();
var shellLower = shell.Trim().ToLowerInvariant();
switch (shellLower)
{
case "powershell" or "pwsh" or "ps" or "ps1":
sb.AppendLine("Add the following to your PowerShell profile ($PROFILE):\n");
sb.AppendLine(GetPowerShellSnippet());
sb.AppendLine();
sb.AppendLine($"Profile location: {OhMyPoshHelper.GetPowerShellProfilePath() ?? "$PROFILE"}");
break;
case "bash":
sb.AppendLine("Add the following to your ~/.bashrc:\n");
sb.AppendLine(GetBashSnippet());
break;
case "zsh":
sb.AppendLine("Add the following to your ~/.zshrc:\n");
sb.AppendLine(GetZshSnippet());
break;
case "cmd" or "clink":
sb.AppendLine("Shell integration for CMD requires Clink v1.14.25 or later.");
sb.AppendLine("Clink supports FinalTerm sequences natively.\n");
sb.AppendLine("Install Clink: https://chrisant996.github.io/clink/");
sb.AppendLine("Clink automatically provides shell integration when running in Windows Terminal.");
break;
case "omp" or "oh-my-posh":
sb.AppendLine("Oh My Posh has built-in shell integration support.\n");
sb.AppendLine("Add or set this in your Oh My Posh config file (JSON):\n");
sb.AppendLine(" \"shell_integration\": true\n");
sb.AppendLine("This is the recommended approach when using Oh My Posh.");
sb.AppendLine("It wraps the prompt with FinalTerm OSC 133 sequences automatically.");
var configPath = OhMyPoshHelper.DetectConfigFromProfile();
if (configPath is not null)
{
sb.AppendLine($"\nYour config file: {configPath}");
}
break;
default:
sb.AppendLine($"Unknown shell: \"{shell}\"");
sb.AppendLine("Supported shells: powershell, bash, zsh, cmd (via Clink), omp (Oh My Posh)");
break;
}
return sb.ToString().TrimEnd();
}
private const string ShellIntegrationMarker = "# Shell integration for Windows Terminal";
[McpServerTool, Description("""
Enables shell integration by writing the appropriate snippet to a shell profile.
Handles PowerShell, bash, zsh (Windows or WSL), and Oh My Posh.
Idempotent — will not add the snippet if shell integration is already detected.
For OMP users on PowerShell, sets shell_integration: true in the OMP config instead.
For WSL, specify the distribution name (e.g., "Ubuntu").
""")]
public static string EnableShellIntegration(
[Description("The shell to enable integration for: 'powershell', 'bash', 'zsh', or 'omp' (Oh My Posh).")] string shell,
[Description("WSL distribution name (e.g., 'Ubuntu'). Required for WSL bash/zsh. Omit for Windows shells.")] string? wslDistro = null)
{
var shellLower = shell.Trim().ToLowerInvariant();
switch (shellLower)
{
case "powershell" or "pwsh" or "ps" or "ps1":
return EnablePowerShellIntegration();
case "bash":
return wslDistro is not null
? EnableWslShellIntegration(wslDistro, "bash", "~/.bashrc", GetBashSnippet())
: EnableWindowsShellIntegration("bash", ".bashrc", GetBashSnippet());
case "zsh":
return wslDistro is not null
? EnableWslShellIntegration(wslDistro, "zsh", "~/.zshrc", GetZshSnippet())
: EnableWindowsShellIntegration("zsh", ".zshrc", GetZshSnippet());
case "omp" or "oh-my-posh":
return EnableOmpShellIntegration();
default:
return $"Unknown shell: \"{shell}\". Supported: powershell, bash, zsh, omp";
}
}
/// <summary>
/// Enables shell integration for PowerShell. If OMP is detected, delegates to OMP integration.
/// Otherwise appends the PowerShell snippet to $PROFILE.
/// </summary>
private static string EnablePowerShellIntegration()
{
var profilePath = OhMyPoshHelper.GetPowerShellProfilePath();
if (profilePath is null)
{
return "Could not determine PowerShell profile path.";
}
if (File.Exists(profilePath))
{
var content = File.ReadAllText(profilePath);
if (content.Contains("oh-my-posh", StringComparison.OrdinalIgnoreCase))
{
return EnableOmpShellIntegration();
}
if (HasOsc133Markers(content, powershell: true))
{
return "Shell integration is already present in your PowerShell profile.";
}
}
var snippet = "\n" + GetPowerShellSnippet() + "\n";
File.AppendAllText(profilePath, snippet);
return $"✓ Shell integration appended to: {profilePath}\nRestart your shell or run `. $PROFILE` to activate.";
}
/// <summary>
/// Enables OMP shell_integration by patching the OMP config file.
/// </summary>
private static string EnableOmpShellIntegration()
{
var configPath = OhMyPoshHelper.DetectConfigFromProfile();
if (configPath is null)
{
return "Could not detect Oh My Posh config path from your PowerShell profile.";
}
if (!configPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase) &&
!configPath.EndsWith(".omp.json", StringComparison.OrdinalIgnoreCase))
{
return $"OMP config is not JSON ({configPath}). Manually add shell_integration to your config.";
}
try
{
var json = File.ReadAllText(configPath);
var doc = JsonNode.Parse(json);
if (doc is null)
{
return "Failed to parse OMP config.";
}
var current = doc["shell_integration"]?.GetValue<bool>() ?? false;
if (current)
{
return "✓ Oh My Posh shell_integration is already enabled.";
}
doc["shell_integration"] = true;
File.Copy(configPath, configPath + ".bak", overwrite: true);
File.WriteAllText(configPath, doc.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
return $"✓ Enabled shell_integration in: {configPath}\nRestart your shell to activate.";
}
catch (Exception ex)
{
return $"Error updating OMP config: {ex.Message}";
}
}
/// <summary>
/// Enables shell integration for a Windows-side bash/zsh profile.
/// </summary>
private static string EnableWindowsShellIntegration(string shellName, string rcFileName, string snippet)
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var rcPath = Path.Combine(home, rcFileName);
if (File.Exists(rcPath))
{
var content = File.ReadAllText(rcPath);
if (content.Contains(ShellIntegrationMarker, StringComparison.Ordinal))
{
return $"Shell integration is already present in {rcPath}.";
}
}
File.AppendAllText(rcPath, "\n" + snippet + "\n");
return $"✓ Shell integration appended to: {rcPath}\nRestart your {shellName} session to activate.";
}
/// <summary>
/// Enables shell integration inside a WSL distribution by appending to the profile via wsl.exe.
/// </summary>
private static string EnableWslShellIntegration(string distro, string shellName, string rcFile, string snippet)
{
// Check if the distro exists
var distros = GetWslDistributions();
if (distros is null)
{
return "WSL is not available.";
}
if (!distros.Any(d => d.Equals(distro, StringComparison.OrdinalIgnoreCase)))
{
return $"WSL distribution \"{distro}\" not found. Available: {string.Join(", ", distros)}";
}
// Check if integration is already present
var existing = ReadWslFile(distro, rcFile);
if (existing is not null && existing.Contains(ShellIntegrationMarker, StringComparison.Ordinal))
{
return $"Shell integration is already present in {rcFile} on {distro}.";
}
// Write the snippet to a temp file on Windows with LF line endings,
// then use wsl to append it to the profile
var tempFile = Path.GetTempFileName();
try
{
var content = "\n" + snippet + "\n";
content = content.Replace("\r\n", "\n");
File.WriteAllBytes(tempFile, System.Text.Encoding.UTF8.GetBytes(content));
// Convert Windows path to WSL path
var wslPathResult = ProcessHelper.Run("wsl.exe", $"-d {distro} wslpath \"{tempFile}\"", timeoutMs: 10000);
if (wslPathResult is null || wslPathResult.ExitCode != 0)
{
return $"Failed to convert temp file path for WSL. Is the {distro} distribution running?";
}
var wslTempPath = wslPathResult.Stdout.Trim();
// Append to the profile
var appendResult = ProcessHelper.Run("wsl.exe", $"-d {distro} bash -c \"cat '{wslTempPath}' >> {rcFile}\"", timeoutMs: 10000);
if (appendResult is null || appendResult.ExitCode != 0)
{
var err = appendResult?.Stderr ?? "unknown error";
return $"Failed to append to {rcFile} on {distro}: {err}";
}
// Verify it was written
var verify = ReadWslFile(distro, rcFile);
if (verify is not null && verify.Contains(ShellIntegrationMarker, StringComparison.Ordinal))
{
return $"✓ Shell integration appended to {rcFile} on WSL/{distro}.\nRestart your {shellName} session to activate.";
}
return $"Append appeared to succeed but verification failed. Check {rcFile} on {distro} manually.";
}
finally
{
try { File.Delete(tempFile); } catch { }
}
}
private static string GetPowerShellSnippet()
{
return """
# Shell integration for Windows Terminal
# Marks prompt boundaries and tracks command status using OSC 133 sequences
$Global:__LastHistoryId = -1
function Global:__Terminal-Get-LastExitCode {
if ($? -eq $True) { return 0 }
$LastHistoryEntry = $(Get-History -Count 1)
$IsPowerShellError = $Error[0] -and $Error[0].InvocationInfo -and
$Error[0].InvocationInfo.HistoryId -eq $LastHistoryEntry.Id
if ($IsPowerShellError) { return -1 }
return 0
}
function Global:prompt {
$LastExitCode = $(__Terminal-Get-LastExitCode)
# FTCS_COMMAND_FINISHED marks end of previous command output with exit code
if ($Global:__LastHistoryId -ne -1) {
Write-Host "`e]133;D;$LastExitCode`a" -NoNewline
}
$loc = $executionContext.SessionState.Path.CurrentLocation
$out = ""
# OSC 9;9 notify Terminal of current working directory
$out += "`e]9;9;`"$($loc.Path)`"`a"
# FTCS_PROMPT marks start of prompt
$out += "`e]133;A`a"
# Your prompt content here (customize as desired)
$out += "PS $loc$('>' * ($nestedPromptLevel + 1)) "
# FTCS_COMMAND_START marks end of prompt / start of user input
$out += "`e]133;B`a"
$Global:__LastHistoryId = (Get-History -Count 1).Id ?? -1
return $out
}
""";
}
private static string GetBashSnippet()
{
return """
# Shell integration for Windows Terminal
# Marks prompt boundaries and tracks command status using OSC 133 sequences
__terminal_prompt_start() {
printf '\e]133;A\a'
}
__terminal_prompt_end() {
printf '\e]133;B\a'
}
__terminal_preexec() {
printf '\e]133;C\a'
}
__terminal_precmd() {
local exit_code=$?
printf '\e]133;D;%d\a' "$exit_code"
printf '\e]9;9;"%s"\a' "$PWD"
}
PROMPT_COMMAND="__terminal_precmd;${PROMPT_COMMAND}"
PS0="${PS0}\$(__terminal_preexec)"
PS1="\$(__terminal_prompt_start)${PS1}\$(__terminal_prompt_end)"
""";
}
private static string GetZshSnippet()
{
return """
# Shell integration for Windows Terminal
# Marks prompt boundaries and tracks command status using OSC 133 sequences
__terminal_precmd() {
printf '\e]133;D;%d\a' "$?"
printf '\e]9;9;"%s"\a' "$PWD"
}
__terminal_preexec() {
printf '\e]133;C\a'
}
precmd_functions+=(__terminal_precmd)
preexec_functions+=(__terminal_preexec)
# Wrap prompt with FTCS markers
PS1=$'%{\e]133;A\a%}'$PS1$'%{\e]133;B\a%}'
""";
}
}

View File

@@ -0,0 +1,530 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
/// <summary>
/// MCP tools for managing snippets (sendInput commands) across all Windows Terminal locations.
/// Snippets can live in three places:
/// - wt.json files: per-directory snippets, best for project/folder-specific commands
/// - settings.json actions: global sendInput actions, available everywhere via command palette
/// - fragment extensions: portable sendInput actions grouped with other fragment settings
/// The AddSnippet tool handles all three locations via its "location" parameter.
/// </summary>
[McpServerToolType]
internal class SnippetTools
{
// wt.json snippets are cached per-directory in Terminal's memory.
// New directories load on first access, but edits to already-cached
// directories require restarting Terminal.
private const string WtJsonReloadNote =
"Snippets appear in the Suggestions UI and Snippets Pane. " +
"If this directory was already cached, restart Terminal to pick up the changes.";
// settings.json changes are picked up automatically by Terminal's file watcher.
private const string SettingsReloadNote =
"Available in the Suggestions UI, Snippets Pane, and Command Palette. " +
"Settings changes are picked up automatically.";
[McpServerTool, Description("""
Reads a wt.json snippet file. If no path is given, searches the current directory
and parent directories (same lookup behavior as Windows Terminal).
Returns the file content and its resolved location.
""")]
public static string ReadWtJson(
[Description("Path to the wt.json file or the directory to search in. If omitted, searches from the current directory upward.")] string? path = null)
{
string resolvedPath;
if (path is not null && File.Exists(path))
{
resolvedPath = Path.GetFullPath(path);
}
else
{
var searchDir = path is not null && Directory.Exists(path) ? path : null;
var found = WtJsonHelper.FindWtJson(searchDir);
if (found is null)
{
var searchFrom = searchDir ?? Directory.GetCurrentDirectory();
return $"No .wt.json file found searching from: {searchFrom}";
}
resolvedPath = found;
}
var (content, error) = WtJsonHelper.ReadWtJson(resolvedPath);
if (error is not null)
{
return error;
}
return $"File: {resolvedPath}\n\n{content}";
}
[McpServerTool, Description("""
Previews creating a new wt.json snippet file WITHOUT writing any changes.
Returns the formatted content and validation results.
Always call this before CreateWtJson so the user can review the content.
Display the returned preview to the user in your response so they can review it.
wt.json files require "$version" (string) and "snippets" (array).
Each snippet needs "name" and "input". Optional: "description" and "icon".
Add \r at the end of "input" to auto-execute the command.
""")]
public static string PreviewCreateWtJson(
[Description("The full JSON content of the wt.json file")] string wtJson,
[Description("Directory to create the file in. Defaults to the current directory.")] string? directory = null)
{
var dir = directory ?? Directory.GetCurrentDirectory();
var path = Path.Combine(dir, ".wt.json");
var prettyJson = WtJsonHelper.PrettyPrint(wtJson);
var exists = File.Exists(path);
var validationErrors = WtJsonHelper.Validate(prettyJson);
var sb = new StringBuilder();
sb.AppendLine(exists
? $"⚠ File already exists and will be overwritten: {path}"
: $"Will create: {path}");
sb.AppendLine();
sb.AppendLine(prettyJson);
if (validationErrors.Count > 0)
{
sb.AppendLine();
sb.AppendLine("⚠ Validation issues:");
foreach (var error in validationErrors)
{
sb.AppendLine($" ⚠ {error}");
}
}
else
{
sb.AppendLine();
sb.AppendLine("✓ Validation passed");
}
return sb.ToString().TrimEnd();
}
[McpServerTool, Description("""
Creates a new wt.json snippet file.
IMPORTANT: Always call PreviewCreateWtJson first and show the preview to the user.
After showing the preview, call this tool immediately — do not ask for separate user confirmation.
The client will show its own confirmation dialog for approval.
""")]
public static string CreateWtJson(
[Description("The full JSON content of the wt.json file")] string wtJson,
[Description("Directory to create the file in. Defaults to the current directory.")] string? directory = null)
{
var dir = directory ?? Directory.GetCurrentDirectory();
var path = Path.Combine(dir, ".wt.json");
var prettyJson = WtJsonHelper.PrettyPrint(wtJson);
var validationErrors = WtJsonHelper.Validate(prettyJson);
if (validationErrors.Count > 0)
{
var errors = string.Join("\n", validationErrors.Select(e => $" ✗ {e}"));
return $"wt.json rejected — validation failed:\n{errors}";
}
WtJsonHelper.WriteWtJson(path, prettyJson);
return $"Created: {path}\n\n{WtJsonReloadNote}";
}
[McpServerTool, Description("""
Previews changes to an existing wt.json file WITHOUT writing any changes.
Returns a unified diff showing exactly what would change.
Always call this before UpdateWtJson so the user can review the diff.
IMPORTANT: You MUST display the returned diff inside a ```diff fenced code block.
""")]
public static string PreviewUpdateWtJson(
[Description("The new full JSON content of the wt.json file")] string wtJson,
[Description("Path to the wt.json file. If omitted, searches from the current directory upward.")] string? path = null)
{
var resolvedPath = ResolvePath(path);
if (resolvedPath is null)
{
return path is null
? "No .wt.json file found. Use PreviewCreateWtJson/CreateWtJson to create a new one."
: $"File not found: {path}";
}
var (currentContent, error) = WtJsonHelper.ReadWtJson(resolvedPath);
if (error is not null)
{
return error;
}
var prettyNew = WtJsonHelper.PrettyPrint(wtJson);
var validationErrors = WtJsonHelper.Validate(prettyNew);
var diff = SettingsHelper.UnifiedDiff(currentContent!, prettyNew, Path.GetFileName(resolvedPath));
if (string.IsNullOrEmpty(diff))
{
return "No changes — the new content is identical to the existing file.";
}
var sb = new StringBuilder();
sb.Append(diff);
if (validationErrors.Count > 0)
{
sb.AppendLine();
sb.AppendLine("⚠ Validation issues:");
foreach (var ve in validationErrors)
{
sb.AppendLine($" ⚠ {ve}");
}
}
else
{
sb.AppendLine();
sb.AppendLine("✓ Validation passed");
}
return sb.ToString().TrimEnd();
}
[McpServerTool, Description("""
Updates an existing wt.json file with new content.
IMPORTANT: Always call PreviewUpdateWtJson first and show the diff to the user.
After showing the diff, call this tool immediately — do not ask for separate user confirmation.
The client will show its own confirmation dialog for approval.
""")]
public static string UpdateWtJson(
[Description("The new full JSON content of the wt.json file")] string wtJson,
[Description("Path to the wt.json file. If omitted, searches from the current directory upward.")] string? path = null)
{
var resolvedPath = ResolvePath(path);
if (resolvedPath is null)
{
return path is null
? "No .wt.json file found. Use CreateWtJson to create a new one."
: $"File not found: {path}";
}
var prettyJson = WtJsonHelper.PrettyPrint(wtJson);
var validationErrors = WtJsonHelper.Validate(prettyJson);
if (validationErrors.Count > 0)
{
var errors = string.Join("\n", validationErrors.Select(e => $" ✗ {e}"));
return $"Update rejected — validation failed:\n{errors}";
}
WtJsonHelper.WriteWtJson(resolvedPath, prettyJson);
return $"Updated: {resolvedPath}\n\n{WtJsonReloadNote}";
}
[McpServerTool, Description("""
Adds a single snippet to a wt.json file. If no wt.json exists in the target directory,
creates a new one. If one exists, adds the snippet to the existing snippets array.
Uses the preview/apply pattern: shows what would change, then applies.
Add \r at the end of the input to auto-execute the command when selected.
Snippets (also known as "sendInput actions") can live in three locations:
- "wt.json" (default): Best for project/folder-specific commands. The snippet only appears
when Terminal's working directory is at or below the wt.json file location.
- "settings": Best for globally useful commands that should be available everywhere,
regardless of the current directory. Adds a sendInput action to settings.json.
- "fragment": Best for portable, grouped settings that belong to a fragment extension.
Adds a sendInput action to the specified fragment. Requires appName and fileName.
When the user asks to add a "snippet" or "sendInput action", consider which location
is most appropriate based on the context. If unclear, prefer wt.json for project-specific
commands and settings for general-purpose commands.
""")]
public static string AddSnippet(
[Description("The command text to send to the shell. Add \\r at the end to auto-execute.")] string input,
[Description("Display name for the snippet.")] string name,
[Description("Optional description of what the snippet does.")] string? description = null,
[Description("Optional icon (e.g. a Segoe Fluent icon character).")] string? icon = null,
[Description("Where to add the snippet: 'wt.json' (default, project/folder-specific), 'settings' (global, in settings.json), or 'fragment' (in a fragment extension).")] string location = "wt.json",
[Description("Path to the wt.json file or directory. Only used when location is 'wt.json'. If omitted, uses the current directory.")] string? path = null,
[Description("Fragment app name. Required when location is 'fragment'.")] string? appName = null,
[Description("Fragment file name. Required when location is 'fragment'.")] string? fileName = null,
[Description("The release channel for settings.json. Only used when location is 'settings'.")] TerminalRelease? release = null,
[Description("If true, only preview the change without writing. Default is false.")] bool previewOnly = false)
{
var locationLower = location.Trim().ToLowerInvariant();
return locationLower switch
{
"wt.json" or "wtjson" or "wt" => AddSnippetToWtJson(input, name, description, icon, path, previewOnly),
"settings" or "settings.json" => AddSnippetToSettings(input, name, description, icon, release, previewOnly),
"fragment" or "fragments" => AddSnippetToFragment(input, name, description, icon, appName, fileName, previewOnly),
_ => $"Unknown location: \"{location}\". Use 'wt.json', 'settings', or 'fragment'."
};
}
private static string AddSnippetToWtJson(string input, string name, string? description, string? icon, string? path, bool previewOnly)
{
// Resolve to an existing file or default to CWD
string resolvedPath;
if (path is not null && File.Exists(path))
{
resolvedPath = Path.GetFullPath(path);
}
else if (path is not null && Directory.Exists(path))
{
resolvedPath = Path.Combine(Path.GetFullPath(path), ".wt.json");
}
else
{
var found = WtJsonHelper.FindWtJson(path);
resolvedPath = found ?? Path.Combine(Directory.GetCurrentDirectory(), ".wt.json");
}
// Build the new snippet
var snippet = new JsonObject { ["input"] = input, ["name"] = name };
if (description is not null)
{
snippet["description"] = description;
}
if (icon is not null)
{
snippet["icon"] = icon;
}
JsonObject root;
string before;
if (File.Exists(resolvedPath))
{
var (content, error) = WtJsonHelper.ReadWtJson(resolvedPath);
if (error is not null)
{
return error;
}
before = content!;
root = JsonNode.Parse(before)?.AsObject() ?? new JsonObject();
}
else
{
before = "";
root = new JsonObject
{
["$version"] = "1.0.0",
["snippets"] = new JsonArray()
};
}
// Add the snippet
if (root["snippets"] is not JsonArray snippets)
{
snippets = new JsonArray();
root["snippets"] = snippets;
}
snippets.Add(snippet);
var writeOptions = new JsonSerializerOptions { WriteIndented = true };
var after = root.ToJsonString(writeOptions);
if (string.IsNullOrEmpty(before))
{
// New file
if (previewOnly)
{
return $"Will create: {resolvedPath}\n\n{after}\n\n✓ Validation passed";
}
var validationErrors = WtJsonHelper.Validate(after);
if (validationErrors.Count > 0)
{
var errors = string.Join("\n", validationErrors.Select(e => $" ✗ {e}"));
return $"Rejected — validation failed:\n{errors}";
}
WtJsonHelper.WriteWtJson(resolvedPath, after);
return $"Created {resolvedPath} with snippet \"{name}\".\n\n{WtJsonReloadNote}";
}
// Existing file — show diff
var diff = SettingsHelper.UnifiedDiff(before, after, Path.GetFileName(resolvedPath));
if (previewOnly)
{
return $"Preview of changes to {resolvedPath}:\n\n{diff}";
}
var errors2 = WtJsonHelper.Validate(after);
if (errors2.Count > 0)
{
var errorList = string.Join("\n", errors2.Select(e => $" ✗ {e}"));
return $"Rejected — validation failed:\n{errorList}";
}
WtJsonHelper.WriteWtJson(resolvedPath, after);
return $"Added snippet \"{name}\" to {resolvedPath}.\n\n{WtJsonReloadNote}";
}
private static string AddSnippetToSettings(string input, string name, string? description, string? icon, TerminalRelease? release, bool previewOnly)
{
var resolved = SettingsHelper.ResolveRelease(release);
if (resolved is null)
{
return "No Windows Terminal installations found.";
}
var (doc, error) = SettingsHelper.LoadSettings(resolved.Value);
if (error is not null)
{
return error;
}
// Build a sendInput action
var action = new JsonObject
{
["command"] = new JsonObject
{
["action"] = "sendInput",
["input"] = input
},
["id"] = $"User.sendInput.{SanitizeId(name)}"
};
if (name is not null)
{
action["name"] = name;
}
if (description is not null)
{
action["description"] = description;
}
if (icon is not null)
{
action["icon"] = icon;
}
// Add to actions array
if (doc!["actions"] is not JsonArray actions)
{
actions = new JsonArray();
doc!["actions"] = actions;
}
actions.Add(action);
var writeOptions = new JsonSerializerOptions { WriteIndented = true };
var before = File.ReadAllText(resolved.Value.GetSettingsJsonPath());
var after = doc.ToJsonString(writeOptions);
var diff = SettingsHelper.UnifiedDiff(before, after, $"settings.json ({resolved})");
if (previewOnly)
{
return $"Preview of changes to settings.json ({resolved}):\n\n{diff}";
}
var path = resolved.Value.GetSettingsJsonPath();
File.Copy(path, path + ".bak", overwrite: true);
File.WriteAllText(path, after);
return $"Added sendInput action \"{name}\" to settings.json ({resolved}).\n\n{SettingsReloadNote}";
}
private static string AddSnippetToFragment(string input, string name, string? description, string? icon, string? appName, string? fileName, bool previewOnly)
{
if (string.IsNullOrWhiteSpace(appName))
{
return "appName is required when adding a snippet to a fragment extension.";
}
if (string.IsNullOrWhiteSpace(fileName))
{
return "fileName is required when adding a snippet to a fragment extension.";
}
// Build a sendInput action for the fragment
var action = new JsonObject
{
["command"] = new JsonObject
{
["action"] = "sendInput",
["input"] = input
},
["id"] = $"{SanitizeId(appName)}.sendInput.{SanitizeId(name)}"
};
if (name is not null)
{
action["name"] = name;
}
if (description is not null)
{
action["description"] = description;
}
if (icon is not null)
{
action["icon"] = icon;
}
// Load or create the fragment
var fragPath = FragmentHelper.FindFragmentPath(appName, fileName);
JsonObject root;
string before;
if (fragPath is not null)
{
var (content, error) = FragmentHelper.ReadFragment(appName, fileName);
if (error is not null)
{
return error;
}
before = content!;
root = JsonNode.Parse(before)?.AsObject() ?? new JsonObject();
}
else
{
before = "";
root = new JsonObject();
}
// Add to actions array
if (root["actions"] is not JsonArray actions)
{
actions = new JsonArray();
root["actions"] = actions;
}
actions.Add(action);
var writeOptions = new JsonSerializerOptions { WriteIndented = true };
var after = root.ToJsonString(writeOptions);
if (string.IsNullOrEmpty(before))
{
if (previewOnly)
{
return $"Will create fragment {appName}/{fileName}:\n\n{after}";
}
FragmentHelper.WriteFragment(appName, fileName, after);
return $"Created fragment {appName}/{fileName} with sendInput action \"{name}\".";
}
var diff = SettingsHelper.UnifiedDiff(before, after, $"{appName}/{fileName}");
if (previewOnly)
{
return $"Preview of changes to fragment {appName}/{fileName}:\n\n{diff}";
}
FragmentHelper.WriteFragment(appName, fileName, after);
return $"Added sendInput action \"{name}\" to fragment {appName}/{fileName}.";
}
private static string SanitizeId(string value)
{
return new string(value.Where(c => char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-').ToArray());
}
private static string? ResolvePath(string? path)
{
if (path is not null)
{
if (File.Exists(path))
{
return Path.GetFullPath(path);
}
return null;
}
return WtJsonHelper.FindWtJson();
}
}

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifiers>win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64</RuntimeIdentifiers>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Set up the NuGet package to be an MCP server -->
<PackAsTool>true</PackAsTool>
<PackageType>McpServer</PackageType>
<!-- Set up the MCP server to be a self-contained application that does not rely on a shared framework -->
<SelfContained>true</SelfContained>
<PublishSelfContained>true</PublishSelfContained>
<!-- Set up the MCP server to be a single file executable -->
<PublishSingleFile>true</PublishSingleFile>
<!-- Set recommended package metadata -->
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageId>SampleMcpServer</PackageId>
<PackageVersion>0.1.0-beta</PackageVersion>
<PackageTags>AI; MCP; server; stdio</PackageTags>
<Description>An MCP server using the MCP C# SDK.</Description>
</PropertyGroup>
<!-- Include additional files for browsing the MCP server. -->
<ItemGroup>
<None Include=".mcp\server.json" Pack="true" PackagePath="/.mcp/" />
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="JsonPatch.Net" Version="3.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="ModelContextProtocol" Version="0.7.0-preview.1" />
</ItemGroup>
</Project>