diff --git a/bread-cli/src/init.rs b/bread-cli/src/init.rs
new file mode 100644
index 0000000..9920f8d
--- /dev/null
+++ b/bread-cli/src/init.rs
@@ -0,0 +1,1281 @@
+//! `bread init` — installs bread's Hyprland compositor integration: the
+//! `layerrule`s (blur, ignore_alpha, blur_popups, animation) that make
+//! breadbar/breadbox actually look like the translucent shell they're
+//! designed as, generated from the active shell theme's `[compositor]`
+//! table via `bread-theme layerrules` → `~/.config/hypr/layerrules.json`.
+//!
+//! # Why this command auto-edits a file, unlike `hooks_shell`/`hooks_git`
+//!
+//! Read `hooks_shell.rs`'s module header first — it lays out the general
+//! rule this repo follows: bread does not silently edit a user's own
+//! startup/config files, because that kind of edit is hard to notice and
+//! hard to undo. This module is the one deliberate exception, and it is
+//! narrow and consent-gated on purpose:
+//!
+//! - A missing shell hook is invisible enrichment — the shell just doesn't
+//! report telemetry, nothing looks broken, and the fix (source a line)
+//! is something the user can take at their own pace.
+//! - A missing compositor layer rule is not invisible. breadbar renders
+//! without blur, without `ignore_alpha`, motion is wrong — it looks like
+//! a broken app, not a missing optional feature, and a user hitting that
+//! has no way to know the fix is "hand-write five `hl.layer_rule` calls
+//! into your Hyprland config." The gap between "install the bread stack"
+//! and "the shell looks right" needs to be closable in one command.
+//!
+//! So: this module *does* propose an edit to the user's own config, but
+//! only ever one specific, clearly marked, single block; only after
+//! showing the exact diff; only with the user's consent (an interactive
+//! prompt, `--yes`, or nothing at all in a non-interactive session); always
+//! behind a timestamped backup; and always reversible with `bread init
+//! --undo`. Every actual line of layer-rule logic lives in a separate,
+//! entirely bread-owned file (`~/.config/hypr/bread.lua`) that is
+//! regenerated freely and never hand-edited — the one line touched in the
+//! user's own `hyprland.lua` just sources it.
+//!
+//! # Scope: appearance only
+//!
+//! `bread.lua` (see [`BREAD_LUA`]) emits `hl.layer_rule` calls built from
+//! exactly five fields: `blur`, `ignore_alpha`, `blur_popups`, `animation`,
+//! `no_anim`. It never reads or emits window placement, workspace
+//! assignment, or focus rules — those are `hl.window_rule` policy, are
+//! never part of `layerrules.json`, and are explicitly out of scope for
+//! this file even if a future `layerrules.json` somehow grew such a key.
+//! An installer that rearranged a user's workspaces on upgrade would be a
+//! serious bug; this module is structured so it physically cannot do that.
+//!
+//! # CLI shape: top-level `Init`, not `HooksCommand::Compositor`
+//!
+//! `hooks install-shell`/`hooks install-git` share one shape: "generate
+//! some bread-owned files, print instructions, never touch the user's own
+//! config." This command's shape is different in a way that matters enough
+//! to warrant its own top-level verb rather than a third `hooks install`
+//! variant: it *proposes an edit to a file it doesn't own*, which needs a
+//! diff, a consent gate, a backup, and a symmetrical `--undo` — none of
+//! which the `hooks` family has or needs. Folding it into `HooksCommand`
+//! would either bolt consent/undo semantics onto a subcommand group that
+//! otherwise has neither, or quietly suggest to a reader that hooks and
+//! compositor integration are the same kind of operation, when the whole
+//! point of this module's design is that they aren't.
+
+use anyhow::{Context, Result};
+use serde::Deserialize;
+use std::collections::BTreeMap;
+use std::fs;
+use std::io::{self, IsTerminal, Write as IoWrite};
+use std::path::{Path, PathBuf};
+
+/// Start-of-block marker written into the user's `hyprland.lua`. Both this
+/// and [`MARKER_END`] must appear verbatim (as a whole trimmed line) for a
+/// block to be recognized as bread-managed and therefore safe to update or
+/// remove; text near, but not matching exactly, is left alone.
+pub const MARKER_BEGIN: &str = "-- >>> bread managed >>>";
+/// End-of-block marker. See [`MARKER_BEGIN`].
+pub const MARKER_END: &str = "-- <<< bread managed <<<";
+
+/// The single line inserted between the markers. Deliberately just a
+/// `pcall(dofile(...))` — every actual rule lives in `bread.lua`, guarded
+/// by its own internal `pcall` too (belt and suspenders: this call site
+/// can never abort the rest of `hyprland.lua` even if `bread.lua` itself
+/// somehow threw outside its own guard, e.g. a syntax error from a
+/// half-written file).
+fn managed_block_body() -> &'static str {
+ "pcall(dofile, os.getenv(\"HOME\") .. \"/.config/hypr/bread.lua\") -- bread compositor integration; installed by `bread init`, removed by `bread init --undo`"
+}
+
+fn managed_block_text() -> String {
+ format!("{MARKER_BEGIN}\n{}\n{MARKER_END}", managed_block_body())
+}
+
+// ---------------------------------------------------------------------------
+// bread.lua — the entirely bread-owned generated file
+// ---------------------------------------------------------------------------
+
+/// Full contents written to `~/.config/hypr/bread.lua`.
+///
+/// Self-contained on purpose: it bundles its own minimal JSON object
+/// decoder rather than `dofile`-ing `scripts/lib/json.lua`, because that
+/// file is a BOS/this-machine convention, not something every Hyprland+Lua
+/// user has. The decoder only needs to handle what `layerrules.json` can
+/// contain (a flat object of objects with string/number/bool/null leaves)
+/// — see `bread-theme layerrules`'s writer for the schema this reads.
+///
+/// The whole body runs inside one `pcall`. A missing `layerrules.json`
+/// (bread-theme layerrules never run), an unreadable one, or a malformed
+/// one all degrade to "no bread layer rules applied this session" — never
+/// to a Hyprland config that fails to load.
+const BREAD_LUA: &str = r#"-- ~/.config/hypr/bread.lua
+--
+-- Generated by `bread init`. DO NOT HAND-EDIT — rerun `bread init` any
+-- time to regenerate this file from the current layerrules.json, or
+-- `bread init --undo` to remove the one line in hyprland.lua that sources
+-- it (this file itself is left in place, inert, until you delete it).
+--
+-- Reads ~/.config/hypr/layerrules.json (written by `bread-theme
+-- layerrules` from the active shell theme's [compositor] table) and emits
+-- one hl.layer_rule() per namespace it describes.
+--
+-- Appearance only: blur, ignore_alpha, blur_popups, animation, no_anim.
+-- This file must never grow window placement, workspace assignment, or
+-- focus rules — those are hl.window_rule policy and belong in your own
+-- hyprland.lua, not here. Only these five fields are ever read off each
+-- entry below; any other key in layerrules.json (there should not be any)
+-- is silently ignored.
+
+local ok, err = pcall(function()
+ local path = os.getenv("HOME") .. "/.config/hypr/layerrules.json"
+
+ local fh = io.open(path, "r")
+ if not fh then
+ return -- no layerrules.json yet (bread-theme layerrules not run) — nothing to do
+ end
+ local raw = fh:read("*a")
+ fh:close()
+
+ -- Minimal JSON decoder, scoped to layerrules.json's shape (an object of
+ -- objects; string/number/bool/null leaves; arrays supported for
+ -- completeness though the schema never uses one). Not a general-purpose
+ -- parser — see BREAD_LUA's doc comment in bread-cli/src/init.rs for why
+ -- this doesn't just dofile scripts/lib/json.lua.
+ local pos, len = 1, #raw
+
+ local function skip_ws()
+ while pos <= len and raw:sub(pos, pos):match("%s") do
+ pos = pos + 1
+ end
+ end
+
+ local parse_value
+
+ local function parse_string()
+ pos = pos + 1
+ local out = {}
+ while pos <= len do
+ local c = raw:sub(pos, pos)
+ if c == '"' then
+ pos = pos + 1
+ return table.concat(out)
+ elseif c == "\\" then
+ local n = raw:sub(pos + 1, pos + 1)
+ local escapes = { ['"'] = '"', ["\\"] = "\\", ["/"] = "/", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t" }
+ out[#out + 1] = escapes[n] or n
+ pos = pos + 2
+ else
+ out[#out + 1] = c
+ pos = pos + 1
+ end
+ end
+ error("unterminated string in layerrules.json")
+ end
+
+ local function parse_array()
+ pos = pos + 1
+ skip_ws()
+ local arr = {}
+ if raw:sub(pos, pos) == "]" then
+ pos = pos + 1
+ return arr
+ end
+ while true do
+ skip_ws()
+ arr[#arr + 1] = parse_value()
+ skip_ws()
+ local c = raw:sub(pos, pos)
+ pos = pos + 1
+ if c == "]" then
+ return arr
+ elseif c ~= "," then
+ error("expected ',' or ']' in layerrules.json array")
+ end
+ end
+ end
+
+ local function parse_object()
+ pos = pos + 1
+ skip_ws()
+ local obj = {}
+ if raw:sub(pos, pos) == "}" then
+ pos = pos + 1
+ return obj
+ end
+ while true do
+ skip_ws()
+ local key = parse_string()
+ skip_ws()
+ if raw:sub(pos, pos) ~= ":" then
+ error("expected ':' in layerrules.json object")
+ end
+ pos = pos + 1
+ skip_ws()
+ obj[key] = parse_value()
+ skip_ws()
+ local c = raw:sub(pos, pos)
+ pos = pos + 1
+ if c == "}" then
+ return obj
+ elseif c ~= "," then
+ error("expected ',' or '}' in layerrules.json object")
+ end
+ end
+ end
+
+ parse_value = function()
+ skip_ws()
+ local c = raw:sub(pos, pos)
+ if c == '"' then
+ return parse_string()
+ elseif c == "{" then
+ return parse_object()
+ elseif c == "[" then
+ return parse_array()
+ elseif raw:sub(pos, pos + 3) == "true" then
+ pos = pos + 4
+ return true
+ elseif raw:sub(pos, pos + 4) == "false" then
+ pos = pos + 5
+ return false
+ elseif raw:sub(pos, pos + 3) == "null" then
+ pos = pos + 4
+ return nil
+ else
+ local start = pos
+ while pos <= len and raw:sub(pos, pos):match("[%d%+%-.eE]") do
+ pos = pos + 1
+ end
+ if pos == start then
+ error("unexpected character in layerrules.json at byte " .. pos)
+ end
+ return tonumber(raw:sub(start, pos - 1))
+ end
+ end
+
+ local parsed = parse_value()
+ if type(parsed) ~= "table" then
+ return
+ end
+
+ local namespaces = {}
+ for ns, rule in pairs(parsed) do
+ if type(ns) == "string" and type(rule) == "table" then
+ namespaces[#namespaces + 1] = ns
+ end
+ end
+ table.sort(namespaces) -- deterministic emission order
+
+ for _, ns in ipairs(namespaces) do
+ local r = parsed[ns]
+ hl.layer_rule({
+ name = ns,
+ match = { namespace = "^" .. ns .. "$" },
+ blur = r.blur == true,
+ ignore_alpha = r.ignore_alpha,
+ blur_popups = r.blur_popups == true,
+ animation = r.animation,
+ no_anim = r.no_anim == true,
+ })
+ end
+end)
+
+if not ok then
+ -- Never let a bad layerrules.json break the rest of the compositor
+ -- config — Hyprland just runs without bread's layer rules until the
+ -- underlying file is fixed (or regenerated with `bread-theme layerrules`).
+ io.stderr:write("bread.lua: layer rules not applied: " .. tostring(err) .. "\n")
+end
+"#;
+
+// ---------------------------------------------------------------------------
+// Config layout detection
+// ---------------------------------------------------------------------------
+
+/// What we found at `~/.config/hypr` and whether it's something `bread
+/// init` can safely auto-edit.
+#[derive(Debug, PartialEq, Eq)]
+enum ConfigLayout {
+ /// `hyprland.lua` exists and is readable as UTF-8 text — the only
+ /// layout this command will ever write to.
+ Lua(PathBuf),
+ /// No `hyprland.lua`, but `hyprland.conf` exists — the legacy hyprlang
+ /// format. Never auto-edited (a `dofile`-based integration can't work
+ /// there anyway; conf and Lua are different, mutually exclusive config
+ /// entry points).
+ ConfOnly(PathBuf),
+ /// Neither recognized, or `hyprland.lua` exists but couldn't be read as
+ /// text (permissions, binary garbage, etc). Conservative default:
+ /// print instructions, write nothing.
+ Unrecognized,
+}
+
+fn detect_layout(hypr_dir: &Path) -> ConfigLayout {
+ let lua_path = hypr_dir.join("hyprland.lua");
+ let conf_path = hypr_dir.join("hyprland.conf");
+
+ if lua_path.is_file() {
+ return if fs::read_to_string(&lua_path).is_ok() {
+ ConfigLayout::Lua(lua_path)
+ } else {
+ ConfigLayout::Unrecognized
+ };
+ }
+ if conf_path.is_file() {
+ return ConfigLayout::ConfOnly(conf_path);
+ }
+ ConfigLayout::Unrecognized
+}
+
+// ---------------------------------------------------------------------------
+// BOS / existing-owner conflict detection
+// ---------------------------------------------------------------------------
+
+/// How deep to recurse under `~/.config/hypr` while scanning for an
+/// existing `breadbar` layer rule. Real trees here are a handful of levels
+/// (`scripts/ui/rules.lua`, etc); this is just a hang-safety bound.
+const CONFLICT_SCAN_MAX_DEPTH: usize = 8;
+
+/// Look for a `breadbar*` layer rule bread doesn't own — either BOS's own
+/// dotfiles or a user's hand-authored one (this machine's
+/// `scripts/ui/rules.lua` is exactly this case: `hl.layer_rule({ name =
+/// "breadbar-island", match = { namespace = "^breadbar$" }, ... })`).
+///
+/// Detection is deliberately heuristic and file-scoped rather than a real
+/// Lua/hyprlang parse: any `.lua` or `.conf` file under `hypr_dir` (other
+/// than bread's own generated `bread.lua`) that mentions both a layer-rule
+/// keyword (`layer_rule` for Lua, `layerrule` for hyprlang conf) and
+/// `breadbar` is treated as an existing owner. False positives (a file
+/// that mentions both words without actually defining a breadbar layer
+/// rule) are the safe failure mode here — worst case bread declines to
+/// install and points at a red herring, which is recoverable by hand;
+/// installing a second, conflicting rule set is not.
+fn find_breadbar_conflict(hypr_dir: &Path) -> Result