From 29c960e051be983092e8dfe24dcb90121350f902 Mon Sep 17 00:00:00 2001 From: Neil Chudleigh <5580824+nchudleigh@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:50:54 -0500 Subject: [PATCH 1/2] Add Script Commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executables in ~/.config/omacast/script-commands with Raycast's @raycast.* headers become root-search commands (SPEC §9.9). Text, password and dropdown arguments are pills; silent shows a HUD with the last output line, compact a toast. fullOutput and inline run as compact for now. Co-Authored-By: Claude Opus 5.5 --- Backend.qml | 30 ++++++ CLAUDE.md | 1 + Launcher.qml | 11 ++ README.md | 26 ++++- SPEC.md | 1 + core/RootSearch.js | 2 + core/ScriptCommands.js | 167 +++++++++++++++++++++++++++++++ services/ScriptCommandSource.qml | 45 +++++++++ services/ScriptRunner.qml | 39 ++++++++ tests/script-commands.test.js | 100 ++++++++++++++++++ ui/LauncherWindow.qml | 29 +++++- 11 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 core/ScriptCommands.js create mode 100644 services/ScriptCommandSource.qml create mode 100644 services/ScriptRunner.qml create mode 100644 tests/script-commands.test.js diff --git a/Backend.qml b/Backend.qml index 8895670..7c4008e 100644 --- a/Backend.qml +++ b/Backend.qml @@ -12,6 +12,7 @@ import "core/Frecency.js" as Frecency import "core/OmarchyMenu.js" as OmarchyMenu import "core/RootSearch.js" as RootSearch import "core/Calculator.js" as Calculator +import "core/ScriptCommands.js" as ScriptCommands import "services" Item { @@ -95,6 +96,7 @@ Item { for (var i = 0; i < apps.length; i++) if (apps[i] && apps[i].id) next.push(RootSearch.appCandidate(apps[i])) next = next.concat(RootSearch.menuCandidates(menuSource.tree, menuSource.guards, menuSource.providerItems, backend.deps)) for (var b = 0; b < RootSearch.BUILTINS.length; b++) next.push(RootSearch.builtinCandidate(RootSearch.BUILTINS[b])) + for (var s = 0; s < scriptSource.commands.length; s++) next.push(ScriptCommands.candidate(scriptSource.commands[s])) var byId = ({}) for (var j = 0; j < next.length; j++) byId[next[j].id] = next[j] var entries = FleaSearch.entries(next, Ranker.transliterate) @@ -298,6 +300,19 @@ Item { case "fallback": Util.execArgv(["omarchy-launch-browser", RootSearch.fallbackUrl(candidate, query)]) return { close: true } + case "script": + var script = candidate.script + var args = ScriptCommands.argv(script, (data && data.arguments) || {}) + if (args.error) throw new Error(args.error) + if (script.needsConfirmation && !(data && data.confirmed)) + return { confirm: { title: script.title, message: "Are you sure you want to run this script?", primary: "Run", destructive: false } } + backend.recordVisit(id, query) + scriptRunner.run(script, args.argv) + // silent closes and shows a HUD. shortcut: fullOutput and inline + // run as compact (toast with the last line); add the output view + // and inline refresh when a script needs them. + if (script.mode === "silent") return { close: true } + return { toast: { style: "success", title: "Running " + script.title + "…" } } } throw new Error("Cannot open " + id) } @@ -552,6 +567,7 @@ Item { onNotification: function(method, data) { if (method === "window.visibilityChanged" && data && data.visible) { menuSource.refresh() + scriptSource.refresh() calculatorData.refresh() } } @@ -564,6 +580,20 @@ Item { onFailed: function(message) { backend.log("error", "clipboard", message, null) } } + ScriptCommandSource { + id: scriptSource + directory: backend.directoriesReady ? backend.paths.scriptCommands : "" + onDirectoryChanged: refresh() + onRevisionChanged: rebuildTimer.restart() + onMessage: function(level, text, metadata) { backend.log(level, "script-commands", text, metadata) } + } + + // Launcher.qml shows the result as a toast while the window is open, else a HUD. + ScriptRunner { + id: scriptRunner + onFinished: function(command, result) { backend.emit("scriptCommands.finished", { mode: command.mode, style: result.style, title: result.title }) } + } + AppFocuser { id: appFocuser onLaunchRequested: function(candidate) { backend.launchApp(candidate) } diff --git a/CLAUDE.md b/CLAUDE.md index b9d6019..a8969ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,7 @@ Works: config.json sets `remToPxRate` (16), `workHoursPerDay` (8) and `clockFormat` (`auto` follows the Qt locale, or `12h` / `24h`). ↵ copies the answer (HUD), Ctrl+↵ pastes it (wl-copy on stdin, then Shift+Insert), Ctrl+Shift+C copies "question = answer". Each one saves to **Calculator History** (`db/calculator.json`, 4096 rows / 90 days): a built-in command and pushed view, searchable, Ctrl+D deletes an entry, Ctrl+Shift+D deletes all. RPC: `calculator.calculate|historyGetAll|historyDelete|historyDeleteAll|refreshCurrencies|getCurrencies`. A bare number, word or quantity (`42`, `firefox`, `5 kg`) never produces a row, because the section takes ↵. +- **Script Commands** (SPEC §9.9, `core/ScriptCommands.js`): `services/ScriptCommandSource.qml` scans `~/.config/omacast/script-commands` on every open (executable, ≤ 2 MB, no `.template.`, first 60 lines), and `services/ScriptRunner.qml` runs each one under `bash -lc` from its `currentDirectoryPath`. Text, password and dropdown arguments are pills (`←`/`→` cycle a dropdown). `silent` closes and shows a HUD with the last output line; `compact` shows "Running…" then a toast (a HUD if the window closed). `needsConfirmation` uses the alert. Not yet: the `fullOutput` output view and `inline` refresh (both run as `compact`), image icons (emoji only), a settings list of folders, Create/Reload commands. - **Latency:** show → mapped in 59–66 ms, hide in 24–42 ms on Omarchy 4.0.6 (`tests/smoke.sh`; it was 37–44 ms on the old `OverlayWindow`). - **Tests:** root search, calculator and privacy coverage (`node --test tests/*.test.js`). diff --git a/Launcher.qml b/Launcher.qml index f14679f..6492237 100644 --- a/Launcher.qml +++ b/Launcher.qml @@ -77,6 +77,17 @@ Item { summon: function(id, payload) { return root.shell ? root.shell.summon(id, payload) : false } } + // A script command's last line of output: a toast if the launcher is still + // open, else a HUD (SPEC §10.2 Toast → HUD). + Connections { + target: backend + function onNotification(method, data) { + if (method !== "scriptCommands.finished") return + if (root.opened && data.mode !== "silent") launcher.showToast(data.style, data.title) + else launcher.hud(data.title) + } + } + LauncherWindow { id: launcher shown: root.opened diff --git a/README.md b/README.md index 5900ced..9fa5d7d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ files: omacast creates its `~/.config`, `~/.local/share`, `~/.cache` and `~/.loc `0700` and its database files as `0600`, and tightens any it finds readable by other users. Text you copy or paste from the calculator goes to `wl-copy` on stdin, never on a command line, so other local users can't read it from the process list. Paste sends Shift+Insert after copying, as Omarchy's clipboard history does. +The one exception is a script command's arguments: they reach your script as `$1`…`$3`, so they are on its +command line while it runs. ### Upgrading from oma-cast @@ -132,6 +134,29 @@ the old plugin link and `~/.local/bin/oma-cast`, and `omacast setup hyprland` mo | `Esc` / `Shift+Esc` | Back / back to root | | `Ctrl +` / `Ctrl −` / `Ctrl 0` | Zoom | +## Script Commands + +Executable scripts in `~/.config/omacast/script-commands/` become commands, using the same +[Raycast Script Commands](https://github.com/raycast/script-commands) headers: + +```bash +#!/bin/bash +# @raycast.schemaVersion 1 +# @raycast.title Switch Display +# @raycast.mode silent +# @raycast.icon 🖥️ +# @raycast.argument1 { "type": "dropdown", "placeholder": "Input", "data": [{"title": "Mac mini", "value": "x0091"}, {"title": "MacBook Pro", "value": "x00d0"}] } + +monitor-input-cycle "$1" && echo "Switching display" +``` + +- Arguments (`text`, `password`, `dropdown`, up to three) appear as pills after the query: `Tab` moves into them, + and `←`/`→` pick a dropdown option. +- The last line the script prints becomes the result: a HUD for `silent`, a toast for `compact`. +- `fullOutput` and `inline` scripts run as `compact` for now. +- `needsConfirmation` asks first, and `currentDirectoryPath` sets the folder the script runs in. +- The folder is rescanned each time the launcher opens. Scripts need `chmod +x` and a shebang. + ## CLI ```sh @@ -146,7 +171,6 @@ omacast doctor omacast is early. Search, ranking and the Omarchy integration are done. Coming next: - [ ] Settings window -- [ ] Script Commands - [ ] Clipboard history, snippets, quicklinks, calculator - [ ] Running Raycast extensions built from the open-source [`raycast/extensions`](https://github.com/raycast/extensions) repo - [ ] Window management diff --git a/SPEC.md b/SPEC.md index 4e3a823..1cbaef3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -633,6 +633,7 @@ Each running menu-bar command owns one SNI item (ksni): - `silent`: close the window, then a HUD with the last line - `inline`: the first line becomes the root-item subtitle, refreshed every `refreshTime` (≥ 10 s) - Success = exit 0. A compatibility test corpus is the `raycast/script-commands` GitHub repo. +- **Privacy [D, 2026-11-03]:** arguments are the one place user text reaches an argv: `$1..$3` are the Script Commands interface, and the script is the user's own. Everything else still follows the stdin rule. --- diff --git a/core/RootSearch.js b/core/RootSearch.js index b424a03..b1dbcf0 100644 --- a/core/RootSearch.js +++ b/core/RootSearch.js @@ -359,6 +359,7 @@ function actionsFor(candidate) { case "omarchy-menu": case "omarchy-link": primary = { id: "open", title: "Open Menu", icon: "" }; break case "fallback": primary = { id: "open", title: candidate.title, icon: "" }; break + case "script": primary = { id: "open", title: "Run Script", icon: "\uf120" }; break default: primary = { id: "open", title: "Open Command", icon: "" } } primary.shortcut = "↵" @@ -463,6 +464,7 @@ function aboutFor(c, nowMs) { if (c.kind === "app") add("Desktop Entry", c.appId + ".desktop") if (c.kind === "omarchy-action") add("Command", c.action) if (c.kind === "omarchy-link") add("Opens", c.target) + if (c.kind === "script") { add("Script", c.script.path); add("Mode", c.script.mode) } add("URL", c.url || (c.run && c.run.url)) add("Arguments", (c.arguments || []).map(function(a) { return a.name + (a.required ? "" : " (optional)") }).join(", ")) add("Keywords", (c.keywords || []).join(", ")) diff --git a/core/ScriptCommands.js b/core/ScriptCommands.js new file mode 100644 index 0000000..909e2a3 --- /dev/null +++ b/core/ScriptCommands.js @@ -0,0 +1,167 @@ +// Script Commands (SPEC §9.9, ref 03 §5) [O]: executables in the script +// commands folder whose comment headers (`# @raycast.title …`) make them +// root-search commands. Pure logic; services/ScriptCommandSource.qml scans +// the folder and Backend.qml runs them. + +var HEADER = /^(?:#|REM|::|\/{2})\s*@raycast\.(\w+)\s+(.*)$/i +var KEYS = ["schemaVersion", "title", "mode", "packageName", "icon", "iconDark", "description", "author", + "authorURL", "currentDirectoryPath", "needsConfirmation", "refreshTime", "argument1", "argument2", "argument3"] +var MODES = { fullOutput: "fullOutput", "full-output": "fullOutput", compact: "compact", silent: "silent", inline: "inline" } +var TERMINAL_GLYPH = "" // nf-fa-terminal + +// One file per record: "\x1e\n" (the scan script's output). +function splitScan(output) { + return String(output || "").split("\x1e").slice(1).map(function(chunk) { + var nl = chunk.indexOf("\n") + return nl < 0 ? { path: chunk, text: "" } : { path: chunk.slice(0, nl), text: chunk.slice(nl + 1) } + }) +} + +function titleCase(name) { + return String(name).replace(/[-_]+/g, " ").replace(/\b\w/g, function(c) { return c.toUpperCase() }) +} + +function dirname(path) { return String(path).replace(/\/[^\/]*$/, "") || "/" } + +// "../x" relative to the script's folder, as Node's path.resolve would. +function resolveDir(base, rel) { + var r = String(rel || ".") + var parts = (r.charAt(0) === "/" ? r : base + "/" + r).split("/") + var out = [] + for (var i = 0; i < parts.length; i++) { + if (parts[i] === "" || parts[i] === ".") continue + if (parts[i] === "..") out.pop() + else out.push(parts[i]) + } + return "/" + out.join("/") +} + +// {command} or {error} for one script's text. +function parse(text, path) { + var h = {} + var lower = {} + KEYS.forEach(function(k) { lower[k.toLowerCase()] = k }) + var lines = String(text || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var m = lines[i].trim().match(HEADER) + if (m && lower[m[1].toLowerCase()]) h[lower[m[1].toLowerCase()]] = m[2].trim() + } + if (h.schemaVersion !== "1") return { error: "needs @raycast.schemaVersion 1" } + if (!h.title) return { error: "needs @raycast.title" } + var mode = MODES[h.mode] + if (!mode) return { error: "needs @raycast.mode (fullOutput, compact, silent or inline)" } + if (mode === "inline" && !h.refreshTime) mode = "compact" + + var args = [] + if (mode !== "inline") { + for (var n = 1; n <= 3; n++) { + var raw = h["argument" + n] + if (!raw) continue + var a + try { a = JSON.parse(raw) } catch (e) { return { error: "argument" + n + " is not JSON" } } + var type = a.type || "text" + if (["text", "password", "dropdown"].indexOf(type) < 0) return { error: "argument" + n + " has unknown type " + type } + var options = [] + if (type === "dropdown") { + options = (Array.isArray(a.data) ? a.data : []).filter(function(o) { return o && o.value !== undefined }) + .map(function(o) { return { title: String(o.title || o.value), value: String(o.value) } }) + if (!options.length) return { error: "argument" + n + " dropdown has no data" } + } + args.push({ + name: "argument" + n, type: type, placeholder: String(a.placeholder || "Argument " + n), + required: a.optional !== true, percentEncoded: a.percentEncoded === true, options: options + }) + } + } + + var dir = dirname(path) + return { + command: { + path: path, + title: h.title, + mode: mode, + packageName: h.packageName || titleCase(dir.replace(/^.*\//, "")), + description: h.description || "", + icon: h.icon || "", + cwd: resolveDir(dir, h.currentDirectoryPath), + needsConfirmation: h.needsConfirmation === "true" && mode !== "inline", + arguments: args + } + } +} + +// Emoji and other short text icons render as a glyph; image paths and URLs +// fall back to the terminal glyph. shortcut: no image icons, add them when +// someone ships scripts with .png icons. +function glyphFor(icon) { + var s = String(icon || "") + return s && s.length <= 8 && !/[\/.]/.test(s) ? s : TERMINAL_GLYPH +} + +function candidate(cmd) { + return { + id: "script:" + cmd.path, + type: "command", + kind: "script", + title: cmd.title, + subtitle: cmd.packageName, + keywords: [cmd.packageName].concat(cmd.description ? [cmd.description] : []), + typeLabel: "Script Command", + glyph: glyphFor(cmd.icon), + glyphFont: "", + arguments: cmd.arguments, + script: cmd, + rootSearch: { when: "always" } + } +} + +// Positional $1..$3 for the run, or {error, index} for the first bad one. +// Empty optional arguments still hold their place as "". +function argv(cmd, values) { + var out = [] + for (var i = 0; i < cmd.arguments.length; i++) { + var def = cmd.arguments[i] + var v = String((values && values[def.name]) || "") + if (def.type !== "password") v = v.trim() + if (!v && def.required) return { error: "Type a " + def.placeholder.toLowerCase() + " first", index: i } + if (v && def.type === "dropdown" && !def.options.some(function(o) { return o.value === v })) + return { error: v + " is not a " + def.placeholder.toLowerCase() + " option", index: i } + out.push(def.percentEncoded ? encodeURIComponent(v) : v) + } + return { argv: out } +} + +function lastLine(output) { + var lines = String(output || "").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").split("\n") + for (var i = lines.length - 1; i >= 0; i--) if (lines[i].trim()) return lines[i].trim() + return "" +} + +// The toast or HUD a finished run shows: its last line of output (ref 03 §5). +function outcome(cmd, exitCode, stdout, stderr) { + if (exitCode === 0) return { style: "success", title: lastLine(stdout) || cmd.title + " finished" } + return { style: "failure", title: lastLine(stderr) || lastLine(stdout) || cmd.title + " failed (exit " + exitCode + ")" } +} + +// The dropdown option after `value`, `step` places on, wrapping. +function cycleOption(def, value, step) { + var opts = def.options || [] + if (!opts.length) return "" + var at = -1 + for (var i = 0; i < opts.length; i++) if (opts[i].value === value) at = i + if (at < 0) return opts[step > 0 ? 0 : opts.length - 1].value + return opts[(at + step + opts.length) % opts.length].value +} + +function optionTitle(def, value) { + var opts = def.options || [] + for (var i = 0; i < opts.length; i++) if (opts[i].value === value) return opts[i].title + return value +} + +if (typeof module !== "undefined") { + module.exports = { + splitScan: splitScan, parse: parse, candidate: candidate, argv: argv, lastLine: lastLine, + outcome: outcome, cycleOption: cycleOption, optionTitle: optionTitle, resolveDir: resolveDir + } +} diff --git a/services/ScriptCommandSource.qml b/services/ScriptCommandSource.qml new file mode 100644 index 0000000..0c6e139 --- /dev/null +++ b/services/ScriptCommandSource.qml @@ -0,0 +1,45 @@ +import QtQuick +import Quickshell.Io +import "../core/ScriptCommands.js" as ScriptCommands + +// Scans the script commands folder (non-recursive; ref 03 §5 discovery): +// executable regular files, 1 byte to 2 MB, no ".template." in the name. +// Only the first 60 lines are read; headers live at the top. +Item { + id: source + + property string directory: "" + property var commands: [] + property int revision: 0 + + signal message(string level, string text, var metadata) + + function refresh() { + if (!source.directory || scan.running) return + scan.command = ["bash", "-c", source.scanScript, "scan", source.directory] + scan.running = true + } + + readonly property string scanScript: 'for f in "$1"/*; do ' + + '[[ -f $f && -x $f && $f != *.template.* ]] || continue; ' + + 's=$(stat -c %s -- "$f"); (( s > 0 && s <= 2097152 )) || continue; ' + + 'printf "\\036%s\\n" "$f"; head -n 60 -- "$f"; done' + + Process { + id: scan + stdout: StdioCollector { + onStreamFinished: { + var next = [] + var files = ScriptCommands.splitScan(text) + for (var i = 0; i < files.length; i++) { + var r = ScriptCommands.parse(files[i].text, files[i].path) + if (r.command) next.push(r.command) + else source.message("warning", "skipping " + files[i].path + ": " + r.error, null) + } + if (JSON.stringify(next) === JSON.stringify(source.commands)) return + source.commands = next + source.revision++ + } + } + } +} diff --git a/services/ScriptRunner.qml b/services/ScriptRunner.qml new file mode 100644 index 0000000..0e977c6 --- /dev/null +++ b/services/ScriptRunner.qml @@ -0,0 +1,39 @@ +import QtQuick +import Quickshell.Io +import "../core/ScriptCommands.js" as ScriptCommands + +// Runs one script command per Process: the file itself (it needs +x and a +// shebang), from its currentDirectoryPath, under a login shell for the +// user's PATH (ref 03 §5 "Execution"). stdin is closed, as in Raycast. +Item { + id: runner + + signal finished(var command, var result) + + // Arguments reach the script as $1..$3, so they are in its argv: that is + // the Script Commands interface, and the script is the user's own [D]. + function run(command, args) { + var proc = processComponent.createObject(runner, { + script: command, + command: ["bash", "-lc", 'cd -- "$1" || exit; shift; exec "$@" { + const { command } = SC.parse(DISPLAY, "/home/u/.config/omacast/script-commands/display.sh") + assert.equal(command.title, "Switch Display") + assert.equal(command.mode, "silent") + assert.equal(command.packageName, "Script Commands", "defaults to the folder name, title-cased") + assert.equal(command.cwd, "/home/u/.config/omacast/script-commands") + assert.deepEqual(command.arguments.map((a) => [a.name, a.type, a.required]), [["argument1", "dropdown", true], ["argument2", "text", false]]) + assert.deepEqual(command.arguments[0].options[0], { title: "Mac mini", value: "x0091" }) + const c = SC.candidate(command) + assert.equal(c.kind, "script") + assert.equal(c.glyph, "🖥️") +}) + +test("other comment styles, key case and modes", () => { + const js = "// @raycast.schemaVersion 1\n// @raycast.Title Hi\n// @raycast.mode full-output\n// @raycast.currentDirectoryPath ../x\n// @raycast.needsConfirmation true" + const { command } = SC.parse(js, "/a/b/hi.js") + assert.equal(command.title, "Hi") + assert.equal(command.mode, "fullOutput") + assert.equal(command.cwd, "/a/x") + assert.equal(command.needsConfirmation, true) + assert.equal(SC.parse("# @raycast.schemaVersion 1\n# @raycast.title T\n# @raycast.mode inline", "/a/t").command.mode, "compact", "inline without refreshTime") +}) + +test("invalid headers are reported", () => { + assert.match(SC.parse("# @raycast.title T\n# @raycast.mode silent", "/a").error, /schemaVersion/) + assert.match(SC.parse("# @raycast.schemaVersion 1\n# @raycast.mode silent", "/a").error, /title/) + assert.match(SC.parse("# @raycast.schemaVersion 1\n# @raycast.title T\n# @raycast.mode loud", "/a").error, /mode/) + assert.match(SC.parse("# @raycast.schemaVersion 1\n# @raycast.title T\n# @raycast.mode silent\n# @raycast.argument1 {nope", "/a").error, /JSON/) +}) + +test("argv validates required arguments and dropdown values, and percent-encodes", () => { + const { command } = SC.parse(DISPLAY, "/s/display.sh") + assert.equal(SC.argv(command, {}).index, 0) + assert.match(SC.argv(command, { argument1: "x9999" }).error, /not a input option/) + assert.deepEqual(SC.argv(command, { argument1: "x00d0" }).argv, ["x00d0", ""]) + assert.deepEqual(SC.argv(command, { argument1: "x00d0", argument2: "a b" }).argv, ["x00d0", "a%20b"]) +}) + +test("dropdown cycling wraps both ways", () => { + const def = SC.parse(DISPLAY, "/s/d").command.arguments[0] + assert.equal(SC.cycleOption(def, "", 1), "x0091") + assert.equal(SC.cycleOption(def, "", -1), "x00d0") + assert.equal(SC.cycleOption(def, "x00d0", 1), "x0091") + assert.equal(SC.optionTitle(def, "x00d0"), "MacBook Pro") +}) + +test("the result is the last non-empty line, ANSI stripped", () => { + const cmd = { title: "T" } + assert.deepEqual(SC.outcome(cmd, 0, "one\n\x1b[32mDone\x1b[0m\n\n", ""), { style: "success", title: "Done" }) + assert.deepEqual(SC.outcome(cmd, 2, "", "oops\n"), { style: "failure", title: "oops" }) + assert.deepEqual(SC.outcome(cmd, 1, "", ""), { style: "failure", title: "T failed (exit 1)" }) +}) + +test("the scan finds executable scripts only and keeps their headers", (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omacast-scripts-")) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + fs.writeFileSync(dir + "/display.sh", DISPLAY, { mode: 0o755 }) + fs.writeFileSync(dir + "/plain.sh", DISPLAY, { mode: 0o644 }) + fs.writeFileSync(dir + "/x.template.sh", DISPLAY, { mode: 0o755 }) + fs.writeFileSync(dir + "/empty.sh", "", { mode: 0o755 }) + fs.mkdirSync(dir + "/sub") + const qml = fs.readFileSync(path.join(root, "services/ScriptCommandSource.qml"), "utf8") + const script = eval(qml.match(/scanScript: ('[\s\S]*?')\n\n/)[1]) + const r = spawnSync("bash", ["-c", script, "scan", dir], { encoding: "utf8" }) + assert.equal(r.status, 0, r.stderr) + const files = SC.splitScan(r.stdout) + assert.deepEqual(files.map((f) => path.basename(f.path)), ["display.sh"]) + assert.equal(SC.parse(files[0].text, files[0].path).command.title, "Switch Display") +}) + +test("the runner runs the script from its folder with $1..$3", (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omacast-run-")) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + fs.writeFileSync(dir + "/s.sh", '#!/bin/bash\necho "$PWD|$1|$2|$#"\n', { mode: 0o755 }) + const qml = fs.readFileSync(path.join(root, "services/ScriptRunner.qml"), "utf8") + const wrapper = qml.match(/"bash", "-lc", '([^']*)'/)[1] + const r = spawnSync("bash", ["-c", wrapper, "script", dir, dir + "/s.sh", "x00d0", ""], { encoding: "utf8" }) + assert.equal(r.stdout.trim(), dir + "|x00d0||2") +}) diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 618a8ad..ad6e22f 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -6,6 +6,7 @@ import qs.Commons as Commons import qs.Ui import "../core/RootSearch.js" as RootSearch import "../core/Rows.js" as Rows +import "../core/ScriptCommands.js" as ScriptCommands // Omarchy 4.0.x dropped qs.Ui.OverlayWindow; this is the same full-screen // overlay layer the shell's own emoji picker uses. @@ -425,6 +426,14 @@ PanelWindow { return true } if (key === Qt.Key_Backspace && !value) { window.argFocus--; return true } + // A dropdown pill (script commands) steps through its options; typing + // doesn't edit it. + if (def.type === "dropdown") { + if (key === Qt.Key_Right || key === Qt.Key_Down) { set(ScriptCommands.cycleOption(def, value, 1)); return true } + if (key === Qt.Key_Left || key === Qt.Key_Up) { set(ScriptCommands.cycleOption(def, value, -1)); return true } + if (key === Qt.Key_Backspace) { set(""); return true } + return window.isTyping(event) + } if (Util.editsFilter(event, value)) { set(Util.editedFilter(event, value)); return true } if (window.isTyping(event)) { set(value + event.text); return true } return false @@ -476,6 +485,7 @@ PanelWindow { if (!text) return if (window.actionPanelOpen) actionPanel.item.filter += text else if (window.argFocus >= 0) { + if (window.argDefs[window.argFocus].type === "dropdown") return var name = window.argDefs[window.argFocus].name var values = Object.assign({}, window.argValues) values[name] = (values[name] || "") + text @@ -784,7 +794,12 @@ PanelWindow { anchors.rightMargin: window.px(10) anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText - text: pill.value || (pill.modelData.placeholder + (pill.modelData.required ? "" : " (optional)")) + readonly property bool dropdown: pill.modelData.type === "dropdown" + readonly property string shownValue: dropdown ? ScriptCommands.optionTitle(pill.modelData, pill.value) + : pill.modelData.type === "password" ? "•".repeat(pill.value.length) : pill.value + text: (dropdown && pill.focused ? "‹ " : "") + + (shownValue || (pill.modelData.placeholder + (pill.modelData.required ? "" : " (optional)"))) + + (dropdown && pill.focused ? " ›" : "") opacity: pill.value ? 1 : 0.5 color: window.look.foreground elide: Text.ElideLeft @@ -793,7 +808,7 @@ PanelWindow { } Rectangle { - visible: pill.focused && window.shown + visible: pill.focused && window.shown && !pillText.dropdown width: Math.max(1, Style.space(2)) height: pillText.font.pixelSize + Style.space(2) anchors.verticalCenter: parent.verticalCenter @@ -804,7 +819,15 @@ PanelWindow { MouseArea { anchors.fill: parent cursorShape: Qt.IBeamCursor - onClicked: { window.argFocus = pill.index; keyCatcher.forceActiveFocus() } + onClicked: { + if (pill.modelData.type === "dropdown" && pill.focused) { + var values = Object.assign({}, window.argValues) + values[pill.modelData.name] = ScriptCommands.cycleOption(pill.modelData, pill.value, 1) + window.argValues = values + } + window.argFocus = pill.index + keyCatcher.forceActiveFocus() + } } } } From c41ba9aa6a630f240c2433496132f7b64f738872 Mon Sep 17 00:00:00 2001 From: Neil Chudleigh <5580824+nchudleigh@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:01:22 -0500 Subject: [PATCH 2/2] Report script results after all output arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wait for both the exit and the end of output before reading the last line, with stderr merged into stdout as Raycast reads it. Let the runner's cd resolve currentDirectoryPath, and clear argument pills after a compact run so a second ↵ starts fresh. Co-Authored-By: Claude Opus 5.5 --- core/ScriptCommands.js | 28 +++++++++------------------- services/ScriptRunner.qml | 19 ++++++++++--------- tests/script-commands.test.js | 18 ++++++++++-------- ui/LauncherWindow.qml | 3 +++ 4 files changed, 32 insertions(+), 36 deletions(-) diff --git a/core/ScriptCommands.js b/core/ScriptCommands.js index 909e2a3..8c7fef8 100644 --- a/core/ScriptCommands.js +++ b/core/ScriptCommands.js @@ -23,19 +23,6 @@ function titleCase(name) { function dirname(path) { return String(path).replace(/\/[^\/]*$/, "") || "/" } -// "../x" relative to the script's folder, as Node's path.resolve would. -function resolveDir(base, rel) { - var r = String(rel || ".") - var parts = (r.charAt(0) === "/" ? r : base + "/" + r).split("/") - var out = [] - for (var i = 0; i < parts.length; i++) { - if (parts[i] === "" || parts[i] === ".") continue - if (parts[i] === "..") out.pop() - else out.push(parts[i]) - } - return "/" + out.join("/") -} - // {command} or {error} for one script's text. function parse(text, path) { var h = {} @@ -83,7 +70,8 @@ function parse(text, path) { packageName: h.packageName || titleCase(dir.replace(/^.*\//, "")), description: h.description || "", icon: h.icon || "", - cwd: resolveDir(dir, h.currentDirectoryPath), + // Relative to the script's folder; the runner's `cd` resolves "..". + cwd: (h.currentDirectoryPath || "").charAt(0) === "/" ? h.currentDirectoryPath : dir + "/" + (h.currentDirectoryPath || "."), needsConfirmation: h.needsConfirmation === "true" && mode !== "inline", arguments: args } @@ -137,10 +125,12 @@ function lastLine(output) { return "" } -// The toast or HUD a finished run shows: its last line of output (ref 03 §5). -function outcome(cmd, exitCode, stdout, stderr) { - if (exitCode === 0) return { style: "success", title: lastLine(stdout) || cmd.title + " finished" } - return { style: "failure", title: lastLine(stderr) || lastLine(stdout) || cmd.title + " failed (exit " + exitCode + ")" } +// The toast or HUD a finished run shows: the last line of its combined +// stdout and stderr (ref 03 §5). +function outcome(cmd, exitCode, output) { + var line = lastLine(output) + if (exitCode === 0) return { style: "success", title: line || cmd.title + " finished" } + return { style: "failure", title: line || cmd.title + " failed (exit " + exitCode + ")" } } // The dropdown option after `value`, `step` places on, wrapping. @@ -162,6 +152,6 @@ function optionTitle(def, value) { if (typeof module !== "undefined") { module.exports = { splitScan: splitScan, parse: parse, candidate: candidate, argv: argv, lastLine: lastLine, - outcome: outcome, cycleOption: cycleOption, optionTitle: optionTitle, resolveDir: resolveDir + outcome: outcome, cycleOption: cycleOption, optionTitle: optionTitle } } diff --git a/services/ScriptRunner.qml b/services/ScriptRunner.qml index 0e977c6..6a815a7 100644 --- a/services/ScriptRunner.qml +++ b/services/ScriptRunner.qml @@ -15,7 +15,7 @@ Item { function run(command, args) { var proc = processComponent.createObject(runner, { script: command, - command: ["bash", "-lc", 'cd -- "$1" || exit; shift; exec "$@" &1', "script", command.cwd, command.path].concat(args) }) proc.running = true } @@ -25,15 +25,16 @@ Item { Process { id: proc property var script: null - stdout: StdioCollector { id: out } - stderr: StdioCollector { id: err } - onExited: function(exitCode) { - // The collectors finish on the next tick; read them after it. - Qt.callLater(function() { - runner.finished(proc.script, ScriptCommands.outcome(proc.script, exitCode, out.text, err.text)) - proc.destroy() - }) + // Exit and end of output arrive in either order; report after both. + property var exitCode: null + property var output: null + function report() { + if (proc.exitCode === null || proc.output === null) return + runner.finished(proc.script, ScriptCommands.outcome(proc.script, proc.exitCode, proc.output)) + proc.destroy() } + stdout: StdioCollector { onStreamFinished: { proc.output = text; proc.report() } } + onExited: function(code) { proc.exitCode = code; proc.report() } } } } diff --git a/tests/script-commands.test.js b/tests/script-commands.test.js index 0271ef3..7c6473a 100644 --- a/tests/script-commands.test.js +++ b/tests/script-commands.test.js @@ -24,7 +24,7 @@ test("headers parse into a command with its arguments", () => { assert.equal(command.title, "Switch Display") assert.equal(command.mode, "silent") assert.equal(command.packageName, "Script Commands", "defaults to the folder name, title-cased") - assert.equal(command.cwd, "/home/u/.config/omacast/script-commands") + assert.equal(command.cwd, "/home/u/.config/omacast/script-commands/.") assert.deepEqual(command.arguments.map((a) => [a.name, a.type, a.required]), [["argument1", "dropdown", true], ["argument2", "text", false]]) assert.deepEqual(command.arguments[0].options[0], { title: "Mac mini", value: "x0091" }) const c = SC.candidate(command) @@ -37,7 +37,7 @@ test("other comment styles, key case and modes", () => { const { command } = SC.parse(js, "/a/b/hi.js") assert.equal(command.title, "Hi") assert.equal(command.mode, "fullOutput") - assert.equal(command.cwd, "/a/x") + assert.equal(command.cwd, "/a/b/../x") assert.equal(command.needsConfirmation, true) assert.equal(SC.parse("# @raycast.schemaVersion 1\n# @raycast.title T\n# @raycast.mode inline", "/a/t").command.mode, "compact", "inline without refreshTime") }) @@ -67,9 +67,9 @@ test("dropdown cycling wraps both ways", () => { test("the result is the last non-empty line, ANSI stripped", () => { const cmd = { title: "T" } - assert.deepEqual(SC.outcome(cmd, 0, "one\n\x1b[32mDone\x1b[0m\n\n", ""), { style: "success", title: "Done" }) - assert.deepEqual(SC.outcome(cmd, 2, "", "oops\n"), { style: "failure", title: "oops" }) - assert.deepEqual(SC.outcome(cmd, 1, "", ""), { style: "failure", title: "T failed (exit 1)" }) + assert.deepEqual(SC.outcome(cmd, 0, "one\n\x1b[32mDone\x1b[0m\n\n"), { style: "success", title: "Done" }) + assert.deepEqual(SC.outcome(cmd, 2, "oops\n"), { style: "failure", title: "oops" }) + assert.deepEqual(SC.outcome(cmd, 1, ""), { style: "failure", title: "T failed (exit 1)" }) }) test("the scan finds executable scripts only and keeps their headers", (t) => { @@ -92,9 +92,11 @@ test("the scan finds executable scripts only and keeps their headers", (t) => { test("the runner runs the script from its folder with $1..$3", (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omacast-run-")) t.after(() => fs.rmSync(dir, { recursive: true, force: true })) - fs.writeFileSync(dir + "/s.sh", '#!/bin/bash\necho "$PWD|$1|$2|$#"\n', { mode: 0o755 }) + fs.mkdirSync(dir + "/bin") + fs.writeFileSync(dir + "/bin/s.sh", '#!/bin/bash\necho "$PWD|$1|$2|$#"; echo err >&2\n', { mode: 0o755 }) const qml = fs.readFileSync(path.join(root, "services/ScriptRunner.qml"), "utf8") const wrapper = qml.match(/"bash", "-lc", '([^']*)'/)[1] - const r = spawnSync("bash", ["-c", wrapper, "script", dir, dir + "/s.sh", "x00d0", ""], { encoding: "utf8" }) - assert.equal(r.stdout.trim(), dir + "|x00d0||2") + const cwd = SC.parse("# @raycast.schemaVersion 1\n# @raycast.title T\n# @raycast.mode silent\n# @raycast.currentDirectoryPath ..", dir + "/bin/s.sh").command.cwd + const r = spawnSync("bash", ["-c", wrapper, "script", cwd, dir + "/bin/s.sh", "x00d0", ""], { encoding: "utf8" }) + assert.equal(r.stdout, dir + "|x00d0||2\nerr\n", "runs from the resolved folder; stderr joins stdout") }) diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index ad6e22f..ecef4ec 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -333,6 +333,9 @@ PanelWindow { return } if (outcome.toast) window.showToast(outcome.toast.style, outcome.toast.title, outcome.toast.actions) + // A command that ran keeps the window open; a second ↵ starts it fresh. + window.argValues = ({}) + window.argFocus = -1 window.rebuild(false) }