owlry 2.0.1: doctor visibility + proactive migration hints #7

Merged
vikingowl merged 4 commits from fix/dynamic-providers-doctor into main 2026-05-13 03:54:34 +02:00
11 changed files with 848 additions and 28 deletions
Generated
+1 -1
View File
@@ -1823,7 +1823,7 @@ dependencies = [
[[package]]
name = "owlry"
version = "2.0.0"
version = "2.0.1"
dependencies = [
"chrono",
"clap",
+2 -2
View File
@@ -1,6 +1,6 @@
pkgbase = owlry
pkgdesc = Lightweight Wayland application launcher — UI, daemon, and providers in one binary
pkgver = 2.0.0
pkgver = 2.0.1
pkgrel = 1
url = https://somegit.dev/Owlibou/owlry
install = owlry.install
@@ -78,7 +78,7 @@ pkgbase = owlry
replaces = owlry-meta-tools
replaces = owlry-meta-full
options = !debug
source = owlry-2.0.0.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v2.0.0.tar.gz
source = owlry-2.0.1.tar.gz::https://somegit.dev/Owlibou/owlry/archive/owlry-v2.0.1.tar.gz
b2sums = e943074d2768cd3260248b67298860b00eeadffab0ccde5bcc07dc71356390c09ea6328fb2655cfdf9323de3a490e410be2b36ac1719bfc11361c4911142ee05
pkgname = owlry
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlry
pkgver=2.0.0
pkgver=2.0.1
pkgrel=1
pkgdesc="Lightweight Wayland application launcher — UI, daemon, and providers in one binary"
arch=('x86_64')
+131 -21
View File
@@ -1,8 +1,127 @@
## owlry .install hook
##
## v2.0 renamed the systemd user units (owlryd.{service,socket} → owlry.{service,socket})
## and consolidated 14 separate packages into one. Handle the unit migration so users
## upgrading from 1.x don't end up with an enabled-but-missing owlryd.service.
## and consolidated 14 separate packages into one.
##
## 2.0.1 extends this hook with proactive detection of two common upgrade snags:
## 1. Stale ~/.config/systemd/user/owlry{,d}.{service,socket} overrides pointing
## at a deleted dev-time build path or the removed `owlryd` binary. systemd-user
## gives precedence to user-level units over /usr/lib/systemd/user/*, so a
## stale override silently breaks the AUR-shipped unit.
## 2. Compositor configs (Hyprland, Sway, i3, river, niri) with `exec owlryd` /
## `exec-once = owlryd` lines that won't resolve anymore.
##
## The hook only DETECTS and REPORTS — it never touches user homedirs from a root
## pacman context. Each affected user gets a precise remediation command.
_owlry_for_each_user() {
# Apply $1 (function name) to each logged-in user's home directory.
# Falls back silently if loginctl/getent aren't available.
local fn="$1"
command -v loginctl >/dev/null 2>&1 || return 0
command -v getent >/dev/null 2>&1 || return 0
local user home
while read -r user; do
[ -n "$user" ] || continue
home="$(getent passwd "$user" 2>/dev/null | awk -F: '{print $6}')"
[ -d "$home" ] || continue
"$fn" "$user" "$home"
done < <(loginctl list-users --no-legend 2>/dev/null | awk '{print $2}')
}
_owlry_check_unit() {
# Inspect a user-level systemd unit; emit a remediation hint if it's stale.
# Args: $1=user, $2=home, $3=unit-filename (e.g. owlry.service)
local user="$1" home="$2" unit="$3"
local svc="$home/.config/systemd/user/$unit"
[ -f "$svc" ] || return 1
local exec_line
exec_line="$(grep -E '^ExecStart=' "$svc" 2>/dev/null | head -1 | sed 's/^ExecStart=//')"
case "$exec_line" in
*/owlryd|*/owlryd\ *)
printf ' %s -> references deleted owlryd binary: %s\n' "$svc" "$exec_line"
return 0
;;
*/target/debug/*|*/target/release/*)
printf ' %s -> points at a dev-time build path: %s\n' "$svc" "$exec_line"
return 0
;;
*/owlry|*/owlry\ *|/usr/bin/owlry|/usr/bin/owlry\ *)
# If the override matches the canonical /usr/bin/owlry, it's harmless
# but redundant. Flag it gently.
printf ' %s -> user-level override (redundant; AUR ships this unit)\n' "$svc"
return 0
;;
*)
# Unknown ExecStart — flag it so the user can review.
printf ' %s -> non-standard ExecStart: %s\n' "$svc" "$exec_line"
return 0
;;
esac
}
_owlry_check_compositors() {
# Args: $1=user, $2=home
local user="$1" home="$2"
local found=0 f
# Single-file configs
for f in \
"$home/.config/sway/config" \
"$home/.config/i3/config" \
"$home/.config/river/init" \
"$home/.config/niri/config.kdl"; do
if [ -f "$f" ] && grep -qE '\bowlryd\b' "$f" 2>/dev/null; then
printf ' %s -> contains `owlryd` reference\n' "$f"
found=1
fi
done
# Hyprland — main file + include directory
if [ -d "$home/.config/hypr" ]; then
while IFS= read -r f; do
if grep -qE '\bowlryd\b' "$f" 2>/dev/null; then
printf ' %s -> contains `owlryd` reference\n' "$f"
found=1
fi
done < <(find "$home/.config/hypr" -type f \( -name '*.conf' -o -name '*.hyprlang' \) 2>/dev/null)
fi
return $((1 - found))
}
_owlry_per_user_migration_check() {
local user="$1" home="$2"
# Collect all check output in one subshell so internal newlines are
# preserved (only the trailing newline gets stripped by $()).
local out
out="$({
_owlry_check_unit "$user" "$home" owlry.service
_owlry_check_unit "$user" "$home" owlry.socket
_owlry_check_unit "$user" "$home" owlryd.service
_owlry_check_unit "$user" "$home" owlryd.socket
_owlry_check_compositors "$user" "$home"
})"
[ -z "$out" ] && return 0
echo
echo " ── user '$user' ─────────────────────────────────────────────"
echo "$out"
echo
echo " Suggested cleanup (run as '$user'):"
echo
cat <<EOF
# 1. Drop any user-level systemd overrides:
rm -f ~/.config/systemd/user/owlry.service ~/.config/systemd/user/owlry.socket
rm -f ~/.config/systemd/user/owlryd.service ~/.config/systemd/user/owlryd.socket
systemctl --user daemon-reload
systemctl --user reenable --now owlry.service
systemctl --user reset-failed owlry.service 2>/dev/null || true
# 2. Replace any \`owlryd\` autostart line in your compositor config:
# exec-once = owlryd -> exec-once = owlry -d
# exec owlryd -> exec owlry -d
# (Usually unnecessary now — owlry.service / owlry.socket replace autostart.)
EOF
}
post_upgrade() {
local old_pkgver="$2"
@@ -20,31 +139,22 @@ post_upgrade() {
│ owlryd.service -> owlry.service │
│ owlryd.socket -> owlry.socket │
│ │
│ If you had the old service enabled, run: │
│ systemctl --user disable --now owlryd.service │
│ systemctl --user enable --now owlry.service │
│ │
│ Plugin packages (bookmarks, systemd, clipboard, …) are now │
│ built into owlry by default — they were dropped from AUR and │
│ replaced via this package. │
│ │
│ Widget providers (weather, media, pomodoro) are not in 2.0;
│ they return in a later 2.x release. See:
│ docs/RESTRUCTURE-V2.md (decisions D20, section 8)
│ Widget providers (weather, media, pomodoro) and bookmarks
are not in 2.0; they return in a later 2.x release. See: │
│ docs/RESTRUCTURE-V2.md (decisions D20, D22)
╰─────────────────────────────────────────────────────────────────╯
EOF
# Best-effort transition: if the old owlryd.service is enabled or
# active for the invoking user, stop it and disable it so the new
# owlry.service can take over. Errors are non-fatal — pacman runs
# as root, so we can only inspect, not toggle user units here.
if command -v loginctl >/dev/null 2>&1; then
local invoking_user
invoking_user="$(loginctl list-users --no-legend 2>/dev/null | awk 'NR==1 {print $2}')"
if [ -n "$invoking_user" ]; then
echo " (Run the systemctl --user commands above as user '$invoking_user'.)"
fi
fi
_owlry_for_each_user _owlry_per_user_migration_check
;;
2.0|2.0.0)
# 2.0.0 → 2.0.1: re-run the migration check; some users may have
# missed cleanup the first time. Idempotent — only prints when
# there's still something to flag.
_owlry_for_each_user _owlry_per_user_migration_check
;;
esac
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "owlry"
version = "2.0.0"
version = "2.0.1"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"
+16
View File
@@ -22,6 +22,22 @@ impl DynamicProvider for CalculatorProvider {
10_000
}
fn prefix(&self) -> Option<&str> {
Some(":calc")
}
fn icon(&self) -> &str {
"accessories-calculator"
}
fn tab_label(&self) -> Option<&str> {
Some("Calc")
}
fn search_noun(&self) -> Option<&str> {
Some("math expression")
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let expr = match extract_expression(query) {
Some(e) if !e.is_empty() => e,
@@ -28,6 +28,22 @@ impl DynamicProvider for ConverterProvider {
9_000
}
fn prefix(&self) -> Option<&str> {
Some(":conv")
}
fn icon(&self) -> &str {
PROVIDER_ICON
}
fn tab_label(&self) -> Option<&str> {
Some("Convert")
}
fn search_noun(&self) -> Option<&str> {
Some("unit / currency conversion")
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
let query_str = query.trim();
// Strip prefix
+16
View File
@@ -192,6 +192,22 @@ impl DynamicProvider for FileSearchProvider {
8_000
}
fn prefix(&self) -> Option<&str> {
Some(":file")
}
fn icon(&self) -> &str {
"system-file-manager"
}
fn tab_label(&self) -> Option<&str> {
Some("Files")
}
fn search_noun(&self) -> Option<&str> {
Some("files")
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
self.evaluate(query)
}
+147 -2
View File
@@ -217,6 +217,23 @@ pub trait DynamicProvider: Send + Sync {
fn query(&self, query: &str) -> Vec<LaunchItem>;
fn priority(&self) -> u32;
/// Optional search prefix (e.g. ":calc"). None = no prefix.
fn prefix(&self) -> Option<&str> {
None
}
/// Icon name (XDG icon theme).
fn icon(&self) -> &str {
"application-x-addon"
}
/// Tab button label.
fn tab_label(&self) -> Option<&str> {
None
}
/// Search placeholder noun.
fn search_noun(&self) -> Option<&str> {
None
}
/// Handle a plugin action command. Returns true if handled.
fn execute_action(&self, _command: &str) -> bool {
false
@@ -631,12 +648,19 @@ impl ProviderManager {
}
/// Get all available provider types (for UI tabs).
///
/// Includes both static `Provider`s and dynamic `DynamicProvider`s so
/// callers like `owlry doctor` / `owlry providers` see the full picture.
#[allow(dead_code)]
pub fn available_provider_types(&self) -> Vec<ProviderType> {
self.providers.iter().map(|p| p.provider_type()).collect()
self.providers
.iter()
.map(|p| p.provider_type())
.chain(self.builtin_dynamic.iter().map(|p| p.provider_type()))
.collect()
}
/// Get descriptors for all registered providers.
/// Get descriptors for all registered providers (static + dynamic).
///
/// Used by the IPC server to report what providers are available to clients.
pub fn available_providers(&self) -> Vec<ProviderDescriptor> {
@@ -675,6 +699,27 @@ impl ProviderManager {
});
}
// Dynamic providers (calc, conv, websearch, filesearch). They're always
// ProviderType::Plugin(<type_id>) — the only other variants are
// Application/Command/Dmenu which are static-only.
for provider in &self.builtin_dynamic {
let id = match provider.provider_type() {
ProviderType::Plugin(type_id) => type_id,
// Defensive: keep the surface honest even if a future dynamic
// provider claims a non-Plugin type.
other => other.to_string(),
};
descs.push(ProviderDescriptor {
id,
name: provider.name().to_string(),
prefix: provider.prefix().map(String::from),
icon: provider.icon().to_string(),
position: ProviderPosition::Normal.as_str().to_string(),
tab_label: provider.tab_label().map(String::from),
search_noun: provider.search_noun().map(String::from),
});
}
descs
}
@@ -1186,6 +1231,106 @@ mod tests {
assert!(pm.execute_plugin_action("DYN:thing"));
}
#[test]
fn available_providers_includes_dynamic_providers() {
// Regression guard (2.0.1): dynamic providers must surface in the
// diagnostic list so `owlry doctor` and `owlry providers` can report
// them. Pre-2.0.1, only static providers were enumerated and the
// dynamic ones were silently absent despite running fine.
struct DynRich;
impl DynamicProvider for DynRich {
fn name(&self) -> &str {
"Rich Dynamic"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("rich-dyn".into())
}
fn query(&self, _q: &str) -> Vec<LaunchItem> {
Vec::new()
}
fn priority(&self) -> u32 {
7_000
}
fn prefix(&self) -> Option<&str> {
Some(":rdyn")
}
fn icon(&self) -> &str {
"rich-dynamic-icon"
}
fn tab_label(&self) -> Option<&str> {
Some("Rich")
}
fn search_noun(&self) -> Option<&str> {
Some("rich queries")
}
}
let pm = ProviderManager::new(Vec::new(), vec![Box::new(DynRich)]);
let descs = pm.available_providers();
assert_eq!(descs.len(), 1);
let d = &descs[0];
assert_eq!(d.id, "rich-dyn");
assert_eq!(d.name, "Rich Dynamic");
assert_eq!(d.prefix.as_deref(), Some(":rdyn"));
assert_eq!(d.icon, "rich-dynamic-icon");
assert_eq!(d.position, "normal");
assert_eq!(d.tab_label.as_deref(), Some("Rich"));
assert_eq!(d.search_noun.as_deref(), Some("rich queries"));
}
#[test]
fn available_provider_types_includes_dynamic_providers() {
struct DynStub;
impl DynamicProvider for DynStub {
fn name(&self) -> &str {
"stub"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("stub".into())
}
fn query(&self, _q: &str) -> Vec<LaunchItem> {
Vec::new()
}
fn priority(&self) -> u32 {
0
}
}
let mock = MockProvider::new("Apps", ProviderType::Application);
let pm = ProviderManager::new(vec![Box::new(mock)], vec![Box::new(DynStub)]);
let types = pm.available_provider_types();
assert_eq!(types.len(), 2);
assert!(types.contains(&ProviderType::Application));
assert!(types.contains(&ProviderType::Plugin("stub".into())));
}
#[test]
fn dynamic_provider_trait_defaults_return_documented_values() {
// 2.0.1 added prefix/icon/tab_label/search_noun as defaulted methods
// on DynamicProvider so the trait can describe its own UI metadata
// without falling back to a hardcoded match table.
struct Minimal;
impl DynamicProvider for Minimal {
fn name(&self) -> &str {
"m"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("m".into())
}
fn query(&self, _: &str) -> Vec<LaunchItem> {
Vec::new()
}
fn priority(&self) -> u32 {
0
}
}
let m = Minimal;
assert_eq!(m.prefix(), None);
assert_eq!(m.icon(), "application-x-addon");
assert_eq!(m.tab_label(), None);
assert_eq!(m.search_noun(), None);
}
#[test]
fn execute_plugin_action_returns_false_when_nothing_handles() {
let prov = RichMockProvider {
+16
View File
@@ -150,6 +150,22 @@ impl DynamicProvider for WebSearchProvider {
9_000
}
fn prefix(&self) -> Option<&str> {
Some(":web")
}
fn icon(&self) -> &str {
"applications-internet"
}
fn tab_label(&self) -> Option<&str> {
Some("Web")
}
fn search_noun(&self) -> Option<&str> {
Some("the web")
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
match self.evaluate(query) {
Some(item) => vec![item],
+501
View File
@@ -0,0 +1,501 @@
# Owlry Lua API
The owlry Lua configuration system. Lands in **2.1** as an opt-in preview alongside TOML; becomes the canonical config format in **3.0** when TOML support is removed.
> **Status:** design spec for Phase 3 of the v2 rewrite plan. Becomes the user-facing reference doc once Phase 3 ships.
---
## 1. Why Lua
Owlry's 2.0 config is `~/.config/owlry/config.toml`. TOML is fine for flat key/value settings, but it can't express the things people actually want from a launcher config:
- "Use a different SSH command for `:ssh host-foo` than for everything else"
- "Add a Hyprland shutdown menu as a `:hs` provider"
- "Build the bookmarks list from `~/Documents/links.md` instead of Firefox"
- "Conditionally enable systemd-units only on machines where `$HOSTNAME` matches a regex"
These are all one-liners in a real programming language. They're impossible in TOML.
Owlry 2.1 ships a Lua-driven config: the file is real Lua 5.4, evaluated at startup, and the same file holds:
1. **Global settings** (theme, dimensions, terminal command)
2. **Enabled providers** (which built-ins run)
3. **Tab layout** (which providers appear as tab buttons)
4. **User-defined providers** (custom search sources)
5. **Theme overrides**
This is the same model Hyprland adopted (hyprlang) and that AwesomeWM / wezterm / xmonad have always used: **the config language IS the extension language**. No second-class scripting bolted on alongside a static config.
---
## 2. File location & loading order
The config file is **`~/.config/owlry/owlry.lua`**.
> Not `init.lua` — the name `owlry.lua` is intentional brand identity. `init.lua` is Lua's `require` entry-point convention; this file isn't loaded by `require`, it's loaded explicitly by owlry, so the convention doesn't apply.
### Resolution
On startup the daemon resolves config in this order:
1. **`$XDG_CONFIG_HOME/owlry/owlry.lua`** (default: `~/.config/owlry/owlry.lua`) — if this file exists, it's the **only** source of truth. TOML is ignored entirely when Lua is present.
2. **`$XDG_CONFIG_HOME/owlry/config.toml`** — fallback for 2.x users who haven't migrated. Behavior unchanged from 2.0.
3. **Shipped defaults**`/usr/share/owlry/default.lua` (a stub that calls `owlry.set {}` with reasonable defaults). Used when neither user file exists.
In **3.0**, step 2 is removed. Users who haven't migrated get a clear error from `owlry config validate` and a pointer to `owlry migrate-config`.
### Why Lua "wins" over TOML when both exist
To avoid the "did you remember to delete `config.toml`?" footgun. If you ever write an `owlry.lua`, that's your config — the TOML next to it doesn't quietly partial-override anything. Clear winner: predictable behavior.
---
## 3. Quick reference
```lua
-- ~/.config/owlry/owlry.lua
local owlry = require("owlry")
-- ──────────────────────────────────────────────────────────────────────────
-- Global settings
-- ──────────────────────────────────────────────────────────────────────────
owlry.set {
theme = "owl",
width = 850,
height = 650,
font_size = 14,
terminal = "kitty",
use_uwsm = false,
show_icons = true,
max_results = 100,
}
-- ──────────────────────────────────────────────────────────────────────────
-- Which providers run (the "enabled set")
-- ──────────────────────────────────────────────────────────────────────────
owlry.providers {
"app", "cmd", "calc", "conv", "power", -- always-on built-ins
"systemd", "ssh", "websearch", "filesearch", "emoji", "clipboard",
}
-- ──────────────────────────────────────────────────────────────────────────
-- Which providers get a tab button in the UI header bar (Ctrl+1..N)
-- Must be a subset of the enabled set. If omitted, defaults to all providers.
-- ──────────────────────────────────────────────────────────────────────────
owlry.tabs { "app", "cmd", "uuctl" }
-- ──────────────────────────────────────────────────────────────────────────
-- A user-defined provider — automatically joins the enabled set.
-- ──────────────────────────────────────────────────────────────────────────
owlry.provider {
id = "hs",
prefix = ":hs",
tab_label = "Shutdown",
icon = "system-shutdown",
items = function()
return {
{ name = "Lock", command = "hyprlock" },
{ name = "Shutdown", command = "systemctl poweroff" },
{ name = "Reboot", command = "systemctl reboot" },
}
end,
}
-- ──────────────────────────────────────────────────────────────────────────
-- Theme selection (or inline definition; see §4.5)
-- ──────────────────────────────────────────────────────────────────────────
owlry.theme("catppuccin-mocha")
```
---
## 4. API reference
The `owlry` module is the only thing in scope. `require("owlry")` returns it. All functions are callable in any order, multiple times — last-write-wins on most settings; some accumulate (see each function's note).
### 4.1 `owlry.set { ... }` — global settings
Sets top-level config values. Takes a table of key-value pairs. Calling `owlry.set` multiple times **merges** — later calls override earlier values for the same key. Keys not mentioned keep their default.
| Key | Type | Default | What it does |
|---|---|---|---|
| `theme` | `string` | `nil` (system theme) | Theme name (see §4.5 for options) |
| `width` | `integer` | `850` | Launcher window width in pixels |
| `height` | `integer` | `650` | Launcher window height in pixels |
| `font_size` | `integer` | `14` | Base font size in points |
| `border_radius` | `integer` | `12` | Window corner radius in pixels |
| `terminal` | `string \| nil` | autodetected | Terminal command for items marked `terminal=true`. Falls back to `$TERMINAL``xdg-terminal-exec` → common terminals |
| `use_uwsm` | `boolean` | `false` | Launch apps via `uwsm app --` for systemd session integration |
| `show_icons` | `boolean` | `true` | Show provider icons in results |
| `max_results` | `integer` | `100` | Cap on results returned per query |
| `frecency` | `boolean` | `true` | Boost frequently/recently used items |
| `frecency_weight` | `number` | `0.3` | Frecency boost weight (0.0 = off, 1.0 = strong) |
| `search_engine` | `string` | `"duckduckgo"` | Engine for `:web` / `?` queries (see §6) |
**Example:**
```lua
owlry.set { theme = "owl", width = 900 }
owlry.set { font_size = 16 } -- merged: theme + width still applied
```
Unknown keys produce a `owlry config validate` warning, not an error — forward-compat for 2.2+ keys.
---
### 4.2 `owlry.providers { ... }` — enabled set
Lists **which providers run**. Takes a sequence (array) of provider IDs. **Order doesn't matter.**
```lua
owlry.providers { "app", "cmd", "calc", "conv", "power" }
```
**Rules:**
- A provider must be in this list to produce any results. If it's not enabled, neither typing its prefix (`:foo`) nor including it in `owlry.tabs` will make it active.
- Built-in IDs are: `app`, `cmd`, `dmenu`, `calc`, `conv`, `power`, `systemd` (alias: `uuctl`), `ssh`, `clipboard`, `emoji`, `websearch`, `filesearch`. Pre-2.0 aliases (`sys`, `system``power`; `uuctl``systemd`) still work.
- User providers defined via `owlry.provider {}` **auto-join the enabled set** — you don't list them here unless you want to.
- Calling `owlry.providers` multiple times **replaces** the list (it's not additive). Use one call.
- If a feature isn't compiled into the binary (e.g. someone built with `--no-default-features`), the provider is silently ignored at runtime — `owlry doctor` reports the mismatch.
**Default if omitted:** all compiled-in providers are enabled. (The AUR build has `--features full`, so this defaults to everything.)
---
### 4.3 `owlry.tabs { ... }` — UI tab buttons
Lists which providers appear as **tab buttons** in the header bar. Tabs cycle with `Tab` / `Shift+Tab` and can be jumped to with `Ctrl+1..9`.
```lua
owlry.tabs { "app", "cmd", "uuctl" }
```
**Rules:**
- Each entry must be in `owlry.providers` (or be a `owlry.provider {}` user provider). Listing an unknown ID produces a `owlry config validate` warning and is silently dropped at runtime.
- Order is preserved — `Ctrl+1` targets the first entry, `Ctrl+2` the second, etc.
- The implicit "All" tab is always present at position 0 (`Ctrl+0`).
- **Default if omitted:** all enabled providers get tabs in the order they were registered.
- **`owlry.tabs {}` (empty)** is valid: hides all tab buttons. The "All" tab is still there.
**The `tabs` ⊆ `providers` rule, made obvious:**
```
provider IS enabled ┌──────────────────────────────────────────┐
(runs, contributes results) │ app cmd power calc conv systemd ssh emoji│ <- owlry.providers
└─────┬─────┬───────────────┬──────────────┘
│ │ │
┌─────▼─────▼───────────────▼──────────────┐
│ app cmd uuctl │ <- owlry.tabs
│ ↑1 ↑2 ↑3 │ (subset; Ctrl+N order)
└──────────────────────────────────────────┘
```
A provider in `providers` but not in `tabs` is fully searchable (auto mode + `:prefix` works), it just doesn't get a permanent tab button. This is the right place for calc/conv/websearch/filesearch — they're triggered with `=` / `>` / `?` / `/` and don't need to take up tab-bar real estate.
---
### 4.4 `owlry.provider { ... }` — user-defined provider
Defines a custom provider that runs alongside the built-ins. The minimum useful shape:
```lua
owlry.provider {
id = "hs", -- type_id, used internally
prefix = ":hs", -- search prefix (optional)
items = function(query) -- called to populate results
return {
{ name = "Lock", command = "hyprlock" },
{ name = "Shutdown", command = "systemctl poweroff" },
}
end,
}
```
**Full field reference:**
| Field | Type | Required | Default | What it does |
|---|---|---|---|---|
| `id` | `string` | yes | — | Unique provider ID. Used for filter matching (`-m <id>`) and tab listings. Must be lowercase, alphanumeric + `-`/`_`. |
| `items` | `function(query: string) -> table` | yes | — | Returns the list of items. Called on every query if `dynamic = true`, otherwise cached after the first call (see `refresh` below). |
| `prefix` | `string \| nil` | no | `nil` | Search prefix (e.g. `":hs "`). Typing the prefix narrows results to this provider. |
| `name` | `string` | no | `id` capitalised | Display name shown in `owlry providers`. |
| `tab_label` | `string` | no | `name` | Tab button text. |
| `icon` | `string` | no | `"application-x-addon"` | XDG icon name shown next to items. |
| `search_noun` | `string` | no | `id` | Search placeholder noun (e.g. "Search SSH hosts…"). |
| `dynamic` | `boolean` | no | `false` | If true, `items` is called on every keystroke. If false, `items` is called once at startup and cached. **2.1 ships only `false` (static); `true` arrives in 2.2.** |
| `priority` | `integer` | no | `0` | Tiebreaker for ordering when multiple providers match. Higher = first. |
**The item table:**
Each entry in the table returned by `items` is itself a table:
| Field | Type | Required | What it does |
|---|---|---|---|
| `name` | `string` | yes | Item title shown in the result row |
| `command` | `string` | yes | Shell command executed on launch |
| `description` | `string` | no | Secondary text below the title |
| `icon` | `string` | no | Per-item icon (overrides provider icon) |
| `terminal` | `boolean` | no | If true, launches inside a terminal |
| `tags` | `table` | no | List of tag strings for `:tag:X` filtering |
**Multiple `owlry.provider` calls accumulate** — each call registers an additional provider. Calling twice with the same `id` overrides the previous registration (warning emitted).
**Lifecycle:** the provider's `items` function is called by the daemon. It runs in the Lua state with the full host API available (see §5). It must return within a reasonable time — slow `items` functions block the UI. If you need to do work that takes >100ms, cache it externally and refresh on demand (see `dynamic = true` for the 2.2 path).
---
### 4.5 `owlry.theme(...)` — theme selection
Two forms:
```lua
-- Built-in or user theme by name
owlry.theme("catppuccin-mocha")
```
Looks up the theme in order: `~/.config/owlry/themes/{name}.css` → bundled theme. Built-in themes: `owl`, `catppuccin-mocha`, `nord`, `rose-pine`, `dracula`, `gruvbox-dark`, `tokyo-night`, `solarized-dark`, `one-dark`, `apex-neon`.
```lua
-- Inline color overrides (merges on top of selected theme or system defaults)
owlry.theme {
background = "#1e1e2e",
background_secondary = "#313244",
border = "#45475a",
text = "#cdd6f4",
text_secondary = "#a6adc8",
accent = "#cba6f7",
accent_bright = "#f5c2e7",
-- Per-provider badge colors (optional)
badge_app = "#a6e3a1",
badge_cmd = "#fab387",
badge_power = "#f38ba8",
badge_uuctl = "#9ece6a",
}
```
The two forms can be combined — call `owlry.theme(name)` first, then `owlry.theme { ... }` to layer overrides on top.
---
## 5. The host API in Lua scope
User code runs in a Lua 5.4 state with the `mlua` runtime. Beyond the standard library, owlry exposes a small **host API** for things provider code is likely to need.
### 5.1 What's available in `owlry.lua`
- **Full Lua 5.4 stdlib** — `math`, `string`, `table`, `os`, `io`, `coroutine`, `package`. No sandboxing. It's your config file on your machine; deal with it.
- **The `owlry` module** — `set`, `providers`, `tabs`, `provider`, `theme`. (Documented above.)
- **Convenience helpers under `owlry.util`** (added incrementally; ship in 2.1):
- `owlry.util.shell(cmd) -> string` — run a shell command, return stdout. Blocking; use sparingly.
- `owlry.util.shell_lines(cmd) -> table` — same, split into a list of lines.
- `owlry.util.read_file(path) -> string|nil` — read a file's contents, nil if missing.
- `owlry.util.glob(pattern) -> table` — list paths matching a glob.
- `owlry.util.env(name, default?) -> string` — read an env var with a fallback.
- `owlry.util.hostname() -> string` — current hostname.
### 5.2 What's NOT in scope
- **No process spawning at config-load time for non-trivial setup.** Provider `items` functions can shell out via `owlry.util.shell`, but the top-level config eval should be fast. If the daemon spends >500ms running `owlry.lua` at startup, something's wrong.
- **No network helpers in 2.1.** If a user provider needs HTTP, they can `os.execute("curl ...")` for now. A first-class `owlry.util.http_get` lands in 2.2.
- **No reactive state.** Each provider's `items` function should be stateless (or use local upvalues for caching). Cross-call state via shared mutable globals will work but isn't a supported pattern.
### 5.3 Sandbox stance
**None in 2.1.** This is the user's config file on the user's machine, by analogy to `bashrc` or `init.vim`. If a future "share your config" community emerges, sandbox concerns kick in then. For now: assume the user wrote the file they're running.
---
## 6. How `providers` + `tabs` + `provider {}` compose at runtime
The single most-asked question this section pre-empts.
### 6.1 The three axes
1. **Compiled in** — cargo features at build time. AUR build = all of them. Users on `cargo install --no-default-features` only have a minimal set. Cannot be enabled at runtime.
2. **Enabled**`owlry.providers { ... }` decides which compiled-in providers actually run. Plus all `owlry.provider {}` user definitions are auto-enabled.
3. **Shown as tab**`owlry.tabs { ... }` decides which enabled providers get a button in the header bar.
A provider must be **compiled in** AND **enabled** to do anything. Being shown as a tab is purely a UI convenience.
### 6.2 Selection at use time
On top of the three axes, the user picks at launch time:
```bash
owlry # auto mode — every enabled provider contributes, tabs cycle through
owlry -m auto # explicit form of above
owlry -m app # single-mode — only `app` runs, even though others are enabled
owlry --profile dev # profile (see §6.5) — pre-baked subset of enabled providers
```
Plus the **prefix override** inside the UI: typing `:uuctl foo` narrows the current query to the systemd provider regardless of the mode.
### 6.3 Worked example
Given this config:
```lua
owlry.providers { "app", "cmd", "calc", "conv", "power", "systemd", "websearch" }
owlry.tabs { "app", "cmd", "systemd" }
owlry.provider { id = "hs", prefix = ":hs", items = function() ... end }
```
Behavior:
| Invocation / typed prefix | What runs | Tabs shown |
|---|---|---|
| `owlry` (no flags), empty query | every enabled provider scored by frecency. `app`, `cmd`, `calc`, `conv`, `power`, `systemd`, `websearch`, `hs` all contribute | `app`, `cmd`, `systemd`, `hs` (`hs` auto-added because it wasn't in `owlry.tabs` → defaults policy kicks in for user providers; see note below) |
| `owlry` then user types `:hs ` | only the `hs` provider | tabs unchanged |
| `owlry -m app` | only `app` | only the `app` tab is highlighted |
| `owlry`, user types `= 2+3` | calculator catches the `=` trigger, returns `5` | tabs unchanged |
| `owlry`, user types `:foo bar` | nothing (no provider with id `foo`) | tabs unchanged |
| `owlry`, user types `:bookmarks rust` | nothing (`bookmarks` not in `providers` → not enabled, even though it's compiled in) | warning surfaces in `owlry doctor` |
> **Default tab policy when `owlry.tabs` is omitted:** all enabled providers get tabs.
> **When `owlry.tabs` IS present:** user providers from `owlry.provider {}` are NOT auto-added — the user explicitly chose their tab list. To pin a user provider, include its `id` in `owlry.tabs`. (Open: do we want auto-add behavior here? See §10.)
### 6.4 What happens if a config refers to something missing
| Situation | Behavior | Where surfaced |
|---|---|---|
| `owlry.providers` lists a provider not compiled in | Silently dropped at runtime; warning logged | `owlry doctor` lists it under "Not compiled in" |
| `owlry.providers` lists an unknown id | Warning at config-validate time | `owlry config validate` prints the offending line |
| `owlry.tabs` lists an id not in `owlry.providers` | Warning, dropped from tab bar | `owlry config validate` |
| `owlry.provider { items = function() ... end }` errors | Provider returns 0 items for that query; full traceback in daemon log | `owlry doctor` shows the provider as "errored" with the last exception |
| Two `owlry.provider {}` calls with same `id` | Second one wins; warning emitted | `owlry config validate` |
### 6.5 Profiles
Pre-baked alternate enabled sets (a la 2.0's `--profile`). Inline in Lua:
```lua
owlry.profiles {
dev = { "app", "cmd", "ssh" },
media = { "emoji", "clipboard" },
minimal = { "app" },
}
```
```bash
owlry --profile dev # uses { app, cmd, ssh } instead of owlry.providers's full set
```
Profiles override `owlry.providers` for the launch but inherit `owlry.set` / `owlry.theme` / `owlry.tabs`.
---
## 7. Hot reload
The daemon watches `owlry.lua` and any files it `require`s. On save:
1. Re-evaluate the Lua state in an isolated context.
2. If eval succeeds → swap the new config into the running daemon. Providers reload. No window flicker; no socket disconnect.
3. If eval fails → keep the old config alive. The error is logged to the daemon journal and surfaced on the next `owlry doctor` invocation.
There is **no `systemctl reload`** required. Edit the file, save, the next query reflects the change.
Hot reload uses the `notify` filesystem watcher (re-added to deps in Phase 3 — it was removed in v2 demolition).
---
## 8. Validation & error reporting
```bash
owlry config validate
```
Runs the Lua file in dry-run mode (no side effects, no daemon swap). Reports:
- Syntax errors with line numbers.
- Unknown keys in `owlry.set`.
- Unknown IDs in `owlry.providers` / `owlry.tabs`.
- `owlry.tabs` entries not in `owlry.providers`.
- Duplicate `id` in `owlry.provider {}` calls.
- Providers compiled out (warning, not error).
Exit code: `0` on clean, `1` on any error, `2` on warnings only (configurable).
```bash
owlry config show
```
Prints the **resolved** config as TOML (yes, TOML — for diffability and consistency with how `owlry config show` works today). Useful for debugging "what does owlry actually see?"
```bash
owlry config show --lua
```
Prints the resolved config as a Lua file (round-trippable). Useful for `owlry migrate-config` diff comparison.
---
## 9. Migration from TOML
```bash
owlry migrate-config
```
Reads `~/.config/owlry/config.toml`, writes `~/.config/owlry/owlry.lua` with the equivalent settings. Refuses to overwrite an existing `owlry.lua` unless `--force` is passed.
**Mapping:**
| TOML | Lua |
|---|---|
| `[general] theme = "owl"` | `owlry.set { theme = "owl" }` |
| `[general] tabs = ["app", "cmd"]` | `owlry.tabs { "app", "cmd" }` |
| `[providers] app = true` | included in `owlry.providers { ... }` |
| `[providers] app = false` | excluded from `owlry.providers { ... }` |
| `[appearance.colors] badge_app = "..."` | merged into `owlry.theme { badge_app = "..." }` |
| `[profiles.dev] modes = [...]` | `owlry.profiles { dev = { ... } }` |
Edge cases:
- Pre-v2 aliases (`system` config key, `badge_sys` color) are normalized to v2 names (`power`, `badge_power`) in the emitted Lua.
- Comments in the TOML are translated to Lua comments where they survive serialization round-trips. Order is preserved as much as possible.
- Unknown TOML keys (e.g. removed config options) are commented out in the output with a `-- DROPPED:` prefix.
The migrator is **deterministic** — running it twice on the same input produces byte-identical output.
---
## 10. Open questions (resolved before Phase 3 ships)
| Q | Default proposed | Notes |
|---|---|---|
| Auto-add user providers to `owlry.tabs` when tabs is non-empty? | **No** — explicit tabs list is explicit | Easy to revisit; user feedback after 2.1 will decide |
| `owlry.theme` separate vs folded into `owlry.set`? | **Separate** — themes are big enough to deserve their own verb | Matches Hyprland's `general` / `decoration` split |
| Profile keybinds (per-key Lua functions)? | **Deferred to 2.2** | Needs careful design re: which thread runs the function |
| Error reporting depth: traceback in UI banner? | **No in 2.1** — log to journal, surface via `owlry doctor` | UI banner gets noisy fast |
| Allow `owlry.lua` to be a directory of files merged together? | **No** — single file. Users wanting modularity use Lua `require` | |
| Multi-file config via `require`? | **Yes, supported transparently** — same hot-reload watcher follows `require`d files | Standard Lua semantics |
---
## 11. Version & compatibility roadmap
| Owlry version | Lua config | TOML config | Notes |
|---|---|---|---|
| **2.0** (shipped) | not supported | canonical | `owlry migrate-config` exists as a stub |
| **2.1** (Phase 3) | opt-in preview | still works | `owlry migrate-config` becomes functional |
| **2.2** (Phase 3 polish) | preferred; documented | works but warning emitted on load | dynamic providers, `owlry.bind`, `owlry.util.http_get` |
| **3.0** (D18) | canonical | **removed** | `owlry config validate` errors loudly when TOML is found without `owlry.lua` |
---
## 12. Implementation outline (engineering, not user-facing)
Tracked in `docs/RESTRUCTURE-V2.md` Phase 3. High level:
1. Add `mlua` dep (Lua 5.4, vendored, send, serialize)
2. New `crates/owlry/src/lua/` module: runtime, api surface, error types, host API utilities
3. `crates/owlry/src/config/loader.rs`: resolve order Lua → TOML → defaults; both paths produce the same `Config` struct
4. `LuaProvider` impl `Provider`/`DynamicProvider` from a Lua closure
5. `notify` watcher re-added; daemon listens for `owlry.lua` changes, re-evaluates in an isolated state, hot-swaps
6. `owlry migrate-config` functional; reads TOML, emits Lua
7. Characterization tests for every section of this doc; integration test that loads each example end-to-end
8. README + CLAUDE.md + `owlry(1)` updated
Target release: **2.1.0**.