From 8c4d0ac00dfdee3e27bb933d8094afce4767d25f Mon Sep 17 00:00:00 2001 From: Bobby Johnson Date: Fri, 16 Jan 2026 14:50:28 -0800 Subject: [PATCH] Add --no-interactive flag to disable REPL console Adds a --no-interactive CLI flag to PipelinesCommandSettings that disables the interactive REPL console. This enables running commands like `preview` in headless environments such as: - CI/CD pipelines - Docker containers without TTY - Background processes - IDE task runners (VS Code, Claude Code, etc.) When --no-interactive is set (or console is unavailable), the command will still watch for file changes and rebuild, but won't attempt to use console operations that require valid handles (cursor positioning, ReadKey, etc.). Also adds auto-detection via IsConsoleAvailable() that checks if console operations would succeed, falling back to non-interactive mode automatically when no console is present. Fixes #283 Co-Authored-By: Claude Opus 4.5 --- .../Statiq.App/Commands/InteractiveCommand.cs | 139 +++++++++++++----- .../Commands/PipelinesCommandSettings.cs | 4 + 2 files changed, 108 insertions(+), 35 deletions(-) diff --git a/src/core/Statiq.App/Commands/InteractiveCommand.cs b/src/core/Statiq.App/Commands/InteractiveCommand.cs index 32104869c..a82d8a55b 100644 --- a/src/core/Statiq.App/Commands/InteractiveCommand.cs +++ b/src/core/Statiq.App/Commands/InteractiveCommand.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.IO; using System.Linq; using System.Text; using System.Threading; @@ -54,14 +55,37 @@ protected override async Task ExecuteEngineAsync( ExitCode exitCode = ExitCode.Normal; - // Start the console listener - ConsoleListener consoleListener = new ConsoleListener( - () => + // Determine if we should run in interactive mode + bool interactiveMode = !commandSettings.NoInteractive && IsConsoleAvailable(); + + // Start the console listener only if interactive mode is enabled + ConsoleListener consoleListener = null; + if (interactiveMode) + { + consoleListener = new ConsoleListener( + () => + { + TriggerExit(); + return Task.CompletedTask; + }, + input => EvaluateScriptAsync(input, commandContext, commandSettings, engineManager)); + } + else + { + // In non-interactive mode, set up console cancel key press handler if possible + try { - TriggerExit(); - return Task.CompletedTask; - }, - input => EvaluateScriptAsync(input, commandContext, commandSettings, engineManager)); + Console.CancelKeyPress += (sender, e) => + { + e.Cancel = true; + TriggerExit(); + }; + } + catch + { + // Console may not be available, cancellation will rely on the token + } + } // Execute the engine for the first time exitCode = await engineManager.ExecuteAsync(_cancellationTokenSource); @@ -71,41 +95,64 @@ protected override async Task ExecuteEngineAsync( { await AfterInitialExecutionAsync(commandContext, commandSettings, engineManager, _cancellationTokenSource); - // Log that we're ready and start waiting on input - const string prompt = "Type Ctrl-C or \"Exit()\" to exit and \"Help()\" for global methods and properties"; - logger.LogInformation(prompt); - ConsoleLoggerProvider.FlushAndWait(); - consoleListener.StartReadingLines(); - - // Wait for activity - while (true) + if (interactiveMode) { - // Blocks the current thread until a signal - // This will also reset the event (since it's an AutoResetEvent) so any triggering will cause a following execution - _triggerExecutionEvent.WaitOne(); - - // Stop listening while we run again - consoleListener.StopReadingLines(); + // Interactive mode: use REPL + const string prompt = "Type Ctrl-C or \"Exit()\" to exit and \"Help()\" for global methods and properties"; + logger.LogInformation(prompt); + ConsoleLoggerProvider.FlushAndWait(); + consoleListener.StartReadingLines(); - // Break here before running if we're exiting - if (_exit) + // Wait for activity + while (true) { - break; - } + // Blocks the current thread until a signal + // This will also reset the event (since it's an AutoResetEvent) so any triggering will cause a following execution + _triggerExecutionEvent.WaitOne(); - // Execute - exitCode = await ExecutionTriggeredAsync(commandContext, commandSettings, engineManager, exitCode, _cancellationTokenSource); + // Stop listening while we run again + consoleListener.StopReadingLines(); - // Check one more time for exit - if (_exit) - { - break; - } + // Break here before running if we're exiting + if (_exit) + { + break; + } - // Log that we're ready and start waiting on input (again) - logger.LogInformation(prompt); + // Execute + exitCode = await ExecutionTriggeredAsync(commandContext, commandSettings, engineManager, exitCode, _cancellationTokenSource); + + // Check one more time for exit + if (_exit) + { + break; + } + + // Log that we're ready and start waiting on input (again) + logger.LogInformation(prompt); + ConsoleLoggerProvider.FlushAndWait(); + consoleListener.StartReadingLines(); + } + } + else + { + // Non-interactive mode: wait for cancellation or file changes + logger.LogInformation("Running in non-interactive mode. Press Ctrl+C to exit."); ConsoleLoggerProvider.FlushAndWait(); - consoleListener.StartReadingLines(); + + // Wait for activity (file changes will trigger execution, Ctrl+C will trigger exit) + while (!_exit) + { + _triggerExecutionEvent.WaitOne(); + + if (_exit) + { + break; + } + + // Execute + exitCode = await ExecutionTriggeredAsync(commandContext, commandSettings, engineManager, exitCode, _cancellationTokenSource); + } } } @@ -117,6 +164,28 @@ protected override async Task ExecuteEngineAsync( } } + /// + /// Checks if a console is available for interactive input/output. + /// + private static bool IsConsoleAvailable() + { + try + { + // Try to access console properties that require a valid handle + // This will throw if no console is available + _ = Console.KeyAvailable; + return true; + } + catch (IOException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + public void TriggerExecution() => _triggerExecutionEvent.Set(); public void TriggerExit() diff --git a/src/core/Statiq.App/Commands/PipelinesCommandSettings.cs b/src/core/Statiq.App/Commands/PipelinesCommandSettings.cs index b48e0f1d2..ab2392c2e 100644 --- a/src/core/Statiq.App/Commands/PipelinesCommandSettings.cs +++ b/src/core/Statiq.App/Commands/PipelinesCommandSettings.cs @@ -9,6 +9,10 @@ public class PipelinesCommandSettings : EngineCommandSettings [Description("Executes normal pipelines as well as those specified.")] public bool NormalPipelines { get; set; } + [CommandOption("--no-interactive")] + [Description("Disables the interactive console/REPL. Useful for CI/CD, containers, background processes, or environments without a console.")] + public bool NoInteractive { get; set; } + [CommandArgument(0, "[pipelines]")] [Description("The pipeline(s) to execute.")] public string[] Pipelines { get; set; }