diff --git a/.agents/rules/file-access.md b/.agents/rules/file-access.md new file mode 100644 index 0000000..884d0bc --- /dev/null +++ b/.agents/rules/file-access.md @@ -0,0 +1,5 @@ +--- +trigger: always_on +--- + +Ask for explicit permision before modifying or creating any files or folders outside of the current workspace C:\Users\e41307\Documents (Local)\Coding\Git Repos\PaletteShellExtension \ No newline at end of file diff --git a/PaletteShellExtension.Tests/ScriptRunnerTests.cs b/PaletteShellExtension.Tests/ScriptRunnerTests.cs index d654b05..20f41bb 100644 --- a/PaletteShellExtension.Tests/ScriptRunnerTests.cs +++ b/PaletteShellExtension.Tests/ScriptRunnerTests.cs @@ -74,4 +74,44 @@ public void BuildProcessStartInfo_EscapesApostropheInModulePath() Directory.Delete(dir, recursive: true); } } + + [Fact] + public void ParseHost_RecognizesStandardAndCustomPaths() + { + Assert.Equal(ScriptRunner.ShellHost.Auto, ScriptRunner.ParseHost("auto")); + Assert.Equal(ScriptRunner.ShellHost.Auto, ScriptRunner.ParseHost(null)); + Assert.Equal(ScriptRunner.ShellHost.Pwsh, ScriptRunner.ParseHost("pwsh")); + Assert.Equal(ScriptRunner.ShellHost.WindowsPowerShell, ScriptRunner.ParseHost("powershell")); + Assert.Equal(ScriptRunner.ShellHost.CustomPath, ScriptRunner.ParseHost(@"C:\Users\User\AppData\Local\Microsoft\WindowsApps\Microsoft.PowerShell_8wekyb3d8bbwe\pwsh.exe")); + Assert.Equal(ScriptRunner.ShellHost.CustomPath, ScriptRunner.ParseHost("pwsh.exe")); + Assert.Equal(ScriptRunner.ShellHost.Unknown, ScriptRunner.ParseHost("bash")); + } + + [Fact] + public void ResolveShell_CustomPath_ResolvesExistingOrThrows() + { + var tempExe = Path.GetTempFileName(); + try + { + var resolved = ScriptRunner.ResolveShell(tempExe); + Assert.Equal(tempExe, resolved); + + var missingExe = Path.Combine(Path.GetTempPath(), "nonexistent_pwsh_" + System.Guid.NewGuid().ToString("N") + ".exe"); + Assert.Throws(() => ScriptRunner.ResolveShell(missingExe)); + } + finally + { + if (File.Exists(tempExe)) + { + File.Delete(tempExe); + } + } + } + + [Fact] + public void PwshInstallDirs_IncludesWindowsAppsLocation() + { + var dirs = System.Linq.Enumerable.ToList(ScriptRunner.PwshInstallDirs()); + Assert.Contains(dirs, d => d.Contains("WindowsApps", System.StringComparison.OrdinalIgnoreCase)); + } } diff --git a/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs b/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs index 0db9c48..acaa292 100644 --- a/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs +++ b/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs @@ -71,9 +71,28 @@ internal sealed class PaletteShellSettingsManager : JsonSettingsManager "How long to keep files in %TEMP%\\PaletteShell before auto-clean removes them.", DefaultCleanupDays.ToString(CultureInfo.InvariantCulture)); + private readonly TextSetting _customPowerShellPath = new( + "customPowerShellPath", + "Custom PowerShell path", + "Optional path to a custom pwsh.exe (e.g. from Windows Terminal or a custom install). Leave blank to auto-detect.", + string.Empty); + + private readonly ToggleSetting _loadPowerShellProfile = new( + "loadPowerShellProfile", + "Load PowerShell profile ($PROFILE)", + "Whether to load your PowerShell profile when running scripts. Defaults to off for faster, isolated execution.", + false); + /// The configured default host: "auto", "pwsh", or "powershell". public string DefaultHost => _defaultHost.Value ?? HostAuto; + /// The user-configured custom PowerShell path, or null when unset. + public string? CustomPowerShellPath => + string.IsNullOrWhiteSpace(_customPowerShellPath.Value) ? null : _customPowerShellPath.Value.Trim(); + + /// Whether to load the user's PowerShell profile when running scripts. + public bool LoadPowerShellProfile => _loadPowerShellProfile.Value; + /// The user-configured default timeout, clamped to the same bounds a /// script-declared [ScriptTimeout(...)] gets, so a typo'd setting (an extra /// digit, say) can't turn every run into a multi-minute blocked COM call. @@ -165,6 +184,8 @@ private PaletteShellSettingsManager() } Settings.Add(_defaultHost); + Settings.Add(_customPowerShellPath); + Settings.Add(_loadPowerShellProfile); Settings.Add(_defaultTimeoutMs); Settings.Add(_preferredEditor); Settings.Add(_scriptsFolder); diff --git a/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs b/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs index ea17f5d..050d758 100644 --- a/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs +++ b/PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs @@ -51,11 +51,18 @@ public static ScriptCompatibility Validate(ScriptManifest? manifest, bool pwshAv if (!AppVersion.IsCompatible(manifest.MinVersion, manifest.MaxVersion, out var required, out var tooNew)) return new ScriptCompatibility(ScriptCompatibilityKind.RequiresUpdate, required!.ToString(), tooNew); - // A declared host that parses to Unknown is a manifest error: block it here rather than - // let the runner pick an interpreter the script never asked for. Null means "no override" - // (the configured default applies), which is always a valid token. - if (manifest.Host is not null && ScriptRunner.ParseHost(manifest.Host) == ScriptRunner.ShellHost.Unknown) - return new ScriptCompatibility(ScriptCompatibilityKind.UnknownHost, BadHost: manifest.Host); + // A declared host that parses to Unknown (or a custom path that does not exist) is a manifest error: + // block it here rather than let the runner pick an interpreter the script never asked for. + // Null means "no override" (the configured default applies), which is always a valid token. + if (manifest.Host is not null) + { + var hostKind = ScriptRunner.ParseHost(manifest.Host); + if (hostKind == ScriptRunner.ShellHost.Unknown || + (hostKind == ScriptRunner.ShellHost.CustomPath && !System.IO.File.Exists(manifest.Host.Trim()))) + { + return new ScriptCompatibility(ScriptCompatibilityKind.UnknownHost, BadHost: manifest.Host); + } + } // A script whose effective host resolves to pwsh (declared, or via the default) can't run // if pwsh 7 isn't installed — ResolveShell would throw. Block it up front with an diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index 637f9d9..8d0f417 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -110,6 +110,8 @@ public enum ShellHost Pwsh, /// Require Windows PowerShell (powershell.exe). WindowsPowerShell, + /// Direct path to a custom interpreter executable. + CustomPath, /// Unrecognized token — the script is incompatible. Unknown, } @@ -131,13 +133,26 @@ public static ShellHost ParseHost(string? host) return ShellHost.Auto; } - return host.Trim().ToLowerInvariant() switch + var trimmed = host.Trim(); + switch (trimmed.ToLowerInvariant()) { - "auto" => ShellHost.Auto, - "pwsh" => ShellHost.Pwsh, - "powershell" => ShellHost.WindowsPowerShell, - _ => ShellHost.Unknown, - }; + case "auto": + return ShellHost.Auto; + case "pwsh": + return ShellHost.Pwsh; + case "powershell": + return ShellHost.WindowsPowerShell; + } + + if (trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || + trimmed.Contains(Path.DirectorySeparatorChar) || + trimmed.Contains(Path.AltDirectorySeparatorChar) || + File.Exists(trimmed)) + { + return ShellHost.CustomPath; + } + + return ShellHost.Unknown; } /// @@ -147,6 +162,7 @@ public static ShellHost ParseHost(string? host) /// Pwsh: pwsh only — throws "PowerShell 7 is required but not installed" when missing, /// rather than silently downgrading a script that declared a 7-only requirement. /// WindowsPowerShell: powershell.exe only. + /// CustomPath: custom executable path (validated to exist). /// Unknown: throws — the script is incompatible. /// /// Each call re-resolves against the current PATH and standard install locations, so an @@ -156,6 +172,14 @@ public static string ResolveShell(string? host) { switch (ParseHost(host)) { + case ShellHost.CustomPath: + var trimmed = host!.Trim(); + if (File.Exists(trimmed)) + { + return trimmed; + } + throw new ShellResolutionException($"Custom script host '{host}' was not found."); + case ShellHost.WindowsPowerShell: return FindWindowsPowerShell() ?? throw new ShellResolutionException("Windows PowerShell (powershell.exe) is required but was not found."); @@ -173,7 +197,7 @@ public static string ResolveShell(string? host) } string throwUnknown() => - throw new ShellResolutionException($"Unknown script host '{host}'. Supported values are 'auto', 'pwsh', and 'powershell'."); + throw new ShellResolutionException($"Unknown script host '{host}'. Supported values are 'auto', 'pwsh', 'powershell', or an executable path."); } /// Best-effort interpreter name for display (the failure report) that never throws: @@ -190,6 +214,7 @@ public static string DescribeShell(string? host) { return ParseHost(host) switch { + ShellHost.CustomPath => $"{host} (not found)", ShellHost.Pwsh => "pwsh.exe (not found)", ShellHost.WindowsPowerShell => "powershell.exe (not found)", ShellHost.Auto => "(no PowerShell found)", @@ -198,8 +223,17 @@ public static string DescribeShell(string? host) } } - /// Finds pwsh.exe on PATH or in the standard PowerShell 7+ install locations. - private static string? FindPwsh() => FindExecutable("pwsh.exe", PwshInstallDirs()); + /// Finds pwsh.exe on PATH, in configured custom path, or in standard PowerShell 7+ install locations. + private static string? FindPwsh() + { + var custom = PaletteShellSettingsManager.Instance.CustomPowerShellPath; + if (!string.IsNullOrWhiteSpace(custom) && File.Exists(custom)) + { + return custom; + } + + return FindExecutable("pwsh.exe", PwshInstallDirs()); + } // pwsh 7 detection is probed once and cached for the process so the discovery gate never // rescans the filesystem on a machine that will never have pwsh. The only things that trigger a @@ -232,8 +266,8 @@ public static bool IsPwshInstalled() private static string? FindWindowsPowerShell() => FindExecutable("powershell.exe", WindowsPowerShellInstallDirs()); /// Standard install roots for PowerShell 7+: %ProgramFiles%\PowerShell\<version> - /// (both bitnesses) and the winget/WindowsApps shim location. - private static IEnumerable PwshInstallDirs() + /// (both bitnesses) and the winget/WindowsApps shim and package locations. + internal static IEnumerable PwshInstallDirs() { foreach (var pf in new[] { @@ -267,7 +301,26 @@ private static IEnumerable PwshInstallDirs() var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); if (!string.IsNullOrWhiteSpace(localAppData)) { - yield return Path.Combine(localAppData, "Microsoft", "WindowsApps"); + var windowsApps = Path.Combine(localAppData, "Microsoft", "WindowsApps"); + yield return windowsApps; + + if (Directory.Exists(windowsApps)) + { + string[] packageDirs; + try + { + packageDirs = Directory.GetDirectories(windowsApps, "Microsoft.PowerShell*"); + } + catch (Exception) + { + packageDirs = Array.Empty(); + } + + foreach (var dir in packageDirs) + { + yield return dir; + } + } } } @@ -390,7 +443,10 @@ public static ProcessStartInfo BuildProcessStartInfo( // Always use STA mode - scripts may call clipboard functions internally psi.ArgumentList.Add("-STA"); - psi.ArgumentList.Add("-NoProfile"); + if (!PaletteShellSettingsManager.Instance.LoadPowerShellProfile) + { + psi.ArgumentList.Add("-NoProfile"); + } psi.ArgumentList.Add("-ExecutionPolicy"); psi.ArgumentList.Add("Bypass"); diff --git a/docs/PaletteShellScripts.AGENTS.md b/docs/PaletteShellScripts.AGENTS.md index 8b8da78..9fc2135 100644 --- a/docs/PaletteShellScripts.AGENTS.md +++ b/docs/PaletteShellScripts.AGENTS.md @@ -40,7 +40,7 @@ param( ## Common Attributes -- `[ScriptHost('pwsh')]` or `[ScriptHost('powershell')]` +- `[ScriptHost('pwsh')]`, `[ScriptHost('powershell')]`, or `[ScriptHost('path\to\pwsh.exe')]` - `[ScriptOutput('None'|'Toast'|'Clipboard'|'Markdown'|'Result'|'List'|'Open'|'File[:ext]')]` - `[ScriptIcon('...')]` - `[ScriptTimeout(30000)]` diff --git a/docs/PaletteShellScripts.Reference.md b/docs/PaletteShellScripts.Reference.md index a3d3cb0..b9fc835 100644 --- a/docs/PaletteShellScripts.Reference.md +++ b/docs/PaletteShellScripts.Reference.md @@ -69,7 +69,7 @@ Defined in `PaletteScriptAttributes.psm1`. Only these are recognized; anything e | Attribute | Purpose | |-----------|---------| -| `[ScriptHost('pwsh')]` | Host to run under: `'pwsh'` (PowerShell 7, default) or `'powershell'` (Windows PowerShell 5.1) | +| `[ScriptHost('pwsh')]` | Host to run under: `'pwsh'` (PowerShell 7, default), `'powershell'` (Windows PowerShell 5.1), or a direct executable path to a custom interpreter | | `[ScriptCwd('{ScriptDir}')]` | Working directory (supports path tokens, below) | | `[ScriptGroup('Category')]` | Group name used by tooling such as the Script Manager catalog browser | | `[ScriptTags('foo,bar,baz')]` | Comma-delimited free-form tags used by tooling such as the Script Manager catalog browser | diff --git a/docs/extension-behavior-reference.md b/docs/extension-behavior-reference.md index 00203e0..d2f1847 100644 --- a/docs/extension-behavior-reference.md +++ b/docs/extension-behavior-reference.md @@ -101,6 +101,8 @@ PaletteShell's top-level command has a built-in **Settings** page (the gear icon |---------|---------| | **Scripts folder** | Where PaletteShell looks for `.ps1` scripts. Changing this doesn't move your existing scripts or `pinned.txt` — run **"Reload scripts"** afterward to point PaletteShell at the new folder. | | **Default script host** | `Auto` (recommended), `PowerShell 7 (pwsh)`, or `Windows PowerShell 5.1` — used for any script that doesn't declare its own `[ScriptHost(...)]`. | +| **Custom PowerShell path** | Optional custom path to `pwsh.exe` (e.g. from Windows Terminal or Microsoft Store). When set, this path is preferred for PowerShell 7. | +| **Load PowerShell profile** | Whether to load your PowerShell `$PROFILE` when running scripts. Defaults to off for faster, isolated execution. | | **Default timeout** | Milliseconds to wait for a script that doesn't declare its own `[ScriptTimeout(...)]` before treating it as timed out. | | **Preferred editor** | Command or path used by **"Open in editor"**. Leave blank to fall back to `$VISUAL`, then `$EDITOR`, then Notepad. |