From f8835df6449c5349ad96fd75e338b2280bb8e0f8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 29 Jul 2026 18:34:47 -0700 Subject: [PATCH] Plugin cloning via menu (#6433) * Add plugin cloning via menu * Split plugin commands by action * Keep plugin enablement in action commands * Remove unused plugin edit command * Simplify plugin rescan arguments * Assume Omarchy shell is running for plugin commands * Remove plugin compatibility dispatcher * Flatten plugin clone command * Keep only shared plugin helpers * Remove plugin rescan wrapper * Keep plugin commands self-contained * Simplify plugin clone lifecycle --- bin/omarchy-bar-plugin | 5 +- bin/omarchy-menu-plugin | 24 +- bin/omarchy-plugin | 554 ------------------ bin/omarchy-plugin-add | 170 ++++++ bin/omarchy-plugin-catalog | 10 +- bin/omarchy-plugin-clone | 490 +++++----------- bin/omarchy-plugin-disable | 26 + bin/omarchy-plugin-enable | 43 ++ bin/omarchy-plugin-list | 46 ++ bin/omarchy-plugin-remove | 141 +++++ bin/omarchy-plugin-update | 133 +++++ bin/omarchy-plugin-validate | 14 +- default/omarchy-skill/SKILL.md | 11 +- default/omarchy/omarchy-menu.jsonc | 3 +- docs/omarchy-shell.md | 19 +- shell/README.md | 28 +- shell/plugins/bar/README.md | 2 +- .../bar/widgets/Indicators.manifest.json | 8 + shell/plugins/bar/widgets/Tray.manifest.json | 8 + shell/services/PluginRegistry.qml | 61 +- shell/shell.qml | 23 +- .../fixtures/plugin-registry/shell.qml | 26 + test/shell.d/menu-plugin-test.sh | 68 ++- test/shell.d/menu-test.sh | 22 +- test/shell.d/plugin-add-test.sh | 2 +- test/shell.d/plugin-clone-test.sh | 239 ++++++-- test/shell.d/plugins-test.sh | 27 + test/shell.d/runtime-smoke-test.sh | 47 ++ 28 files changed, 1238 insertions(+), 1012 deletions(-) delete mode 100755 bin/omarchy-plugin create mode 100755 bin/omarchy-plugin-add create mode 100755 bin/omarchy-plugin-disable create mode 100755 bin/omarchy-plugin-enable create mode 100755 bin/omarchy-plugin-list create mode 100755 bin/omarchy-plugin-remove create mode 100755 bin/omarchy-plugin-update diff --git a/bin/omarchy-bar-plugin b/bin/omarchy-bar-plugin index cabf31f2..15da1ea5 100755 --- a/bin/omarchy-bar-plugin +++ b/bin/omarchy-bar-plugin @@ -458,8 +458,9 @@ cmd_replace() { prog=$(cat < -# omarchy:examples=omarchy menu plugin enable | omarchy menu plugin disable +# omarchy:args= set -euo pipefail @@ -13,19 +12,21 @@ PLUGIN_ICON=$'\U000f0431' case "${1:-}" in enable) filter='(.enabled | not)' ;; disable) filter='.canDisable and .enabled' ;; + clone) filter='.firstParty and ((.id | sub("^omarchy\\."; "local.")) as $clone_id | ($plugins | map(.id) | index($clone_id)) == null)' ;; remove) filter='(.firstParty | not)' ;; *) - echo "Usage: omarchy-menu-plugin " >&2 + echo "Usage: omarchy-menu-plugin " >&2 exit 1 ;; esac -plugins=$(omarchy-plugin list --json) || exit 1 +plugins=$(omarchy-plugin-list --json) # Two plugins may share a name, so keep the id with the row and show it when the # name alone cannot identify the pick. rows=$(jq -r --arg icon "$PLUGIN_ICON" \ - "([.[] | select($filter)]) as \$rows + ". as \$plugins + | ([.[] | select($filter)]) as \$rows | \$rows[] | .name as \$name | (if ([\$rows[] | select(.name == \$name)] | length) > 1 then \$name + \" (\" + .id + \")\" else \$name end) as \$label @@ -38,8 +39,13 @@ name=$(omarchy-menu-select "${1^} plugin" < <(cut -f1,2 <<<"$rows")) || exit 0 id=$(awk -F'\t' -v label="$name" '$2 == label { print $3; exit }' <<<"$rows") [[ -n $id ]] || exit 1 -if [[ $1 == "remove" ]]; then - omarchy-launch-floating-terminal-with-presentation "omarchy-plugin remove $(printf '%q' "$id")" +if [[ $1 == "clone" ]]; then + new_id="local.${id#omarchy.}" + target="$HOME/.config/omarchy/plugins/$new_id" + omarchy-launch-floating-terminal-with-presentation \ + "omarchy-plugin-clone $(printf '%q' "$id") && exec \$EDITOR $(printf '%q' "$target")" +elif [[ $1 == "remove" ]]; then + omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove $(printf '%q' "$id")" else - omarchy-plugin "$1" "$id" + "omarchy-plugin-$1" "$id" fi diff --git a/bin/omarchy-plugin b/bin/omarchy-plugin deleted file mode 100755 index c3e26865..00000000 --- a/bin/omarchy-plugin +++ /dev/null @@ -1,554 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Manage Omarchy shell plugins and bar widgets -# omarchy:group=plugin -# 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 - -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...] - -Manage plugins: - list [--json] List discovered shell plugins - rescan Rescan ~/.config/omarchy/plugins - enable [placement] Enable a plugin (a bar replaces the one in use) - disable Disable a plugin - -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 - - 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 widgets are placed in the layout with 'omarchy bar plugin'. - -Examples: - 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 enable acme.weather --section right -USAGE -} - -fail() { - echo "omarchy-plugin: $*" >&2 - exit 1 -} - -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 != *..* ]] -} - -is_bar_option() { - omarchy-plugin-catalog | jq -e --arg id "$1" 'any(.[]; .id == $id and (.kinds | index("bar")))' >/dev/null -} - -# Only one bar runs at a time, so enabling one replaces the one before it. Say -# that rather than "Enabled", which reads like one more thing switched on next -# to everything already there. -enabled_message() { - local id="$1" - if is_bar_option "$id"; then - echo "Now using $id as the bar" - else - echo "Enabled $id" - fi -} - -# A bar widget lands on the right when it is enabled, which is rarely where it -# belongs. Ask once, here, rather than leaving a follow-up 'omarchy bar plugin -# move' as the only way to place it. -place_bar_widget() { - local id="$1" section default_section - interactive || return 0 - (( ASSUME_YES )) && return 0 - # A plugin that is also a bar has no place in the layout to ask about. - jq -e '(.kinds // []) | (index("bar") | not) and (index("bar-widget") != null)' \ - "$PLUGINS_DIR/$id/manifest.json" >/dev/null 2>&1 || return 0 - - default_section=$(jq -r '.barWidget.defaultSection // "center"' "$PLUGINS_DIR/$id/manifest.json") - section=$(printf '%s\n' left center right | - gum choose --header="Place $id in which bar section?" --selected "$default_section") || return 0 - [[ -n $section ]] || return 0 - omarchy-bar-plugin move "$id" --section "$section" >/dev/null && echo "Placed $id in the $section section" -} - -installed_plugin_ids() { - find "$PLUGINS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) ! -name '.*' -printf '%f\n' 2>/dev/null | sort -} - -plugin_id_manifest() { - omarchy-plugin-catalog | jq -r --arg id "$1" ' - map(select(.id == $id))[0].manifestPath // empty - ' -} - -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 -} - -print_plugins() { - local json="false" - - while (( $# > 0 )); do - case "$1" in - --json) - json="true" - ;; - -h | --help) - usage - exit 0 - ;; - *) - fail "unknown list option: $1" - ;; - esac - shift - done - - local plugins - plugins=$(omarchy-shell shell listPlugins) || fail "could not list plugins; is omarchy-shell running?" - - if [[ $json == "true" ]]; then - printf '%s\n' "$plugins" - return - fi - - jq -r ' - sort_by(.id)[] | - [ .id, - (if .enabled then "enabled" else "disabled" end), - (if .firstParty then "first-party" else "third-party" end), - ((.kinds // []) | join(",")), - (.name // "") - ] | @tsv - ' <<<"$plugins" | awk -F '\t' ' - BEGIN { printf "%-32s %-9s %-11s %-18s %s\n", "ID", "STATE", "SOURCE", "KINDS", "NAME" } - { printf "%-32s %-9s %-11s %-18s %s\n", $1, $2, $3, $4, $5 } - ' -} - -rescan_plugins() { - (( $# == 0 )) || fail "rescan does not take arguments" - omarchy-shell shell rescanPlugins || fail "could not rescan plugins; is omarchy-shell running?" - echo "Plugins rescanned" -} - -plugin_enabled() { - local enabled="$1" - local id="${2:-}" - [[ -n $id ]] || fail "plugin id is required" - shift 2 - - if [[ $enabled != "true" ]] && (( $# > 0 )); then - fail "disable does not take placement options" - fi - - # Enabling a bar writes bar.id and touches no layout, so a placement could - # only be applied to a widget that is not there. Refuse before the bar is - # switched, rather than switching it and failing on the move afterwards. - if [[ $enabled == "true" ]] && (( $# > 0 )) && is_bar_option "$id"; then - fail "'$id' is a bar; it replaces the bar in use rather than taking a place in one" - fi - - omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true - if [[ $enabled == "true" ]]; then - wait_for_plugin_discovery "$id" || fail "plugin '$id' was not discovered; is omarchy-shell running?" - fi - - local result - result=$(omarchy-shell shell setPluginEnabled "$id" "$enabled") || fail "could not update plugin; is omarchy-shell running?" - [[ $result == "ok" ]] || fail "plugin '$id' is not known; run: omarchy plugin rescan" - - if [[ $enabled == "true" ]] && (( $# > 0 )); then - omarchy-bar-plugin move "$id" "$@" - echo "Enabled and moved $id" - elif [[ $enabled == "true" ]]; then - enabled_message "$id" - else - echo "Disabled $id" - fi -} - -# ---------------------------------------------------------------- add - -plugin_add() { - 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 - enabled_message "$id" - place_bar_widget "$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 omarchy-cmd-present delta; 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() { - 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" -} - -command="${1:-}" -case "$command" in -list | ls) - shift - print_plugins "$@" - ;; -rescan) - shift - rescan_plugins "$@" - ;; -enable) - shift - plugin_enabled true "$@" - ;; -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 "$@" - ;; -validate) - shift - exec omarchy-plugin-validate "$@" - ;; --h | --help | help | "") - usage - ;; -*) - fail "unknown command: $command" - ;; -esac diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add new file mode 100755 index 00000000..40167826 --- /dev/null +++ b/bin/omarchy-plugin-add @@ -0,0 +1,170 @@ +#!/bin/bash + +# omarchy:summary=Add a shell plugin from git +# omarchy:group=plugin +# omarchy:args=[git-url] [--enable] [--yes] +# omarchy:examples=omarchy plugin add https://github.com/acme/omarchy-weather.git --enable +# omarchy:alias=omarchy plugin install + +set -euo pipefail + +export GIT_TERMINAL_PROMPT=0 +export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -oBatchMode=yes}" + +PLUGINS_DIR="$HOME/.config/omarchy/plugins" +ASSUME_YES=0 + +fail() { + echo "omarchy-plugin-add: $*" >&2 + exit 1 +} + +interactive() { + [[ -t 0 && -t 1 ]] +} + +confirm() { + local prompt="$1" + (( ASSUME_YES )) && return 0 + if interactive; then + gum confirm "$prompt" + else + fail "refusing to continue without confirmation; pass --yes" + fi +} + +place_bar_widget() { + local id="$1" + local section + local default_section + interactive || return 0 + (( ASSUME_YES )) && return 0 + + jq -e '(.kinds // []) | (index("bar") | not) and (index("bar-widget") != null)' \ + "$PLUGINS_DIR/$id/manifest.json" >/dev/null 2>&1 || return 0 + + default_section=$(jq -r '.barWidget.defaultSection // "center"' "$PLUGINS_DIR/$id/manifest.json") + section=$(printf '%s\n' left center right | + gum choose --header="Place $id in which bar section?" --selected "$default_section") || return 0 + [[ -n $section ]] || return 0 + omarchy-bar-plugin move "$id" --section "$section" >/dev/null && + echo "Placed $id in the $section section" +} + +plugin_id_manifest() { + omarchy-plugin-catalog | jq -r --arg id "$1" ' + map(select(.id == $id))[0].manifestPath // empty + ' +} + +url="" +enable_after="" + +while (( $# > 0 )); do + case "$1" in + --enable) + enable_after=true + shift + ;; + --yes | -y) + ASSUME_YES=1 + shift + ;; + -h | --help) + echo "Usage: omarchy plugin add [git-url] [--enable] [--yes]" + exit 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 + +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 + for (( attempt = 0; attempt < 40; attempt++ )); do + result=$(omarchy-shell shell setPluginEnabled "$id" true) + [[ $result == "ok" ]] && break + sleep 0.05 + done + [[ $result == "ok" ]] || fail "plugin '$id' is not known" + if omarchy-plugin-catalog | jq -e --arg id "$id" ' + any(.[]; .id == $id and (.kinds | index("bar"))) + ' >/dev/null; then + echo "Now using $id as the bar" + else + echo "Enabled $id" + fi + place_bar_widget "$id" +else + echo "Enable it later with: omarchy plugin enable $id" +fi diff --git a/bin/omarchy-plugin-catalog b/bin/omarchy-plugin-catalog index de57ef80..2ecb3a85 100755 --- a/bin/omarchy-plugin-catalog +++ b/bin/omarchy-plugin-catalog @@ -14,15 +14,11 @@ set -o pipefail -OMARCHY_PATH="${OMARCHY_PATH:-}" - paths=() -if [[ -n $OMARCHY_PATH && -d $OMARCHY_PATH/shell/plugins ]]; then - while IFS= read -r manifest; do - paths+=("$manifest") - done < <(find "$OMARCHY_PATH/shell/plugins" -mindepth 2 -maxdepth 4 -type f \( -name manifest.json -o -name '*.manifest.json' \) 2>/dev/null | sort) -fi +while IFS= read -r manifest; do + paths+=("$manifest") +done < <(find "$OMARCHY_PATH/shell/plugins" -mindepth 2 -maxdepth 4 -type f \( -name manifest.json -o -name '*.manifest.json' \) | sort) user_dir="$HOME/.config/omarchy/plugins" if [[ -d $user_dir ]]; then diff --git a/bin/omarchy-plugin-clone b/bin/omarchy-plugin-clone index 21473d8f..d03eb2d3 100755 --- a/bin/omarchy-plugin-clone +++ b/bin/omarchy-plugin-clone @@ -1,9 +1,8 @@ #!/bin/bash -# omarchy:summary=Clone a built-in or user Omarchy shell plugin into your own config +# omarchy:summary=Clone a built-in Omarchy shell plugin into your own config # omarchy:group=plugin -# omarchy:args=[source-id] [new-id] [--name ] [--replace] [--add [placement]] [--use] -# omarchy:examples=omarchy plugin clone | omarchy plugin clone omarchy.clock local.clock | omarchy plugin clone omarchy.clock local.clock --name "My Clock" --replace +# omarchy:args= set -euo pipefail @@ -14,355 +13,178 @@ fail() { exit 1 } -require_omarchy_path() { - [[ -n ${OMARCHY_PATH:-} ]] || fail "OMARCHY_PATH is not set" +copy_plugin() { + local source_dir="$1" + local manifest="$2" + local target_dir="$3" + + if [[ ${manifest##*/} == "manifest.json" ]]; then + cp -aL "$source_dir/." "$target_dir/" + return + fi + + cp -aL "$manifest" "$target_dir/manifest.json" + + local source target source_pattern file + while IFS=$'\t' read -r source target; do + [[ $target != /* && $target != *".."* ]] || fail "invalid clone target path: $target" + if [[ -d $source_dir/$source ]]; then + mkdir -p "$target_dir/$target" + cp -aL "$source_dir/$source/." "$target_dir/$target/" + else + mkdir -p "$(dirname "$target_dir/$target")" + cp -aL "$source_dir/$source" "$target_dir/$target" + fi + + if [[ $source != "$target" ]]; then + source_pattern=${source//./\\.} + while IFS= read -r -d '' file; do + sed -i "s|$source_pattern|$target|g" "$file" + done < <(rg --files-with-matches --null --fixed-strings "$source" "$target_dir") + fi + done < <(jq -r ' + [ + (.entryPoints[] | {source: ., target: .}), + (.omarchy.clonePaths[]? | {source: .source, target: .target}) + ] | unique_by(.target)[] | [.source, .target] | @tsv + ' "$manifest") } -interactive() { - [[ -t 0 && -t 1 ]] +update_manifest() { + local target_dir="$1" + local source_id="$2" + local new_id="$3" + local display_name="$4" + local manifest="$target_dir/manifest.json" + + # Keep built-in ids inside the plugin code as stable IPC targets. The shell + # uses clonedFrom to route those calls to this local manifest id. + jq \ + --arg id "$new_id" \ + --arg name "$display_name" \ + --arg sourceId "$source_id" ' + .id = $id | + .name = $name | + if (.barWidget | type) == "object" then + .barWidget.displayName = $name + else + . + end | + .omarchy = ( + (if (.omarchy | type) == "object" then .omarchy else {} end) + + { clonedFrom: $sourceId } + ) | + del(.omarchy.clonePaths) + ' "$manifest" >"$manifest.tmp" + mv "$manifest.tmp" "$manifest" } -slug_id() { - tr '[:upper:]' '[:lower:]' <<<"$1" | sed -E 's/^omarchy\.//; s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' -} +switch_to_clone() { + local source_id="$1" + local new_id="$2" + local is_bar="$3" + local is_bar_widget="$4" + local has_non_widget_kind="$5" -validate_plugin_id() { - local id="$1" - [[ $id =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "plugin id must contain only letters, numbers, dots, underscores, and dashes" - [[ $id == *.* ]] || fail "plugin id must be namespaced, e.g. local.clock" - [[ $id != omarchy.* ]] || fail "plugin ids beginning with omarchy. are reserved for built-ins" -} - -catalog_json() { - require_omarchy_path - omarchy-plugin-catalog -} - -# Emit: idbarWidgetPathnamecategoryallowMultiple for a -# built-in bar widget matching $1 (by id or alias). Used to find the source QML -# file and metadata to copy when cloning a widget. -builtin_widget_info() { - catalog_json | jq -r --arg id "$1" ' - .[] | select((.kinds | index("bar-widget")) and .barWidgetPath != null) - | select(.id == $id or (((.barWidget.aliases // [])) | index($id))) - | [.id, .barWidgetPath, (.barWidget.displayName // .name // .id), (.barWidget.category // "Plugin"), (if .barWidget.allowMultiple == true then "true" else "false" end)] - | @tsv - ' | head -1 -} - -clone_source_options() { - catalog_json | jq -r ' - .[] | "\(.id)\t" + - (if .firstParty then (if (.kinds | index("bar-widget")) then "Built-in widget" else "Built-in plugin" end) else "User plugin" end) + - "\t" + ((.barWidget.displayName // .name // .id) // .id) - ' -} - -choose_clone_source() { - local selected - selected=$(clone_source_options | awk -F '\t' '{ printf "%-32s %-15s %s\n", $3, $2, $1 }' \ - | gum filter --header "Clone plugin" --placeholder "Search built-in and user plugins..." --limit 1) || return 1 - awk '{ print $NF }' <<<"$selected" -} - -input_value() { - local prompt="$1" - local value="${2:-}" - gum input --prompt "$prompt " --value "$value" -} - -rewrite_cloned_qml() { - local file="$1" - local id="$2" - local source_path="$3" - python3 - "$file" "$id" "$source_path" <<'PY' -import pathlib -import re -import sys -path, plugin_id, source_path = sys.argv[1], sys.argv[2], pathlib.Path(sys.argv[3]) -text = open(path, encoding="utf-8").read() -text = re.sub(r'moduleName:\s*"[^"]+"', f'moduleName: "{plugin_id}"', text, count=1) - - -def resolve_relative(match): - rel = match.group(1) - if rel.startswith(("../", "./")) or (not rel.startswith("/") and "://" not in rel): - resolved = (source_path.parent / rel).resolve() - return '"' + resolved.as_uri() + '"' - return match.group(0) - -text = re.sub(r'Qt\.resolvedUrl\("([^"]+)"\)', resolve_relative, text) - - -# A widget that imports a sibling JS module (the clock's Model.js) clones into -# a directory that does not have it. Point the import back at the bundled file, -# the same way Qt.resolvedUrl() references above are rewritten. -def resolve_js_import(match): - rel = match.group(1) - if rel.startswith("/") or "://" in rel: - return match.group(0) - resolved = (source_path.parent / rel).resolve() - return 'import "' + resolved.as_uri() + '"' - -text = re.sub(r'import\s+"([^"]+\.js)"', resolve_js_import, text) - -# Indicators dynamically loads sibling indicator components via string -# concatenation, so the simple Qt.resolvedUrl("literal") rewrite above cannot -# see it. Point the clone back at Omarchy's bundled indicator directory. -if source_path.name == "Indicators.qml": - indicators_url = (source_path.parent.parent / "indicators").resolve().as_uri() + "/" - text = text.replace( - 'Qt.resolvedUrl("../indicators/" + indicatorSlot.indicatorId + ".qml")', - f'"{indicators_url}" + indicatorSlot.indicatorId + ".qml"', - ) - -# Panel-backed clones should not register the same global IPC target as the -# built-in widget. Direct panel clones can disable Panel.manageIpc themselves; -# wrapper widgets such as Weather disable it on the loaded panel instance. -if "ipcTarget:" in text and "manageIpc:" not in text: - text = text.replace(" id: root\n", " id: root\n manageIpc: false\n", 1) -if "function injectPanel()" in text and '"manageIpc" in target' not in text: - text = text.replace( - " if (!target) return\n", - " if (!target) return\n if (\"manageIpc\" in target) target.manageIpc = false\n", - 1, - ) - -open(path, "w", encoding="utf-8").write(text) -PY -} - -write_clone_manifest() { - local file="$1" - local id="$2" - local name="$3" - local description="$4" - local category="$5" - local multiple="$6" - local source_id="$7" - local source_path="$8" - python3 - "$file" "$id" "$name" "$description" "$category" "$multiple" "$source_id" "$source_path" <<'PY' -import json -import sys -file, plugin_id, name, description, category, multiple, source_id, source_path = sys.argv[1:] -data = { - "schemaVersion": 1, - "id": plugin_id, - "name": name, - "version": "1.0.0", - "author": "Local", - "description": description, - "kinds": ["bar-widget"], - "entryPoints": {"barWidget": "Widget.qml"}, - "barWidget": { - "displayName": name, - "description": description, - "category": category, - "allowMultiple": multiple == "true" - }, - "omarchy": { - "clonedFrom": source_id, - "clonedFromPath": source_path - } -} -open(file, "w", encoding="utf-8").write(json.dumps(data, indent=2) + "\n") -PY -} - -update_cloned_manifest() { - local file="$1" - local id="$2" - local name="$3" - local source_id="$4" - local source_path="$5" - python3 - "$file" "$id" "$name" "$source_id" "$source_path" <<'PY' -import json -import sys -file, plugin_id, name, source_id, source_path = sys.argv[1:] -with open(file, encoding="utf-8") as handle: - data = json.load(handle) -data["id"] = plugin_id -data["name"] = name -if "barWidget" in data and isinstance(data["barWidget"], dict): - data["barWidget"]["displayName"] = name -meta = data.get("omarchy") if isinstance(data.get("omarchy"), dict) else {} -meta.update({"clonedFrom": source_id, "clonedFromPath": source_path}) -data["omarchy"] = meta -open(file, "w", encoding="utf-8").write(json.dumps(data, indent=2) + "\n") -PY + if [[ $is_bar == "true" ]]; then + omarchy-bar use "$new_id" + elif [[ $is_bar_widget == "true" ]]; then + local shell_config + shell_config=$(omarchy-shell shell listShellConfig) + if jq -e --arg id "$source_id" ' + [ + .bar.layout.left[]?, + .bar.layout.center[]?, + .bar.layout.right[]? + ] | any(.[]; (if type == "object" then .id else . end) == $id) + ' <<<"$shell_config" >/dev/null; then + omarchy-bar-plugin replace "$source_id" "$new_id" + else + omarchy-bar-plugin add "$new_id" + fi + [[ $has_non_widget_kind == "false" ]] || omarchy-plugin-disable "$source_id" + else + omarchy-plugin-disable "$source_id" + omarchy-plugin-enable "$new_id" + fi } usage() { cat < -Clones a built-in or user plugin into ~/.config/omarchy/plugins// so you -can edit it freely. The original stays built into Omarchy. +Copies a built-in plugin into ~/.config/omarchy/plugins/local./ so you can +edit it freely. The clone keeps all of the source plugin's files and kinds and +uses "My " as its display name, then replaces the built-in with the clone. -Options: - --name Display name for the clone - --replace Replace the first bar instance of the source with the clone - --add [placement] Add the cloned bar widget to the bar - --use Use the clone as the active bar option (bar plugins) - -Placement (with --add): - --section - --index - --before - --after - -Examples: - omarchy plugin clone - omarchy plugin clone omarchy.clock local.clock - omarchy plugin clone omarchy.clock local.clock --name "My Clock" --replace USAGE } -clone_command() { - require_omarchy_path +source_id="" +if (( $# > 0 )) && [[ $1 != --* ]]; then + source_id="$1" + shift +fi - local source_id="" - local new_id="" - if (( $# > 0 )) && [[ $1 != --* ]]; then - source_id="$1" - shift - fi - if (( $# > 0 )) && [[ $1 != --* ]]; then - new_id="$1" - shift - fi +while (( $# > 0 )); do + case "$1" in + -h | --help) + usage + exit 0 + ;; + *) + fail "unknown clone option: $1" + ;; + esac +done - local display_name="" - local replace="false" - local add="false" - local use_bar="false" - local placement=() +[[ -n $source_id ]] || fail "clone source is required" - while (( $# > 0 )); do - case "$1" in - --name) - display_name="${2:-}" - [[ -n $display_name ]] || fail "--name requires a value" - shift 2 - ;; - --replace) - replace="true" - shift - ;; - --add) - add="true" - shift - ;; - --use) - use_bar="true" - shift - ;; - --section | --index | --before | --after) - placement+=("$1" "${2:-}") - shift 2 - ;; - -h | --help) - usage - return - ;; - *) - fail "unknown clone option: $1" - ;; - esac - done +source_info=$(omarchy-plugin-catalog | jq -r --arg id "$source_id" ' + .[] | select(.firstParty and .id == $id) + | [ + .sourceDir, + .manifestPath, + (.name // .id), + ((.kinds | index("bar")) != null), + ((.kinds | index("bar-widget")) != null), + (any(.kinds[]; . != "bar-widget")) + ] | @tsv + ') +[[ -n $source_info ]] || fail "unknown built-in plugin: $source_id" +IFS=$'\t' read -r source_dir source_manifest source_name is_bar is_bar_widget has_non_widget_kind <<<"$source_info" - if [[ -z $source_id ]]; then - if interactive; then - source_id=$(choose_clone_source) || fail "clone cancelled" - else - fail "clone source is required" - fi - fi +new_id="local.${source_id#omarchy.}" +display_name="My $source_name" +target_dir="$PLUGINS_DIR/$new_id" +[[ ! -e $target_dir && ! -L $target_dir ]] || fail "$target_dir already exists" - local default_slug - default_slug=$(slug_id "$source_id") - if [[ -z $new_id ]]; then - if interactive; then - new_id=$(input_value "New plugin id:" "local.$default_slug") - else - fail "new plugin id is required" - fi - fi - validate_plugin_id "$new_id" - - local target_dir="$PLUGINS_DIR/$new_id" - [[ ! -e $target_dir && ! -L $target_dir ]] || fail "$target_dir already exists" - - local source_path="" - local source_name="" - local source_category="Plugin" - local source_multiple="false" - local source_type="" - - if IFS=$'\t' read -r _ source_path source_name source_category source_multiple < <(builtin_widget_info "$source_id"); then - source_type="builtin-widget" - elif [[ -f $PLUGINS_DIR/$source_id/manifest.json ]]; then - source_type="user-plugin" - source_path="$PLUGINS_DIR/$source_id" - source_name=$(jq -r '.name // .id' "$source_path/manifest.json") - else - # Non-widget built-in plugin (e.g. a panel or bar): find it in the catalog. - local hit - hit=$(catalog_json | jq -r --arg id "$source_id" '.[] | select(.id == $id) | .sourceDir' | head -1) - if [[ -n $hit ]]; then - source_type="builtin-plugin" - source_path="$hit" - source_name=$(jq -r '.name // .id' "$source_path/manifest.json") - fi - fi - - [[ -n $source_type ]] || fail "unknown clone source: $source_id" - if [[ -z $display_name ]]; then - if interactive; then - display_name=$(input_value "Display name:" "${source_name:-$new_id}") - else - display_name="${source_name:-$new_id}" - fi - fi - [[ -n $display_name ]] || fail "display name is required" - - mkdir -p "$target_dir" - if [[ $source_type == "builtin-widget" ]]; then - cp "$source_path" "$target_dir/Widget.qml" - rewrite_cloned_qml "$target_dir/Widget.qml" "$new_id" "$source_path" - write_clone_manifest "$target_dir/manifest.json" "$new_id" "$display_name" "Cloned from $source_id" "$source_category" "$source_multiple" "$source_id" "$source_path" - else - cp -aL "$source_path/." "$target_dir/" - update_cloned_manifest "$target_dir/manifest.json" "$new_id" "$display_name" "$source_id" "$source_path" - fi - - { - printf '# %s\n\n' "$display_name" - printf 'Cloned from `%s`.\n\n' "$source_id" - printf 'Source: `%s`\n\n' "$source_path" - printf 'The original remains built into Omarchy. Remove this plugin directory or swap\n' - printf 'your bar entry back to `%s` whenever you want to return to the built-in.\n' "$source_id" - } >"$target_dir/UPSTREAM.md" - - omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true - - if [[ $replace == "true" ]]; then - omarchy-bar-plugin replace "$source_id" "$new_id" - echo "Cloned $source_id to $new_id and replaced the first bar instance" - elif [[ $add == "true" ]]; then - omarchy-bar-plugin add "$new_id" "${placement[@]}" - echo "Cloned $source_id to $new_id" - elif [[ $use_bar == "true" ]]; then - omarchy-bar use "$new_id" - echo "Cloned $source_id to $new_id and selected it as the active bar option" - else - echo "Cloned $source_id to $new_id" - fi - - if interactive; then - cd "$target_dir" - exec "${SHELL:-bash}" - fi +mkdir -p "$PLUGINS_DIR" +stage=$(mktemp -d "$PLUGINS_DIR/.clone.XXXXXX") +clone_complete=0 +cleanup() { + [[ -d ${stage:-} ]] && rm -rf "$stage" + (( clone_complete )) || rm -rf "$target_dir" } +trap cleanup EXIT +copy_plugin "$source_dir" "$source_manifest" "$stage" +update_manifest "$stage" "$source_id" "$new_id" "$display_name" +mv "$stage" "$target_dir" +stage="" -clone_command "$@" +omarchy-shell shell rescanPlugins >/dev/null +discovered=0 +for (( attempt = 0; attempt < 40; attempt++ )); do + if omarchy-plugin-list --json | jq -e --arg id "$new_id" 'any(.[]; .id == $id)' >/dev/null; then + discovered=1 + break + fi + sleep 0.05 +done +(( discovered )) || fail "cloned plugin '$new_id' was not discovered" +switch_to_clone "$source_id" "$new_id" "$is_bar" "$is_bar_widget" "$has_non_widget_kind" +clone_complete=1 +omarchy-notification-send -g 󰐱 \ + "Editing Cloned Plugin" \ + "Original plugin has been replace by clone." +echo "Cloned $source_id to $target_dir and switched to $new_id" diff --git a/bin/omarchy-plugin-disable b/bin/omarchy-plugin-disable new file mode 100755 index 00000000..5968fa84 --- /dev/null +++ b/bin/omarchy-plugin-disable @@ -0,0 +1,26 @@ +#!/bin/bash + +# omarchy:summary=Disable a shell plugin +# omarchy:group=plugin +# omarchy:args= + +set -euo pipefail + +fail() { + echo "omarchy-plugin-disable: $*" >&2 + exit 1 +} + +if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then + echo "Usage: omarchy plugin disable " + exit 0 +fi + +id="${1:-}" +[[ -n $id ]] || fail "plugin id is required" + +result=$(omarchy-shell shell setPluginEnabled "$id" false) +[[ $result == "ok" ]] || + fail "plugin '$id' is not known; run: omarchy-shell shell rescanPlugins" + +echo "Disabled $id" diff --git a/bin/omarchy-plugin-enable b/bin/omarchy-plugin-enable new file mode 100755 index 00000000..c4785a31 --- /dev/null +++ b/bin/omarchy-plugin-enable @@ -0,0 +1,43 @@ +#!/bin/bash + +# omarchy:summary=Enable a shell plugin +# omarchy:group=plugin +# omarchy:args= [placement] +# omarchy:examples=omarchy plugin enable acme.weather --section right + +set -euo pipefail + +fail() { + echo "omarchy-plugin-enable: $*" >&2 + exit 1 +} + +if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then + echo "Usage: omarchy plugin enable [placement]" + exit 0 +fi + +id="${1:-}" +[[ -n $id ]] || fail "plugin id is required" +shift + +if (( $# > 0 )) && omarchy-plugin-catalog | jq -e --arg id "$id" ' + any(.[]; .id == $id and (.kinds | index("bar"))) +' >/dev/null; then + fail "'$id' is a bar; it replaces the bar in use rather than taking a place in one" +fi + +result=$(omarchy-shell shell setPluginEnabled "$id" true) +[[ $result == "ok" ]] || + fail "plugin '$id' is not known; run: omarchy-shell shell rescanPlugins" + +if (( $# > 0 )); then + omarchy-bar-plugin move "$id" "$@" + echo "Enabled and moved $id" +elif omarchy-plugin-catalog | jq -e --arg id "$id" ' + any(.[]; .id == $id and (.kinds | index("bar"))) +' >/dev/null; then + echo "Now using $id as the bar" +else + echo "Enabled $id" +fi diff --git a/bin/omarchy-plugin-list b/bin/omarchy-plugin-list new file mode 100755 index 00000000..a4b42018 --- /dev/null +++ b/bin/omarchy-plugin-list @@ -0,0 +1,46 @@ +#!/bin/bash + +# omarchy:summary=List discovered shell plugins +# omarchy:group=plugin +# omarchy:args=[--json] + +set -euo pipefail + +json="false" + +while (( $# > 0 )); do + case "$1" in + --json) + json="true" + ;; + -h | --help) + echo "Usage: omarchy plugin list [--json]" + exit 0 + ;; + *) + echo "omarchy-plugin-list: unknown option: $1" >&2 + exit 1 + ;; + esac + shift +done + +plugins=$(omarchy-shell shell listPlugins) + +if [[ $json == "true" ]]; then + printf '%s\n' "$plugins" + exit 0 +fi + +jq -r ' + .[] | + [ .id, + (if .enabled then "enabled" else "disabled" end), + (if .firstParty then "first-party" else "third-party" end), + ((.kinds // []) | join(",")), + (.name // "") + ] | @tsv +' <<<"$plugins" | awk -F '\t' ' + BEGIN { printf "%-32s %-9s %-11s %-18s %s\n", "ID", "STATE", "SOURCE", "KINDS", "NAME" } + { printf "%-32s %-9s %-11s %-18s %s\n", $1, $2, $3, $4, $5 } +' diff --git a/bin/omarchy-plugin-remove b/bin/omarchy-plugin-remove new file mode 100755 index 00000000..0310accf --- /dev/null +++ b/bin/omarchy-plugin-remove @@ -0,0 +1,141 @@ +#!/bin/bash + +# omarchy:summary=Remove an installed shell plugin +# omarchy:group=plugin +# omarchy:args=[id] [--yes] +# omarchy:alias=omarchy plugin rm + +set -euo pipefail + +PLUGINS_DIR="$HOME/.config/omarchy/plugins" +ASSUME_YES=0 + +fail() { + echo "omarchy-plugin-remove: $*" >&2 + exit 1 +} + +interactive() { + [[ -t 0 && -t 1 ]] +} + +confirm() { + local prompt="$1" + (( ASSUME_YES )) && return 0 + if interactive; then + gum confirm "$prompt" + else + fail "refusing to continue without confirmation; pass --yes" + fi +} + +valid_plugin_id() { + [[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && $1 != *..* ]] +} + +id="" + +while (( $# > 0 )); do + case "$1" in + --yes | -y) + ASSUME_YES=1 + shift + ;; + -h | --help) + echo "Usage: omarchy plugin remove [id] [--yes]" + exit 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=$(find "$PLUGINS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) \ + ! -name '.*' -printf '%f\n' 2>/dev/null | + sort | + gum choose --header="Remove which plugin?") || fail "cancelled" + [[ -n $id ]] || fail "nothing selected" +fi +valid_plugin_id "$id" || fail "invalid plugin id '$id'" + +target="$PLUGINS_DIR/$id" +[[ -e $target || -L $target ]] || fail "plugin '$id' is not installed" + +was_enabled="" +shell_plugins=$(omarchy-shell shell listPlugins) +if [[ -n $shell_plugins ]]; then + was_enabled=$(jq -r --arg id "$id" ' + .[] | select(.id == $id) | .enabled + ' <<<"$shell_plugins") +fi + +cloned_from="" +restored_source=0 +if [[ -f $target/manifest.json ]]; then + cloned_from=$(jq -r '.omarchy.clonedFrom // empty' "$target/manifest.json") +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 + +if [[ -n $cloned_from ]]; then + is_bar=$(jq -r '(.kinds | index("bar")) != null' "$target/manifest.json") + is_bar_widget=$(jq -r '(.kinds | index("bar-widget")) != null' "$target/manifest.json") + has_non_widget_kind=$(jq -r 'any(.kinds[]; . != "bar-widget")' "$target/manifest.json") + + if [[ $is_bar == "true" && $was_enabled == "true" ]]; then + omarchy-bar use "$cloned_from" + restored_source=1 + elif [[ $is_bar_widget == "true" && $was_enabled == "true" ]]; then + omarchy-bar-plugin replace "$id" "$cloned_from" + [[ $has_non_widget_kind == "false" ]] || omarchy-plugin-enable "$cloned_from" + restored_source=1 + elif [[ $has_non_widget_kind == "true" ]]; then + [[ $was_enabled != "true" ]] || omarchy-shell shell setPluginEnabled "$id" false >/dev/null + omarchy-plugin-enable "$cloned_from" + restored_source=1 + fi +elif [[ $was_enabled == "true" ]]; then + omarchy-shell shell setPluginEnabled "$id" false >/dev/null +fi + +if [[ -L $target ]]; then + rm -f "$target" + echo "Unlinked $id." +elif [[ -d $target/.git ]]; then + rm -rf "$target" + echo "Removed $id." +else + base="$PLUGINS_DIR/.${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 $id. Backup at: $backup" +fi + +omarchy-shell shell rescanPlugins >/dev/null + +if (( restored_source )); then + echo "Restored $cloned_from." +elif [[ $was_enabled == "true" ]]; then + echo "Plugin was enabled and was unloaded from omarchy-shell." +fi diff --git a/bin/omarchy-plugin-update b/bin/omarchy-plugin-update new file mode 100755 index 00000000..90a9732e --- /dev/null +++ b/bin/omarchy-plugin-update @@ -0,0 +1,133 @@ +#!/bin/bash + +# omarchy:summary=Update installed git-managed plugins +# omarchy:group=plugin +# omarchy:args=[id] [--yes] + +set -euo pipefail + +export GIT_TERMINAL_PROMPT=0 +export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -oBatchMode=yes}" + +PLUGINS_DIR="$HOME/.config/omarchy/plugins" +ASSUME_YES=0 +UPDATED_ANY=0 + +fail() { + echo "omarchy-plugin-update: $*" >&2 + exit 1 +} + +interactive() { + [[ -t 0 && -t 1 ]] +} + +confirm() { + local prompt="$1" + (( ASSUME_YES )) && return 0 + if interactive; then + gum confirm "$prompt" + else + fail "refusing to continue without confirmation; pass --yes" + fi +} + +valid_plugin_id() { + [[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && $1 != *..* ]] +} + +update_one() { + local id="$1" + local dir="$PLUGINS_DIR/$id" + + if ! git -C "$dir" fetch --quiet origin HEAD; then + echo "omarchy-plugin-update: 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 omarchy-cmd-present delta; 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-update: cannot fast-forward '$id'; you have local changes in $dir" >&2 + return 1 + fi + + if ! omarchy-plugin-validate "$dir"; then + git -C "$dir" reset --hard ORIG_HEAD >/dev/null + echo "omarchy-plugin-update: update of '$id' failed validation; rolled back" >&2 + return 1 + fi + + echo "Updated $id." + UPDATED_ANY=1 +} + +id="" + +while (( $# > 0 )); do + case "$1" in + --yes | -y) + ASSUME_YES=1 + shift + ;; + -h | --help) + echo "Usage: omarchy plugin update [id] [--yes]" + exit 0 + ;; + -*) + fail "unknown update option: $1" + ;; + *) + [[ -z $id ]] || fail "unexpected argument: $1" + id="$1" + shift + ;; + esac +done + +[[ -d $PLUGINS_DIR ]] || fail "no plugins installed" + +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 + for dir in "$PLUGINS_DIR"/*/; do + [[ -d $dir/.git ]] || continue + targets+=("$(basename "$dir")") + done + if (( ${#targets[@]} == 0 )); then + echo "No git-managed plugins installed." + exit 0 + fi +fi + +rc=0 +for id in "${targets[@]}"; do + update_one "$id" || rc=1 +done + +if (( UPDATED_ANY )); then + omarchy-shell shell rescanPlugins >/dev/null +fi +exit $rc diff --git a/bin/omarchy-plugin-validate b/bin/omarchy-plugin-validate index 3603e7b5..00d75122 100755 --- a/bin/omarchy-plugin-validate +++ b/bin/omarchy-plugin-validate @@ -49,7 +49,7 @@ 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 != *".."* ]] || fail "invalid plugin id '$ID'" [[ $ID != omarchy.* ]] || fail "plugin id '$ID' uses the reserved omarchy.* namespace" # kinds must be a non-empty array. @@ -115,16 +115,4 @@ done 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. -# A third-party plugin claiming one of those ids would be rejected by the shell -# and could shadow built-in behaviour, so refuse it here too. -FIRST_PARTY_DIR="${OMARCHY_PATH:-$HOME/.local/share/omarchy}/shell/plugins" -if [[ -d $FIRST_PARTY_DIR ]]; then - while IFS= read -r fp_manifest; do - [[ -f $fp_manifest ]] || continue - fp_id=$(jq -r '.id // empty' "$fp_manifest" 2>/dev/null || true) - [[ $fp_id == "$ID" ]] && fail "plugin id '$ID' collides with a first-party Omarchy plugin" - done < <(find "$FIRST_PARTY_DIR" -type f \( -name manifest.json -o -name '*.manifest.json' \) 2>/dev/null) -fi - exit 0 diff --git a/default/omarchy-skill/SKILL.md b/default/omarchy-skill/SKILL.md index 70c74e0b..b0ab7000 100644 --- a/default/omarchy-skill/SKILL.md +++ b/default/omarchy-skill/SKILL.md @@ -130,7 +130,7 @@ Run `omarchy --help` for the full list. The most common groups: | `omarchy toggle` | Toggle feature on/off | `omarchy toggle nightlight` | | `omarchy theme` | Theme management | `omarchy theme set ` | | `omarchy bar` | Bar layout and widgets | `omarchy bar plugin move omarchy.clock --section right` | -| `omarchy plugin` | Manage/clone shell plugins | `omarchy plugin clone omarchy.clock local.clock --replace` | +| `omarchy plugin` | Manage/clone shell plugins | `omarchy plugin clone omarchy.clock` | | `omarchy hook` | Install automation hooks | `omarchy hook install theme-set