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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Backend.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -552,6 +567,7 @@ Item {
onNotification: function(method, data) {
if (method === "window.visibilityChanged" && data && data.visible) {
menuSource.refresh()
scriptSource.refresh()
calculatorData.refresh()
}
}
Expand All @@ -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) }
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
11 changes: 11 additions & 0 deletions Launcher.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
2 changes: 2 additions & 0 deletions core/RootSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "↵"
Expand Down Expand Up @@ -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(", "))
Expand Down
157 changes: 157 additions & 0 deletions core/ScriptCommands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// 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<path>\n<first lines>" (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(/\/[^\/]*$/, "") || "/" }

// {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 || "",
// 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
}
}
}

// 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: 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.
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
}
}
45 changes: 45 additions & 0 deletions services/ScriptCommandSource.qml
Original file line number Diff line number Diff line change
@@ -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++
}
}
}
}
Loading
Loading