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 <add|list|remove|refresh>
  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').
This commit is contained in:
Ryan Hughes
2026-05-29 16:55:15 -04:00
parent 5bab2dd230
commit 1bb439476a
10 changed files with 1355 additions and 14 deletions
+46
View File
@@ -22,6 +22,19 @@ Plugin commands:
edit [id] [--with <ai|editor>] [--prompt <prompt>]
Open a user plugin for editing
Add from sources (see 'omarchy plugin <command> --help'):
source <add|list|remove|refresh> Manage trusted plugin source repos
available List plugins offered by your sources
add [id] [--from <src>] [--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 <plugin-folder> 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 <name> Display name for the clone
--with <ai|editor> 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-<command>). 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
;;
+297
View File
@@ -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 <source-id>] [--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/<id>/. 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 <<USAGE
Usage: omarchy plugin add [plugin-id] [--from <source-id>] [--enable] [--no-enable] [--review] [--no-refresh] [--yes]
Adds a plugin from a trusted source into ~/.config/omarchy/plugins/<id>/.
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 <git-url>"
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 <source-id>"
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 <<INFO
Plugin: $m_name ($m_id)
Version: $m_version${m_author:+
Author: $m_author}${m_license:+
License: $m_license}
Kinds: $m_kinds
Source: $src_id ($src_url)${m_desc:+
About: $m_desc}
INFO
if ((target_is_link)); then
echo " Target: $target (REPLACING a symlink -> $(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 <left|center|right>"
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
+98
View File
@@ -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
Usage: omarchy plugin available [--json] [--no-refresh]
Lists every plugin found across your trusted sources, with whether it is
installed and whether an update is available. Add one with:
omarchy plugin add <plugin-id>
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 <git-url>"
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 <id>"
+107
View File
@@ -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 <<USAGE
Usage: omarchy plugin remove [plugin-id] [--yes]
Disables the plugin and removes it from ~/.config/omarchy/plugins/.
A real install is backed up to .<id>.bak.<timestamp>/; 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
+60
View File
@@ -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"
+372
View File
@@ -0,0 +1,372 @@
#!/bin/bash
# omarchy:summary=Manage trusted git repos that Omarchy plugins are installed from
# omarchy:group=plugin
# omarchy:args=<add|list|remove|refresh> [...]
# 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 <name>"
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 <<WARN
⚠️ You are about to trust a new plugin source.
Name: $name
URL: $url
Ref: ${ref:-<default branch>}
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 <git-url>"; 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 <git-url>"
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 <<USAGE
Usage: omarchy plugin source <command> [args...]
add [<git-url>] [--ref <ref>] [--as <name>] [--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 [<source-id>] [--yes]
Forget a source and drop its cache (installed plugins stay).
refresh [<source-id>]
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/<source-id>/.
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
+215
View File
@@ -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 <<USAGE
Usage: omarchy plugin update [plugin-id] [--all] [--force] [--review] [--no-refresh] [--yes]
Updates installed plugins when their source has a newer version.
(no args) pick from the plugins that have an update available
<plugin-id> 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 "<status>\t<source-id>\t<installed-version>\t<upstream-version>\t<cache-path>"
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
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# omarchy:summary=Validate a plugin folder against the Omarchy plugin manifest schema
# omarchy:group=plugin
# omarchy:args=<plugin-folder>
# 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 <<USAGE
Usage: omarchy plugin validate <plugin-folder>
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:-<none>}"
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