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:
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
|
||||
Reference in New Issue
Block a user