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
This commit is contained in:
David Heinemeier Hansson
2026-07-29 21:34:47 -04:00
committed by GitHub
parent fb564e3ac1
commit f8835df644
28 changed files with 1238 additions and 1012 deletions
+3 -2
View File
@@ -458,8 +458,9 @@ cmd_replace() {
prog=$(cat <<JQ prog=$(cat <<JQ
$JQ_DEFS $NORMALIZE $JQ_DEFS $NORMALIZE
| resolve_source as \$source | resolve_source as \$source
| if \$source.entry | type != "object" then error("widget entry must be an object") else . end | .bar.layout[\$source.section][\$source.index] = (
| .bar.layout[\$source.section][\$source.index].id = \$new \$source.entry | if type == "object" then .id = \$new else { id: \$new } end
)
JQ JQ
) )
# resolve_source finds by widget id when no placement is given; replace # resolve_source finds by widget id when no placement is given; replace
+15 -9
View File
@@ -1,10 +1,9 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Pick a shell plugin to enable, disable, or remove # omarchy:summary=Pick a shell plugin to enable, disable, clone, or remove
# omarchy:group=menu # omarchy:group=menu
# omarchy:name=plugin # omarchy:name=plugin
# omarchy:args=<enable|disable|remove> # omarchy:args=<enable|disable|clone|remove>
# omarchy:examples=omarchy menu plugin enable | omarchy menu plugin disable
set -euo pipefail set -euo pipefail
@@ -13,19 +12,21 @@ PLUGIN_ICON=$'\U000f0431'
case "${1:-}" in case "${1:-}" in
enable) filter='(.enabled | not)' ;; enable) filter='(.enabled | not)' ;;
disable) filter='.canDisable and .enabled' ;; 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)' ;; remove) filter='(.firstParty | not)' ;;
*) *)
echo "Usage: omarchy-menu-plugin <enable|disable|remove>" >&2 echo "Usage: omarchy-menu-plugin <enable|disable|clone|remove>" >&2
exit 1 exit 1
;; ;;
esac 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 # Two plugins may share a name, so keep the id with the row and show it when the
# name alone cannot identify the pick. # name alone cannot identify the pick.
rows=$(jq -r --arg icon "$PLUGIN_ICON" \ rows=$(jq -r --arg icon "$PLUGIN_ICON" \
"([.[] | select($filter)]) as \$rows ". as \$plugins
| ([.[] | select($filter)]) as \$rows
| \$rows[] | \$rows[]
| .name as \$name | .name as \$name
| (if ([\$rows[] | select(.name == \$name)] | length) > 1 then \$name + \" (\" + .id + \")\" else \$name end) as \$label | (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") id=$(awk -F'\t' -v label="$name" '$2 == label { print $3; exit }' <<<"$rows")
[[ -n $id ]] || exit 1 [[ -n $id ]] || exit 1
if [[ $1 == "remove" ]]; then if [[ $1 == "clone" ]]; then
omarchy-launch-floating-terminal-with-presentation "omarchy-plugin remove $(printf '%q' "$id")" 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 else
omarchy-plugin "$1" "$id" "omarchy-plugin-$1" "$id"
fi fi
-554
View File
@@ -1,554 +0,0 @@
#!/bin/bash
# omarchy:summary=Manage Omarchy shell plugins and bar widgets
# omarchy:group=plugin
# omarchy:args=<list|rescan|enable|disable|add|update|remove|clone|edit|validate> [...]
# 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 <<USAGE
Usage: omarchy-plugin <command> [args...]
Manage plugins:
list [--json] List discovered shell plugins
rescan Rescan ~/.config/omarchy/plugins
enable <id> [placement] Enable a plugin (a bar replaces the one in use)
disable <id> 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 <plugin-folder> 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 <<WARN
⚠️ Plugins run as arbitrary, unsandboxed code inside your long-lived
omarchy-shell process. Only add repos you trust, and review the code
before you enable it.
URL: $url
WARN
confirm "Clone and add this plugin?" || fail "aborted"
fi
mkdir -p "$PLUGINS_DIR"
# Clone into a dot-prefixed staging dir (invisible to the plugin scanner) so
# a plugin only ever appears under its manifest id, fully validated.
local stage="$PLUGINS_DIR/.add.tmp.$$"
rm -rf "$stage"
if ! git clone -- "$url" "$stage"; then
rm -rf "$stage"
fail "failed to clone $url"
fi
if ! omarchy-plugin-validate "$stage"; then
rm -rf "$stage"
fail "refusing to add: validation failed"
fi
local id existing_manifest
id=$(jq -r '.id' "$stage/manifest.json")
existing_manifest=$(plugin_id_manifest "$id") || {
rm -rf "$stage"
fail "could not inspect installed plugin ids"
}
if [[ -n $existing_manifest ]]; then
rm -rf "$stage"
fail "plugin id '$id' is already used by $existing_manifest"
fi
local target="$PLUGINS_DIR/$id"
if [[ -e $target || -L $target ]]; then
rm -rf "$stage"
fail "plugin '$id' is already installed; update it with: omarchy plugin update $id"
fi
mv "$stage" "$target"
echo "Added $id into $target"
omarchy-shell shell rescanPlugins >/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
+170
View File
@@ -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 <<WARN
⚠️ Plugins run as arbitrary, unsandboxed code inside your long-lived
omarchy-shell process. Only add repos you trust, and review the code
before you enable it.
URL: $url
WARN
confirm "Clone and add this plugin?" || fail "aborted"
fi
mkdir -p "$PLUGINS_DIR"
stage="$PLUGINS_DIR/.add.tmp.$$"
rm -rf "$stage"
if ! git clone -- "$url" "$stage"; then
rm -rf "$stage"
fail "failed to clone $url"
fi
if ! omarchy-plugin-validate "$stage"; then
rm -rf "$stage"
fail "refusing to add: validation failed"
fi
id=$(jq -r '.id' "$stage/manifest.json")
existing_manifest=$(plugin_id_manifest "$id") || {
rm -rf "$stage"
fail "could not inspect installed plugin ids"
}
if [[ -n $existing_manifest ]]; then
rm -rf "$stage"
fail "plugin id '$id' is already used by $existing_manifest"
fi
target="$PLUGINS_DIR/$id"
if [[ -e $target || -L $target ]]; then
rm -rf "$stage"
fail "plugin '$id' is already installed; update it with: omarchy plugin update $id"
fi
mv "$stage" "$target"
echo "Added $id into $target"
omarchy-shell shell rescanPlugins >/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
+3 -7
View File
@@ -14,15 +14,11 @@
set -o pipefail set -o pipefail
OMARCHY_PATH="${OMARCHY_PATH:-}"
paths=() paths=()
if [[ -n $OMARCHY_PATH && -d $OMARCHY_PATH/shell/plugins ]]; then while IFS= read -r manifest; do
while IFS= read -r manifest; do paths+=("$manifest")
paths+=("$manifest") done < <(find "$OMARCHY_PATH/shell/plugins" -mindepth 2 -maxdepth 4 -type f \( -name manifest.json -o -name '*.manifest.json' \) | sort)
done < <(find "$OMARCHY_PATH/shell/plugins" -mindepth 2 -maxdepth 4 -type f \( -name manifest.json -o -name '*.manifest.json' \) 2>/dev/null | sort)
fi
user_dir="$HOME/.config/omarchy/plugins" user_dir="$HOME/.config/omarchy/plugins"
if [[ -d $user_dir ]]; then if [[ -d $user_dir ]]; then
+156 -334
View File
@@ -1,9 +1,8 @@
#!/bin/bash #!/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:group=plugin
# omarchy:args=[source-id] [new-id] [--name <name>] [--replace] [--add [placement]] [--use] # omarchy:args=<source-id>
# omarchy:examples=omarchy plugin clone | omarchy plugin clone omarchy.clock local.clock | omarchy plugin clone omarchy.clock local.clock --name "My Clock" --replace
set -euo pipefail set -euo pipefail
@@ -14,355 +13,178 @@ fail() {
exit 1 exit 1
} }
require_omarchy_path() { copy_plugin() {
[[ -n ${OMARCHY_PATH:-} ]] || fail "OMARCHY_PATH is not set" 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() { update_manifest() {
[[ -t 0 && -t 1 ]] 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() { switch_to_clone() {
tr '[:upper:]' '[:lower:]' <<<"$1" | sed -E 's/^omarchy\.//; s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' 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() { if [[ $is_bar == "true" ]]; then
local id="$1" omarchy-bar use "$new_id"
[[ $id =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "plugin id must contain only letters, numbers, dots, underscores, and dashes" elif [[ $is_bar_widget == "true" ]]; then
[[ $id == *.* ]] || fail "plugin id must be namespaced, e.g. local.clock" local shell_config
[[ $id != omarchy.* ]] || fail "plugin ids beginning with omarchy. are reserved for built-ins" shell_config=$(omarchy-shell shell listShellConfig)
} if jq -e --arg id "$source_id" '
[
catalog_json() { .bar.layout.left[]?,
require_omarchy_path .bar.layout.center[]?,
omarchy-plugin-catalog .bar.layout.right[]?
} ] | any(.[]; (if type == "object" then .id else . end) == $id)
' <<<"$shell_config" >/dev/null; then
# Emit: id<TAB>barWidgetPath<TAB>name<TAB>category<TAB>allowMultiple for a omarchy-bar-plugin replace "$source_id" "$new_id"
# built-in bar widget matching $1 (by id or alias). Used to find the source QML else
# file and metadata to copy when cloning a widget. omarchy-bar-plugin add "$new_id"
builtin_widget_info() { fi
catalog_json | jq -r --arg id "$1" ' [[ $has_non_widget_kind == "false" ]] || omarchy-plugin-disable "$source_id"
.[] | select((.kinds | index("bar-widget")) and .barWidgetPath != null) else
| select(.id == $id or (((.barWidget.aliases // [])) | index($id))) omarchy-plugin-disable "$source_id"
| [.id, .barWidgetPath, (.barWidget.displayName // .name // .id), (.barWidget.category // "Plugin"), (if .barWidget.allowMultiple == true then "true" else "false" end)] omarchy-plugin-enable "$new_id"
| @tsv fi
' | 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
} }
usage() { usage() {
cat <<USAGE cat <<USAGE
Usage: omarchy plugin clone [source-id] [new-id] [options] Usage: omarchy plugin clone <source-id>
Clones a built-in or user plugin into ~/.config/omarchy/plugins/<new-id>/ so you Copies a built-in plugin into ~/.config/omarchy/plugins/local.<id>/ so you can
can edit it freely. The original stays built into Omarchy. edit it freely. The clone keeps all of the source plugin's files and kinds and
uses "My <name>" as its display name, then replaces the built-in with the clone.
Options:
--name <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 <left|center|right>
--index <n>
--before <id>
--after <id>
Examples:
omarchy plugin clone
omarchy plugin clone omarchy.clock local.clock
omarchy plugin clone omarchy.clock local.clock --name "My Clock" --replace
USAGE USAGE
} }
clone_command() { source_id=""
require_omarchy_path if (( $# > 0 )) && [[ $1 != --* ]]; then
source_id="$1"
shift
fi
local source_id="" while (( $# > 0 )); do
local new_id="" case "$1" in
if (( $# > 0 )) && [[ $1 != --* ]]; then -h | --help)
source_id="$1" usage
shift exit 0
fi ;;
if (( $# > 0 )) && [[ $1 != --* ]]; then *)
new_id="$1" fail "unknown clone option: $1"
shift ;;
fi esac
done
local display_name="" [[ -n $source_id ]] || fail "clone source is required"
local replace="false"
local add="false"
local use_bar="false"
local placement=()
while (( $# > 0 )); do source_info=$(omarchy-plugin-catalog | jq -r --arg id "$source_id" '
case "$1" in .[] | select(.firstParty and .id == $id)
--name) | [
display_name="${2:-}" .sourceDir,
[[ -n $display_name ]] || fail "--name requires a value" .manifestPath,
shift 2 (.name // .id),
;; ((.kinds | index("bar")) != null),
--replace) ((.kinds | index("bar-widget")) != null),
replace="true" (any(.kinds[]; . != "bar-widget"))
shift ] | @tsv
;; ')
--add) [[ -n $source_info ]] || fail "unknown built-in plugin: $source_id"
add="true" IFS=$'\t' read -r source_dir source_manifest source_name is_bar is_bar_widget has_non_widget_kind <<<"$source_info"
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
if [[ -z $source_id ]]; then new_id="local.${source_id#omarchy.}"
if interactive; then display_name="My $source_name"
source_id=$(choose_clone_source) || fail "clone cancelled" target_dir="$PLUGINS_DIR/$new_id"
else [[ ! -e $target_dir && ! -L $target_dir ]] || fail "$target_dir already exists"
fail "clone source is required"
fi
fi
local default_slug mkdir -p "$PLUGINS_DIR"
default_slug=$(slug_id "$source_id") stage=$(mktemp -d "$PLUGINS_DIR/.clone.XXXXXX")
if [[ -z $new_id ]]; then clone_complete=0
if interactive; then cleanup() {
new_id=$(input_value "New plugin id:" "local.$default_slug") [[ -d ${stage:-} ]] && rm -rf "$stage"
else (( clone_complete )) || rm -rf "$target_dir"
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
} }
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"
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# omarchy:summary=Disable a shell plugin
# omarchy:group=plugin
# omarchy:args=<id>
set -euo pipefail
fail() {
echo "omarchy-plugin-disable: $*" >&2
exit 1
}
if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then
echo "Usage: omarchy plugin disable <id>"
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"
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# omarchy:summary=Enable a shell plugin
# omarchy:group=plugin
# omarchy:args=<id> [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 <id> [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
+46
View File
@@ -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 }
'
+141
View File
@@ -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
+133
View File
@@ -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
+1 -13
View File
@@ -49,7 +49,7 @@ done
ID=$(jq -r '.id // ""' "$MANIFEST") ID=$(jq -r '.id // ""' "$MANIFEST")
[[ -n $ID ]] || fail "manifest 'id' is empty" [[ -n $ID ]] || fail "manifest 'id' is empty"
[[ $ID =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "invalid plugin id '$ID'" [[ $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" [[ $ID != omarchy.* ]] || fail "plugin id '$ID' uses the reserved omarchy.* namespace"
# kinds must be a non-empty array. # 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) 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" [[ -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 exit 0
+5 -6
View File
@@ -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 toggle` | Toggle feature on/off | `omarchy toggle nightlight` |
| `omarchy theme` | Theme management | `omarchy theme set <name>` | | `omarchy theme` | Theme management | `omarchy theme set <name>` |
| `omarchy bar` | Bar layout and widgets | `omarchy bar plugin move omarchy.clock --section right` | | `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 <script>` | | `omarchy hook` | Install automation hooks | `omarchy hook install theme-set <script>` |
| `omarchy install` | Install optional software / packages | `omarchy install docker dbs` | | `omarchy install` | Install optional software / packages | `omarchy install docker dbs` |
| `omarchy launch` | Launch apps | `omarchy launch browser` | | `omarchy launch` | Launch apps | `omarchy launch browser` |
@@ -183,9 +183,8 @@ To customize a built-in bar widget, never edit `$OMARCHY_PATH/shell/plugins/`.
Clone it into the user plugin directory instead: Clone it into the user plugin directory instead:
```bash ```bash
omarchy plugin clone omarchy.workspaces local.workspaces --replace omarchy plugin clone omarchy.workspaces
# Edit ~/.config/omarchy/plugins/local.workspaces/, then: # Edit ~/.config/omarchy/plugins/local.workspaces/; saved changes reload automatically.
omarchy plugin rescan
``` ```
**Commands:** `omarchy restart shell`, `omarchy refresh shell` **Commands:** `omarchy restart shell`, `omarchy refresh shell`
@@ -228,7 +227,7 @@ cp ~/.config/hypr/bindings.lua ~/.config/hypr/bindings.lua.bak.$(date +%s)
# 4. Apply changes # 4. Apply changes
# - Hyprland: auto-reloads on save, but MUST validate with `hyprctl reload` and `hyprctl configerrors` # - Hyprland: auto-reloads on save, but MUST validate with `hyprctl reload` and `hyprctl configerrors`
# - Omarchy shell: shell.json hot-reloads; use `omarchy plugin rescan` for plugin/widget code changes # - Omarchy shell: shell.json hot-reloads; use `omarchy-shell shell rescanPlugins` for plugin/widget code changes
# - Launcher: restart with `omarchy restart shell` # - Launcher: restart with `omarchy restart shell`
# - Terminals: MUST restart with `omarchy restart terminal` # - Terminals: MUST restart with `omarchy restart terminal`
``` ```
@@ -420,6 +419,6 @@ This skill intentionally does not cover Omarchy source development. Do not use t
- "Clear all reminders" -> `omarchy reminder clear` - "Clear all reminders" -> `omarchy reminder clear`
- "Customize the catppuccin theme colors" -> Create `~/.config/omarchy/themes/catppuccin-custom/` by copying from stock, then edit - "Customize the catppuccin theme colors" -> Create `~/.config/omarchy/themes/catppuccin-custom/` by copying from stock, then edit
- "Run a script every time I change themes" -> Install it with `omarchy hook install theme-set <script>` - "Run a script every time I change themes" -> Install it with `omarchy hook install theme-set <script>`
- "Change how workspace labels are rendered" -> Clone `omarchy.workspaces` to a user plugin with `--replace`, then edit the clone - "Change how workspace labels are rendered" -> Clone `omarchy.workspaces`, which switches the bar to `local.workspaces`, then edit the clone
- "Lock after ten minutes" -> Set `idle.lock` to `600` in `~/.config/omarchy/shell.json` - "Lock after ten minutes" -> Set `idle.lock` to `600` in `~/.config/omarchy/shell.json`
- "Reset shell/bar to defaults" -> `omarchy refresh shell` - "Reset shell/bar to defaults" -> `omarchy refresh shell`
+2 -1
View File
@@ -157,7 +157,8 @@
"setup.plugin": {"icon":"󰐱","label":"Plugins","aliases":["plugin","plugins"]}, "setup.plugin": {"icon":"󰐱","label":"Plugins","aliases":["plugin","plugins"]},
"setup.plugin.enable": {"icon":"󰄬","label":"Enable Plugin","action":"omarchy-menu-plugin enable"}, "setup.plugin.enable": {"icon":"󰄬","label":"Enable Plugin","action":"omarchy-menu-plugin enable"},
"setup.plugin.disable": {"icon":"󰅖","label":"Disable Plugin","action":"omarchy-menu-plugin disable"}, "setup.plugin.disable": {"icon":"󰅖","label":"Disable Plugin","action":"omarchy-menu-plugin disable"},
"setup.plugin.add": {"icon":"󰖟","label":"Add Plugin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-plugin add'"}, "setup.plugin.add": {"icon":"󰖟","label":"Add Plugin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-plugin-add'"},
"setup.plugin.clone": {"icon":"󰆏","label":"Clone Plugin","action":"omarchy-menu-plugin clone"},
// Only a plugin you installed yourself can be deleted, so Remove stays // Only a plugin you installed yourself can be deleted, so Remove stays
// hidden until there is one. // hidden until there is one.
"setup.plugin.remove": {"icon":"󰭌","label":"Remove Plugin","when":"compgen -G \"$HOME/.config/omarchy/plugins/*/manifest.json\"","action":"omarchy-menu-plugin remove"}, "setup.plugin.remove": {"icon":"󰭌","label":"Remove Plugin","when":"compgen -G \"$HOME/.config/omarchy/plugins/*/manifest.json\"","action":"omarchy-menu-plugin remove"},
+13 -6
View File
@@ -48,14 +48,21 @@ fast-forward pull:
```bash ```bash
omarchy plugin add https://github.com/acme/omarchy-weather.git omarchy plugin add https://github.com/acme/omarchy-weather.git
omarchy plugin update --all # fetches, shows a diff, fast-forwards omarchy plugin update # fetches, shows a diff, fast-forwards
omarchy plugin remove acme.weather omarchy plugin remove acme.weather
``` ```
**Setup Plugins** offers Enable, Disable, Add, and Remove. Enable and Disable **Setup Plugins** offers Enable, Disable, Add, Clone, and Remove. Enable and
include built-ins as well as installed plugins. Remove is limited to installed Disable include built-ins as well as installed plugins. Clone is limited to
plugins, since a built-in has no checkout to delete. Add and Remove open a built-ins, while Remove is limited to installed plugins since a built-in has
terminal so their warning, confirmation, and output stay visible. no checkout to delete. Add, Clone, and Remove open a terminal so their warning,
editor, confirmation, and output stay visible.
Cloning `omarchy.clock`, for example, creates and switches to
`~/.config/omarchy/plugins/local.clock/`, names it `My Clock`, and preserves
the built-in IPC identity so existing shortcuts keep working. Saving files in
a `local.*` clone reloads its code automatically, and removing an active clone
switches back to its built-in source.
For a bar widget, on and off means its place in the bar. Everything else is For a bar widget, on and off means its place in the bar. Everything else is
loaded by default when it is built in, so `shell.json` records only the loaded by default when it is built in, so `shell.json` records only the
@@ -72,7 +79,7 @@ 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). arguments — add `--yes` to skip every prompt (the path for scripts and agents).
You can still install by hand: drop a plugin into You can still install by hand: drop a plugin into
`~/.config/omarchy/plugins/<id>/`, run `omarchy plugin rescan`, then `~/.config/omarchy/plugins/<id>/`, run `omarchy-shell shell rescanPlugins`, then
`omarchy plugin enable <id>`. A bar widget starts in its declared default `omarchy plugin enable <id>`. A bar widget starts in its declared default
section and can be moved with `omarchy bar plugin move`; enabling a full bar section and can be moved with `omarchy bar plugin move`; enabling a full bar
replaces the one in use. replaces the one in use.
+18 -10
View File
@@ -100,7 +100,7 @@ manifest id); updating is a fast-forward pull of that checkout.
```bash ```bash
omarchy plugin add https://github.com/acme/omarchy-weather.git omarchy plugin add https://github.com/acme/omarchy-weather.git
omarchy plugin update acme.weather # fetches, shows a diff, fast-forwards omarchy plugin update acme.weather # fetches, shows a diff, fast-forwards
omarchy plugin update --all omarchy plugin update # updates every git-managed plugin
omarchy plugin remove acme.weather omarchy plugin remove acme.weather
``` ```
@@ -116,7 +116,7 @@ AI agents:
```bash ```bash
omarchy plugin add https://github.com/acme/omarchy-weather.git --enable --yes omarchy plugin add https://github.com/acme/omarchy-weather.git --enable --yes
omarchy plugin update --all --yes omarchy plugin update --yes
``` ```
The installer never runs plugin code, install hooks, or sudo — it only clones The installer never runs plugin code, install hooks, or sudo — it only clones
@@ -130,26 +130,34 @@ You can still drop a plugin in without git:
1. Put it in `~/.config/omarchy/plugins/<plugin-id>/` with a `manifest.json` 1. Put it in `~/.config/omarchy/plugins/<plugin-id>/` with a `manifest.json`
plus the QML referenced from its `entryPoints`. plus the QML referenced from its `entryPoints`.
2. `omarchy plugin rescan`. 2. `omarchy-shell shell rescanPlugins`.
3. `omarchy plugin enable <id>`. Bar widgets start in 3. `omarchy plugin enable <id>`. Bar widgets start in
`barWidget.defaultSection`, or in the center when it is omitted, and can be `barWidget.defaultSection`, or in the center when it is omitted, and can be
moved with `omarchy bar plugin move`; a full bar replaces the one in use. moved with `omarchy bar plugin move`; a full bar replaces the one in use.
The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`,
`omarchy-shell shell setPluginEnabled <id> true`, and `omarchy-shell shell listPlugins`. `omarchy-shell shell setPluginEnabled <id> true`, and `omarchy-shell shell listPlugins`.
The `omarchy plugin` command wraps those calls and can also edit the persisted The `omarchy plugin` commands wrap those calls and can also edit the persisted
bar layout in `shell.json`. bar layout in `shell.json`.
To hack on an existing widget safely, clone it into a user plugin instead of To hack on a built-in plugin safely, clone it into user config instead of
editing the built-in source. Third-party ids must be namespaced and may not use editing the built-in source. The complete plugin directory is copied, including
the reserved `omarchy.*` prefix. every declared kind and local dependency. A built-in id such as
`omarchy.clock` becomes `local.clock`, with `My Clock` as its display name.
```bash ```bash
omarchy plugin clone omarchy.clock local.clock --replace omarchy plugin clone omarchy.clock
omarchy plugin clone # interactive source/name picker
omarchy plugin edit local.clock # cd into the plugin directory
``` ```
Cloning switches from the built-in to the new local plugin, preserving an
existing bar widget's position and settings. Setup > Plugins > Clone provides
the interactive picker, then opens the new `local.*` directory in `$EDITOR`.
Existing shortcuts and shell IPC calls made to the built-in id are routed to
the enabled clone, so cloning does not require changing its callers. Removing
an active clone switches back to its built-in source.
Saving a file anywhere inside a `local.*` plugin reloads plugin code
automatically; `omarchy-shell shell rescanPlugins` remains available to force a reload.
First-party plugins under `shell/plugins/` are discovered the same way and load First-party plugins under `shell/plugins/` are discovered the same way and load
by default. Disabling a non-widget records it in `disabledPlugins[]`; disabling by default. Disabling a non-widget records it in `disabledPlugins[]`; disabling
a widget removes it from the bar layout while leaving its component available a widget removes it from the bar layout while leaving its component available
+1 -1
View File
@@ -180,4 +180,4 @@ Third-party widgets ship as separate plugins under
declaring `kinds: ["bar-widget"]` and a `barWidget` entry point. See declaring `kinds: ["bar-widget"]` and a `barWidget` entry point. See
[../../README.md](../../README.md) for the manifest schema. Enable, [../../README.md](../../README.md) for the manifest schema. Enable,
rescan, and place third-party plugins with `omarchy plugin enable`, rescan, and place third-party plugins with `omarchy plugin enable`,
`omarchy plugin rescan`, and `omarchy bar plugin add`. `omarchy-shell shell rescanPlugins`, and `omarchy bar plugin add`.
@@ -66,5 +66,13 @@
"defaultValue": false "defaultValue": false
} }
] ]
},
"omarchy": {
"clonePaths": [
{
"source": "../indicators",
"target": "indicators"
}
]
} }
} }
@@ -16,5 +16,13 @@
"description": "Status notifier items", "description": "Status notifier items",
"category": "Status", "category": "Status",
"allowMultiple": false "allowMultiple": false
},
"omarchy": {
"clonePaths": [
{
"source": "TrayModel.js",
"target": "TrayModel.js"
}
]
} }
} }
+59 -2
View File
@@ -28,6 +28,7 @@ QtObject {
signal pluginsChanged() signal pluginsChanged()
signal scanFinished() signal scanFinished()
signal pluginLoadFailed(string id, string error) signal pluginLoadFailed(string id, string error)
signal localPluginChanged(string id)
// ---------------------------------------------------------------- helpers // ---------------------------------------------------------------- helpers
@@ -141,6 +142,19 @@ QtObject {
&& config.disabledPlugins.indexOf(Util.canonicalWidgetId(String(id))) !== -1 && config.disabledPlugins.indexOf(Util.canonicalWidgetId(String(id))) !== -1
} }
function resolveEnabledId(id) {
var key = Util.canonicalWidgetId(String(id || ""))
// Callers keep using the built-in id after cloning; the enabled local
// manifest is the implementation that should receive the call.
for (var candidate in installedPlugins) {
var manifest = installedPlugins[candidate]
var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
if (metadata && String(metadata.clonedFrom || "") === key && isEnabled(candidate))
return candidate
}
return key
}
// A bar widget is on when it sits in the bar, whoever shipped it. That is a // A bar widget is on when it sits in the bar, whoever shipped it. That is a
// different question from isEnabled(), which decides whether the widget's // different question from isEnabled(), which decides whether the widget's
// component is loaded at all — a built-in stays loadable so it can be put // component is loaded at all — a built-in stays loadable so it can be put
@@ -200,6 +214,8 @@ QtObject {
} }
var isBarOption = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1 var isBarOption = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1
var isBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1 var isBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1
var hasNonWidgetKind = manifest && Array.isArray(manifest.kinds)
&& manifest.kinds.some(function(kind) { return kind !== "bar-widget" })
shellConfigMutator(function(config) { shellConfigMutator(function(config) {
// Ensure shape exists. // Ensure shape exists.
if (!Util.isPlainObject(config.bar)) config.bar = { layout: { left: [], center: [], right: [] } } if (!Util.isPlainObject(config.bar)) config.bar = { layout: { left: [], center: [], right: [] } }
@@ -241,7 +257,7 @@ QtObject {
else if (location.kind === "plugin") config.plugins.splice(location.index, 1) else if (location.kind === "plugin") config.plugins.splice(location.index, 1)
// Dropping the layout entry is the whole story for a widget. Anything // Dropping the layout entry is the whole story for a widget. Anything
// else built-in loads by default, so switching it off has to be stated. // else built-in loads by default, so switching it off has to be stated.
if (isFirstParty && !isBarWidget && !isDisabled(config, key)) { if (isFirstParty && (!isBarWidget || hasNonWidgetKind) && !isDisabled(config, key)) {
if (!Array.isArray(config.disabledPlugins)) config.disabledPlugins = [] if (!Array.isArray(config.disabledPlugins)) config.disabledPlugins = []
config.disabledPlugins.push(key) config.disabledPlugins.push(key)
} }
@@ -337,7 +353,36 @@ QtObject {
} }
property Process initProcess: Process { property Process initProcess: Process {
onExited: registry.rescan() onExited: {
localPluginWatcher.running = true
registry.rescan()
}
}
property Process localPluginWatcher: Process {
command: [
"inotifywait",
"-m",
"-r",
"-q",
"-e",
"close_write,create,delete,move",
"--format",
"%w%f",
registry.pluginsDir
]
stdout: SplitParser {
onRead: function(path) {
var pluginId = registry.localPluginIdForPath(path)
if (pluginId) registry.localPluginChanged(pluginId)
}
}
onExited: localPluginWatcherRestart.restart()
}
property Timer localPluginWatcherRestart: Timer {
interval: 1000
onTriggered: localPluginWatcher.running = true
} }
function rescan() { function rescan() {
@@ -379,5 +424,17 @@ QtObject {
initProcess.running = true initProcess.running = true
} }
function localPluginIdForPath(filePath) {
var base = pluginsDir.replace(/\/$/, "") + "/"
var path = String(filePath || "").trim()
if (path.indexOf(base + "local.") !== 0) return ""
var relative = path.slice(base.length)
if (relative.indexOf("/.git/") !== -1 || relative.endsWith("/.git")) return ""
var slash = relative.indexOf("/")
return slash === -1 ? relative : relative.slice(0, slash)
}
Component.onCompleted: ensureUserDir() Component.onCompleted: ensureUserDir()
} }
+17 -6
View File
@@ -58,6 +58,12 @@ ShellRoot {
property bool pluginReloading: false property bool pluginReloading: false
property bool pluginReloadPending: false property bool pluginReloadPending: false
Timer {
id: localPluginReloadTimer
interval: 150
onTriggered: shell.reloadPlugins()
}
onShellConfigChanged: { onShellConfigChanged: {
if (failedBarId !== "") failedBarId = "" if (failedBarId !== "") failedBarId = ""
pluginRegistry.registryRevision++ pluginRegistry.registryRevision++
@@ -440,7 +446,7 @@ ShellRoot {
} }
function summon(pluginId, payloadJson) { function summon(pluginId, payloadJson) {
var id = String(pluginId || "") var id = shell.pluginRegistry.resolveEnabledId(pluginId)
if (!id) return false if (!id) return false
var plugins = shell.pluginRegistry.installedPlugins var plugins = shell.pluginRegistry.installedPlugins
if (!plugins[id]) { if (!plugins[id]) {
@@ -480,7 +486,7 @@ ShellRoot {
} }
function hide(pluginId) { function hide(pluginId) {
var id = String(pluginId || "") var id = shell.pluginRegistry.resolveEnabledId(pluginId)
if (!id) return false if (!id) return false
if (shell.isBarWidgetPanelPlugin(id)) { if (shell.isBarWidgetPanelPlugin(id)) {
var hidden = shell.bar && typeof shell.bar.hideBarWidget === "function" var hidden = shell.bar && typeof shell.bar.hideBarWidget === "function"
@@ -497,7 +503,7 @@ ShellRoot {
} }
function isPluginOpen(pluginId) { function isPluginOpen(pluginId) {
var id = String(pluginId || "") var id = shell.pluginRegistry.resolveEnabledId(pluginId)
if (shell.isBarWidgetPanelPlugin(id)) { if (shell.isBarWidgetPanelPlugin(id)) {
return shell.bar && typeof shell.bar.isBarWidgetOpen === "function" return shell.bar && typeof shell.bar.isBarWidgetOpen === "function"
? shell.bar.isBarWidgetOpen(id) ? shell.bar.isBarWidgetOpen(id)
@@ -510,7 +516,7 @@ ShellRoot {
} }
function toggle(pluginId, payloadJson) { function toggle(pluginId, payloadJson) {
var id = String(pluginId || "") var id = shell.pluginRegistry.resolveEnabledId(pluginId)
return isPluginOpen(id) ? hide(id) : summon(id, payloadJson) return isPluginOpen(id) ? hide(id) : summon(id, payloadJson)
} }
@@ -567,14 +573,15 @@ ShellRoot {
} }
function callIfLoaded(pluginId, method, arg) { function callIfLoaded(pluginId, method, arg) {
var loader = panelLoaders[pluginId] var id = shell.pluginRegistry.resolveEnabledId(pluginId)
var loader = panelLoaders[id]
if (!loader || !loader.item) return "unknown" if (!loader || !loader.item) return "unknown"
if (typeof loader.item[method] !== "function") return "unknown" if (typeof loader.item[method] !== "function") return "unknown"
try { try {
var result = loader.item[method](arg) var result = loader.item[method](arg)
return result === undefined || result === null ? "ok" : String(result) return result === undefined || result === null ? "ok" : String(result)
} catch (e) { } catch (e) {
console.warn("plugin " + pluginId + " " + method + "() threw:", e) console.warn("plugin " + id + " " + method + "() threw:", e)
return "error" return "error"
} }
} }
@@ -761,6 +768,10 @@ ShellRoot {
Connections { Connections {
target: shell.pluginRegistry target: shell.pluginRegistry
function onLocalPluginChanged(pluginId) {
console.log("Local plugin changed, reloading:", pluginId)
localPluginReloadTimer.restart()
}
function onScanFinished() { function onScanFinished() {
if (shell.pluginReloadPending) { if (shell.pluginReloadPending) {
shell.pluginReloadPending = false shell.pluginReloadPending = false
@@ -82,8 +82,12 @@ ShellRoot {
scan += block("firstparty", "/first/widgets/clock", manifest("omarchy.first-widget", ["bar-widget"], { barWidget: "Widget.qml" })) scan += block("firstparty", "/first/widgets/clock", manifest("omarchy.first-widget", ["bar-widget"], { barWidget: "Widget.qml" }))
scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" })) scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" }))
scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" })) scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" }))
scan += block("firstparty", "/first/hybrid", manifest("omarchy.hybrid", ["menu", "bar-widget"], { menu: "Menu.qml", barWidget: "Widget.qml" }))
scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" }))
scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "left" })) scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "left" }))
var localWidget = manifest("local.first-widget", ["bar-widget"], { barWidget: "Widget.qml" })
localWidget.omarchy = { clonedFrom: "omarchy.first-widget" }
scan += block("thirdparty", "/third/local-widget", localWidget)
scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" })) scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" }))
scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" }))
scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" }))
@@ -96,9 +100,11 @@ ShellRoot {
registry.parseScanOutput(scan) registry.parseScanOutput(scan)
root.assertDeepEqual(pluginIds(), [ root.assertDeepEqual(pluginIds(), [
"local.first-widget",
"omarchy.bar", "omarchy.bar",
"omarchy.first-widget", "omarchy.first-widget",
"omarchy.grouped-panel", "omarchy.grouped-panel",
"omarchy.hybrid",
"third.bar", "third.bar",
"third.panel", "third.panel",
"third.widget" "third.widget"
@@ -120,6 +126,7 @@ ShellRoot {
root.assertTrue(registry.isEnabled("omarchy.bar"), "built-in bar option is active by default") root.assertTrue(registry.isEnabled("omarchy.bar"), "built-in bar option is active by default")
root.assertTrue(!registry.isEnabled("third.bar"), "third-party bar options start inactive") root.assertTrue(!registry.isEnabled("third.bar"), "third-party bar options start inactive")
root.assertTrue(!registry.isEnabled("third.panel"), "third-party plugins start disabled") root.assertTrue(!registry.isEnabled("third.panel"), "third-party plugins start disabled")
root.assertEqual(registry.resolveEnabledId("omarchy.first-widget"), "omarchy.first-widget", "inactive clones do not replace their source id")
registry.setEnabled("third.bar", true) registry.setEnabled("third.bar", true)
root.assertEqual(root.config.bar.id, "third.bar", "enabling third-party bar options writes bar id") root.assertEqual(root.config.bar.id, "third.bar", "enabling third-party bar options writes bar id")
@@ -141,6 +148,10 @@ ShellRoot {
registry.setEnabled("third.widget", false) registry.setEnabled("third.widget", false)
root.assertDeepEqual(root.config.bar.layout.left, [], "disabling bar widgets removes layout entry") root.assertDeepEqual(root.config.bar.layout.left, [], "disabling bar widgets removes layout entry")
registry.setEnabled("local.first-widget", true)
root.assertEqual(registry.resolveEnabledId("omarchy.first-widget"), "local.first-widget", "enabled clones receive calls made to their source id")
registry.setEnabled("local.first-widget", false)
root.config = { root.config = {
version: 1, version: 1,
bar: { layout: { left: [], center: [{ id: "third.widget", size: 4 }], right: [] } }, bar: { layout: { left: [], center: [{ id: "third.widget", size: 4 }], right: [] } },
@@ -183,6 +194,21 @@ ShellRoot {
registry.setEnabled("omarchy.first-widget", true) registry.setEnabled("omarchy.first-widget", true)
root.assertDeepEqual(root.config.bar.layout.center, [{ id: "omarchy.first-widget" }], "a widget without a default section falls back to center") root.assertDeepEqual(root.config.bar.layout.center, [{ id: "omarchy.first-widget" }], "a widget without a default section falls back to center")
root.config = {
version: 1,
bar: { layout: { left: [{ id: "omarchy.hybrid" }], center: [], right: [] } },
plugins: []
}
registry.setEnabled("omarchy.hybrid", false)
root.assertDeepEqual(root.config.bar.layout.left, [], "disabling a multi-kind built-in removes its widget")
root.assertDeepEqual(root.config.disabledPlugins, ["omarchy.hybrid"], "disabling a multi-kind built-in unloads its other kinds")
root.assertTrue(!registry.isEnabled("omarchy.hybrid"), "a disabled multi-kind built-in is not loadable")
var localBase = registry.pluginsDir + "/local.clock"
root.assertEqual(registry.localPluginIdForPath(localBase + "/BarWidget.qml"), "local.clock", "local clone changes are watched")
root.assertEqual(registry.localPluginIdForPath(registry.pluginsDir + "/acme.clock/BarWidget.qml"), "", "installed plugins are not treated as local clones")
root.assertEqual(registry.localPluginIdForPath(localBase + "/.git/index"), "", "clone git metadata is ignored")
root.assertTrue(changeCount > 0, "registry emits change notifications") root.assertTrue(changeCount > 0, "registry emits change notifications")
writeResult() writeResult()
} }
+52 -16
View File
@@ -12,15 +12,21 @@ trap 'rm -rf "$TMPDIR"' EXIT
STUB_DIR="$TMPDIR/stub" STUB_DIR="$TMPDIR/stub"
mkdir -p "$STUB_DIR" mkdir -p "$STUB_DIR"
# The picker reads the plugin list from omarchy-plugin and hands what it decided # The picker reads the plugin list from omarchy-plugin-list and hands what it
# back to it, so stubbing both ends shows which plugin a pick actually resolved # decided to a verb-specific command, so stubbing both ends shows which plugin
# to -- the thing a source-level check cannot see. # a pick actually resolved to -- the thing a source-level check cannot see.
cat >"$STUB_DIR/omarchy-plugin" <<'STUB' cat >"$STUB_DIR/omarchy-plugin-list" <<'STUB'
#!/bin/bash #!/bin/bash
[[ $1 == list ]] && { cat "$FAKE_PLUGINS"; exit 0; } cat "$FAKE_PLUGINS"
printf 'omarchy-plugin %s\n' "$*" >>"$FAKE_CALLS"
STUB STUB
for command in omarchy-plugin-enable omarchy-plugin-disable; do
cat >"$STUB_DIR/$command" <<'STUB'
#!/bin/bash
printf '%s %s\n' "${0##*/}" "$*" >>"$FAKE_CALLS"
STUB
done
# Records the rows it was offered, then answers with the pick under test. # Records the rows it was offered, then answers with the pick under test.
cat >"$STUB_DIR/omarchy-menu-select" <<'STUB' cat >"$STUB_DIR/omarchy-menu-select" <<'STUB'
#!/bin/bash #!/bin/bash
@@ -47,7 +53,8 @@ pick() {
: >"$TMPDIR/calls" : >"$TMPDIR/calls"
: >"$TMPDIR/rows" : >"$TMPDIR/rows"
PATH="$STUB_DIR:$PATH" \ HOME="$TMPDIR/home" \
PATH="$STUB_DIR:$PATH" \
FAKE_PLUGINS="$TMPDIR/plugins.json" \ FAKE_PLUGINS="$TMPDIR/plugins.json" \
FAKE_CALLS="$TMPDIR/calls" \ FAKE_CALLS="$TMPDIR/calls" \
FAKE_ROWS="$TMPDIR/rows" \ FAKE_ROWS="$TMPDIR/rows" \
@@ -58,9 +65,8 @@ pick() {
CALLS=$(cat "$TMPDIR/calls") CALLS=$(cat "$TMPDIR/calls")
} }
# Cloning a plugin keeps the name it was cloned from, so two plugins really can # Two plugins can declare the same display name. When both are eligible for the
# arrive at the picker calling themselves Clock. Both eligible for the same # same verb, neither row can stand on the name alone.
# verb: neither row can stand on the name alone.
cat >"$TMPDIR/plugins.json" <<'JSON' cat >"$TMPDIR/plugins.json" <<'JSON'
[ [
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": true}, {"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": true},
@@ -72,7 +78,7 @@ pick enable "Clock (local.clock)"
[[ $ROWS == *"Clock (omarchy.clock)"* && $ROWS == *"Clock (local.clock)"* ]] \ [[ $ROWS == *"Clock (omarchy.clock)"* && $ROWS == *"Clock (local.clock)"* ]] \
|| fail "picker tells two plugins of the same name apart" "$ROWS" || fail "picker tells two plugins of the same name apart" "$ROWS"
pass "picker tells two plugins of the same name apart" pass "picker tells two plugins of the same name apart"
[[ $CALLS == *"omarchy-plugin enable local.clock"* ]] \ [[ $CALLS == *"omarchy-plugin-enable local.clock"* ]] \
|| fail "picker acts on the row that was picked, not the one that shares its name" "$CALLS" || fail "picker acts on the row that was picked, not the one that shares its name" "$CALLS"
pass "picker acts on the row that was picked, not the one that shares its name" pass "picker acts on the row that was picked, not the one that shares its name"
@@ -88,12 +94,12 @@ JSON
pick enable "Clock" pick enable "Clock"
[[ $ROWS != *"("* ]] || fail "picker adorns a row only when its name is taken twice over" "$ROWS" [[ $ROWS != *"("* ]] || fail "picker adorns a row only when its name is taken twice over" "$ROWS"
pass "picker adorns a row only when its name is taken twice over" pass "picker adorns a row only when its name is taken twice over"
[[ $CALLS == *"omarchy-plugin enable local.clock"* ]] \ [[ $CALLS == *"omarchy-plugin-enable local.clock"* ]] \
|| fail "picker resolves a lone row to the plugin the verb offered, not a namesake it filtered out" "$CALLS" || fail "picker resolves a lone row to the plugin the verb offered, not a namesake it filtered out" "$CALLS"
pass "picker resolves a lone row to the plugin the verb offered, not a namesake it filtered out" pass "picker resolves a lone row to the plugin the verb offered, not a namesake it filtered out"
pick remove "Clock" pick remove "Clock"
[[ $CALLS == *"omarchy-plugin remove local.clock"* ]] \ [[ $CALLS == *"omarchy-plugin-remove local.clock"* ]] \
|| fail "picker removes the plugin whose row was picked" "$CALLS" || fail "picker removes the plugin whose row was picked" "$CALLS"
pass "picker removes the plugin whose row was picked" pass "picker removes the plugin whose row was picked"
@@ -108,10 +114,40 @@ pick enable "Weather"
[[ $ROWS == *"Weather"* && $ROWS != *"acme.weather)"* ]] \ [[ $ROWS == *"Weather"* && $ROWS != *"acme.weather)"* ]] \
|| fail "picker leaves an unambiguous name unadorned" "$ROWS" || fail "picker leaves an unambiguous name unadorned" "$ROWS"
pass "picker leaves an unambiguous name unadorned" pass "picker leaves an unambiguous name unadorned"
[[ $CALLS == *"omarchy-plugin enable acme.weather"* ]] \ [[ $CALLS == *"omarchy-plugin-enable acme.weather"* ]] \
|| fail "picker delegates plugin enablement to the plugin command" "$CALLS" || fail "picker delegates plugin enablement to the plugin command" "$CALLS"
pass "picker delegates plugin enablement to the plugin command" pass "picker delegates plugin enablement to the plugin command"
# Clone offers only first-party plugins without an existing local counterpart,
# then performs the clone and opens its deterministic path in $EDITOR.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": true, "active": false, "canDisable": true, "firstParty": true},
{"id": "acme.weather", "name": "Weather", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
]
JSON
pick clone "Clock"
[[ $ROWS == *"Clock"* && $ROWS != *"Weather"* ]] ||
fail "clone picker offers only built-in plugins" "$ROWS"
pass "clone picker offers built-in plugins"
[[ $CALLS == *'terminal: omarchy-plugin-clone omarchy.clock && exec $EDITOR '*"/.config/omarchy/plugins/local.clock" ]] ||
fail "clone picker opens the cloned path in EDITOR" "$CALLS"
pass "clone picker clones and opens the local plugin"
# Once local.<id> is discovered, the source no longer belongs in Clone.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": true, "active": false, "canDisable": true, "firstParty": true},
{"id": "local.clock", "name": "My Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
]
JSON
pick clone ""
[[ $CALLS == *"notification: No plugin to clone"* ]] ||
fail "clone picker offers an already cloned plugin" "$CALLS"
pass "clone picker omits plugins already cloned locally"
# The picker treats every plugin alike and leaves kind-specific behavior to the # The picker treats every plugin alike and leaves kind-specific behavior to the
# plugin command. # plugin command.
cat >"$TMPDIR/plugins.json" <<'JSON' cat >"$TMPDIR/plugins.json" <<'JSON'
@@ -121,7 +157,7 @@ cat >"$TMPDIR/plugins.json" <<'JSON'
JSON JSON
pick enable "Fancy" pick enable "Fancy"
[[ $CALLS == *"omarchy-plugin enable acme.fancy"* && $CALLS != *"--section"* ]] \ [[ $CALLS == *"omarchy-plugin-enable acme.fancy"* && $CALLS != *"--section"* ]] \
|| fail "picker delegates kind-specific enablement" "$CALLS" || fail "picker delegates kind-specific enablement" "$CALLS"
pass "picker delegates kind-specific enablement" pass "picker delegates kind-specific enablement"
@@ -151,7 +187,7 @@ pick enable "Bar"
[[ $ROWS == *"Bar"* && $ROWS != *"Neon Bar"* ]] \ [[ $ROWS == *"Bar"* && $ROWS != *"Neon Bar"* ]] \
|| fail "picker offers every bar except the one already running" "$ROWS" || fail "picker offers every bar except the one already running" "$ROWS"
pass "picker offers every bar except the one already running" pass "picker offers every bar except the one already running"
[[ $CALLS == *"omarchy-plugin enable omarchy.bar"* ]] \ [[ $CALLS == *"omarchy-plugin-enable omarchy.bar"* ]] \
|| fail "picker returns to the built-in bar by enabling it" "$CALLS" || fail "picker returns to the built-in bar by enabling it" "$CALLS"
pass "picker returns to the built-in bar by enabling it" pass "picker returns to the built-in bar by enabling it"
+12 -10
View File
@@ -183,11 +183,11 @@ assertEqual(
) )
assertDeepEqual( assertDeepEqual(
defaultItems.filter(item => item.parent === 'setup.plugin').map(item => item.label), defaultItems.filter(item => item.parent === 'setup.plugin').map(item => item.label),
['Enable Plugin', 'Disable Plugin', 'Add Plugin', 'Remove Plugin'], ['Enable Plugin', 'Disable Plugin', 'Add Plugin', 'Clone Plugin', 'Remove Plugin'],
'menu manages plugins from Setup > Plugins' 'menu manages plugins from Setup > Plugins'
) )
assert( assert(
['enable', 'disable', 'remove'].every( ['enable', 'disable', 'clone', 'remove'].every(
verb => defaultById[`setup.plugin.${verb}`].action === `omarchy-menu-plugin ${verb}` verb => defaultById[`setup.plugin.${verb}`].action === `omarchy-menu-plugin ${verb}`
), ),
'menu picks a plugin the way it already picks a theme or a timezone' 'menu picks a plugin the way it already picks a theme or a timezone'
@@ -201,7 +201,7 @@ assert(
'menu hides Remove until a plugin the user installed exists to delete' 'menu hides Remove until a plugin the user installed exists to delete'
) )
assert( assert(
defaultById['setup.plugin.add'].action.includes('omarchy-plugin add'), defaultById['setup.plugin.add'].action.includes('omarchy-plugin-add'),
'menu adds a plugin through the CLI, where the trust warning and clone output are visible' 'menu adds a plugin through the CLI, where the trust warning and clone output are visible'
) )
@@ -212,23 +212,25 @@ assert(
) )
assert( assert(
/remove\).*\(\.firstParty \| not\)/.test(pluginPicker) /remove\).*\(\.firstParty \| not\)/.test(pluginPicker)
&& /clone\).*\.firstParty/.test(pluginPicker)
&& !/kinds|bar-widget|A_BAR_OPTION|NOT_A_BAR_OPTION|BAR_ICON/.test(pluginPicker), && !/kinds|bar-widget|A_BAR_OPTION|NOT_A_BAR_OPTION|BAR_ICON/.test(pluginPicker),
'plugin picker leaves plugin-kind decisions to its data and the plugin command' 'plugin picker leaves plugin-kind decisions to its data and the plugin command'
) )
const pluginCli = fs.readFileSync(path.join(root, 'bin/omarchy-plugin'), 'utf8') const pluginAdd = fs.readFileSync(path.join(root, 'bin/omarchy-plugin-add'), 'utf8')
const pluginEnable = fs.readFileSync(path.join(root, 'bin/omarchy-plugin-enable'), 'utf8')
assert( assert(
/Now using \$id as the bar/.test(pluginCli) /Now using \$id as the bar/.test(pluginEnable)
&& /enabled_message "\$id"[\s\S]*?place_bar_widget/.test(pluginCli), && /Now using \$id as the bar[\s\S]*?place_bar_widget/.test(pluginAdd),
'plugin enable reports a bar as replacing the one in use, whether enabled or freshly added' 'plugin enable reports a bar as replacing the one in use, whether enabled or freshly added'
) )
assert( assert(
/\.barWidget\.defaultSection \/\/ "center"/.test(pluginCli) /\.barWidget\.defaultSection \/\/ "center"/.test(pluginAdd)
&& /gum choose[\s\S]*?--selected "\$default_section"/.test(pluginCli), && /gum choose[\s\S]*?--selected "\$default_section"/.test(pluginAdd),
'interactive plugin add selects the manifest placement or center fallback by default' 'interactive plugin add selects the manifest placement or center fallback by default'
) )
assert( assert(
/omarchy-plugin "\$1" "\$id"/.test(pluginPicker), /"omarchy-plugin-\$1" "\$id"/.test(pluginPicker),
'plugin picker delegates enable and disable without interpreting plugin kinds' 'plugin picker delegates enable and disable without interpreting plugin kinds'
) )
// Icons ride along as "<glyph>\tlabel"; the menu shows the glyph and hands // Icons ride along as "<glyph>\tlabel"; the menu shows the glyph and hands
@@ -245,7 +247,7 @@ assert(
'menu select mode reads a leading icon off an option and filters on the label alone' 'menu select mode reads a leading icon off an option and filters on the label alone'
) )
assert( assert(
/omarchy-launch-floating-terminal-with-presentation "omarchy-plugin remove/.test(pluginPicker), /omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove/.test(pluginPicker),
'plugin picker removes where the confirmation and backup path are visible' 'plugin picker removes where the confirmation and backup path are visible'
) )
+1 -1
View File
@@ -49,7 +49,7 @@ git -C "$incoming" add .
git -C "$incoming" -c user.name=Test -c user.email=test@example.com commit -qm "Initial" git -C "$incoming" -c user.name=Test -c user.email=test@example.com commit -qm "Initial"
output=$(HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$stub_dir:$ROOT/bin:$PATH" \ output=$(HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$stub_dir:$ROOT/bin:$PATH" \
omarchy-plugin add "$incoming" --yes 2>&1) && omarchy-plugin-add "$incoming" --yes 2>&1) &&
fail "plugin add accepts an id already installed under another directory" "$output" fail "plugin add accepts an id already installed under another directory" "$output"
grep -qF "plugin id 'acme.same' is already used by" <<<"$output" || grep -qF "plugin id 'acme.same' is already used by" <<<"$output" ||
fail "plugin add explains the installed id collision" "$output" fail "plugin add explains the installed id collision" "$output"
+205 -34
View File
@@ -4,45 +4,216 @@ set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command jq
require_command python3
TMPDIR=$(mktemp -d) TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT trap 'rm -rf "$TMPDIR"' EXIT
mkdir -p "$TMPDIR/home/.config/omarchy" mkdir -p "$TMPDIR/home/.config/omarchy" "$TMPDIR/bin"
CALLS="$TMPDIR/calls"
clone() { cat >"$TMPDIR/bin/omarchy-shell" <<'SH'
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" \ #!/bin/bash
omarchy-plugin-clone "$1" "$2" --name "$3" if [[ $* == *"listShellConfig"* ]]; then
if [[ -n ${FAKE_SHELL_CONFIG:-} ]]; then
printf '%s\n' "$FAKE_SHELL_CONFIG"
else
printf '{}\n'
fi
elif [[ $* == *"listPlugins"* ]]; then
if [[ ${FAKE_NO_DISCOVERY:-0} == 1 ]]; then
printf '[]\n'
else
find "$HOME/.config/omarchy/plugins" -mindepth 2 -maxdepth 2 -name manifest.json -print0 |
xargs -0 -r jq -s 'map({id: .id, enabled: true})'
fi
fi
exit 0
SH
for command in omarchy-bar omarchy-bar-plugin; do
cat >"$TMPDIR/bin/$command" <<'SH'
#!/bin/bash
printf '%s %s\n' "${0##*/}" "$*" >>"$FAKE_CALLS"
exec "$OMARCHY_TEST_ROOT/bin/${0##*/}" "$@"
SH
done
for command in omarchy-plugin-enable omarchy-plugin-disable omarchy-notification-send; do
cat >"$TMPDIR/bin/$command" <<'SH'
#!/bin/bash
printf '%s %s\n' "${0##*/}" "$*" >>"$FAKE_CALLS"
SH
done
chmod +x "$TMPDIR/bin/"*
clone_plugin() {
local default_config='{
"bar": {
"layout": {
"left": [{"id": "omarchy.menu"}],
"center": [{"id": "omarchy.clock", "format": "HH:mm"}],
"right": []
}
}
}'
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$TMPDIR/bin:$ROOT/bin:$PATH" \
FAKE_CALLS="$CALLS" OMARCHY_TEST_ROOT="$ROOT" \
FAKE_SHELL_CONFIG="${FAKE_CLONE_CONFIG:-$default_config}" \
omarchy-plugin-clone "$@"
} }
# A bar widget that pulls in a sibling JS module clones into a directory that clone_plugin omarchy.clock >/dev/null
# does not contain it, so the import has to be rewritten back to the bundled clock="$TMPDIR/home/.config/omarchy/plugins/local.clock"
# file or the cloned widget fails to load.
clone omarchy.clock local.clock-clone "Cloned Clock" >/dev/null
widget="$TMPDIR/home/.config/omarchy/plugins/local.clock-clone/Widget.qml"
[[ -f $widget ]] || fail "clone produces a widget file" for file in manifest.json BarWidget.qml Panel.qml Model.js; do
pass "clone produces a widget file" [[ -f $clock/$file ]] || fail "clock clone is missing $file"
grep -q 'moduleName: "local.clock-clone"' "$widget" ||
fail "clone rewrites the module name"
pass "clone rewrites the module name"
while read -r ref; do
[[ $ref == file://* ]] || fail "clone leaves a relative reference behind" "$ref"
done < <(grep -oE '(import|Qt\.resolvedUrl\() *"[^"]+"' "$widget" |
grep -oE '"[^"]+"' | tr -d '"' | grep -E '\.(js|qml)$')
pass "clone resolves every relative QML and JS reference to the bundled file"
for ref in $(grep -oE 'file://[^"]+\.(js|qml)' "$widget"); do
path=${ref#file://}
[[ -f $path ]] || fail "cloned reference points at a real file" "$ref"
done done
pass "cloned references point at files that exist" pass "clone copies the complete plugin"
# The bundled widget genuinely has such an import, so the check above is not grep -q 'import "Model.js"' "$clock/BarWidget.qml" &&
# passing by accident. grep -q 'Qt.resolvedUrl("Panel.qml")' "$clock/BarWidget.qml" ||
grep -qE '^import "[^"/][^"]*\.js"' "$ROOT/shell/plugins/panels/clock/BarWidget.qml" || fail "clock clone does not preserve local dependencies"
fail "the clock widget still imports a sibling JS module" pass "clone keeps plugin dependencies local"
pass "the clock widget still imports a sibling JS module"
rg -qF "omarchy.clock" "$clock" -g '*.qml' -g '*.js' ||
fail "clock clone does not preserve the stable runtime id"
pass "clone preserves the built-in runtime IPC id"
jq -e '
.id == "local.clock" and
.name == "My Clock" and
.barWidget.displayName == "My Clock" and
.omarchy.clonedFrom == "omarchy.clock" and
.kinds == ["bar-widget"] and
.entryPoints.barWidget == "BarWidget.qml"
' "$clock/manifest.json" >/dev/null || fail "clock clone manifest is incorrect"
pass "clone updates identity without replacing the manifest"
grep -qx 'omarchy-bar-plugin replace omarchy.clock local.clock' "$CALLS" ||
fail "clone does not replace an active bar widget"
grep -qx 'omarchy-notification-send -g 󰐱 Editing Cloned Plugin Original plugin has been replace by clone.' "$CALLS" ||
fail "clone does not notify that the editable clone is active"
jq -e '
any(.bar.layout.center[];
.id == "local.clock" and
.format == "dddd HH:mm" and
.formatAlt == "d MMMM \u0027W\u0027ww yyyy")
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ||
fail "clone does not preserve the active widget's placement and settings"
pass "clone switches bar widgets in place and confirms the editable clone"
jq '.bar.layout.center += ["omarchy.keyboard-layout"]' \
"$TMPDIR/home/.config/omarchy/shell.json" >"$TMPDIR/shell.json"
mv "$TMPDIR/shell.json" "$TMPDIR/home/.config/omarchy/shell.json"
FAKE_CLONE_CONFIG='{
"bar": {
"layout": {
"left": [],
"center": ["omarchy.keyboard-layout"],
"right": []
}
}
}' clone_plugin omarchy.keyboard-layout >/dev/null
jq -e '
any(.bar.layout.center[]; .id == "local.keyboard-layout")
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ||
fail "clone does not replace a string-form bar entry"
pass "clone replaces legacy string-form bar entries"
clone_plugin omarchy.menu >/dev/null
menu="$TMPDIR/home/.config/omarchy/plugins/local.menu"
for file in manifest.json Menu.qml MenuModel.js BarWidget.qml; do
[[ -f $menu/$file ]] || fail "menu clone is missing $file"
done
jq -e '
.id == "local.menu" and
.kinds == ["menu", "bar-widget"] and
.entryPoints.menu == "Menu.qml" and
.entryPoints.barWidget == "BarWidget.qml"
' "$menu/manifest.json" >/dev/null || fail "menu clone loses plugin kinds"
grep -qx 'omarchy-plugin-disable omarchy.menu' "$CALLS" ||
fail "clone leaves the built-in half of a multi-kind plugin enabled"
pass "clone preserves multi-kind plugins"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$TMPDIR/bin:$ROOT/bin:$PATH" \
FAKE_CALLS="$CALLS" OMARCHY_TEST_ROOT="$ROOT" \
omarchy-plugin-remove local.menu --yes >/dev/null
grep -qx 'omarchy-bar-plugin replace local.menu omarchy.menu' "$CALLS" &&
grep -qx 'omarchy-plugin-enable omarchy.menu' "$CALLS" ||
fail "removing a clone does not restore its built-in source"
pass "removing a clone restores its built-in source"
clone_plugin omarchy.active-window >/dev/null
[[ -f $TMPDIR/home/.config/omarchy/plugins/local.active-window/ActiveWindow.qml ]] ||
fail "flat bar plugin clone is incomplete"
pass "flat bar plugins clone from adjacent manifests"
grep -qx 'omarchy-bar-plugin add local.active-window' "$CALLS" ||
fail "clone does not activate a bar widget whose source is absent"
jq -e '
any(.bar.layout.left[]; .id == "local.active-window")
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ||
fail "clone does not add the absent widget using its default placement"
pass "clone activates an absent bar widget"
clone_plugin omarchy.indicators >/dev/null
indicators="$TMPDIR/home/.config/omarchy/plugins/local.indicators"
for file in Indicators.qml indicators/Dnd.qml indicators/Reminder.qml; do
[[ -f $indicators/$file ]] || fail "indicators clone is missing $file"
done
grep -q 'Qt.resolvedUrl("indicators/"' "$indicators/Indicators.qml" ||
fail "indicators clone does not point at its copied components"
pass "flat bar plugins declare extra clone dependencies"
clone_plugin omarchy.tray >/dev/null
[[ -f $TMPDIR/home/.config/omarchy/plugins/local.tray/TrayModel.js ]] ||
fail "tray clone is missing its model"
pass "flat bar plugins keep local script dependencies"
clone_plugin omarchy.bar >/dev/null
grep -qx 'omarchy-bar use local.bar' "$CALLS" ||
fail "clone does not select a cloned bar"
jq -e '.bar.id == "local.bar"' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null ||
fail "clone does not persist the cloned bar as active"
pass "clone switches full bars"
clone_plugin omarchy.background >/dev/null
grep -qx 'omarchy-plugin-enable local.background' "$CALLS" ||
fail "clone does not enable an ordinary cloned plugin"
grep -qx 'omarchy-plugin-disable omarchy.background' "$CALLS" ||
fail "clone does not disable the ordinary source plugin"
pass "clone switches ordinary plugins"
mkdir -p "$TMPDIR/home/.config/omarchy/plugins/acme.example"
cat >"$TMPDIR/home/.config/omarchy/plugins/acme.example/manifest.json" <<'JSON'
{"id":"acme.example","name":"Example","kinds":["bar-widget"],"entryPoints":{"barWidget":"Widget.qml"}}
JSON
if clone_plugin acme.example >/dev/null 2>&1; then
fail "clone accepts a user plugin"
fi
pass "clone is limited to built-in plugins"
if clone_plugin omarchy.weather custom.weather >/dev/null 2>&1; then
fail "clone accepts a custom id"
fi
[[ ! -e $TMPDIR/home/.config/omarchy/plugins/local.weather ]] ||
fail "rejected custom id leaves a clone behind"
pass "clone derives the local id"
if clone_plugin omarchy.weather --replace >/dev/null 2>&1; then
fail "clone still accepts bar layout actions"
fi
[[ ! -e $TMPDIR/home/.config/omarchy/plugins/local.weather ]] ||
fail "rejected bar action leaves a clone behind"
pass "clone does not accept manual switch options"
if clone_plugin >/dev/null 2>&1; then
fail "clone opens an interactive picker without a source id"
fi
pass "clone requires an explicit source id"
if FAKE_NO_DISCOVERY=1 clone_plugin omarchy.osd >/dev/null 2>&1; then
fail "clone succeeds before the shell discovers it"
fi
[[ ! -e $TMPDIR/home/.config/omarchy/plugins/local.osd ]] ||
fail "failed clone discovery leaves a partial clone behind"
pass "clone removes a partial clone when switching fails"
+27
View File
@@ -156,6 +156,33 @@ for (const manifestPath of manifests) {
if (relativePath.endsWith('.manifest.json')) { if (relativePath.endsWith('.manifest.json')) {
check(JSON.stringify(manifest.kinds) === JSON.stringify(['bar-widget']), `${manifest.id} sibling manifest must be a bar widget`) check(JSON.stringify(manifest.kinds) === JSON.stringify(['bar-widget']), `${manifest.id} sibling manifest must be a bar widget`)
} }
const clonePaths = manifest.omarchy?.clonePaths
if (clonePaths !== undefined) {
check(Array.isArray(clonePaths), `${manifest.id} omarchy.clonePaths must be an array`)
const cloneTargets = new Set()
for (const clonePath of Array.isArray(clonePaths) ? clonePaths : []) {
const valid = isPlainObject(clonePath)
&& typeof clonePath.source === 'string'
&& /^[A-Za-z0-9_./-]+$/.test(clonePath.source)
&& typeof clonePath.target === 'string'
&& /^[A-Za-z0-9_./-]+$/.test(clonePath.target)
&& !clonePath.target.startsWith('/')
&& !clonePath.target.includes('..')
check(
valid,
`${manifest.id} clone paths must have safe source and target paths`
)
if (valid) {
check(
fs.existsSync(path.resolve(path.dirname(manifestPath), clonePath.source)),
`${manifest.id} clone source ${clonePath.source} must exist`
)
check(!cloneTargets.has(clonePath.target), `${manifest.id} clone target ${clonePath.target} must be unique`)
cloneTargets.add(clonePath.target)
}
}
}
} }
const byId = Object.fromEntries(manifests.map(manifestPath => { const byId = Object.fromEntries(manifests.map(manifestPath => {
+47
View File
@@ -53,6 +53,28 @@ cp -a "$ROOT/shell" "$test_root/shell"
ln -s "$ROOT/config" "$test_root/config" ln -s "$ROOT/config" "$test_root/config"
ln -s "$ROOT/bin" "$test_root/bin" ln -s "$ROOT/bin" "$test_root/bin"
hot_reload_dir="$test_home/.config/omarchy/plugins/local.hot-reload"
mkdir -p "$hot_reload_dir"
cat >"$hot_reload_dir/manifest.json" <<'JSON'
{
"schemaVersion": 1,
"id": "local.hot-reload",
"name": "Before Hot Reload",
"version": "1.0.0",
"kinds": ["overlay"],
"entryPoints": {"overlay": "Overlay.qml"},
"omarchy": {"clonedFrom": "omarchy.emojis"}
}
JSON
cat >"$hot_reload_dir/Overlay.qml" <<'QML'
import QtQuick
Item {
function open(payloadJson) {}
function close() {}
}
QML
cat >"$stub_bin/omarchy-update-available" <<'SH' cat >"$stub_bin/omarchy-update-available" <<'SH'
#!/bin/bash #!/bin/bash
echo "Omarchy update available (test)" echo "Omarchy update available (test)"
@@ -116,6 +138,31 @@ jq -e '
} }
pass "shell IPC lists plugin metadata" pass "shell IPC lists plugin metadata"
jq '.name = "After Hot Reload"' "$hot_reload_dir/manifest.json" >"$hot_reload_dir/manifest.json.tmp"
mv "$hot_reload_dir/manifest.json.tmp" "$hot_reload_dir/manifest.json"
hot_reload_name=""
for _ in {1..80}; do
hot_reload_name=$(shell_ipc shell listPlugins 2>/dev/null |
jq -r '.[] | select(.id == "local.hot-reload") | .name' 2>/dev/null || true)
[[ $hot_reload_name == "After Hot Reload" ]] && break
if ! kill -0 "$QS_PID" 2>/dev/null; then
fail_with_log "test shell exited while reloading a changed local clone"
fi
sleep 0.1
done
[[ $hot_reload_name == "After Hot Reload" ]] ||
fail_with_log "local clone changes reload without an explicit rescan"
pass "local clone changes reload without an explicit rescan"
[[ $(shell_ipc shell setPluginEnabled local.hot-reload true) == "ok" ]] ||
fail_with_log "local clone could not be enabled"
[[ $(shell_ipc shell summon omarchy.emojis "{}") == "ok" ]] ||
fail_with_log "calls to a cloned source id do not reach its enabled clone"
shell_ipc_quiet shell hide omarchy.emojis >/dev/null
shell_ipc_quiet shell setPluginEnabled local.hot-reload false >/dev/null
pass "shell IPC routes built-in ids to enabled clones"
shell_config=$(shell_ipc shell listShellConfig) shell_config=$(shell_ipc shell listShellConfig)
jq -e ' jq -e '
.version == 1 and .version == 1 and