mirror of
https://github.com/microsoft/terminal.git
synced 2026-04-14 18:21:02 +00:00
Compare commits
5 Commits
main
...
dev/cazamo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab10aba3b | ||
|
|
911683c561 | ||
|
|
55b80d16c4 | ||
|
|
73f5bb032b | ||
|
|
9f7d3fe179 |
152
.github/copilot-instructions.md
vendored
Normal file
152
.github/copilot-instructions.md
vendored
Normal 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`)
|
||||
@@ -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>
|
||||
|
||||
22
src/tools/wt.mcp/.mcp/server.json
Normal file
22
src/tools/wt.mcp/.mcp/server.json
Normal 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"
|
||||
}
|
||||
}
|
||||
210
src/tools/wt.mcp/Helpers/FragmentHelper.cs
Normal file
210
src/tools/wt.mcp/Helpers/FragmentHelper.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
239
src/tools/wt.mcp/Helpers/OhMyPoshHelper.cs
Normal file
239
src/tools/wt.mcp/Helpers/OhMyPoshHelper.cs
Normal 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();
|
||||
}
|
||||
75
src/tools/wt.mcp/Helpers/ProcessHelper.cs
Normal file
75
src/tools/wt.mcp/Helpers/ProcessHelper.cs
Normal 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);
|
||||
}
|
||||
147
src/tools/wt.mcp/Helpers/SchemaValidator.cs
Normal file
147
src/tools/wt.mcp/Helpers/SchemaValidator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
333
src/tools/wt.mcp/Helpers/SettingsHelper.cs
Normal file
333
src/tools/wt.mcp/Helpers/SettingsHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
56
src/tools/wt.mcp/Helpers/TerminalRelease.cs
Normal file
56
src/tools/wt.mcp/Helpers/TerminalRelease.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
171
src/tools/wt.mcp/Helpers/WtJsonHelper.cs
Normal file
171
src/tools/wt.mcp/Helpers/WtJsonHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/tools/wt.mcp/NuGet.Config
Normal file
6
src/tools/wt.mcp/NuGet.Config
Normal 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>
|
||||
26
src/tools/wt.mcp/Program.cs
Normal file
26
src/tools/wt.mcp/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
99
src/tools/wt.mcp/README.md
Normal file
99
src/tools/wt.mcp/README.md
Normal 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)
|
||||
303
src/tools/wt.mcp/Tools/FragmentTools.cs
Normal file
303
src/tools/wt.mcp/Tools/FragmentTools.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
421
src/tools/wt.mcp/Tools/OhMyPoshTools.cs
Normal file
421
src/tools/wt.mcp/Tools/OhMyPoshTools.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
137
src/tools/wt.mcp/Tools/SettingsTools.cs
Normal file
137
src/tools/wt.mcp/Tools/SettingsTools.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
823
src/tools/wt.mcp/Tools/ShellIntegrationTools.cs
Normal file
823
src/tools/wt.mcp/Tools/ShellIntegrationTools.cs
Normal 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%}'
|
||||
""";
|
||||
}
|
||||
}
|
||||
530
src/tools/wt.mcp/Tools/SnippetTools.cs
Normal file
530
src/tools/wt.mcp/Tools/SnippetTools.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
41
src/tools/wt.mcp/wt.mcp.csproj
Normal file
41
src/tools/wt.mcp/wt.mcp.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user