diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0659339 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + platform: [x64, ARM64] + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore + run: dotnet restore PaletteShellExtension.sln + + - name: Build + run: dotnet build PaletteShellExtension.sln -c Release -p:Platform=${{ matrix.platform }} --no-restore + + test: + name: Test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore + run: dotnet restore PaletteShellExtension.sln + + - name: Test + run: dotnet test PaletteShellExtension.sln -c Debug -p:Platform=x64 --no-restore --logger "trx;LogFileName=test-results.trx" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: '**/TestResults/test-results.trx' + retention-days: 7 diff --git a/.gitignore b/.gitignore index 6c20295..bbd4c2d 100644 --- a/.gitignore +++ b/.gitignore @@ -167,4 +167,3 @@ BenchmarkDotNet.Artifacts/ # ========================= !Directory.Build.rsp /PaletteShellExtension/bundle_mapping.txt -/PaletteShellExtension/PackageApplication.ps1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e43ae5e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,85 @@ +# Contributing to PaletteShell + +This covers building, testing, and debugging the **extension itself** (the C# host). If you're +looking to write a PaletteShell *script*, see the [README](README.md#-creating-your-own-scripts) +and [AGENTS.md](AGENTS.md) instead — this doc is for people changing the extension's code. + +## Prerequisites + +- .NET 9 SDK with the Windows 10.0.26100 platform (`dotnet --list-sdks` should show a `9.0.x` entry) +- Windows 10 (10.0.19041) or later +- [Windows Command Palette](https://learn.microsoft.com/windows/powertoys/command-palette/overview) + (part of PowerToys) installed, for the sideload/debug loop below +- Visual Studio 2022 (17.13+) with the "Windows application development" workload, only needed for + MSIX packaging and process-attach debugging — everyday build/test/edit works from the CLI alone + +## Project layout + +Two projects, one solution (`PaletteShellExtension.sln`): + +- `PaletteShellExtension/` — the extension itself (see the project structure table in the + [README](README.md#project-structure) for what each file does) +- `PaletteShellExtension.Tests/` — xUnit tests, currently focused on `PowerShellScriptParser` + +Both target `net9.0-windows10.0.26100.0` and only build for `x64`/`ARM64` (no `x86`/`AnyCPU`) — +that's enforced by `Directory.Build.props` at the repo root, so pass `-p:Platform=x64` (or +`ARM64`) to any `dotnet build`/`test` invocation. + +## Building and testing + +```powershell +dotnet restore PaletteShellExtension.sln +dotnet build PaletteShellExtension.sln -c Debug -p:Platform=x64 +dotnet test PaletteShellExtension.sln -c Debug -p:Platform=x64 +``` + +For most changes — anything in `PowerShellScriptParser`, the manifest models, or output +handling — `dotnet test` is the fast loop: it doesn't need Command Palette, sideloading, or a +debugger attached. Add cases to `PaletteShellExtension.Tests/PowerShellScriptParserTests.cs` +alongside behavior changes. + +`.github/workflows/ci.yml` runs the same build (both platforms, Release) and test suite on every +PR, so a green `dotnet build`/`dotnet test` locally is a reliable predictor of CI passing. + +## Running and debugging the extension locally + +Open `PaletteShellExtension.sln` in Visual Studio with `PaletteShellExtension` as the startup +project and press **F5**. VS's single-project MSIX tooling (`EnableMsixTooling`, +`HasPackageAndPublishMenu` in the `.csproj`) handles build, sideload deployment, and registering +the COM server in one step, and arms the debugger for it — when Command Palette activates +PaletteShell, breakpoints just hit. No manual `Attach to Process` needed. + +Open Command Palette and search **PaletteShell** to trigger activation. If you're mid-debug +session and want to force a fresh activation after a code change, stop debugging, rebuild, and F5 +again — VS redeploys the updated package. + +For issues you can't easily catch with a debugger (e.g. something a user reports), check the +extension's log file instead — parse failures, icon-validation warnings, and script execution +errors are written to `%LOCALAPPDATA%\PaletteShell\logs\palette-shell-.log` +(see `Classes/Log.cs`), which is pruned automatically after 7 days. + +### Producing a standalone test package + +To hand someone a sideloadable build without Visual Studio attached (or to test the packaged +artifact itself), right-click the `PaletteShellExtension` project → **Publish → Create App +Packages...** → choose **Sideloading** → build for the platform you need (`x64` or `ARM64`). This +produces +`PaletteShellExtension/AppPackages//PaletteShellExtension___Test/`, +containing the `.msix` and an `Add-AppDevPackage.ps1` installer script — run that script (as your +normal user, not elevated) to install it. On first use it also installs the developer certificate +needed to trust the unsigned test package. + +## Bumping the package version + +`AppxPackageVersion` in `PaletteShellExtension.csproj` and `` in +`Package.appxmanifest` must match — bump both together. + +## Submitting a change + +- Keep `dotnet build` and `dotnet test` (both platforms if the change could plausibly differ + between them) green before opening a PR; CI re-checks the same thing. +- If you change parsing or manifest behavior, add or update a test in + `PaletteShellExtension.Tests` rather than relying on manual sideload testing alone. +- If you change end-user-facing behavior (attributes, output modes, sample scripts), update the + relevant section of [README.md](README.md) and, if it affects script authoring, [AGENTS.md](AGENTS.md) + in the same PR — they're both meant to stay authoritative. diff --git a/Directory.Packages.props b/Directory.Packages.props index 5fc3fbf..630e991 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,9 @@ + + + diff --git a/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs b/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs new file mode 100644 index 0000000..2343763 --- /dev/null +++ b/PaletteShellExtension.Tests/NewScriptWizardFormTests.cs @@ -0,0 +1,137 @@ +using System.IO; +using PaletteShellExtension.Forms; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class NewScriptWizardFormTests +{ + [Fact] + public void SubmitForm_Create_WritesScriptWithAllOptions_AndParserReadsItBack() + { + var root = Directory.CreateTempSubdirectory("pswizard-").FullName; + try + { + var form = new NewScriptWizardForm(root); + var inputs = """ + { + "name": "MyTestScript", + "description": "Does a thing", + "group": "My Group", + "icon": "🎯", + "output": "Result", + "host": "pwsh", + "timeout": "45000", + "elevate": "true", + "confirm": "Are you sure?", + "open": "false" + } + """; + + form.SubmitForm(inputs, """{"verb":"create"}"""); + + var path = Path.Combine(root, "MyTestScript.ps1"); + Assert.True(File.Exists(path)); + + var manifest = PowerShellScriptParser.TryParseManifest(path); + Assert.NotNull(manifest); + Assert.Equal("MyTestScript", manifest!.Title); + Assert.Equal("Does a thing", manifest.Description); + Assert.Equal("My Group", manifest.Group); + Assert.Equal("🎯", manifest.IconGlyph); + Assert.Equal("Result", manifest.Output); + Assert.Equal("pwsh", manifest.Host); + Assert.Equal(45000, manifest.TimeoutMs); + Assert.True(manifest.RequiresAdmin); + Assert.Equal("Are you sure?", manifest.ConfirmMessage); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SubmitForm_OmittedOptions_OmitHostAndTimeoutForRuntimeDefaults() + { + var root = Directory.CreateTempSubdirectory("pswizard-").FullName; + try + { + var form = new NewScriptWizardForm(root); + form.SubmitForm("""{"name":"Defaults","open":"false"}""", """{"verb":"create"}"""); + + var manifest = PowerShellScriptParser.TryParseManifest(Path.Combine(root, "Defaults.ps1")); + + Assert.NotNull(manifest); + Assert.Equal("General", manifest!.Group); + Assert.Equal("None", manifest.Output); + // Host/timeout are left unset in the file itself, so the script picks up + // whatever the global default settings are at run time. + Assert.Null(manifest.Host); + Assert.Null(manifest.TimeoutMs); + Assert.Null(manifest.RequiresAdmin); + Assert.Null(manifest.ConfirmMessage); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SubmitForm_TimeoutBelowMinimum_ClampsToMinimum() + { + var root = Directory.CreateTempSubdirectory("pswizard-").FullName; + try + { + var form = new NewScriptWizardForm(root); + form.SubmitForm("""{"name":"BadTimeout","timeout":"10","open":"false"}""", """{"verb":"create"}"""); + + var manifest = PowerShellScriptParser.TryParseManifest(Path.Combine(root, "BadTimeout.ps1")); + + Assert.Equal(1000, manifest!.TimeoutMs); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SubmitForm_HostDefault_OmitsScriptHostAttribute() + { + var root = Directory.CreateTempSubdirectory("pswizard-").FullName; + try + { + var form = new NewScriptWizardForm(root); + form.SubmitForm("""{"name":"DefaultHost","host":"default","open":"false"}""", """{"verb":"create"}"""); + + var manifest = PowerShellScriptParser.TryParseManifest(Path.Combine(root, "DefaultHost.ps1")); + + Assert.Null(manifest!.Host); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void SubmitForm_Cancel_DoesNotCreateFile() + { + var root = Directory.CreateTempSubdirectory("pswizard-").FullName; + try + { + var form = new NewScriptWizardForm(root); + + form.SubmitForm("{}", """{"verb":"cancel"}"""); + + Assert.False(Directory.Exists(root) && Directory.GetFiles(root).Length > 0); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/PaletteShellExtension.Tests/PaletteShellExtension.Tests.csproj b/PaletteShellExtension.Tests/PaletteShellExtension.Tests.csproj new file mode 100644 index 0000000..86e3b23 --- /dev/null +++ b/PaletteShellExtension.Tests/PaletteShellExtension.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0-windows10.0.26100.0 + 10.0.19041.0 + 10.0.26100.68-preview + enable + false + true + + $(NoWarn);CA1707 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs new file mode 100644 index 0000000..1ef0f1d --- /dev/null +++ b/PaletteShellExtension.Tests/PowerShellScriptParserTests.cs @@ -0,0 +1,530 @@ +using System.IO; +using System.Linq; +using PaletteShellExtension.Classes; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class PowerShellScriptParserTests +{ + // ----- Comment-based help ----------------------------------------------------------- + + [Fact] + public void Synopsis_BecomesTitle() + { + using var file = new TestScriptFile(""" + <# + .SYNOPSIS + My Cool Script + #> + Write-Output "hi" + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.NotNull(manifest); + Assert.Equal("My Cool Script", manifest!.Title); + } + + [Fact] + public void MissingSynopsis_FallsBackToFileName() + { + using var file = new TestScriptFile("Write-Output \"hi\""); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.NotNull(manifest); + Assert.Equal(Path.GetFileNameWithoutExtension(file.Path), manifest!.Title); + } + + [Fact] + public void Description_IsParsed() + { + using var file = new TestScriptFile(""" + <# + .SYNOPSIS + Title + .DESCRIPTION + Does a thing. + #> + Write-Output "hi" + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("Does a thing.", manifest!.Description); + } + + [Fact] + public void ParameterHelp_BecomesLabel_WhenNoHelpMessage() + { + using var file = new TestScriptFile(""" + <# + .SYNOPSIS + Title + .PARAMETER Name + The name to greet. + #> + param( + [string]$Name + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal("The name to greet.", parameter.Label); + } + + // ----- Parameters --------------------------------------------------------------------- + + [Theory] + [InlineData("[string]$P", "string")] + [InlineData("[int]$P", "int")] + [InlineData("[int32]$P", "int")] + [InlineData("[long]$P", "int")] + [InlineData("[double]$P", "number")] + [InlineData("[decimal]$P", "number")] + [InlineData("[switch]$P", "bool")] + [InlineData("[bool]$P", "bool")] + [InlineData("$P", "string")] // no type constraint at all + public void ParameterType_MapsToUiType(string declaration, string expectedUiType) + { + using var file = new TestScriptFile($"param(\n {declaration}\n)"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal(expectedUiType, parameter.Type); + } + + [Fact] + public void ValidateSet_ProducesEnumWithOptions() + { + using var file = new TestScriptFile(""" + param( + [ValidateSet('A','B','C')] + [string]$Choice + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal("enum", parameter.Type); + Assert.Equal(["A", "B", "C"], parameter.Options); + } + + [Fact] + public void ValidateRange_SetsMinAndMax() + { + using var file = new TestScriptFile(""" + param( + [ValidateRange(1,10)] + [int]$Count + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal(1, parameter.Min); + Assert.Equal(10, parameter.Max); + } + + [Theory] + [InlineData("[Parameter(Mandatory=$true)]", true)] + [InlineData("[Parameter(Mandatory)]", true)] + [InlineData("[Parameter(Mandatory=$false)]", false)] + [InlineData("", false)] + public void Mandatory_SetsRequired(string attribute, bool expectRequired) + { + using var file = new TestScriptFile($"param(\n {attribute}\n [string]$P\n)"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal(expectRequired ? true : (bool?)null, parameter.Required); + } + + [Fact] + public void HelpMessage_OverridesParameterHelpText() + { + using var file = new TestScriptFile(""" + <# + .SYNOPSIS + Title + .PARAMETER Name + Comment-based help text. + #> + param( + [Parameter(HelpMessage='Explicit label')] + [string]$Name + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal("Explicit label", parameter.Label); + } + + [Fact] + public void AllowExpression_SetsFlag() + { + using var file = new TestScriptFile(""" + param( + [AllowExpression()] + [string]$Expr + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.True(parameter.AllowExpression); + } + + [Theory] + [InlineData("[int]$P = 5", 5)] + [InlineData("[string]$P = \"hi\"", "hi")] + [InlineData("[bool]$P = $true", true)] + [InlineData("[bool]$P = $false", false)] + public void DefaultValue_IsInterpretedByType(string declaration, object expected) + { + using var file = new TestScriptFile($"param(\n {declaration}\n)"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + var parameter = Assert.Single(manifest!.Parameters); + Assert.Equal(expected, parameter.Default); + } + + [Fact] + public void MultipleParameters_AllParsed() + { + using var file = new TestScriptFile(""" + param( + [string]$Name, + [int]$Count = 3, + [switch]$Force + ) + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(3, manifest!.Parameters.Count); + Assert.Equal(["Name", "Count", "Force"], manifest.Parameters.Select(p => p.Name)); + } + + // ----- Script-level attributes ---------------------------------------------------------- + + [Fact] + public void ScriptHost_IsParsed() + { + using var file = new TestScriptFile("[ScriptHost('powershell')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("powershell", manifest!.Host); + } + + [Fact] + public void ScriptCwd_IsParsed() + { + using var file = new TestScriptFile("[ScriptCwd('{ScriptDir}')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("{ScriptDir}", manifest!.Cwd); + } + + [Fact] + public void RequiresElevationAttribute_SetsRequiresAdmin() + { + using var file = new TestScriptFile("[RequiresElevation()]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.True(manifest!.RequiresAdmin); + } + + [Fact] + public void RequiresAdminComment_SetsRequiresAdmin() + { + using var file = new TestScriptFile("#Requires -RunAsAdministrator\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.True(manifest!.RequiresAdmin); + } + + [Theory] + [InlineData("[ConfirmBeforeRun()]", "Are you sure you want to run this script?")] + [InlineData("[ConfirmBeforeRun]", "Are you sure you want to run this script?")] + [InlineData("[ConfirmBeforeRun('This deletes files')]", "This deletes files")] + public void ConfirmBeforeRun_SetsMessage(string attribute, string expectedMessage) + { + using var file = new TestScriptFile($"{attribute}\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(expectedMessage, manifest!.ConfirmMessage); + } + + [Fact] + public void ScriptTimeout_IsParsed() + { + using var file = new TestScriptFile("[ScriptTimeout(30000)]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(30000, manifest!.TimeoutMs); + } + + [Theory] + [InlineData(-5)] + [InlineData(0)] + [InlineData(999)] + public void ScriptTimeout_BelowMinimum_IsIgnored(int timeoutMs) + { + using var file = new TestScriptFile($"[ScriptTimeout({timeoutMs})]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Null(manifest!.TimeoutMs); + } + + [Fact] + public void ScriptTimeout_AboveMaximum_IsClamped() + { + using var file = new TestScriptFile("[ScriptTimeout(999999999)]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(600_000, manifest!.TimeoutMs); + } + + [Fact] + public void ScriptOutput_WithoutExtension_SetsOutputOnly() + { + using var file = new TestScriptFile("[ScriptOutput('Clipboard')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("Clipboard", manifest!.Output); + Assert.Null(manifest.FileExtension); + } + + [Fact] + public void ScriptOutput_WithExtensionHint_SplitsOutputAndExtension() + { + using var file = new TestScriptFile("[ScriptOutput('File:csv')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("File", manifest!.Output); + Assert.Equal("csv", manifest.FileExtension); + } + + [Fact] + public void ScriptGroup_IsParsed() + { + using var file = new TestScriptFile("[ScriptGroup('Utilities')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("Utilities", manifest!.Group); + } + + [Fact] + public void ScriptEnv_MultipleAttributes_AllCaptured() + { + using var file = new TestScriptFile(""" + [ScriptEnv('KEY1','value1')] + [ScriptEnv('KEY2','value2')] + param() + """); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal("value1", manifest!.Env["KEY1"]); + Assert.Equal("value2", manifest.Env["KEY2"]); + } + + // ----- Icon glyph validation ------------------------------------------------------------- + // + // Glyphs below are built from Unicode escapes rather than typed as literal characters: + // Segoe MDL2/Fluent PUA codepoints and ZWJ emoji sequences are invisible or easily + // mis-typed in an editor, which is exactly the failure mode this validation guards + // against (see PowerShellScriptParser.IsValidIconGlyph). + + [Fact] + public void ScriptIcon_SingleEmoji_IsAccepted() + { + var glyph = "\U0001F680"; // rocket + using var file = new TestScriptFile($"[ScriptIcon('{glyph}')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(glyph, manifest!.IconGlyph); + } + + [Fact] + public void ScriptIcon_SinglePuaGlyph_IsAccepted() + { + var glyph = "\uE8B7"; // Segoe MDL2 Assets glyph + using var file = new TestScriptFile($"[ScriptIcon('{glyph}')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(glyph, manifest!.IconGlyph); + } + + [Fact] + public void ScriptIcon_ZwjEmojiSequence_IsAcceptedAsSingleGrapheme() + { + // Family emoji: three codepoints joined by ZWJ (U+200D), rendering as one glyph. + var glyph = "\U0001F468\u200D\U0001F469\u200D\U0001F467"; + using var file = new TestScriptFile($"[ScriptIcon('{glyph}')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Equal(glyph, manifest!.IconGlyph); + } + + [Fact] + public void ScriptIcon_MultiWordText_IsRejected() + { + using var file = new TestScriptFile("[ScriptIcon('todo icon please')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Null(manifest!.IconGlyph); + } + + [Fact] + public void ScriptIcon_Empty_IsNoOp() + { + using var file = new TestScriptFile("[ScriptIcon('')]\nparam()"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.Null(manifest!.IconGlyph); + } + + // ----- Robustness ------------------------------------------------------------------------ + + [Fact] + public void NonexistentFile_ReturnsNull() + { + var manifest = PowerShellScriptParser.TryParseManifest( + Path.Combine(Path.GetTempPath(), $"does-not-exist-{System.Guid.NewGuid():N}.ps1")); + + Assert.Null(manifest); + } + + [Fact] + public void NoParamBlock_ReturnsManifestWithNoParameters() + { + using var file = new TestScriptFile("Write-Output \"no params here\""); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.NotNull(manifest); + Assert.Empty(manifest!.Parameters); + } + + [Fact] + public void UnbalancedParamBlock_DoesNotThrow_AndYieldsNoParameters() + { + using var file = new TestScriptFile("param(\n [string]$Name"); + + var manifest = PowerShellScriptParser.TryParseManifest(file.Path); + + Assert.NotNull(manifest); + Assert.Empty(manifest!.Parameters); + } + + // ----- Path token expansion --------------------------------------------------------------- + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ExpandPathTokens_NullOrWhitespace_PassesThrough(string? input) + { + var result = PowerShellScriptParser.ExpandPathTokens(input, @"C:\scripts\test.ps1"); + + Assert.Equal(input, result); + } + + [Fact] + public void ExpandPathTokens_ScriptDir_ExpandsToScriptDirectory() + { + var result = PowerShellScriptParser.ExpandPathTokens("{ScriptDir}\\out.txt", @"C:\scripts\test.ps1"); + + Assert.Equal(@"C:\scripts\out.txt", result); + } + + [Fact] + public void ExpandPathTokens_Home_ExpandsToUserProfile() + { + var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); + + var result = PowerShellScriptParser.ExpandPathTokens("{Home}\\notes.txt", @"C:\scripts\test.ps1"); + + Assert.Equal(home + "\\notes.txt", result); + } + + [Fact] + public void ExpandPathTokens_Temp_ExpandsToTempPath() + { + var temp = Path.GetTempPath(); + + var result = PowerShellScriptParser.ExpandPathTokens("{Temp}file.txt", @"C:\scripts\test.ps1"); + + Assert.Equal(temp + "file.txt", result); + } + + [Fact] + public void ResolveCwd_NonexistentDirectory_ReturnsNull() + { + var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{System.Guid.NewGuid():N}"); + + var result = PowerShellScriptParser.ResolveCwd(missing, @"C:\scripts\test.ps1"); + + Assert.Null(result); + } + + [Fact] + public void ResolveCwd_ExistingDirectory_ReturnsExpandedPath() + { + var temp = Path.GetTempPath(); + + var result = PowerShellScriptParser.ResolveCwd("{Temp}", @"C:\scripts\test.ps1"); + + Assert.Equal(temp, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ResolveCwd_NullOrEmpty_PassesThrough(string? input) + { + var result = PowerShellScriptParser.ResolveCwd(input, @"C:\scripts\test.ps1"); + + Assert.Equal(input, result); + } + + [Fact] + public void ResolveCwd_ScriptDirOfRealScript_Resolves() + { + using var file = new TestScriptFile("param()"); + var expectedDir = Path.GetDirectoryName(file.Path); + + var result = PowerShellScriptParser.ResolveCwd("{ScriptDir}", file.Path); + + Assert.Equal(expectedDir, result); + } +} diff --git a/PaletteShellExtension.Tests/ScriptParameterFormTests.cs b/PaletteShellExtension.Tests/ScriptParameterFormTests.cs new file mode 100644 index 0000000..0882e0d --- /dev/null +++ b/PaletteShellExtension.Tests/ScriptParameterFormTests.cs @@ -0,0 +1,106 @@ +using System.Text.Json.Nodes; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; +using PaletteShellExtension.Forms; +using Xunit; + +namespace PaletteShellExtension.Tests; + +public class ScriptParameterFormTests +{ + private const string ScriptPath = @"C:\scripts\test.ps1"; + + private static ScriptManifest ManifestWithRequiredName(string? label = null) => new() + { + Title = "Test", + Parameters = [new ScriptParameter { Name = "Name", Type = "string", Required = true, Label = label }] + }; + + // ----- GetMissingRequiredFields (pure logic, no script execution) ----------------------- + + [Fact] + public void GetMissingRequiredFields_EmptyValue_IsReportedMissing() + { + var manifest = ManifestWithRequiredName(); + var values = JsonNode.Parse("""{"Name":""}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Equal(["Name"], missing); + } + + [Fact] + public void GetMissingRequiredFields_WhitespaceOnlyValue_IsReportedMissing() + { + var manifest = ManifestWithRequiredName(); + var values = JsonNode.Parse("""{"Name":" "}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Equal(["Name"], missing); + } + + [Fact] + public void GetMissingRequiredFields_UsesLabelWhenPresent() + { + var manifest = ManifestWithRequiredName(label: "Full Name"); + var values = JsonNode.Parse("""{"Name":""}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Equal(["Full Name"], missing); + } + + [Fact] + public void GetMissingRequiredFields_FilledRequiredField_IsNotReported() + { + var manifest = ManifestWithRequiredName(); + var values = JsonNode.Parse("""{"Name":"Alice"}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Empty(missing); + } + + [Fact] + public void GetMissingRequiredFields_OptionalFieldEmpty_IsNotReported() + { + var manifest = new ScriptManifest + { + Title = "Test", + Parameters = [new ScriptParameter { Name = "Name", Type = "string", Required = null }] + }; + var values = JsonNode.Parse("""{"Name":""}""")!.AsObject(); + + var missing = ScriptParameterForm.GetMissingRequiredFields(manifest, values); + + Assert.Empty(missing); + } + + // ----- SubmitForm early-exit paths (don't reach script execution) ----------------------- + + [Fact] + public void SubmitForm_MissingRequiredField_KeepsFormOpenWithToast() + { + var form = new ScriptParameterForm(ScriptPath, ManifestWithRequiredName()); + + var result = form.SubmitForm("""{"Name":""}""", """{"verb":"run"}"""); + + Assert.Equal(CommandResultKind.ShowToast, result.Kind); + var toastArgs = Assert.IsType(result.Args); + Assert.Contains("Name", toastArgs.Message); + Assert.NotNull(toastArgs.Result); + Assert.Equal(CommandResultKind.KeepOpen, toastArgs.Result!.Kind); + } + + [Fact] + public void SubmitForm_Cancel_Dismisses() + { + var form = new ScriptParameterForm(ScriptPath, ManifestWithRequiredName()); + + var result = form.SubmitForm("""{"Name":""}""", """{"verb":"cancel"}"""); + + Assert.Equal(CommandResultKind.Dismiss, result.Kind); + } +} diff --git a/PaletteShellExtension.Tests/TestScriptFile.cs b/PaletteShellExtension.Tests/TestScriptFile.cs new file mode 100644 index 0000000..913a64b --- /dev/null +++ b/PaletteShellExtension.Tests/TestScriptFile.cs @@ -0,0 +1,21 @@ +using System; +using System.IO; + +namespace PaletteShellExtension.Tests; + +/// Writes a temporary .ps1 file for a test and deletes it on dispose. +internal sealed class TestScriptFile : IDisposable +{ + public string Path { get; } + + public TestScriptFile(string content) + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"pstest-{Guid.NewGuid():N}.ps1"); + File.WriteAllText(Path, content, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + public void Dispose() + { + try { File.Delete(Path); } catch { /* best-effort cleanup */ } + } +} diff --git a/PaletteShellExtension.sln b/PaletteShellExtension.sln index 8632d80..4969403 100644 --- a/PaletteShellExtension.sln +++ b/PaletteShellExtension.sln @@ -1,18 +1,22 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.13.35507.96 d17.13 +VisualStudioVersion = 17.13.35507.96 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaletteShellExtension", "PaletteShellExtension\PaletteShellExtension.csproj", "{79F86DE5-70B1-4EC1-9832-DF428B55E466}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaletteShellExtension.Tests", "PaletteShellExtension.Tests\PaletteShellExtension.Tests.csproj", "{B3E8D0AB-C335-4D95-8F08-E705407147AC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 Debug|x64 = Debug|x64 Debug|x86 = Debug|x86 + Debug|Any CPU = Debug|Any CPU Release|ARM64 = Release|ARM64 Release|x64 = Release|x64 Release|x86 = Release|x86 + Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|ARM64.ActiveCfg = Debug|ARM64 @@ -24,6 +28,8 @@ Global {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.ActiveCfg = Debug|x86 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.Build.0 = Debug|x86 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|x86.Deploy.0 = Debug|x86 + {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Debug|Any CPU.Build.0 = Debug|Any CPU {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.ActiveCfg = Release|ARM64 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.Build.0 = Release|ARM64 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|ARM64.Deploy.0 = Release|ARM64 @@ -33,6 +39,24 @@ Global {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.ActiveCfg = Release|x86 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Build.0 = Release|x86 {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|x86.Deploy.0 = Release|x86 + {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79F86DE5-70B1-4EC1-9832-DF428B55E466}.Release|Any CPU.Build.0 = Release|Any CPU + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|ARM64.Build.0 = Debug|ARM64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x64.ActiveCfg = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x64.Build.0 = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x86.ActiveCfg = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|x86.Build.0 = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|Any CPU.ActiveCfg = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Debug|Any CPU.Build.0 = Debug|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|ARM64.ActiveCfg = Release|ARM64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|ARM64.Build.0 = Release|ARM64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x64.ActiveCfg = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x64.Build.0 = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x86.ActiveCfg = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|x86.Build.0 = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|Any CPU.ActiveCfg = Release|x64 + {B3E8D0AB-C335-4D95-8F08-E705407147AC}.Release|Any CPU.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PaletteShellExtension/Classes/EditorLauncher.cs b/PaletteShellExtension/Classes/EditorLauncher.cs index 8c200c7..c7bb103 100644 --- a/PaletteShellExtension/Classes/EditorLauncher.cs +++ b/PaletteShellExtension/Classes/EditorLauncher.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; namespace PaletteShellExtension.Classes; @@ -11,9 +12,14 @@ namespace PaletteShellExtension.Classes; /// internal static class EditorLauncher { + // Written with a BOM so arbitrary external editors (notably the notepad.exe fallback) + // reliably detect UTF-8 instead of guessing at all-non-Latin content. + private static readonly UTF8Encoding Utf8WithBom = new(encoderShouldEmitUTF8Identifier: true); + public static void Open(string path) { - var editor = Environment.GetEnvironmentVariable("VISUAL") + var editor = PaletteShellSettingsManager.Instance.PreferredEditor + ?? Environment.GetEnvironmentVariable("VISUAL") ?? Environment.GetEnvironmentVariable("EDITOR") ?? "notepad.exe"; Process.Start(new ProcessStartInfo(editor, $"\"{path}\"") { UseShellExecute = true }); @@ -34,7 +40,7 @@ public static string OpenContent(string content, string? extension = null, strin var fileName = $"{name}-{DateTime.Now:yyyyMMdd-HHmmss}{NormalizeExtension(extension)}"; var path = Path.Combine(dir, fileName); - File.WriteAllText(path, content ?? ""); + File.WriteAllText(path, content ?? "", Utf8WithBom); Open(path); return path; } diff --git a/PaletteShellExtension/Classes/Log.cs b/PaletteShellExtension/Classes/Log.cs new file mode 100644 index 0000000..1566cb7 --- /dev/null +++ b/PaletteShellExtension/Classes/Log.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; + +namespace PaletteShellExtension.Classes; + +/// +/// Minimal file logger for diagnosing script-loading and execution failures. The extension +/// runs as a COM server hosted by Command Palette, so there is no console the user can see — +/// this is the only way to learn *why* a script failed to parse or run without attaching a +/// debugger. Every write is best-effort; logging must never be the thing that crashes the +/// extension. +/// +internal static class Log +{ + private static readonly object WriteLock = new(); + private static readonly string LogPath; + + static Log() + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PaletteShell", + "logs"); + + LogPath = Path.Combine(dir, $"palette-shell-{DateTime.Now:yyyyMMdd}.log"); + + try + { + Directory.CreateDirectory(dir); + PruneOldLogs(dir); + } + catch (Exception) + { + // If we can't even create the log directory, logging is a no-op for this session. + } + } + + public static void Info(string message) => Write("INFO", message); + + public static void Warn(string message) => Write("WARN", message); + + public static void Error(string message, Exception? exception = null) => + Write("ERROR", exception is null ? message : $"{message}: {exception}"); + + private static void Write(string level, string message) + { + try + { + var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}"; + lock (WriteLock) + { + File.AppendAllText(LogPath, line); + } + } + catch (Exception) + { + // Never let logging failures surface to the caller. + } + } + + // Keeps the log directory from growing forever — one file per day, last week retained. + private static void PruneOldLogs(string dir) + { + var cutoff = DateTime.Now.AddDays(-7); + foreach (var file in Directory.GetFiles(dir, "palette-shell-*.log")) + { + try + { + if (File.GetLastWriteTime(file) < cutoff) + { + File.Delete(file); + } + } + catch (Exception) + { + // A locked or already-removed file just gets picked up next time. + } + } + } +} diff --git a/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs b/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs new file mode 100644 index 0000000..d4f9bb5 --- /dev/null +++ b/PaletteShellExtension/Classes/PaletteShellSettingsManager.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PaletteShellExtension.Classes; + +/// +/// Owns PaletteShell's extension-level settings (surfaced by the host as the provider's +/// settings page) and persists them to a JSON file under the extension's app-data folder. +/// +internal sealed class PaletteShellSettingsManager : JsonSettingsManager +{ + public const string HostAuto = "auto"; + public const string HostPwsh = "pwsh"; + public const string HostPowerShell = "powershell"; + + // Used when a script must be waited on to honor its output mode but declared no + // [ScriptTimeout], and the user hasn't overridden the default (or entered garbage). + private const int FallbackTimeoutMs = 30000; + + public static PaletteShellSettingsManager Instance { get; } = new(); + + private readonly ChoiceSetSetting _defaultHost = new( + "defaultHost", + "Default script host", + "Used for scripts that don't declare [ScriptHost(...)].", + new List + { + new("Auto (recommended)", HostAuto), + new("PowerShell 7 (pwsh)", HostPwsh), + new("Windows PowerShell 5.1", HostPowerShell), + }); + + private readonly TextSetting _defaultTimeoutMs = new( + "defaultTimeoutMs", + "Default timeout (ms)", + "Used for scripts that don't declare [ScriptTimeout(...)].", + FallbackTimeoutMs.ToString(CultureInfo.InvariantCulture)); + + private readonly TextSetting _preferredEditor = new( + "preferredEditor", + "Preferred editor", + "Command or path used by \"Open in editor\". Leave blank to use $VISUAL, then $EDITOR, then Notepad.", + string.Empty); + + private readonly TextSetting _scriptsFolder = new( + "scriptsFolder", + "Scripts folder", + "Folder PaletteShell scans for .ps1 scripts. Changing this doesn't move existing scripts or pins — run \"Reload scripts\" afterward to pick it up.", + string.Empty); + + /// The configured default host: "auto", "pwsh", or "powershell". + public string DefaultHost => _defaultHost.Value ?? HostAuto; + + public int DefaultTimeoutMs => + int.TryParse(_defaultTimeoutMs.Value, out var ms) && ms > 0 ? ms : FallbackTimeoutMs; + + /// The user's preferred editor command/path, or null when unset (fall back to env vars/Notepad). + public string? PreferredEditor => + string.IsNullOrWhiteSpace(_preferredEditor.Value) ? null : _preferredEditor.Value.Trim(); + + /// The configured scripts folder, or null when the user hasn't chosen one yet. + public string? ScriptsFolder + { + get => string.IsNullOrWhiteSpace(_scriptsFolder.Value) ? null : _scriptsFolder.Value.Trim(); + set + { + _scriptsFolder.Value = value ?? string.Empty; + SaveSettings(); + } + } + + private PaletteShellSettingsManager() + { + var settingsDir = Utilities.BaseSettingsPath("PaletteShellExtension"); + Directory.CreateDirectory(settingsDir); + FilePath = Path.Combine(settingsDir, "settings.json"); + + Settings.Add(_defaultHost); + Settings.Add(_defaultTimeoutMs); + Settings.Add(_preferredEditor); + Settings.Add(_scriptsFolder); + + LoadSettings(); + + Settings.SettingsChanged += (_, _) => SaveSettings(); + } +} diff --git a/PaletteShellExtension/Classes/ScriptOutputHandler.cs b/PaletteShellExtension/Classes/ScriptOutputHandler.cs index 25dfc21..b252ed2 100644 --- a/PaletteShellExtension/Classes/ScriptOutputHandler.cs +++ b/PaletteShellExtension/Classes/ScriptOutputHandler.cs @@ -50,9 +50,9 @@ private static void TrySetClipboard(string text) { TextCopy.ClipboardService.SetText(text ?? ""); } - catch (Exception) + catch (Exception ex) { - // Clipboard access can fail; ignore and continue. + Log.Warn($"Failed to set clipboard text: {ex.Message}"); } } } diff --git a/PaletteShellExtension/Classes/ScriptRunner.cs b/PaletteShellExtension/Classes/ScriptRunner.cs index bc76a9c..be3bce5 100644 --- a/PaletteShellExtension/Classes/ScriptRunner.cs +++ b/PaletteShellExtension/Classes/ScriptRunner.cs @@ -118,29 +118,17 @@ public static ProcessStartInfo BuildProcessStartInfo( // Pre-load the module so attributes can be resolved at parse time var scriptDir = Path.GetDirectoryName(scriptPath) ?? ""; var modulePath = Path.Combine(scriptDir, "PaletteScriptAttributes.psm1"); - if (File.Exists(modulePath)) - { - psi.ArgumentList.Add("-Command"); - // Import module, force UTF-8 console output so captured stdout isn't mangled - // (the `using` statement must come first), then dot-source the script with - // args. Always redirect the information stream (6) to stdout to capture Write-Host. - var commandString = $"using module '{modulePath}'; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; . '{scriptPath}' {args} 6>&1"; - psi.ArgumentList.Add(commandString); - } - else - { - psi.ArgumentList.Add("-File"); - psi.ArgumentList.Add(scriptPath); - - // Add arguments - if (!string.IsNullOrWhiteSpace(args)) - { - foreach (var arg in args.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - { - psi.ArgumentList.Add(arg); - } - } - } + var usingModule = File.Exists(modulePath) ? $"using module '{modulePath}'; " : ""; + + psi.ArgumentList.Add("-Command"); + // Import the module when present (the `using` statement must come first), force + // UTF-8 console output so captured stdout isn't mangled, then dot-source the script + // with args. Always redirect the information stream (6) to stdout to capture + // Write-Host. `args` is already single-quoted per value by the caller, so it's + // interpolated into this single command string rather than re-split into + // ArgumentList entries (which would break values containing spaces). + var commandString = $"{usingModule}[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; . '{scriptPath}' {args} 6>&1"; + psi.ArgumentList.Add(commandString); if (!string.IsNullOrWhiteSpace(cwd)) { @@ -195,8 +183,9 @@ public static bool RunScript( using var proc = Process.Start(psi); return proc is not null; } - catch (Exception) + catch (Exception ex) { + Log.Error($"Failed to launch script '{scriptPath}'", ex); return false; } } @@ -257,6 +246,7 @@ public static bool RunScript( if (timeoutMs.HasValue && !proc.WaitForExit(timeoutMs.Value)) { result.TimedOut = true; + Log.Warn($"Script '{scriptPath}' timed out after {timeoutMs.Value}ms and was killed"); try { proc.Kill(entireProcessTree: true); } catch (Exception) { @@ -279,10 +269,15 @@ public static bool RunScript( result.StandardOutput = AwaitRead(stdoutTask); result.StandardError = AwaitRead(stderrTask); result.ExitCode = proc.ExitCode; + if (result.ExitCode != 0) + { + Log.Warn($"Script '{scriptPath}' exited with code {result.ExitCode}"); + } return result; } - catch (Exception) + catch (Exception ex) { + Log.Error($"Failed to run script '{scriptPath}'", ex); return null; } finally diff --git a/PaletteShellExtension/Commands/RunScriptCommand.cs b/PaletteShellExtension/Commands/RunScriptCommand.cs index 3cc495d..1de06af 100644 --- a/PaletteShellExtension/Commands/RunScriptCommand.cs +++ b/PaletteShellExtension/Commands/RunScriptCommand.cs @@ -13,9 +13,6 @@ internal sealed partial class RunScriptCommand(string path, ScriptManifest? mani { private readonly ScriptManifest _manifest = manifest ?? new ScriptManifest(); - // Used when a script must be waited on to honor its output mode but declared no timeout. - private const int DefaultTimeoutMs = 30000; - public override string Name => $"Run {Path.GetFileNameWithoutExtension(path)}"; public override IconInfo Icon => new(_manifest.IconGlyph ?? ""); // Play; or map emoji Icon if you like @@ -43,7 +40,7 @@ private CommandResult RunNow() var wantsAdmin = _manifest.RequiresAdmin == true; // CWD - var cwd = ExpandPathTokens(_manifest.Cwd, path); + var cwd = PowerShellScriptParser.ResolveCwd(_manifest.Cwd, path); // Env var expandedEnv = new Dictionary(); @@ -63,7 +60,7 @@ private CommandResult RunNow() ScriptRunner.RunScript( scriptPath: path, args: "", - host: _manifest.Host ?? "pwsh", + host: _manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, cwd: cwd, env: expandedEnv); return CommandResult.ShowToast("Script completed"); @@ -74,11 +71,11 @@ private CommandResult RunNow() var result = ScriptRunner.RunScriptAndWait( scriptPath: path, args: "", - host: _manifest.Host ?? "pwsh", + host: _manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, cwd: cwd, env: expandedEnv, requiresAdmin: wantsAdmin, - timeoutMs: timeout ?? DefaultTimeoutMs); + timeoutMs: timeout ?? PaletteShellSettingsManager.Instance.DefaultTimeoutMs); if (result == null) return CommandResult.ShowToast("Error: Process.Start returned null"); diff --git a/PaletteShellExtension/Forms/NewScriptWizardForm.cs b/PaletteShellExtension/Forms/NewScriptWizardForm.cs index bbe7ecc..9778957 100644 --- a/PaletteShellExtension/Forms/NewScriptWizardForm.cs +++ b/PaletteShellExtension/Forms/NewScriptWizardForm.cs @@ -1,6 +1,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; using PaletteShellExtension.Classes; using System; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -27,6 +28,73 @@ public NewScriptWizardForm(string root) "placeholder": "MyScript", "value": "MyScript" }, + { + "type": "Input.Text", + "id": "description", + "label": "Description (optional)", + "placeholder": "Describe what this script does" + }, + { + "type": "Input.Text", + "id": "group", + "label": "Group", + "value": "General" + }, + { + "type": "Input.Text", + "id": "icon", + "label": "Icon (emoji or glyph)", + "value": "🧩" + }, + { + "type": "Input.ChoiceSet", + "id": "output", + "label": "Output mode", + "style": "compact", + "value": "None", + "choices": [ + { "title": "None — run silently", "value": "None" }, + { "title": "Toast — show output in a notification", "value": "Toast" }, + { "title": "Clipboard — copy output", "value": "Clipboard" }, + { "title": "Markdown — render output as Markdown", "value": "Markdown" }, + { "title": "Result — single copyable result", "value": "Result" }, + { "title": "List — searchable list of items", "value": "List" }, + { "title": "File — open output in editor", "value": "File" } + ] + }, + { + "type": "Input.ChoiceSet", + "id": "host", + "label": "Host", + "style": "compact", + "value": "default", + "choices": [ + { "title": "Use default (from Settings)", "value": "default" }, + { "title": "PowerShell 7 (pwsh)", "value": "pwsh" }, + { "title": "Windows PowerShell 5.1", "value": "powershell" } + ] + }, + { + "type": "Input.Number", + "id": "timeout", + "label": "Timeout (ms) — leave blank to use the default from Settings", + "min": 1000, + "max": 600000 + }, + { + "type": "Input.Toggle", + "id": "elevate", + "title": "Requires administrator rights", + "valueOn": "true", + "valueOff": "false", + "value": "false" + }, + { + "type": "Input.Text", + "id": "confirm", + "label": "Confirmation message (optional — prompts before running)", + "placeholder": "Are you sure you want to run this script?" + }, { "type": "Input.Toggle", "id": "open", @@ -61,10 +129,21 @@ public override CommandResult SubmitForm(string inputs, string data) } var rawName = formInput["name"]?.ToString()?.Trim(); + var name = string.IsNullOrWhiteSpace(rawName) ? "MyScript" : rawName; + + var options = new ScriptOptions( + Description: formInput["description"]?.ToString()?.Trim(), + Group: formInput["group"]?.ToString()?.Trim(), + Icon: formInput["icon"]?.ToString()?.Trim(), + Output: formInput["output"]?.ToString()?.Trim(), + Host: ParseHost(formInput["host"]?.ToString()), + TimeoutMs: ParseTimeout(formInput["timeout"]?.ToString()), + RequiresElevation: (formInput["elevate"]?.ToString() ?? "false").Equals("true", StringComparison.OrdinalIgnoreCase), + ConfirmMessage: formInput["confirm"]?.ToString()?.Trim()); + var open = (formInput["open"]?.ToString() ?? "true").Equals("true", StringComparison.OrdinalIgnoreCase); - var name = string.IsNullOrWhiteSpace(rawName) ? "MyScript" : rawName; - var path = CreateScript(_root, name); + var path = CreateScript(_root, name, options); if (open && path is not null) EditorLauncher.Open(path); @@ -86,7 +165,42 @@ public override CommandResult SubmitForm(string inputs, string data) } } - private static string? CreateScript(string root, string rawName) + // Null means "no override" — the generated script omits [ScriptHost(...)] and falls + // back to the global default host setting at runtime. + private static string? ParseHost(string? raw) + { + var trimmed = raw?.Trim(); + return string.IsNullOrEmpty(trimmed) || string.Equals(trimmed, "default", StringComparison.OrdinalIgnoreCase) + ? null + : trimmed; + } + + // Null means "no override" — the generated script omits [ScriptTimeout(...)] and falls + // back to the global default timeout setting at runtime. + private static int? ParseTimeout(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + const int Min = 1000, Max = 600_000; + if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + return null; + + return Math.Clamp(value, Min, Max); + } + + /// Collected wizard answers that shape the generated attribute block and body. + private readonly record struct ScriptOptions( + string? Description, + string? Group, + string? Icon, + string? Output, + string? Host, + int? TimeoutMs, + bool RequiresElevation, + string? ConfirmMessage); + + private static string? CreateScript(string root, string rawName, ScriptOptions options) { Directory.CreateDirectory(root); @@ -96,7 +210,8 @@ public override CommandResult SubmitForm(string inputs, string data) var full = UniquePath(Path.Combine(root, safe)); var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - File.WriteAllText(full, DefaultHeader(Path.GetFileNameWithoutExtension(full)) + BlankBody(), utf8NoBom); + var content = BuildHeader(Path.GetFileNameWithoutExtension(full), options) + BuildParamBlock() + BuildBody(options.Output); + File.WriteAllText(full, content, utf8NoBom); return full; } private static string Sanitize(string name) @@ -120,35 +235,93 @@ private static string UniquePath(string path) if (!File.Exists(candidate)) return candidate; } } + // ---------- templates (match the sample-script format the parser reads: // comment-based help + [Script*] attributes) ---------- - private static string DefaultHeader(string name) => -$@"using module .\PaletteScriptAttributes.psm1 - -<# -.SYNOPSIS - {name} -.DESCRIPTION - Describe what this script does. -#> -[ScriptHost('pwsh')] -[ScriptGroup('General')] -[ScriptIcon('🧩')] -[ScriptTimeout(20000)] -[ScriptOutput('None')] -[CmdletBinding()] -"; + private static string EscapeSingleQuoted(string value) => value.Replace("'", "''"); + + private static string BuildHeader(string name, ScriptOptions options) + { + var sb = new StringBuilder(); + sb.Append("using module .\\PaletteScriptAttributes.psm1\n\n"); + sb.Append("<#\n.SYNOPSIS\n ").Append(name).Append('\n'); + + var description = string.IsNullOrWhiteSpace(options.Description) + ? "Describe what this script does." + : options.Description; + sb.Append(".DESCRIPTION\n ").Append(description).Append('\n'); + sb.Append("#>\n"); + + // Omitted entirely when the user didn't override it, so the script picks up the + // global default host setting at runtime instead of freezing in today's value. + if (!string.IsNullOrWhiteSpace(options.Host)) + sb.Append(CultureInfo.InvariantCulture, $"[ScriptHost('{options.Host}')]\n"); + + var group = string.IsNullOrWhiteSpace(options.Group) ? "General" : options.Group; + sb.Append(CultureInfo.InvariantCulture, $"[ScriptGroup('{EscapeSingleQuoted(group)}')]\n"); - private static string BlankBody() => + if (!string.IsNullOrWhiteSpace(options.Icon)) + sb.Append(CultureInfo.InvariantCulture, $"[ScriptIcon('{EscapeSingleQuoted(options.Icon)}')]\n"); + + if (options.RequiresElevation) + sb.Append("[RequiresElevation()]\n"); + + if (!string.IsNullOrWhiteSpace(options.ConfirmMessage)) + sb.Append(CultureInfo.InvariantCulture, $"[ConfirmBeforeRun('{EscapeSingleQuoted(options.ConfirmMessage)}')]\n"); + + // Omitted entirely when the user didn't override it, so the script picks up the + // global default timeout setting at runtime instead of freezing in today's value. + if (options.TimeoutMs is { } timeoutMs) + sb.Append(CultureInfo.InvariantCulture, $"[ScriptTimeout({timeoutMs})]\n"); + + var output = string.IsNullOrWhiteSpace(options.Output) ? "None" : options.Output; + sb.Append(CultureInfo.InvariantCulture, $"[ScriptOutput('{output}')]\n"); + + sb.Append("[CmdletBinding()]\n"); + return sb.ToString(); + } + + private static string BuildParamBlock() => @"param( # Add parameters here # [Parameter(Mandatory=$true)] # [string]$Path ) -# --- Script body --- -# Write-Host ""Hello from PaletteShell!"" "; + /// A short, working body matching the chosen output mode, so the scaffold + /// demonstrates the right shape (e.g. Result mode expects a single emitted value) + /// instead of leaving every mode with the same generic placeholder. + private static string BuildBody(string? output) => (string.IsNullOrWhiteSpace(output) ? "None" : output) switch + { + "Result" => + "# Emit just the value; Result mode shows it as a single copyable result.\n" + + "[System.Guid]::NewGuid().ToString()\n", + + "List" => + "# Print newline-delimited items, or a JSON array of objects (title/subtitle/value/url/icon) for richer items.\n" + + "Get-ChildItem -Name\n", + + "Markdown" => + "# Captured stdout is rendered as Markdown on its own page.\n" + + "\"## Report`n`nSomething happened.\"\n", + + "Clipboard" => + "# Whatever stdout writes is copied to the clipboard.\n" + + "\"Copied text\"\n", + + "File" => + "# Captured stdout is written to a temp file and opened in your editor.\n" + + "Get-Process | Select-Object Name, Id, CPU | ConvertTo-Csv -NoTypeInformation\n", + + "Toast" => + "# Captured stdout is shown in a notification.\n" + + "\"Done!\"\n", + + _ => + "# --- Script body ---\n" + + "# Write-Host \"Hello from PaletteShell!\"\n" + }; } diff --git a/PaletteShellExtension/Forms/ScriptParameterForm.cs b/PaletteShellExtension/Forms/ScriptParameterForm.cs index f7723c7..b9a2b24 100644 --- a/PaletteShellExtension/Forms/ScriptParameterForm.cs +++ b/PaletteShellExtension/Forms/ScriptParameterForm.cs @@ -33,7 +33,7 @@ public ScriptParameterForm( _scriptPath = scriptPath; _manifest = manifest; - _host = host ?? "pwsh"; + _host = host ?? PaletteShellSettingsManager.Instance.DefaultHost; _cwd = cwd; _env = env ?? new(StringComparer.OrdinalIgnoreCase); _onMarkdown = onMarkdown; @@ -57,6 +57,20 @@ public override CommandResult SubmitForm(string inputs, string data) var obj = JsonNode.Parse(inputs)?.AsObject(); if (obj is null) return CommandResult.Dismiss(); + // The Adaptive Card's `isRequired` flag is enforced client-side by the host, but + // that isn't guaranteed for every input type or host version — this is the + // server-side backstop so a mandatory parameter can never reach the script empty. + var missing = GetMissingRequiredFields(_manifest, obj); + + if (missing.Count > 0) + { + return CommandResult.ShowToast(new ToastArgs + { + Message = $"Required: {string.Join(", ", missing)}", + Result = CommandResult.KeepOpen() + }); + } + // Build argument list from form values var args = new List(); foreach (var param in _manifest.Parameters) @@ -118,7 +132,7 @@ private CommandResult StartMarkdownRun(string argsLine) string body; try { - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : 30000; + var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; var result = ScriptRunner.RunScriptAndWait( scriptPath: _scriptPath, args: argsLine, @@ -178,7 +192,7 @@ private CommandResult Execute(string argsLine) try { // Run script and wait for completion - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : 30000; + var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; var result = ScriptRunner.RunScriptAndWait( scriptPath: _scriptPath, args: argsLine, @@ -218,6 +232,14 @@ private CommandResult Execute(string argsLine) } } + /// Returns the label/name of each required parameter whose submitted value is + /// empty or whitespace-only. + internal static List GetMissingRequiredFields(ScriptManifest manifest, JsonObject values) => + manifest.Parameters + .Where(p => p.Required == true && string.IsNullOrWhiteSpace(values[p.Name]?.ToString())) + .Select(p => p.Label ?? p.Name) + .ToList(); + private string BuildTemplateJson() { // Build the body as a list of nodes, then materialize via the JsonArray(params) diff --git a/PaletteShellExtension/Forms/ScriptsFolderSetupForm.cs b/PaletteShellExtension/Forms/ScriptsFolderSetupForm.cs new file mode 100644 index 0000000..262fb79 --- /dev/null +++ b/PaletteShellExtension/Forms/ScriptsFolderSetupForm.cs @@ -0,0 +1,73 @@ +using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; +using System; +using System.IO; +using System.Text.Json.Nodes; + +namespace PaletteShellExtension.Forms; + +/// +/// First-run (and re-run, from "Change scripts folder") prompt that collects the folder +/// PaletteShell should scan for .ps1 scripts. Modeled on . +/// +internal sealed partial class ScriptsFolderSetupForm : FormContent +{ + private readonly string _suggestedFolder; + private readonly Action _onConfigured; + + public ScriptsFolderSetupForm(string suggestedFolder, Action onConfigured) + { + _suggestedFolder = suggestedFolder; + _onConfigured = onConfigured; + + var escaped = EscapeJson(suggestedFolder); + TemplateJson = $$""" +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.6", + "body": [ + { "type": "TextBlock", "size": "Medium", "weight": "Bolder", "text": "Choose your scripts folder", "wrap": true }, + { "type": "TextBlock", "text": "PaletteShell reads .ps1 scripts from this folder and creates it if it doesn't exist yet — pick a synced folder if you use PaletteShell on more than one machine.", "wrap": true, "isSubtle": true }, + { + "type": "Input.Text", + "id": "folder", + "label": "Folder path", + "value": "{{escaped}}", + "placeholder": "{{escaped}}" + } + ], + "actions": [ + { "type": "Action.Submit", "title": "Use this folder", "data": { "verb": "use" } } + ] +} +"""; + } + + public override CommandResult SubmitForm(string inputs, string data) + { + var formInput = JsonNode.Parse(inputs)?.AsObject(); + var raw = formInput?["folder"]?.ToString()?.Trim(); + var folder = string.IsNullOrWhiteSpace(raw) + ? _suggestedFolder + : Environment.ExpandEnvironmentVariables(raw); + + try + { + Directory.CreateDirectory(folder); + } + catch (Exception ex) + { + Log.Warn($"Couldn't create scripts folder '{folder}': {ex.Message}"); + return CommandResult.ShowToast($"Couldn't use that folder: {ex.Message}"); + } + + PaletteShellSettingsManager.Instance.ScriptsFolder = folder; + _onConfigured(folder); + + return CommandResult.GoBack(); + } + + private static string EscapeJson(string value) => + value.Replace("\\", "\\\\").Replace("\"", "\\\""); +} diff --git a/PaletteShellExtension/Package.appxmanifest b/PaletteShellExtension/Package.appxmanifest index 767050d..71e062a 100644 --- a/PaletteShellExtension/Package.appxmanifest +++ b/PaletteShellExtension/Package.appxmanifest @@ -11,7 +11,7 @@ + Version="0.0.6.0" /> PaletteShell phireForge diff --git a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs index 81447c6..ac99772 100644 --- a/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs +++ b/PaletteShellExtension/Pages/PaletteShellExtensionPage.cs @@ -13,14 +13,19 @@ using System.Linq; using System.Reflection; using System.Text; +using System.Threading.Tasks; namespace PaletteShellExtension; internal sealed partial class PaletteShellExtensionPage : ListPage { - private readonly string _rootDirectory; - private readonly PinnedScripts _pins; + private static readonly string SuggestedDefaultFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "PaletteShellScripts"); + + private string? _rootDirectory; + private PinnedScripts? _pins; private List _files = []; private IListItem[]? _cachedItems; @@ -30,25 +35,77 @@ public PaletteShellExtensionPage() Title = "PaletteShell"; Name = "PaletteShell"; - _rootDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), - "PaletteShellScripts"); + var configuredFolder = PaletteShellSettingsManager.Instance.ScriptsFolder; + if (configuredFolder is null && Directory.Exists(SuggestedDefaultFolder)) + { + // Upgrading from a version that predates this setting: the well-known default + // folder already exists (with the user's scripts and pins in it), so adopt it + // silently instead of prompting someone who's already set up. + PaletteShellSettingsManager.Instance.ScriptsFolder = SuggestedDefaultFolder; + configuredFolder = SuggestedDefaultFolder; + } - Directory.CreateDirectory(_rootDirectory); - _pins = new PinnedScripts(_rootDirectory); - CopySampleScripts(); - CopyPowerShellModule(); + if (configuredFolder is not null) + { + InitializeFolder(configuredFolder); + } + // Else: genuinely first run, no folder configured and no pre-existing default folder + // — GetItems() prompts for one instead of silently defaulting, and InitializeFolder + // runs once the user picks one. + } + + // Points the page at the given folder, creating it and copying in the sample scripts and + // supporting module/docs, then scanning it. Runs both on normal startup (folder already + // configured) and once the first-run setup form or "Reload scripts" picks up a new folder. + private void InitializeFolder(string folder) + { + _rootDirectory = folder; + Directory.CreateDirectory(folder); + _pins = new PinnedScripts(folder); + CopySampleScripts(folder); RefreshFiles(); + // The module/docs/dll files copied here are never *.ps1 files, so RefreshFiles() + // never picks them up and they can't affect what GetItems() shows — safe to push + // off the constructor's critical path instead of blocking the first render on them. + _ = Task.Run(() => CopyPowerShellModule(folder)); + } + + // Called by the setup form once the user chooses a folder for the first time. + private void HandleFolderConfigured(string folder) + { + InitializeFolder(folder); + RaiseItemsChanged(); } /// Rescans the scripts folder and refreshes the list. Returns the number of - /// .ps1 scripts found so callers (e.g. the Reload command) can confirm completion. + /// .ps1 scripts found so callers (e.g. the Reload command) can confirm completion. + /// Also picks up a folder changed via the settings page since the page was built. public int RefreshFiles() { _cachedItems = null; // Clear cache - var files = Directory.GetFiles(_rootDirectory, "*.ps1", SearchOption.TopDirectoryOnly); + if (_rootDirectory is null) + { + return 0; + } + + // The folder may have been repointed via the settings page since this page was + // built (or since the last reload) — re-derive it here so "Reload scripts" is the + // single, explicit action that picks up a relocation, consistent with how it's + // already the single action that picks up new/edited scripts. + var configuredFolder = PaletteShellSettingsManager.Instance.ScriptsFolder; + if (configuredFolder is not null && !string.Equals(configuredFolder, _rootDirectory, StringComparison.OrdinalIgnoreCase)) + { + _rootDirectory = configuredFolder; + Directory.CreateDirectory(configuredFolder); + _pins = new PinnedScripts(configuredFolder); + CopySampleScripts(configuredFolder); + _ = Task.Run(() => CopyPowerShellModule(configuredFolder)); + } + + var rootDirectory = _rootDirectory; + var files = Directory.GetFiles(rootDirectory, "*.ps1", SearchOption.TopDirectoryOnly); _files = [.. files]; // Use the page's change notification so CmdPal asks for items again. @@ -57,7 +114,7 @@ public int RefreshFiles() return _files.Count; } - private void CopySampleScripts() + private static void CopySampleScripts(string root) { var assembly = Assembly.GetExecutingAssembly(); var resourceNames = assembly.GetManifestResourceNames() @@ -67,7 +124,7 @@ private void CopySampleScripts() foreach (var resourceName in resourceNames) { var fileName = resourceName.Split('.').Reverse().Skip(1).First() + ".ps1"; - var targetPath = Path.Combine(_rootDirectory, fileName); + var targetPath = Path.Combine(root, fileName); // Only copy if the file doesn't exist (don't overwrite user modifications) if (!File.Exists(targetPath)) @@ -82,44 +139,44 @@ private void CopySampleScripts() File.WriteAllText(targetPath, content, new UTF8Encoding(false)); } } - catch (Exception) + catch (Exception ex) { - // Skip scripts that fail to copy. + Log.Warn($"Failed to copy sample script '{fileName}': {ex.Message}"); } } } } - private void CopyPowerShellModule() + private static void CopyPowerShellModule(string root) { var baseDir = AppContext.BaseDirectory; // Copy the PowerShell module var moduleSourcePath = Path.Combine(baseDir, "PaletteScriptAttributes.psm1"); - var moduleTargetPath = Path.Combine(_rootDirectory, "PaletteScriptAttributes.psm1"); + var moduleTargetPath = Path.Combine(root, "PaletteScriptAttributes.psm1"); if (File.Exists(moduleSourcePath)) { try { - File.Copy(moduleSourcePath, moduleTargetPath, overwrite: true); + CopyIfChanged(moduleSourcePath, moduleTargetPath); } - catch (Exception) + catch (Exception ex) { - // Module copy is best-effort. + Log.Warn($"Failed to copy PaletteScriptAttributes.psm1 to scripts folder: {ex.Message}"); } } // Copy the agent-facing authoring spec so tools pointed at the scripts folder discover // the script contract. Best-effort and always refreshed to stay in sync with the extension. var agentsSourcePath = Path.Combine(baseDir, "AGENTS.md"); - var agentsTargetPath = Path.Combine(_rootDirectory, "AGENTS.md"); + var agentsTargetPath = Path.Combine(root, "AGENTS.md"); if (File.Exists(agentsSourcePath)) { try { - File.Copy(agentsSourcePath, agentsTargetPath, overwrite: true); + CopyIfChanged(agentsSourcePath, agentsTargetPath); } catch (Exception) { @@ -129,13 +186,13 @@ private void CopyPowerShellModule() // Copy TextCopy.dll and its dependencies so PowerShell can load it var textCopySource = Path.Combine(baseDir, "TextCopy.dll"); - var textCopyTarget = Path.Combine(_rootDirectory, "TextCopy.dll"); + var textCopyTarget = Path.Combine(root, "TextCopy.dll"); if (File.Exists(textCopySource)) { try { - File.Copy(textCopySource, textCopyTarget, overwrite: true); + CopyIfChanged(textCopySource, textCopyTarget); } catch (Exception) { @@ -144,27 +201,64 @@ private void CopyPowerShellModule() } } + // Skips the copy when the target already matches the source (same size and write time), + // so a launch that changes nothing doesn't pay for redundant disk writes. + private static void CopyIfChanged(string source, string target) + { + if (File.Exists(target)) + { + var sourceInfo = new FileInfo(source); + var targetInfo = new FileInfo(target); + if (sourceInfo.Length == targetInfo.Length && sourceInfo.LastWriteTimeUtc == targetInfo.LastWriteTimeUtc) + { + return; + } + } + + File.Copy(source, target, overwrite: true); + } + public override IListItem[] GetItems() { + if (_rootDirectory is not { } rootDirectory || _pins is not { } pins) + { + // First run - no folder configured yet. Prompt for one instead of silently + // defaulting; everything else (samples, module, script scan) waits for it. + return + [ + new ListItem(new ScriptsFolderSetupPage(SuggestedDefaultFolder, HandleFolderConfigured)) + { + Title = "Choose scripts folder", + Subtitle = "Pick where PaletteShell should look for your .ps1 scripts", + }, + ]; + } + if (_cachedItems != null) { return _cachedItems; } List items = [ - new ListItem(new OpenFolderCommand(_rootDirectory)) { Title = "Open scripts folder" }, + new ListItem(new OpenFolderCommand(rootDirectory)) { Title = "Open scripts folder" }, new ListItem(new ReloadPageCommand(this)) { Title = "Reload scripts" }, - new ListItem(new NewScriptWizardPage(_rootDirectory)) { Title = "Create new script", Subtitle = "Add a scaffolded .ps1 with metadata headers" }, + new ListItem(new NewScriptWizardPage(rootDirectory)) { Title = "Create new script", Subtitle = "Add a scaffolded .ps1 with metadata headers" }, new ListItem(new OpenLinkCommand("Find more scripts", "https://github.com/paletteshell/PaletteShellScripts", "")) { Title = "Find more scripts", Subtitle = "Browse the PaletteShellScripts repository on GitHub" }, ]; // Script items are sorted below: pinned scripts first, then alphabetically by their // displayed Title (not by filename, since the manifest Title often differs from it). // The Pinned flag is captured per item so the sort doesn't have to re-read the store. - List<(bool Pinned, string Title, IListItem Item)> scriptItems = []; - - foreach (var path in _files) + // + // Parsing each script is independent (PowerShellScriptParser is stateless, and _pins + // is only read here, never mutated concurrently), so this runs in parallel and writes + // into a slot per index rather than a shared List.Add. Sorting below is unaffected + // by completion order, so results are identical to the sequential version. + var scriptResults = new (bool Pinned, string Title, IListItem Item)?[_files.Count]; + + Parallel.For(0, _files.Count, i => { + var path = _files[i]; try { var manifest = PowerShellScriptParser.TryParseManifest(path); @@ -182,24 +276,24 @@ public override IListItem[] GetItems() // stdout into a searchable, pickable list. If the script declares a // parameter, that page feeds it the palette's search text (it acts as a // live provider) rather than using the parameter form. - var resolvedCwd = PowerShellScriptParser.ExpandPathTokens(manifest.Cwd, path); + var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); command = new ScriptListPage( scriptPath: path, manifest: manifest, - host: manifest.Host ?? "pwsh", + host: manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, cwd: resolvedCwd, env: manifest.Env); } else if (manifest?.Parameters is { Count: > 0 }) { // Script has parameters - navigate to parameter form page - var resolvedCwd = PowerShellScriptParser.ExpandPathTokens(manifest.Cwd, path); + var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); var formPage = new ScriptParameterFormPage( scriptPath: path, manifest: manifest, - host: manifest.Host ?? "pwsh", + host: manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, cwd: resolvedCwd, env: manifest.Env ); @@ -210,12 +304,12 @@ public override IListItem[] GetItems() { // No parameters, Markdown output - navigate to a page that runs // the script and renders its stdout as Markdown. - var resolvedCwd = PowerShellScriptParser.ExpandPathTokens(manifest.Cwd, path); + var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); command = new ScriptMarkdownPage( scriptPath: path, manifest: manifest, - host: manifest.Host ?? "pwsh", + host: manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, cwd: resolvedCwd, env: manifest.Env); } @@ -224,12 +318,12 @@ public override IListItem[] GetItems() // No parameters, Result output - navigate to a page that runs the script // and shows its output as a single copyable result (Enter copies), the way // a calculator shows an answer. - var resolvedCwd = PowerShellScriptParser.ExpandPathTokens(manifest.Cwd, path); + var resolvedCwd = PowerShellScriptParser.ResolveCwd(manifest.Cwd, path); command = new ScriptResultPage( scriptPath: path, manifest: manifest, - host: manifest.Host ?? "pwsh", + host: manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost, cwd: resolvedCwd, env: manifest.Env); } @@ -241,7 +335,7 @@ public override IListItem[] GetItems() var group = manifest?.Group; - var pinned = _pins.IsPinned(path); + var pinned = pins.IsPinned(path); var listItem = new ListItem(command) { @@ -251,32 +345,34 @@ public override IListItem[] GetItems() ? new IconInfo(manifest.IconGlyph) : DefaultScriptIcon, Tags = BuildTags(pinned, group), - MoreCommands = BuildContextCommands(path) + MoreCommands = BuildContextCommands(path, pins) }; - scriptItems.Add((pinned, title, listItem)); + scriptResults[i] = (pinned, title, listItem); } - catch (Exception) + catch (Exception ex) { // Building the rich entry failed (e.g. a malformed parameter block). Rather than // dropping the script silently, surface it with an error hint so the user can // find it, open it to fix, or remove it — instead of wondering where it went. - var pinned = _pins.IsPinned(path); + Log.Warn($"Failed to build list item for '{path}': {ex.Message}"); + var pinned = pins.IsPinned(path); var errorTitle = Path.GetFileNameWithoutExtension(path); - scriptItems.Add((pinned, errorTitle, new ListItem(new OpenInEditorCommand(path)) + scriptResults[i] = (pinned, errorTitle, new ListItem(new OpenInEditorCommand(path)) { Title = errorTitle, Subtitle = $"⚠ Couldn't load this script — open to inspect ({Path.GetFileName(path)})", Icon = new IconInfo(""), // Warning Tags = BuildTags(pinned, null), - MoreCommands = BuildContextCommands(path) - })); + MoreCommands = BuildContextCommands(path, pins) + }); } - } + }); // Keep the system commands at the very top; then pinned scripts, then the rest — // each group alphabetical by title. - items.AddRange(scriptItems + items.AddRange(scriptResults + .Select(r => r!.Value) .OrderByDescending(i => i.Pinned) .ThenBy(i => i.Title, StringComparer.CurrentCultureIgnoreCase) .Select(i => i.Item)); @@ -290,9 +386,9 @@ public override IListItem[] GetItems() private static readonly IconInfo DefaultScriptIcon = new(""); // CommandPrompt // Per-script context menu shared by normal and error entries: pin, open, reveal, delete. - private CommandContextItem[] BuildContextCommands(string path) => + private CommandContextItem[] BuildContextCommands(string path, PinnedScripts pins) => [ - new CommandContextItem(new TogglePinCommand(path, _pins, () => RefreshFiles())), + new CommandContextItem(new TogglePinCommand(path, pins, () => RefreshFiles())), new CommandContextItem(new OpenInEditorCommand(path)), new CommandContextItem(new RevealInExplorerCommand(path)), new CommandContextItem(new DeleteScriptCommand(path, () => RefreshFiles())), diff --git a/PaletteShellExtension/Pages/ScriptListPage.cs b/PaletteShellExtension/Pages/ScriptListPage.cs index b3a1e61..120f7b6 100644 --- a/PaletteShellExtension/Pages/ScriptListPage.cs +++ b/PaletteShellExtension/Pages/ScriptListPage.cs @@ -62,7 +62,7 @@ public ScriptListPage( { _scriptPath = scriptPath; _manifest = manifest; - _host = host ?? manifest.Host ?? "pwsh"; + _host = host ?? manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost; _cwd = cwd; _env = env ?? new(StringComparer.OrdinalIgnoreCase); _queryParam = manifest.Parameters.FirstOrDefault()?.Name; @@ -148,7 +148,7 @@ private void Execute(string? query, int? version) { var args = BuildArgs(query); - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : 30000; + var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; // Elevated scripts can't have their output captured, so List mode always // runs unelevated — there'd be nothing to list otherwise. diff --git a/PaletteShellExtension/Pages/ScriptMarkdownPage.cs b/PaletteShellExtension/Pages/ScriptMarkdownPage.cs index 9922b1c..0f1392f 100644 --- a/PaletteShellExtension/Pages/ScriptMarkdownPage.cs +++ b/PaletteShellExtension/Pages/ScriptMarkdownPage.cs @@ -34,7 +34,7 @@ public ScriptMarkdownPage( { _scriptPath = scriptPath; _manifest = manifest; - _host = host ?? manifest.Host ?? "pwsh"; + _host = host ?? manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost; _cwd = cwd; _env = env ?? new(StringComparer.OrdinalIgnoreCase); _args = args; @@ -68,7 +68,7 @@ private void RunAndRender() { try { - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : 30000; + var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; // Elevated scripts can't have their output captured, so Markdown mode // always runs unelevated to be able to render the result. diff --git a/PaletteShellExtension/Pages/ScriptParameterFormPage.cs b/PaletteShellExtension/Pages/ScriptParameterFormPage.cs index 377f43f..a266447 100644 --- a/PaletteShellExtension/Pages/ScriptParameterFormPage.cs +++ b/PaletteShellExtension/Pages/ScriptParameterFormPage.cs @@ -42,7 +42,7 @@ public ScriptParameterFormPage( _content = [_form]; Title = manifest.Title ?? "Run Script"; - Name = "script-params"; + Name = "Enter Parameters"; Icon = new(manifest.IconGlyph ?? ""); Id = $"ScriptParams_{System.IO.Path.GetFileNameWithoutExtension(scriptPath)}"; } diff --git a/PaletteShellExtension/Pages/ScriptResultPage.cs b/PaletteShellExtension/Pages/ScriptResultPage.cs index 8f62e6d..d111db4 100644 --- a/PaletteShellExtension/Pages/ScriptResultPage.cs +++ b/PaletteShellExtension/Pages/ScriptResultPage.cs @@ -41,7 +41,7 @@ public ScriptResultPage( { _scriptPath = scriptPath; _manifest = manifest; - _host = host ?? manifest.Host ?? "pwsh"; + _host = host ?? manifest.Host ?? PaletteShellSettingsManager.Instance.DefaultHost; _cwd = cwd; _env = env ?? new(StringComparer.OrdinalIgnoreCase); @@ -82,7 +82,7 @@ private void Execute() { try { - var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : 30000; + var timeout = _manifest.TimeoutMs is > 0 ? _manifest.TimeoutMs!.Value : PaletteShellSettingsManager.Instance.DefaultTimeoutMs; // Elevated scripts can't have their output captured, so Result mode always runs // unelevated — there'd be no value to show otherwise. diff --git a/PaletteShellExtension/Pages/ScriptsFolderSetupPage.cs b/PaletteShellExtension/Pages/ScriptsFolderSetupPage.cs new file mode 100644 index 0000000..c7e9f2b --- /dev/null +++ b/PaletteShellExtension/Pages/ScriptsFolderSetupPage.cs @@ -0,0 +1,22 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Forms; +using System; + +namespace PaletteShellExtension.Pages; + +internal sealed partial class ScriptsFolderSetupPage : ContentPage +{ + private readonly ScriptsFolderSetupForm _form; + + public ScriptsFolderSetupPage(string suggestedFolder, Action onConfigured) + { + Title = "Set up PaletteShell"; + Name = "setup"; + Icon = new(""); // Folder, matches OpenFolderCommand's icon + Id = "ScriptsFolderSetup"; + _form = new(suggestedFolder, onConfigured); + } + + public override IContent[] GetContent() => [_form]; +} diff --git a/PaletteShellExtension/PaletteShellExtension.csproj b/PaletteShellExtension/PaletteShellExtension.csproj index 0661dba..72a40fe 100644 --- a/PaletteShellExtension/PaletteShellExtension.csproj +++ b/PaletteShellExtension/PaletteShellExtension.csproj @@ -16,7 +16,7 @@ phireForge.PaletteShell phireForge - 0.0.5.0 + 0.0.6.0 - - - - - - + + + + + + diff --git a/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs b/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs index 4cf7e54..c2e794f 100644 --- a/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs +++ b/PaletteShellExtension/PaletteShellExtensionCommandsProvider.cs @@ -4,6 +4,7 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using PaletteShellExtension.Classes; namespace PaletteShellExtension; @@ -15,6 +16,7 @@ public PaletteShellExtensionCommandsProvider() { DisplayName = "PaletteShell"; Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + Settings = PaletteShellSettingsManager.Instance.Settings; _commands = [ new CommandItem(new PaletteShellExtensionPage()) { Title = DisplayName, Subtitle = "Run your PowerShell scripts" }, ]; diff --git a/PaletteShellExtension/PowerShellScriptParser.cs b/PaletteShellExtension/PowerShellScriptParser.cs index 8f79f22..fda60c1 100644 --- a/PaletteShellExtension/PowerShellScriptParser.cs +++ b/PaletteShellExtension/PowerShellScriptParser.cs @@ -94,8 +94,9 @@ internal static partial class PowerShellScriptParser return manifest; } - catch (Exception) + catch (Exception ex) { + Log.Warn($"Failed to parse manifest for '{ps1Path}': {ex.Message}"); return null; } } @@ -116,6 +117,55 @@ internal static partial class PowerShellScriptParser .Replace("{Temp}", temp); } + /// + /// Expands path tokens in a [ScriptCwd(...)] value and verifies the result exists. + /// Unlike (also used for [ScriptEnv(...)] values, + /// which aren't necessarily directory paths), this is cwd-specific: a nonexistent working + /// directory would otherwise reach Process.Start and fail the whole run, so this + /// falls back to null (the process's default working directory) instead. + /// + public static string? ResolveCwd(string? cwd, string scriptPath) + { + var expanded = ExpandPathTokens(cwd, scriptPath); + if (string.IsNullOrWhiteSpace(expanded)) + { + return expanded; + } + + if (!Directory.Exists(expanded)) + { + Log.Warn($"ScriptCwd '{expanded}' does not exist for '{scriptPath}'; using the default working directory instead"); + return null; + } + + return expanded; + } + + private const int MinTimeoutMs = 1000; + private const int MaxTimeoutMs = 600_000; // 10 minutes + + /// + /// Rejects a timeout too small to be meaningful (and negative values, which would throw + /// from Process.WaitForExit) and clamps one that's unreasonably large, so a typo'd + /// [ScriptTimeout(...)] can't hang a run indefinitely or fail it instantly. + /// + private static int? ValidateTimeout(int timeoutMs) + { + if (timeoutMs < MinTimeoutMs) + { + Log.Warn($"Ignoring ScriptTimeout({timeoutMs}) — must be at least {MinTimeoutMs}ms"); + return null; + } + + if (timeoutMs > MaxTimeoutMs) + { + Log.Warn($"Clamping ScriptTimeout({timeoutMs}) to the {MaxTimeoutMs}ms maximum"); + return MaxTimeoutMs; + } + + return timeoutMs; + } + // ----- Comment-based help ----------------------------------------------------------- private sealed class HelpInfo @@ -226,7 +276,7 @@ private static void ParseScriptAttributes(string zone, ScriptManifest manifest) : "Are you sure you want to run this script?"; break; case "ScriptTimeout" when values.Count >= 1 && int.TryParse(values[0], out var timeout): - manifest.TimeoutMs = timeout; + manifest.TimeoutMs = ValidateTimeout(timeout); break; case "ScriptOutput" when values.Count >= 1: // The mode may carry an extension hint for File mode after a colon, @@ -247,7 +297,18 @@ private static void ParseScriptAttributes(string zone, ScriptManifest manifest) manifest.Group = values[0]; break; case "ScriptIcon" when values.Count >= 1: - manifest.IconGlyph = values[0]; + if (string.IsNullOrEmpty(values[0])) + { + // Explicit empty glyph is a no-op, same as omitting the attribute. + } + else if (IsValidIconGlyph(values[0])) + { + manifest.IconGlyph = values[0]; + } + else + { + Log.Warn($"Ignoring invalid ScriptIcon value {EscapeForLog(values[0])} (expected a single glyph)"); + } break; case "ScriptEnv" when values.Count >= 2: manifest.Env[values[0]] = values[1]; @@ -654,6 +715,40 @@ private static (string Name, string? Args) SplitNameArgs(string group) return (name, args); } + /// + /// True when looks like a single icon glyph rather than a word + /// or sentence someone mistakenly passed to [ScriptIcon(...)]. Accepts both a lone + /// Segoe Fluent/MDL2 PUA codepoint and a multi-codepoint emoji sequence (skin tone + /// modifiers, ZWJ joins, flags), since those combine into a single rendered glyph even + /// though they span several chars. + /// + private static bool IsValidIconGlyph(string glyph) + { + if (string.IsNullOrEmpty(glyph) || glyph.Length > 32) + { + return false; + } + + foreach (var c in glyph) + { + if (char.IsControl(c) || char.IsWhiteSpace(c)) + { + return false; + } + } + + return new StringInfo(glyph).LengthInTextElements == 1; + } + + /// Renders a string safely for a log line: escapes control characters and caps length. + private static string EscapeForLog(string value) + { + const int max = 40; + var truncated = value.Length > max ? value[..max] + "…" : value; + return "'" + truncated.Replace("\\", "\\\\").Replace("'", "\\'") + .Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t") + "'"; + } + /// Trims one matching pair of surrounding quotes and unescapes doubled quotes. private static string StripQuotes(string value) { diff --git a/README.md b/README.md index 0626b3c..7923d26 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ - **🚀 Quick Access**: Run PowerShell scripts directly from the Windows Command Palette - **📋 Clipboard Utilities**: Transform and manipulate clipboard text with one keystroke -- **🔧 Customizable**: Drop your own `.ps1` files into a folder and they show up automatically - **📝 Parameter Support**: Scripts with parameters get an interactive input form, generated from the script's own `param()` block - **🎨 Rich Metadata**: Organize scripts with icons, descriptions, groups, and tags via PowerShell attributes - **📄 Markdown Output**: Render a script's output as formatted Markdown inside the palette @@ -23,12 +22,14 @@ - **⚡ Cross-Platform PowerShell**: Supports both PowerShell Core (`pwsh`) and Windows PowerShell (`powershell`) - **🤖 AI-Ready**: Ships an `AGENTS.md` authoring spec into your scripts folder so AI coding agents can write compliant scripts for you on the fly - **🔒 Security**: Runs in user context with optional admin elevation and an optional confirmation prompt per script +- **📁 Configurable Scripts Folder**: Choose where your scripts live the first time you open PaletteShell (or change it later from Settings) instead of being locked to one fixed folder +- **⚙️ Settings**: Set a default script host, default timeout, and preferred editor from PaletteShell's built-in Settings page ## 📖 Overview PaletteShell turns a folder of PowerShell scripts into searchable, runnable commands inside the Windows Command Palette. Each `.ps1` file becomes a list item: PaletteShell reads metadata out of the script (its synopsis, description, icon, parameters, and behavior attributes) and presents it with a friendly title and subtitle. Selecting an item either runs the script immediately or — if the script declares parameters — opens a form to collect input first. -Scripts live in **`Documents\PaletteShellScripts`**. This folder is created automatically the first time the extension loads, and a set of ready-to-use sample scripts plus the supporting `PaletteScriptAttributes.psm1` module are copied in for you. Add, edit, or remove files in that folder at any time; use **"Reload scripts"** in the palette to pick up changes. +The first time you open PaletteShell it asks which folder to use for your scripts, suggesting **`Documents\PaletteShellScripts`**. Accept the suggestion or pick your own (e.g. a folder synced across machines); PaletteShell creates it and copies in a set of ready-to-use sample scripts plus the supporting `PaletteScriptAttributes.psm1` module. If you already used PaletteShell before this prompt existed, your existing `Documents\PaletteShellScripts` folder is picked up automatically — no prompt, nothing to redo. Add, edit, or remove files in your scripts folder at any time; use **"Reload scripts"** in the palette to pick up changes (this also picks up a folder you've changed from [Settings](#-settings)). ## ⚙️ How It Works @@ -36,14 +37,15 @@ Scripts live in **`Documents\PaletteShellScripts`**. This folder is created auto When the extension is activated, `PaletteShellExtensionPage` does the following: -1. Creates the `Documents\PaletteShellScripts` directory if it doesn't exist. -2. Copies the embedded sample scripts (only files that aren't already there, so your edits are never overwritten). -3. Copies the `PaletteScriptAttributes.psm1` module and `TextCopy.dll` next to the scripts so they're available at runtime. -4. Enumerates every `*.ps1` file in the folder (top level only) and builds the command list. +1. Resolves the scripts folder: the one saved in [Settings](#-settings), or — for an install that predates that setting — the existing `Documents\PaletteShellScripts` if it's already there. If neither exists yet, the page shows a **"Choose scripts folder"** prompt (suggesting `Documents\PaletteShellScripts`) instead of a script list, and the rest of this flow runs once you submit it. +2. Creates that directory if it doesn't exist. +3. Copies the embedded sample scripts (only files that aren't already there, so your edits are never overwritten). +4. Copies the `PaletteScriptAttributes.psm1` module and `TextCopy.dll` next to the scripts so they're available at runtime. +5. Enumerates every `*.ps1` file in the folder (top level only) and builds the command list. The list always begins with four built-in actions: -- **Open scripts folder** — opens `Documents\PaletteShellScripts` in Explorer. +- **Open scripts folder** — opens your configured scripts folder in Explorer. - **Reload scripts** — re-scans the folder so new or changed scripts appear. - **Create new script** — opens a guided wizard that scaffolds a new `.ps1` with metadata headers. - **Find more scripts** — opens the community [PaletteShellScripts](https://github.com/paletteshell/PaletteShellScripts) repository in your browser. @@ -93,7 +95,7 @@ Whether PaletteShell waits for the script depends on its output mode and timeout | Condition | Behavior | |-----------|----------| | `[ScriptOutput('None')]` and no `[ScriptTimeout]` | Fire-and-forget — the process is started and a "Script completed" toast is shown. | -| Any other output mode (`Toast`/`Clipboard`/`Markdown`/`Result`/`List`/`File`) | PaletteShell waits (up to the declared timeout, or a 30s default), captures stdout/stderr, and surfaces the result. | +| Any other output mode (`Toast`/`Clipboard`/`Markdown`/`Result`/`List`/`File`) | PaletteShell waits (up to the declared timeout, or the [default timeout setting](#-settings), 30s unless changed), captures stdout/stderr, and surfaces the result. | | `[ScriptTimeout(ms)]` set | PaletteShell waits up to `ms`, then kills the process tree on timeout. | | `[ScriptOutput('Clipboard')]` | Captured output is copied to the clipboard. | | `[ScriptOutput('Markdown')]` | Captured output is rendered as Markdown on its own page. | @@ -109,18 +111,32 @@ The bundled `PaletteScriptAttributes.psm1` module exposes `Get-ClipboardText` / ### Pinning -Use a script item's **Pin to top** context command to keep it above the rest of the list; **Unpin** puts it back. Pinning is a per-user UI preference, so it lives outside the `.ps1` files — toggling a pin never rewrites your script. The pinned set is stored in a plain `pinned.txt` alongside your scripts (one entry per line, keyed by the script's path relative to the scripts folder), so it survives reloads and restarts and travels with the folder if you sync or copy it. Renaming or moving a script simply orphans its old pin, which is harmless. +Use a script item's **Pin to top** context command to keep it above the rest of the list; **Unpin** puts it back. Pinning is a per-user UI preference, so it lives outside the `.ps1` files — toggling a pin never rewrites your script. The pinned set is stored in a plain `pinned.txt` alongside your scripts (one entry per line, keyed by the script's path relative to the scripts folder), so it survives reloads and restarts and travels with the folder if you sync or copy it. Renaming or moving a script simply orphans its old pin, which is harmless. Pointing PaletteShell at a **different** scripts folder (see [Settings](#-settings)) works the same way: it's a fresh folder, so it starts with nothing pinned — just re-pin what you need there. + +## ⚙️ Settings + +PaletteShell's top-level command has a built-in **Settings** page (the gear icon next to it in the Command Palette) with: + +| Setting | Purpose | +|---------|---------| +| **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(...)]`. | +| **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. | + +These are extension-wide defaults, not per-script overrides — a script's own `[ScriptHost(...)]`/`[ScriptTimeout(...)]` attributes always win when present. The **"Create new script"** wizard's Host and Timeout fields default to **"Use default (from Settings)"**, so scaffolded scripts pick up whatever you've configured here instead of freezing in a fixed value, unless you explicitly choose otherwise in the wizard. ## 🚀 Getting Started 1. Install the extension (from the Microsoft Store, or by building and deploying the MSIX package — see [Building](#-building-from-source)). 2. Open the Command Palette and type **PaletteShell**. -3. Browse the bundled sample scripts, choose **Create new script** to scaffold your own, or **Find more scripts** to grab one from the [community repo](https://github.com/paletteshell/PaletteShellScripts). -4. Edit your scripts in `Documents\PaletteShellScripts` and run **Reload scripts** to see changes. +3. The first time, choose a scripts folder (or accept the suggested `Documents\PaletteShellScripts`). +4. Browse the bundled sample scripts, choose **Create new script** to scaffold your own, or **Find more scripts** to grab one from the [community repo](https://github.com/paletteshell/PaletteShellScripts). +5. Edit your scripts in your scripts folder and run **Reload scripts** to see changes. ## ✍️ Creating Your Own Scripts -You can use the in-palette **Create new script** wizard, or simply drop a `.ps1` file into `Documents\PaletteShellScripts`. Scripts use PowerShell attributes for metadata. A typical script looks like: +You can use the in-palette **Create new script** wizard, or simply drop a `.ps1` file into your scripts folder (`Documents\PaletteShellScripts` by default — see [Settings](#-settings)). Scripts use PowerShell attributes for metadata. A typical script looks like: ```powershell using module .\PaletteScriptAttributes.psm1 @@ -152,7 +168,7 @@ Set-ClipboardText $result ### 🤖 Let an AI agent write scripts for you -PaletteShell ships an [`AGENTS.md`](AGENTS.md) authoring spec and copies it into your `Documents\PaletteShellScripts` folder (kept in sync on every load, right next to `PaletteScriptAttributes.psm1`). It's the full contract for a valid script — the required file shape, every recognized `[Script*]` attribute, output modes, parameter-to-form mapping, and a pre-finish checklist. +PaletteShell ships an [`AGENTS.md`](AGENTS.md) authoring spec and copies it into your scripts folder (kept in sync on every load, right next to `PaletteScriptAttributes.psm1`). It's the full contract for a valid script — the required file shape, every recognized `[Script*]` attribute, output modes, parameter-to-form mapping, and a pre-finish checklist. Because it lives alongside your scripts, any AI coding agent you point at that folder — Claude Code, Copilot, Cursor, and others that read `AGENTS.md` — discovers the rules automatically and can scaffold new `.ps1` scripts on the fly that PaletteShell loads and runs correctly. Just ask: @@ -164,11 +180,11 @@ The agent reads `AGENTS.md`, produces a compliant script in the folder, and you | Attribute | Purpose | |-----------|---------| -| `[ScriptHost('pwsh')]` | Host to run under: `'pwsh'` (default) or `'powershell'` | +| `[ScriptHost('pwsh')]` | Host to run under: `'pwsh'` or `'powershell'`. Omit it to use the [default script host setting](#-settings) | | `[ScriptCwd('{ScriptDir}')]` | Working directory (supports path tokens, below) | | `[RequiresElevation()]` | Run the script with administrator rights | | `[ConfirmBeforeRun('message')]` | Prompt a yes/no confirmation (with `message`) before running | -| `[ScriptTimeout(30000)]` | Timeout in milliseconds; also forces wait-and-capture | +| `[ScriptTimeout(30000)]` | Timeout in milliseconds; also forces wait-and-capture. Omit it to use the [default timeout setting](#-settings) | | `[ScriptGroup('Category')]` | Group/category name (shown as a tag) | | `[ScriptIcon('🚀')]` | Icon emoji or glyph shown in the palette | | `[ScriptOutput('None')]` | Output mode (see below) | @@ -316,7 +332,8 @@ For more, browse the community library at **[paletteshell/PaletteShellScripts](h ## 🛠️ Building from Source -PaletteShell is a .NET 9 Windows app packaged as an MSIX Command Palette extension. +PaletteShell is a .NET 9 Windows app packaged as an MSIX Command Palette extension. For running +tests, sideloading, and debugging the extension itself, see [CONTRIBUTING.md](CONTRIBUTING.md). **Requirements** @@ -344,7 +361,9 @@ dotnet build PaletteShellExtension/PaletteShellExtension.csproj | `Classes/ScriptStatus.cs` | Shows the "Running…" spinner in the status bar while a script runs | | `Classes/PinnedScripts.cs` | Tracks pinned scripts (persisted to `pinned.txt`) so they sort to the top | | `Classes/RecycleBin.cs` | Sends a deleted script to the Windows Recycle Bin via `SHFileOperation` | -| `Classes/EditorLauncher.cs` | Opens a script in `$VISUAL`/`$EDITOR` (Notepad fallback) | +| `Classes/EditorLauncher.cs` | Opens a script in the preferred editor setting, `$VISUAL`/`$EDITOR`, or Notepad | +| `Classes/PaletteShellSettingsManager.cs` | Backs the Settings page (scripts folder, default host, default timeout, preferred editor) and persists it to `settings.json` | +| `Pages/ScriptsFolderSetupPage.cs`, `Forms/ScriptsFolderSetupForm.cs` | First-run (and re-run) prompt that collects the scripts folder | | `Commands/RunScriptCommand.cs` | Runs a parameterless script and handles output/clipboard/toast/confirmation | | `Commands/CallbackCommand.cs` | Wraps a callback as a command — the confirmed action behind a confirmation dialog | | `Commands/TogglePinCommand.cs` | Pins/unpins a script and refreshes the list | @@ -362,11 +381,11 @@ dotnet build PaletteShellExtension/PaletteShellExtension.csproj ## 🤝 Community Scripts -The **[PaletteShellScripts](https://github.com/paletteshell/PaletteShellScripts)** repository is a growing, community-maintained collection of scripts ready to drop into your `Documents\PaletteShellScripts` folder. Grab the ones you find useful, or contribute your own. You can open it any time from the palette's **"Find more scripts"** command. +The **[PaletteShellScripts](https://github.com/paletteshell/PaletteShellScripts)** repository is a growing, community-maintained collection of scripts ready to drop into your scripts folder. Grab the ones you find useful, or contribute your own. You can open it any time from the palette's **"Find more scripts"** command. ## Need Help? -Check out the bundled sample scripts (in `Documents\PaletteShellScripts` after first run) and the [community repo](https://github.com/paletteshell/PaletteShellScripts) for examples of common patterns and best practices. +Check out the bundled sample scripts (in your scripts folder after first run) and the [community repo](https://github.com/paletteshell/PaletteShellScripts) for examples of common patterns and best practices. ## License