Files
arthur-os/bin/omarchy-plugin
T

1203 lines
34 KiB
Bash
Executable File

#!/bin/bash
# omarchy:summary=Manage Omarchy shell plugins and bar widgets
# omarchy:group=plugin
# omarchy:args=<list|rescan|enable|disable|clone|edit|bar> [...]
# omarchy:examples=omarchy plugin list | omarchy plugin clone omarchy.clock local.clock --with ai | omarchy plugin edit local.clock --with editor | omarchy plugin enable acme.weather --section right | omarchy plugin bar settings
set -euo pipefail
CONFIG_FILE="$HOME/.config/omarchy/shell.json"
usage() {
cat <<USAGE
Usage: omarchy-plugin <command> [args...]
Plugin commands:
list [--json] List discovered shell plugins
rescan Rescan ~/.config/omarchy/plugins
enable <id> [placement] Enable a plugin
disable <id> Disable a plugin
clone [source] [new-id] [options] Clone a built-in or user plugin
edit [id] [--with <ai|editor>] [--prompt <prompt>]
Open a user plugin for editing
Clone options:
--name <name> Display name for the clone
--with <ai|editor> Open the clone with AI or editor
--prompt <prompt> Initial prompt when opening with AI
--replace Replace the first source bar instance
--add [placement] Add cloned bar widget to the bar
Bar widget commands:
bar settings Open the inline bar config panel
bar list [--json] List current bar layout
bar add <id> [placement] [--duplicate]
bar move <id> [placement] [--from-section SECTION] [--from-index N]
bar remove <id> [--section SECTION] [--all]
bar remove --section SECTION --index N
bar set <id> <key> <value> [--json] [--section SECTION] [--index N]
Placement options:
--section <left|center|right> Target bar section
--index <n> Insert at index in the target section
--before <id> Insert before the first matching widget
--after <id> Insert after the first matching widget
Examples:
omarchy plugin list
omarchy plugin rescan
omarchy plugin clone
omarchy plugin clone omarchy.clock local.clock --name "My Clock" --replace --with ai --prompt "Customize this clock"
omarchy plugin edit local.clock --with editor
omarchy plugin edit local.clock --with ai --prompt "Customize this clock"
omarchy plugin enable acme.weather --section right
omarchy plugin bar add acme.weather --section right --before omarchy.tray
omarchy plugin bar move omarchy.clock --section center --index 0
omarchy plugin bar remove acme.weather
omarchy plugin bar set omarchy.clock format HH:mm
omarchy plugin bar set omarchy.indicators items '["Dnd","NightLight"]' --json
USAGE
}
fail() {
echo "omarchy-plugin: $*" >&2
exit 1
}
require_omarchy_path() {
[[ -n ${OMARCHY_PATH:-} ]] || fail "OMARCHY_PATH is not set"
}
require_command() {
local command_name="$1"
command -v "$command_name" >/dev/null 2>&1 || fail "$command_name is required"
}
section_valid() {
[[ $1 =~ ^(left|center|right)$ ]]
}
validate_section() {
local section="$1"
section_valid "$section" || fail "section must be left, center, or right"
}
validate_index() {
local value="$1"
[[ $value =~ ^[0-9]+$ ]] || fail "index must be a non-negative integer"
}
canonical_widget_id() {
printf '%s' "$1"
}
validate_plugin_id() {
local id="$1"
[[ $id =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "plugin id must contain only letters, numbers, dots, underscores, and dashes"
[[ $id == *.* ]] || fail "plugin id must be namespaced, e.g. local.clock"
[[ $id != omarchy.* ]] || fail "plugin ids beginning with omarchy. are reserved for built-ins"
[[ $(canonical_widget_id "$id") == "$id" ]] || fail "$id is a reserved built-in widget id"
}
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
require_command jq
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" && $# -gt 0 ]]; then
fail "disable does not take placement options"
fi
id=$(canonical_widget_id "$id")
omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
omarchy-shell shell setPluginEnabled "$id" "$enabled" || fail "could not update plugin; is omarchy-shell running?"
if [[ $enabled == "true" && $# -gt 0 ]]; then
mutate_bar move --id "$id" "$@"
echo "Enabled and moved $id"
else
if [[ $enabled == "true" ]]; then
echo "Enabled $id"
else
echo "Disabled $id"
fi
fi
}
config_source() {
require_omarchy_path
local defaults_file="$OMARCHY_PATH/config/omarchy/shell.json"
if [[ -s $CONFIG_FILE ]] && jq -e 'type == "object" and .version == 1' "$CONFIG_FILE" >/dev/null 2>&1; then
printf '%s' "$CONFIG_FILE"
elif [[ -s $defaults_file ]] && jq -e 'type == "object" and .version == 1' "$defaults_file" >/dev/null 2>&1; then
printf '%s' "$defaults_file"
else
fail "could not find a valid shell config or defaults file"
fi
}
bar_list() {
local json="false"
while (( $# > 0 )); do
case "$1" in
--json)
json="true"
;;
-h | --help)
usage
exit 0
;;
*)
fail "unknown bar list option: $1"
;;
esac
shift
done
require_command jq
local source
source=$(config_source)
if [[ $json == "true" ]]; then
jq '.bar.layout // { left: [], center: [], right: [] }' "$source"
return
fi
jq -r '
(.bar.layout // { left: [], center: [], right: [] }) as $layout |
["left", "center", "right"][] as $section |
($layout[$section] // []) as $entries |
if ($entries | length) == 0 then
"\($section)\t-\t-"
else
$entries | to_entries[] | "\($section)\t\(.key)\t\(.value.id // "<missing>")"
end
' "$source" | awk '
BEGIN { printf "%-8s %-5s %s\n", "SECTION", "INDEX", "WIDGET" }
{ printf "%-8s %-5s %s\n", $1, $2, $3 }
'
}
mutate_bar() {
require_command jq
require_command python3
require_omarchy_path
local operation="$1"
shift
local id=""
local section=""
local index=""
local before=""
local after=""
local from_section=""
local from_index=""
local duplicate="false"
local all="false"
local key=""
local value=""
local value_is_json="false"
local new_id=""
while (( $# > 0 )); do
case "$1" in
--id)
id="${2-}"
shift 2
;;
--section)
section="${2:-}"
validate_section "$section"
shift 2
;;
--index)
index="${2:-}"
validate_index "$index"
shift 2
;;
--before)
before="${2:-}"
[[ -n $before ]] || fail "--before requires a widget id"
shift 2
;;
--after)
after="${2:-}"
[[ -n $after ]] || fail "--after requires a widget id"
shift 2
;;
--from-section)
from_section="${2:-}"
validate_section "$from_section"
shift 2
;;
--from-index)
from_index="${2:-}"
validate_index "$from_index"
shift 2
;;
--duplicate)
duplicate="true"
shift
;;
--all)
all="true"
shift
;;
--key)
key="${2:-}"
[[ -n $key ]] || fail "--key requires a value"
shift 2
;;
--new-id)
new_id="${2:-}"
[[ -n $new_id ]] || fail "--new-id requires a value"
shift 2
;;
--value)
value="${2:-}"
shift 2
;;
--json)
value_is_json="true"
shift
;;
*)
fail "unknown option: $1"
;;
esac
done
if [[ -n $before && -n $after ]]; then
fail "use only one of --before or --after"
fi
local source tmp
source=$(config_source)
mkdir -p "$(dirname "$CONFIG_FILE")"
tmp=$(mktemp)
OPERATION="$operation" \
WIDGET_ID="$id" \
TARGET_SECTION="$section" \
TARGET_INDEX="$index" \
TARGET_BEFORE="$before" \
TARGET_AFTER="$after" \
FROM_SECTION="$from_section" \
FROM_INDEX="$from_index" \
ALLOW_DUPLICATE="$duplicate" \
REMOVE_ALL="$all" \
SET_KEY="$key" \
SET_VALUE="$value" \
SET_VALUE_IS_JSON="$value_is_json" \
NEW_WIDGET_ID="$new_id" \
python3 - "$source" >"$tmp" <<'PY'
import json
import os
import sys
from copy import deepcopy
source = sys.argv[1]
operation = os.environ["OPERATION"]
def canonical(value):
return str(value or "")
widget_id = canonical(os.environ.get("WIDGET_ID", ""))
target_section = os.environ.get("TARGET_SECTION", "")
target_index = os.environ.get("TARGET_INDEX", "")
target_before = canonical(os.environ.get("TARGET_BEFORE", ""))
target_after = canonical(os.environ.get("TARGET_AFTER", ""))
from_section = os.environ.get("FROM_SECTION", "")
from_index = os.environ.get("FROM_INDEX", "")
allow_duplicate = os.environ.get("ALLOW_DUPLICATE") == "true"
remove_all = os.environ.get("REMOVE_ALL") == "true"
set_key = os.environ.get("SET_KEY", "")
set_value = os.environ.get("SET_VALUE", "")
set_value_is_json = os.environ.get("SET_VALUE_IS_JSON") == "true"
new_widget_id = os.environ.get("NEW_WIDGET_ID", "")
sections = ["left", "center", "right"]
def die(message):
print(f"omarchy-plugin: {message}", file=sys.stderr)
sys.exit(1)
def load_config(path):
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
die("shell config must be a JSON object")
data = deepcopy(data)
data["version"] = 1
data.setdefault("bar", {})
if not isinstance(data["bar"], dict):
data["bar"] = {}
data["bar"].setdefault("layout", {})
if not isinstance(data["bar"]["layout"], dict):
data["bar"]["layout"] = {}
for section in sections:
entries = data["bar"]["layout"].get(section)
if not isinstance(entries, list):
data["bar"]["layout"][section] = []
data.setdefault("plugins", [])
if not isinstance(data["plugins"], list):
data["plugins"] = []
return data
config = load_config(source)
layout = config["bar"]["layout"]
def find_entries(id_value=None, only_section=""):
found = []
for section in sections:
if only_section and section != only_section:
continue
for index, entry in enumerate(layout[section]):
if id_value is None or (isinstance(entry, dict) and canonical(entry.get("id")) == id_value):
found.append((section, index, entry))
return found
def entry_at(section, index):
if section not in sections:
die("section must be left, center, or right")
if index < 0 or index >= len(layout[section]):
die(f"no widget at {section}[{index}]")
return layout[section][index]
def parse_index(value):
if value == "":
return None
try:
parsed = int(value)
except ValueError:
die("index must be a non-negative integer")
if parsed < 0:
die("index must be a non-negative integer")
return parsed
def resolve_target(default_section="right"):
if target_before or target_after:
target_id = target_before or target_after
matches = find_entries(target_id, target_section)
if not matches:
scope = f" in {target_section}" if target_section else ""
die(f"could not find target widget {target_id}{scope}")
section, index, _entry = matches[0]
if target_after:
index += 1
return section, index
section = target_section or default_section
if section not in sections:
die("section must be left, center, or right")
index = parse_index(target_index)
if index is None:
index = len(layout[section])
if index > len(layout[section]):
index = len(layout[section])
return section, index
def source_location():
if from_index != "":
if not from_section:
die("--from-index requires --from-section")
index = parse_index(from_index)
entry = entry_at(from_section, index)
if widget_id and (not isinstance(entry, dict) or canonical(entry.get("id")) != widget_id):
die(f"widget at {from_section}[{index}] is not {widget_id}")
return from_section, index, entry
if not widget_id:
die("widget id is required")
matches = find_entries(widget_id, from_section)
if not matches:
scope = f" in {from_section}" if from_section else ""
die(f"could not find widget {widget_id}{scope}")
return matches[0]
if operation == "add":
if not widget_id:
die("widget id is required")
if not allow_duplicate and find_entries(widget_id):
die(f"{widget_id} is already in the bar; use 'bar move' or pass --duplicate")
section, index = resolve_target("right")
layout[section].insert(index, {"id": widget_id})
elif operation == "move":
old_section, old_index, entry = source_location()
entry = deepcopy(entry)
if isinstance(entry, dict):
entry["id"] = widget_id
del layout[old_section][old_index]
default_section = target_section or old_section
section, index = resolve_target(default_section)
layout[section].insert(index, entry)
elif operation == "remove":
if from_index != "":
old_section, old_index, _entry = source_location()
del layout[old_section][old_index]
elif target_index != "":
if not target_section:
die("--index requires --section for bar remove")
index = parse_index(target_index)
entry = entry_at(target_section, index)
if widget_id and (not isinstance(entry, dict) or canonical(entry.get("id")) != widget_id):
die(f"widget at {target_section}[{index}] is not {widget_id}")
del layout[target_section][index]
else:
if not widget_id:
die("widget id is required")
matches = find_entries(widget_id, target_section)
if not matches:
scope = f" in {target_section}" if target_section else ""
die(f"could not find widget {widget_id}{scope}")
if remove_all:
for section, index, _entry in reversed(matches):
del layout[section][index]
else:
section, index, _entry = matches[0]
del layout[section][index]
elif operation == "replace":
if not widget_id:
die("source widget id is required")
if not new_widget_id:
die("replacement widget id is required")
section, index, entry = source_location()
if not isinstance(entry, dict):
die("widget entry must be an object")
entry["id"] = new_widget_id
layout[section][index] = entry
elif operation == "set":
if not widget_id:
die("widget id is required")
if not set_key:
die("setting key is required")
if from_index != "":
section, index, entry = source_location()
elif target_index != "":
if not target_section:
die("--index requires --section for bar set")
index = parse_index(target_index)
entry = entry_at(target_section, index)
if not isinstance(entry, dict) or canonical(entry.get("id")) != widget_id:
die(f"widget at {target_section}[{index}] is not {widget_id}")
section = target_section
else:
matches = find_entries(widget_id, target_section)
if not matches:
scope = f" in {target_section}" if target_section else ""
die(f"could not find widget {widget_id}{scope}")
section, index, entry = matches[0]
if not isinstance(entry, dict):
die("widget entry must be an object")
if set_value_is_json:
try:
parsed_value = json.loads(set_value)
except json.JSONDecodeError as error:
die(f"invalid JSON value: {error}")
else:
parsed_value = set_value
layout[section][index][set_key] = parsed_value
else:
die(f"unknown operation: {operation}")
json.dump(config, sys.stdout, indent=2)
sys.stdout.write("\n")
PY
mv "$tmp" "$CONFIG_FILE"
omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true
}
bar_add() {
local id="${1:-}"
[[ -n $id ]] || fail "bar add requires a widget id"
shift
mutate_bar add --id "$id" "$@"
echo "Added $id to the bar"
}
bar_move() {
local id="${1:-}"
[[ -n $id ]] || fail "bar move requires a widget id"
shift
mutate_bar move --id "$id" "$@"
echo "Moved $id"
}
bar_remove() {
local id=""
if (( $# > 0 )) && [[ $1 != --* ]]; then
id="$1"
shift
fi
mutate_bar remove --id "$id" "$@"
if [[ -n $id ]]; then
echo "Removed $id from the bar"
else
echo "Removed bar widget"
fi
}
bar_set() {
local id="${1:-}"
local key="${2:-}"
local value="${3:-}"
[[ -n $id ]] || fail "bar set requires a widget id"
[[ -n $key ]] || fail "bar set requires a setting key"
(( $# >= 3 )) || fail "bar set requires a value"
shift 3
mutate_bar set --id "$id" --key "$key" --value "$value" "$@"
echo "Set $key on $(canonical_widget_id "$id")"
}
slug_id() {
tr '[:upper:]' '[:lower:]' <<<"$1" | sed -E 's/^omarchy\.//; s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'
}
first_party_manifest_paths() {
require_omarchy_path
find "$OMARCHY_PATH/shell/plugins" -mindepth 2 -maxdepth 3 -type f \( -name manifest.json -o -name '*.manifest.json' \) 2>/dev/null | sort
}
manifest_source_dir() {
local manifest="$1"
if [[ ${manifest##*/} == "manifest.json" ]]; then
dirname -- "$manifest"
else
dirname -- "$manifest"
fi
}
builtin_widget_info() {
require_command jq
local wanted
wanted=$(canonical_widget_id "$1")
local manifest id entry source_dir source_path name category multiple
while IFS= read -r manifest; do
if ! jq -e --arg id "$wanted" '
(.id == $id or ((.barWidget.aliases // []) | index($id))) and
((.kinds // []) | index("bar-widget"))
' "$manifest" >/dev/null 2>&1; then
continue
fi
id=$(jq -r '.id' "$manifest")
entry=$(jq -r '.entryPoints.barWidget // empty' "$manifest")
[[ -n $entry ]] || return 1
source_dir=$(manifest_source_dir "$manifest")
source_path="$source_dir/$entry"
name=$(jq -r '.barWidget.displayName // .name // .id' "$manifest")
category=$(jq -r '.barWidget.category // "Plugin"' "$manifest")
multiple=$(jq -r 'if .barWidget.allowMultiple == true then "true" else "false" end' "$manifest")
printf '%s\t%s\t%s\t%s\t%s\n' "$id" "$source_path" "$name" "$category" "$multiple"
return 0
done < <(first_party_manifest_paths)
return 1
}
clone_source_options() {
local id name
first_party_manifest_paths | while IFS= read -r manifest; do
id=$(jq -r '.id // empty' "$manifest" 2>/dev/null || true)
name=$(jq -r '.barWidget.displayName // .name // .id // empty' "$manifest" 2>/dev/null || true)
[[ -n $id && $id != "omarchy.bar" ]] || continue
if jq -e '(.kinds // []) | index("bar-widget")' "$manifest" >/dev/null 2>&1; then
printf '%s\tBuilt-in widget\t%s\n' "$id" "${name:-$id}"
else
printf '%s\tBuilt-in plugin\t%s\n' "$id" "${name:-$id}"
fi
done
if [[ -d $HOME/.config/omarchy/plugins ]]; then
find "$HOME/.config/omarchy/plugins" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) 2>/dev/null | sort | while IFS= read -r dir; do
[[ -f $dir/manifest.json ]] || continue
id=$(jq -r '.id // empty' "$dir/manifest.json" 2>/dev/null || true)
name=$(jq -r '.name // .id // empty' "$dir/manifest.json" 2>/dev/null || true)
[[ -n $id ]] && printf '%s\tUser plugin\t%s\n' "$id" "${name:-$id}"
done
fi
}
choose_value() {
local prompt="$1"
shift
if command -v gum >/dev/null 2>&1; then
gum choose --header "$prompt" "$@"
else
printf '%s\n' "$@" | fzf --prompt "$prompt > "
fi
}
input_value() {
local prompt="$1"
local value="${2:-}"
if command -v gum >/dev/null 2>&1; then
gum input --prompt "$prompt " --value "$value"
else
read -r -p "$prompt " value
printf '%s' "$value"
fi
}
choose_clone_source() {
local selected
if command -v gum >/dev/null 2>&1; then
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"
elif command -v fzf >/dev/null 2>&1; then
selected=$(clone_source_options | fzf --delimiter=$'\t' --with-nth=1,2,3 --prompt "Clone plugin > ") || return 1
cut -f1 <<<"$selected"
else
fail "gum or fzf is required for interactive clone selection"
fi
}
edit_source_options() {
if [[ -d $HOME/.config/omarchy/plugins ]]; then
find "$HOME/.config/omarchy/plugins" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) 2>/dev/null | sort | while IFS= read -r dir; do
[[ -f $dir/manifest.json ]] || continue
id=$(jq -r '.id // empty' "$dir/manifest.json" 2>/dev/null || true)
name=$(jq -r '.name // .id // empty' "$dir/manifest.json" 2>/dev/null || true)
[[ -n $id ]] && printf '%s\t%s\t%s\n' "$id" "${name:-$id}" "$dir"
done
fi
}
choose_edit_source() {
local selected
if command -v gum >/dev/null 2>&1; then
selected=$(edit_source_options | awk -F '\t' '{ printf "%-32s %s\n", $2, $1 }' | gum filter --header "Edit plugin" --placeholder "Search user plugins..." --limit 1) || return 1
awk '{ print $NF }' <<<"$selected"
elif command -v fzf >/dev/null 2>&1; then
selected=$(edit_source_options | fzf --delimiter=$'\t' --with-nth=2,1 --prompt "Edit plugin > ") || return 1
cut -f1 <<<"$selected"
else
fail "gum or fzf is required for interactive plugin selection"
fi
}
validate_user_plugin_dir() {
local dir="$1"
local requested_id="${2:-}"
local root="$HOME/.config/omarchy/plugins"
local dir_abs root_abs manifest_id
require_command jq
require_command python3
[[ -d $dir ]] || fail "plugin directory not found: $dir"
dir_abs=$(python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).expanduser().resolve(strict=False))' "$dir")
root_abs=$(python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).expanduser().resolve(strict=False))' "$root")
[[ $dir_abs == $root_abs/* ]] || fail "plugin edit only opens user plugins under $root"
[[ -f $dir_abs/manifest.json ]] || fail "plugin directory missing manifest.json: $dir_abs"
manifest_id=$(jq -r '.id // empty' "$dir_abs/manifest.json" 2>/dev/null || true)
[[ -n $manifest_id ]] || fail "plugin manifest missing id: $dir_abs/manifest.json"
[[ $manifest_id != omarchy.* && $(canonical_widget_id "$manifest_id") == $manifest_id ]] || fail "$manifest_id is built in; clone it first with: omarchy plugin clone $manifest_id local.$(slug_id "$manifest_id")"
if [[ -n $requested_id && $manifest_id != $requested_id ]]; then
fail "plugin id mismatch: requested $requested_id but manifest declares $manifest_id"
fi
printf '%s' "$dir_abs"
}
plugin_dir_for_id() {
local id="$1"
if [[ $id == */* ]]; then
validate_user_plugin_dir "$id"
return
fi
id=$(canonical_widget_id "$id")
if builtin_widget_info "$id" >/dev/null 2>&1 || [[ $id == omarchy.* ]]; then
fail "$id is built in; clone it first with: omarchy plugin clone $id local.$(slug_id "$id")"
fi
validate_user_plugin_dir "$HOME/.config/omarchy/plugins/$id" "$id"
}
open_plugin_dir() {
local tool="$1"
local dir="$2"
local prompt="${3:-}"
case "$tool" in
"" | none)
printf 'Plugin directory: %s\n' "$dir"
return
;;
ai)
omarchy-launch-ai --path "$dir" --prompt "$prompt"
;;
editor)
omarchy-launch-editor "$dir"
;;
*)
fail "unknown edit target: $tool (use ai, editor, or none)"
;;
esac
}
edit_command() {
local id=""
if (( $# > 0 )) && [[ $1 != --* ]]; then
id="$1"
shift
fi
local open_tool=""
local prompt=""
while (( $# > 0 )); do
case "$1" in
--with)
open_tool="${2:-}"
[[ -n $open_tool ]] || fail "--with requires a value"
shift 2
;;
--open)
open_tool="${2:-}"
[[ -n $open_tool ]] || fail "--open requires a value"
shift 2
;;
--prompt)
prompt="${2:-}"
shift 2
;;
-h | --help)
usage
return
;;
*)
fail "unknown edit option: $1"
;;
esac
done
if [[ -z $id ]]; then
if [[ -t 0 && -t 1 ]]; then
id=$(choose_edit_source) || fail "edit cancelled"
else
fail "plugin id is required"
fi
fi
local dir
dir=$(plugin_dir_for_id "$id")
if [[ -z $open_tool ]]; then
if [[ -t 0 && -t 1 ]]; then
open_tool=$(choose_value "Edit with" ai editor)
else
open_tool="editor"
fi
fi
open_plugin_dir "$open_tool" "$dir" "$prompt"
}
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)
# 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
}
clone_command() {
require_command jq
require_command python3
require_omarchy_path
local source_id=""
local new_id=""
if (( $# > 0 )) && [[ $1 != --* ]]; then
source_id="$1"
shift
fi
if (( $# > 0 )) && [[ $1 != --* ]]; then
new_id="$1"
shift
fi
local display_name=""
local open_tool=""
local prompt=""
local replace="false"
local add="false"
local placement=()
while (( $# > 0 )); do
case "$1" in
--name)
display_name="${2:-}"
[[ -n $display_name ]] || fail "--name requires a value"
shift 2
;;
--with)
open_tool="${2:-}"
[[ -n $open_tool ]] || fail "--with requires a value"
shift 2
;;
--open)
open_tool="${2:-}"
[[ -n $open_tool ]] || fail "--open requires a value"
shift 2
;;
--prompt)
prompt="${2:-}"
shift 2
;;
--replace)
replace="true"
shift
;;
--add)
add="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
if [[ -t 0 && -t 1 ]]; then
source_id=$(choose_clone_source) || fail "clone cancelled"
else
fail "clone source is required"
fi
fi
source_id=$(canonical_widget_id "$source_id")
local default_slug
default_slug=$(slug_id "$source_id")
if [[ -z $new_id ]]; then
if [[ -t 0 && -t 1 ]]; then
new_id=$(input_value "New plugin id:" "local.$default_slug")
else
fail "new plugin id is required"
fi
fi
validate_plugin_id "$new_id"
local target_dir="$HOME/.config/omarchy/plugins/$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 $HOME/.config/omarchy/plugins/$source_id/manifest.json ]]; then
source_type="user-plugin"
source_path="$HOME/.config/omarchy/plugins/$source_id"
source_name=$(jq -r '.name // .id' "$source_path/manifest.json")
else
source_path=$(find "$OMARCHY_PATH/shell/plugins" -path '*/manifest.json' -print0 | xargs -0 jq -r --arg id "$source_id" 'select(.id == $id) | input_filename' 2>/dev/null | head -1 || true)
if [[ -n $source_path ]]; then
source_type="builtin-plugin"
source_path=$(dirname "$source_path")
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 [[ -t 0 && -t 1 ]]; 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
mutate_bar replace --id "$source_id" --new-id "$new_id"
echo "Cloned $source_id to $new_id and replaced the first bar instance"
elif [[ $add == "true" ]]; then
bar_add "$new_id" "${placement[@]}"
echo "Cloned $source_id to $new_id"
else
echo "Cloned $source_id to $new_id"
fi
if [[ -z $open_tool && -t 0 && -t 1 ]]; then
open_tool=$(choose_value "Edit with" ai editor)
fi
if [[ -n $open_tool ]]; then
edit_command "$new_id" --with "$open_tool" --prompt "$prompt"
fi
}
bar_command() {
local command="${1:-list}"
[[ $# -gt 0 ]] && shift || true
case "$command" in
edit | settings)
(( $# == 0 )) || fail "bar settings does not take arguments"
exec omarchy-launch-bar-settings
;;
list)
bar_list "$@"
;;
add)
bar_add "$@"
;;
move)
bar_move "$@"
;;
remove | rm)
bar_remove "$@"
;;
set)
bar_set "$@"
;;
-h | --help | help)
usage
;;
*)
fail "unknown bar command: $command"
;;
esac
}
command="${1:-}"
case "$command" in
list | ls)
shift
print_plugins "$@"
;;
rescan)
shift
rescan_plugins "$@"
;;
enable)
shift
plugin_enabled true "$@"
;;
disable)
shift
plugin_enabled false "$@"
;;
clone)
shift
clone_command "$@"
;;
edit)
shift
edit_command "$@"
;;
bar | widget | widgets)
shift
bar_command "$@"
;;
-h | --help | help | "")
usage
;;
*)
fail "unknown command: $command"
;;
esac