mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-01 20:28:16 +02:00
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:
@@ -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
|
||||
;;
|
||||
|
||||
Executable
+297
@@ -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
|
||||
Executable
+98
@@ -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>"
|
||||
Executable
+107
@@ -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
|
||||
Executable
+60
@@ -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"
|
||||
Executable
+372
@@ -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
|
||||
Executable
+215
@@ -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
|
||||
Executable
+96
@@ -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
|
||||
Reference in New Issue
Block a user