From b05d749364438f0e1a05caaf196ecec1823bc696 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 06:34:40 -0700 Subject: [PATCH 1/7] Package Raycast command suggestions --- .gitignore | 1 + QuickShell.Raycast/README.md | 4 +- .../src/__tests__/suggest-commands.test.ts | 8 +++ .../components/discover-git-repos-view.tsx | 4 +- .../src/components/workspace-form.tsx | 18 ++++-- .../src/lib/suggest-commands.ts | 10 +-- docs/architecture/intelligence.md | 2 +- scripts/RaycastLifecycle.ps1 | 11 ++++ scripts/build-raycast-extension.ps1 | 8 +++ scripts/build-raycast-suggest.ps1 | 64 +++++++++++++++++++ 10 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 scripts/build-raycast-suggest.ps1 diff --git a/.gitignore b/.gitignore index 281aaa88..331480ab 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ ltm/reports/* ltm/snapshots/* # --- /ltm-power --- QuickShell.Raycast/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +/QuickShell.Raycast/assets/QuickShell.Suggest.exe debug-a49e01.log .vscode/settings.json /.workflow diff --git a/QuickShell.Raycast/README.md b/QuickShell.Raycast/README.md index 6ad83c43..1213ead8 100644 --- a/QuickShell.Raycast/README.md +++ b/QuickShell.Raycast/README.md @@ -15,7 +15,7 @@ Create, Discover, and Edit are Actions / pushed views inside Quick Shell (not se ## Requirements - **Raycast** for Windows or macOS -- **Node.js 22.14+** (development only) +- **Node.js 22.14+** and the **.NET 10 SDK** (development only) - Windows: Windows Terminal, PowerShell, or WSL - macOS: Terminal.app and/or iTerm2 (multi-launch can open as tabs in one window) @@ -62,6 +62,8 @@ npm run dev Run `npm run build` before submitting Store changes. Do not add a `version` field to `package.json`; Store versioning uses `CHANGELOG.md`. +On Windows, `scripts/deploy-all.ps1` and `scripts/build-raycast-extension.ps1` publish the Core-backed suggestion CLI into Raycast assets automatically. The packaged CLI uses the .NET 10 Desktop Runtime; macOS continues to use local folder heuristics. + **Distribution:** publish via the [Raycast Store](https://www.raycast.com/store) only. GitHub Releases and WinGet do not ship Raycast sideload packages. `scripts/build-raycast-extension.ps1` is for local/dev packaging. ## Store checklist diff --git a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts index f3e30f94..0af6b0bf 100644 --- a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts +++ b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts @@ -1,7 +1,9 @@ +import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildSuggestCommandArgs, pillsToSetupTasks, + resolveSuggestExecutable, splitPillsIntoSeedAndLeftover, type SuggestionPill, } from "../lib/suggest-commands"; @@ -16,6 +18,12 @@ function pill(partial: Partial & Pick { + it("resolves the packaged CLI from Raycast assets", () => { + expect(resolveSuggestExecutable("C:\\Raycast\\assets")).toBe( + path.join("C:\\Raycast\\assets", "QuickShell.Suggest.exe"), + ); + }); + it("builds suggest CLI args with used commands", () => { expect(buildSuggestCommandArgs("C:\\Projects\\app", ["npm run dev", " ", "dotnet watch"], 42)).toEqual([ "suggest", diff --git a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx index 897e63ab..6e44e992 100644 --- a/QuickShell.Raycast/src/components/discover-git-repos-view.tsx +++ b/QuickShell.Raycast/src/components/discover-git-repos-view.tsx @@ -1,4 +1,4 @@ -import { Action, ActionPanel, Icon, List, showToast, Toast, useNavigation } from "@raycast/api"; +import { Action, ActionPanel, environment, Icon, List, showToast, Toast, useNavigation } from "@raycast/api"; import { usePromise } from "@raycast/utils"; import { useEffect, useMemo, useRef, useState } from "react"; import WorkspaceForm from "./workspace-form"; @@ -28,7 +28,7 @@ type ReviewWorkspaceFormProps = { /** Full seed for every repository selected from Discover Git Repos. */ async function buildWorkspaceFromRepo(directory: string, name: string, remoteUrl?: string | null): Promise { - const resolved = await resolveWorkspaceSetupSuggestions(directory); + const resolved = await resolveWorkspaceSetupSuggestions(directory, [], Date.now(), environment.assetsPath); return createWorkspaceFromDiscoveredGitRepo({ directory, name, diff --git a/QuickShell.Raycast/src/components/workspace-form.tsx b/QuickShell.Raycast/src/components/workspace-form.tsx index 475bbff1..fc1e4d0f 100644 --- a/QuickShell.Raycast/src/components/workspace-form.tsx +++ b/QuickShell.Raycast/src/components/workspace-form.tsx @@ -1,6 +1,7 @@ import { Action, ActionPanel, + environment, Form, Icon, launchCommand, @@ -172,7 +173,12 @@ export default function WorkspaceForm({ let cancelled = false; void (async () => { const generation = ++suggestionGenerationRef.current; - const resolved = await resolveWorkspaceSetupSuggestions(directory, seededCommands); + const resolved = await resolveWorkspaceSetupSuggestions( + directory, + seededCommands, + Date.now(), + environment.assetsPath, + ); if (cancelled || generation !== suggestionGenerationRef.current) { return; } @@ -307,7 +313,7 @@ export default function WorkspaceForm({ tasks: [] as Array<{ label: string; command: string }>, pills: [] as SuggestionPill[], } - : await resolveWorkspaceSetupSuggestions(nextDirectory, usedCommands); + : await resolveWorkspaceSetupSuggestions(nextDirectory, usedCommands, Date.now(), environment.assetsPath); if (generation !== suggestionGenerationRef.current) { return; } @@ -695,12 +701,13 @@ export default function WorkspaceForm({ placeholder={index === 0 ? "npm run dev" : "dotnet watch"} /> ))} - {unusedSuggestionPills.length > 0 ? ( + {suggestionSource ? ( { if (key === "choose-suggestion") { return; @@ -711,7 +718,10 @@ export default function WorkspaceForm({ } }} > - + 0 ? "Choose a suggestion…" : "All suggestions applied"} + /> {unusedSuggestionPills.map((pill) => ( { - const executable = resolveSuggestExecutable(); + const executable = resolveSuggestExecutable(assetsPath); if (!executable || !existsSync(executable)) { return null; } @@ -170,13 +171,14 @@ export async function resolveWorkspaceSetupSuggestions( directory: string, usedCommands: string[] = [], generation = Date.now(), + assetsPath?: string, ): Promise { const trimmed = directory.trim(); if (!trimmed) { return { source: "local", tasks: [], pills: [] }; } - const response = await fetchSuggestionPills(trimmed, usedCommands, generation); + const response = await fetchSuggestionPills(trimmed, usedCommands, generation, assetsPath); if (response && response.pills.length > 0) { const split = splitPillsIntoSeedAndLeftover(response.pills); return { diff --git a/docs/architecture/intelligence.md b/docs/architecture/intelligence.md index e853e51d..50dfee32 100644 --- a/docs/architecture/intelligence.md +++ b/docs/architecture/intelligence.md @@ -75,7 +75,7 @@ GetPills(directory, usedCommands, maxCount = MaxPills) |------|-------------| | CmdPal | Adaptive Card pill actions on form (4 per row, 3 rows collapsed; template rebuilt for visible count; same-type pills grouped by `TypeTitle`, groups ordered by best score) | | Run | `RunLaunchSuggestionPanel` | -| Raycast | `QuickShell.Suggest.exe` → JSON pills via `suggest-commands.ts` (form seeds + Actions). Falls back to `project-setup-suggestion.ts` heuristics when Suggest.exe is missing. `QUICKSHELL_SUGGEST_EXE` overrides the path in development. | +| Raycast | `QuickShell.Suggest.exe` → JSON pills via `suggest-commands.ts` (form seeds + Actions/dropdown). Windows deploy/package scripts publish the CLI into Raycast assets and runtime resolution uses `environment.assetsPath`. Falls back to `project-setup-suggestion.ts` heuristics when Suggest.exe is missing (including macOS). `QUICKSHELL_SUGGEST_EXE` overrides the path in development. | ## Related helpers diff --git a/scripts/RaycastLifecycle.ps1 b/scripts/RaycastLifecycle.ps1 index 8504e52c..a66e4b15 100644 --- a/scripts/RaycastLifecycle.ps1 +++ b/scripts/RaycastLifecycle.ps1 @@ -155,6 +155,17 @@ function Deploy-RaycastExtension { throw 'Node.js/npm is required to build QuickShell.Raycast.' } + if ($env:OS -eq 'Windows_NT') { + $suggestBuildScript = Join-Path $ProjectRoot 'scripts\build-raycast-suggest.ps1' + if (-not (Test-Path -LiteralPath $suggestBuildScript)) { + throw "QuickShell.Suggest build script not found at $suggestBuildScript" + } + & $suggestBuildScript -ProjectRoot $ProjectRoot -Configuration Release -Platform x64 + if ($LASTEXITCODE -ne 0) { + throw "QuickShell.Suggest publish failed with exit code $LASTEXITCODE" + } + } + Push-Location $raycastRoot try { if (-not (Test-Path 'node_modules/@raycast/api')) { diff --git a/scripts/build-raycast-extension.ps1 b/scripts/build-raycast-extension.ps1 index 8447e9ca..328b76d7 100644 --- a/scripts/build-raycast-extension.ps1 +++ b/scripts/build-raycast-extension.ps1 @@ -22,6 +22,14 @@ if (-not (Test-Path $raycastRoot)) { } Write-Host "Building Quick Shell for Raycast v$Version..." -ForegroundColor Cyan +& (Join-Path $PSScriptRoot 'build-raycast-suggest.ps1') ` + -ProjectRoot $repoRoot ` + -Configuration $Configuration ` + -Platform x64 +if ($LASTEXITCODE -ne 0) { + throw "QuickShell.Suggest publish failed with exit code $LASTEXITCODE" +} + Push-Location $raycastRoot try { if (Get-Command npm -ErrorAction SilentlyContinue) { diff --git a/scripts/build-raycast-suggest.ps1 b/scripts/build-raycast-suggest.ps1 new file mode 100644 index 00000000..26c02bb0 --- /dev/null +++ b/scripts/build-raycast-suggest.ps1 @@ -0,0 +1,64 @@ +param( + [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), + + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + [ValidateSet('x64')] + [string]$Platform = 'x64' +) + +$ErrorActionPreference = 'Stop' +$projectPath = Join-Path $ProjectRoot 'QuickShell.Suggest\QuickShell.Suggest.csproj' +$raycastRoot = Join-Path $ProjectRoot 'QuickShell.Raycast' +$publishRoot = Join-Path $raycastRoot 'bin\SuggestPublish' +$assetPath = Join-Path $raycastRoot 'assets\QuickShell.Suggest.exe' + +if (-not (Test-Path -LiteralPath $projectPath)) { + throw "QuickShell.Suggest project not found at $projectPath" +} + +$dotnetCommand = Get-Command dotnet -ErrorAction Stop +$dotnetRoot = Split-Path -Parent $dotnetCommand.Source +$sdkVersion = (& $dotnetCommand.Source --version).Trim() +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sdkVersion)) { + throw 'Unable to determine the installed .NET SDK version.' +} +$sdkPath = Join-Path $dotnetRoot "sdk\$sdkVersion\Sdks" +if (-not (Test-Path -LiteralPath $sdkPath)) { + throw "The .NET SDK resolver path was not found at $sdkPath" +} + +$previousDotnetRoot = $env:DOTNET_ROOT +$previousMsBuildSdksPath = $env:MSBuildSDKsPath +try { + # Use the SDK belonging to the selected dotnet host even when the parent + # shell contains stale Scoop/MSBuild environment variables. + $env:DOTNET_ROOT = $dotnetRoot + $env:MSBuildSDKsPath = $sdkPath + + & $dotnetCommand.Source publish $projectPath ` + -c $Configuration ` + -p:Platform=$Platform ` + -r win-x64 ` + --self-contained false ` + -p:PublishSingleFile=true ` + -p:DebugType=None ` + -p:DebugSymbols=false ` + -o $publishRoot + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish QuickShell.Suggest failed with exit code $LASTEXITCODE" + } +} +finally { + $env:DOTNET_ROOT = $previousDotnetRoot + $env:MSBuildSDKsPath = $previousMsBuildSdksPath +} + +$publishedExecutable = Join-Path $publishRoot 'QuickShell.Suggest.exe' +if (-not (Test-Path -LiteralPath $publishedExecutable)) { + throw "Published QuickShell.Suggest executable not found at $publishedExecutable" +} + +Copy-Item -LiteralPath $publishedExecutable -Destination $assetPath -Force +Write-Host "Published Raycast suggestion CLI: $assetPath" -ForegroundColor Green From 59bb57fe7327a160a4d32f4f67e6aa67eb01a4be Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 06:41:03 -0700 Subject: [PATCH 2/7] Show suggestions in manual workspace creation --- .../src/__tests__/suggest-commands.test.ts | 14 +++++++++++ .../src/components/workspace-form.tsx | 23 +++++++++++++++---- .../src/lib/suggest-commands.ts | 20 ++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts index 0af6b0bf..666138a2 100644 --- a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts +++ b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildSuggestCommandArgs, + combineSuggestionTasksAndPills, pillsToSetupTasks, resolveSuggestExecutable, splitPillsIntoSeedAndLeftover, @@ -76,6 +77,19 @@ describe("suggest-commands", () => { ]); }); + it("keeps seeded and leftover suggestions selectable for manual create", () => { + const combined = combineSuggestionTasksAndPills( + [{ label: "Dev", command: "npm run dev", taskType: "frontend" }], + [ + pill({ command: "npm run dev", taskType: "frontend" }), + pill({ command: "npm test", taskType: "test", displayTitle: "Test" }), + ], + ); + + expect(combined.map((entry) => entry.command)).toEqual(["npm run dev", "npm test"]); + expect(combined[0].displayTitle).toBe("Dev"); + }); + it("splits preferred setup pills into a short seed and leftover Actions pills", () => { const pills = [ pill({ command: "npm run build", taskType: "build", displayTitle: "Build" }), diff --git a/QuickShell.Raycast/src/components/workspace-form.tsx b/QuickShell.Raycast/src/components/workspace-form.tsx index fc1e4d0f..b0c95fe6 100644 --- a/QuickShell.Raycast/src/components/workspace-form.tsx +++ b/QuickShell.Raycast/src/components/workspace-form.tsx @@ -33,7 +33,11 @@ import { tryGetGitRemoteUrl } from "../lib/git-remote-url"; import { createStableId } from "../lib/ids"; import type { OpenWorkspaceLaunchContext } from "../lib/launch-context"; import { buildProjectSetupSuggestions } from "../lib/project-setup-suggestion"; -import { resolveWorkspaceSetupSuggestions, type SuggestionPill } from "../lib/suggest-commands"; +import { + combineSuggestionTasksAndPills, + resolveWorkspaceSetupSuggestions, + type SuggestionPill, +} from "../lib/suggest-commands"; import { getQuickShellStorage } from "../lib/raycast-storage"; import type { Workspace } from "../lib/schema"; import { suggestionPillIcon } from "../lib/task-type-accent"; @@ -291,10 +295,21 @@ export default function WorkspaceForm({ } } - // Manual Add Workspace: stop after name / repo / dev-server. + // Manual Add Workspace: offer commands without auto-applying them. if (directorySeedMode !== "full") { - setSuggestionPills([]); - setSuggestionSource(null); + const generation = ++suggestionGenerationRef.current; + const usedCommands = launches.map((launch) => launch.command.trim()).filter(Boolean); + const resolved = await resolveWorkspaceSetupSuggestions( + nextDirectory, + usedCommands, + Date.now(), + environment.assetsPath, + ); + if (generation !== suggestionGenerationRef.current) { + return; + } + setSuggestionSource(resolved.source); + setSuggestionPills(combineSuggestionTasksAndPills(resolved.tasks, resolved.pills)); return; } diff --git a/QuickShell.Raycast/src/lib/suggest-commands.ts b/QuickShell.Raycast/src/lib/suggest-commands.ts index d7a1e31b..f2e7cc4c 100644 --- a/QuickShell.Raycast/src/lib/suggest-commands.ts +++ b/QuickShell.Raycast/src/lib/suggest-commands.ts @@ -74,6 +74,26 @@ export function pillsToSetupTasks(pills: SuggestionPill[]): WorkspaceSetupTask[] return tasks; } +/** Present seeded tasks as selectable pills without auto-applying them (manual create flow). */ +export function combineSuggestionTasksAndPills(tasks: WorkspaceSetupTask[], pills: SuggestionPill[]): SuggestionPill[] { + const combined: SuggestionPill[] = tasks.map((task) => ({ + command: task.command, + taskType: task.taskType?.trim() || "none", + typeTitle: task.taskType?.trim() || "Setup", + displayTitle: task.label, + tooltip: task.command, + })); + const seen = new Set(combined.map((pill) => pill.command.trim().toLowerCase())); + for (const pill of pills) { + const key = pill.command.trim().toLowerCase(); + if (key && !seen.has(key)) { + seen.add(key); + combined.push(pill); + } + } + return combined; +} + export function isPreferredSetupSeedPill(pill: SuggestionPill): boolean { const taskType = pill.taskType?.trim().toLowerCase() ?? ""; if (PREFERRED_SEED_TASK_TYPES.has(taskType)) { From d6545c7f840b218565f807bcc2f547220880ed90 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 07:13:01 -0700 Subject: [PATCH 3/7] Fix Raycast Suggest packaging and command dropdown UX Generate Suggest.exe on Windows prebuild/prepublish so Store packages include it, hide the empty disabled dropdown, and leave local-heuristic leftovers selectable. Co-authored-by: Cursor --- QuickShell.Raycast/README.md | 2 +- QuickShell.Raycast/package.json | 5 +- .../scripts/ensure-suggest-asset.js | 49 +++++++++++++++++++ .../src/__tests__/suggest-commands.test.ts | 14 ++++++ .../src/components/workspace-form.tsx | 8 +-- .../src/lib/suggest-commands.ts | 23 ++++++++- docs/architecture/forms.md | 2 +- docs/architecture/intelligence.md | 2 +- 8 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 QuickShell.Raycast/scripts/ensure-suggest-asset.js diff --git a/QuickShell.Raycast/README.md b/QuickShell.Raycast/README.md index 1213ead8..5119cc61 100644 --- a/QuickShell.Raycast/README.md +++ b/QuickShell.Raycast/README.md @@ -62,7 +62,7 @@ npm run dev Run `npm run build` before submitting Store changes. Do not add a `version` field to `package.json`; Store versioning uses `CHANGELOG.md`. -On Windows, `scripts/deploy-all.ps1` and `scripts/build-raycast-extension.ps1` publish the Core-backed suggestion CLI into Raycast assets automatically. The packaged CLI uses the .NET 10 Desktop Runtime; macOS continues to use local folder heuristics. +On Windows, `npm run build`, `npm run publish`, `npm run dev` (if the asset is missing), `scripts/deploy-all.ps1`, and `scripts/build-raycast-extension.ps1` all publish `QuickShell.Suggest.exe` into Raycast `assets/` (gitignored; generated at build time). The CLI needs the .NET 10 Desktop Runtime. macOS continues to use local folder heuristics. **Distribution:** publish via the [Raycast Store](https://www.raycast.com/store) only. GitHub Releases and WinGet do not ship Raycast sideload packages. `scripts/build-raycast-extension.ps1` is for local/dev packaging. diff --git a/QuickShell.Raycast/package.json b/QuickShell.Raycast/package.json index a92e0956..bb4148af 100644 --- a/QuickShell.Raycast/package.json +++ b/QuickShell.Raycast/package.json @@ -154,10 +154,11 @@ "vitest": "^3.0.8" }, "scripts": { - "predev": "node scripts/sync-workspace-trust-features.js && node scripts/verify-raycast-cli.js", - "prebuild": "node scripts/sync-workspace-trust-features.js && node scripts/verify-raycast-cli.js", + "predev": "node scripts/sync-workspace-trust-features.js && node scripts/verify-raycast-cli.js && node scripts/ensure-suggest-asset.js --if-missing", + "prebuild": "node scripts/sync-workspace-trust-features.js && node scripts/verify-raycast-cli.js && node scripts/ensure-suggest-asset.js", "prelint": "node scripts/sync-workspace-trust-features.js && node scripts/verify-raycast-cli.js", "pretest": "node scripts/sync-workspace-trust-features.js", + "prepublishOnly": "node scripts/ensure-suggest-asset.js", "build": "ray build", "dev": "ray develop", "lint": "ray lint", diff --git a/QuickShell.Raycast/scripts/ensure-suggest-asset.js b/QuickShell.Raycast/scripts/ensure-suggest-asset.js new file mode 100644 index 00000000..8cbd3a70 --- /dev/null +++ b/QuickShell.Raycast/scripts/ensure-suggest-asset.js @@ -0,0 +1,49 @@ +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const raycastRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(raycastRoot, ".."); +const assetPath = path.join(raycastRoot, "assets", "QuickShell.Suggest.exe"); +const buildScript = path.join(repoRoot, "scripts", "build-raycast-suggest.ps1"); + +const ifMissing = process.argv.includes("--if-missing"); + +function fail(message) { + console.error(message); + process.exit(1); +} + +if (process.platform !== "win32") { + process.exit(0); +} + +if (ifMissing && fs.existsSync(assetPath)) { + process.exit(0); +} + +if (!fs.existsSync(buildScript)) { + fail(`QuickShell.Suggest build script not found at ${buildScript}`); +} + +const result = spawnSync( + "powershell.exe", + ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", buildScript, "-ProjectRoot", repoRoot], + { + cwd: repoRoot, + encoding: "utf8", + stdio: "inherit", + }, +); + +if (result.error) { + fail(`Failed to publish QuickShell.Suggest.exe: ${result.error.message}`); +} + +if (result.status !== 0) { + fail(`QuickShell.Suggest publish failed with exit code ${result.status ?? 1}`); +} + +if (!fs.existsSync(assetPath)) { + fail(`QuickShell.Suggest.exe was not published to ${assetPath}`); +} diff --git a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts index 666138a2..b96cb9c5 100644 --- a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts +++ b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildSuggestCommandArgs, combineSuggestionTasksAndPills, + LOCAL_SETUP_SEED_TASKS, pillsToSetupTasks, resolveSuggestExecutable, splitPillsIntoSeedAndLeftover, @@ -111,6 +112,19 @@ describe("suggest-commands", () => { expect(split.leftoverPills.map((entry) => entry.command)).toEqual(["claude", "npm run lint"]); }); + it("keeps local-heuristic leftovers selectable with the smaller local seed cap", () => { + const pills = [ + pill({ command: "dotnet build", taskType: "none", displayTitle: "Build" }), + pill({ command: "dotnet test", taskType: "none", displayTitle: "Tests" }), + pill({ command: "dotnet watch", taskType: "none", displayTitle: "Watch" }), + pill({ command: "dotnet run", taskType: "none", displayTitle: "Run" }), + ]; + + const split = splitPillsIntoSeedAndLeftover(pills, LOCAL_SETUP_SEED_TASKS); + expect(split.tasks.map((task) => task.command)).toEqual(["dotnet build", "dotnet test"]); + expect(split.leftoverPills.map((entry) => entry.command)).toEqual(["dotnet watch", "dotnet run"]); + }); + it("falls back to the first pills when none are preferred setup types", () => { const pills = [ pill({ command: "echo one", taskType: "none", displayTitle: "One" }), diff --git a/QuickShell.Raycast/src/components/workspace-form.tsx b/QuickShell.Raycast/src/components/workspace-form.tsx index b0c95fe6..7d292c6f 100644 --- a/QuickShell.Raycast/src/components/workspace-form.tsx +++ b/QuickShell.Raycast/src/components/workspace-form.tsx @@ -716,13 +716,12 @@ export default function WorkspaceForm({ placeholder={index === 0 ? "npm run dev" : "dotnet watch"} /> ))} - {suggestionSource ? ( + {unusedSuggestionPills.length > 0 ? ( { if (key === "choose-suggestion") { return; @@ -733,10 +732,7 @@ export default function WorkspaceForm({ } }} > - 0 ? "Choose a suggestion…" : "All suggestions applied"} - /> + {unusedSuggestionPills.map((pill) => ( { + const fromEnv = process.env.QUICKSHELL_SUGGEST_EXE?.trim(); + // Packaged Suggest.exe is Windows-only; allow an explicit override for local experiments. + if (!fromEnv && !isWindowsPlatform()) { + return null; + } + const executable = resolveSuggestExecutable(assetsPath); if (!executable || !existsSync(executable)) { + if (isWindowsPlatform()) { + console.warn( + `[quickshell] Suggest CLI not found at ${executable ?? "(unresolved)"}. ` + + "Run `npm run build` (or deploy-all) so assets/QuickShell.Suggest.exe is published.", + ); + } return null; } @@ -177,11 +193,14 @@ export async function fetchSuggestionPills( const { stdout } = await execFileAsync(executable, args, { windowsHide: true, maxBuffer: 1024 * 1024 }); const parsed = JSON.parse(stdout) as SuggestionResponse; if (parsed.generation !== generation) { + console.warn(`[quickshell] Suggest CLI generation mismatch (wanted ${generation}, got ${parsed.generation}).`); return null; } return parsed; - } catch { + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`[quickshell] Suggest CLI failed (${executable}): ${detail}`); return null; } } @@ -216,6 +235,6 @@ export async function resolveWorkspaceSetupSuggestions( displayTitle: task.label, tooltip: task.command, })); - const split = splitPillsIntoSeedAndLeftover(asPills); + const split = splitPillsIntoSeedAndLeftover(asPills, LOCAL_SETUP_SEED_TASKS); return { source: "local", tasks: split.tasks, pills: split.leftoverPills }; } diff --git a/docs/architecture/forms.md b/docs/architecture/forms.md index 909e7b0c..9ca18097 100644 --- a/docs/architecture/forms.md +++ b/docs/architecture/forms.md @@ -29,7 +29,7 @@ Browse/Paste folder on the form fills name (if unset), repo URL, and Dev Server Suggestion pills on the form call into [intelligence.md](./intelligence.md); companion fields into [companions.md](./companions.md). -In Raycast full-seed forms, unused command suggestions appear in a searchable, nonpersistent **Command suggestions** dropdown directly below the command fields. Choosing one fills the first blank command row or appends a row, then removes that choice from the dropdown. The action-panel suggestion actions remain keyboard-accessible alternatives; manual Add Workspace still uses the minimal seed and does not auto-add commands or companions. +In Raycast full-seed forms, unused command suggestions appear in a searchable, nonpersistent **Command suggestions** dropdown directly below the command fields (hidden when there are no unused leftovers). Choosing one fills the first blank command row or appends a row, then removes that choice from the dropdown. The action-panel suggestion actions remain keyboard-accessible alternatives. Manual Add Workspace offers the same suggestions in the dropdown without auto-applying them. ## Adaptive Card loop diff --git a/docs/architecture/intelligence.md b/docs/architecture/intelligence.md index 50dfee32..765ca438 100644 --- a/docs/architecture/intelligence.md +++ b/docs/architecture/intelligence.md @@ -75,7 +75,7 @@ GetPills(directory, usedCommands, maxCount = MaxPills) |------|-------------| | CmdPal | Adaptive Card pill actions on form (4 per row, 3 rows collapsed; template rebuilt for visible count; same-type pills grouped by `TypeTitle`, groups ordered by best score) | | Run | `RunLaunchSuggestionPanel` | -| Raycast | `QuickShell.Suggest.exe` → JSON pills via `suggest-commands.ts` (form seeds + Actions/dropdown). Windows deploy/package scripts publish the CLI into Raycast assets and runtime resolution uses `environment.assetsPath`. Falls back to `project-setup-suggestion.ts` heuristics when Suggest.exe is missing (including macOS). `QUICKSHELL_SUGGEST_EXE` overrides the path in development. | +| Raycast | `QuickShell.Suggest.exe` → JSON pills via `suggest-commands.ts` (form seeds + Actions/dropdown). Windows `npm` prebuild/prepublish/predev and deploy scripts publish the CLI into Raycast assets; runtime resolution uses `environment.assetsPath`. Falls back to `project-setup-suggestion.ts` heuristics when Suggest.exe is missing (including macOS); local fallback seeds at most two commands so leftover pills stay selectable. `QUICKSHELL_SUGGEST_EXE` overrides the path in development. | ## Related helpers From 44b71a65933f7dd606d0e1598f9fac23e8adbc8c Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 07:27:44 -0700 Subject: [PATCH 4/7] Fix Raycast Suggest publish path and CLI failure handling Ensure Store publish builds Suggest.exe, validate CLI payloads before use, and avoid logging local paths on Suggest failures. Co-authored-by: Cursor --- QuickShell.Raycast/README.md | 2 +- QuickShell.Raycast/package.json | 3 +- .../src/lib/suggest-commands.ts | 74 +++++++++++++++++-- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/QuickShell.Raycast/README.md b/QuickShell.Raycast/README.md index 5119cc61..bdfaca76 100644 --- a/QuickShell.Raycast/README.md +++ b/QuickShell.Raycast/README.md @@ -62,7 +62,7 @@ npm run dev Run `npm run build` before submitting Store changes. Do not add a `version` field to `package.json`; Store versioning uses `CHANGELOG.md`. -On Windows, `npm run build`, `npm run publish`, `npm run dev` (if the asset is missing), `scripts/deploy-all.ps1`, and `scripts/build-raycast-extension.ps1` all publish `QuickShell.Suggest.exe` into Raycast `assets/` (gitignored; generated at build time). The CLI needs the .NET 10 Desktop Runtime. macOS continues to use local folder heuristics. +On Windows, `npm run build`, `npm run publish` (runs `ensure-suggest-asset.js` before the Raycast publish CLI), `npm run dev` (if the asset is missing), `scripts/deploy-all.ps1`, and `scripts/build-raycast-extension.ps1` all publish `QuickShell.Suggest.exe` into Raycast `assets/` (gitignored; generated at build/publish time). The CLI needs the .NET 10 Desktop Runtime. macOS continues to use local folder heuristics. **Distribution:** publish via the [Raycast Store](https://www.raycast.com/store) only. GitHub Releases and WinGet do not ship Raycast sideload packages. `scripts/build-raycast-extension.ps1` is for local/dev packaging. diff --git a/QuickShell.Raycast/package.json b/QuickShell.Raycast/package.json index bb4148af..d714511f 100644 --- a/QuickShell.Raycast/package.json +++ b/QuickShell.Raycast/package.json @@ -158,13 +158,12 @@ "prebuild": "node scripts/sync-workspace-trust-features.js && node scripts/verify-raycast-cli.js && node scripts/ensure-suggest-asset.js", "prelint": "node scripts/sync-workspace-trust-features.js && node scripts/verify-raycast-cli.js", "pretest": "node scripts/sync-workspace-trust-features.js", - "prepublishOnly": "node scripts/ensure-suggest-asset.js", "build": "ray build", "dev": "ray develop", "lint": "ray lint", "test": "vitest run", "test:watch": "vitest", - "publish": "npx @raycast/api@latest publish" + "publish": "node scripts/ensure-suggest-asset.js && npx @raycast/api@latest publish" }, "allowScripts": { "esbuild@0.28.1": true diff --git a/QuickShell.Raycast/src/lib/suggest-commands.ts b/QuickShell.Raycast/src/lib/suggest-commands.ts index 2fc58f68..0aae2ab8 100644 --- a/QuickShell.Raycast/src/lib/suggest-commands.ts +++ b/QuickShell.Raycast/src/lib/suggest-commands.ts @@ -164,6 +164,54 @@ export function splitPillsIntoSeedAndLeftover( }; } +function isSuggestionResponse(value: unknown): value is SuggestionResponse { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Record; + return typeof record.generation === "number" && Array.isArray(record.pills); +} + +/** Spawn/exec failures from Suggest.exe (missing binary, non-zero exit, signals). */ +function isExpectedSuggestExecFailure(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const execError = error as NodeJS.ErrnoException & { + signal?: NodeJS.Signals | null; + status?: number | null; + }; + return ( + typeof execError.code === "string" || + typeof execError.code === "number" || + execError.signal != null || + typeof execError.status === "number" + ); +} + +function formatSuggestExecFailure(error: unknown): string { + if (!(error instanceof Error)) { + return "unknown error"; + } + + const execError = error as NodeJS.ErrnoException & { + signal?: NodeJS.Signals | null; + }; + const parts: string[] = []; + if (execError.code != null) { + parts.push(`code=${execError.code}`); + } + if (execError.signal) { + parts.push(`signal=${execError.signal}`); + } + if (parts.length === 0) { + parts.push(error.name); + } + return parts.join(" "); +} + export async function fetchSuggestionPills( directory: string, usedCommands: string[], @@ -180,8 +228,8 @@ export async function fetchSuggestionPills( if (!executable || !existsSync(executable)) { if (isWindowsPlatform()) { console.warn( - `[quickshell] Suggest CLI not found at ${executable ?? "(unresolved)"}. ` + - "Run `npm run build` (or deploy-all) so assets/QuickShell.Suggest.exe is published.", + "[quickshell] Suggest CLI not found (QuickShell.Suggest.exe). " + + "Run `npm run build` or `npm run publish` so assets/QuickShell.Suggest.exe is published.", ); } return null; @@ -191,7 +239,19 @@ export async function fetchSuggestionPills( try { const { stdout } = await execFileAsync(executable, args, { windowsHide: true, maxBuffer: 1024 * 1024 }); - const parsed = JSON.parse(stdout) as SuggestionResponse; + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + console.warn("[quickshell] Suggest CLI returned invalid JSON."); + return null; + } + + if (!isSuggestionResponse(parsed)) { + console.warn("[quickshell] Suggest CLI returned an unexpected payload shape."); + return null; + } + if (parsed.generation !== generation) { console.warn(`[quickshell] Suggest CLI generation mismatch (wanted ${generation}, got ${parsed.generation}).`); return null; @@ -199,9 +259,11 @@ export async function fetchSuggestionPills( return parsed; } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - console.warn(`[quickshell] Suggest CLI failed (${executable}): ${detail}`); - return null; + if (isExpectedSuggestExecFailure(error)) { + console.warn(`[quickshell] Suggest CLI failed (${formatSuggestExecFailure(error)}). Falling back to local heuristics.`); + return null; + } + throw error; } } From 2c459a5cf7eec572cfd65c50447f8f3cccbc379c Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 07:36:42 -0700 Subject: [PATCH 5/7] Fix Suggest generation overflow and add Raycast ensure script Parse --generation as long so Date.now() values echo correctly, pass the form request counter into Suggest, and add ensure-raycast-suggest.ps1 for Suggest publish plus Raycast deploy. Co-authored-by: Cursor --- .../SuggestCommandLineArgsTests.cs | 13 ++ .../Services/SuggestCommandLineArgs.cs | 5 +- .../src/components/workspace-form.tsx | 6 +- scripts/ensure-raycast-suggest.ps1 | 127 ++++++++++++++++++ 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 scripts/ensure-raycast-suggest.ps1 diff --git a/QuickShell.Core.Tests/SuggestCommandLineArgsTests.cs b/QuickShell.Core.Tests/SuggestCommandLineArgsTests.cs index a6f487f9..356ee935 100644 --- a/QuickShell.Core.Tests/SuggestCommandLineArgsTests.cs +++ b/QuickShell.Core.Tests/SuggestCommandLineArgsTests.cs @@ -21,6 +21,19 @@ public void TryParse_RepeatedUsedFlags_PreservesCommandsWithCommas() Assert.Equal(3, generation); } + [Fact] + public void TryParse_UnixEpochGeneration_ParsesAsLong() + { + var ok = SuggestCommandLineArgs.TryParse( + ["suggest", "--dir", @"C:\Projects\demo", "--generation", "1785335300454"], + out _, + out _, + out var generation); + + Assert.True(ok); + Assert.Equal(1785335300454L, generation); + } + [Fact] public void TryParse_WithoutUsed_ReturnsEmptyList() { diff --git a/QuickShell.Core/Services/SuggestCommandLineArgs.cs b/QuickShell.Core/Services/SuggestCommandLineArgs.cs index 45319ea1..311f2776 100644 --- a/QuickShell.Core/Services/SuggestCommandLineArgs.cs +++ b/QuickShell.Core/Services/SuggestCommandLineArgs.cs @@ -6,7 +6,7 @@ public static bool TryParse( string[] args, out string? directory, out IReadOnlyList usedCommands, - out int generation) + out long generation) { directory = null; usedCommands = []; @@ -34,7 +34,8 @@ public static bool TryParse( if (string.Equals(args[i], "--generation", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) { - _ = int.TryParse(args[++i], out generation); + // Raycast passes Date.now() (ms since epoch), which exceeds Int32. + _ = long.TryParse(args[++i], out generation); } } diff --git a/QuickShell.Raycast/src/components/workspace-form.tsx b/QuickShell.Raycast/src/components/workspace-form.tsx index 7d292c6f..717df97d 100644 --- a/QuickShell.Raycast/src/components/workspace-form.tsx +++ b/QuickShell.Raycast/src/components/workspace-form.tsx @@ -180,7 +180,7 @@ export default function WorkspaceForm({ const resolved = await resolveWorkspaceSetupSuggestions( directory, seededCommands, - Date.now(), + generation, environment.assetsPath, ); if (cancelled || generation !== suggestionGenerationRef.current) { @@ -302,7 +302,7 @@ export default function WorkspaceForm({ const resolved = await resolveWorkspaceSetupSuggestions( nextDirectory, usedCommands, - Date.now(), + generation, environment.assetsPath, ); if (generation !== suggestionGenerationRef.current) { @@ -328,7 +328,7 @@ export default function WorkspaceForm({ tasks: [] as Array<{ label: string; command: string }>, pills: [] as SuggestionPill[], } - : await resolveWorkspaceSetupSuggestions(nextDirectory, usedCommands, Date.now(), environment.assetsPath); + : await resolveWorkspaceSetupSuggestions(nextDirectory, usedCommands, generation, environment.assetsPath); if (generation !== suggestionGenerationRef.current) { return; } diff --git a/scripts/ensure-raycast-suggest.ps1 b/scripts/ensure-raycast-suggest.ps1 new file mode 100644 index 00000000..11df2a1b --- /dev/null +++ b/scripts/ensure-raycast-suggest.ps1 @@ -0,0 +1,127 @@ +<# +.SYNOPSIS + Publish Suggest.exe into Raycast assets, then build and deploy the Raycast extension. + +.DESCRIPTION + One-shot Raycast test loop (does not touch CmdPal or PowerToys Run): + 1. Publish QuickShell.Suggest.exe into QuickShell.Raycast/assets/ + 2. Smoke-test suggest against a directory + 3. npm test/build the Raycast extension and start `npm run dev` + 4. Restart Raycast so the develop extension is live + + Requires .NET 10 SDK + Desktop Runtime, Node.js 22.14+, and Raycast for Windows. + +.EXAMPLE + .\scripts\ensure-raycast-suggest.ps1 + +.EXAMPLE + .\scripts\ensure-raycast-suggest.ps1 -Directory D:\Dev\some-project -SkipTests + +.EXAMPLE + .\scripts\ensure-raycast-suggest.ps1 -BuildOnly +#> +param( + [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), + + [string]$Directory = $ProjectRoot, + + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + [ValidateSet('x64')] + [string]$Platform = 'x64', + + [switch]$SkipSmokeTest, + [switch]$SkipTests, + [switch]$BuildOnly, + [switch]$NoRestart, + [switch]$SkipDeploy +) + +$ErrorActionPreference = 'Stop' + +if ($env:OS -ne 'Windows_NT') { + throw 'ensure-raycast-suggest.ps1 is Windows-only (Suggest.exe is not used on macOS).' +} + +$buildScript = Join-Path $PSScriptRoot 'build-raycast-suggest.ps1' +$lifecycleScript = Join-Path $PSScriptRoot 'RaycastLifecycle.ps1' +if (-not (Test-Path -LiteralPath $buildScript)) { + throw "Build script not found at $buildScript" +} +if (-not (Test-Path -LiteralPath $lifecycleScript)) { + throw "Raycast lifecycle helpers not found at $lifecycleScript" +} + +. $lifecycleScript + +Write-Host '1/3 Publishing QuickShell.Suggest.exe into Raycast assets...' -ForegroundColor Cyan +& $buildScript -ProjectRoot $ProjectRoot -Configuration $Configuration -Platform $Platform +if ($LASTEXITCODE -ne 0) { + throw "build-raycast-suggest.ps1 failed with exit code $LASTEXITCODE" +} + +$assetPath = Join-Path $ProjectRoot 'QuickShell.Raycast\assets\QuickShell.Suggest.exe' +if (-not (Test-Path -LiteralPath $assetPath)) { + throw "Expected asset missing after publish: $assetPath" +} + +$item = Get-Item -LiteralPath $assetPath +Write-Host ("Published: {0} ({1:N1} KB)" -f $item.FullName, ($item.Length / 1KB)) -ForegroundColor Green + +if (-not $SkipSmokeTest) { + if (-not (Test-Path -LiteralPath $Directory)) { + throw "Smoke-test directory not found: $Directory" + } + + Write-Host "Smoke-testing suggest against $Directory ..." -ForegroundColor Cyan + $stdout = & $assetPath suggest --dir $Directory --generation 1 + if ($LASTEXITCODE -ne 0) { + throw "Suggest.exe exited with code $LASTEXITCODE" + } + + $parsed = $stdout | ConvertFrom-Json + $pillCount = @($parsed.pills).Count + Write-Host "Smoke test OK: generation=$($parsed.generation), pills=$pillCount" -ForegroundColor Green +} + +if ($SkipDeploy) { + Write-Host '' + Write-Host 'Suggest ready (-SkipDeploy). Start Raycast yourself:' -ForegroundColor Yellow + Write-Host ' cd QuickShell.Raycast' + Write-Host ' npm run dev' + Write-Host (" `$env:QUICKSHELL_SUGGEST_EXE = '{0}'" -f $assetPath) -ForegroundColor DarkGray + exit 0 +} + +Write-Host '2/3 Stopping Raycast (so the extension can reload)...' -ForegroundColor Cyan +$stoppedRaycast = $false +try { + Stop-RaycastProcesses + $stoppedRaycast = $true +} +catch { + Write-Warning "Could not stop Raycast: $($_.Exception.Message)" +} + +Write-Host '3/3 Building and deploying QuickShell.Raycast...' -ForegroundColor Cyan +Deploy-RaycastExtension ` + -ProjectRoot $ProjectRoot ` + -SkipTests:$SkipTests ` + -BuildOnly:$BuildOnly ` + -StartDevServer:(-not $BuildOnly) + +if (-not $NoRestart -and $stoppedRaycast) { + Write-Host 'Restarting Raycast...' -ForegroundColor Cyan + if (-not (Start-RaycastApp)) { + Write-Warning 'Raycast deploy finished but Raycast could not be restarted.' + } +} + +Write-Host '' +Write-Host 'Raycast Suggest + deploy complete.' -ForegroundColor Green +if (-not $BuildOnly) { + Write-Host 'Use the new develop terminal (npm run dev) or search Quick Shell in Raycast.' +} +Write-Host 'In the workspace form, expect Suggest copy (not "Suggest.exe is unavailable").' -ForegroundColor DarkGray +Write-Host ("Asset: {0}" -f $assetPath) -ForegroundColor DarkGray From 2a3c5c4332e60d99c1455e13e632c623ac20ec20 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 07:37:45 -0700 Subject: [PATCH 6/7] Fix Prettier formatting in suggest-commands.ts Co-authored-by: Cursor --- QuickShell.Raycast/src/lib/suggest-commands.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/QuickShell.Raycast/src/lib/suggest-commands.ts b/QuickShell.Raycast/src/lib/suggest-commands.ts index 0aae2ab8..cc846016 100644 --- a/QuickShell.Raycast/src/lib/suggest-commands.ts +++ b/QuickShell.Raycast/src/lib/suggest-commands.ts @@ -260,7 +260,9 @@ export async function fetchSuggestionPills( return parsed; } catch (error) { if (isExpectedSuggestExecFailure(error)) { - console.warn(`[quickshell] Suggest CLI failed (${formatSuggestExecFailure(error)}). Falling back to local heuristics.`); + console.warn( + `[quickshell] Suggest CLI failed (${formatSuggestExecFailure(error)}). Falling back to local heuristics.`, + ); return null; } throw error; From 92bfb64447504a9762c6c73ea6ab16af921e92e7 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 29 Jul 2026 09:48:08 -0700 Subject: [PATCH 7/7] Harden Suggest payload parsing and Raycast ensure script Validate each suggestion pill shape before use, and make the ensure helper safer for smoke-test JSON and SkipDeploy/stop paths. Co-authored-by: Cursor --- .../src/__tests__/suggest-commands.test.ts | 43 +++++++++++++++++++ .../src/lib/suggest-commands.ts | 39 ++++++++++++++--- scripts/ensure-raycast-suggest.ps1 | 17 +++----- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts index b96cb9c5..8384035c 100644 --- a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts +++ b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts @@ -4,6 +4,7 @@ import { buildSuggestCommandArgs, combineSuggestionTasksAndPills, LOCAL_SETUP_SEED_TASKS, + parseSuggestionResponse, pillsToSetupTasks, resolveSuggestExecutable, splitPillsIntoSeedAndLeftover, @@ -136,4 +137,46 @@ describe("suggest-commands", () => { expect(split.tasks.map((task) => task.command)).toEqual(["echo one", "echo two"]); expect(split.leftoverPills.map((entry) => entry.command)).toEqual(["echo three"]); }); + + it("parses Suggest payloads and drops malformed pills", () => { + expect( + parseSuggestionResponse({ + generation: 7, + pills: [ + { + command: "npm run dev", + taskType: "frontend", + typeTitle: "Frontend", + displayTitle: "Dev", + tooltip: "npm run dev", + }, + { command: 42, taskType: "frontend" }, + null, + "bad", + ], + }), + ).toEqual({ + generation: 7, + pills: [ + { + command: "npm run dev", + taskType: "frontend", + typeTitle: "Frontend", + displayTitle: "Dev", + tooltip: "npm run dev", + }, + ], + }); + }); + + it("rejects Suggest payloads when every pill is malformed", () => { + expect( + parseSuggestionResponse({ + generation: 1, + pills: [{ command: 1 }, { taskType: "frontend" }], + }), + ).toBeNull(); + expect(parseSuggestionResponse({ generation: "1", pills: [] })).toBeNull(); + expect(parseSuggestionResponse({ generation: 1 })).toBeNull(); + }); }); diff --git a/QuickShell.Raycast/src/lib/suggest-commands.ts b/QuickShell.Raycast/src/lib/suggest-commands.ts index cc846016..6fdd42f5 100644 --- a/QuickShell.Raycast/src/lib/suggest-commands.ts +++ b/QuickShell.Raycast/src/lib/suggest-commands.ts @@ -164,13 +164,39 @@ export function splitPillsIntoSeedAndLeftover( }; } -function isSuggestionResponse(value: unknown): value is SuggestionResponse { +function isSuggestionPill(value: unknown): value is SuggestionPill { if (!value || typeof value !== "object") { return false; } + const pill = value as Record; + return ( + typeof pill.command === "string" && + typeof pill.taskType === "string" && + typeof pill.typeTitle === "string" && + typeof pill.displayTitle === "string" && + typeof pill.tooltip === "string" + ); +} + +/** Parse Suggest CLI JSON; drop malformed pills. Returns null when the payload is unusable. */ +export function parseSuggestionResponse(value: unknown): SuggestionResponse | null { + if (!value || typeof value !== "object") { + return null; + } + const record = value as Record; - return typeof record.generation === "number" && Array.isArray(record.pills); + if (typeof record.generation !== "number" || !Number.isFinite(record.generation) || !Array.isArray(record.pills)) { + return null; + } + + const pills = record.pills.filter(isSuggestionPill); + // Non-empty array with zero valid pills is a corrupt payload, not "no suggestions". + if (record.pills.length > 0 && pills.length === 0) { + return null; + } + + return { generation: record.generation, pills }; } /** Spawn/exec failures from Suggest.exe (missing binary, non-zero exit, signals). */ @@ -247,17 +273,18 @@ export async function fetchSuggestionPills( return null; } - if (!isSuggestionResponse(parsed)) { + const response = parseSuggestionResponse(parsed); + if (!response) { console.warn("[quickshell] Suggest CLI returned an unexpected payload shape."); return null; } - if (parsed.generation !== generation) { - console.warn(`[quickshell] Suggest CLI generation mismatch (wanted ${generation}, got ${parsed.generation}).`); + if (response.generation !== generation) { + console.warn(`[quickshell] Suggest CLI generation mismatch (wanted ${generation}, got ${response.generation}).`); return null; } - return parsed; + return response; } catch (error) { if (isExpectedSuggestExecFailure(error)) { console.warn( diff --git a/scripts/ensure-raycast-suggest.ps1 b/scripts/ensure-raycast-suggest.ps1 index 11df2a1b..2ccda64e 100644 --- a/scripts/ensure-raycast-suggest.ps1 +++ b/scripts/ensure-raycast-suggest.ps1 @@ -80,7 +80,9 @@ if (-not $SkipSmokeTest) { throw "Suggest.exe exited with code $LASTEXITCODE" } - $parsed = $stdout | ConvertFrom-Json + # Native stdout may be a string or a line array; always parse as one JSON document. + $json = if ($null -eq $stdout) { '' } elseif ($stdout -is [string]) { $stdout } else { $stdout -join "`n" } + $parsed = $json | ConvertFrom-Json $pillCount = @($parsed.pills).Count Write-Host "Smoke test OK: generation=$($parsed.generation), pills=$pillCount" -ForegroundColor Green } @@ -91,18 +93,13 @@ if ($SkipDeploy) { Write-Host ' cd QuickShell.Raycast' Write-Host ' npm run dev' Write-Host (" `$env:QUICKSHELL_SUGGEST_EXE = '{0}'" -f $assetPath) -ForegroundColor DarkGray - exit 0 + return } Write-Host '2/3 Stopping Raycast (so the extension can reload)...' -ForegroundColor Cyan -$stoppedRaycast = $false -try { - Stop-RaycastProcesses - $stoppedRaycast = $true -} -catch { - Write-Warning "Could not stop Raycast: $($_.Exception.Message)" -} +# Stop-RaycastProcesses already uses -ErrorAction SilentlyContinue; let unexpected errors surface. +Stop-RaycastProcesses +$stoppedRaycast = $true Write-Host '3/3 Building and deploying QuickShell.Raycast...' -ForegroundColor Cyan Deploy-RaycastExtension `