diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..85e287c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,234 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Owl is a modular dotfiles and environment management CLI written in Rust. It manages configurations across different machines through: +- **Setups**: Modular units that handle software configuration (in `setups/`) +- **Nests**: Machine-specific environments that bundle setups together (in `nests/`) +- **Common**: Shared configurations and scripts (in `common/`) + +## Development Commands + +### Building and Running +```bash +cargo build # Build the project +cargo run -- # Run with arguments +cargo build --release # Production build + +# Example: Test nest linking +cargo run -- nest link +cargo run -- setup git info +``` + +### Testing Changes +```bash +# Install the built binary +cargo build && cargo run -- nest link + +# The binary is linked to ~/.local/bin/owl via nest link +# After linking, you can use `owl` directly +``` + +### Validation +```bash +owl setups-validate # Validate all setup.json files +``` + +## Architecture + +### Core Data Model + +The codebase follows a **validate-first pattern**: + +1. **SetupFile** (`setup.json`): Raw JSON with all optional fields + - Parsed directly from disk using serde + - All fields are `Option` to handle missing values + +2. **Setup**: Validated, resolved in-memory representation + - Created by validating a `SetupFile` + - All paths are resolved (no more `local:` or `common:` tokens) + - Used for all operations (link, install, systemd) + +3. **Validated types**: Specific structs for each component + - `ValidatedSetupLink`: Resolved source/target paths with root flag + - `ValidatedRunScript`: RC scripts with resolved paths + - `ValidatedSetupMenuScriptItem`: Menu scripts with paths and names + - `ValidatedSetupService`: Services with resolved unit files and types + +### Path Resolution + +The system uses path tokens that are resolved during validation: + +- `local:` → Relative to the setup directory (e.g., `setups//...`) +- `common:` → Relative to `common/` in the repo root +- `~` → Expands to user home directory +- Absolute paths pass through as-is + +Example: +```json +{ + "source": "local:config/sway.conf", // → setups/sway/config/sway.conf + "source": "common:config/.vimrc", // → common/config/.vimrc + "source": "~/custom/.zshrc" // → /home/user/custom/.zshrc +} +``` + +### Setup System + +**setup.json schema** (all fields optional): +```json +{ + "name": "git", + "dependencies": ["base-shell"], + "install": "local:install.sh", + "links": [ + { + "source": "local:gitconfig", + "target": "~/.gitconfig", + "root": false + } + ], + "rc_scripts": ["common:git-aliases.sh", "local:rc.sh"], + "menu_scripts": ["local:menu-helper.sh"], + "services": [ + { + "path": "local:my-service.service", + "service_type": "User" + } + ] +} +``` + +**Key concepts:** +- **dependencies**: Other setups to install first (recursive) +- **links**: Files/directories to symlink to target locations +- **rc_scripts**: Shell scripts sourced at startup (linked to `~/.config/owl/rc/`) +- **menu_scripts**: Scripts for dmenu/rofi (linked to `~/.config/owl/menu-scripts/`) +- **services**: Systemd units to link and enable (User or System scope) + +### Nest System + +Nests are root setups in `nests/` that define complete machine environments. They use the same `setup.json` format but typically: +- Declare many dependencies +- Include machine-specific links and rc_scripts +- Use `local:` paths relative to the nest directory + +The active nest is tracked in `~/.config/owl/config.json` as `nest_path`. + +### CLI Structure + +Entry point: `src/main.rs` + +Main command groups: +- **setup**: Operations on individual setups + - `owl setup link|install|systemd|info|edit|all [--shallow]` +- **nest**: Operations on the active nest (shorthand for root setup) + - `owl nest link|install|systemd|info|edit|switch|all [--shallow]` +- **system**: Configuration and maintenance + - `owl config`: Show current config + - `owl sync`: Sync repository (fetch, fast-forward, optional push) + - `owl update [--recursive]`: Update owl binary + - `owl setups-validate`: Validate all setups + +The `--shallow` flag prevents recursive dependency processing. + +## Code Conventions + +### Rust Patterns +- Keep all `setup.json` fields optional - validation happens separately +- Use free functions over large impls for CLI orchestration +- Fail fast at validation time with clear, user-friendly errors +- Print structured progress with emojis and colors during operations +- Remove dead code; prefer `quiet: bool` parameters over duplicate functions + +### Shell Scripts +- Use `set -euo pipefail` unless interactive +- Quote variable expansions: `"${VAR}"` +- Make install scripts idempotent (check before installing) +- Scripts receive resolved absolute paths (no `local:` or `common:` tokens) + +### Path Resolution +- Use the single unified resolver for all path token expansion +- All validated types store resolved `PathBuf`s +- Never operate on raw string paths from JSON + +## Common Setup Patterns + +### Adding a new setup +1. Create `setups//setup.json` +2. Add configuration files to the setup directory +3. Use `local:` for files in the setup, `common:` for shared files +4. Define dependencies on other setups if needed +5. Run `owl setups-validate` to check correctness + +### Adding to a nest +Edit `nests//setup.json` and add the setup name to `dependencies`. + +### Service management +Services are linked during `link` and enabled/started during `systemd`: +- User services → `~/.config/systemd/user/` +- System services → `/etc/systemd/system/` (requires sudo) + +### Debugging +- Use `owl setup info` to see what would be linked +- Check `~/.config/owl/config.json` for active configuration +- RC scripts are in `~/.config/owl/rc/` as `rc--` +- Menu scripts are in `~/.config/owl/menu-scripts/` + +## Omni-Menu + +Omni-menu is a GTK4-based application launcher integrated into owl as a second binary. It provides a unified interface for common desktop tasks. + +### Architecture + +**Location**: `src/omni_menu/` + +**Binary**: Built as `omni-menu` alongside the `owl` binary in Cargo.toml + +**Modules**: +- `main.rs` - Entry point with subcommand routing +- `main_menu.rs` - Main menu UI with keyboard shortcuts +- `search_menu.rs` - Web search (Google, ChatGPT, Notes) +- `projects_menu.rs` - Local/remote dev project launcher +- `scripts_menu.rs` - Owl scripts menu +- `launch_tool_menu.rs` - Launch tools in current workspace +- `switch_bench_menu.rs` - Yard bench switcher +- `desk_menu.rs` - Owl desk configuration switcher +- `emoji_menu.rs` - Emoji picker (rofi wrapper) +- `utils.rs` - Shared utilities for list population and filtering + +### Usage + +```bash +omni-menu # Main menu (default) +omni-menu search # Web search +omni-menu projects # Project launcher +omni-menu scripts # Owl scripts +omni-menu launch_tool # Launch tools +omni-menu switch_bench # Switch yard bench +omni-menu desk # Switch desk configuration +omni-menu emoji # Pick emoji +``` + +### Integration with Owl + +- **Desk Integration**: Detects Sway/i3 via `SWAYSOCK` env var, lists desk scripts from `~/.config/desks-sway/` or `~/.config/desks-i3/` +- **Scripts Integration**: Reads from owl's menu scripts directory +- **Setup**: Linked via `setups/menu/setup.json` to `~/.local/bin/omni-menu` + +### Building + +```bash +cargo build --bin omni-menu # Build omni-menu binary +cargo build # Build both owl and omni-menu +owl setup menu link # Link binary to ~/.local/bin +``` + +### Design Patterns + +- Each menu module is self-contained with its own GTK4 application +- Modules follow the `pub mod { pub fn run_app() -> glib::ExitCode }` pattern +- Main menu spawns submenus by re-invoking itself with different arguments +- Utilities module provides shared list filtering and fuzzy matching functionality diff --git a/Cargo.toml b/Cargo.toml index 973598a..6905aff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,14 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[[bin]] +name = "owl" +path = "src/main.rs" + +[[bin]] +name = "omni-menu" +path = "src/omni_menu/main.rs" + [dependencies] clap = { version = "4.3.21", features = ["derive"] } colored = "2.0.4" @@ -13,3 +21,7 @@ serde_json = "1.0.105" shellexpand = "3.1.0" thiserror = "1.0" once_cell = "1.19" +gtk = { version = "0.10.2", package = "gtk4" } +fuzzy-matcher = "0.3" +dirs = "5.0" +regex = "1.5" diff --git a/nests/framework-sway/bg-picker.sh b/nests/framework-sway/bg-picker.sh new file mode 100755 index 0000000..e0e8c1c --- /dev/null +++ b/nests/framework-sway/bg-picker.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Get wallpaper directory from environment or use default +WALL_DIR="${WALL_DIR:-$HOME/docs/media/wallpapers}" + +if [ ! -d "$WALL_DIR" ]; then + echo "Wallpaper directory not found: $WALL_DIR" + exit 1 +fi + +# Find all image files and show in rofi with just the filename +selected=$(find "$WALL_DIR" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.webp" \) -printf "%f\n" | sort | rofi -dmenu -i -p "Select Background") + +if [ -n "$selected" ]; then + # Find the full path of the selected file + full_path=$(find "$WALL_DIR" -type f -name "$selected" | head -1) + + if [ -n "$full_path" ]; then + # Set wallpaper using swww + swww img "$full_path" --transition-type fade --transition-duration 1 + fi +fi diff --git a/nests/framework-sway/setup.json b/nests/framework-sway/setup.json index ee7f7c7..2e3c141 100644 --- a/nests/framework-sway/setup.json +++ b/nests/framework-sway/setup.json @@ -1,4 +1,5 @@ { + "name": "framework-sway", "dependencies": [ "git", "bun", @@ -35,18 +36,14 @@ ], "menu_scripts": [ { - "name": "Emoji", - "path": "common:emoji.sh" - }, - { - "name": "Run Script", - "path": "common:run-script.sh" + "name": "Background Picker", + "path": "local:bg-picker.sh" } ], - "name": "framework-sway", "rc_scripts": [ "common:fzf.sh", "common:base-aliases.sh", "common:systemctl-aliases.sh" - ] + ], + "only_own_menu_scripts": true } \ No newline at end of file diff --git a/setups/i3/i3-config b/setups/i3/i3-config index 4ed22b1..1b6a35a 100644 --- a/setups/i3/i3-config +++ b/setups/i3/i3-config @@ -19,8 +19,8 @@ for_window [title="ClipGPT"] floating enable for_window [class="Spotify"] move to workspace 9 # Rofi -bindsym $mod+i exec "~/.local/bin/omni-menu run" -bindsym $mod+o exec "~/.local/bin/omni-menu window" +bindsym $mod+i exec "~/.local/bin/omni-menu launch_tool" +bindsym $mod+o exec "rofi -show window" bindsym $mod+p exec "~/.local/bin/omni-menu" bindsym $mod+n exec $TERMINAL --role floating -e "tt notes tui" diff --git a/setups/kitty/kitty.conf b/setups/kitty/kitty.conf index ed93965..70d830a 100644 --- a/setups/kitty/kitty.conf +++ b/setups/kitty/kitty.conf @@ -34,3 +34,4 @@ color15 #FFFFFF # Optional padding window_padding_width 6.0 +map shift+enter send_text all \e\r diff --git a/setups/menu/install.sh b/setups/menu/install.sh deleted file mode 100755 index 8ee4061..0000000 --- a/setups/menu/install.sh +++ /dev/null @@ -1,5 +0,0 @@ -git clone git@github.com:tylerthecoder/omni-menu.git ~/dev/omni-menu - -cd ~/dev/omni-menu - -make install \ No newline at end of file diff --git a/setups/menu/setup.json b/setups/menu/setup.json index 9639c11..bea77b5 100644 --- a/setups/menu/setup.json +++ b/setups/menu/setup.json @@ -1,5 +1,9 @@ { - "install": "local:install.sh", "name": "menu", - "links": [] + "links": [ + { + "source": "target/release/omni-menu", + "target": "~/.local/bin/omni-menu" + } + ] } \ No newline at end of file diff --git a/setups/sway/scripts/desks/framework-sway.sh b/setups/sway/scripts/desks/framework-sway.sh index 4cf21c0..5bcd131 100755 --- a/setups/sway/scripts/desks/framework-sway.sh +++ b/setups/sway/scripts/desks/framework-sway.sh @@ -9,10 +9,10 @@ for out in $(swaymsg -t get_outputs | jq -r '.[].name'); do fi done -swaymsg output eDP-1 enable mode 2880x1920 position 0 0 +swaymsg output eDP-1 enable mode 2880x1920 position 0 0 scale 1.5 # Key repeat and caps as super in Wayland -swaymsg input type:keyboard repeat_delay 200 -swaymsg input type:keyboard repeat_rate 40 +swaymsg input type:keyboard repeat_delay 300 +swaymsg input type:keyboard repeat_rate 25 swaymsg input type:keyboard xkb_options caps:super diff --git a/src/main.rs b/src/main.rs index 948b567..174546c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -190,6 +190,7 @@ struct SetupFileRaw { menu_scripts: Option>, services: Option>, dependencies: Option>, + only_own_menu_scripts: Option, } // ======================================= @@ -491,6 +492,7 @@ struct Setup { services: Vec, dependencies: Vec, install_script: Option, + only_own_menu_scripts: bool, } impl Setup { @@ -588,6 +590,7 @@ impl Setup { services, dependencies, install_script, + only_own_menu_scripts: setup_raw.only_own_menu_scripts.unwrap_or(false), }) } @@ -629,10 +632,12 @@ impl Setup { } } - fn link_once(&self) { + fn link_once(&self, skip_menu_scripts: bool) { Self::run_linkables(&self.links); Self::run_linkables(&self.rc_scripts); - Self::run_linkables(&self.menu_scripts); + if !skip_menu_scripts { + Self::run_linkables(&self.menu_scripts); + } Self::run_linkables(&self.services); } @@ -677,7 +682,7 @@ impl Setup { } } - fn apply_operation_once(&self, op: Operation) { + fn apply_operation_once(&self, op: Operation, skip_menu_scripts: bool) { let op_description = op.description(); let op_description_colored = op_description.magenta().bold(); let setup_name = self.name.cyan().bold(); @@ -690,12 +695,12 @@ impl Setup { println!("{} {} ({})", op_description_colored, setup_name, setup_dir); match op { - Operation::Link => self.link_once(), + Operation::Link => self.link_once(skip_menu_scripts), Operation::Install => self.install_once(), Operation::Systemd => self.systemd_once(), Operation::Info => self.info_once(), Operation::All => { - self.link_once(); + self.link_once(skip_menu_scripts); self.install_once(); self.systemd_once(); } @@ -704,10 +709,14 @@ impl Setup { fn run_op(&self, op: Operation, shallow: bool) { if shallow { - self.apply_operation_once(op); + self.apply_operation_once(op, false); } else { + let skip_inherited_menu_scripts = self.only_own_menu_scripts; for_each_dep_depth_first(&self.name, |s| { - s.apply_operation_once(op); + // Skip menu scripts for dependencies if the root setup has only_own_menu_scripts set + let is_root = s.name == self.name; + let skip = skip_inherited_menu_scripts && !is_root; + s.apply_operation_once(op, skip); }); } } diff --git a/src/omni_menu/desk_menu.rs b/src/omni_menu/desk_menu.rs new file mode 100644 index 0000000..3d67811 --- /dev/null +++ b/src/omni_menu/desk_menu.rs @@ -0,0 +1,169 @@ +pub mod desk_menu { + use gtk::prelude::*; + use gtk::{ + glib, Application, ApplicationWindow, Box as GtkBox, Entry, Label, ListBox, + Orientation, ScrolledWindow, + }; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::process::Command; + use crate::utils::{populate_list, filter_list}; + + const APP_ID: &str = "org.gtk_rs.DeskMenu"; + + fn get_desks_dir() -> PathBuf { + let home = dirs::home_dir().unwrap(); + // Check if we're in Sway or i3 + if std::env::var("SWAYSOCK").is_ok() { + home.join(".config/desks-sway") + } else { + home.join(".config/desks-i3") + } + } + + fn get_desk_scripts() -> Vec { + let desks_dir = get_desks_dir(); + if !desks_dir.exists() { + return Vec::new(); + } + + match fs::read_dir(&desks_dir) { + Ok(entries) => entries + .filter_map(|entry| { + let entry = entry.ok()?; + let file_name = entry.file_name().to_str()?.to_string(); + // Only include executable files or .sh files + if file_name.ends_with(".sh") || entry.metadata().ok()?.permissions().mode() & 0o111 != 0 { + Some(file_name) + } else { + None + } + }) + .collect(), + Err(_) => Vec::new(), + } + } + + fn build_ui(app: &Application) { + let window = ApplicationWindow::builder() + .application(app) + .title("Select Desk") + .default_width(500) + .default_height(600) + .decorated(true) + .resizable(false) + .modal(true) + .build(); + + let vbox = GtkBox::new(Orientation::Vertical, 5); + vbox.set_margin_top(10); + vbox.set_margin_bottom(10); + vbox.set_margin_start(10); + vbox.set_margin_end(10); + + // Search entry + let search_entry = Entry::new(); + search_entry.set_placeholder_text(Some("Type to filter desks...")); + vbox.append(&search_entry); + + // Scrolled window + let scrolled_window = ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .vexpand(true) + .build(); + + let list_box = ListBox::new(); + list_box.set_selection_mode(gtk::SelectionMode::Single); + list_box.set_activate_on_single_click(true); + scrolled_window.set_child(Some(&list_box)); + + vbox.append(&scrolled_window); + window.set_child(Some(&vbox)); + + // Get desks and populate + let desks = get_desk_scripts(); + if desks.is_empty() { + let label = Label::new(Some("No desk scripts found")); + label.set_margin_top(20); + list_box.append(>k::ListBoxRow::new()); + let row = list_box.row_at_index(0).unwrap(); + row.set_child(Some(&label)); + } else { + populate_list(&list_box, &desks); + } + + // Handle search + let list_box_weak = list_box.downgrade(); + let desks_clone = desks.clone(); + search_entry.connect_changed(move |entry| { + if let Some(list_box) = list_box_weak.upgrade() { + let query = entry.text().to_string().to_lowercase(); + filter_list(&list_box, &desks_clone, &query); + } + }); + + // Handle activation + let window_weak = window.downgrade(); + let desks_dir = get_desks_dir(); + list_box.connect_row_activated(move |_, row| { + if let Some(label) = row.child().and_then(|w| w.downcast::