From 1bb439476af2a7b2bdee03c682ae002655c3523c Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 29 May 2026 16:55:15 -0400 Subject: [PATCH] Add third-party plugin sources and installer Let users add trusted plugin source repos and add/update/remove plugins from them, alongside the existing in-shell plugin commands: omarchy plugin source omarchy plugin available omarchy plugin add | update | remove | validate Each command is interactive (gum/fzf pickers, confirmation, an update diff) in a TTY and fully flag-driven with --yes for scripts and agents. Sources live in ~/.config/omarchy/plugins/sources.json and clone into ~/.cache/omarchy/plugin-sources/. The installer only copies files, validates manifests against the shell's schema, and toggles enabled state over IPC -- it never runs plugin code, hooks, or sudo. Also guard 'plugin bar add' so only known bar-widget ids enter the layout (rejecting typos/non-widgets like a stray '--help'). --- bin/omarchy-plugin | 46 +++++ bin/omarchy-plugin-add | 297 ++++++++++++++++++++++++++++ bin/omarchy-plugin-available | 98 +++++++++ bin/omarchy-plugin-remove | 107 ++++++++++ bin/omarchy-plugin-scan | 60 ++++++ bin/omarchy-plugin-source | 372 +++++++++++++++++++++++++++++++++++ bin/omarchy-plugin-update | 215 ++++++++++++++++++++ bin/omarchy-plugin-validate | 96 +++++++++ docs/omarchy-shell.md | 28 ++- shell/README.md | 50 ++++- 10 files changed, 1355 insertions(+), 14 deletions(-) create mode 100755 bin/omarchy-plugin-add create mode 100755 bin/omarchy-plugin-available create mode 100755 bin/omarchy-plugin-remove create mode 100755 bin/omarchy-plugin-scan create mode 100755 bin/omarchy-plugin-source create mode 100755 bin/omarchy-plugin-update create mode 100755 bin/omarchy-plugin-validate diff --git a/bin/omarchy-plugin b/bin/omarchy-plugin index 39688fc7..d2ce7fcc 100755 --- a/bin/omarchy-plugin +++ b/bin/omarchy-plugin @@ -22,6 +22,19 @@ Plugin commands: edit [id] [--with ] [--prompt ] Open a user plugin for editing +Add from sources (see 'omarchy plugin --help'): + source Manage trusted plugin source repos + available List plugins offered by your sources + add [id] [--from ] [--enable] [--review] [--yes] + Add a plugin from a trusted source + update [id] [--all] [--review] [--yes] + Update added plugins (shows a diff) + remove [id] [--yes] Remove an installed plugin + validate Check a plugin's manifest (for authors) + + Source/add commands run their own binaries; the rest are handled here. + Plugins are unsandboxed code — review what you add and enable. + Clone options: --name Display name for the clone --with Open the clone with AI or editor @@ -574,10 +587,34 @@ PY omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true } +# Canonical ids of every known bar widget, read from manifests on disk +# (first-party + user). This is the same set `omarchy plugin list` reports, but +# read straight from disk so it works with the shell down and never races an +# in-flight rescan. +bar_widget_ids() { + require_command jq + first_party_manifest_paths | while IFS= read -r manifest; do + jq -r 'select((.kinds // []) | index("bar-widget")) | .id' "$manifest" 2>/dev/null + done + local user_dir="$HOME/.config/omarchy/plugins" + if [[ -d $user_dir ]]; then + find "$user_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) 2>/dev/null | while IFS= read -r dir; do + [[ -f $dir/manifest.json ]] || continue + jq -r 'select((.kinds // []) | index("bar-widget")) | .id' "$dir/manifest.json" 2>/dev/null + done + fi +} + bar_add() { local id="${1:-}" [[ -n $id ]] || fail "bar add requires a widget id" shift + id=$(canonical_widget_id "$id") + # Only let real bar widgets into the layout — a typo'd or non-widget id just + # renders as a dead entry (this is how a stray "--help" once got in). + if ! bar_widget_ids | grep -qxF -- "$id"; then + fail "$id is not a known bar widget; run 'omarchy plugin list' to see valid ids" + fi mutate_bar add --id "$id" "$@" echo "Added $id to the bar" } @@ -1193,6 +1230,15 @@ bar | widget | widgets) shift bar_command "$@" ;; +source | available | add | update | remove | validate) + # These live in sibling binaries (omarchy-plugin-). The `omarchy` + # dispatcher normally routes straight to them; delegate here too so invoking + # this base binary directly matches the commands its --help advertises. + shift + sibling="$(dirname -- "${BASH_SOURCE[0]}")/omarchy-plugin-$command" + [[ -x $sibling ]] || fail "missing helper: omarchy-plugin-$command" + exec "$sibling" "$@" + ;; -h | --help | help | "") usage ;; diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add new file mode 100755 index 00000000..0b1048bf --- /dev/null +++ b/bin/omarchy-plugin-add @@ -0,0 +1,297 @@ +#!/bin/bash + +# omarchy:summary=Add an Omarchy shell plugin from a trusted source +# omarchy:group=plugin +# omarchy:args=[plugin-id] [--from ] [--enable] [--no-enable] [--review] [--no-refresh] [--yes] +# omarchy:examples=omarchy plugin add | omarchy plugin add orbit --enable | omarchy plugin add model-usage --from owner-plugins --yes + +# Copies a plugin folder out of a trusted source's local clone into +# ~/.config/omarchy/plugins//. It never runs plugin code, never runs install +# hooks, and never uses sudo — it only copies files, validates the manifest, and +# toggles enabled state over shell IPC. Reviewing and trusting the code is the +# user's call; --yes opts out of every prompt for scripts/agents. + +set -o pipefail + +PLUGINS_DIR="$HOME/.config/omarchy/plugins" +OMARCHY_PATH="${OMARCHY_PATH:-$HOME/.local/share/omarchy}" + +fail() { + echo "omarchy-plugin-add: $*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required" +} + +interactive() { + [[ -t 0 && -t 1 ]] +} + +PLUGIN_ID="" +FROM_SOURCE="" +ENABLE_AFTER="" # "", "true", or "false" +DO_REVIEW=0 +NO_REFRESH=0 +ASSUME_YES=0 + +while (($# > 0)); do + case "$1" in + --from) FROM_SOURCE="${2:-}"; shift 2 ;; + --enable) ENABLE_AFTER=true; shift ;; + --no-enable) ENABLE_AFTER=false; shift ;; + --review) DO_REVIEW=1; shift ;; + --no-refresh) NO_REFRESH=1; shift ;; + --yes | -y) ASSUME_YES=1; shift ;; + -h | --help) + cat <] [--enable] [--no-enable] [--review] [--no-refresh] [--yes] + +Adds a plugin from a trusted source into ~/.config/omarchy/plugins//. +Without a plugin-id, an interactive picker lists everything available. + --from disambiguate when the same id exists in multiple sources + --enable enable the plugin afterward (bar widgets land in the bar) + --no-enable add but leave disabled + --review open the plugin's files before adding (always in a TTY) + --no-refresh use the cached clones as-is, skip the network refresh + --yes skip every confirmation (review, add, enable) +USAGE + exit 0 + ;; + -*) fail "unknown option: $1" ;; + *) + [[ -z $PLUGIN_ID ]] || fail "unexpected argument: $1" + PLUGIN_ID="$1"; shift ;; + esac +done + +require_command jq + +confirm() { + local prompt="$1" + ((ASSUME_YES)) && return 0 + if command -v gum >/dev/null 2>&1 && interactive; then + gum confirm "$prompt"; return + fi + if interactive; then + local reply; read -r -p "$prompt [y/N] " reply; [[ $reply == [yY]* ]]; return + fi + fail "refusing to add without confirmation; pass --yes" +} + +if ((!NO_REFRESH)); then + omarchy-plugin-source refresh >/dev/null 2>&1 || true +fi + +CATALOG=$(omarchy-plugin-scan) +if [[ $(jq 'length' <<<"$CATALOG") -eq 0 ]]; then + fail "no plugins available; add a source with: omarchy plugin source add " +fi + +# ---------------------------------------------------------------- resolve id + +if [[ -z $PLUGIN_ID ]]; then + interactive || fail "a plugin-id is required (try: omarchy plugin available)" + choices=$(jq -r '.[] | "\(.manifest.id)\t\(.manifest.name // .manifest.id)\t\(.manifest.version // "?")\t\(.sourceId)"' <<<"$CATALOG") + rows=$(awk -F '\t' '{ printf "%-26s %-26s v%-8s [%s]\n", $1, $2, $3, $4 }' <<<"$choices") + if command -v gum >/dev/null 2>&1; then + pick=$(gum choose --header="Add which plugin?" <<<"$rows") || fail "cancelled" + elif command -v fzf >/dev/null 2>&1; then + pick=$(fzf --prompt "Add which plugin > " <<<"$rows") || fail "cancelled" + else + fail "a plugin-id is required (gum or fzf needed for an interactive picker)" + fi + [[ -n $pick ]] || fail "nothing selected" + PLUGIN_ID=$(awk '{print $1}' <<<"$pick") +fi + +if [[ -n $FROM_SOURCE ]]; then + matches=$(jq --arg id "$PLUGIN_ID" --arg src "$FROM_SOURCE" '[.[] | select(.manifest.id == $id and .sourceId == $src)]' <<<"$CATALOG") +else + matches=$(jq --arg id "$PLUGIN_ID" '[.[] | select(.manifest.id == $id)]' <<<"$CATALOG") +fi + +match_count=$(jq 'length' <<<"$matches") +if ((match_count == 0)); then + if [[ -n $FROM_SOURCE ]]; then + fail "no plugin '$PLUGIN_ID' in source '$FROM_SOURCE'" + fi + fail "no plugin '$PLUGIN_ID' in any configured source (try: omarchy plugin available)" +fi +if ((match_count > 1)); then + echo "omarchy-plugin-add: '$PLUGIN_ID' is available from multiple sources:" >&2 + jq -r '.[] | " - " + .sourceId' <<<"$matches" >&2 + fail "pick one with: --from " +fi + +entry=$(jq -c '.[0]' <<<"$matches") +src_id=$(jq -r '.sourceId' <<<"$entry") +src_url=$(jq -r '.sourceUrl' <<<"$entry") +src_path=$(jq -r '.path' <<<"$entry") +m_id=$(jq -r '.manifest.id' <<<"$entry") +m_name=$(jq -r '.manifest.name // .manifest.id' <<<"$entry") +m_version=$(jq -r '.manifest.version // "?"' <<<"$entry") +m_author=$(jq -r '.manifest.author // ""' <<<"$entry") +m_license=$(jq -r '.manifest.license // ""' <<<"$entry") +m_desc=$(jq -r '.manifest.description // ""' <<<"$entry") +m_kinds=$(jq -r '(.manifest.kinds // []) | join(", ")' <<<"$entry") + +# ---------------------------------------------------------------- validate + +omarchy-plugin-validate "$src_path" || fail "refusing to add: validation failed" + +target="$PLUGINS_DIR/$m_id" +replacing=0 +target_is_link=0 +if [[ -L $target ]]; then + target_is_link=1 + replacing=1 +elif [[ -d $target ]]; then + replacing=1 +fi + +# Soft warning: a third-party id that matches a built-in's alias installs as a +# separate widget rather than replacing the built-in. +alias_owner="" +while IFS= read -r fp_manifest; do + [[ -f $fp_manifest ]] || continue + if jq -e --arg id "$m_id" '((.barWidget.aliases // []) | index($id)) != null' "$fp_manifest" >/dev/null 2>&1; then + alias_owner=$(jq -r '.id' "$fp_manifest") + break + fi +done < <(find "$OMARCHY_PATH/shell/plugins" -type f \( -name manifest.json -o -name '*.manifest.json' \) 2>/dev/null) + +# Always surface an alias collision, even under --yes, since it changes what the +# added plugin actually shadows. +if [[ -n $alias_owner ]]; then + echo "Note: '$m_id' is also an alias of the built-in $alias_owner; it is added as a separate plugin and does not replace the built-in." >&2 +fi + +# ---------------------------------------------------------------- summary + +print_summary() { + cat < $(readlink "$target"))" + elif ((replacing)); then + echo " Target: $target (REPLACING existing install)" + else + echo " Target: $target" + fi + echo + echo " Plugins run as unsandboxed code inside omarchy-shell. Review before enabling." + echo +} + +review_files() { + echo "Files in $m_id:" + find "$src_path" -type f -printf '%P\n' 2>/dev/null | sort | sed 's/^/ /' + echo + # Only spawn an editor in a real terminal; a headless run just prints the path + # so it never launches a detached window. + if interactive && command -v omarchy-launch-editor >/dev/null 2>&1; then + omarchy-launch-editor "$src_path" + else + echo "Source folder: $src_path" + fi +} + +if ((!ASSUME_YES)); then + print_summary + + if ((DO_REVIEW)); then + review_files + confirm "Add $m_id $m_version now?" || fail "aborted" + elif interactive && command -v gum >/dev/null 2>&1; then + action=$(gum choose --header="Add $m_id $m_version?" "Add" "Review files first" "Cancel") || fail "aborted" + case "$action" in + "Review files first") + review_files + confirm "Add $m_id $m_version now?" || fail "aborted" + ;; + Add) ;; + *) fail "aborted" ;; + esac + else + confirm "Add $m_id $m_version?" || fail "aborted" + fi +fi + +# ---------------------------------------------------------------- install files + +mkdir -p "$PLUGINS_DIR" + +stage="$PLUGINS_DIR/.${m_id}.tmp.$$" +rm -rf "$stage" +if ! cp -a "$src_path/." "$stage/"; then + rm -rf "$stage" + fail "failed to stage plugin into $stage" +fi + +backup="" +if ((replacing)); then + if ((target_is_link)); then + # Don't drag a (possibly large) dev-symlink target into a backup; just drop + # the link. The real files it points at are untouched. + rm -f "$target" + else + base="$PLUGINS_DIR/.${m_id}.bak.$(date -u +%Y%m%d%H%M%S)" + backup="$base" + n=1 + while [[ -e $backup ]]; do backup="${base}-${n}"; n=$((n + 1)); done + if ! mv "$target" "$backup"; then + rm -rf "$stage" + fail "failed to back up existing $target" + fi + fi +fi + +if ! mv "$stage" "$target"; then + rm -rf "$stage" + [[ -n $backup && -d $backup ]] && mv "$backup" "$target" + fail "failed to move staged plugin into place" +fi + +echo "Added $m_id $m_version into $target" +[[ -n $backup ]] && echo "Previous version backed up to: $backup" + +omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true + +# ---------------------------------------------------------------- enable + +if [[ -z $ENABLE_AFTER ]]; then + if ((ASSUME_YES)) || ! interactive; then + ENABLE_AFTER=false + elif confirm "Enable '$m_id' now?"; then + ENABLE_AFTER=true + else + ENABLE_AFTER=false + fi +fi + +if [[ $ENABLE_AFTER == true ]]; then + if omarchy-shell shell setPluginEnabled "$m_id" true >/dev/null 2>&1; then + echo "Enabled $m_id." + if [[ $m_kinds == *bar-widget* ]]; then + echo "It was added to the bar; arrange it with: omarchy plugin bar move $m_id --section " + fi + else + echo "Could not enable $m_id over IPC (is omarchy-shell running?). Enable later with: omarchy plugin enable $m_id" >&2 + fi +else + echo "Enable it later with: omarchy plugin enable $m_id" +fi + +# Reaching here means the files are in place; never let a falsy trailing test +# leak a non-zero status to callers like `omarchy plugin update`. +exit 0 diff --git a/bin/omarchy-plugin-available b/bin/omarchy-plugin-available new file mode 100755 index 00000000..01c517b6 --- /dev/null +++ b/bin/omarchy-plugin-available @@ -0,0 +1,98 @@ +#!/bin/bash + +# omarchy:summary=List plugins available across trusted sources and their install status +# omarchy:group=plugin +# omarchy:args=[--json] [--no-refresh] +# omarchy:examples=omarchy plugin available | omarchy plugin available --json + +set -o pipefail + +PLUGINS_DIR="$HOME/.config/omarchy/plugins" + +fail() { + echo "omarchy-plugin-available: $*" >&2 + exit 1 +} + +command -v jq >/dev/null 2>&1 || fail "jq is required" + +JSON=0 +NO_REFRESH=0 +while (($# > 0)); do + case "$1" in + --json) JSON=1; shift ;; + --no-refresh) NO_REFRESH=1; shift ;; + -h | --help) + cat < +USAGE + exit 0 + ;; + *) fail "unknown option: $1" ;; + esac +done + +if ((!NO_REFRESH)); then + omarchy-plugin-source refresh >/dev/null 2>&1 || true +fi + +CATALOG=$(omarchy-plugin-scan) + +# Annotate each catalogue entry with a stable status token (available | +# installed | update-available | local-newer, matching omarchy-plugin-update's +# vocabulary) plus the installed version, by comparing the installed manifest +# version (if any) against the upstream version. Entries are read as compact +# JSON objects so an empty version field never shifts the parse. +rows='[]' +while IFS= read -r row; do + [[ -n $row ]] || continue + id=$(jq -r '.id' <<<"$row") + name=$(jq -r '.name' <<<"$row") + version=$(jq -r '.version' <<<"$row") + source=$(jq -r '.source' <<<"$row") + + installed="" + [[ -f "$PLUGINS_DIR/$id/manifest.json" ]] && installed=$(jq -r '.version // ""' "$PLUGINS_DIR/$id/manifest.json" 2>/dev/null) + if [[ -z $installed ]]; then + status="available" + elif [[ $installed == "$version" ]]; then + status="installed" + elif [[ $(printf '%s\n%s\n' "$installed" "$version" | sort -V | tail -n1) == "$version" ]]; then + status="update-available" + else + status="local-newer" + fi + + entry=$(jq -n --arg id "$id" --arg name "$name" --arg version "$version" \ + --arg installed "$installed" --arg source "$source" --arg status "$status" \ + '{id:$id, name:$name, version:$version, installedVersion:$installed, source:$source, status:$status}') + rows=$(jq --argjson e "$entry" '. + [$e]' <<<"$rows") +done < <(jq -c '.[] | {id: .manifest.id, name: (.manifest.name // .manifest.id), version: (.manifest.version // ""), source: .sourceId}' <<<"$CATALOG") + +if ((JSON)); then + echo "$rows" + exit 0 +fi + +if [[ $(jq 'length' <<<"$rows") -eq 0 ]]; then + echo "No plugins available. Add a source with: omarchy plugin source add " + exit 0 +fi + +# Render the human status from the token + versions (ASCII arrow, matching update). +jq -r '.[] | [.id, .name, .version, .source, .status, .installedVersion] | @tsv' <<<"$rows" | + awk -F '\t' ' + function human(st, inst, ver) { + if (st == "update-available") return "update (" inst " -> " ver ")" + if (st == "local-newer") return "local-newer (" inst ")" + return st + } + BEGIN { printf "%-26s %-26s %-10s %-30s %s\n", "ID", "NAME", "VERSION", "SOURCE", "STATUS" } + { printf "%-26s %-26s %-10s %-30s %s\n", $1, $2, ("v" ($3=="" ? "?" : $3)), $4, human($5, $6, $3) }' + +echo +echo "Add with: omarchy plugin add " diff --git a/bin/omarchy-plugin-remove b/bin/omarchy-plugin-remove new file mode 100755 index 00000000..26a2e77c --- /dev/null +++ b/bin/omarchy-plugin-remove @@ -0,0 +1,107 @@ +#!/bin/bash + +# omarchy:summary=Remove an installed Omarchy shell plugin +# omarchy:group=plugin +# omarchy:args=[plugin-id] [--yes] +# omarchy:examples=omarchy plugin remove | omarchy plugin remove orbit --yes + +# Disables a plugin and removes it from ~/.config/omarchy/plugins/. A real +# install is moved to a timestamped backup so it can be restored; a dev symlink +# is just unlinked (the files it points at are left alone). + +set -o pipefail + +PLUGINS_DIR="$HOME/.config/omarchy/plugins" + +fail() { + echo "omarchy-plugin-remove: $*" >&2 + exit 1 +} + +interactive() { + [[ -t 0 && -t 1 ]] +} + +PLUGIN_ID="" +ASSUME_YES=0 +while (($# > 0)); do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + -h | --help) + cat <.bak./; a symlink is unlinked. +USAGE + exit 0 + ;; + -*) fail "unknown option: $1" ;; + *) + [[ -z $PLUGIN_ID ]] || fail "unexpected argument: $1" + PLUGIN_ID="$1"; shift ;; + esac +done + +confirm() { + local prompt="$1" + ((ASSUME_YES)) && return 0 + if command -v gum >/dev/null 2>&1 && interactive; then gum confirm "$prompt"; return; fi + if interactive; then local reply; read -r -p "$prompt [y/N] " reply; [[ $reply == [yY]* ]]; return; fi + fail "refusing to remove without confirmation; pass --yes" +} + +[[ -d $PLUGINS_DIR ]] || fail "no plugins installed" + +if [[ -z $PLUGIN_ID ]]; then + mapfile -t ids < <(find "$PLUGINS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) ! -name '.*' -printf '%f\n' 2>/dev/null | sort) + ((${#ids[@]})) || fail "no plugins installed" + interactive || fail "a plugin-id is required" + if command -v gum >/dev/null 2>&1; then + PLUGIN_ID=$(printf '%s\n' "${ids[@]}" | gum choose --header="Remove which plugin?") || fail "cancelled" + elif command -v fzf >/dev/null 2>&1; then + PLUGIN_ID=$(printf '%s\n' "${ids[@]}" | fzf --prompt "Remove which plugin > ") || fail "cancelled" + else + fail "a plugin-id is required (gum or fzf needed for an interactive picker)" + fi + [[ -n $PLUGIN_ID ]] || fail "nothing selected" +fi + +# The id becomes a path under PLUGINS_DIR that we mv/rm, so reject anything that +# could escape it (matches the id rules in omarchy-plugin-validate). +[[ $PLUGIN_ID =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && $PLUGIN_ID != *"/"* && $PLUGIN_ID != *".."* ]] \ + || fail "invalid plugin id '$PLUGIN_ID'" + +target="$PLUGINS_DIR/$PLUGIN_ID" +[[ -e $target || -L $target ]] || fail "plugin '$PLUGIN_ID' is not installed" + +was_enabled="" +if shell_plugins=$(omarchy-shell shell listPlugins 2>/dev/null) && [[ -n $shell_plugins ]]; then + was_enabled=$(jq -r --arg id "$PLUGIN_ID" '.[] | select(.id == $id) | .enabled' <<<"$shell_plugins" 2>/dev/null) +fi + +if [[ -L $target ]]; then + confirm "Unlink '$PLUGIN_ID' (symlink -> $(readlink "$target"))?" || fail "aborted" +else + confirm "Remove '$PLUGIN_ID'? The folder will be backed up." || fail "aborted" +fi + +[[ $was_enabled == "true" ]] && omarchy-shell shell setPluginEnabled "$PLUGIN_ID" false >/dev/null 2>&1 || true + +if [[ -L $target ]]; then + rm -f "$target" || fail "failed to unlink $target" + echo "Unlinked $PLUGIN_ID." +else + base="$PLUGINS_DIR/.${PLUGIN_ID}.bak.$(date -u +%Y%m%d%H%M%S)" + backup="$base"; n=1 + while [[ -e $backup ]]; do backup="${base}-${n}"; n=$((n + 1)); done + mv "$target" "$backup" || fail "failed to move $target to backup" + echo "Removed $PLUGIN_ID. Backup at: $backup" +fi + +omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true + +if [[ $was_enabled == "true" ]]; then + echo "Plugin was enabled; restart the shell to fully unload it:" + echo " omarchy restart shell" +fi diff --git a/bin/omarchy-plugin-scan b/bin/omarchy-plugin-scan new file mode 100755 index 00000000..80394807 --- /dev/null +++ b/bin/omarchy-plugin-scan @@ -0,0 +1,60 @@ +#!/bin/bash + +# omarchy:summary=List every plugin available across configured sources (JSON) +# omarchy:group=plugin +# omarchy:hidden=true + +# Walks the locally cached clones of every trusted source and emits one JSON +# entry per plugin folder it finds. This is the shared catalogue that +# `available`, `install`, and `update` all read; it never touches the network +# (run `omarchy plugin source refresh` first to pull fresh clones). + +set -o pipefail + +SOURCES_FILE="$HOME/.config/omarchy/plugins/sources.json" +CACHE_ROOT="$HOME/.cache/omarchy/plugin-sources" + +command -v jq >/dev/null 2>&1 || { echo "omarchy-plugin-scan: jq is required" >&2; exit 1; } + +if [[ ! -f $SOURCES_FILE ]]; then + echo "[]" + exit 0 +fi + +results='[]' + +# Read each source as a compact JSON object (one per line) rather than a +# delimiter-joined string — no separator to get wrong, and empty fields survive. +while IFS= read -r src_json; do + [[ -n $src_json ]] || continue + src_id=$(jq -r '.id // ""' <<<"$src_json") + src_url=$(jq -r '.url // ""' <<<"$src_json") + src_ref=$(jq -r '.ref // ""' <<<"$src_json") + [[ -n $src_id ]] || continue + + cache_dir="$CACHE_ROOT/$src_id" + [[ -d $cache_dir ]] || continue + + for folder in "$cache_dir"/*/; do + [[ -d $folder ]] || continue + manifest="$folder/manifest.json" + [[ -f $manifest ]] || continue + jq -e . "$manifest" >/dev/null 2>&1 || continue + + folder_name=$(basename "$folder") + abs_path=$(cd "$folder" && pwd) + + entry=$(jq -n \ + --arg sourceId "$src_id" \ + --arg sourceUrl "$src_url" \ + --arg ref "$src_ref" \ + --arg folder "$folder_name" \ + --arg path "$abs_path" \ + --slurpfile manifest "$manifest" \ + '{sourceId:$sourceId, sourceUrl:$sourceUrl, ref:$ref, folder:$folder, path:$path, manifest:$manifest[0]}') || continue + + results=$(jq --argjson e "$entry" '. + [$e]' <<<"$results") + done +done < <(jq -c '.sources[]?' "$SOURCES_FILE") + +echo "$results" diff --git a/bin/omarchy-plugin-source b/bin/omarchy-plugin-source new file mode 100755 index 00000000..3f41b318 --- /dev/null +++ b/bin/omarchy-plugin-source @@ -0,0 +1,372 @@ +#!/bin/bash + +# omarchy:summary=Manage trusted git repos that Omarchy plugins are installed from +# omarchy:group=plugin +# omarchy:args= [...] +# omarchy:examples=omarchy plugin source add | omarchy plugin source list | omarchy plugin source add https://github.com/owner/omarchy-plugins.git --as owner-plugins | omarchy plugin source refresh + +set -o pipefail + +# Never let git block an unattended run on a credential or host-key prompt; fail +# fast instead so callers' `|| true` / error paths can handle it. +export GIT_TERMINAL_PROMPT=0 +export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -oBatchMode=yes}" + +SOURCES_FILE="$HOME/.config/omarchy/plugins/sources.json" +CACHE_ROOT="$HOME/.cache/omarchy/plugin-sources" + +fail() { + echo "omarchy-plugin-source: $*" >&2 + exit 1 +} + +# A source id becomes a cache directory name and a sources.json key, so it must +# never contain path separators or traversal. Used for --as, derived ids, and +# (defensively) any id read back before an rm -rf. +valid_source_id() { + [[ $1 =~ ^[a-z0-9][a-z0-9._-]*$ && $1 != *..* ]] +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required" +} + +interactive() { + [[ -t 0 && -t 1 ]] +} + +# Yes/no prompt. Honours a caller-set ASSUME_YES, falls back to a plain read +# when gum is missing, and refuses in a non-interactive context so an agent +# must pass --yes deliberately rather than hang on a prompt. +confirm() { + local prompt="$1" + [[ ${ASSUME_YES:-0} == 1 ]] && return 0 + if command -v gum >/dev/null 2>&1 && interactive; then + gum confirm "$prompt" + return + fi + if interactive; then + local reply + read -r -p "$prompt [y/N] " reply + [[ $reply == [yY]* ]] + return + fi + fail "refusing to continue without confirmation; pass --yes" +} + +ask_input() { + local prompt="$1" + local value="" + if command -v gum >/dev/null 2>&1; then + gum input --prompt "$prompt " || return 1 + else + read -r -p "$prompt " value || return 1 + printf '%s' "$value" + fi +} + +ensure_sources_file() { + mkdir -p "$(dirname "$SOURCES_FILE")" + if [[ ! -f $SOURCES_FILE ]]; then + # Subshell the umask so it does not leak into the rest of this process. + (umask 077; echo '{"version":1,"sources":[]}' >"$SOURCES_FILE") + fi +} + +# Persist a jq transform of the sources file, then keep it owner-only. +write_sources() { + local filter="$1" + shift + local tmp + tmp=$(mktemp) + if ! jq "$@" "$filter" "$SOURCES_FILE" >"$tmp"; then + rm -f "$tmp" + fail "failed to update $SOURCES_FILE" + fi + mv "$tmp" "$SOURCES_FILE" + chmod 600 "$SOURCES_FILE" 2>/dev/null || true +} + +# owner-repo, lowercased and sanitized, from any git URL form (scp-like or URL). +derive_source_id() { + local url="$1" + url="${url%.git}" + url="${url%/}" + local path + if [[ $url =~ ^[^/]+@[^/]+:(.+)$ ]]; then + path="${BASH_REMATCH[1]}" + else + path="${url#*://}" + path="${path#*/}" + fi + local repo="${path##*/}" + local owner_part="${path%/*}" + local owner="${owner_part##*/}" + local id + if [[ -n $owner && $owner != "$path" ]]; then + id="${owner}-${repo}" + else + id="$repo" + fi + id="${id,,}" + id="${id//[^a-z0-9._-]/-}" + while [[ $id == -* ]]; do id="${id#-}"; done + while [[ $id == *- ]]; do id="${id%-}"; done + printf '%s' "$id" +} + +source_exists() { + jq -e --arg id "$1" '.sources | map(.id) | index($id) != null' "$SOURCES_FILE" >/dev/null 2>&1 +} + +# ---------------------------------------------------------------- add + +source_add() { + require_command jq + require_command git + + local url="" ref="" name="" + ASSUME_YES=0 + while (($# > 0)); do + case "$1" in + --ref) ref="${2:-}"; shift 2 ;; + --as) name="${2:-}"; shift 2 ;; + --yes | -y) ASSUME_YES=1; shift ;; + -h | --help) usage; return 0 ;; + --) shift; break ;; + -*) fail "unknown option: $1" ;; + *) + [[ -z $url ]] || fail "unexpected argument: $1" + url="$1"; shift ;; + esac + done + + if [[ -z $url ]]; then + interactive || fail "a git URL is required (e.g. omarchy plugin source add https://github.com/owner/repo.git)" + url=$(ask_input "Git URL of the plugin source repo:") || fail "cancelled" + [[ -n $url ]] || fail "a git URL is required" + fi + + [[ -n $name ]] || name=$(derive_source_id "$url") + [[ -n $name ]] || fail "could not derive a source id from '$url'; re-run with --as " + valid_source_id "$name" || fail "invalid source id '$name' (lowercase letters, digits, '.', '_', '-'; no '/' or '..')" + + ensure_sources_file + if source_exists "$name"; then + local existing + existing=$(jq -r --arg id "$name" '.sources[] | select(.id == $id) | .url' "$SOURCES_FILE") + fail "source '$name' already exists (url: $existing); remove it first with: omarchy plugin source remove $name" + fi + + cat >&2 <} + + Plugins from this repo run as arbitrary code inside your long-lived + omarchy-shell process. They are not sandboxed. Only add sources you + trust, and review a plugin's code before you install and enable it. + +WARN + confirm "Trust this source?" || fail "aborted" + + local cache_dir="$CACHE_ROOT/$name" + rm -rf "$cache_dir" + mkdir -p "$CACHE_ROOT" + + local clone_args=(--depth 1) + [[ -n $ref ]] && clone_args+=(--branch "$ref") + + echo "Cloning $url ..." + if ! git clone "${clone_args[@]}" -- "$url" "$cache_dir"; then + rm -rf "$cache_dir" + fail "failed to clone $url" + fi + + [[ -n $ref ]] || ref=$(git -C "$cache_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + + local ts + ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + write_sources '.sources += [{id:$id, url:$url, ref:$ref, trustedAt:$ts}]' \ + --arg id "$name" --arg url "$url" --arg ref "$ref" --arg ts "$ts" + + echo "Added source '$name'${ref:+ (ref: $ref)}." + echo "Browse its plugins with: omarchy plugin available" +} + +# ---------------------------------------------------------------- list + +source_list() { + require_command jq + local json=0 + while (($# > 0)); do + case "$1" in + --json) json=1; shift ;; + -h | --help) usage; return 0 ;; + *) fail "unknown list option: $1" ;; + esac + done + + if [[ ! -f $SOURCES_FILE ]]; then + if ((json)); then echo '{"version":1,"sources":[]}'; else echo "No plugin sources configured. Add one with: omarchy plugin source add "; fi + return 0 + fi + + if ((json)); then + cat "$SOURCES_FILE" + return 0 + fi + + if [[ $(jq '.sources | length' "$SOURCES_FILE") -eq 0 ]]; then + echo "No plugin sources configured. Add one with: omarchy plugin source add " + return 0 + fi + + jq -r '.sources[] | [.id, .url, (.ref // "")] | @tsv' "$SOURCES_FILE" | + while IFS=$'\t' read -r id url ref; do + local cache="missing" + [[ -d "$CACHE_ROOT/$id/.git" ]] && cache="cached" + printf '%s\t%s\t%s\t%s\n' "$id" "$url" "${ref:-?}" "$cache" + done | + awk -F '\t' ' + BEGIN { printf "%-34s %-46s %-14s %s\n", "ID", "URL", "REF", "CACHE" } + { printf "%-34s %-46s %-14s %s\n", $1, $2, $3, $4 }' +} + +# ---------------------------------------------------------------- remove + +source_remove() { + require_command jq + local id="" + ASSUME_YES=0 + while (($# > 0)); do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + -h | --help) usage; return 0 ;; + -*) fail "unknown option: $1" ;; + *) + [[ -z $id ]] || fail "unexpected argument: $1" + id="$1"; shift ;; + esac + done + + [[ -f $SOURCES_FILE ]] || fail "no plugin sources configured" + + if [[ -z $id ]]; then + interactive || fail "a source id is required" + if command -v gum >/dev/null 2>&1; then + id=$(jq -r '.sources[].id' "$SOURCES_FILE" | gum choose --header="Remove which plugin source?") || fail "cancelled" + elif command -v fzf >/dev/null 2>&1; then + id=$(jq -r '.sources[].id' "$SOURCES_FILE" | fzf --prompt "Remove which plugin source > ") || fail "cancelled" + else + fail "a source id is required (install gum or fzf for an interactive picker)" + fi + [[ -n $id ]] || fail "nothing selected" + fi + + source_exists "$id" || fail "no such source: $id" + confirm "Remove source '$id'? (installed plugins are left in place)" || fail "aborted" + + write_sources '.sources |= map(select(.id != $id))' --arg id "$id" + # Defense in depth: never rm -rf a cache path built from an id that could + # contain traversal, even though valid_source_id gates ids on the way in. + if valid_source_id "$id"; then + rm -rf "${CACHE_ROOT:?}/$id" + fi + echo "Removed source '$id'." +} + +# ---------------------------------------------------------------- refresh + +refresh_one() { + local id="$1" + valid_source_id "$id" || { echo "omarchy-plugin-source: invalid source id: $id" >&2; return 1; } + local url ref cache_dir + url=$(jq -r --arg id "$id" '.sources[] | select(.id == $id) | .url' "$SOURCES_FILE") + ref=$(jq -r --arg id "$id" '.sources[] | select(.id == $id) | .ref // ""' "$SOURCES_FILE") + cache_dir="$CACHE_ROOT/$id" + + [[ -n $url ]] || { echo "omarchy-plugin-source: no such source: $id" >&2; return 1; } + + if [[ ! -d "$cache_dir/.git" ]]; then + echo "Cache missing for '$id'; re-cloning ..." + rm -rf "$cache_dir" + mkdir -p "$CACHE_ROOT" + local clone_args=(--depth 1) + [[ -n $ref ]] && clone_args+=(--branch "$ref") + git clone "${clone_args[@]}" -- "$url" "$cache_dir" || return 1 + return 0 + fi + + echo "Refreshing '$id' (${ref:-HEAD}) ..." + if ! git -C "$cache_dir" fetch --depth 1 origin "${ref:-HEAD}" >/dev/null 2>&1; then + echo "omarchy-plugin-source: fetch failed for '$id'" >&2 + return 1 + fi + git -C "$cache_dir" reset --hard FETCH_HEAD >/dev/null 2>&1 || return 1 +} + +source_refresh() { + require_command jq + require_command git + local id="" + while (($# > 0)); do + case "$1" in + -h | --help) usage; return 0 ;; + -*) fail "unknown option: $1" ;; + *) + [[ -z $id ]] || fail "unexpected argument: $1" + id="$1"; shift ;; + esac + done + + [[ -f $SOURCES_FILE ]] || fail "no plugin sources configured" + + if [[ -n $id ]]; then + refresh_one "$id" + return $? + fi + + local rc=0 + mapfile -t ids < <(jq -r '.sources[].id' "$SOURCES_FILE") + if ((${#ids[@]} == 0)); then + echo "No plugin sources configured." + return 0 + fi + for sid in "${ids[@]}"; do + refresh_one "$sid" || rc=1 + done + return $rc +} + +usage() { + cat < [args...] + + add [] [--ref ] [--as ] [--yes] + Trust a git repo as a plugin source and clone it locally. + list [--json] + List trusted plugin sources and whether each is cached. + remove [] [--yes] + Forget a source and drop its cache (installed plugins stay). + refresh [] + Pull the latest clone for one source, or all sources. + +Sources are stored in ~/.config/omarchy/plugins/sources.json and cloned +into ~/.cache/omarchy/plugin-sources//. +USAGE +} + +command="${1:-list}" +[[ $# -gt 0 ]] && shift || true +case "$command" in +add) source_add "$@" ;; +list | ls) source_list "$@" ;; +remove | rm) source_remove "$@" ;; +refresh | update) source_refresh "$@" ;; +-h | --help | help) usage ;; +*) fail "unknown command: $command (use add, list, remove, or refresh)" ;; +esac diff --git a/bin/omarchy-plugin-update b/bin/omarchy-plugin-update new file mode 100755 index 00000000..a8bb43b0 --- /dev/null +++ b/bin/omarchy-plugin-update @@ -0,0 +1,215 @@ +#!/bin/bash + +# omarchy:summary=Update installed Omarchy plugins from their sources +# omarchy:group=plugin +# omarchy:args=[plugin-id] [--all] [--force] [--review] [--no-refresh] [--yes] +# omarchy:examples=omarchy plugin update | omarchy plugin update orbit --review | omarchy plugin update --all --yes + +# Re-pulls a plugin's folder from its source when the upstream manifest version +# is newer. Updates are just re-installs, so the same "review the code" rule +# applies — by default this prints a diff of exactly what changed before +# touching anything. --yes skips the diff and confirmation for scripts/agents. + +set -o pipefail + +PLUGINS_DIR="$HOME/.config/omarchy/plugins" + +fail() { + echo "omarchy-plugin-update: $*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required" +} + +interactive() { + [[ -t 0 && -t 1 ]] +} + +PLUGIN_ID="" +ALL=0 +FORCE=0 +DO_REVIEW=0 +NO_REFRESH=0 +ASSUME_YES=0 + +while (($# > 0)); do + case "$1" in + --all | -a) ALL=1; shift ;; + --force | -f) FORCE=1; shift ;; + --review) DO_REVIEW=1; shift ;; + --no-refresh) NO_REFRESH=1; shift ;; + --yes | -y) ASSUME_YES=1; shift ;; + -h | --help) + cat < update one plugin + --all update every plugin with an available update + --force re-pull even when versions match (or local looks newer) + --review always show the file diff before updating (default in a TTY) + --no-refresh use cached clones, skip the network refresh + --yes skip the diff and confirmation +USAGE + exit 0 + ;; + -*) fail "unknown option: $1" ;; + *) + [[ -z $PLUGIN_ID ]] || fail "unexpected argument: $1" + PLUGIN_ID="$1"; shift ;; + esac +done + +require_command jq +((ALL)) && [[ -n $PLUGIN_ID ]] && fail "pass either a plugin-id or --all, not both" + +confirm() { + local prompt="$1" + ((ASSUME_YES)) && return 0 + if command -v gum >/dev/null 2>&1 && interactive; then gum confirm "$prompt"; return; fi + if interactive; then local reply; read -r -p "$prompt [y/N] " reply; [[ $reply == [yY]* ]]; return; fi + fail "refusing to update without confirmation; pass --yes" +} + +if ((!NO_REFRESH)); then + omarchy-plugin-source refresh >/dev/null 2>&1 || true +fi + +CATALOG=$(omarchy-plugin-scan 2>/dev/null || echo "[]") + +# Map of currently enabled third-party plugins, so we can preserve state. +declare -A enabled_map +if shell_plugins=$(omarchy-shell shell listPlugins 2>/dev/null) && [[ -n $shell_plugins ]]; then + while IFS=$'\t' read -r pid pen; do + [[ -n $pid ]] && enabled_map["$pid"]="$pen" + done < <(jq -r '.[] | select(.firstParty == false) | [.id, (.enabled|tostring)] | @tsv' <<<"$shell_plugins" 2>/dev/null) +fi + +# Echoes "\t\t\t\t" +plugin_status() { + local id="$1" installed_version="$2" + local up + up=$(jq -c --arg id "$id" 'map(select(.manifest.id == $id)) | .[0] // empty' <<<"$CATALOG") + if [[ -z $up ]]; then + printf 'unmanaged\t\t%s\t\t\n' "$installed_version" + return + fi + local upstream_version src_id cache_path status + upstream_version=$(jq -r '.manifest.version // ""' <<<"$up") + src_id=$(jq -r '.sourceId' <<<"$up") + cache_path=$(jq -r '.path' <<<"$up") + if [[ -z $installed_version || -z $upstream_version ]]; then + status="unknown" + elif [[ $installed_version == "$upstream_version" ]]; then + status="up-to-date" + elif [[ $(printf '%s\n%s\n' "$installed_version" "$upstream_version" | sort -V | tail -n1) == "$upstream_version" ]]; then + status="update-available" + else + status="local-newer" + fi + printf '%s\t%s\t%s\t%s\t%s\n' "$status" "$src_id" "$installed_version" "$upstream_version" "$cache_path" +} + +installed_version_of() { + local id="$1" + [[ -f "$PLUGINS_DIR/$id/manifest.json" ]] || return 1 + jq -r '.version // ""' "$PLUGINS_DIR/$id/manifest.json" 2>/dev/null +} + +# ---------------------------------------------------------------- pick targets + +[[ -d $PLUGINS_DIR ]] || fail "no plugins installed" + +declare -a targets=() +if ((ALL)); then + for d in "$PLUGINS_DIR"/*/; do + [[ -d $d ]] || continue + id=$(basename "$d"); [[ $id == .* ]] && continue + iv=$(installed_version_of "$id") || continue + IFS=$'\t' read -r status _ _ _ _ < <(plugin_status "$id" "$iv") + [[ $status == "update-available" ]] && targets+=("$id") + done + ((${#targets[@]})) || { echo "No updates available."; exit 0; } +elif [[ -n $PLUGIN_ID ]]; then + installed_version_of "$PLUGIN_ID" >/dev/null || fail "plugin '$PLUGIN_ID' is not installed" + targets=("$PLUGIN_ID") +else + interactive || fail "a plugin-id is required (or pass --all)" + outdated="" + for d in "$PLUGINS_DIR"/*/; do + [[ -d $d ]] || continue + id=$(basename "$d"); [[ $id == .* ]] && continue + iv=$(installed_version_of "$id") || continue + IFS=$'\t' read -r status _ instv upv _ < <(plugin_status "$id" "$iv") + [[ $status == "update-available" ]] && outdated+="$id"$'\t'"$instv -> $upv"$'\n' + done + [[ -n $outdated ]] || { echo "No updates available."; exit 0; } + rows=$(awk -F '\t' 'NF{printf "%-28s %s\n", $1, $2}' <<<"$outdated") + if command -v gum >/dev/null 2>&1; then + pick=$(gum choose --no-limit --header="Update which plugins? (space toggles, enter confirms)" <<<"$rows") || fail "cancelled" + elif command -v fzf >/dev/null 2>&1; then + pick=$(fzf --multi --prompt "Update which plugins (tab to mark) > " <<<"$rows") || fail "cancelled" + else + fail "a plugin-id is required (install gum or fzf for an interactive picker, or pass --all)" + fi + [[ -n $pick ]] || { echo "Nothing selected."; exit 0; } + mapfile -t targets < <(awk '{print $1}' <<<"$pick") +fi + +# ---------------------------------------------------------------- apply + +show_diff() { + local installed="$1" cache="$2" + echo "Changes for $3 ($4 -> $5):" + if command -v delta >/dev/null 2>&1; then + diff -ruN "$installed" "$cache" | delta --paging=never + else + diff -ruN "$installed" "$cache" + fi + echo +} + +any_enabled=0 +rc=0 +for id in "${targets[@]}"; do + iv=$(installed_version_of "$id") || { echo "Skipping $id: not installed." >&2; rc=1; continue; } + IFS=$'\t' read -r status src_id instv upv cache_path < <(plugin_status "$id" "$iv") + + case "$status" in + unmanaged) echo "Skipping $id: not found in any configured source." >&2; rc=1; continue ;; + unknown) echo "Skipping $id: missing version metadata to compare." >&2; rc=1; continue ;; + up-to-date) + ((FORCE)) || { echo "Skipping $id: already up to date ($instv). Use --force to re-pull."; continue; } ;; + local-newer) + ((FORCE)) || { echo "Skipping $id: installed $instv is newer than upstream $upv. Use --force to overwrite." >&2; rc=1; continue; } ;; + esac + + if ((!ASSUME_YES)); then + if ((DO_REVIEW)) || interactive; then + show_diff "$PLUGINS_DIR/$id" "$cache_path" "$id" "$instv" "$upv" + fi + confirm "Update $id ($instv -> $upv)?" || { echo "Skipped $id."; continue; } + fi + + echo "Updating $id ..." + install_args=("$id" --from "$src_id" --no-refresh --yes) + case "${enabled_map[$id]:-}" in + true) install_args+=(--enable); any_enabled=1 ;; + false) install_args+=(--no-enable) ;; + esac + if ! omarchy-plugin-add "${install_args[@]}"; then + echo "Failed to update $id" >&2 + rc=1 + fi +done + +if ((any_enabled)); then + echo + echo "Updated plugins that were enabled may need a shell restart to fully reload:" + echo " omarchy restart shell" +fi + +exit $rc diff --git a/bin/omarchy-plugin-validate b/bin/omarchy-plugin-validate new file mode 100755 index 00000000..a7d99b07 --- /dev/null +++ b/bin/omarchy-plugin-validate @@ -0,0 +1,96 @@ +#!/bin/bash + +# omarchy:summary=Validate a plugin folder against the Omarchy plugin manifest schema +# omarchy:group=plugin +# omarchy:args= +# omarchy:examples=omarchy plugin validate ./my-plugin + +# Mirrors the checks in shell/services/PluginRegistry.qml so the CLI refuses to +# install anything the running shell would silently reject (or worse, load +# unsafely). The shell never trusts manifests blindly; neither do we. + +set -o pipefail + +fail() { + echo "omarchy-plugin-validate: $*" >&2 + exit 1 +} + +if [[ ${1:-} == -h || ${1:-} == --help ]]; then + cat < + +Checks a plugin folder's manifest.json against the schema the shell enforces: +schemaVersion, required fields, safe relative entry points that exist, no +symlinks, and an id that is not reserved. Exits 0 if valid — handy for plugin +authors before publishing. +USAGE + exit 0 +fi + +command -v jq >/dev/null 2>&1 || fail "jq is required" + +PLUGIN_DIR="${1:-}" +[[ -n $PLUGIN_DIR && -d $PLUGIN_DIR ]] || fail "plugin folder not found: ${PLUGIN_DIR:-}" + +MANIFEST="$PLUGIN_DIR/manifest.json" +[[ -f $MANIFEST ]] || fail "missing manifest.json in $PLUGIN_DIR" +jq -e . "$MANIFEST" >/dev/null 2>&1 || fail "manifest.json is not valid JSON: $MANIFEST" + +# schemaVersion must be exactly the JSON number 1 (the only version the registry +# understands). jq's == is type-aware, so the string "1" is correctly rejected +# just as the QML `schemaVersion !== 1` check rejects it. +jq -e '.schemaVersion == 1' "$MANIFEST" >/dev/null 2>&1 \ + || fail "unsupported or missing schemaVersion (expected 1) in $MANIFEST" + +for field in id name version kinds entryPoints; do + jq -e --arg f "$field" 'has($f)' "$MANIFEST" >/dev/null 2>&1 \ + || fail "manifest missing required field '$field'" +done + +ID=$(jq -r '.id // ""' "$MANIFEST") +[[ -n $ID ]] || fail "manifest 'id' is empty" +[[ $ID =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "invalid plugin id '$ID'" +[[ $ID != *"/"* && $ID != *".."* ]] || fail "invalid plugin id '$ID'" +[[ $ID != omarchy.* ]] || fail "plugin id '$ID' uses the reserved omarchy.* namespace" + +# kinds must be a non-empty array. +jq -e '(.kinds | type) == "array" and (.kinds | length) > 0' "$MANIFEST" >/dev/null 2>&1 \ + || fail "'kinds' must be a non-empty array" + +# entryPoints must be an object and every value a safe relative path that exists. +jq -e '(.entryPoints | type) == "object"' "$MANIFEST" >/dev/null 2>&1 \ + || fail "'entryPoints' must be an object" + +# Read each entry point as a JSON-encoded string (one per line), then decode it, +# so a value that itself contains a newline stays one literal path instead of +# being split into fragments that each pass the checks. +while IFS= read -r ep_json; do + [[ -n $ep_json ]] || continue + ep=$(jq -r '.' <<<"$ep_json") + [[ -n $ep ]] || fail "entry point path is empty" + [[ $ep != *$'\n'* ]] || fail "entry point may not contain a newline" + [[ $ep != /* ]] || fail "entry point must be a relative path: '$ep'" + [[ $ep != *".."* ]] || fail "entry point may not contain '..': '$ep'" + [[ -f "$PLUGIN_DIR/$ep" ]] || fail "entry point file not found: '$ep'" +done < <(jq -c '.entryPoints | to_entries[] | .value' "$MANIFEST") + +# Refuse any symlink anywhere inside the plugin folder. Symlinks could point a +# copied plugin back at arbitrary files on disk after it lands in the trusted +# plugins directory. +link=$(find "$PLUGIN_DIR" -type l -print -quit 2>/dev/null) +[[ -z $link ]] || fail "symlinks are not allowed inside a plugin folder: $link" + +# The whole omarchy.* namespace plus every shipped first-party id is reserved. +# A third-party plugin claiming one of those ids would be rejected by the shell +# and could shadow built-in behaviour, so refuse it here too. +FIRST_PARTY_DIR="${OMARCHY_PATH:-$HOME/.local/share/omarchy}/shell/plugins" +if [[ -d $FIRST_PARTY_DIR ]]; then + while IFS= read -r fp_manifest; do + [[ -f $fp_manifest ]] || continue + fp_id=$(jq -r '.id // empty' "$fp_manifest" 2>/dev/null || true) + [[ $fp_id == "$ID" ]] && fail "plugin id '$ID' collides with a first-party Omarchy plugin" + done < <(find "$FIRST_PARTY_DIR" -type f \( -name manifest.json -o -name '*.manifest.json' \) 2>/dev/null) +fi + +exit 0 diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 0afbfce1..02cf2c8e 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -39,14 +39,28 @@ Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegis ## Installing a third-party plugin -1. Drop into `~/.config/omarchy/plugins//` with a `manifest.json` - plus the QML referenced from `entryPoints`. -2. `omarchy plugin rescan` -3. `omarchy plugin enable ` -4. Bar widgets also need adding to a section with `omarchy plugin bar add `. +Plugins come from **source repos** — a git repo where every top-level folder is +a plugin with its own `manifest.json`. Trust a repo, then install from it: -The lower-level IPC methods remain available through `omarchy-shell shell ...` -for callers that need to talk directly to the running shell. +```bash +omarchy plugin source add https://github.com/owner/omarchy-plugins.git +omarchy plugin available # what your sources offer +omarchy plugin add some-widget # validate, copy, offer to enable +omarchy plugin update --all # shows a diff before applying +omarchy plugin remove some-widget +``` + +Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding a source and +installing both warn you and let you review the manifest, the files, and (on +update) a diff before anything is copied or enabled. Commands prompt when run +bare in a terminal and run unattended when given arguments — add `--yes` to skip +every prompt (the path for scripts and agents). + +Sources are recorded in `~/.config/omarchy/plugins/sources.json` and cloned into +`~/.cache/omarchy/plugin-sources/`. You can still install by hand: drop a plugin +into `~/.config/omarchy/plugins//`, run `omarchy plugin rescan`, then +`omarchy plugin enable ` (bar widgets also need `omarchy plugin bar add `). +The lower-level IPC methods remain available through `omarchy-shell shell ...`. ## IPC diff --git a/shell/README.md b/shell/README.md index 5eec0fee..c33d5fb2 100644 --- a/shell/README.md +++ b/shell/README.md @@ -91,14 +91,50 @@ The full schema lives in `services/PluginRegistry.qml`. ## Installing a third-party plugin -1. Drop the plugin into `~/.config/omarchy/plugins//`. - The directory must contain a `manifest.json` plus the QML files - referenced from its `entryPoints`. -2. `omarchy plugin rescan`. -3. Enable the plugin with `omarchy plugin enable `. -4. If it's a `bar-widget`, place it with `omarchy plugin bar add `. +Plugins are distributed as **source repos**: a git repo where every top-level +folder is one plugin with its own `manifest.json`. Trust a repo, then install +individual plugins from it. Everything lands in `~/.config/omarchy/plugins//`. -The lower-level IPC equivalents are still available via `omarchy-shell shell rescanPlugins`, +```bash +omarchy plugin source add https://github.com/owner/omarchy-plugins.git +omarchy plugin available # list what your sources offer +omarchy plugin add some-widget # validate, copy, offer to enable +omarchy plugin update some-widget # shows a diff of changes first +omarchy plugin update --all +omarchy plugin remove some-widget +``` + +> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding a source +> and installing both warn you and let you review the manifest, the files, and +> (on update) a diff of the changes before anything is copied or enabled. Only +> trust sources and plugins whose code you are willing to run. + +Each command is **interactive** when run bare in a terminal (gum/fzf pickers, +confirmation, a diff to review) and fully **non-interactive** when given +arguments. Pass `--yes` to skip every prompt — this is the path for scripts and +AI agents: + +```bash +omarchy plugin source add --as acme --yes +omarchy plugin add acme-weather --from acme --enable --yes +omarchy plugin update --all --yes +``` + +Sources live in `~/.config/omarchy/plugins/sources.json`; their clones are +cached under `~/.cache/omarchy/plugin-sources/`. The installer never runs +plugin code, install hooks, or sudo — it only copies files and toggles enabled +state over shell IPC. + +### Installing by hand + +You can still drop a plugin in without a source: + +1. Put it in `~/.config/omarchy/plugins//` with a `manifest.json` + plus the QML referenced from its `entryPoints`. +2. `omarchy plugin rescan`. +3. `omarchy plugin enable ` (bar widgets also need `omarchy plugin bar add `). + +The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, `omarchy-shell shell setPluginEnabled true`, and `omarchy-shell shell listPlugins`. The `omarchy plugin` command wraps those calls and can also edit the persisted bar layout in `shell.json`.