Prefix plugin clones with the username so shared clones don't collide

Clones of omarchy.clock become dhh.clock instead of local.clock, so a
published clone carries its author's namespace. The clone command owns
the id derivation and gains --edit to open the result in $EDITOR, and
the shell exposes clonedFrom in listPlugins so the plugin menu no longer
reconstructs clone ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-01 13:13:39 -05:00
co-authored by Claude Fable 5
parent 2050f28d55
commit 1bc89105d2
9 changed files with 79 additions and 57 deletions
+2 -4
View File
@@ -12,7 +12,7 @@ PLUGIN_ICON=$'\U000f0431'
case "${1:-}" in
enable) filter='(.enabled | not)' ;;
disable) filter='.canDisable and .enabled' ;;
clone) filter='.firstParty and ((.id | sub("^omarchy\\."; "local.")) as $clone_id | ($plugins | map(.id) | index($clone_id)) == null)' ;;
clone) filter='.firstParty and (.id as $id | ($plugins | map(.clonedFrom // "") | index($id)) == null)' ;;
remove) filter='(.firstParty | not)' ;;
*)
echo "Usage: omarchy-menu-plugin <enable|disable|clone|remove>" >&2
@@ -40,10 +40,8 @@ id=$(awk -F'\t' -v label="$name" '$2 == label { print $3; exit }' <<<"$rows")
[[ -n $id ]] || exit 1
if [[ $1 == "clone" ]]; then
new_id="local.${id#omarchy.}"
target="$HOME/.config/omarchy/plugins/$new_id"
omarchy-launch-floating-terminal-with-presentation \
"omarchy-plugin-clone $(printf '%q' "$id") && exec \$EDITOR $(printf '%q' "$target")"
"omarchy-plugin-clone --edit $(printf '%q' "$id")"
elif [[ $1 == "remove" ]]; then
omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove $(printf '%q' "$id")"
else
+17 -6
View File
@@ -2,7 +2,7 @@
# omarchy:summary=Clone a built-in Omarchy shell plugin into your own config
# omarchy:group=plugin
# omarchy:args=<source-id>
# omarchy:args=<source-id> [--edit]
set -euo pipefail
@@ -81,16 +81,19 @@ update_manifest() {
usage() {
cat <<USAGE
Usage: omarchy plugin clone <source-id>
Usage: omarchy plugin clone <source-id> [--edit]
Copies a built-in plugin into ~/.config/omarchy/plugins/local.<id>/ so you can
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.
Copies a built-in plugin into ~/.config/omarchy/plugins/<username>.<id>/ so you
can 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. The username prefix keeps the id yours, so a shared clone does not
collide with anyone else's. --edit opens the clone in \$EDITOR afterwards.
USAGE
}
source_id=""
edit_clone=0
if (( $# > 0 )) && [[ $1 != --* ]]; then
source_id="$1"
shift
@@ -98,6 +101,10 @@ fi
while (( $# > 0 )); do
case "$1" in
--edit)
edit_clone=1
shift
;;
-h | --help)
usage
exit 0
@@ -121,7 +128,7 @@ source_info=$(omarchy-plugin-catalog | jq -r --arg id "$source_id" '
[[ -n $source_info ]] || fail "unknown built-in plugin: $source_id"
IFS=$'\t' read -r source_dir source_manifest source_name <<<"$source_info"
new_id="local.${source_id#omarchy.}"
new_id="${USER:-$(id -un)}.${source_id#omarchy.}"
display_name="My $source_name"
target_dir="$PLUGINS_DIR/$new_id"
[[ ! -e $target_dir && ! -L $target_dir ]] || fail "$target_dir already exists"
@@ -155,3 +162,7 @@ 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"
if (( edit_clone )); then
# Word-splitting is deliberate: EDITOR may carry flags.
exec $EDITOR "$target_dir"
fi
+2 -2
View File
@@ -184,7 +184,7 @@ Clone it into the user plugin directory instead:
```bash
omarchy plugin clone omarchy.workspaces
# Edit ~/.config/omarchy/plugins/local.workspaces/; saved changes reload automatically.
# Edit ~/.config/omarchy/plugins/<username>.workspaces/; saved changes reload automatically.
```
**Commands:** `omarchy restart shell`, `omarchy refresh shell`
@@ -419,6 +419,6 @@ This skill intentionally does not cover Omarchy source development. Do not use t
- "Clear all reminders" -> `omarchy reminder clear`
- "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>`
- "Change how workspace labels are rendered" -> Clone `omarchy.workspaces`, which switches the bar to `local.workspaces`, then edit the clone
- "Change how workspace labels are rendered" -> Clone `omarchy.workspaces`, which switches the bar to `<username>.workspaces`, then edit the clone
- "Lock after ten minutes" -> Set `idle.lock` to `600` in `~/.config/omarchy/shell.json`
- "Reset shell/bar to defaults" -> `omarchy refresh shell`
+5 -4
View File
@@ -59,10 +59,11 @@ 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
any installed plugin reloads its code automatically, and removing an active clone
switches back to its built-in source.
`~/.config/omarchy/plugins/<username>.clock/` (e.g. `dhh.clock`), names it
`My Clock`, and preserves the built-in IPC identity so existing shortcuts keep
working. The username prefix keeps a shared clone from colliding with anyone
else's. Saving files in any installed plugin 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
loaded by default when it is built in, so `shell.json` records only the
+5 -3
View File
@@ -143,15 +143,17 @@ The `omarchy plugin` commands wrap those calls. `omarchy bar move` and
To hack on a built-in plugin safely, clone it into user config instead of
editing the built-in source. The complete plugin directory is copied, including
every declared kind and local dependency. A built-in id such as
`omarchy.clock` becomes `local.clock`, with `My Clock` as its display name.
`omarchy.clock` becomes `<username>.clock` (e.g. `dhh.clock`), with `My Clock`
as its display name. The username prefix keeps shared clones from colliding
with each other or with other plugin authors.
```bash
omarchy plugin clone omarchy.clock
```
Cloning switches from the built-in to the new local plugin, preserving an
Cloning switches from the built-in to the new personal 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`.
the interactive picker, then opens the new `<username>.*` 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.
+4 -1
View File
@@ -938,6 +938,8 @@ ShellRoot {
var isBarOption = Array.isArray(kinds) && kinds.indexOf("bar") !== -1
var isBarWidget = Array.isArray(kinds) && kinds.indexOf("bar-widget") !== -1
var active = isBarOption && shell.isActiveBarOption(id)
var metadata = plugins[id].omarchy
var clonedFrom = Util.isPlainObject(metadata) ? String(metadata.clonedFrom || "") : ""
out.push({
id: id,
name: plugins[id].name,
@@ -952,7 +954,8 @@ ShellRoot {
// that a caller offering the verbs does not have to read kinds and
// work it out again.
canDisable: !isBarOption,
firstParty: !!plugins[id].__isFirstParty
firstParty: !!plugins[id].__isFirstParty,
clonedFrom: clonedFrom
})
}
// Consumers should not each invent their own presentation order.
+16 -15
View File
@@ -70,15 +70,15 @@ pick() {
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": true},
{"id": "local.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
{"id": "tester.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
]
JSON
pick enable "Clock (local.clock)"
[[ $ROWS == *"Clock (omarchy.clock)"* && $ROWS == *"Clock (local.clock)"* ]] \
pick enable "Clock (tester.clock)"
[[ $ROWS == *"Clock (omarchy.clock)"* && $ROWS == *"Clock (tester.clock)"* ]] \
|| fail "picker tells two plugins of the same name apart" "$ROWS"
pass "picker tells two plugins of the same name apart"
[[ $CALLS == *"omarchy-plugin-enable local.clock"* ]] \
[[ $CALLS == *"omarchy-plugin-enable tester.clock"* ]] \
|| 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"
@@ -87,19 +87,19 @@ pass "picker acts on the row that was picked, not the one that shares its name"
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": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
{"id": "tester.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
]
JSON
pick enable "Clock"
[[ $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"
[[ $CALLS == *"omarchy-plugin-enable local.clock"* ]] \
[[ $CALLS == *"omarchy-plugin-enable tester.clock"* ]] \
|| 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"
pick remove "Clock"
[[ $CALLS == *"omarchy-plugin-remove local.clock"* ]] \
[[ $CALLS == *"omarchy-plugin-remove tester.clock"* ]] \
|| fail "picker removes the plugin whose row was picked" "$CALLS"
pass "picker removes the plugin whose row was picked"
@@ -118,8 +118,8 @@ pass "picker leaves an unambiguous name unadorned"
|| fail "picker delegates plugin enablement to the plugin command" "$CALLS"
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.
# Clone offers only first-party plugins that no installed clone points back at,
# then hands the pick to the clone command, which opens the result in $EDITOR.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": true, "active": false, "canDisable": true, "firstParty": true},
@@ -131,15 +131,16 @@ 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"
[[ $CALLS == *"terminal: omarchy-plugin-clone --edit omarchy.clock"* ]] ||
fail "clone picker delegates cloning and editing to the clone command" "$CALLS"
pass "clone picker clones and opens the personal plugin"
# Once local.<id> is discovered, the source no longer belongs in Clone.
# Once a clone pointing back at the source is discovered, whatever it is named,
# 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}
{"id": "tester.clock", "name": "My Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false, "clonedFrom": "omarchy.clock"}
]
JSON
@@ -179,7 +180,7 @@ pass "picker keeps a bar out of disable"
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.bar", "name": "Bar", "kinds": ["bar"], "enabled": false, "active": false, "canDisable": false, "firstParty": true},
{"id": "local.neon-bar", "name": "Neon Bar", "kinds": ["bar"], "enabled": true, "active": true, "canDisable": false, "firstParty": false}
{"id": "tester.neon-bar", "name": "Neon Bar", "kinds": ["bar"], "enabled": true, "active": true, "canDisable": false, "firstParty": false}
]
JSON
+27 -21
View File
@@ -31,7 +31,7 @@ fi
exit 0
SH
for command in omarchy-plugin-enable omarchy-notification-send; do
for command in omarchy-plugin-enable omarchy-notification-send fake-editor; do
cat >"$TMPDIR/bin/$command" <<'SH'
#!/bin/bash
printf '%s %s\n' "${0##*/}" "$*" >>"$FAKE_CALLS"
@@ -49,14 +49,14 @@ clone_plugin() {
}
}
}'
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" PATH="$TMPDIR/bin:$ROOT/bin:$PATH" \
HOME="$TMPDIR/home" USER=tester 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 "$@"
}
clone_plugin omarchy.clock >/dev/null
clock="$TMPDIR/home/.config/omarchy/plugins/local.clock"
clock="$TMPDIR/home/.config/omarchy/plugins/tester.clock"
for file in manifest.json BarWidget.qml Panel.qml Model.js; do
[[ -f $clock/$file ]] || fail "clock clone is missing $file"
@@ -73,7 +73,7 @@ rg -qF "omarchy.clock" "$clock" -g '*.qml' -g '*.js' ||
pass "clone preserves the built-in runtime IPC id"
jq -e '
.id == "local.clock" and
.id == "tester.clock" and
.name == "My Clock" and
.barWidget.displayName == "My Clock" and
.omarchy.clonedFrom == "omarchy.clock" and
@@ -82,53 +82,53 @@ jq -e '
' "$clock/manifest.json" >/dev/null || fail "clock clone manifest is incorrect"
pass "clone updates identity without replacing the manifest"
grep -qx 'omarchy-plugin-enable local.clock' "$CALLS" ||
grep -qx 'omarchy-plugin-enable tester.clock' "$CALLS" ||
fail "clone does not enable the editable copy"
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"
pass "clone enables bar widgets and confirms the editable clone"
clone_plugin omarchy.keyboard-layout >/dev/null
grep -qx 'omarchy-plugin-enable local.keyboard-layout' "$CALLS" ||
grep -qx 'omarchy-plugin-enable tester.keyboard-layout' "$CALLS" ||
fail "clone does not enable a clone of a legacy string-form bar entry"
pass "clone enables clones of legacy string-form bar entries"
clone_plugin omarchy.menu >/dev/null
menu="$TMPDIR/home/.config/omarchy/plugins/local.menu"
menu="$TMPDIR/home/.config/omarchy/plugins/tester.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
.id == "tester.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-enable local.menu' "$CALLS" ||
grep -qx 'omarchy-plugin-enable tester.menu' "$CALLS" ||
fail "clone does not enable a multi-kind plugin"
pass "clone preserves and enables multi-kind plugins"
remove_output=$(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)
grep -qx 'omarchy-shell shell setPluginEnabled local.menu false' "$CALLS" ||
omarchy-plugin-remove tester.menu --yes)
grep -qx 'omarchy-shell shell setPluginEnabled tester.menu false' "$CALLS" ||
fail "removing an enabled clone does not disable it first"
grep -q 'Restored omarchy.menu.' <<<"$remove_output" ||
fail "removing a clone does not report its restored source"
pass "removing an enabled clone goes through plugin disable and reports its source"
clone_plugin omarchy.active-window >/dev/null
[[ -f $TMPDIR/home/.config/omarchy/plugins/local.active-window/ActiveWindow.qml ]] ||
[[ -f $TMPDIR/home/.config/omarchy/plugins/tester.active-window/ActiveWindow.qml ]] ||
fail "flat bar plugin clone is incomplete"
pass "flat bar plugins clone from adjacent manifests"
grep -qx 'omarchy-plugin-enable local.active-window' "$CALLS" ||
grep -qx 'omarchy-plugin-enable tester.active-window' "$CALLS" ||
fail "clone does not activate a bar widget whose source is absent"
pass "clone activates an absent bar widget"
clone_plugin omarchy.indicators >/dev/null
indicators="$TMPDIR/home/.config/omarchy/plugins/local.indicators"
indicators="$TMPDIR/home/.config/omarchy/plugins/tester.indicators"
for file in Indicators.qml indicators/Dnd.qml indicators/Reminder.qml; do
[[ -f $indicators/$file ]] || fail "indicators clone is missing $file"
done
@@ -137,20 +137,26 @@ grep -q 'Qt.resolvedUrl("indicators/"' "$indicators/Indicators.qml" ||
pass "flat bar plugins declare extra clone dependencies"
clone_plugin omarchy.tray >/dev/null
[[ -f $TMPDIR/home/.config/omarchy/plugins/local.tray/TrayModel.js ]] ||
[[ -f $TMPDIR/home/.config/omarchy/plugins/tester.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-plugin-enable local.bar' "$CALLS" ||
grep -qx 'omarchy-plugin-enable tester.bar' "$CALLS" ||
fail "clone does not select a cloned bar"
pass "clone switches full bars"
clone_plugin omarchy.background >/dev/null
grep -qx 'omarchy-plugin-enable local.background' "$CALLS" ||
grep -qx 'omarchy-plugin-enable tester.background' "$CALLS" ||
fail "clone does not enable an ordinary cloned plugin"
pass "clone switches ordinary plugins"
EDITOR=fake-editor clone_plugin omarchy.weather --edit >/dev/null
grep -qx "fake-editor $TMPDIR/home/.config/omarchy/plugins/tester.weather" "$CALLS" ||
fail "clone --edit does not open the clone in EDITOR"
pass "clone --edit opens the clone in EDITOR"
rm -rf "$TMPDIR/home/.config/omarchy/plugins/tester.weather"
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"}}
@@ -163,14 +169,14 @@ 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 ]] ||
[[ ! -e $TMPDIR/home/.config/omarchy/plugins/tester.weather ]] ||
fail "rejected custom id leaves a clone behind"
pass "clone derives the local id"
pass "clone derives the personal id from the username"
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 ]] ||
[[ ! -e $TMPDIR/home/.config/omarchy/plugins/tester.weather ]] ||
fail "rejected bar action leaves a clone behind"
pass "clone does not accept manual switch options"
@@ -182,6 +188,6 @@ 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 ]] ||
[[ ! -e $TMPDIR/home/.config/omarchy/plugins/tester.osd ]] ||
fail "failed clone discovery leaves a partial clone behind"
pass "clone removes a partial clone when switching fails"
+1 -1
View File
@@ -132,7 +132,7 @@ done
jq -e '
map(.id) as $ids |
all(["omarchy.menu", "omarchy.notifications", "omarchy.clock", "omarchy.osd"][]; $ids | index(.)) and
all(.[]; (.kinds | type == "array") and (.enabled | type == "boolean") and (.canDisable | type == "boolean") and (.firstParty | type == "boolean")) and
all(.[]; (.kinds | type == "array") and (.enabled | type == "boolean") and (.canDisable | type == "boolean") and (.firstParty | type == "boolean") and (.clonedFrom | type == "string")) and
([.[].name] == ([.[].name] | sort))
' <<<"$plugins" >/dev/null || {
printf 'Plugins:\n%s\n' "$plugins" | jq . >&2