diff --git a/Pointframe.Cli/Application/CliApplication.cs b/Pointframe.Cli/Application/CliApplication.cs index b8ec5826..5091b4d3 100644 --- a/Pointframe.Cli/Application/CliApplication.cs +++ b/Pointframe.Cli/Application/CliApplication.cs @@ -1,26 +1,59 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text.Json; using Pointframe.Engine; namespace Pointframe.Cli; internal sealed class CliApplication { + private const int SchemaVersion = 1; + private readonly IDirectCaptureService _directCaptureService; + private readonly IDirectRecordingService _directRecordingService; private readonly TextWriter _standardOutput; private readonly TextWriter _standardError; - internal CliApplication(IDirectCaptureService directCaptureService, TextWriter standardOutput, TextWriter standardError) + internal CliApplication( + IDirectCaptureService directCaptureService, + IDirectRecordingService directRecordingService, + TextWriter standardOutput, + TextWriter standardError) { _directCaptureService = directCaptureService; + _directRecordingService = directRecordingService; _standardOutput = standardOutput; _standardError = standardError; } - internal static async Task RunAsync(string[] args, TextWriter standardOutput, TextWriter standardError) + internal static async Task RunAsync(string[] args, TextWriter standardOutput, TextWriter standardError, CancellationToken cancellationToken = default) { try { + // Parse before constructing any capture/recording/OCR service so --help/--version + // (and usage errors) never depend on those services being constructible. + if (!CliCommandParser.TryParse(args, out var command, out var parseError)) + { + await standardError.WriteLineAsync(parseError); + await standardError.WriteLineAsync(CliCommandParser.Usage); + return 2; + } + + if (string.Equals(command.Name, "help", StringComparison.Ordinal)) + { + await standardOutput.WriteLineAsync(CliCommandParser.HelpText); + return 0; + } + + if (string.Equals(command.Name, "version", StringComparison.Ordinal)) + { + await standardOutput.WriteLineAsync(GetVersion()); + return 0; + } + var directCaptureService = new DirectCaptureService(new DisplayCaptureEngine(), ocrEngineService: new WindowsOcrEngineService()); - return await new CliApplication(directCaptureService, standardOutput, standardError).RunAsync(args); + using var directRecordingService = new DirectRecordingService(new DisplayCaptureEngine(), new FfmpegDirectVideoWriterFactory()); + return await new CliApplication(directCaptureService, directRecordingService, standardOutput, standardError).RunCommandAsync(command, cancellationToken); } catch (Exception exception) { @@ -38,8 +71,30 @@ internal async Task RunAsync(string[] args, CancellationToken cancellationT return 2; } + return await RunCommandAsync(command, cancellationToken); + } + + internal async Task RunCommandAsync(CliCommand command, CancellationToken cancellationToken = default) + { try { + if (string.Equals(command.Name, "help", StringComparison.Ordinal)) + { + await _standardOutput.WriteLineAsync(CliCommandParser.HelpText); + return 0; + } + + if (string.Equals(command.Name, "version", StringComparison.Ordinal)) + { + await _standardOutput.WriteLineAsync(GetVersion()); + return 0; + } + + if (string.Equals(command.Name, "record", StringComparison.Ordinal)) + { + return await RunRecordAsync(command, cancellationToken); + } + var payload = command.Name switch { "displays" => _directCaptureService.ListDisplays(), @@ -57,4 +112,58 @@ internal async Task RunAsync(string[] args, CancellationToken cancellationT return 1; } } + + private async Task RunRecordAsync(CliCommand command, CancellationToken cancellationToken) + { + var startResult = _directRecordingService.Start(new DirectRecordingRequest( + command.MonitorName!, + command.RedactionRegions ?? Array.Empty(), + command.FramesPerSecond)); + + if (!startResult.Success) + { + var failureResponse = new DirectRecordingResponse( + SchemaVersion, + false, + new DirectCaptureError(startResult.ErrorCode ?? "recording_start_failed", startResult.ErrorMessage ?? "Unknown error.")); + await _standardOutput.WriteLineAsync(JsonSerializer.Serialize(failureResponse)); + return 1; + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(command.RecordSeconds!.Value), cancellationToken); + } + catch (OperationCanceledException) + { + // Ctrl+C (or another external cancellation) requested an early, graceful stop. + } + + var stopResult = await _directRecordingService.StopAsync(CancellationToken.None); + var response = new DirectRecordingResponse( + SchemaVersion, + stopResult.Success, + stopResult.ErrorCode is null ? null : new DirectCaptureError(stopResult.ErrorCode, stopResult.ErrorMessage ?? "Unknown error."), + startResult.Session, + stopResult.Artifact); + await _standardOutput.WriteLineAsync(JsonSerializer.Serialize(response)); + return stopResult.Success ? 0 : 1; + } + + private static string GetVersion() + { + // Assembly.Location is empty for single-file publishes; use Environment.ProcessPath instead. + var location = Environment.ProcessPath ?? Assembly.GetEntryAssembly()?.Location; + if (!string.IsNullOrEmpty(location)) + { + var fileVersionInfo = FileVersionInfo.GetVersionInfo(location); + if (!string.IsNullOrWhiteSpace(fileVersionInfo.ProductVersion)) + { + return $"Pointframe CLI {fileVersionInfo.ProductVersion}"; + } + } + + var assemblyVersion = Assembly.GetEntryAssembly()?.GetName().Version; + return $"Pointframe CLI {assemblyVersion?.ToString() ?? "0.0.0"}"; + } } diff --git a/Pointframe.Cli/Commands/CliCommand.cs b/Pointframe.Cli/Commands/CliCommand.cs index bdd783bc..ed3f5c37 100644 --- a/Pointframe.Cli/Commands/CliCommand.cs +++ b/Pointframe.Cli/Commands/CliCommand.cs @@ -1,3 +1,10 @@ +using Pointframe.Engine; + namespace Pointframe.Cli; -internal sealed record CliCommand(string Name, string? MonitorName = null); +internal sealed record CliCommand( + string Name, + string? MonitorName = null, + int? RecordSeconds = null, + int FramesPerSecond = 20, + IReadOnlyList? RedactionRegions = null); diff --git a/Pointframe.Cli/Commands/CliCommandParser.cs b/Pointframe.Cli/Commands/CliCommandParser.cs index d28235a5..11128ef3 100644 --- a/Pointframe.Cli/Commands/CliCommandParser.cs +++ b/Pointframe.Cli/Commands/CliCommandParser.cs @@ -1,13 +1,78 @@ +using Pointframe.Engine; + namespace Pointframe.Cli; internal static class CliCommandParser { - internal const string Usage = "Usage: Pointframe.Cli.exe displays | capture --monitor | ocr --monitor "; + internal const string Usage = "Usage: Pointframe.Cli.exe displays | capture --monitor | ocr --monitor | record --monitor --seconds [--fps <1-60>] [--redact ]... | --help | --version"; + + internal const string HelpText = """ + Pointframe CLI - standalone screen capture, OCR, and recording automation. + + Usage: + Pointframe.Cli.exe displays + Pointframe.Cli.exe capture --monitor + Pointframe.Cli.exe ocr --monitor + Pointframe.Cli.exe record --monitor --seconds [--fps <1-60>] [--redact ]... + Pointframe.Cli.exe --help + Pointframe.Cli.exe --version + + Commands: + displays List every connected monitor as JSON. + capture Capture one monitor as a PNG (base64) JSON response. + ocr Capture one monitor and extract on-screen text via OCR as JSON. + record Record one monitor to an MP4 for a fixed duration, then exit with a JSON summary. + + Options: + -m, --monitor Exact Windows device name (see 'displays' for exact values), e.g. \\.\DISPLAY1 + -s, --seconds record: capture duration in whole seconds (positive integer) + -f, --fps <1-60> record: capture frame rate (default 20) + -r, --redact record: pixelate a capture-local physical-pixel region; repeatable + -h, --help Show this help text and exit + -v, --version Show the CLI version and exit + + Every long option above also accepts an inline value, e.g. --monitor=\\.\DISPLAY1. + --help/--version take priority over any other arguments, so they can be appended to + an otherwise invalid or incomplete command line to see usage instead of an error. + + Every command other than --help/--version writes a single-line JSON response to standard output and + uses the process exit code to signal success (0), a runtime error (1), or a usage error (2). + """; internal static bool TryParse(string[] args, out CliCommand command, out string? error) { ArgumentNullException.ThrowIfNull(args); + args = ExpandInlineFlagValues(args); + + if (args.Any(IsHelpToken)) + { + command = new CliCommand("help"); + error = null; + return true; + } + + if (args.Any(IsVersionToken)) + { + command = new CliCommand("version"); + error = null; + return true; + } + + if (args.Length == 1 && IsHelpFlag(args[0])) + { + command = new CliCommand("help"); + error = null; + return true; + } + + if (args.Length == 1 && IsVersionFlag(args[0])) + { + command = new CliCommand("version"); + error = null; + return true; + } + if (args.Length == 1 && string.Equals(args[0], "displays", StringComparison.OrdinalIgnoreCase)) { command = new CliCommand("displays"); @@ -17,7 +82,7 @@ internal static bool TryParse(string[] args, out CliCommand command, out string? if (args.Length == 3 && string.Equals(args[0], "capture", StringComparison.OrdinalIgnoreCase) - && string.Equals(args[1], "--monitor", StringComparison.OrdinalIgnoreCase) + && IsMonitorFlag(args[1]) && !string.IsNullOrWhiteSpace(args[2])) { command = new CliCommand("capture", args[2]); @@ -27,7 +92,7 @@ internal static bool TryParse(string[] args, out CliCommand command, out string? if (args.Length == 3 && string.Equals(args[0], "ocr", StringComparison.OrdinalIgnoreCase) - && string.Equals(args[1], "--monitor", StringComparison.OrdinalIgnoreCase) + && IsMonitorFlag(args[1]) && !string.IsNullOrWhiteSpace(args[2])) { command = new CliCommand("ocr", args[2]); @@ -35,6 +100,11 @@ internal static bool TryParse(string[] args, out CliCommand command, out string? return true; } + if (args.Length > 0 && string.Equals(args[0], "record", StringComparison.OrdinalIgnoreCase)) + { + return TryParseRecord(args, out command, out error); + } + command = default!; error = args.FirstOrDefault()?.Equals("capture", StringComparison.OrdinalIgnoreCase) == true ? "The capture command requires --monitor followed by an exact Windows device name." @@ -43,4 +113,180 @@ internal static bool TryParse(string[] args, out CliCommand command, out string? : "Unknown or incomplete command."; return false; } + + private static bool TryParseRecord(string[] args, out CliCommand command, out string? error) + { + string? monitorName = null; + int? seconds = null; + var framesPerSecond = 20; + var redactionRegions = new List(); + + var index = 1; + while (index < args.Length) + { + var flag = args[index]; + var hasValue = index + 1 < args.Length; + + if (IsMonitorFlag(flag)) + { + if (!hasValue) + { + command = default!; + error = "The record command requires --monitor followed by an exact Windows device name."; + return false; + } + + monitorName = args[index + 1]; + index += 2; + continue; + } + + if (IsSecondsFlag(flag)) + { + if (!hasValue || !int.TryParse(args[index + 1], out var parsedSeconds) || parsedSeconds <= 0) + { + command = default!; + error = "The record command requires --seconds to be a positive integer."; + return false; + } + + seconds = parsedSeconds; + index += 2; + continue; + } + + if (IsFpsFlag(flag)) + { + if (!hasValue || !int.TryParse(args[index + 1], out framesPerSecond) || framesPerSecond is < 1 or > 60) + { + command = default!; + error = "The record command requires --fps to be an integer between 1 and 60."; + return false; + } + + index += 2; + continue; + } + + if (IsRedactFlag(flag)) + { + if (!hasValue || !TryParseRedactionRegion(args[index + 1], out var region)) + { + command = default!; + error = "Each --redact value must be four comma-separated integers formatted as x,y,width,height with a positive width and height."; + return false; + } + + redactionRegions.Add(region); + index += 2; + continue; + } + + command = default!; + error = $"Unrecognized record option '{flag}'."; + return false; + } + + if (string.IsNullOrWhiteSpace(monitorName)) + { + command = default!; + error = "The record command requires --monitor followed by an exact Windows device name."; + return false; + } + + if (seconds is null) + { + command = default!; + error = "The record command requires --seconds followed by a positive integer duration."; + return false; + } + + command = new CliCommand("record", monitorName, seconds, framesPerSecond, redactionRegions); + error = null; + return true; + } + + private static bool TryParseRedactionRegion(string value, out PixelBounds region) + { + var parts = value.Split(','); + if (parts.Length == 4 + && int.TryParse(parts[0], out var x) + && int.TryParse(parts[1], out var y) + && int.TryParse(parts[2], out var width) + && int.TryParse(parts[3], out var height) + && width > 0 + && height > 0) + { + region = new PixelBounds(x, y, width, height); + return true; + } + + region = default; + return false; + } + + /// + /// Splits any "--flag=value" token into separate "--flag" and "value" tokens so every existing + /// flag-parsing branch below can keep matching on adjacent tokens without knowing about the + /// inline-value syntax. + /// + private static string[] ExpandInlineFlagValues(string[] args) + { + var hasInlineValue = args.Any(arg => arg.StartsWith("--", StringComparison.Ordinal) && arg.IndexOf('=') > 2); + if (!hasInlineValue) + { + return args; + } + + var expanded = new List(args.Length); + foreach (var arg in args) + { + var equalsIndex = arg.IndexOf('='); + if (arg.StartsWith("--", StringComparison.Ordinal) && equalsIndex > 2) + { + expanded.Add(arg[..equalsIndex]); + expanded.Add(arg[(equalsIndex + 1)..]); + } + else + { + expanded.Add(arg); + } + } + + return [.. expanded]; + } + + private static bool IsHelpToken(string value) => + string.Equals(value, "--help", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "-h", StringComparison.OrdinalIgnoreCase); + + private static bool IsVersionToken(string value) => + string.Equals(value, "--version", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "-v", StringComparison.OrdinalIgnoreCase); + + private static bool IsHelpFlag(string value) => + string.Equals(value, "--help", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "-h", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "help", StringComparison.OrdinalIgnoreCase); + + private static bool IsVersionFlag(string value) => + string.Equals(value, "--version", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "-v", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "version", StringComparison.OrdinalIgnoreCase); + + private static bool IsMonitorFlag(string value) => + string.Equals(value, "--monitor", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "-m", StringComparison.OrdinalIgnoreCase); + + private static bool IsSecondsFlag(string value) => + string.Equals(value, "--seconds", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "-s", StringComparison.OrdinalIgnoreCase); + + private static bool IsFpsFlag(string value) => + string.Equals(value, "--fps", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "-f", StringComparison.OrdinalIgnoreCase); + + private static bool IsRedactFlag(string value) => + string.Equals(value, "--redact", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "-r", StringComparison.OrdinalIgnoreCase); } diff --git a/Pointframe.Cli/Program.cs b/Pointframe.Cli/Program.cs index d8b1f291..9269a460 100644 --- a/Pointframe.Cli/Program.cs +++ b/Pointframe.Cli/Program.cs @@ -1,3 +1,10 @@ using Pointframe.Cli; -return await CliApplication.RunAsync(args, Console.Out, Console.Error); +using var cancellationTokenSource = new CancellationTokenSource(); +Console.CancelKeyPress += (_, eventArgs) => +{ + eventArgs.Cancel = true; + cancellationTokenSource.Cancel(); +}; + +return await CliApplication.RunAsync(args, Console.Out, Console.Error, cancellationTokenSource.Token); diff --git a/Pointframe.Engine/Recording/Models/DirectRecordingModels.cs b/Pointframe.Engine/Recording/Models/DirectRecordingModels.cs index e1a4604f..d3055d3b 100644 --- a/Pointframe.Engine/Recording/Models/DirectRecordingModels.cs +++ b/Pointframe.Engine/Recording/Models/DirectRecordingModels.cs @@ -42,3 +42,16 @@ public sealed record DirectRecordingResult( DirectRecordingArtifact? Artifact = null, string? ErrorCode = null, string? ErrorMessage = null); + +public sealed record DirectRecordingResponse( + int SchemaVersion, + bool Success, + DirectCaptureError? Error = null, + DirectRecordingSession? Session = null, + DirectRecordingArtifact? Artifact = null); + +public sealed record DirectRecordingStatus( + int SchemaVersion, + bool IsRecording, + DirectRecordingSession? Session = null, + TimeSpan? Elapsed = null); diff --git a/Pointframe.Engine/Recording/Services/DirectRecordingService.cs b/Pointframe.Engine/Recording/Services/DirectRecordingService.cs index b85969bb..36c3ffc4 100644 --- a/Pointframe.Engine/Recording/Services/DirectRecordingService.cs +++ b/Pointframe.Engine/Recording/Services/DirectRecordingService.cs @@ -176,6 +176,17 @@ public async Task StopAsync(CancellationToken cancellatio } } + public DirectRecordingStatus GetStatus() + { + lock (_sessionLock) + { + var session = _activeSession; + return session is null + ? new DirectRecordingStatus(SchemaVersion, false) + : new DirectRecordingStatus(SchemaVersion, true, session.Session, session.Stopwatch.Elapsed); + } + } + public void Dispose() { StopAsync().GetAwaiter().GetResult(); diff --git a/Pointframe.Engine/Recording/Services/FfmpegDirectVideoWriter.cs b/Pointframe.Engine/Recording/Services/FfmpegDirectVideoWriter.cs index 20b499e1..0e9a7cac 100644 --- a/Pointframe.Engine/Recording/Services/FfmpegDirectVideoWriter.cs +++ b/Pointframe.Engine/Recording/Services/FfmpegDirectVideoWriter.cs @@ -3,12 +3,45 @@ namespace Pointframe.Engine; +/// +/// Reports where ffmpeg was found (or would be looked for) without starting a process, so health/version +/// checks can answer whether recording is likely to work before a caller attempts to start one. +/// +public sealed record FfmpegAvailability(bool Found, string Path, string Source); + public sealed class FfmpegDirectVideoWriterFactory : IDirectVideoWriterFactory { public IDirectVideoWriter Create(int width, int height, int framesPerSecond, string outputPath) { return new FfmpegDirectVideoWriter(width, height, framesPerSecond, outputPath); } + + public static FfmpegAvailability GetAvailability() + { + var configuredPath = Environment.GetEnvironmentVariable("POINTFRAME_FFMPEG_PATH"); + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + return new FfmpegAvailability(File.Exists(configuredPath), configuredPath, "EnvironmentVariable"); + } + + var bundledPath = Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe"); + if (File.Exists(bundledPath)) + { + return new FfmpegAvailability(true, bundledPath, "Bundled"); + } + + var pathVariable = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (var directory in pathVariable.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = Path.Combine(directory.Trim(), "ffmpeg.exe"); + if (File.Exists(candidate)) + { + return new FfmpegAvailability(true, candidate, "Path"); + } + } + + return new FfmpegAvailability(false, "ffmpeg.exe", "NotFound"); + } } public sealed class FfmpegDirectVideoWriter : IDirectVideoWriter diff --git a/Pointframe.Engine/Recording/Services/IRecordingServices.cs b/Pointframe.Engine/Recording/Services/IRecordingServices.cs index 352223af..dbc37eec 100644 --- a/Pointframe.Engine/Recording/Services/IRecordingServices.cs +++ b/Pointframe.Engine/Recording/Services/IRecordingServices.cs @@ -5,6 +5,8 @@ public interface IDirectRecordingService : IDisposable DirectRecordingResult Start(DirectRecordingRequest request); Task StopAsync(CancellationToken cancellationToken = default); + + DirectRecordingStatus GetStatus(); } public interface IDirectVideoWriter : IDisposable diff --git a/Pointframe.Mcp/Mappers/McpResponseMapper.cs b/Pointframe.Mcp/Mappers/McpResponseMapper.cs index ca2a7e46..bee733f7 100644 --- a/Pointframe.Mcp/Mappers/McpResponseMapper.cs +++ b/Pointframe.Mcp/Mappers/McpResponseMapper.cs @@ -15,6 +15,11 @@ internal static McpRecordingResponse DeserializeRecordingResponse(string json) return MapRecordingResponse(Deserialize(json)); } + internal static McpRecordingStatusResponse DeserializeRecordingStatusResponse(string json) + { + return MapRecordingStatusResponse(Deserialize(json)); + } + private static T Deserialize(string json) { return JsonSerializer.Deserialize(json) @@ -60,17 +65,7 @@ private static McpRecordingResponse MapRecordingResponse(DirectRecordingResponse response.SchemaVersion, response.Success, response.Error is null ? null : new McpCaptureError(response.Error.Code, response.Error.Message), - response.Session is null - ? null - : new McpRecordingSession( - response.Session.SchemaVersion, - response.Session.OperationId, - response.Session.ArtifactPath, - response.Session.MonitorName, - response.Session.FramesPerSecond, - ToMcpBounds(response.Session.CaptureBoundsPixels), - response.Session.RedactionRegionsCaptureLocalPixels.Select(ToMcpBounds).ToArray(), - response.Session.StartedUtc), + MapRecordingSession(response.Session), response.Artifact is null ? null : new McpRecordingArtifact( @@ -94,6 +89,30 @@ response.Artifact is null response.Artifact.EventTrackSchemaVersion)); } + private static McpRecordingStatusResponse MapRecordingStatusResponse(DirectRecordingStatus status) + { + return new McpRecordingStatusResponse( + status.SchemaVersion, + status.IsRecording, + MapRecordingSession(status.Session), + status.Elapsed); + } + + private static McpRecordingSession? MapRecordingSession(DirectRecordingSession? session) + { + return session is null + ? null + : new McpRecordingSession( + session.SchemaVersion, + session.OperationId, + session.ArtifactPath, + session.MonitorName, + session.FramesPerSecond, + ToMcpBounds(session.CaptureBoundsPixels), + session.RedactionRegionsCaptureLocalPixels.Select(ToMcpBounds).ToArray(), + session.StartedUtc); + } + private static McpPixelBounds ToMcpBounds(PixelBounds bounds) { return new McpPixelBounds(bounds.X, bounds.Y, bounds.Width, bounds.Height); diff --git a/Pointframe.Mcp/Models/DirectRecordingResponse.cs b/Pointframe.Mcp/Models/DirectRecordingResponse.cs deleted file mode 100644 index 3ef4a37d..00000000 --- a/Pointframe.Mcp/Models/DirectRecordingResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Pointframe.Engine; - -namespace Pointframe.Mcp; - -public sealed record DirectRecordingResponse( - int SchemaVersion, - bool Success, - DirectCaptureError? Error = null, - DirectRecordingSession? Session = null, - DirectRecordingArtifact? Artifact = null); diff --git a/Pointframe.Mcp/Models/McpToolResponses.cs b/Pointframe.Mcp/Models/McpToolResponses.cs index 03806bd8..69d39c4f 100644 --- a/Pointframe.Mcp/Models/McpToolResponses.cs +++ b/Pointframe.Mcp/Models/McpToolResponses.cs @@ -74,3 +74,17 @@ public sealed record McpRecordingArtifact( string EventSidecarPath, long EventCount, int EventTrackSchemaVersion); + +public sealed record McpRecordingStatusResponse( + int SchemaVersion, + bool IsRecording, + McpRecordingSession? Session = null, + TimeSpan? Elapsed = null); + +public sealed record McpServerInfoResponse( + int SchemaVersion, + string Version, + bool DesktopTestingEnabled, + McpFfmpegAvailability Ffmpeg); + +public sealed record McpFfmpegAvailability(bool Found, string Path, string Source); diff --git a/Pointframe.Mcp/Resources/PointframeCommandCatalog.cs b/Pointframe.Mcp/Resources/PointframeCommandCatalog.cs index 41fa2e82..051bef1f 100644 --- a/Pointframe.Mcp/Resources/PointframeCommandCatalog.cs +++ b/Pointframe.Mcp/Resources/PointframeCommandCatalog.cs @@ -1,34 +1,14 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using ModelContextProtocol.Server; + namespace Pointframe.Mcp; -public static class PointframeCommandCatalog +public static partial class PointframeCommandCatalog { - public static readonly IReadOnlyList DirectTools = - [ - "list_displays", - "capture_monitor", - "read_text_from_monitor", - "start_recording", - "stop_recording", - ]; + public static readonly IReadOnlyList DirectTools = GetToolNames(typeof(PointframeMcpTools)); - public static readonly IReadOnlyList DesktopTestingTools = - [ - "list_apps", - "start_test_session", - "restart_app", - "observe_app", - "focus_window", - "click", - "press_keys", - "drag", - "enter_text", - "invoke", - "check_ui", - "get_action_result", - "get_test_report", - "end_test_session", - "scroll", - ]; + public static readonly IReadOnlyList DesktopTestingTools = GetToolNames(typeof(DesktopTestingMcpTools)); public static IReadOnlyList Create(bool desktopTestingEnabled) { @@ -36,4 +16,31 @@ public static IReadOnlyList Create(bool desktopTestingEnabled) ? DirectTools.Concat(DesktopTestingTools).ToArray() : DirectTools; } + + /// + /// Derives MCP tool identifiers directly from a tool type's -decorated + /// methods instead of a hand-maintained list, so a new tool method cannot silently drift out of sync with + /// the resource that advertises available commands. + /// + private static IReadOnlyList GetToolNames(Type toolType) + { + return toolType + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Select(method => (method, attribute: method.GetCustomAttribute())) + .Where(pair => pair.attribute is not null) + .OrderBy(pair => pair.method.MetadataToken) + .Select(pair => string.IsNullOrWhiteSpace(pair.attribute!.Name) ? ToToolName(pair.method.Name) : pair.attribute.Name) + .ToArray(); + } + + private static string ToToolName(string methodName) + { + var withoutAsyncSuffix = methodName.EndsWith("Async", StringComparison.Ordinal) + ? methodName[..^"Async".Length] + : methodName; + return PascalCaseBoundaryPattern().Replace(withoutAsyncSuffix, "_").ToLowerInvariant(); + } + + [GeneratedRegex("(? redactionRegionsCaptureLocalPixels, int framesPerSecond); Task StopRecordingAsync(CancellationToken cancellationToken = default); + + string GetRecordingStatus(); } public sealed class DirectRecordingMcpService : IDirectRecordingMcpService @@ -35,6 +37,12 @@ public async Task StopRecordingAsync(CancellationToken cancellationToken return JsonSerializer.Serialize(ToResponse(result)); } + public string GetRecordingStatus() + { + var status = _directRecordingService.GetStatus(); + return JsonSerializer.Serialize(status); + } + private static DirectRecordingResponse ToResponse(DirectRecordingResult result) { return new DirectRecordingResponse( diff --git a/Pointframe.Mcp/Tools/PointframeMcpTools.cs b/Pointframe.Mcp/Tools/PointframeMcpTools.cs index e8f947b1..83f46fa8 100644 --- a/Pointframe.Mcp/Tools/PointframeMcpTools.cs +++ b/Pointframe.Mcp/Tools/PointframeMcpTools.cs @@ -73,4 +73,17 @@ public async Task StopRecordingAsync(CancellationToken can var json = await directRecordingMcpService.StopRecordingAsync(cancellationToken).ConfigureAwait(false); return McpResponseMapper.DeserializeRecordingResponse(json); } + + [McpServerTool( + Title = "Get recording status", + ReadOnly = true, + Destructive = false, + Idempotent = true, + UseStructuredContent = true), + Description("Reports whether a direct recording session is currently active. When active, includes the session started by start_recording and the elapsed duration since it started; when not, session and elapsed are omitted.")] + public Task GetRecordingStatusAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(McpResponseMapper.DeserializeRecordingStatusResponse(directRecordingMcpService.GetRecordingStatus())); + } } diff --git a/Pointframe.Tests/Cli/CliApplicationTests.cs b/Pointframe.Tests/Cli/CliApplicationTests.cs index 027803c1..3d47fceb 100644 --- a/Pointframe.Tests/Cli/CliApplicationTests.cs +++ b/Pointframe.Tests/Cli/CliApplicationTests.cs @@ -9,6 +9,93 @@ namespace Pointframe.Tests.Cli; public sealed class CliApplicationTests { + [Theory] + [InlineData("--help")] + [InlineData("-h")] + [InlineData("help")] + public void TryParse_HelpFlag_UsesHelpCommand(string flag) + { + var parsed = CliCommandParser.TryParse([flag], out var command, out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal("help", command.Name); + } + + [Theory] + [InlineData("--version")] + [InlineData("-v")] + [InlineData("version")] + public void TryParse_VersionFlag_UsesVersionCommand(string flag) + { + var parsed = CliCommandParser.TryParse([flag], out var command, out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal("version", command.Name); + } + + [Theory] + [InlineData("--help")] + [InlineData("-h")] + public void TryParse_HelpFlagAmongOtherArguments_TakesPriorityAndUsesHelpCommand(string flag) + { + var parsed = CliCommandParser.TryParse(["record", "--monitor", @"\\.\DISPLAY1", flag], out var command, out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal("help", command.Name); + } + + [Theory] + [InlineData("--version")] + [InlineData("-v")] + public void TryParse_VersionFlagAmongOtherArguments_TakesPriorityAndUsesVersionCommand(string flag) + { + var parsed = CliCommandParser.TryParse(["capture", flag], out var command, out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal("version", command.Name); + } + + [Fact] + public async Task RunAsync_Help_WritesHelpTextAndExitsZero() + { + var directCaptureService = new Mock(); + var directRecordingService = new Mock(); + var standardOutput = new StringWriter(); + var standardError = new StringWriter(); + var application = new CliApplication(directCaptureService.Object, directRecordingService.Object, standardOutput, standardError); + + var exitCode = await application.RunAsync(["--help"]); + + Assert.Equal(0, exitCode); + Assert.Contains("Pointframe CLI", standardOutput.ToString()); + Assert.Contains("displays", standardOutput.ToString()); + Assert.Empty(standardError.ToString()); + directCaptureService.VerifyNoOtherCalls(); + directRecordingService.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RunAsync_Version_WritesVersionAndExitsZero() + { + var directCaptureService = new Mock(); + var directRecordingService = new Mock(); + var standardOutput = new StringWriter(); + var standardError = new StringWriter(); + var application = new CliApplication(directCaptureService.Object, directRecordingService.Object, standardOutput, standardError); + + var exitCode = await application.RunAsync(["--version"]); + + Assert.Equal(0, exitCode); + Assert.StartsWith("Pointframe CLI ", standardOutput.ToString()); + Assert.Empty(standardError.ToString()); + directCaptureService.VerifyNoOtherCalls(); + directRecordingService.VerifyNoOtherCalls(); + } + [Fact] public async Task RunAsync_Displays_WritesDisplayDescriptorsJson() { @@ -16,9 +103,10 @@ public async Task RunAsync_Displays_WritesDisplayDescriptorsJson() directCaptureService .Setup(service => service.ListDisplays()) .Returns("[{\"monitorName\":\"\\\\\\\\.\\\\DISPLAY1\"}]"); + var directRecordingService = new Mock(); var standardOutput = new StringWriter(); var standardError = new StringWriter(); - var application = new CliApplication(directCaptureService.Object, standardOutput, standardError); + var application = new CliApplication(directCaptureService.Object, directRecordingService.Object, standardOutput, standardError); var exitCode = await application.RunAsync(["displays"]); @@ -51,6 +139,17 @@ public void TryParse_Capture_RequiresExactMonitorArgument() Assert.Equal(@"\\.\DISPLAY1", command.MonitorName); } + [Fact] + public void TryParse_Capture_AcceptsShortMonitorAlias() + { + var parsed = CliCommandParser.TryParse(["capture", "-m", @"\\.\DISPLAY1"], out var command, out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal("capture", command.Name); + Assert.Equal(@"\\.\DISPLAY1", command.MonitorName); + } + [Fact] public void TryParse_CaptureWithoutMonitor_ReturnsUsageError() { @@ -80,6 +179,179 @@ public void TryParse_OcrWithoutMonitor_ReturnsUsageError() Assert.Equal("The ocr command requires --monitor followed by an exact Windows device name.", error); } + [Fact] + public void TryParse_Record_ParsesMonitorSecondsFpsAndRedactionRegions() + { + var parsed = CliCommandParser.TryParse( + ["record", "--monitor", @"\\.\DISPLAY1", "--seconds", "5", "--fps", "30", "--redact", "10,20,30,40", "--redact", "1,2,3,4"], + out var command, + out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal("record", command.Name); + Assert.Equal(@"\\.\DISPLAY1", command.MonitorName); + Assert.Equal(5, command.RecordSeconds); + Assert.Equal(30, command.FramesPerSecond); + Assert.NotNull(command.RedactionRegions); + Assert.Equal(2, command.RedactionRegions!.Count); + Assert.Equal(new PixelBounds(10, 20, 30, 40), command.RedactionRegions[0]); + Assert.Equal(new PixelBounds(1, 2, 3, 4), command.RedactionRegions[1]); + } + + [Fact] + public void TryParse_Record_AcceptsShortOptionAliases() + { + var parsed = CliCommandParser.TryParse( + ["record", "-m", @"\\.\DISPLAY1", "-s", "5", "-f", "30", "-r", "10,20,30,40"], + out var command, + out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal("record", command.Name); + Assert.Equal(@"\\.\DISPLAY1", command.MonitorName); + Assert.Equal(5, command.RecordSeconds); + Assert.Equal(30, command.FramesPerSecond); + Assert.NotNull(command.RedactionRegions); + Assert.Equal(new PixelBounds(10, 20, 30, 40), command.RedactionRegions![0]); + } + + [Fact] + public void TryParse_Record_AcceptsInlineFlagValues() + { + var parsed = CliCommandParser.TryParse( + ["record", @"--monitor=\\.\DISPLAY1", "--seconds=5", "--fps=30", "--redact=10,20,30,40"], + out var command, + out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal("record", command.Name); + Assert.Equal(@"\\.\DISPLAY1", command.MonitorName); + Assert.Equal(5, command.RecordSeconds); + Assert.Equal(30, command.FramesPerSecond); + Assert.NotNull(command.RedactionRegions); + Assert.Equal(new PixelBounds(10, 20, 30, 40), command.RedactionRegions![0]); + } + + [Fact] + public void TryParse_Capture_AcceptsInlineMonitorValue() + { + var parsed = CliCommandParser.TryParse(["capture", @"--monitor=\\.\DISPLAY1"], out var command, out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal("capture", command.Name); + Assert.Equal(@"\\.\DISPLAY1", command.MonitorName); + } + + [Fact] + public void TryParse_Record_DefaultsFramesPerSecondAndRedactionRegions() + { + var parsed = CliCommandParser.TryParse(["record", "--monitor", @"\\.\DISPLAY1", "--seconds", "5"], out var command, out var error); + + Assert.True(parsed); + Assert.Null(error); + Assert.Equal(20, command.FramesPerSecond); + Assert.NotNull(command.RedactionRegions); + Assert.Empty(command.RedactionRegions!); + } + + [Fact] + public void TryParse_RecordWithoutMonitor_ReturnsUsageError() + { + var parsed = CliCommandParser.TryParse(["record", "--seconds", "5"], out _, out var error); + + Assert.False(parsed); + Assert.Equal("The record command requires --monitor followed by an exact Windows device name.", error); + } + + [Fact] + public void TryParse_RecordWithoutSeconds_ReturnsUsageError() + { + var parsed = CliCommandParser.TryParse(["record", "--monitor", @"\\.\DISPLAY1"], out _, out var error); + + Assert.False(parsed); + Assert.Equal("The record command requires --seconds followed by a positive integer duration.", error); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("not-a-number")] + public void TryParse_RecordWithInvalidSeconds_ReturnsUsageError(string secondsValue) + { + var parsed = CliCommandParser.TryParse(["record", "--monitor", @"\\.\DISPLAY1", "--seconds", secondsValue], out _, out var error); + + Assert.False(parsed); + Assert.Equal("The record command requires --seconds to be a positive integer.", error); + } + + [Fact] + public void TryParse_RecordWithInvalidRedactionRegion_ReturnsUsageError() + { + var parsed = CliCommandParser.TryParse( + ["record", "--monitor", @"\\.\DISPLAY1", "--seconds", "5", "--redact", "10,20,30"], + out _, + out var error); + + Assert.False(parsed); + Assert.Equal("Each --redact value must be four comma-separated integers formatted as x,y,width,height with a positive width and height.", error); + } + + [Theory] + [InlineData("0")] + [InlineData("61")] + [InlineData("-1")] + [InlineData("not-a-number")] + public void TryParse_RecordWithInvalidFps_ReturnsUsageError(string fpsValue) + { + var parsed = CliCommandParser.TryParse( + ["record", "--monitor", @"\\.\DISPLAY1", "--seconds", "5", "--fps", fpsValue], + out _, + out var error); + + Assert.False(parsed); + Assert.Equal("The record command requires --fps to be an integer between 1 and 60.", error); + } + + [Fact] + public void TryParse_RecordWithTrailingMonitorFlagAndNoValue_ReturnsMonitorUsageErrorNotUnrecognizedOption() + { + var parsed = CliCommandParser.TryParse(["record", "--seconds", "5", "--monitor"], out _, out var error); + + Assert.False(parsed); + Assert.Equal("The record command requires --monitor followed by an exact Windows device name.", error); + } + + [Fact] + public void TryParse_RecordWithTrailingSecondsFlagAndNoValue_ReturnsSecondsUsageErrorNotUnrecognizedOption() + { + var parsed = CliCommandParser.TryParse(["record", "--monitor", @"\\.\DISPLAY1", "--seconds"], out _, out var error); + + Assert.False(parsed); + Assert.Equal("The record command requires --seconds to be a positive integer.", error); + } + + [Fact] + public void TryParse_RecordWithTrailingFpsFlagAndNoValue_ReturnsFpsUsageErrorNotUnrecognizedOption() + { + var parsed = CliCommandParser.TryParse(["record", "--monitor", @"\\.\DISPLAY1", "--seconds", "5", "--fps"], out _, out var error); + + Assert.False(parsed); + Assert.Equal("The record command requires --fps to be an integer between 1 and 60.", error); + } + + [Fact] + public void TryParse_RecordWithTrailingRedactFlagAndNoValue_ReturnsRedactUsageErrorNotUnrecognizedOption() + { + var parsed = CliCommandParser.TryParse(["record", "--monitor", @"\\.\DISPLAY1", "--seconds", "5", "--redact"], out _, out var error); + + Assert.False(parsed); + Assert.Equal("Each --redact value must be four comma-separated integers formatted as x,y,width,height with a positive width and height.", error); + } + [Fact] public async Task RunAsync_Capture_WritesDirectArtifactJson() { @@ -87,9 +359,10 @@ public async Task RunAsync_Capture_WritesDirectArtifactJson() directCaptureService .Setup(service => service.CaptureMonitorAsync(@"\\.\DISPLAY1", It.IsAny())) .ReturnsAsync("{\"artifact\":{\"metadata\":{\"artifactId\":\"artifact-1\"}}}"); + var directRecordingService = new Mock(); var standardOutput = new StringWriter(); var standardError = new StringWriter(); - var application = new CliApplication(directCaptureService.Object, standardOutput, standardError); + var application = new CliApplication(directCaptureService.Object, directRecordingService.Object, standardOutput, standardError); var exitCode = await application.RunAsync(["capture", "--monitor", @"\\.\DISPLAY1"]); @@ -106,9 +379,10 @@ public async Task RunAsync_Ocr_WritesRecognizedTextJson() directCaptureService .Setup(service => service.CaptureMonitorTextAsync(@"\\.\DISPLAY1", It.IsAny())) .ReturnsAsync("{\"artifact\":{\"metadata\":{\"artifactId\":\"artifact-1\"}},\"recognizedText\":\"Hello world\"}"); + var directRecordingService = new Mock(); var standardOutput = new StringWriter(); var standardError = new StringWriter(); - var application = new CliApplication(directCaptureService.Object, standardOutput, standardError); + var application = new CliApplication(directCaptureService.Object, directRecordingService.Object, standardOutput, standardError); var exitCode = await application.RunAsync(["ocr", "--monitor", @"\\.\DISPLAY1"]); @@ -117,4 +391,117 @@ public async Task RunAsync_Ocr_WritesRecognizedTextJson() Assert.Contains("Hello world", standardOutput.ToString(), StringComparison.Ordinal); Assert.Empty(standardError.ToString()); } + + [Fact] + public async Task RunAsync_Record_StartsWaitsAndStopsThenWritesCombinedJson() + { + var directCaptureService = new Mock(); + var directRecordingService = new Mock(); + var session = new DirectRecordingSession( + 1, + "rec_1", + @"C:\recordings\rec_1.mp4", + @"\\.\DISPLAY1", + 20, + new PixelBounds(0, 0, 1920, 1080), + [], + DateTimeOffset.UtcNow); + var artifact = new DirectRecordingArtifact( + 1, + "rec_1", + "video/mp4", + @"C:\recordings\rec_1.mp4", + "deadbeef", + 1024, + DateTimeOffset.UtcNow, + TimeSpan.FromSeconds(1), + false, + @"\\.\DISPLAY1", + 1.0, + 1.0, + new PixelBounds(0, 0, 1920, 1080), + new PixelBounds(0, 0, 1920, 1080), + new PixelBounds(0, 0, 1920, 1080), + @"C:\recordings\rec_1.mp4.events.jsonl", + 2, + 1); + directRecordingService + .Setup(service => service.Start(It.IsAny())) + .Returns(new DirectRecordingResult(true, Session: session)); + directRecordingService + .Setup(service => service.StopAsync(It.IsAny())) + .ReturnsAsync(new DirectRecordingResult(true, Artifact: artifact)); + var standardOutput = new StringWriter(); + var standardError = new StringWriter(); + var application = new CliApplication(directCaptureService.Object, directRecordingService.Object, standardOutput, standardError); + + var exitCode = await application.RunAsync(["record", "--monitor", @"\\.\DISPLAY1", "--seconds", "1"]); + + Assert.Equal(0, exitCode); + directRecordingService.Verify( + service => service.Start(It.Is(request => + request.MonitorName == @"\\.\DISPLAY1" && request.FramesPerSecond == 20 && request.RedactionRegionsCaptureLocalPixels.Count == 0)), + Times.Once); + directRecordingService.Verify(service => service.StopAsync(It.IsAny()), Times.Once); + var response = JsonSerializer.Deserialize(standardOutput.ToString()); + Assert.NotNull(response); + Assert.True(response!.Success); + Assert.Equal("rec_1", response.Session?.OperationId); + Assert.Equal("rec_1", response.Artifact?.ArtifactId); + Assert.Empty(standardError.ToString()); + } + + [Fact] + public async Task RunAsync_Record_WhenStartFails_WritesFailureJsonAndDoesNotStop() + { + var directCaptureService = new Mock(); + var directRecordingService = new Mock(); + directRecordingService + .Setup(service => service.Start(It.IsAny())) + .Returns(new DirectRecordingResult(false, ErrorCode: "monitor_not_found", ErrorMessage: "The monitor was not found.")); + var standardOutput = new StringWriter(); + var standardError = new StringWriter(); + var application = new CliApplication(directCaptureService.Object, directRecordingService.Object, standardOutput, standardError); + + var exitCode = await application.RunAsync(["record", "--monitor", @"\\.\DISPLAY1", "--seconds", "1"]); + + Assert.Equal(1, exitCode); + directRecordingService.Verify(service => service.StopAsync(It.IsAny()), Times.Never); + var response = JsonSerializer.Deserialize(standardOutput.ToString()); + Assert.NotNull(response); + Assert.False(response!.Success); + Assert.Equal("monitor_not_found", response.Error?.Code); + } + + [Theory] + [InlineData("--help")] + [InlineData("--version")] + public async Task StaticRunAsync_HelpOrVersion_SucceedsWithoutConstructingCaptureOrRecordingServices(string flag) + { + // Regression test for a PR review comment: the static entry point used to construct + // DirectCaptureService/WindowsOcrEngineService/DirectRecordingService before parsing args, + // so --help/--version could fail (or do unnecessary work) even though they never need those + // services. Parsing now happens first, and help/version short-circuit before any service + // is constructed; this exercises that path end-to-end through the real static entry point. + var standardOutput = new StringWriter(); + var standardError = new StringWriter(); + + var exitCode = await CliApplication.RunAsync([flag], standardOutput, standardError); + + Assert.Equal(0, exitCode); + Assert.Empty(standardError.ToString()); + Assert.NotEmpty(standardOutput.ToString()); + } + + [Fact] + public async Task StaticRunAsync_UsageError_ReturnsExitCodeTwoWithoutConstructingServices() + { + var standardOutput = new StringWriter(); + var standardError = new StringWriter(); + + var exitCode = await CliApplication.RunAsync(["capture"], standardOutput, standardError); + + Assert.Equal(2, exitCode); + Assert.Contains("requires --monitor", standardError.ToString()); + } } diff --git a/Pointframe.Tests/DocsSync/CliDocumentationTests.cs b/Pointframe.Tests/DocsSync/CliDocumentationTests.cs new file mode 100644 index 00000000..a6adc8b7 --- /dev/null +++ b/Pointframe.Tests/DocsSync/CliDocumentationTests.cs @@ -0,0 +1,90 @@ +using System.IO; +using System.Text.RegularExpressions; +using Pointframe.Cli; +using Xunit; + +namespace Pointframe.Tests.DocsSync; + +/// +/// Guards against docs/cli/README.md drifting away from the CLI's own , +/// which is the single source of truth for supported commands and flags (also asserted against by +/// CliApplicationTests.RunAsync_Help_WritesHelpTextAndExitsZero). A new command or flag added to the +/// parser without a matching docs update fails this test instead of silently going undocumented. +/// +public sealed class CliDocumentationTests +{ + [Fact] + public void CliReadme_DocumentsEveryCommandFromHelpText() + { + var readme = File.ReadAllText(FindRepoFile(Path.Combine("docs", "cli", "README.md"))); + + var undocumented = ExtractCommandNames(CliCommandParser.HelpText) + .Where(command => !Regex.IsMatch(readme, $@"\b{Regex.Escape(command)}\b")) + .ToArray(); + + Assert.True( + undocumented.Length == 0, + $"docs/cli/README.md does not mention these CLI commands: {string.Join(", ", undocumented)}"); + } + + [Fact] + public void CliReadme_DocumentsEveryLongFlagFromHelpText() + { + var readme = File.ReadAllText(FindRepoFile(Path.Combine("docs", "cli", "README.md"))); + + var undocumented = ExtractLongFlags(CliCommandParser.HelpText) + .Where(flag => !readme.Contains(flag, StringComparison.Ordinal)) + .ToArray(); + + Assert.True( + undocumented.Length == 0, + $"docs/cli/README.md does not mention these CLI flags: {string.Join(", ", undocumented)}"); + } + + private static IEnumerable ExtractCommandNames(string helpText) + { + var commandsBlock = FindParagraph(helpText, "Commands:"); + return Regex.Matches(commandsBlock, @"^\s*(?[a-z]+)\s", RegexOptions.Multiline) + .Select(match => match.Groups["name"].Value) + .Distinct(StringComparer.Ordinal); + } + + private static IEnumerable ExtractLongFlags(string helpText) + { + var optionsBlock = FindParagraph(helpText, "Options:"); + return Regex.Matches(optionsBlock, @"(?--[a-z-]+)") + .Select(match => match.Groups["flag"].Value) + .Distinct(StringComparer.Ordinal); + } + + /// + /// Returns the blank-line-delimited paragraph that starts with the given heading line, + /// tolerating either "\n" or "\r\n" line endings. + /// + private static string FindParagraph(string content, string heading) + { + var paragraphs = Regex.Split(content, @"\r?\n\s*\r?\n"); + var paragraph = paragraphs.FirstOrDefault(candidate => candidate.TrimStart().StartsWith(heading, StringComparison.Ordinal)); + + Assert.True(paragraph is not null, $"'{heading}' heading was not found in CliCommandParser.HelpText."); + var headingIndex = paragraph!.IndexOf(heading, StringComparison.Ordinal); + return paragraph[(headingIndex + heading.Length)..]; + } + + private static string FindRepoFile(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException($"{relativePath} was not found in any parent directory."); + } +} diff --git a/Pointframe.Tests/DocsSync/McpPackagingScriptDocumentationTests.cs b/Pointframe.Tests/DocsSync/McpPackagingScriptDocumentationTests.cs new file mode 100644 index 00000000..490aacc6 --- /dev/null +++ b/Pointframe.Tests/DocsSync/McpPackagingScriptDocumentationTests.cs @@ -0,0 +1,81 @@ +using System.IO; +using System.Text.RegularExpressions; +using Pointframe.Mcp; +using Xunit; + +namespace Pointframe.Tests.DocsSync; + +/// +/// Guards packaging/test-mcp-stdio.ps1's hand-maintained expected-tool arrays against drifting away +/// from , which derives the real tool set reflectively from +/// McpServerTool-decorated methods. The packaging script itself also fails at release time if the +/// live server returns an unexpected tool set, but this test catches the same drift immediately in +/// dotnet test, without publishing and starting the MCP server. +/// +public sealed class McpPackagingScriptDocumentationTests +{ + [Fact] + public void PackagingScript_ExpectedToolsMatchDisabledCatalog() + { + var script = ReadPackagingScript(); + var expectedTools = ExtractArray(script, "$expectedTools"); + + AssertSameSet(PointframeCommandCatalog.DirectTools, expectedTools, "$expectedTools"); + } + + [Fact] + public void PackagingScript_EnabledExpectedToolsMatchEnabledCatalog() + { + var script = ReadPackagingScript(); + var expectedTools = ExtractArray(script, "$expectedTools"); + var additionalEnabledTools = ExtractArray(script, "$enabledExpectedTools"); + + var scriptEnabledTools = expectedTools.Concat(additionalEnabledTools).ToArray(); + AssertSameSet(PointframeCommandCatalog.Create(desktopTestingEnabled: true), scriptEnabledTools, "$enabledExpectedTools"); + } + + private static void AssertSameSet(IReadOnlyList actual, IReadOnlyList expected, string variableName) + { + var missing = actual.Where(name => !expected.Contains(name)).ToArray(); + var stale = expected.Where(name => !actual.Contains(name)).ToArray(); + + Assert.True( + missing.Length == 0 && stale.Length == 0, + $"packaging/test-mcp-stdio.ps1's '{variableName}' is out of sync with the registered MCP tool catalog. " + + $"Missing from script: {string.Join(", ", missing)}. Stale in script: {string.Join(", ", stale)}."); + } + + /// + /// Extracts the string literals from a PowerShell "$variableName = @(...)" or + /// "$variableName = $other + @(...)" array literal. + /// + private static string[] ExtractArray(string script, string variableName) + { + var match = Regex.Match(script, $@"{Regex.Escape(variableName)}\s*=.*?@\((?[^)]*)\)", RegexOptions.Singleline); + Assert.True(match.Success, $"'{variableName}' was not found in packaging/test-mcp-stdio.ps1."); + + return Regex.Matches(match.Groups["items"].Value, "\"(?[^\"]+)\"") + .Select(m => m.Groups["value"].Value) + .ToArray(); + } + + private static string ReadPackagingScript() => + File.ReadAllText(FindRepoFile(Path.Combine("packaging", "test-mcp-stdio.ps1"))); + + private static string FindRepoFile(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException($"{relativePath} was not found in any parent directory."); + } +} diff --git a/Pointframe.Tests/DocsSync/McpToolDocumentationTests.cs b/Pointframe.Tests/DocsSync/McpToolDocumentationTests.cs new file mode 100644 index 00000000..130c4c35 --- /dev/null +++ b/Pointframe.Tests/DocsSync/McpToolDocumentationTests.cs @@ -0,0 +1,77 @@ +using System.IO; +using Pointframe.Mcp; +using Xunit; + +namespace Pointframe.Tests.DocsSync; + +/// +/// Guards against MCP tool names silently drifting away from the documentation that lists them. +/// derives tool names reflectively from the +/// McpServerTool-decorated methods, so a newly added or renamed tool method automatically +/// changes what these tests check for without needing a parallel hand-maintained list here. +/// +public sealed class McpToolDocumentationTests +{ + private const string DirectToolsHeading = "### MCP capabilities"; + private const string DesktopTestingToolsHeading = "## Available opt-in tools"; + + [Fact] + public void Readme_DocumentsEveryDirectTool() + { + var section = ReadSection(FindRepoFile("README.md"), DirectToolsHeading, "\n### "); + + var undocumented = PointframeCommandCatalog.DirectTools + .Where(name => !section.Contains($"`{name}`", StringComparison.Ordinal)) + .ToArray(); + + Assert.True( + undocumented.Length == 0, + $"README.md '{DirectToolsHeading}' section is missing these registered MCP tools: {string.Join(", ", undocumented)}"); + } + + [Fact] + public void DesktopTestingReadme_DocumentsEveryDesktopTestingTool() + { + var section = ReadSection( + FindRepoFile(Path.Combine("docs", "mcp-desktop-testing", "README.md")), + DesktopTestingToolsHeading, + "\n## "); + + var undocumented = PointframeCommandCatalog.DesktopTestingTools + .Where(name => !section.Contains($"`{name}`", StringComparison.Ordinal)) + .ToArray(); + + Assert.True( + undocumented.Length == 0, + $"docs/mcp-desktop-testing/README.md '{DesktopTestingToolsHeading}' section is missing these registered tools: {string.Join(", ", undocumented)}"); + } + + private static string ReadSection(string filePath, string heading, string nextHeadingPrefix) + { + var content = File.ReadAllText(filePath); + + var start = content.IndexOf(heading, StringComparison.Ordinal); + Assert.True(start >= 0, $"'{heading}' heading was not found in {filePath}."); + + var afterHeading = start + heading.Length; + var end = content.IndexOf(nextHeadingPrefix, afterHeading, StringComparison.Ordinal); + return end < 0 ? content[afterHeading..] : content[afterHeading..end]; + } + + private static string FindRepoFile(string relativePath) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException($"{relativePath} was not found in any parent directory."); + } +} diff --git a/Pointframe.Tests/Engine/DirectFfmpegPathOverrideCollection.cs b/Pointframe.Tests/Engine/DirectFfmpegPathOverrideCollection.cs new file mode 100644 index 00000000..e42b6488 --- /dev/null +++ b/Pointframe.Tests/Engine/DirectFfmpegPathOverrideCollection.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace Pointframe.Tests.Engine; + +/// +/// Groups tests that read or mutate the process-wide POINTFRAME_FFMPEG_PATH environment variable consumed by +/// Pointframe.Engine's FfmpegDirectVideoWriter, so they never run concurrently with each other. +/// +[CollectionDefinition("DirectFfmpegPathOverride", DisableParallelization = true)] +public sealed class DirectFfmpegPathOverrideCollection +{ +} diff --git a/Pointframe.Tests/Engine/DirectRecordingServiceTests.cs b/Pointframe.Tests/Engine/DirectRecordingServiceTests.cs index 91cc87b3..202ab00e 100644 --- a/Pointframe.Tests/Engine/DirectRecordingServiceTests.cs +++ b/Pointframe.Tests/Engine/DirectRecordingServiceTests.cs @@ -6,6 +6,7 @@ namespace Pointframe.Tests.Engine; +[Collection("DirectFfmpegPathOverride")] public sealed class DirectRecordingServiceTests : IDisposable { private readonly string _outputDirectory = Path.Combine(Path.GetTempPath(), $"Pointframe.Tests.{Guid.NewGuid():N}"); @@ -76,6 +77,51 @@ public async Task Start_WhenRecordingIsActive_ReturnsStructuredStateError() Assert.Equal("recording_already_active", second.ErrorCode); } + [Fact] + public void GetStatus_WhenNoRecordingIsActive_ReturnsNotRecording() + { + var display = new DisplayDescriptor(@"\\.\DISPLAY1", 1d, 1d, new PixelBounds(0, 0, 16, 2)); + using var sut = new DirectRecordingService(new FakeDisplayCaptureEngine(display), new FakeVideoWriterFactory()); + + var status = sut.GetStatus(); + + Assert.False(status.IsRecording); + Assert.Null(status.Session); + Assert.Null(status.Elapsed); + } + + [Fact] + public async Task GetStatus_WhileRecordingIsActive_ReturnsSessionAndElapsed() + { + var display = new DisplayDescriptor(@"\\.\DISPLAY1", 1d, 1d, new PixelBounds(0, 0, 16, 2)); + using var sut = new DirectRecordingService(new FakeDisplayCaptureEngine(display), new FakeVideoWriterFactory()); + var start = sut.Start(new DirectRecordingRequest(display.MonitorName, [], OutputDirectory: _outputDirectory)); + Assert.True(start.Success); + + await Task.Delay(20); + var status = sut.GetStatus(); + await sut.StopAsync(); + + Assert.True(status.IsRecording); + Assert.Equal(start.Session, status.Session); + Assert.NotNull(status.Elapsed); + Assert.True(status.Elapsed >= TimeSpan.Zero); + } + + [Fact] + public async Task GetStatus_AfterStop_ReturnsNotRecording() + { + var display = new DisplayDescriptor(@"\\.\DISPLAY1", 1d, 1d, new PixelBounds(0, 0, 16, 2)); + using var sut = new DirectRecordingService(new FakeDisplayCaptureEngine(display), new FakeVideoWriterFactory()); + sut.Start(new DirectRecordingRequest(display.MonitorName, [], OutputDirectory: _outputDirectory)); + await sut.StopAsync(); + + var status = sut.GetStatus(); + + Assert.False(status.IsRecording); + Assert.Null(status.Session); + } + [Fact] [Trait("Category", "Integration")] public async Task StartAndStopAsync_WithFfmpeg_ProducesMp4AndSidecars() diff --git a/Pointframe.Tests/Engine/FfmpegDirectVideoWriterFactoryTests.cs b/Pointframe.Tests/Engine/FfmpegDirectVideoWriterFactoryTests.cs new file mode 100644 index 00000000..75f31b02 --- /dev/null +++ b/Pointframe.Tests/Engine/FfmpegDirectVideoWriterFactoryTests.cs @@ -0,0 +1,72 @@ +using System.IO; +using Pointframe.Engine; +using Xunit; + +namespace Pointframe.Tests.Engine; + +[Collection("DirectFfmpegPathOverride")] +public sealed class FfmpegDirectVideoWriterFactoryTests +{ + [Fact] + public void GetAvailability_WithConfiguredPathToExistingFile_ReportsFoundFromEnvironmentVariable() + { + var configuredPath = Path.Combine(Path.GetTempPath(), $"pointframe-ffmpeg-test-{Guid.NewGuid():N}.exe"); + File.WriteAllBytes(configuredPath, []); + var original = Environment.GetEnvironmentVariable("POINTFRAME_FFMPEG_PATH"); + try + { + Environment.SetEnvironmentVariable("POINTFRAME_FFMPEG_PATH", configuredPath); + + var availability = FfmpegDirectVideoWriterFactory.GetAvailability(); + + Assert.True(availability.Found); + Assert.Equal(configuredPath, availability.Path); + Assert.Equal("EnvironmentVariable", availability.Source); + } + finally + { + Environment.SetEnvironmentVariable("POINTFRAME_FFMPEG_PATH", original); + File.Delete(configuredPath); + } + } + + [Fact] + public void GetAvailability_WithConfiguredPathToMissingFile_ReportsNotFoundFromEnvironmentVariable() + { + var missingPath = Path.Combine(Path.GetTempPath(), $"pointframe-ffmpeg-missing-{Guid.NewGuid():N}.exe"); + var original = Environment.GetEnvironmentVariable("POINTFRAME_FFMPEG_PATH"); + try + { + Environment.SetEnvironmentVariable("POINTFRAME_FFMPEG_PATH", missingPath); + + var availability = FfmpegDirectVideoWriterFactory.GetAvailability(); + + Assert.False(availability.Found); + Assert.Equal(missingPath, availability.Path); + Assert.Equal("EnvironmentVariable", availability.Source); + } + finally + { + Environment.SetEnvironmentVariable("POINTFRAME_FFMPEG_PATH", original); + } + } + + [Fact] + public void GetAvailability_WithoutConfiguredPath_ReturnsNonEmptyPathAndKnownSource() + { + var original = Environment.GetEnvironmentVariable("POINTFRAME_FFMPEG_PATH"); + try + { + Environment.SetEnvironmentVariable("POINTFRAME_FFMPEG_PATH", null); + + var availability = FfmpegDirectVideoWriterFactory.GetAvailability(); + + Assert.False(string.IsNullOrWhiteSpace(availability.Path)); + Assert.Contains(availability.Source, new[] { "Bundled", "Path", "NotFound" }); + } + finally + { + Environment.SetEnvironmentVariable("POINTFRAME_FFMPEG_PATH", original); + } + } +} diff --git a/Pointframe.Tests/Mcp/DesktopTestingToolContractTests.cs b/Pointframe.Tests/Mcp/DesktopTestingToolContractTests.cs index aa3857f2..d871d1df 100644 --- a/Pointframe.Tests/Mcp/DesktopTestingToolContractTests.cs +++ b/Pointframe.Tests/Mcp/DesktopTestingToolContractTests.cs @@ -14,6 +14,37 @@ public void CatalogContainsExactlyFifteenGateATools() Assert.Equal(15, PointframeCommandCatalog.DesktopTestingTools.Distinct(StringComparer.Ordinal).Count()); } + [Fact] + public void DirectToolsMatchKnownDeliveredToolIdentifiers() + { + // Mirrors the exact tool set asserted against the live server by packaging/test-mcp-stdio.ps1. + string[] expected = + [ + "list_displays", + "capture_monitor", + "read_text_from_monitor", + "start_recording", + "stop_recording", + "get_recording_status", + ]; + + Assert.Equal(expected, PointframeCommandCatalog.DirectTools); + } + + [Fact] + public void DesktopTestingToolsMatchKnownDeliveredToolIdentifiers() + { + // Mirrors the exact tool set asserted against the live server by packaging/test-mcp-stdio.ps1. + var expected = new HashSet(StringComparer.Ordinal) + { + "list_apps", "start_test_session", "restart_app", "observe_app", "focus_window", + "click", "press_keys", "drag", "enter_text", "invoke", "check_ui", "scroll", + "get_action_result", "get_test_report", "end_test_session", + }; + + Assert.Equal(expected, PointframeCommandCatalog.DesktopTestingTools.ToHashSet(StringComparer.Ordinal)); + } + [Fact] public void ToolTypeExposesFifteenMethods() { @@ -25,4 +56,22 @@ public void ToolTypeExposesFifteenMethods() Assert.Equal(15, methods.Length); } + + [Fact] + public void CatalogNamesMatchEveryRegisteredToolMethodOnBothToolTypes() + { + var directType = typeof(PointframeCommandCatalog).Assembly.GetType("Pointframe.Mcp.PointframeMcpTools"); + var desktopType = typeof(PointframeCommandCatalog).Assembly.GetType("Pointframe.Mcp.DesktopTestingMcpTools"); + Assert.NotNull(directType); + Assert.NotNull(desktopType); + + Assert.Equal(CountToolMethods(directType!), PointframeCommandCatalog.DirectTools.Count); + Assert.Equal(CountToolMethods(desktopType!), PointframeCommandCatalog.DesktopTestingTools.Count); + } + + private static int CountToolMethods(Type type) + { + return type.GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Count(method => method.GetCustomAttribute() is not null); + } } diff --git a/Pointframe.Tests/Mcp/DirectRecordingMcpServiceTests.cs b/Pointframe.Tests/Mcp/DirectRecordingMcpServiceTests.cs index e9b3d266..2658559f 100644 --- a/Pointframe.Tests/Mcp/DirectRecordingMcpServiceTests.cs +++ b/Pointframe.Tests/Mcp/DirectRecordingMcpServiceTests.cs @@ -43,11 +43,50 @@ public async Task StopRecordingAsync_MapsFinalizedEngineArtifactToStructuredJson Assert.Equal(artifact, response.Artifact); } + [Fact] + public void GetRecordingStatus_WhenNoRecordingIsActive_ReturnsNotRecordingWithoutSession() + { + var sut = new DirectRecordingMcpService(new FakeDirectRecordingService + { + StatusResult = new DirectRecordingStatus(1, false), + }); + + var json = sut.GetRecordingStatus(); + var status = JsonSerializer.Deserialize(json); + + Assert.NotNull(status); + Assert.False(status.IsRecording); + Assert.Null(status.Session); + Assert.Null(status.Elapsed); + } + + [Fact] + public void GetRecordingStatus_WhenRecordingIsActive_ReturnsSessionAndElapsed() + { + var session = new DirectRecordingSession(1, "rec_1", "C:\\recording.mp4", @"\\.\DISPLAY1", 20, new Pointframe.Engine.PixelBounds(0, 0, 2, 2), [], DateTimeOffset.UtcNow); + var sut = new DirectRecordingMcpService(new FakeDirectRecordingService + { + StatusResult = new DirectRecordingStatus(1, true, session, TimeSpan.FromSeconds(5)), + }); + + var json = sut.GetRecordingStatus(); + var status = JsonSerializer.Deserialize(json); + + Assert.NotNull(status); + Assert.True(status.IsRecording); + Assert.Equal(session.OperationId, status.Session?.OperationId); + Assert.Equal(session.MonitorName, status.Session?.MonitorName); + Assert.Equal(session.ArtifactPath, status.Session?.ArtifactPath); + Assert.Equal(session.StartedUtc, status.Session?.StartedUtc); + Assert.Equal(TimeSpan.FromSeconds(5), status.Elapsed); + } + private sealed class FakeDirectRecordingService : IDirectRecordingService { public DirectRecordingRequest? Request { get; private set; } public DirectRecordingResult StartResult { get; set; } = new(true); public DirectRecordingResult StopResult { get; set; } = new(true); + public DirectRecordingStatus StatusResult { get; set; } = new(1, false); public DirectRecordingResult Start(DirectRecordingRequest request) { @@ -60,6 +99,11 @@ public Task StopAsync(CancellationToken cancellationToken return Task.FromResult(StopResult); } + public DirectRecordingStatus GetStatus() + { + return StatusResult; + } + public void Dispose() { } diff --git a/Pointframe.Tests/Mcp/PointframeMcpResourcesTests.cs b/Pointframe.Tests/Mcp/PointframeMcpResourcesTests.cs new file mode 100644 index 00000000..a1a989c8 --- /dev/null +++ b/Pointframe.Tests/Mcp/PointframeMcpResourcesTests.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using System.Text.Json; +using Pointframe.Mcp; +using Pointframe.Mcp.Configuration; +using Xunit; + +namespace Pointframe.Tests.Mcp; + +public sealed class PointframeMcpResourcesTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetServerInfo_ReturnsVersionDesktopTestingFlagAndFfmpegAvailability(bool desktopTestingEnabled) + { + var resourcesType = typeof(PointframeCommandCatalog).Assembly.GetType("Pointframe.Mcp.PointframeMcpResources"); + Assert.NotNull(resourcesType); + + var options = new DesktopTestingHostOptions(desktopTestingEnabled, false, null, null, null); + var instance = Activator.CreateInstance(resourcesType!, options); + var method = resourcesType!.GetMethod("GetServerInfo", BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(method); + + var json = (string)method!.Invoke(instance, null)!; + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + Assert.Equal(1, root.GetProperty("SchemaVersion").GetInt32()); + Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("Version").GetString())); + Assert.Equal(desktopTestingEnabled, root.GetProperty("DesktopTestingEnabled").GetBoolean()); + + var ffmpeg = root.GetProperty("Ffmpeg"); + Assert.False(string.IsNullOrWhiteSpace(ffmpeg.GetProperty("Path").GetString())); + Assert.False(string.IsNullOrWhiteSpace(ffmpeg.GetProperty("Source").GetString())); + } + + [Fact] + public void GetCommands_StillReturnsToolCatalog() + { + // Regression guard: confirms adding the server-info resource did not disturb the existing commands resource. + var resourcesType = typeof(PointframeCommandCatalog).Assembly.GetType("Pointframe.Mcp.PointframeMcpResources"); + Assert.NotNull(resourcesType); + + var options = new DesktopTestingHostOptions(false, false, null, null, null); + var instance = Activator.CreateInstance(resourcesType!, options); + var method = resourcesType!.GetMethod("GetCommands", BindingFlags.Instance | BindingFlags.Public); + Assert.NotNull(method); + + var json = (string)method!.Invoke(instance, null)!; + var tools = JsonSerializer.Deserialize(json); + + Assert.Equal(PointframeCommandCatalog.DirectTools, tools); + } +} diff --git a/README.md b/README.md index 74659917..882a4818 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,9 @@ exit codes, and troubleshooting, see the dedicated ## Pointframe CLI Each GitHub Release includes `Pointframe.Cli--win-x64.zip`, a self-contained -Windows CLI for monitor discovery and whole-monitor PNG screenshots. Extract the ZIP -and run `Pointframe.Cli.exe`; the Pointframe desktop app, the .NET runtime, and the -.NET SDK are not required. +Windows CLI for monitor discovery, whole-monitor PNG screenshots, and whole-monitor +MP4 recordings. Extract the ZIP and run `Pointframe.Cli.exe`; the Pointframe desktop +app, the .NET runtime, and the .NET SDK are not required. The CLI requires an interactive Windows desktop session. It cannot capture a user's desktop from a Windows service (session 0). @@ -81,15 +81,20 @@ desktop from a Windows service (session 0). .\Pointframe.Cli.exe displays .\Pointframe.Cli.exe capture --monitor '\\.\DISPLAY1' .\Pointframe.Cli.exe ocr --monitor '\\.\DISPLAY1' +.\Pointframe.Cli.exe record --monitor '\\.\DISPLAY1' --seconds 10 ``` Use the exact `monitorName` emitted by `displays`. A successful command writes JSON to standard output and exits with code `0`; invalid arguments exit with code `2`, and -capture failures exit with code `1`. Screenshots and their metadata sidecars are saved -under `%LOCALAPPDATA%\Pointframe\Screenshots`. `ocr` captures the monitor the same way -`capture` does, then runs Windows OCR against the captured image and adds a -`recognizedText` field to the JSON output (`null` when no text is found or no OCR -language pack is installed). +capture/OCR/recording failures exit with code `1`. Screenshots and their metadata +sidecars are saved under `%LOCALAPPDATA%\Pointframe\Screenshots`. `ocr` captures the +monitor the same way `capture` does, then runs Windows OCR against the captured image +and adds a `recognizedText` field to the JSON output (`null` when no text is found or +no OCR language pack is installed). `record` starts a direct MP4 recording, waits for +the requested `--seconds` (or an earlier Ctrl+C for a graceful early stop), then +writes the combined session/artifact JSON; recordings are saved under +`%LOCALAPPDATA%\Pointframe\Recordings` and require `ffmpeg.exe` on `PATH`, via +`POINTFRAME_FFMPEG_PATH`, or bundled next to the executable. ## Pointframe MCP Server @@ -106,13 +111,20 @@ The server exposes: - 🔤 `read_text_from_monitor` — capture a named monitor and run OCR against it, returning the PNG artifact plus recognized text (`null` when no text is found or no OCR language pack is installed). - đŸŽĨ `start_recording` — start a whole-monitor MP4 recording. Recording requires an explicit `redactionRegionsCaptureLocalPixels` array, even when it is empty. - âšī¸ `stop_recording` — stop the active recording and return the finalized MP4 artifact, metadata, and event sidecar references. +- âąī¸ `get_recording_status` — report whether a recording is currently active and, if so, its session details and elapsed duration; returns no session when nothing is recording. + +The server also exposes MCP resources: + +- `pointframe://commands` — the exact list of registered tool identifiers (varies depending on whether desktop testing is enabled). +- `pointframe://server-info` — server version, whether desktop testing tools are enabled, and whether `ffmpeg` (required for recording) was found, along with where it was found (`EnvironmentVariable`, `Bundled`, or `Path`). Useful for a health check before calling `start_recording`. The normal workflow is: 1. Call `list_displays` and select a returned `monitorName`. 2. Call `capture_monitor` with that exact monitor name, call `read_text_from_monitor` to also extract on-screen text, or call `start_recording`. 3. For recording, pass redaction rectangles in capture-local physical pixels. Use `[]` when no redaction is required. -4. Call `stop_recording` to finalize the MP4 and retrieve its metadata. +4. Call `get_recording_status` at any time to check whether a recording is active before calling `stop_recording`. +5. Call `stop_recording` to finalize the MP4 and retrieve its metadata. Example tool arguments: diff --git a/docs/cli/README.md b/docs/cli/README.md index b336c84d..fa1c648e 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -1,15 +1,18 @@ # Pointframe CLI `Pointframe.Cli.exe` is a self-contained Windows command-line tool for -discovering monitors, capturing a whole monitor as PNG, and running Windows OCR -against a monitor capture. It uses `Pointframe.Engine` directly and does not -start the Pointframe tray application or any WPF window. +discovering monitors, capturing a whole monitor as PNG, running Windows OCR +against a monitor capture, and recording a whole monitor to MP4. It uses +`Pointframe.Engine` directly and does not start the Pointframe tray +application or any WPF window. ## Requirements - Windows x64 - An interactive, unlocked Windows desktop session - No .NET runtime or .NET SDK when using the published ZIP +- `ffmpeg.exe` on `PATH`, set via `POINTFRAME_FFMPEG_PATH`, or bundled next to + `Pointframe.Cli.exe` — required only for the `record` command The CLI cannot capture a user's desktop from a Windows service or session 0. Run it as the same interactive user who owns the desktop being inspected. @@ -24,14 +27,23 @@ single-file executable and its native dependencies. For source builds, use: ```powershell -pwsh .\packaging\build-cli-package.ps1 -Version 1.0.0 +pwsh .\packaging\build-cli-package.ps1 -Version 1.0.0 -FfmpegPath 'C:\path\to\ffmpeg.exe' ``` +`-FfmpegPath` is optional; omit it to build a package without a bundled +`ffmpeg.exe` (the `record` command then relies on `PATH` or +`POINTFRAME_FFMPEG_PATH` on the target machine). + The script writes the ZIP and SHA-256 file under `packaging\output\Pointframe.Cli--win-x64`. ## Commands +Every long option below also accepts a short alias: `-m` for `--monitor`, +`-s` for `--seconds`, `-f` for `--fps`, and `-r` for `--redact`. Every long +option also accepts an inline value, e.g. `--monitor=\\.\DISPLAY1` instead of +`--monitor \\.\DISPLAY1`. + ### Discover monitors ```powershell @@ -70,17 +82,61 @@ current user's installed language profiles. The PNG and metadata sidecar are still produced. The JSON adds `recognizedText`; it is `null` when no text is recognized or no suitable OCR language pack is installed. +### Record a monitor + +```powershell +.\Pointframe.Cli.exe record --monitor '\\.\DISPLAY1' --seconds 10 +``` + +`record` starts a direct MP4 recording of the whole monitor, waits for the +requested duration (or an earlier Ctrl+C, which stops the recording gracefully +instead of killing the process), stops the recording, and writes a single +combined JSON response containing both the started `session` and the +finished `artifact`. The MP4 and its `.events.jsonl` sidecar are saved beneath: + +```text +%LOCALAPPDATA%\Pointframe\Recordings +``` + +Optional flags: + +| Flag | Meaning | Default | +|---|---|---| +| `--fps <1-60>` (`-f`) | Capture frame rate | `20` | +| `--redact ` (`-r`) | Pixelate a capture-local physical-pixel region; repeatable | none | + +Example with a 30 fps capture and two redacted regions: + +```powershell +.\Pointframe.Cli.exe record --monitor '\\.\DISPLAY1' --seconds 30 --fps 30 --redact 100,100,200,80 --redact 400,300,150,150 +``` + +`record` is a single blocking command: there is no separate `stop-recording` +command because each CLI invocation is a standalone process with no session +state that could persist across two separate invocations. If a script needs +to start recording and stop it later from a different process, use the MCP +server's `start_recording`/`stop_recording` tools instead — see the +[MCP server README](../mcp-desktop-testing/README.md). + +If the recording cannot be started (for example, an unknown monitor name or a +missing `ffmpeg.exe`), the command writes a JSON response with +`"success": false` and an `error` object to standard output and exits with +code `1`. + ## Exit codes and errors | Exit code | Meaning | |---:|---| | `0` | Command completed successfully | -| `1` | Runtime or capture/OCR failure | +| `1` | Runtime or capture/OCR/recording failure | | `2` | Invalid or incomplete command-line arguments | Invalid commands print the error and usage to standard error. Runtime failures print `Pointframe CLI failed: ...` to standard error; successful JSON is written -to standard output. +to standard output. `record` failures that the engine reports as a structured +error (rather than an exception) print a `"success": false` JSON response to +standard output instead, so scripts can parse the failure the same way as a +success. The parser accepts only these forms: @@ -88,11 +144,30 @@ The parser accepts only these forms: Pointframe.Cli.exe displays Pointframe.Cli.exe capture --monitor Pointframe.Cli.exe ocr --monitor +Pointframe.Cli.exe record --monitor --seconds [--fps <1-60>] [--redact ]... +Pointframe.Cli.exe --help +Pointframe.Cli.exe --version ``` Friendly monitor labels, display indexes, or omitted `--monitor` values are not accepted. +## Help and version + +```powershell +.\Pointframe.Cli.exe --help # or -h +.\Pointframe.Cli.exe --version # or -v +``` + +Both accept the flag form (`--help`/`--version`), the short form (`-h`/`-v`), +or a bare `help`/`version` command. Unlike every other command, these write +plain text (not JSON) to standard output and always exit with code `0`. + +`--help`/`-h` and `--version`/`-v` take priority over any other arguments on +the command line, so they can be appended to an otherwise invalid or +incomplete command to see usage instead of an error, e.g. +`Pointframe.Cli.exe record --monitor '\\.\DISPLAY1' --help`. + ## Artifact verification For every successful capture or OCR operation: @@ -102,8 +177,17 @@ For every successful capture or OCR operation: 3. Compare the file length and SHA-256 in the sidecar with the actual PNG. 4. Preserve both files together when attaching evidence to a report. -The CLI writes through the shared direct capture services, so the metadata is -produced alongside the artifact rather than inferred by the caller. +For every successful `record` operation: + +1. Read the JSON response from standard output. +2. Locate the MP4 at `artifact.path` and the `.events.jsonl` sidecar at + `artifact.eventSidecarPath`. +3. Compare the file length and SHA-256 in `artifact` with the actual MP4. +4. Preserve both files together when attaching evidence to a report. + +The CLI writes through the shared direct capture and recording services, so +the metadata is produced alongside the artifact rather than inferred by the +caller. ## Development and testing @@ -127,9 +211,10 @@ dotnet test Pointframe.Tests\Pointframe.Tests.csproj ` ``` The CLI tests cover command parsing, output and error streams, exit codes, and -the direct-capture service contract. A successful unit test does not prove that -the current machine has an unlocked interactive desktop; use a real `displays` -or `capture` invocation for that check. +the direct-capture and direct-recording service contracts. A successful unit +test does not prove that the current machine has an unlocked interactive +desktop or a working `ffmpeg.exe`; use a real `displays`, `capture`, or +`record` invocation for that check. ## Troubleshooting @@ -150,6 +235,12 @@ The capture may contain no readable text, or Windows may not have an OCR language pack matching the current user's language profile. The PNG remains valid and can be inspected independently. +### `record` fails immediately + +`record` needs `ffmpeg.exe`. Set `POINTFRAME_FFMPEG_PATH` to its full path, +place `ffmpeg.exe` next to `Pointframe.Cli.exe`, or add it to `PATH`. Rebuild +the package with `-FfmpegPath` to bundle it automatically. + ### ZIP or checksum problems Rebuild with `packaging\build-cli-package.ps1`, ensure the archive and `.sha256` diff --git a/docs/knowledge-base/knowledge-base.md b/docs/knowledge-base/knowledge-base.md index 94634637..a9a244b4 100644 --- a/docs/knowledge-base/knowledge-base.md +++ b/docs/knowledge-base/knowledge-base.md @@ -261,9 +261,11 @@ Text and Callout edit in a live `TextBox`; `LostFocus` converts it to a `TextBlo **Capabilities.** -- The CLI accepts `displays`, `capture --monitor `, or `ocr --monitor `. Invalid or incomplete commands print usage and return exit code 2; execution failures return exit code 1. -- The MCP resource `pointframe://commands` lists the supported command identifiers. -- MCP tools list displays, capture a named monitor to a PNG artifact, read text from a named monitor (capture plus OCR) via `read_text_from_monitor`, start a no-microphone MP4 recording, and stop that recording with finalized artifact and sidecar metadata. +- The CLI accepts `displays`, `capture --monitor `, `ocr --monitor `, `record --monitor --seconds [--fps <1-60>] [--redact ]...`, `--help`/`-h`, or `--version`/`-v`. `--monitor`, `--seconds`, `--fps`, and `--redact` each accept a short alias (`-m`, `-s`, `-f`, `-r`); both forms are interchangeable and can be mixed within one invocation. Any long-form flag also accepts inline `--flag=value` syntax (e.g. `--monitor=\\.\DISPLAY1`); `CliCommandParser.ExpandInlineFlagValues` splits these into two tokens before the rest of parsing runs, so both `--flag value` and `--flag=value` are equivalent everywhere. `--help`/`-h`/`--version`/`-v` take priority over any other arguments present and are detected anywhere in the argument list (e.g. `record --monitor X --help` prints help instead of attempting to parse `record`), not just when passed alone. Invalid or incomplete commands print usage and return exit code 2; execution failures return exit code 1. `--help`/`--version` (and bare `help`/`version`) write plain text rather than JSON to standard output, always exit 0, and skip constructing/invoking the capture and recording services entirely. `record` is a single blocking command scoped to one CLI process: it starts the recording, waits for the requested duration (or an earlier Ctrl+C, handled as a graceful stop rather than a hard kill), stops the recording, and writes one combined JSON response containing both the started `session` and the finished `artifact`. There is no separate CLI `stop-recording` command because a CLI invocation has no state that persists across two separate process launches; use the MCP server's `start_recording`/`stop_recording` tools for a start-now/stop-later workflow instead. `Pointframe.Engine.DirectRecordingResponse` (schemaVersion, success, error, session, artifact) is the shared JSON response DTO used by both the CLI `record` command and `DirectRecordingMcpService`, so the two hosts serialize recording results identically. +- `Pointframe.Tests/DocsSync/` guards documentation against drifting out of sync with the code it describes: `McpToolDocumentationTests` asserts every name in `PointframeCommandCatalog.DirectTools`/`DesktopTestingTools` is mentioned in README.md/`docs/mcp-desktop-testing/README.md`; `CliDocumentationTests` asserts every command and long flag extracted from `CliCommandParser.HelpText` is mentioned in `docs/cli/README.md`; `McpPackagingScriptDocumentationTests` asserts `packaging/test-mcp-stdio.ps1`'s hand-maintained `$expectedTools`/`$enabledExpectedTools` arrays match the same catalog (a second, earlier line of defense in addition to that script's own runtime assertion in CI). These follow the same pattern as `Pointframe.Tests/Services/TelemetryDocumentationTests.cs`: fail a normal `dotnet test` run immediately when a tool or flag is added/renamed without updating its documentation, rather than relying on someone remembering to update docs by hand. +- The MCP resource `pointframe://commands` lists the supported command identifiers. `PointframeCommandCatalog` derives these identifiers by reflecting over `[McpServerTool]`-decorated methods on `PointframeMcpTools` and `DesktopTestingMcpTools` (converting the method name to snake_case and stripping an `Async` suffix, or using an explicit `Name` on the attribute), so the resource cannot drift out of sync with the registered tools the way a hand-maintained list could. +- The MCP resource `pointframe://server-info` reports `schemaVersion`, `version` (the server's `FileVersionInfo.ProductVersion`, resolved via `Environment.ProcessPath` the same way the CLI resolves `--version`, since `Assembly.Location` is empty for single-file publishes), `desktopTestingEnabled` (from `DesktopTestingHostOptions.Enabled`), and `ffmpeg` availability (`found`, `path`, `source` — `EnvironmentVariable`, `Bundled`, `Path`, or `NotFound`, from `FfmpegDirectVideoWriterFactory.GetAvailability()`). This lets an MCP client check server health/version and whether recording will work before calling `start_recording`, without starting a process or attempting a real recording. +- MCP tools list displays, capture a named monitor to a PNG artifact, read text from a named monitor (capture plus OCR) via `read_text_from_monitor`, start a no-microphone MP4 recording, stop that recording with finalized artifact and sidecar metadata, and check whether a recording is currently active via `get_recording_status` (returns `isRecording`, and when true, the active `session` and `elapsed` duration; `session`/`elapsed` are omitted when nothing is recording). `get_recording_status` is read-only and does not require a prior `start_recording` call to be safe to invoke. - As of 2026-09-07, the opt-in desktop-testing driver registers its policy-gated observation and mutation MCP tools and routes physical input and UIA actions through a supervised MCP worker. Owned-key release is synchronous: the worker is attempted first, then the parent-side `DesktopControlGuard` fallback is used when the worker or pipe is unavailable. The native input adapter records only successfully injected keys that were not already down, so fallback release cannot release user-owned keys; failed native release is reported rather than fabricated as success. The tool layer must route every mutation through `IDesktopActionCoordinator` and the worker-backed input/UIA services; preflight failures must produce no input and uncertain dispatch must not be replayed. Do not claim Gate A, B, or C evidence from headless contract tests. - The interactive gate harness creates its policy through `Pointframe.AutomationTests/Support/DesktopGatePolicyFactory.cs` and must launch the MCP executable with `--desktop-testing --desktop-policy `. The factory uses ordinary empty Pointframe arguments and writes the temporary policy beneath the configured evidence directory; `DesktopGatePolicyFactoryTests` verifies enabled tool registration without launching Pointframe. This registration check is not a desktop gate: the real Gate A workflow still needs a generic notification-area interaction and normal target exit before it can safely launch the target, while Gates B and C require their external visual/file evidence. - Real desktop acceptance requires explicit operator approval of the chosen Windows account or machine. A dedicated account or machine is preferred, but a personal account is permitted with informed consent because normal settings and desktop state may change; the driver cannot prove that the environment is safe. Isolation is not implemented through target data-root overrides. @@ -272,11 +274,11 @@ Text and Callout edit in a live `TextBox`; `LostFocus` converts it to a `TextBlo **Packaging and configuration.** -`Pointframe.Cli` is released as a self-contained, single-file `win-x64` ZIP with a SHA-256 checksum. `packaging/build-cli-package.ps1` publishes it and creates the versioned archive. Users extract it and run `Pointframe.Cli.exe`; it has no WPF application or .NET runtime prerequisite. +`Pointframe.Cli` is released as a self-contained, single-file `win-x64` ZIP with a SHA-256 checksum. `packaging/build-cli-package.ps1` publishes it and creates the versioned archive. Users extract it and run `Pointframe.Cli.exe`; it has no WPF application or .NET runtime prerequisite. An optional `-FfmpegPath` parameter bundles `ffmpeg.exe` into the package (mirroring the MCP package builder) so `record` works out of the box; when omitted, `record` still works if `ffmpeg.exe` is on `PATH` or `POINTFRAME_FFMPEG_PATH` is set on the target machine. `Pointframe.Mcp` is published self-contained for `win-x64`. `packaging/build-mcp-package.ps1` copies the published executable and a supplied `ffmpeg.exe` into a versioned legacy ZIP and MCPB bundle, alongside `packaging/assets/mcp-icon.png` bundled as `icon.png` and referenced from the manifest's `icon` field so Claude Desktop's Extensions UI shows a Pointframe icon instead of the default placeholder. `mcp-icon.png` is a dedicated 512x512 asset (recreating the brand glyph on a dark rounded-square badge); it is separate from the 32x32 `website/app-icon.png` favicon because MCPB/Claude Desktop recommend 128-256px+ icons. If the glyph or brand colors change, re-export a new `mcp-icon.png` at 512x512 and rebuild the package. The build also emits a SHA-256 checksum and MCP Registry `server.json` metadata pointing at the GitHub Release MCPB asset. The standalone package contains no WPF application. VS Code and other MCP clients launch the executable as a local stdio server. -CI publishes the CLI and MCP executables and runs `packaging/test-mcp-stdio.ps1`, which verifies disabled and explicitly enabled discovery against the exact five delivered tools without starting WPF. The MCP package also includes a disabled-by-default policy schema example. CD attaches the CLI ZIP/checksum plus the MCPB, checksum, and registry metadata to the versioned GitHub Release. Both standalone hosts remain Windows-only and require an interactive desktop session for capture and recording. +CI publishes the CLI and MCP executables and runs `packaging/test-mcp-stdio.ps1`, which verifies disabled and explicitly enabled discovery against the exact six delivered tools without starting WPF. The MCP package also includes a disabled-by-default policy schema example. CD attaches the CLI ZIP/checksum plus the MCPB, checksum, and registry metadata to the versioned GitHub Release. Both standalone hosts remain Windows-only and require an interactive desktop session for capture and recording. **Invariants.** @@ -287,7 +289,7 @@ CI publishes the CLI and MCP executables and runs `packaging/test-mcp-stdio.ps1` **Tests.** `Pointframe.Tests/Cli/CliApplicationTests.cs`, `Pointframe.Tests/Mcp/DirectCaptureServiceTests.cs`, `Pointframe.Tests/Mcp/DirectRecordingMcpServiceTests.cs`, `Pointframe.Tests/Mcp/DesktopInputPreflightTests.cs`, `Pointframe.Tests/Mcp/DesktopTestingToolContractTests.cs`, `Pointframe.Tests/Engine/DirectRecordingServiceTests.cs`, and `Pointframe.Tests/Engine/WindowsOcrEngineServiceTests.cs`. Protocol smoke coverage is in `packaging/test-mcp-stdio.ps1`; enabled-policy harness coverage is in `Pointframe.AutomationTests/Support/DesktopGatePolicyFactoryTests.cs`. -**Files.** `Pointframe.Cli/Program.cs`, `Pointframe.Cli/Application/CliApplication.cs`, `Pointframe.Cli/Commands/CliCommandParser.cs`, `Pointframe.Cli/Commands/CliCommand.cs`, `Pointframe.Mcp/Program.cs`, `Pointframe.Mcp/Tools/PointframeMcpTools.cs`, `Pointframe.Mcp/Tools/DesktopTestingMcpTools.cs`, `Pointframe.Mcp/Automation/WindowsDesktopInputService.cs`, `Pointframe.Mcp/Automation/WindowsDesktopNativeMethods.cs`, `Pointframe.Mcp/Automation/DesktopAutomationWorkerHost.cs`, `Pointframe.Mcp/Resources/PointframeMcpResources.cs`, `Pointframe.Mcp/Resources/PointframeCommandCatalog.cs`, `Pointframe.Mcp/Services/DirectRecordingMcpService.cs`, `Pointframe.Mcp/Mappers/McpResponseMapper.cs`, `Pointframe.Mcp/Mappers/DesktopTestingResponseMapper.cs`, `Pointframe.Mcp/Models/DirectRecordingResponse.cs`, `Pointframe.Mcp/Models/McpToolResponses.cs`, `Pointframe.Mcp/Models/DesktopTestingResponses.cs`, `Pointframe.Engine/Automation/Services/DesktopActionCoordinator.cs`, `Pointframe.Engine/Automation/Services/DesktopActionLedger.cs`, `Pointframe.Engine/Automation/Services/DesktopTestReportService.cs`, `Pointframe.Engine/Capture/Services/DirectCaptureService.cs`, `Pointframe.Engine/Capture/Services/DisplayCaptureEngine.cs`, `Pointframe.Engine/Capture/Models/DirectCaptureModels.cs`, `Pointframe.Engine/Ocr/Services/IOcrEngineService.cs`, `Pointframe.Engine/Ocr/Services/WindowsOcrEngineService.cs`, `Pointframe.Engine/Recording/Services/DirectRecordingService.cs`, `Pointframe.Engine/Recording/Services/RawFrameRecordingPipeline.cs`, `Pointframe.Engine/Recording/Services/FfmpegDirectVideoWriter.cs`, `Pointframe.Engine/Recording/Models/DirectRecordingModels.cs`, `Pointframe/Services/Recording/ArtifactMetadataService.cs`, `Pointframe.AutomationTests/Support/DesktopGatePolicyFactory.cs`, `Pointframe.AutomationTests/Support/DesktopGatePolicyFactoryTests.cs`, `packaging/build-cli-package.ps1`, `packaging/build-mcp-package.ps1`, `packaging/test-mcp-stdio.ps1`, `.github/workflows/ci.yml`, `.github/workflows/cd.yml`. +**Files.** `Pointframe.Cli/Program.cs`, `Pointframe.Cli/Application/CliApplication.cs`, `Pointframe.Cli/Commands/CliCommandParser.cs`, `Pointframe.Cli/Commands/CliCommand.cs`, `Pointframe.Mcp/Program.cs`, `Pointframe.Mcp/Tools/PointframeMcpTools.cs`, `Pointframe.Mcp/Tools/DesktopTestingMcpTools.cs`, `Pointframe.Mcp/Automation/WindowsDesktopInputService.cs`, `Pointframe.Mcp/Automation/WindowsDesktopNativeMethods.cs`, `Pointframe.Mcp/Automation/DesktopAutomationWorkerHost.cs`, `Pointframe.Mcp/Resources/PointframeMcpResources.cs`, `Pointframe.Mcp/Resources/PointframeCommandCatalog.cs`, `Pointframe.Mcp/Services/DirectRecordingMcpService.cs`, `Pointframe.Mcp/Mappers/McpResponseMapper.cs`, `Pointframe.Mcp/Mappers/DesktopTestingResponseMapper.cs`, `Pointframe.Mcp/Models/McpToolResponses.cs`, `Pointframe.Mcp/Models/DesktopTestingResponses.cs`, `Pointframe.Engine/Automation/Services/DesktopActionCoordinator.cs`, `Pointframe.Engine/Automation/Services/DesktopActionLedger.cs`, `Pointframe.Engine/Automation/Services/DesktopTestReportService.cs`, `Pointframe.Engine/Capture/Services/DirectCaptureService.cs`, `Pointframe.Engine/Capture/Services/DisplayCaptureEngine.cs`, `Pointframe.Engine/Capture/Models/DirectCaptureModels.cs`, `Pointframe.Engine/Ocr/Services/IOcrEngineService.cs`, `Pointframe.Engine/Ocr/Services/WindowsOcrEngineService.cs`, `Pointframe.Engine/Recording/Services/DirectRecordingService.cs`, `Pointframe.Engine/Recording/Services/IRecordingServices.cs`, `Pointframe.Engine/Recording/Services/RawFrameRecordingPipeline.cs`, `Pointframe.Engine/Recording/Services/FfmpegDirectVideoWriter.cs`, `Pointframe.Engine/Recording/Models/DirectRecordingModels.cs`, `Pointframe/Services/Recording/ArtifactMetadataService.cs`, `Pointframe.AutomationTests/Support/DesktopGatePolicyFactory.cs`, `Pointframe.AutomationTests/Support/DesktopGatePolicyFactoryTests.cs`, `packaging/build-cli-package.ps1`, `packaging/build-mcp-package.ps1`, `packaging/test-mcp-stdio.ps1`, `.github/workflows/ci.yml`, `.github/workflows/cd.yml`. **Files.** `Pointframe/Services/Recording/ScreenRecordingService.cs`, `Pointframe/Services/Recording/IScreenRecordingService.cs`, `Pointframe/Services/Recording/IRecordingRedactionSession.cs`, `Pointframe/Services/Recording/RecordingRedactionSession.cs`, `Pointframe/Services/Recording/IRecordingEventTrack.cs`, `Pointframe/Services/Recording/RecordingEventTrack.cs`, `Pointframe/Services/Recording/VideoWriterFactory.cs`, `Pointframe/Services/Recording/FFMpegVideoWriter.cs`, `Pointframe/Services/Recording/FfmpegResolver.cs`, `Pointframe/Models/RecordingSessionGeometry.cs`, `Pointframe/Views/RecordingOverlayWindow.xaml.cs`, `Pointframe/Views/OverlayWindow.Recording.cs`, `Pointframe/Views/OverlayWindow.RecordingHud.cs`, `Pointframe/Views/OverlayWindow.RecordingAnnotation.cs`, `Pointframe/Views/CountdownWindow.xaml.cs`, `Pointframe/ViewModels/RecordingHudViewModel.cs`, `Pointframe/Services/Recording/RecordingHudCoordinator.cs`, `Pointframe/Services/Recording/RecordingAnnotationSurfaceCoordinator.cs`, `Pointframe/Services/Recording/RecordingMousePassthroughCoordinator.cs`, `Pointframe/Services/Recording/RecordingOverlayNativeInterop.cs`, `Pointframe/Services/Recording/RecordingCursorEffectsService.cs`, `Pointframe/Services/Recording/RecordingMicrophoneSession.cs`, `Pointframe/Services/Infrastructure/MicrophoneDeviceService.cs`, `Pointframe/Services/Recording/GifExportService.cs`, `Pointframe/Services/Recording/VideoTrimService.cs`, `Pointframe/ViewModels/TrimViewModel.cs`, `Pointframe/Services/Recording/WatermarkTokenResolver.cs`, `Pointframe/Services/Messaging/RecordingCompletedMessage.cs`, `Pointframe/Services/Messaging/TrimRecordingRequestedMessage.cs`. @@ -634,15 +636,15 @@ dip = physical_px / scale ### The MCP command list resource must name every registered tool -**Rule.** `PointframeMcpResources.GetCommands()` returns a hardcoded JSON array of command identifiers. Every `[McpServerTool]` method added to `PointframeMcpTools` needs its snake_case tool name (for example `read_text_from_monitor`) added to that array in the same change. +**Rule.** `PointframeMcpResources.GetCommands()` returns `PointframeCommandCatalog.Create(...)`, which reflects over the `[McpServerTool]` methods on `PointframeMcpTools` and `DesktopTestingMcpTools` and derives each tool's snake_case identifier from its method name (or an explicit `Name` on the attribute) instead of a hand-maintained string list. Adding, renaming, or removing a `[McpServerTool]` method automatically changes the resource's output; no separate list needs editing. -**Why.** MCP clients that call the `pointframe://commands` resource before `tools/list` use it as a cheap capability check. The array is not generated from the tool attributes, so nothing keeps it in sync automatically; a new tool method compiles and works over MCP even if the resource still lists only the older commands, silently telling callers the new capability does not exist. +**Why.** MCP clients that call the `pointframe://commands` resource before `tools/list` use it as a cheap capability check. Before 2026-09-08 the identifiers were a hardcoded array that nothing kept in sync automatically; a new tool method compiled and worked over MCP even if the resource still listed only the older commands, silently telling callers the new capability did not exist. Reflection over the attribute removes that failure mode entirely, at the cost of assuming the MCP SDK's default tool-naming convention (PascalCase method name, `Async` suffix stripped, converted to snake_case) unless a method sets an explicit `Name`. -**Enforced by.** `packaging/test-mcp-stdio.ps1`'s `$expectedTools` list checks `tools/list`, not the `pointframe://commands` resource, so it does not catch a stale resource. There is no automated check for this file; review `PointframeMcpResources.cs` whenever `PointframeMcpTools.cs` gains or removes a tool. +**Enforced by.** `Pointframe.Tests/Mcp/DesktopTestingToolContractTests.cs` asserts the reflected `DirectTools`/`DesktopTestingTools` catalogs exactly match the known-good tool identifier sets, and `packaging/test-mcp-stdio.ps1`'s `$expectedTools` list independently checks the live `tools/list` response against the same identifiers. If the MCP SDK's naming convention ever changes, or a tool method needs a name the convention would get wrong, set `Name` explicitly on that method's `[McpServerTool]` attribute. -**Symptoms when violated.** A client that gates on `pointframe://commands` never offers the new tool to the user or agent, even though calling it directly still succeeds. +**Symptoms when violated.** A client that gates on `pointframe://commands` never offers a tool to the user or agent even though calling it directly still succeeds — this was possible before the reflection-based rewrite; the contract tests above now catch a mismatch at build time. -**Files.** `Pointframe.Mcp/Resources/PointframeMcpResources.cs`, `Pointframe.Mcp/Tools/PointframeMcpTools.cs`, `packaging/test-mcp-stdio.ps1`. See [Standalone CLI and MCP automation](#standalone-cli-and-mcp-automation). +**Files.** `Pointframe.Mcp/Resources/PointframeMcpResources.cs`, `Pointframe.Mcp/Resources/PointframeCommandCatalog.cs`, `Pointframe.Mcp/Tools/PointframeMcpTools.cs`, `Pointframe.Tests/Mcp/DesktopTestingToolContractTests.cs`, `packaging/test-mcp-stdio.ps1`. See [Standalone CLI and MCP automation](#standalone-cli-and-mcp-automation). ## How-tos diff --git a/docs/mcp-desktop-testing/README.md b/docs/mcp-desktop-testing/README.md index 85669d29..06180541 100644 --- a/docs/mcp-desktop-testing/README.md +++ b/docs/mcp-desktop-testing/README.md @@ -133,6 +133,7 @@ capture_monitor read_text_from_monitor start_recording stop_recording +get_recording_status ``` ## Local validation diff --git a/packaging/build-cli-package.ps1 b/packaging/build-cli-package.ps1 index 8d491a6e..b2bff5af 100644 --- a/packaging/build-cli-package.ps1 +++ b/packaging/build-cli-package.ps1 @@ -1,6 +1,7 @@ [CmdletBinding()] param( - [string]$Version = "1.0.0" + [string]$Version = "1.0.0", + [string]$FfmpegPath ) Set-StrictMode -Version Latest @@ -37,6 +38,20 @@ if (Test-Path $packageDirectory) New-Item -ItemType Directory -Path $packageDirectory -Force | Out-Null Copy-Item (Join-Path $publishDirectory "*") $packageDirectory -Recurse -Force +if ($FfmpegPath) +{ + if (-not (Test-Path $FfmpegPath -PathType Leaf)) + { + throw "FfmpegPath '$FfmpegPath' was not found." + } + + Copy-Item $FfmpegPath (Join-Path $packageDirectory "ffmpeg.exe") -Force +} +else +{ + Write-Warning "No -FfmpegPath supplied: the packaged CLI's 'record' command will only work if ffmpeg.exe is on PATH or POINTFRAME_FFMPEG_PATH is set on the target machine." +} + if (Test-Path $archivePath) { Remove-Item $archivePath -Force diff --git a/packaging/test-mcp-stdio.ps1 b/packaging/test-mcp-stdio.ps1 index 246e06a8..77df4a36 100644 --- a/packaging/test-mcp-stdio.ps1 +++ b/packaging/test-mcp-stdio.ps1 @@ -139,7 +139,7 @@ function Invoke-McpDiscovery { } $resolvedExecutablePath = (Resolve-Path $ExecutablePath).Path -$expectedTools = @("list_displays", "capture_monitor", "read_text_from_monitor", "start_recording", "stop_recording") +$expectedTools = @("list_displays", "capture_monitor", "read_text_from_monitor", "start_recording", "stop_recording", "get_recording_status") Invoke-McpDiscovery -ResolvedExecutablePath $resolvedExecutablePath -Arguments @() -ExpectedTools $expectedTools -DiscoveryName "disabled" if (-not $SkipEnabledDiscovery)