Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .agents/rules/file-access.md
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions PaletteShellExtension.Tests/ScriptRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.ShellResolutionException>(() => 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));
}
}
21 changes: 21 additions & 0 deletions PaletteShellExtension/Classes/PaletteShellSettingsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>The configured default host: <c>"auto"</c>, <c>"pwsh"</c>, or <c>"powershell"</c>.</summary>
public string DefaultHost => _defaultHost.Value ?? HostAuto;

/// <summary>The user-configured custom PowerShell path, or null when unset.</summary>
public string? CustomPowerShellPath =>
string.IsNullOrWhiteSpace(_customPowerShellPath.Value) ? null : _customPowerShellPath.Value.Trim();

/// <summary>Whether to load the user's PowerShell profile when running scripts.</summary>
public bool LoadPowerShellProfile => _loadPowerShellProfile.Value;

/// <summary>The user-configured default timeout, clamped to the same bounds a
/// script-declared <c>[ScriptTimeout(...)]</c> gets, so a typo'd setting (an extra
/// digit, say) can't turn every run into a multi-minute blocked COM call.</summary>
Expand Down Expand Up @@ -165,6 +184,8 @@ private PaletteShellSettingsManager()
}

Settings.Add(_defaultHost);
Settings.Add(_customPowerShellPath);
Settings.Add(_loadPowerShellProfile);
Settings.Add(_defaultTimeoutMs);
Settings.Add(_preferredEditor);
Settings.Add(_scriptsFolder);
Expand Down
17 changes: 12 additions & 5 deletions PaletteShellExtension/Classes/ScriptCompatibilityValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 69 additions & 13 deletions PaletteShellExtension/Classes/ScriptRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ public enum ShellHost
Pwsh,
/// <summary>Require Windows PowerShell (powershell.exe).</summary>
WindowsPowerShell,
/// <summary>Direct path to a custom interpreter executable.</summary>
CustomPath,
/// <summary>Unrecognized token — the script is incompatible.</summary>
Unknown,
}
Expand All @@ -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;
}

/// <summary>
Expand All @@ -147,6 +162,7 @@ public static ShellHost ParseHost(string? host)
/// <item>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.</item>
/// <item>WindowsPowerShell: powershell.exe only.</item>
/// <item>CustomPath: custom executable path (validated to exist).</item>
/// <item>Unknown: throws — the script is incompatible.</item>
/// </list>
/// Each call re-resolves against the current PATH and standard install locations, so an
Expand All @@ -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.");
Expand All @@ -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.");
}

/// <summary>Best-effort interpreter name for display (the failure report) that never throws:
Expand All @@ -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)",
Expand All @@ -198,8 +223,17 @@ public static string DescribeShell(string? host)
}
}

/// <summary>Finds pwsh.exe on PATH or in the standard PowerShell 7+ install locations.</summary>
private static string? FindPwsh() => FindExecutable("pwsh.exe", PwshInstallDirs());
/// <summary>Finds pwsh.exe on PATH, in configured custom path, or in standard PowerShell 7+ install locations.</summary>
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
Expand Down Expand Up @@ -232,8 +266,8 @@ public static bool IsPwshInstalled()
private static string? FindWindowsPowerShell() => FindExecutable("powershell.exe", WindowsPowerShellInstallDirs());

/// <summary>Standard install roots for PowerShell 7+: <c>%ProgramFiles%\PowerShell\&lt;version&gt;</c>
/// (both bitnesses) and the winget/WindowsApps shim location.</summary>
private static IEnumerable<string> PwshInstallDirs()
/// (both bitnesses) and the winget/WindowsApps shim and package locations.</summary>
internal static IEnumerable<string> PwshInstallDirs()
{
foreach (var pf in new[]
{
Expand Down Expand Up @@ -267,7 +301,26 @@ private static IEnumerable<string> 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<string>();
}

foreach (var dir in packageDirs)
{
yield return dir;
}
}
}
}

Expand Down Expand Up @@ -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");

Expand Down
2 changes: 1 addition & 1 deletion docs/PaletteShellScripts.AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)]`
Expand Down
2 changes: 1 addition & 1 deletion docs/PaletteShellScripts.Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/extension-behavior-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down