From 798d6af8b0f9f278836804eb522b7d4601f95bb6 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 2 Jul 2026 21:47:33 -0700 Subject: [PATCH] Replace the plugin package manager with plain git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin is now just a git repo cloned into ~/.config/omarchy/plugins//. That one idea replaces the entire homegrown package-manager half of the plugin suite: trusted-source registry, clone cache, catalog scanning, semver comparison, staging dirs, and timestamped backups — 1,025 lines across five binaries whose jobs git already does. Gone: - omarchy-plugin-source: the trusted-repo registry (sources.json) and its clone cache under ~/.cache/omarchy/plugin-sources/. The trust decision now happens once, at add time, with the same unsandboxed-code warning. - omarchy-plugin-scan + omarchy-plugin-available: the catalog machinery over cached clones. Discovery belongs on a web page, not in the CLI. - omarchy-plugin-add: copying folders out of cached source clones with hand-rolled staging and .bak backups. Replaced by a git clone. - omarchy-plugin-update: manifest version comparison via sort -V and re-installs. Replaced by fetch + diff + fast-forward; git is the version and git is the backup. - omarchy-plugin-remove and omarchy-plugin-edit as separate binaries: folded into omarchy-plugin, much slimmer. The consolidated omarchy-plugin now handles the full lifecycle: - add : warn, clone into a dot-prefixed staging dir (invisible to the plugin scanner), validate, then move into place named by the manifest id. Plugins land disabled — enabling is the single consent moment, replacing the old review-before-copy flow. - update [id | --all]: fetch origin HEAD, show the diff (delta when available), confirm, fast-forward. Updates are code the shell will run, so the result is re-validated and rolled back to ORIG_HEAD if upstream turned invalid (e.g. smuggled a symlink). - remove [id]: git checkouts are deleted outright since upstream keeps the history; hand-made plugin folders still get a backup, and dev symlinks are just unlinked. - edit [id]: opens the user plugin directory in a shell. All commands keep the interactive/unattended split: gum prompts in a terminal, hard refusal without --yes otherwise, so scripts and agents never hang on a hidden prompt. Kept as siblings: omarchy-plugin-catalog (omarchy-bar reads it), omarchy-plugin-validate (the security boundary, now pruning .git from its symlink scan since installs are git checkouts), and omarchy-plugin-clone (local development of built-in widgets, a separate concern). Trade-offs accepted: one repo = one plugin (no more multi-plugin source repos), and ref pinning or branch switching is no longer a CLI feature — an installed plugin is a plain checkout, so that is ordinary git in the plugin directory. None of the removed machinery ever shipped: it existed only on this branch, so there is no migration. The net effect is 11 scripts / 2,040 lines down to 4 scripts / 1,080 lines, and one less concept for users to learn — everyone already knows what a git repo is. Co-Authored-By: Claude Fable 5 --- bin/omarchy-plugin | 393 +++++++++++++++++++++++++++++++---- bin/omarchy-plugin-add | 305 --------------------------- bin/omarchy-plugin-available | 98 --------- bin/omarchy-plugin-edit | 157 -------------- bin/omarchy-plugin-remove | 99 --------- bin/omarchy-plugin-scan | 60 ------ bin/omarchy-plugin-source | 354 ------------------------------- bin/omarchy-plugin-update | 207 ------------------ bin/omarchy-plugin-validate | 5 +- docs/file-layout.md | 2 +- docs/omarchy-shell.md | 28 ++- shell/README.md | 37 ++-- 12 files changed, 390 insertions(+), 1355 deletions(-) delete mode 100755 bin/omarchy-plugin-add delete mode 100755 bin/omarchy-plugin-available delete mode 100755 bin/omarchy-plugin-edit delete mode 100755 bin/omarchy-plugin-remove delete mode 100755 bin/omarchy-plugin-scan delete mode 100755 bin/omarchy-plugin-source delete mode 100755 bin/omarchy-plugin-update diff --git a/bin/omarchy-plugin b/bin/omarchy-plugin index 0ef6fce2..ce57deae 100755 --- a/bin/omarchy-plugin +++ b/bin/omarchy-plugin @@ -2,49 +2,50 @@ # omarchy:summary=Manage Omarchy shell plugins and bar widgets # omarchy:group=plugin -# omarchy:args= [...] -# omarchy:examples=omarchy plugin list | omarchy plugin clone omarchy.clock local.clock | omarchy plugin edit local.clock | omarchy plugin enable acme.weather --section right | omarchy plugin bar settings +# omarchy:args= [...] +# omarchy:examples=omarchy plugin list | omarchy plugin add https://github.com/acme/omarchy-weather.git | omarchy plugin update --all | omarchy plugin clone omarchy.clock local.clock | omarchy plugin bar settings set -euo pipefail +PLUGINS_DIR="$HOME/.config/omarchy/plugins" + +# Never let git block an unattended run on a credential or host-key prompt; fail +# fast instead so error paths can handle it. +export GIT_TERMINAL_PROMPT=0 +export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -oBatchMode=yes}" + usage() { cat < [args...] -Plugin commands: - list [--json] List discovered shell plugins - rescan Rescan ~/.config/omarchy/plugins - enable [placement] Enable a plugin - disable Disable a plugin - clone [source] [new-id] [options] Clone a built-in or user plugin - edit [id] Open a user plugin directory in a shell +Manage plugins: + list [--json] List discovered shell plugins + rescan Rescan ~/.config/omarchy/plugins + enable [placement] Enable a plugin + disable Disable a plugin -Add from sources (see 'omarchy plugin --help'): - source Manage trusted plugin source repos - available List plugins offered by your sources - add [id] [--from ] [--enable] [--review] [--yes] - Add a plugin from a trusted source - update [id] [--all] [--review] [--yes] - Update added plugins (shows a diff) - remove [id] [--yes] Remove an installed plugin - validate Check a plugin's manifest (for authors) +Install from git (a plugin is a git repo): + add [git-url] [--enable] [--yes] Clone a plugin repo into your plugins + update [id | --all] [--yes] Fetch, review the diff, fast-forward + remove [id] [--yes] Disable and delete an installed plugin - Source/add commands run their own binaries; the rest are handled here. Plugins are unsandboxed code — review what you add and enable. +Make your own: + clone [source] [new-id] [options] Clone a built-in or user plugin + edit [id] Open a user plugin directory in a shell + validate Check a plugin's manifest (for authors) + Bar commands (delegate to 'omarchy bar'; run 'omarchy bar --help' for details): - bar settings Open the inline bar config panel + bar settings Open the inline bar config panel bar list|layout|options|use|reset|add|move|remove|set|position|transparent - widget|widgets Alias of 'bar list' + widget|widgets Alias of 'bar list' Examples: - omarchy plugin list - omarchy plugin rescan + omarchy plugin add https://github.com/acme/omarchy-weather.git --enable + omarchy plugin update --all omarchy plugin clone omarchy.clock local.clock --name "My Clock" --replace - omarchy plugin edit local.clock omarchy plugin enable acme.weather --section right - omarchy plugin bar add acme.weather --section right - omarchy plugin bar set omarchy.clock format HH:mm USAGE } @@ -61,6 +62,33 @@ require_command() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required" } +interactive() { + [[ -t 0 && -t 1 ]] +} + +# Yes/no prompt. Honours ASSUME_YES, and refuses in a non-interactive context so +# an agent must pass --yes deliberately rather than hang on a prompt. +ASSUME_YES=0 +confirm() { + local prompt="$1" + (( ASSUME_YES )) && return 0 + if interactive; then + gum confirm "$prompt" + return + fi + fail "refusing to continue without confirmation; pass --yes" +} + +# Plugin ids become paths under PLUGINS_DIR that we mv/rm, so reject anything +# that could escape it (matches the id rules in omarchy-plugin-validate). +valid_plugin_id() { + [[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && $1 != *..* ]] +} + +installed_plugin_ids() { + find "$PLUGINS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) ! -name '.*' -printf '%f\n' 2>/dev/null | sort +} + plugin_discovered() { local id="$1" plugins plugins=$(omarchy-shell shell listPlugins 2>/dev/null) || return 1 @@ -154,6 +182,290 @@ plugin_enabled() { fi } +# ---------------------------------------------------------------- add + +plugin_add() { + require_command git + require_command jq + + local url="" enable_after="" + while (( $# > 0 )); do + case "$1" in + --enable) enable_after=true; shift ;; + --no-enable) enable_after=false; shift ;; + --yes | -y) ASSUME_YES=1; shift ;; + -h | --help) usage; return 0 ;; + -*) fail "unknown add 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 add https://github.com/acme/omarchy-weather.git)" + url=$(gum input --prompt "Git URL of the plugin repo: ") || fail "cancelled" + [[ -n $url ]] || fail "a git URL is required" + fi + + if (( ! ASSUME_YES )); then + cat >&2 </dev/null 2>&1 || true + + if [[ -z $enable_after ]]; then + if (( ASSUME_YES )) || ! interactive; then + enable_after=false + elif confirm "Enable '$id' now?"; then + enable_after=true + else + enable_after=false + fi + fi + + if [[ $enable_after == true ]]; then + if wait_for_plugin_discovery "$id" && [[ $(omarchy-shell shell setPluginEnabled "$id" true) == "ok" ]]; then + echo "Enabled $id" + else + echo "Could not enable $id (is omarchy-shell running?). Enable later with: omarchy plugin enable $id" >&2 + fi + else + echo "Enable it later with: omarchy plugin enable $id" + fi +} + +# ---------------------------------------------------------------- update + +UPDATED_ANY=0 + +update_one() { + local id="$1" + local dir="$PLUGINS_DIR/$id" + + if ! git -C "$dir" fetch --quiet origin HEAD; then + echo "omarchy-plugin: fetch failed for '$id'" >&2 + return 1 + fi + + if [[ $(git -C "$dir" rev-parse HEAD) == $(git -C "$dir" rev-parse FETCH_HEAD) ]]; then + echo "$id is up to date." + return 0 + fi + + if (( ! ASSUME_YES )); then + echo "Changes for $id:" + if command -v delta >/dev/null 2>&1; then + git -C "$dir" diff HEAD FETCH_HEAD | delta --paging=never + else + git -C "$dir" diff HEAD FETCH_HEAD + fi + echo + confirm "Update $id?" || { echo "Skipped $id."; return 0; } + fi + + if ! git -C "$dir" merge --ff-only FETCH_HEAD >/dev/null 2>&1; then + echo "omarchy-plugin: cannot fast-forward '$id'; you have local changes in $dir" >&2 + return 1 + fi + + # An update is code the shell will run, same as an add: re-validate, and roll + # back to the pre-merge commit if upstream turned invalid. + if ! omarchy-plugin-validate "$dir"; then + git -C "$dir" reset --hard ORIG_HEAD >/dev/null + echo "omarchy-plugin: update of '$id' failed validation; rolled back" >&2 + return 1 + fi + + echo "Updated $id." + UPDATED_ANY=1 +} + +plugin_update() { + require_command git + + local id="" all=0 + while (( $# > 0 )); do + case "$1" in + --all | -a) all=1; shift ;; + --yes | -y) ASSUME_YES=1; shift ;; + -h | --help) usage; return 0 ;; + -*) fail "unknown update option: $1" ;; + *) + [[ -z $id ]] || fail "unexpected argument: $1" + id="$1"; shift ;; + esac + done + if (( all )) && [[ -n $id ]]; then + fail "pass either a plugin-id or --all, not both" + fi + + [[ -d $PLUGINS_DIR ]] || fail "no plugins installed" + + local -a targets=() + if [[ -n $id ]]; then + valid_plugin_id "$id" || fail "invalid plugin id '$id'" + [[ -d "$PLUGINS_DIR/$id" ]] || fail "plugin '$id' is not installed" + [[ -d "$PLUGINS_DIR/$id/.git" ]] || fail "plugin '$id' is not a git checkout, so there is nothing to pull from" + targets=("$id") + else + local dir + for dir in "$PLUGINS_DIR"/*/; do + [[ -d $dir/.git ]] || continue + targets+=("$(basename "$dir")") + done + if (( ${#targets[@]} == 0 )); then + echo "No git-managed plugins installed." + return 0 + fi + fi + + local rc=0 + for id in "${targets[@]}"; do + update_one "$id" || rc=1 + done + + if (( UPDATED_ANY )); then + omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true + fi + return $rc +} + +# ---------------------------------------------------------------- remove + +plugin_remove() { + local id="" + while (( $# > 0 )); do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + -h | --help) usage; return 0 ;; + -*) fail "unknown remove option: $1" ;; + *) + [[ -z $id ]] || fail "unexpected argument: $1" + id="$1"; shift ;; + esac + done + + [[ -d $PLUGINS_DIR ]] || fail "no plugins installed" + + if [[ -z $id ]]; then + interactive || fail "a plugin-id is required" + id=$(installed_plugin_ids | gum choose --header="Remove which plugin?") || fail "cancelled" + [[ -n $id ]] || fail "nothing selected" + fi + valid_plugin_id "$id" || fail "invalid plugin id '$id'" + + local target="$PLUGINS_DIR/$id" + [[ -e $target || -L $target ]] || fail "plugin '$id' is not installed" + + local was_enabled="" + local shell_plugins + if shell_plugins=$(omarchy-shell shell listPlugins 2>/dev/null) && [[ -n $shell_plugins ]]; then + was_enabled=$(jq -r --arg id "$id" '.[] | select(.id == $id) | .enabled' <<<"$shell_plugins" 2>/dev/null) || true + fi + + if [[ -L $target ]]; then + confirm "Unlink '$id' (symlink -> $(readlink "$target"))?" || fail "aborted" + elif [[ -d $target/.git ]]; then + confirm "Delete '$id'? Its git repo remains upstream." || fail "aborted" + else + confirm "Remove '$id'? The folder will be backed up." || fail "aborted" + fi + + [[ $was_enabled == "true" ]] && omarchy-shell shell setPluginEnabled "$id" false >/dev/null 2>&1 || true + + if [[ -L $target ]]; then + # A dev symlink is just unlinked; the files it points at are left alone. + rm -f "$target" + echo "Unlinked $id." + elif [[ -d $target/.git ]]; then + rm -rf "$target" + echo "Removed $id." + else + # A hand-made plugin may be the user's only copy, so keep a backup. + local base="$PLUGINS_DIR/.${id}.bak.$(date -u +%Y%m%d%H%M%S)" + local 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 $id. Backup at: $backup" + fi + + omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true + + if [[ $was_enabled == "true" ]]; then + echo "Plugin was enabled and was unloaded from omarchy-shell." + fi +} + +# ---------------------------------------------------------------- edit + +plugin_edit() { + local id="" + while (( $# > 0 )); do + case "$1" in + -h | --help) usage; return 0 ;; + -*) fail "unknown edit option: $1" ;; + *) + [[ -z $id ]] || fail "unexpected argument: $1" + id="$1"; shift ;; + esac + done + + if [[ -z $id ]]; then + interactive || fail "a plugin id is required" + id=$(installed_plugin_ids | gum choose --header="Edit which plugin?") || fail "cancelled" + [[ -n $id ]] || fail "nothing selected" + fi + valid_plugin_id "$id" || fail "invalid plugin id '$id'" + [[ $id != omarchy.* ]] || fail "$id is built in; clone it first with: omarchy plugin clone $id" + + local dir="$PLUGINS_DIR/$id" + [[ -f $dir/manifest.json ]] || fail "no user plugin at $dir" + + if interactive; then + cd "$dir" + exec "${SHELL:-bash}" + fi + printf '%s\n' "$dir" +} + +# ---------------------------------------------------------------- bar + bar_command() { local command="${1:-list}" [[ $# -gt 0 ]] && shift || true @@ -190,27 +502,34 @@ disable) shift plugin_enabled false "$@" ;; +add | install) + shift + plugin_add "$@" + ;; +update) + shift + plugin_update "$@" + ;; +remove | rm) + shift + plugin_remove "$@" + ;; +edit) + shift + plugin_edit "$@" + ;; clone) shift exec omarchy-plugin-clone "$@" ;; -edit) +validate) shift - exec omarchy-plugin-edit "$@" + exec omarchy-plugin-validate "$@" ;; bar | widget | widgets) shift bar_command "$@" ;; -source | available | add | update | remove | validate) - # These live in sibling binaries (omarchy-plugin-). The `omarchy` - # dispatcher normally routes straight to them; delegate here too so invoking - # this base binary directly matches the commands its --help advertises. - shift - sibling="$(dirname -- "${BASH_SOURCE[0]}")/omarchy-plugin-$command" - [[ -x $sibling ]] || fail "missing helper: omarchy-plugin-$command" - exec "$sibling" "$@" - ;; -h | --help | help | "") usage ;; diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add deleted file mode 100755 index 0b45a19d..00000000 --- a/bin/omarchy-plugin-add +++ /dev/null @@ -1,305 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Add an Omarchy shell plugin from a trusted source -# omarchy:group=plugin -# omarchy:args=[plugin-id] [--from ] [--enable] [--no-enable] [--review] [--no-refresh] [--yes] -# omarchy:examples=omarchy plugin add | omarchy plugin add orbit --enable | omarchy plugin add model-usage --from owner-plugins --yes - -# Copies a plugin folder out of a trusted source's local clone into -# ~/.config/omarchy/plugins//. It never runs plugin code, never runs install -# hooks, and never uses sudo — it only copies files, validates the manifest, and -# toggles enabled state over shell IPC. Reviewing and trusting the code is the -# user's call; --yes opts out of every prompt for scripts/agents. - -set -o pipefail - -PLUGINS_DIR="$HOME/.config/omarchy/plugins" -OMARCHY_PATH="${OMARCHY_PATH:-$HOME/.local/share/omarchy}" - -fail() { - echo "omarchy-plugin-add: $*" >&2 - exit 1 -} - -require_command() { - command -v "$1" >/dev/null 2>&1 || fail "$1 is required" -} - -interactive() { - [[ -t 0 && -t 1 ]] -} - -plugin_discovered() { - local id="$1" plugins - plugins=$(omarchy-shell shell listPlugins 2>/dev/null) || return 1 - jq -e --arg id "$id" 'any(.[]; .id == $id)' <<<"$plugins" >/dev/null 2>&1 -} - -wait_for_plugin_discovery() { - local id="$1" attempt - for (( attempt = 0; attempt < 40; attempt++ )); do - plugin_discovered "$id" && return 0 - sleep 0.05 - done - return 1 -} - -PLUGIN_ID="" -FROM_SOURCE="" -ENABLE_AFTER="" # "", "true", or "false" -DO_REVIEW=0 -NO_REFRESH=0 -ASSUME_YES=0 - -while (($# > 0)); do - case "$1" in - --from) FROM_SOURCE="${2:-}"; shift 2 ;; - --enable) ENABLE_AFTER=true; shift ;; - --no-enable) ENABLE_AFTER=false; shift ;; - --review) DO_REVIEW=1; shift ;; - --no-refresh) NO_REFRESH=1; shift ;; - --yes | -y) ASSUME_YES=1; shift ;; - -h | --help) - cat <] [--enable] [--no-enable] [--review] [--no-refresh] [--yes] - -Adds a plugin from a trusted source into ~/.config/omarchy/plugins//. -Without a plugin-id, an interactive picker lists everything available. - --from disambiguate when the same id exists in multiple sources - --enable enable the plugin afterward (bar widgets land in the bar) - --no-enable add but leave disabled - --review open the plugin's files before adding (always in a TTY) - --no-refresh use the cached clones as-is, skip the network refresh - --yes skip every confirmation (review, add, enable) -USAGE - exit 0 - ;; - -*) fail "unknown option: $1" ;; - *) - [[ -z $PLUGIN_ID ]] || fail "unexpected argument: $1" - PLUGIN_ID="$1"; shift ;; - esac -done - -require_command jq - -confirm() { - local prompt="$1" - ((ASSUME_YES)) && return 0 - if interactive; then - gum confirm "$prompt"; return - fi - fail "refusing to add without confirmation; pass --yes" -} - -if ((!NO_REFRESH)); then - omarchy-plugin-source refresh >/dev/null 2>&1 || true -fi - -CATALOG=$(omarchy-plugin-scan) -if [[ $(jq 'length' <<<"$CATALOG") -eq 0 ]]; then - fail "no plugins available; add a source with: omarchy plugin source add " -fi - -# ---------------------------------------------------------------- resolve id - -if [[ -z $PLUGIN_ID ]]; then - interactive || fail "a plugin-id is required (try: omarchy plugin available)" - choices=$(jq -r '.[] | "\(.manifest.id)\t\(.manifest.name // .manifest.id)\t\(.manifest.version // "?")\t\(.sourceId)"' <<<"$CATALOG") - rows=$(awk -F '\t' '{ printf "%-26s %-26s v%-8s [%s]\n", $1, $2, $3, $4 }' <<<"$choices") - pick=$(gum choose --header="Add which plugin?" <<<"$rows") || fail "cancelled" - [[ -n $pick ]] || fail "nothing selected" - PLUGIN_ID=$(awk '{print $1}' <<<"$pick") -fi - -if [[ -n $FROM_SOURCE ]]; then - matches=$(jq --arg id "$PLUGIN_ID" --arg src "$FROM_SOURCE" '[.[] | select(.manifest.id == $id and .sourceId == $src)]' <<<"$CATALOG") -else - matches=$(jq --arg id "$PLUGIN_ID" '[.[] | select(.manifest.id == $id)]' <<<"$CATALOG") -fi - -match_count=$(jq 'length' <<<"$matches") -if ((match_count == 0)); then - if [[ -n $FROM_SOURCE ]]; then - fail "no plugin '$PLUGIN_ID' in source '$FROM_SOURCE'" - fi - fail "no plugin '$PLUGIN_ID' in any configured source (try: omarchy plugin available)" -fi -if ((match_count > 1)); then - echo "omarchy-plugin-add: '$PLUGIN_ID' is available from multiple sources:" >&2 - jq -r '.[] | " - " + .sourceId' <<<"$matches" >&2 - fail "pick one with: --from " -fi - -entry=$(jq -c '.[0]' <<<"$matches") -src_id=$(jq -r '.sourceId' <<<"$entry") -src_url=$(jq -r '.sourceUrl' <<<"$entry") -src_path=$(jq -r '.path' <<<"$entry") -m_id=$(jq -r '.manifest.id' <<<"$entry") -m_name=$(jq -r '.manifest.name // .manifest.id' <<<"$entry") -m_version=$(jq -r '.manifest.version // "?"' <<<"$entry") -m_author=$(jq -r '.manifest.author // ""' <<<"$entry") -m_license=$(jq -r '.manifest.license // ""' <<<"$entry") -m_desc=$(jq -r '.manifest.description // ""' <<<"$entry") -m_kinds=$(jq -r '(.manifest.kinds // []) | join(", ")' <<<"$entry") - -# ---------------------------------------------------------------- validate - -omarchy-plugin-validate "$src_path" || fail "refusing to add: validation failed" - -target="$PLUGINS_DIR/$m_id" -replacing=0 -target_is_link=0 -if [[ -L $target ]]; then - target_is_link=1 - replacing=1 -elif [[ -d $target ]]; then - replacing=1 -fi - -# Soft warning: a third-party id that matches a built-in's alias installs as a -# separate widget rather than replacing the built-in. -alias_owner="" -while IFS= read -r fp_manifest; do - [[ -f $fp_manifest ]] || continue - if jq -e --arg id "$m_id" '((.barWidget.aliases // []) | index($id)) != null' "$fp_manifest" >/dev/null 2>&1; then - alias_owner=$(jq -r '.id' "$fp_manifest") - break - fi -done < <(find "$OMARCHY_PATH/shell/plugins" -type f \( -name manifest.json -o -name '*.manifest.json' \) 2>/dev/null) - -# Always surface an alias collision, even under --yes, since it changes what the -# added plugin actually shadows. -if [[ -n $alias_owner ]]; then - echo "Note: '$m_id' is also an alias of the built-in $alias_owner; it is added as a separate plugin and does not replace the built-in." >&2 -fi - -# ---------------------------------------------------------------- summary - -print_summary() { - cat < $(readlink "$target"))" - elif ((replacing)); then - echo " Target: $target (REPLACING existing install)" - else - echo " Target: $target" - fi - echo - echo " Plugins run as unsandboxed code inside omarchy-shell. Review before enabling." - echo -} - -review_files() { - echo "Files in $m_id:" - find "$src_path" -type f -printf '%P\n' 2>/dev/null | sort | sed 's/^/ /' - echo - # Only spawn an editor in a real terminal; a headless run just prints the path - # so it never launches a detached window. - if interactive && command -v omarchy-launch-editor >/dev/null 2>&1; then - omarchy-launch-editor "$src_path" - else - echo "Source folder: $src_path" - fi -} - -if ((!ASSUME_YES)); then - print_summary - - if ((DO_REVIEW)); then - review_files - confirm "Add $m_id $m_version now?" || fail "aborted" - elif interactive; 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 ! wait_for_plugin_discovery "$m_id"; then - echo "Could not enable $m_id because omarchy-shell did not discover it yet. Enable later with: omarchy plugin enable $m_id" >&2 - elif result=$(omarchy-shell shell setPluginEnabled "$m_id" true) && [[ $result == "ok" ]]; then - echo "Enabled $m_id." - if [[ $m_kinds == *bar-widget* ]]; then - echo "It was added to the bar; arrange it with: omarchy plugin bar move $m_id --section " - fi - else - echo "Could not enable $m_id over IPC (is omarchy-shell running?). Enable later with: omarchy plugin enable $m_id" >&2 - fi -else - echo "Enable it later with: omarchy plugin enable $m_id" -fi - -# Reaching here means the files are in place; never let a falsy trailing test -# leak a non-zero status to callers like `omarchy plugin update`. -exit 0 diff --git a/bin/omarchy-plugin-available b/bin/omarchy-plugin-available deleted file mode 100755 index 01c517b6..00000000 --- a/bin/omarchy-plugin-available +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/bash - -# omarchy:summary=List plugins available across trusted sources and their install status -# omarchy:group=plugin -# omarchy:args=[--json] [--no-refresh] -# omarchy:examples=omarchy plugin available | omarchy plugin available --json - -set -o pipefail - -PLUGINS_DIR="$HOME/.config/omarchy/plugins" - -fail() { - echo "omarchy-plugin-available: $*" >&2 - exit 1 -} - -command -v jq >/dev/null 2>&1 || fail "jq is required" - -JSON=0 -NO_REFRESH=0 -while (($# > 0)); do - case "$1" in - --json) JSON=1; shift ;; - --no-refresh) NO_REFRESH=1; shift ;; - -h | --help) - cat < -USAGE - exit 0 - ;; - *) fail "unknown option: $1" ;; - esac -done - -if ((!NO_REFRESH)); then - omarchy-plugin-source refresh >/dev/null 2>&1 || true -fi - -CATALOG=$(omarchy-plugin-scan) - -# Annotate each catalogue entry with a stable status token (available | -# installed | update-available | local-newer, matching omarchy-plugin-update's -# vocabulary) plus the installed version, by comparing the installed manifest -# version (if any) against the upstream version. Entries are read as compact -# JSON objects so an empty version field never shifts the parse. -rows='[]' -while IFS= read -r row; do - [[ -n $row ]] || continue - id=$(jq -r '.id' <<<"$row") - name=$(jq -r '.name' <<<"$row") - version=$(jq -r '.version' <<<"$row") - source=$(jq -r '.source' <<<"$row") - - installed="" - [[ -f "$PLUGINS_DIR/$id/manifest.json" ]] && installed=$(jq -r '.version // ""' "$PLUGINS_DIR/$id/manifest.json" 2>/dev/null) - if [[ -z $installed ]]; then - status="available" - elif [[ $installed == "$version" ]]; then - status="installed" - elif [[ $(printf '%s\n%s\n' "$installed" "$version" | sort -V | tail -n1) == "$version" ]]; then - status="update-available" - else - status="local-newer" - fi - - entry=$(jq -n --arg id "$id" --arg name "$name" --arg version "$version" \ - --arg installed "$installed" --arg source "$source" --arg status "$status" \ - '{id:$id, name:$name, version:$version, installedVersion:$installed, source:$source, status:$status}') - rows=$(jq --argjson e "$entry" '. + [$e]' <<<"$rows") -done < <(jq -c '.[] | {id: .manifest.id, name: (.manifest.name // .manifest.id), version: (.manifest.version // ""), source: .sourceId}' <<<"$CATALOG") - -if ((JSON)); then - echo "$rows" - exit 0 -fi - -if [[ $(jq 'length' <<<"$rows") -eq 0 ]]; then - echo "No plugins available. Add a source with: omarchy plugin source add " - exit 0 -fi - -# Render the human status from the token + versions (ASCII arrow, matching update). -jq -r '.[] | [.id, .name, .version, .source, .status, .installedVersion] | @tsv' <<<"$rows" | - awk -F '\t' ' - function human(st, inst, ver) { - if (st == "update-available") return "update (" inst " -> " ver ")" - if (st == "local-newer") return "local-newer (" inst ")" - return st - } - BEGIN { printf "%-26s %-26s %-10s %-30s %s\n", "ID", "NAME", "VERSION", "SOURCE", "STATUS" } - { printf "%-26s %-26s %-10s %-30s %s\n", $1, $2, ("v" ($3=="" ? "?" : $3)), $4, human($5, $6, $3) }' - -echo -echo "Add with: omarchy plugin add " diff --git a/bin/omarchy-plugin-edit b/bin/omarchy-plugin-edit deleted file mode 100755 index 9553dfc1..00000000 --- a/bin/omarchy-plugin-edit +++ /dev/null @@ -1,157 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Open a user Omarchy shell plugin directory in a shell -# omarchy:group=plugin -# omarchy:args=[plugin-id] -# omarchy:examples=omarchy plugin edit | omarchy plugin edit local.clock - -set -euo pipefail - -PLUGINS_DIR="$HOME/.config/omarchy/plugins" - -fail() { - echo "omarchy-plugin-edit: $*" >&2 - exit 1 -} - -require_command() { - command -v "$1" >/dev/null 2>&1 || fail "$1 is required" -} - -interactive() { - [[ -t 0 && -t 1 ]] -} - -canonical_widget_id() { - printf '%s' "$1" -} - -slug_id() { - tr '[:upper:]' '[:lower:]' <<<"$1" | sed -E 's/^omarchy\.//; s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' -} - -require_omarchy_path() { - [[ -n ${OMARCHY_PATH:-} ]] || fail "OMARCHY_PATH is not set" -} - -# A built-in id is one the catalog knows as first-party (or an omarchy.* id). -builtin_id_known() { - local id="$1" - require_command jq - require_omarchy_path - omarchy-plugin-catalog | jq -e --arg id "$id" \ - 'any(.[]; .id == $id and .firstParty)' >/dev/null 2>&1 -} - -validate_user_plugin_dir() { - local dir="$1" - local requested_id="${2:-}" - local root="$PLUGINS_DIR" - local dir_abs root_abs manifest_id - - require_command jq - require_command python3 - [[ -d $dir ]] || fail "plugin directory not found: $dir" - - dir_abs=$(python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).expanduser().resolve(strict=False))' "$dir") - root_abs=$(python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).expanduser().resolve(strict=False))' "$root") - [[ $dir_abs == $root_abs/* ]] || fail "plugin edit only opens user plugins under $root" - [[ -f $dir_abs/manifest.json ]] || fail "plugin directory missing manifest.json: $dir_abs" - - manifest_id=$(jq -r '.id // empty' "$dir_abs/manifest.json" 2>/dev/null || true) - [[ -n $manifest_id ]] || fail "plugin manifest missing id: $dir_abs/manifest.json" - [[ $manifest_id != omarchy.* && $(canonical_widget_id "$manifest_id") == $manifest_id ]] \ - || fail "$manifest_id is built in; clone it first with: omarchy plugin clone $manifest_id local.$(slug_id "$manifest_id")" - if [[ -n $requested_id && $manifest_id != $requested_id ]]; then - fail "plugin id mismatch: requested $requested_id but manifest declares $manifest_id" - fi - - printf '%s' "$dir_abs" -} - -plugin_dir_for_id() { - local id="$1" - - if [[ $id == */* ]]; then - validate_user_plugin_dir "$id" - return - fi - - id=$(canonical_widget_id "$id") - if builtin_id_known "$id" || [[ $id == omarchy.* ]]; then - fail "$id is built in; clone it first with: omarchy plugin clone $id local.$(slug_id "$id")" - fi - - validate_user_plugin_dir "$PLUGINS_DIR/$id" "$id" -} - -edit_source_options() { - [[ -d $PLUGINS_DIR ]] || return 0 - find "$PLUGINS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) 2>/dev/null | sort | while IFS= read -r dir; do - [[ -f $dir/manifest.json ]] || continue - id=$(jq -r '.id // empty' "$dir/manifest.json" 2>/dev/null || true) - name=$(jq -r '.name // .id // empty' "$dir/manifest.json" 2>/dev/null || true) - [[ -n $id ]] && printf '%s\t%s\t%s\n' "$id" "${name:-$id}" "$dir" - done -} - -choose_edit_source() { - local selected - selected=$(edit_source_options | awk -F '\t' '{ printf "%-32s %s\n", $2, $1 }' \ - | gum filter --header "Edit plugin" --placeholder "Search user plugins..." --limit 1) || return 1 - awk '{ print $NF }' <<<"$selected" -} - -usage() { - cat <) in a shell so you -can edit it. Built-in plugins must be cloned first with 'omarchy plugin clone'. - -Without an id in a terminal, an interactive picker lists your user plugins. - -Examples: - omarchy plugin edit - omarchy plugin edit local.clock -USAGE -} - -edit_command() { - local id="" - if (( $# > 0 )) && [[ $1 != --* ]]; then - id="$1" - shift - fi - - while (( $# > 0 )); do - case "$1" in - -h | --help) - usage - return - ;; - *) - fail "unknown edit option: $1" - ;; - esac - done - - if [[ -z $id ]]; then - if interactive; then - id=$(choose_edit_source) || fail "edit cancelled" - else - fail "plugin id is required" - fi - fi - - local dir - dir=$(plugin_dir_for_id "$id") - - if interactive; then - cd "$dir" - exec "${SHELL:-bash}" - fi - printf '%s\n' "$dir" -} - -edit_command "$@" diff --git a/bin/omarchy-plugin-remove b/bin/omarchy-plugin-remove deleted file mode 100755 index ccdc2329..00000000 --- a/bin/omarchy-plugin-remove +++ /dev/null @@ -1,99 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Remove an installed Omarchy shell plugin -# omarchy:group=plugin -# omarchy:args=[plugin-id] [--yes] -# omarchy:examples=omarchy plugin remove | omarchy plugin remove orbit --yes - -# Disables a plugin and removes it from ~/.config/omarchy/plugins/. A real -# install is moved to a timestamped backup so it can be restored; a dev symlink -# is just unlinked (the files it points at are left alone). - -set -o pipefail - -PLUGINS_DIR="$HOME/.config/omarchy/plugins" - -fail() { - echo "omarchy-plugin-remove: $*" >&2 - exit 1 -} - -interactive() { - [[ -t 0 && -t 1 ]] -} - -PLUGIN_ID="" -ASSUME_YES=0 -while (($# > 0)); do - case "$1" in - --yes | -y) ASSUME_YES=1; shift ;; - -h | --help) - cat <.bak./; a symlink is unlinked. -USAGE - exit 0 - ;; - -*) fail "unknown option: $1" ;; - *) - [[ -z $PLUGIN_ID ]] || fail "unexpected argument: $1" - PLUGIN_ID="$1"; shift ;; - esac -done - -confirm() { - local prompt="$1" - ((ASSUME_YES)) && return 0 - if interactive; then gum confirm "$prompt"; 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" - PLUGIN_ID=$(printf '%s\n' "${ids[@]}" | gum choose --header="Remove which plugin?") || fail "cancelled" - [[ -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 and was unloaded from omarchy-shell." -fi diff --git a/bin/omarchy-plugin-scan b/bin/omarchy-plugin-scan deleted file mode 100755 index 80394807..00000000 --- a/bin/omarchy-plugin-scan +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash - -# omarchy:summary=List every plugin available across configured sources (JSON) -# omarchy:group=plugin -# omarchy:hidden=true - -# Walks the locally cached clones of every trusted source and emits one JSON -# entry per plugin folder it finds. This is the shared catalogue that -# `available`, `install`, and `update` all read; it never touches the network -# (run `omarchy plugin source refresh` first to pull fresh clones). - -set -o pipefail - -SOURCES_FILE="$HOME/.config/omarchy/plugins/sources.json" -CACHE_ROOT="$HOME/.cache/omarchy/plugin-sources" - -command -v jq >/dev/null 2>&1 || { echo "omarchy-plugin-scan: jq is required" >&2; exit 1; } - -if [[ ! -f $SOURCES_FILE ]]; then - echo "[]" - exit 0 -fi - -results='[]' - -# Read each source as a compact JSON object (one per line) rather than a -# delimiter-joined string — no separator to get wrong, and empty fields survive. -while IFS= read -r src_json; do - [[ -n $src_json ]] || continue - src_id=$(jq -r '.id // ""' <<<"$src_json") - src_url=$(jq -r '.url // ""' <<<"$src_json") - src_ref=$(jq -r '.ref // ""' <<<"$src_json") - [[ -n $src_id ]] || continue - - cache_dir="$CACHE_ROOT/$src_id" - [[ -d $cache_dir ]] || continue - - for folder in "$cache_dir"/*/; do - [[ -d $folder ]] || continue - manifest="$folder/manifest.json" - [[ -f $manifest ]] || continue - jq -e . "$manifest" >/dev/null 2>&1 || continue - - folder_name=$(basename "$folder") - abs_path=$(cd "$folder" && pwd) - - entry=$(jq -n \ - --arg sourceId "$src_id" \ - --arg sourceUrl "$src_url" \ - --arg ref "$src_ref" \ - --arg folder "$folder_name" \ - --arg path "$abs_path" \ - --slurpfile manifest "$manifest" \ - '{sourceId:$sourceId, sourceUrl:$sourceUrl, ref:$ref, folder:$folder, path:$path, manifest:$manifest[0]}') || continue - - results=$(jq --argjson e "$entry" '. + [$e]' <<<"$results") - done -done < <(jq -c '.sources[]?' "$SOURCES_FILE") - -echo "$results" diff --git a/bin/omarchy-plugin-source b/bin/omarchy-plugin-source deleted file mode 100755 index 95715c0e..00000000 --- a/bin/omarchy-plugin-source +++ /dev/null @@ -1,354 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Manage trusted git repos that Omarchy plugins are installed from -# omarchy:group=plugin -# omarchy:args= [...] -# omarchy:examples=omarchy plugin source add | omarchy plugin source list | omarchy plugin source add https://github.com/owner/omarchy-plugins.git --as owner-plugins | omarchy plugin source refresh - -set -o pipefail - -# Never let git block an unattended run on a credential or host-key prompt; fail -# fast instead so callers' `|| true` / error paths can handle it. -export GIT_TERMINAL_PROMPT=0 -export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -oBatchMode=yes}" - -SOURCES_FILE="$HOME/.config/omarchy/plugins/sources.json" -CACHE_ROOT="$HOME/.cache/omarchy/plugin-sources" - -fail() { - echo "omarchy-plugin-source: $*" >&2 - exit 1 -} - -# A source id becomes a cache directory name and a sources.json key, so it must -# never contain path separators or traversal. Used for --as, derived ids, and -# (defensively) any id read back before an rm -rf. -valid_source_id() { - [[ $1 =~ ^[a-z0-9][a-z0-9._-]*$ && $1 != *..* ]] -} - -require_command() { - command -v "$1" >/dev/null 2>&1 || fail "$1 is required" -} - -interactive() { - [[ -t 0 && -t 1 ]] -} - -# Yes/no prompt. Honours a caller-set ASSUME_YES, 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 interactive; then - gum confirm "$prompt" - return - fi - fail "refusing to continue without confirmation; pass --yes" -} - -ask_input() { - local prompt="$1" - gum input --prompt "$prompt " || return 1 -} - -ensure_sources_file() { - mkdir -p "$(dirname "$SOURCES_FILE")" - if [[ ! -f $SOURCES_FILE ]]; then - # Subshell the umask so it does not leak into the rest of this process. - (umask 077; echo '{"version":1,"sources":[]}' >"$SOURCES_FILE") - fi -} - -# Persist a jq transform of the sources file, then keep it owner-only. -write_sources() { - local filter="$1" - shift - local tmp - tmp=$(mktemp) - if ! jq "$@" "$filter" "$SOURCES_FILE" >"$tmp"; then - rm -f "$tmp" - fail "failed to update $SOURCES_FILE" - fi - mv "$tmp" "$SOURCES_FILE" - chmod 600 "$SOURCES_FILE" 2>/dev/null || true -} - -# owner-repo, lowercased and sanitized, from any git URL form (scp-like or URL). -derive_source_id() { - local url="$1" - url="${url%.git}" - url="${url%/}" - local path - if [[ $url =~ ^[^/]+@[^/]+:(.+)$ ]]; then - path="${BASH_REMATCH[1]}" - else - path="${url#*://}" - path="${path#*/}" - fi - local repo="${path##*/}" - local owner_part="${path%/*}" - local owner="${owner_part##*/}" - local id - if [[ -n $owner && $owner != "$path" ]]; then - id="${owner}-${repo}" - else - id="$repo" - fi - id="${id,,}" - id="${id//[^a-z0-9._-]/-}" - while [[ $id == -* ]]; do id="${id#-}"; done - while [[ $id == *- ]]; do id="${id%-}"; done - printf '%s' "$id" -} - -source_exists() { - jq -e --arg id "$1" '.sources | map(.id) | index($id) != null' "$SOURCES_FILE" >/dev/null 2>&1 -} - -# ---------------------------------------------------------------- add - -source_add() { - require_command jq - require_command git - - local url="" ref="" name="" - ASSUME_YES=0 - while (($# > 0)); do - case "$1" in - --ref) ref="${2:-}"; shift 2 ;; - --as) name="${2:-}"; shift 2 ;; - --yes | -y) ASSUME_YES=1; shift ;; - -h | --help) usage; return 0 ;; - --) shift; break ;; - -*) fail "unknown option: $1" ;; - *) - [[ -z $url ]] || fail "unexpected argument: $1" - url="$1"; shift ;; - esac - done - - if [[ -z $url ]]; then - interactive || fail "a git URL is required (e.g. omarchy plugin source add https://github.com/owner/repo.git)" - url=$(ask_input "Git URL of the plugin source repo:") || fail "cancelled" - [[ -n $url ]] || fail "a git URL is required" - fi - - [[ -n $name ]] || name=$(derive_source_id "$url") - [[ -n $name ]] || fail "could not derive a source id from '$url'; re-run with --as " - valid_source_id "$name" || fail "invalid source id '$name' (lowercase letters, digits, '.', '_', '-'; no '/' or '..')" - - ensure_sources_file - if source_exists "$name"; then - local existing - existing=$(jq -r --arg id "$name" '.sources[] | select(.id == $id) | .url' "$SOURCES_FILE") - fail "source '$name' already exists (url: $existing); remove it first with: omarchy plugin source remove $name" - fi - - cat >&2 <} - - Plugins from this repo run as arbitrary code inside your long-lived - omarchy-shell process. They are not sandboxed. Only add sources you - trust, and review a plugin's code before you install and enable it. - -WARN - confirm "Trust this source?" || fail "aborted" - - local cache_dir="$CACHE_ROOT/$name" - rm -rf "$cache_dir" - mkdir -p "$CACHE_ROOT" - - local clone_args=(--depth 1) - [[ -n $ref ]] && clone_args+=(--branch "$ref") - - echo "Cloning $url ..." - if ! git clone "${clone_args[@]}" -- "$url" "$cache_dir"; then - rm -rf "$cache_dir" - fail "failed to clone $url" - fi - - [[ -n $ref ]] || ref=$(git -C "$cache_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") - - local ts - ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - write_sources '.sources += [{id:$id, url:$url, ref:$ref, trustedAt:$ts}]' \ - --arg id "$name" --arg url "$url" --arg ref "$ref" --arg ts "$ts" - - echo "Added source '$name'${ref:+ (ref: $ref)}." - echo "Browse its plugins with: omarchy plugin available" -} - -# ---------------------------------------------------------------- list - -source_list() { - require_command jq - local json=0 - while (($# > 0)); do - case "$1" in - --json) json=1; shift ;; - -h | --help) usage; return 0 ;; - *) fail "unknown list option: $1" ;; - esac - done - - if [[ ! -f $SOURCES_FILE ]]; then - if ((json)); then echo '{"version":1,"sources":[]}'; else echo "No plugin sources configured. Add one with: omarchy plugin source add "; fi - return 0 - fi - - if ((json)); then - cat "$SOURCES_FILE" - return 0 - fi - - if [[ $(jq '.sources | length' "$SOURCES_FILE") -eq 0 ]]; then - echo "No plugin sources configured. Add one with: omarchy plugin source add " - return 0 - fi - - jq -r '.sources[] | [.id, .url, (.ref // "")] | @tsv' "$SOURCES_FILE" | - while IFS=$'\t' read -r id url ref; do - local cache="missing" - [[ -d "$CACHE_ROOT/$id/.git" ]] && cache="cached" - printf '%s\t%s\t%s\t%s\n' "$id" "$url" "${ref:-?}" "$cache" - done | - awk -F '\t' ' - BEGIN { printf "%-34s %-46s %-14s %s\n", "ID", "URL", "REF", "CACHE" } - { printf "%-34s %-46s %-14s %s\n", $1, $2, $3, $4 }' -} - -# ---------------------------------------------------------------- remove - -source_remove() { - require_command jq - local id="" - ASSUME_YES=0 - while (($# > 0)); do - case "$1" in - --yes | -y) ASSUME_YES=1; shift ;; - -h | --help) usage; return 0 ;; - -*) fail "unknown option: $1" ;; - *) - [[ -z $id ]] || fail "unexpected argument: $1" - id="$1"; shift ;; - esac - done - - [[ -f $SOURCES_FILE ]] || fail "no plugin sources configured" - - if [[ -z $id ]]; then - interactive || fail "a source id is required" - id=$(jq -r '.sources[].id' "$SOURCES_FILE" | gum choose --header="Remove which plugin source?") || fail "cancelled" - [[ -n $id ]] || fail "nothing selected" - fi - - source_exists "$id" || fail "no such source: $id" - confirm "Remove source '$id'? (installed plugins are left in place)" || fail "aborted" - - write_sources '.sources |= map(select(.id != $id))' --arg id "$id" - # Defense in depth: never rm -rf a cache path built from an id that could - # contain traversal, even though valid_source_id gates ids on the way in. - if valid_source_id "$id"; then - rm -rf "${CACHE_ROOT:?}/$id" - fi - echo "Removed source '$id'." -} - -# ---------------------------------------------------------------- refresh - -refresh_one() { - local id="$1" - valid_source_id "$id" || { echo "omarchy-plugin-source: invalid source id: $id" >&2; return 1; } - local url ref cache_dir - url=$(jq -r --arg id "$id" '.sources[] | select(.id == $id) | .url' "$SOURCES_FILE") - ref=$(jq -r --arg id "$id" '.sources[] | select(.id == $id) | .ref // ""' "$SOURCES_FILE") - cache_dir="$CACHE_ROOT/$id" - - [[ -n $url ]] || { echo "omarchy-plugin-source: no such source: $id" >&2; return 1; } - - if [[ ! -d "$cache_dir/.git" ]]; then - echo "Cache missing for '$id'; re-cloning ..." - rm -rf "$cache_dir" - mkdir -p "$CACHE_ROOT" - local clone_args=(--depth 1) - [[ -n $ref ]] && clone_args+=(--branch "$ref") - git clone "${clone_args[@]}" -- "$url" "$cache_dir" || return 1 - return 0 - fi - - echo "Refreshing '$id' (${ref:-HEAD}) ..." - if ! git -C "$cache_dir" fetch --depth 1 origin "${ref:-HEAD}" >/dev/null 2>&1; then - echo "omarchy-plugin-source: fetch failed for '$id'" >&2 - return 1 - fi - git -C "$cache_dir" reset --hard FETCH_HEAD >/dev/null 2>&1 || return 1 -} - -source_refresh() { - require_command jq - require_command git - local id="" - while (($# > 0)); do - case "$1" in - -h | --help) usage; return 0 ;; - -*) fail "unknown option: $1" ;; - *) - [[ -z $id ]] || fail "unexpected argument: $1" - id="$1"; shift ;; - esac - done - - [[ -f $SOURCES_FILE ]] || fail "no plugin sources configured" - - if [[ -n $id ]]; then - refresh_one "$id" - return $? - fi - - local rc=0 - mapfile -t ids < <(jq -r '.sources[].id' "$SOURCES_FILE") - if ((${#ids[@]} == 0)); then - echo "No plugin sources configured." - return 0 - fi - for sid in "${ids[@]}"; do - refresh_one "$sid" || rc=1 - done - return $rc -} - -usage() { - cat < [args...] - - add [] [--ref ] [--as ] [--yes] - Trust a git repo as a plugin source and clone it locally. - list [--json] - List trusted plugin sources and whether each is cached. - remove [] [--yes] - Forget a source and drop its cache (installed plugins stay). - refresh [] - Pull the latest clone for one source, or all sources. - -Sources are stored in ~/.config/omarchy/plugins/sources.json and cloned -into ~/.cache/omarchy/plugin-sources//. -USAGE -} - -command="${1:-list}" -[[ $# -gt 0 ]] && shift || true -case "$command" in -add) source_add "$@" ;; -list | ls) source_list "$@" ;; -remove | rm) source_remove "$@" ;; -refresh | update) source_refresh "$@" ;; --h | --help | help) usage ;; -*) fail "unknown command: $command (use add, list, remove, or refresh)" ;; -esac diff --git a/bin/omarchy-plugin-update b/bin/omarchy-plugin-update deleted file mode 100755 index 3bfe6bb1..00000000 --- a/bin/omarchy-plugin-update +++ /dev/null @@ -1,207 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Update installed Omarchy plugins from their sources -# omarchy:group=plugin -# omarchy:args=[plugin-id] [--all] [--force] [--review] [--no-refresh] [--yes] -# omarchy:examples=omarchy plugin update | omarchy plugin update orbit --review | omarchy plugin update --all --yes - -# Re-pulls a plugin's folder from its source when the upstream manifest version -# is newer. Updates are just re-installs, so the same "review the code" rule -# applies — by default this prints a diff of exactly what changed before -# touching anything. --yes skips the diff and confirmation for scripts/agents. - -set -o pipefail - -PLUGINS_DIR="$HOME/.config/omarchy/plugins" - -fail() { - echo "omarchy-plugin-update: $*" >&2 - exit 1 -} - -require_command() { - command -v "$1" >/dev/null 2>&1 || fail "$1 is required" -} - -interactive() { - [[ -t 0 && -t 1 ]] -} - -PLUGIN_ID="" -ALL=0 -FORCE=0 -DO_REVIEW=0 -NO_REFRESH=0 -ASSUME_YES=0 - -while (($# > 0)); do - case "$1" in - --all | -a) ALL=1; shift ;; - --force | -f) FORCE=1; shift ;; - --review) DO_REVIEW=1; shift ;; - --no-refresh) NO_REFRESH=1; shift ;; - --yes | -y) ASSUME_YES=1; shift ;; - -h | --help) - cat < update one plugin - --all update every plugin with an available update - --force re-pull even when versions match (or local looks newer) - --review always show the file diff before updating (default in a TTY) - --no-refresh use cached clones, skip the network refresh - --yes skip the diff and confirmation -USAGE - exit 0 - ;; - -*) fail "unknown option: $1" ;; - *) - [[ -z $PLUGIN_ID ]] || fail "unexpected argument: $1" - PLUGIN_ID="$1"; shift ;; - esac -done - -require_command jq -((ALL)) && [[ -n $PLUGIN_ID ]] && fail "pass either a plugin-id or --all, not both" - -confirm() { - local prompt="$1" - ((ASSUME_YES)) && return 0 - if interactive; then gum confirm "$prompt"; return; fi - fail "refusing to update without confirmation; pass --yes" -} - -if ((!NO_REFRESH)); then - omarchy-plugin-source refresh >/dev/null 2>&1 || true -fi - -CATALOG=$(omarchy-plugin-scan 2>/dev/null || echo "[]") - -# Map of currently enabled third-party plugins, so we can preserve state. -declare -A enabled_map -if shell_plugins=$(omarchy-shell shell listPlugins 2>/dev/null) && [[ -n $shell_plugins ]]; then - while IFS=$'\t' read -r pid pen; do - [[ -n $pid ]] && enabled_map["$pid"]="$pen" - done < <(jq -r '.[] | select(.firstParty == false) | [.id, (.enabled|tostring)] | @tsv' <<<"$shell_plugins" 2>/dev/null) -fi - -# Echoes "\t\t\t\t" -plugin_status() { - local id="$1" installed_version="$2" - local up - up=$(jq -c --arg id "$id" 'map(select(.manifest.id == $id)) | .[0] // empty' <<<"$CATALOG") - if [[ -z $up ]]; then - printf 'unmanaged\t\t%s\t\t\n' "$installed_version" - return - fi - local upstream_version src_id cache_path status - upstream_version=$(jq -r '.manifest.version // ""' <<<"$up") - src_id=$(jq -r '.sourceId' <<<"$up") - cache_path=$(jq -r '.path' <<<"$up") - if [[ -z $installed_version || -z $upstream_version ]]; then - status="unknown" - elif [[ $installed_version == "$upstream_version" ]]; then - status="up-to-date" - elif [[ $(printf '%s\n%s\n' "$installed_version" "$upstream_version" | sort -V | tail -n1) == "$upstream_version" ]]; then - status="update-available" - else - status="local-newer" - fi - printf '%s\t%s\t%s\t%s\t%s\n' "$status" "$src_id" "$installed_version" "$upstream_version" "$cache_path" -} - -installed_version_of() { - local id="$1" - [[ -f "$PLUGINS_DIR/$id/manifest.json" ]] || return 1 - jq -r '.version // ""' "$PLUGINS_DIR/$id/manifest.json" 2>/dev/null -} - -# ---------------------------------------------------------------- pick targets - -[[ -d $PLUGINS_DIR ]] || fail "no plugins installed" - -declare -a targets=() -if ((ALL)); then - for d in "$PLUGINS_DIR"/*/; do - [[ -d $d ]] || continue - id=$(basename "$d"); [[ $id == .* ]] && continue - iv=$(installed_version_of "$id") || continue - IFS=$'\t' read -r status _ _ _ _ < <(plugin_status "$id" "$iv") - [[ $status == "update-available" ]] && targets+=("$id") - done - ((${#targets[@]})) || { echo "No updates available."; exit 0; } -elif [[ -n $PLUGIN_ID ]]; then - installed_version_of "$PLUGIN_ID" >/dev/null || fail "plugin '$PLUGIN_ID' is not installed" - targets=("$PLUGIN_ID") -else - interactive || fail "a plugin-id is required (or pass --all)" - outdated="" - for d in "$PLUGINS_DIR"/*/; do - [[ -d $d ]] || continue - id=$(basename "$d"); [[ $id == .* ]] && continue - iv=$(installed_version_of "$id") || continue - IFS=$'\t' read -r status _ instv upv _ < <(plugin_status "$id" "$iv") - [[ $status == "update-available" ]] && outdated+="$id"$'\t'"$instv -> $upv"$'\n' - done - [[ -n $outdated ]] || { echo "No updates available."; exit 0; } - rows=$(awk -F '\t' 'NF{printf "%-28s %s\n", $1, $2}' <<<"$outdated") - pick=$(gum choose --no-limit --header="Update which plugins? (space toggles, enter confirms)" <<<"$rows") || fail "cancelled" - [[ -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 enabled plugins were hot-reloaded by omarchy-shell." -fi - -exit $rc diff --git a/bin/omarchy-plugin-validate b/bin/omarchy-plugin-validate index a7d99b07..d309a4a5 100755 --- a/bin/omarchy-plugin-validate +++ b/bin/omarchy-plugin-validate @@ -77,8 +77,9 @@ 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) +# plugins directory. The .git dir is skipped: installed plugins are git +# checkouts, and git's internals are never loaded by the shell. +link=$(find "$PLUGIN_DIR" -name .git -prune -o -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. diff --git a/docs/file-layout.md b/docs/file-layout.md index 04488f1b..dd103ec3 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -45,7 +45,7 @@ defaults must use the resync command. Current generated theme state lives under `~/.local/state/omarchy/current/`. Keep `~/.config/omarchy/` for files a user may intentionally version in a dotfile manager, such as user themes, hooks, -shell layout, plugin sources, and themed template overrides. +shell layout, plugins, and themed template overrides. ## Build-time map (repo → installed paths) diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 56a01962..0b27413f 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -42,26 +42,24 @@ Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegis ## Installing a third-party plugin -Plugins come from **source repos** — a git repo where every top-level folder is -a plugin with its own `manifest.json`. Trust a repo, then install from it: +A plugin is a **git repo** with a `manifest.json` at its root. Adding one +clones it straight into `~/.config/omarchy/plugins//`; updating is a +fast-forward pull: ```bash -omarchy plugin source add https://github.com/owner/omarchy-plugins.git -omarchy plugin available # what your sources offer -omarchy plugin add some-widget # validate, copy, offer to enable -omarchy plugin update --all # shows a diff before applying -omarchy plugin remove some-widget +omarchy plugin add https://github.com/acme/omarchy-weather.git +omarchy plugin update --all # fetches, shows a diff, fast-forwards +omarchy plugin remove acme.weather ``` -Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding a source and -installing both warn you and let you review the manifest, the files, and (on -update) a diff before anything is copied or enabled. Commands prompt when run -bare in a terminal and run unattended when given arguments — add `--yes` to skip -every prompt (the path for scripts and agents). +Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you +before cloning, plugins land disabled so you can review the code before +`omarchy plugin enable`, and updates show a diff before touching anything. +Commands prompt when run bare in a terminal and run unattended when given +arguments — add `--yes` to skip every prompt (the path for scripts and agents). -Sources are recorded in `~/.config/omarchy/plugins/sources.json` and cloned into -`~/.cache/omarchy/plugin-sources/`. You can still install by hand: drop a plugin -into `~/.config/omarchy/plugins//`, run `omarchy plugin rescan`, then +You can still install by hand: drop a plugin into +`~/.config/omarchy/plugins//`, run `omarchy plugin rescan`, then `omarchy plugin enable ` (bar widgets also need `omarchy bar add `; full bar replacements are selected with `omarchy bar use `). The lower-level IPC methods remain available through `omarchy-shell shell ...`. diff --git a/shell/README.md b/shell/README.md index 57d28219..1917e7e8 100644 --- a/shell/README.md +++ b/shell/README.md @@ -93,43 +93,40 @@ The full schema lives in `services/PluginRegistry.qml`. ## Installing a third-party plugin -Plugins are distributed as **source repos**: a git repo where every top-level -folder is one plugin with its own `manifest.json`. Trust a repo, then install -individual plugins from it. Everything lands in `~/.config/omarchy/plugins//`. +A plugin is a **git repo** with a `manifest.json` at its root. Adding one +clones it straight into `~/.config/omarchy/plugins//` (named by the +manifest id); updating is a fast-forward pull of that checkout. ```bash -omarchy plugin source add https://github.com/owner/omarchy-plugins.git -omarchy plugin available # list what your sources offer -omarchy plugin add some-widget # validate, copy, offer to enable -omarchy plugin update some-widget # shows a diff of changes first +omarchy plugin add https://github.com/acme/omarchy-weather.git +omarchy plugin update acme.weather # fetches, shows a diff, fast-forwards omarchy plugin update --all -omarchy plugin remove some-widget +omarchy plugin remove acme.weather ``` -> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding a source -> and installing both warn you and let you review the manifest, the files, and -> (on update) a diff of the changes before anything is copied or enabled. Only -> trust sources and plugins whose code you are willing to run. +> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns +> you before cloning, plugins land disabled so you can review the code before +> enabling, and updates show a diff of the changes before touching anything. +> Only add repos whose code you are willing to run. -Each command is **interactive** when run bare in a terminal (gum/fzf pickers, +Each command is **interactive** when run bare in a terminal (gum pickers, confirmation, a diff to review) and fully **non-interactive** when given arguments. Pass `--yes` to skip every prompt — this is the path for scripts and AI agents: ```bash -omarchy plugin source add --as acme --yes -omarchy plugin add acme-weather --from acme --enable --yes +omarchy plugin add https://github.com/acme/omarchy-weather.git --enable --yes omarchy plugin update --all --yes ``` -Sources live in `~/.config/omarchy/plugins/sources.json`; their clones are -cached under `~/.cache/omarchy/plugin-sources/`. The installer never runs -plugin code, install hooks, or sudo — it only copies files and toggles enabled -state over shell IPC. +The installer never runs plugin code, install hooks, or sudo — it only clones +files, validates the manifest, and toggles enabled state over shell IPC. Since +an installed plugin is a plain git checkout, anything beyond add/update +(pinning a ref, switching branches) is ordinary git in the plugin directory. ### Installing by hand -You can still drop a plugin in without a source: +You can still drop a plugin in without git: 1. Put it in `~/.config/omarchy/plugins//` with a `manifest.json` plus the QML referenced from its `entryPoints`.