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.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/README.md b/QuickShell.Raycast/README.md index 6ad83c43..bdfaca76 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, `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. ## Store checklist diff --git a/QuickShell.Raycast/package.json b/QuickShell.Raycast/package.json index a92e0956..d714511f 100644 --- a/QuickShell.Raycast/package.json +++ b/QuickShell.Raycast/package.json @@ -154,8 +154,8 @@ "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", "build": "ray build", @@ -163,7 +163,7 @@ "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/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 f3e30f94..8384035c 100644 --- a/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts +++ b/QuickShell.Raycast/src/__tests__/suggest-commands.test.ts @@ -1,7 +1,12 @@ +import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildSuggestCommandArgs, + combineSuggestionTasksAndPills, + LOCAL_SETUP_SEED_TASKS, + parseSuggestionResponse, pillsToSetupTasks, + resolveSuggestExecutable, splitPillsIntoSeedAndLeftover, type SuggestionPill, } from "../lib/suggest-commands"; @@ -16,6 +21,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", @@ -68,6 +79,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" }), @@ -89,6 +113,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" }), @@ -100,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/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..717df97d 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, @@ -32,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"; @@ -172,7 +177,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, + generation, + environment.assetsPath, + ); if (cancelled || generation !== suggestionGenerationRef.current) { return; } @@ -285,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, + generation, + environment.assetsPath, + ); + if (generation !== suggestionGenerationRef.current) { + return; + } + setSuggestionSource(resolved.source); + setSuggestionPills(combineSuggestionTasksAndPills(resolved.tasks, resolved.pills)); return; } @@ -307,7 +328,7 @@ export default function WorkspaceForm({ tasks: [] as Array<{ label: string; command: string }>, pills: [] as SuggestionPill[], } - : await resolveWorkspaceSetupSuggestions(nextDirectory, usedCommands); + : await resolveWorkspaceSetupSuggestions(nextDirectory, usedCommands, generation, environment.assetsPath); if (generation !== suggestionGenerationRef.current) { return; } diff --git a/QuickShell.Raycast/src/lib/suggest-commands.ts b/QuickShell.Raycast/src/lib/suggest-commands.ts index 2919d706..6fdd42f5 100644 --- a/QuickShell.Raycast/src/lib/suggest-commands.ts +++ b/QuickShell.Raycast/src/lib/suggest-commands.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; +import { isWindowsPlatform } from "./platform"; import { buildProjectSetupSuggestions, type WorkspaceSetupTask } from "./project-setup-suggestion"; const execFileAsync = promisify(execFile); @@ -9,6 +10,9 @@ const execFileAsync = promisify(execFile); /** Cap setup seed so leftover pills remain available in Actions (CmdPal-shaped). */ export const MAX_SETUP_SEED_TASKS = 4; +/** Local heuristics are short; keep the seed tiny so the form dropdown still has choices. */ +export const LOCAL_SETUP_SEED_TASKS = 2; + const PREFERRED_SEED_TASK_TYPES = new Set(["frontend", "api", "services", "test", "build"]); const PREFERRED_SEED_COMMAND_HINTS = ["dev", "start", "test", "build", "watch", "run"]; @@ -31,13 +35,13 @@ export type WorkspaceSuggestionResult = { pills: SuggestionPill[]; }; -export function resolveSuggestExecutable(): string | null { +export function resolveSuggestExecutable(assetsPath?: string): string | null { const fromEnv = process.env.QUICKSHELL_SUGGEST_EXE?.trim(); if (fromEnv) { return fromEnv; } - const packaged = path.join(__dirname, "..", "..", "bin", "QuickShell.Suggest.exe"); + const packaged = path.join(assetsPath ?? path.join(__dirname, "..", "..", "assets"), "QuickShell.Suggest.exe"); return packaged; } @@ -74,6 +78,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)) { @@ -140,13 +164,100 @@ export function splitPillsIntoSeedAndLeftover( }; } +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; + 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). */ +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[], generation: number, + assetsPath?: string, ): Promise { - const executable = resolveSuggestExecutable(); + 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 (QuickShell.Suggest.exe). " + + "Run `npm run build` or `npm run publish` so assets/QuickShell.Suggest.exe is published.", + ); + } return null; } @@ -154,14 +265,34 @@ export async function fetchSuggestionPills( try { const { stdout } = await execFileAsync(executable, args, { windowsHide: true, maxBuffer: 1024 * 1024 }); - const parsed = JSON.parse(stdout) as SuggestionResponse; - if (parsed.generation !== generation) { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + console.warn("[quickshell] Suggest CLI returned invalid JSON."); return null; } - return parsed; - } catch { - return null; + const response = parseSuggestionResponse(parsed); + if (!response) { + console.warn("[quickshell] Suggest CLI returned an unexpected payload shape."); + return null; + } + + if (response.generation !== generation) { + console.warn(`[quickshell] Suggest CLI generation mismatch (wanted ${generation}, got ${response.generation}).`); + return null; + } + + return response; + } catch (error) { + if (isExpectedSuggestExecFailure(error)) { + console.warn( + `[quickshell] Suggest CLI failed (${formatSuggestExecFailure(error)}). Falling back to local heuristics.`, + ); + return null; + } + throw error; } } @@ -170,13 +301,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 { @@ -194,6 +326,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 e853e51d..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). 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 `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 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 diff --git a/scripts/ensure-raycast-suggest.ps1 b/scripts/ensure-raycast-suggest.ps1 new file mode 100644 index 00000000..2ccda64e --- /dev/null +++ b/scripts/ensure-raycast-suggest.ps1 @@ -0,0 +1,124 @@ +<# +.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" + } + + # 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 +} + +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 + return +} + +Write-Host '2/3 Stopping Raycast (so the extension can reload)...' -ForegroundColor Cyan +# 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 ` + -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