chore: reset history

This commit is contained in:
Bogdan Buduroiu 2026-07-06 09:27:39 +03:00
commit fe44657d8d
Signed by: bogdan
GPG key ID: F71EF75A4C09A095
213 changed files with 14801 additions and 0 deletions

View file

@ -0,0 +1,23 @@
---
name: OpenSpec: Apply
description: Implement an approved OpenSpec change and keep tasks in sync.
category: OpenSpec
tags: [openspec, apply]
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
Track these steps as TODOs and complete them one by one.
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
**Reference**
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
<!-- OPENSPEC:END -->

View file

@ -0,0 +1,27 @@
---
name: OpenSpec: Archive
description: Archive a deployed OpenSpec change and update specs.
category: OpenSpec
tags: [openspec, archive]
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
1. Determine the change ID to archive:
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
- Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
2. Validate the change ID by running `openspec list` (or `openspec show <id>`) and stop if the change is missing, already archived, or otherwise not ready to archive.
3. Run `openspec archive <id> --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work).
4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`.
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
**Reference**
- Use `openspec list` to confirm change IDs before archiving.
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
<!-- OPENSPEC:END -->

View file

@ -0,0 +1,27 @@
---
name: OpenSpec: Proposal
description: Scaffold a new OpenSpec change and validate strictly.
category: OpenSpec
tags: [openspec, change]
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
**Steps**
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
5. Draft spec deltas in `changes/<id>/specs/<capability>/spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant.
6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
**Reference**
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
<!-- OPENSPEC:END -->

View file

@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(openspec list:*)",
"Bash(openspec validate:*)",
"Bash(nmcli device:*)",
"Bash(nmcli connection show:*)",
"Bash(iw dev:*)",
"Bash(pmtiles tile:*)",
"Bash(nix-shell:*)"
],
"deny": [
"Read(./secrets/**)"
]
}
}

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.aider*
.DS_Store
.opencode
.secrets.env
.playwright-mcp/
tmp
scripts/
docs/

46
README.md Normal file
View file

@ -0,0 +1,46 @@
<h1 align="center">dotfiles</h1>
## Why?
- A single repository that manages all my machines: Framework13, Steam Deck, other potential servers
- Must be able to:
- apply configuration to any machine from my main Framework13 laptop
- encrypt and store secrets I might need here
- define common service definition and decide which machine gets which service / config
- Must be able to manage home directory, dotfiles and `~/.config` via this repository
- dislike symlinking
## The current setup
System Management: [NixOS](https://nixos.org/)
- replacing all dotfile management with NixOS Home Manager
Gaming System Management: [Jovian-NixOS](https://github.com/Jovian-Experiments/Jovian-NixOS)
Syncthing for syncing all my devices
Terminal Emulator: [Ghostty](https://ghostty.org/)
- Zig-based terminal emulator
- uses platform-native UI & GPU acceleration (e.g. MacOS secure input for passwords)
Shell: [fish](https://fishshell.com/)
- completions
- sane scripting
- user friendly
Text Editor: [neovim](https://neovim.io/)
- vi-movements
- great community, loads of completions
Secrets Management: [sops-nix](https://github.com/Mic92/sops-nix)
- easy to manage AGE keys
- easy to integrate into my git repository (RIP me when we get quantum chips breaking encryption)
Code Completion & Language Servers: Neovim LSP & [Mason](https://github.com/williamboman/mason.nvim)
Colorscheme: [Rosé Pine](https://rosepinetheme.com/)
## Rice 米
![rice dark](./assets/rice.png)
![rice light](./assets/rice_light.png)

BIN
assets/rice.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
assets/rice_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

2223
flake.lock generated Normal file

File diff suppressed because it is too large Load diff

152
flake.nix Normal file
View file

@ -0,0 +1,152 @@
{
description = "Framework 13 NixOS Configuration";
outputs = inputs:
inputs.flake-parts.lib.mkFlake {inherit inputs;} {
systems = ["x86_64-linux"];
perSystem = { pkgs, system, ... }: {
_module.args.pkgs = import inputs.nixpkgs {
inherit system;
overlays = [ ];
config = { };
};
# Boox eReader and Android device management shell
devShells.default = import ./shell.nix {
inherit pkgs inputs;
};
};
imports = [
./hosts
];
};
inputs = {
devenv = {
url = "github:cachix/devenv";
inputs.nixpkgs.follows = "nixpkgs";
};
# Declarative disk partitioning (for phantom USB)
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
elephant.url = "github:abenz1267/elephant";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager/release-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};
# Home Manager for Steam Deck (follows unstable for Jovian-NixOS compatibility)
home-manager-unstable = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs-unstable";
};
hypridle = {
url = "github:hyprwm/hypridle";
inputs = {
hyprlang.follows = "hyprland/hyprlang";
hyprutils.follows = "hyprland/hyprutils";
nixpkgs.follows = "hyprland/nixpkgs";
systems.follows = "hyprland/systems";
};
};
# Hyprland
hyprland.url = "github:hyprwm/hyprland";
hyprland-contrib = {
url = "github:hyprwm/contrib";
inputs.nixpkgs.follows = "hyprland/nixpkgs";
};
hyprlock = {
url = "github:hyprwm/hyprlock";
inputs = {
hyprgraphics.follows = "hyprland/hyprgraphics";
hyprlang.follows = "hyprland/hyprlang";
hyprutils.follows = "hyprland/hyprutils";
nixpkgs.follows = "hyprland/nixpkgs";
systems.follows = "hyprland/systems";
};
};
hyprpaper = {
url = "github:hyprwm/hyprpaper";
inputs = {
hyprgraphics.follows = "hyprland/hyprgraphics";
hyprlang.follows = "hyprland/hyprlang";
hyprutils.follows = "hyprland/hyprutils";
nixpkgs.follows = "hyprland/nixpkgs";
systems.follows = "hyprland/systems";
};
};
# Ephemeral root with selective persistence (for phantom USB)
impermanence.url = "github:nix-community/impermanence";
# Steam Deck support via Jovian-NixOS
# Note: Don't override nixpkgs to ensure cache hits for pre-built binaries
jovian-nixos.url = "github:Jovian-Experiments/Jovian-NixOS";
mcp-servers-nix = {
url = "github:natsukium/mcp-servers-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# Minecraft declarative mod management
nix-minecraft = {
url = "github:Infinidoge/nix-minecraft";
inputs.nixpkgs.follows = "nixpkgs-unstable";
};
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
nur = {
url = "github:nix-community/NUR";
inputs.nixpkgs.follows = "nixpkgs";
};
# Path to your local nvim configuration folder
nvchad-config = {
url = "path:./home/editors/nvim/nvchad";
flake = false;
};
nix4nvchad = {
url = "github:nix-community/nix4nvchad";
inputs.nixpkgs.follows = "nixpkgs";
inputs.nvchad-starter.follows = "nvchad-config"; # Overrides the starter
};
openapi-tui.url = "github:zaghaghi/openapi-tui";
secrets.url = "git+ssh://forgejo@git.oolongtechnologies.io/bogdan/secrets.git";
# Secrets management
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
stylix = {
url = "github:nix-community/stylix/release-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};
walker = {
url = "github:abenz1267/walker";
inputs.elephant.follows = "elephant";
};
yazi.url = "github:sxyazi/yazi"; };
}

29
home/default.nix Normal file
View file

@ -0,0 +1,29 @@
{ config, pkgs, ... }:
{
imports = [
./terminal
./terminal/emulators/ghostty.nix
];
home = {
username = "bogdan";
homeDirectory = "/home/bogdan";
stateVersion = "25.11";
pointerCursor = {
name = "BreezeX-RosePine-Linux";
package = pkgs.rose-pine-cursor;
size = 24;
gtk.enable = true;
};
};
# Symlink hyprcursor theme into ~/.local/share/icons/ so hyprcursor
# can discover it even when XDG_DATA_DIRS is empty (UWSM sessions).
xdg.dataFile."icons/rose-pine-hyprcursor".source =
"${pkgs.rose-pine-hyprcursor}/share/icons/rose-pine-hyprcursor";
# Let Home Manager install and manage itself
programs.home-manager.enable = true;
}

View file

@ -0,0 +1,66 @@
{ config, pkgs, ... }:
{
# vimAlias replacement: nix4nvchad only installs `nvim`, so map `vim` → nvim.
home.shellAliases.vim = "nvim";
programs.nvchad = {
enable = true;
# Pin plugin commits reproducibly. Without this, nix4nvchad installs an
# EMPTY lazy-lock.json into the store config, which nvchad.sh's cleanup_lock
# then deletes at launch — so lazy.nvim resolves plugin HEADs freshly on
# every machine. Feeding the committed lockfile here lands it in
# ~/.config/nvim/lazy-lock.json so `:Lazy restore` pins to known-good commits.
# Refresh it with: cp ~/.local/share/nvim/lazy-lock.json ./nvchad/lazy-lock.json
lazy-lock = builtins.readFile ./nvchad/lazy-lock.json;
# image.nvim uses processor = "magick_rock", which require()s the `magick`
# LuaRock. nix4nvchad has no --cmd/initLua hook, but image.nvim is
# ft = { "markdown" } (lazy), so injecting package.path here in extraConfig
# (appended to init.lua at startup) runs before any markdown buffer opens.
# The hard-coded store path makes this independent of neovim's LUA_PATH
# wrapper, which was unreliable on 0.12.
extraConfig = ''
package.path = package.path
.. ";${pkgs.luajitPackages.magick}/share/lua/5.1/?.lua"
.. ";${pkgs.luajitPackages.magick}/share/lua/5.1/?/init.lua"
'';
extraPackages = with pkgs; [
clang
# formatters
gofumpt
goimports-reviser
golines
# gopls
gopls
# tools
gh # GitHub CLI — octo.nvim's API backend (token → OS keyring)
go
# image.nvim — inline images in markdown via Ghostty's kitty graphics
imagemagick
# test runner
python3Packages.pytest
# nix
nixfmt # was nixfmt-rfc-style; aliased to pkgs.nixfmt in 26.05
nil
# lua (lua_ls is enabled by NvChad defaults)
lua-language-server
stylua
# ts
prettierd
# html template formatters
djlint
];
};
}

View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View file

@ -0,0 +1,43 @@
vim.g.base46_cache = vim.fn.stdpath("data") .. "/base46/"
vim.g.mapleader = "\\"
-- Distinct localleader for octo.nvim's <localleader>-prefixed review keys.
-- Must be set before lazy.setup() so plugin keymaps register against it.
vim.g.maplocalleader = ","
-- bootstrap lazy and all plugins
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.uv.fs_stat(lazypath) then
local repo = "https://github.com/folke/lazy.nvim.git"
vim.fn.system({ "git", "clone", "--filter=blob:none", repo, "--branch=stable", lazypath })
end
vim.opt.rtp:prepend(lazypath)
local lazy_config = require("configs.lazy")
vim.opt.relativenumber = true
-- load plugins
require("lazy").setup({
{
"NvChad/NvChad",
lazy = false,
branch = "v2.5",
import = "nvchad.plugins",
},
{ import = "plugins" },
}, lazy_config)
-- load theme (recompile to pick up external theme changes from theme-switch)
require("base46").compile()
dofile(vim.g.base46_cache .. "defaults")
dofile(vim.g.base46_cache .. "statusline")
require("options")
require("nvchad.autocmds")
vim.schedule(function()
require("mappings")
end)

View file

@ -0,0 +1,50 @@
{
"FixCursorHold.nvim": { "branch": "master", "commit": "1900f89dc17c603eec29960f57c00bd9ae696495" },
"NvChad": { "branch": "v2.5", "commit": "d042cc975247c2aa55fcb228e5d146dc1dc6c648" },
"base46": { "branch": "v3.0", "commit": "884b990dcdbe07520a0892da6ba3e8d202b46337" },
"blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" },
"conform.nvim": { "branch": "master", "commit": "619363c30309d29ffa631e67c8183f2a72caa373" },
"edgy.nvim": { "branch": "main", "commit": "8bfd2808994a988c975694122f68624b8a219f5f" },
"friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" },
"gitsigns.nvim": { "branch": "main", "commit": "2038c666bd9d8a0b7349a0b6ee00dc83104b9ecf" },
"hardtime.nvim": { "branch": "main", "commit": "b4e431934af1fe224a3a801f632c008278cb7628" },
"harpoon": { "branch": "harpoon2", "commit": "87b1a3506211538f460786c23f98ec63ad9af4e5" },
"image.nvim": { "branch": "master", "commit": "88351f1f7d9dbae286e671ce3690a49660dd8a5c" },
"indent-blankline.nvim": { "branch": "master", "commit": "d28a3f70721c79e3c5f6693057ae929f3d9c0a03" },
"jj.nvim": { "branch": "main", "commit": "cffc74128a80398469085e01ff2e4001e330b800" },
"lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
"mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" },
"menu": { "branch": "main", "commit": "7a0a4a2896b715c066cfbe320bdc048091874cc6" },
"minty": { "branch": "main", "commit": "aafc9e8e0afe6bf57580858a2849578d8d8db9e0" },
"neotest": { "branch": "master", "commit": "ad991822b7076b1d940b33a9d6d0d30416d5df81" },
"neotest-go": { "branch": "main", "commit": "59b50505053f9c45a9febb79e11a56206c3e3901" },
"neotest-python": { "branch": "master", "commit": "e6df4f1892f6137f58135917db24d1655937d831" },
"neotest-vitest": { "branch": "main", "commit": "2f19d215d7d4d7f121826f99f6f35a81e2c18a6f" },
"noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" },
"nui.nvim": { "branch": "main", "commit": "de740991c12411b663994b2860f1a4fd0937c130" },
"nvim-cmp": { "branch": "main", "commit": "a1d504892f2bc56c2e79b65c6faded2fd21f3eca" },
"nvim-lightbulb": { "branch": "master", "commit": "b8c08c5f3b1586dfcdd9f34d7d54fe6982e01ac9" },
"nvim-lspconfig": { "branch": "master", "commit": "bfcc0171a43f22afa61d927ffe9fcb6cb85dc99e" },
"nvim-nio": { "branch": "master", "commit": "21f5324bfac14e22ba26553caf69ec76ae8a7662" },
"nvim-notify": { "branch": "master", "commit": "8701bece920b38ea289b457f902e2ad184131a5d" },
"nvim-tree.lua": { "branch": "master", "commit": "8e8be709c9a93e1ebefde7964884a8b564bfd3e0" },
"nvim-treesitter": { "branch": "main", "commit": "4916d6592ede8c07973490d9322f187e07dfefac" },
"nvim-treesitter-textobjects": { "branch": "main", "commit": "851e865342e5a4cb1ae23d31caf6e991e1c99f1e" },
"nvim-web-devicons": { "branch": "master", "commit": "dfbfaa967a6f7ec50789bead7ef87e336c1fa63c" },
"plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" },
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" },
"telescope-fzf-native.nvim": { "branch": "main", "commit": "b25b749b9db64d375d782094e2b9dce53ad53a40" },
"telescope-undo.nvim": { "branch": "main", "commit": "928d0c2dc9606e01e2cc547196f48d2eaecf58e5" },
"telescope.nvim": { "branch": "master", "commit": "427b576c16792edad01a92b89721d923c19ad60f" },
"trouble.nvim": { "branch": "main", "commit": "bd67efe408d4816e25e8491cc5ad4088e708a69a" },
"twilight.nvim": { "branch": "main", "commit": "664e752f4a219801265cc3fc18782b457b58c1e1" },
"ui": { "branch": "v3.0", "commit": "3e67e9d5325fd47fdbc90ca00a147db2f3525754" },
"vim-fugitive": { "branch": "master", "commit": "3b753cf8c6a4dcde6edee8827d464ba9b8c4a6f0" },
"vim-hugo": { "branch": "master", "commit": "324fb8c7371d31701349c1192e25a0bdcf9898f8" },
"vim-rhubarb": { "branch": "master", "commit": "5496d7c94581c4c9ad7430357449bb57fc59f501" },
"vim-xcode": { "branch": "main", "commit": "24dc91d2f0e4736231011231d93855ca28c3f921" },
"volt": { "branch": "main", "commit": "620de1321f275ec9d80028c68d1b88b409c0c8b1" },
"which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" },
"yaml.nvim": { "branch": "main", "commit": "43ddedd0f3b060b4475e19e762b1796fce1b23be" },
"zen-mode.nvim": { "branch": "main", "commit": "8564ce6d29ec7554eb9df578efa882d33b3c23a7" }
}

View file

@ -0,0 +1,42 @@
-- This file needs to have same structure as nvconfig.lua
-- https://github.com/NvChad/ui/blob/v3.0/lua/nvconfig.lua
-- Please read that file to know all available options :(
-- Read theme from file (set by theme-switch script)
local function get_theme()
local f = io.open(os.getenv("HOME") .. "/.config/theme/nvim-theme", "r")
if f then
local name = f:read("*l")
f:close()
if name and name ~= "" then return name end
end
return "catppuccin"
end
local function get_background()
local f = io.open(os.getenv("HOME") .. "/.config/theme/nvim-bg", "r")
if f then
local bg = f:read("*l")
f:close()
if bg and bg ~= "" then
vim.o.background = bg
end
end
end
get_background()
---@type ChadrcConfig
local M = {}
M.base46 = {
theme = get_theme(),
transparency = true,
hl_override = {
Comment = { italic = true },
["@comment"] = { italic = true },
},
}
return M

View file

@ -0,0 +1,27 @@
return {
enabled = function() return not vim.tbl_contains({ "typr" }, vim.bo.filetype)
and vim.bo.buftype ~= "prompt"
and vim.b.completion ~= false
end,
keymap = {
["<C-n>"] = { "show", "hide" },
["<C-p>"] = { "show_documentation", "hide_documentation" },
["<S-Tab>"] = { "select_prev", "fallback" },
["<Tab>"] = { "select_next", "fallback" },
["<CR>"] = { "select_and_accept", "fallback" },
},
completion = {
menu = {
draw = {
treesitter = { "lsp" }
}
},
ghost_text = {
enabled = true,
},
},
sources = {
default = { "lsp", "path", "snippets", "buffer" },
}
}

View file

@ -0,0 +1,37 @@
local options = {
formatters_by_ft = {
lua = { "stylua" },
css = { "prettier" },
html = { "prettier" },
htmlhugo = { "djlint" },
python = { "ruff_format", "ruff_fix", "ruff_organize_imports" },
tf = { "tflint", "terraform-ls" },
go = { "goimports", "gofmt" },
json = { "jq" },
templ = { "templ" },
javascript = { "prettierd" },
typescript = { "prettierd" },
typescriptReact = { "prettierd" },
yaml = { "gh-actions-language-server" },
zig = { "zls" },
},
formatters = {
djlint = {
prepend_args = { "--profile", "golang", "--indent", "2", "--preserve-blank-lines" },
},
},
format_on_save = {
-- These options will be passed to conform.format()
timeout_ms = 500,
lsp_format = "fallback",
},
-- Set the log level. Use `:ConformInfo` to see the location of the log file.
log_level = vim.log.levels.ERROR,
-- Conform will notify you when a formatter errors
notify_on_error = true,
-- Conform will notify you when no formatters are available for the buffer
notify_no_formatters = true,
}
return options

View file

@ -0,0 +1,68 @@
-- diffview.nvim — whole-branch / PR diff review (file-tree panel + per-file diff).
-- Keymaps live in the plugin spec (lua/plugins/init.lua) under the <leader>r group.
--
-- Navigation is all builtin: ]c/[c walk hunks in a file (native diff motions,
-- restored buffer-locally in mappings.lua), <Tab>/<S-Tab> next/prev file,
-- <leader>e focus the file panel, gf open the file for real edits.
-- Hunk-level stage/reset via gitsigns, which lives on the RIGHT pane because
-- --imply-local makes it the live working-tree buffer. The left pane is a git
-- blob with no gitsigns state — guard instead of erroring there.
-- Note: gitsigns hunks are worktree-vs-index, matching the <leader>rw review;
-- in a base...HEAD review already-committed hunks have nothing to stage.
local function gs_hunk(fn_name, visual)
return function()
if not vim.b.gitsigns_status_dict then
vim.notify("No gitsigns here — stage/reset from the local (right) pane", vim.log.levels.WARN)
return
end
local gs = require("gitsigns")
if visual then
gs[fn_name]({ vim.fn.line("."), vim.fn.line("v") })
else
gs[fn_name]()
end
end
end
return {
enhanced_diff_hl = true,
-- --imply-local makes the RIGHT diff pane the live working-tree buffer, so
-- LSP, gd, formatting — and gitsigns staging — work inside the review.
-- Baked into the default so PR-range opens always get it.
default_args = {
DiffviewOpen = { "--imply-local" },
},
-- Tree listing reads best for big multi-file PRs.
file_panel = {
listing_style = "tree",
},
hooks = {
-- gitsigns is lazy (NvChad: User FilePost) and attaches from a BufRead
-- autocmd; diffview loads the local buffer in a hidden temp window, so
-- when gitsigns isn't loaded yet that autocmd never ran for this buffer
-- and hunk stage/reset silently has no state. Force load + attach here;
-- attach is a no-op when already attached and rejects non-file buffers
-- (left-pane blobs), so this is safe to fire for every diff buffer.
diff_buf_read = function(bufnr)
pcall(function()
require("gitsigns").attach(bufnr)
end)
end,
},
keymaps = {
view = {
-- `-` mirrors the file panel's `-` (stage entry) at hunk granularity;
-- whole-file staging stays on the file panel's `-`.
{ "n", "-", gs_hunk("stage_hunk"), { desc = "Stage/unstage hunk" } },
{ "v", "-", gs_hunk("stage_hunk", true), { desc = "Stage selected lines" } },
-- `X` mirrors the file panel's `X` (restore entry) at hunk granularity.
{ "n", "X", gs_hunk("reset_hunk"), { desc = "Reset hunk" } },
{ "v", "X", gs_hunk("reset_hunk", true), { desc = "Reset selected lines" } },
},
},
}

View file

@ -0,0 +1,62 @@
-- edgy.nvim — three-rail layout contract:
-- left = project navigation (file tree, diffview panels, git status)
-- bottom = transient output (terminal, quickfix, diagnostics, test output)
-- right = code context while reading (symbols outline, LSP refs/defs, test summary)
--
-- Trouble placement: trouble's mode config (plugins/init.lua) is the single
-- source of truth for which edge a mode opens at; the filters below make edgy
-- adopt each trouble window at that edge. A bare ft=trouble entry instead
-- RELOCATES every trouble window to one hardcoded rail while trouble itself
-- keeps opening at its configured position — that tug-of-war is what made
-- references land on different rails depending on which edgebars were open.
local function trouble_at(pos)
return {
ft = "trouble",
filter = function(_, win)
local t = vim.w[win].trouble
return t ~= nil and t.position == pos and t.type == "split" and t.relative == "editor"
end,
}
end
-- Edgy should only manage real splits; floats (NvTerm_float, pickers) pass through.
local function is_split(_, win)
return vim.api.nvim_win_get_config(win).relative == ""
end
return {
animate = { enabled = false },
exit_when_last = false,
left = {
-- height 0.5 keeps the tree at half the rail when a diffview/fugitive
-- panel stacks below it during review
{ ft = "NvimTree", size = { height = 0.5 } },
{ ft = "DiffviewFiles", title = "Diff Files" },
{ ft = "DiffviewFileHistory", title = "Diff History" },
{ ft = "fugitive" },
{ ft = "gitcommit" },
},
bottom = {
-- NvChad's term module tags horizontal terminals NvTerm_sp (there is no
-- toggleterm in this config); vsp/float terminals are left alone
{ ft = "NvTerm_sp", title = "Terminal" },
trouble_at("bottom"), -- diagnostics / qflist / loclist modes
{ ft = "qf", title = "Quickfix" },
{ ft = "neotest-output-panel", title = "Test Output" },
-- noice's long-message splits (presets.long_message_to_split)
{ ft = "noice", filter = is_split },
},
right = {
trouble_at("right"), -- symbols outline, lsp defs/refs panels
{ ft = "neotest-summary", title = "Tests" },
},
options = {
left = { size = 35 },
bottom = { size = 0.3 },
right = { size = 0.3 },
},
}

View file

@ -0,0 +1,99 @@
return {
signs = {
add = { text = "" },
change = { text = "" },
delete = { text = "" },
topdelete = { text = "" },
changedelete = { text = "~" },
untracked = { text = "" },
},
signcolumn = true,
-- Open hunk qflist in the native quickfix (ft=qf → edgy bottom panel),
-- not Trouble. Gitsigns auto-enables this whenever trouble.nvim is installed
-- (defaults to pcall(require,'trouble')), which routed setqflist to the
-- right-side Trouble panel instead of the bottom qf rule in edgy.lua.
trouble = false,
numhl = true,
linehl = false,
word_diff = false,
watch_gitdir = {
follow_files = true,
},
auto_attach = true,
attach_to_untracked = true,
current_line_blame = true,
current_line_blame_opts = {
virt_text = true,
virt_text_pos = "eol",
delay = 1000,
ignore_whitespace = false,
virt_text_priority = 100,
},
current_line_blame_formatter = "<author>, <author_time:%Y-%m-%d> - <summary>",
sign_priority = 6,
update_debounce = 100,
status_formatter = nil,
max_file_length = 40000,
preview_config = {
border = "rounded",
style = "minimal",
relative = "cursor",
row = 0,
col = 1,
},
on_attach = function(bufnr)
local gs = package.loaded.gitsigns
local function map(mode, l, r, opts)
opts = opts or {}
opts.buffer = bufnr
vim.keymap.set(mode, l, r, opts)
end
-- Navigation (use ]g/[g to avoid conflict with treesitter ]c/[c).
-- In diff windows gitsigns hunks (vs index) don't match what the diff
-- shows, so delegate to the native ]c/[c change motion there. (]g has no
-- native meaning — the old `normal! ]g` was a no-op.)
map("n", "]g", function()
if vim.wo.diff then
vim.cmd.normal({ "]c", bang = true })
else
gs.nav_hunk("next")
end
end, { desc = "Next hunk" })
map("n", "[g", function()
if vim.wo.diff then
vim.cmd.normal({ "[c", bang = true })
else
gs.nav_hunk("prev")
end
end, { desc = "Previous hunk" })
-- Jump to first/last hunk in buffer (review sweeps)
map("n", "]G", function() gs.nav_hunk("last") end, { desc = "Last hunk" })
map("n", "[G", function() gs.nav_hunk("first") end, { desc = "First hunk" })
-- Actions
map("n", "<leader>gb", gs.blame, { desc = "Blame" })
map("n", "<leader>hs", gs.stage_hunk, { desc = "Stage hunk" })
map("n", "<leader>hr", gs.reset_hunk, { desc = "Reset hunk" })
map("v", "<leader>hs", function() gs.stage_hunk({ vim.fn.line("."), vim.fn.line("v") }) end, { desc = "Stage selected hunk" })
map("v", "<leader>hr", function() gs.reset_hunk({ vim.fn.line("."), vim.fn.line("v") }) end, { desc = "Reset selected hunk" })
map("n", "<leader>hS", gs.stage_buffer, { desc = "Stage buffer" })
map("n", "<leader>hR", gs.reset_buffer, { desc = "Reset buffer" })
map("n", "<leader>hp", gs.preview_hunk, { desc = "Preview hunk" })
map("n", "<leader>hi", gs.preview_hunk_inline, { desc = "Preview hunk inline" })
map("n", "<leader>hb", function() gs.blame_line({ full = true }) end, { desc = "Blame line" })
map("n", "<leader>tb", gs.toggle_current_line_blame, { desc = "Toggle line blame" })
map("n", "<leader>tw", gs.toggle_word_diff, { desc = "Toggle word diff" })
map("n", "<leader>hd", gs.diffthis, { desc = "Diff this" })
map("n", "<leader>hD", function() gs.diffthis("~") end, { desc = "Diff against HEAD~" })
map("n", "<leader>hQ", function() gs.setqflist("all") end, { desc = "Hunks as qflist (all)" })
map("n", "<leader>hq", gs.setqflist, { desc = "Hunks as qflist (buffer)" })
map("n", "<leader>hu", gs.toggle_deleted, { desc = "Toggle deleted" })
-- Text object
map({ "o", "x" }, "ih", gs.select_hunk, { desc = "Select hunk inner" })
end,
}

View file

@ -0,0 +1,57 @@
return {
defaults = { lazy = true },
install = { colorscheme = { "nvchad" } },
-- Read the lockfile nix4nvchad places in the config dir (= stdpath("config")),
-- so the Nix-pinned commits are what lazy.nvim restores. The old stdpath("data")
-- path was a workaround for the readonly /nix/store symlink era; under nvchad the
-- config dir is a writable copy, so point back here.
lockfile = vim.fn.stdpath("config") .. "/lazy-lock.json",
-- NixOS can't run lazy's hererocks/luarocks installer (sandboxed store);
-- no plugins here need it, so disable to silence the healthcheck error.
rocks = { enabled = false },
ui = {
icons = {
ft = "",
lazy = "󰂠 ",
loaded = "",
not_loaded = "",
},
},
performance = {
rtp = {
disabled_plugins = {
"2html_plugin",
"tohtml",
"getscript",
"getscriptPlugin",
"gzip",
"logipat",
"netrw",
"netrwPlugin",
"netrwSettings",
"netrwFileHandlers",
"matchit",
"tar",
"tarPlugin",
"rrhelper",
"spellfile_plugin",
"vimball",
"vimballPlugin",
"zip",
"zipPlugin",
"tutor",
"rplugin",
"syntax",
"synmenu",
"optwin",
"compiler",
"bugreport",
"ftplugin",
},
},
},
}

View file

@ -0,0 +1,30 @@
return {
sign = {
enabled = true,
text = "󰌶",
texthl = "DiagnosticSignHint",
linehl = "",
numhl = "",
priority = 10,
},
float = {
enabled = false,
},
virtual_text = {
enabled = false,
},
status_text = {
enabled = false,
},
autocmd = {
enabled = true,
updatetime = 100,
events = { "CursorHold", "CursorHoldI", "InsertLeave" },
pattern = { "*" },
},
ignore = {
clients = {},
ft = {},
actions = {},
},
}

View file

@ -0,0 +1,74 @@
local configs = require("nvchad.configs.lspconfig")
configs.defaults()
-- Noice has lsp.signature.enabled = false, so signature_help uses the default
-- vim.lsp handler. focusable = false is REQUIRED: without it the cursor jumps
-- into the floating signature window on trigger. The :checkhealth noice warning
-- "signature_help is not configured to be handled by Noice" is expected here.
-- vim.lsp.with() was removed in nvim 0.12; wrap the handler manually (same effect).
vim.lsp.handlers["textDocument/signatureHelp"] = function(err, result, ctx, config)
return vim.lsp.handlers.signature_help(err, result, ctx,
vim.tbl_deep_extend("force", { focusable = false, silent = true }, config or {}))
end
local servers = {
html = {},
cssls = {},
gopls = {
analyses = {
unusedparams = true,
},
staticcheck = true,
gofumpt = true,
},
ruff = {
lint = {
run = "onSave",
},
},
zls = {},
terraformls = {},
templ = {},
gh_actions_ls = {},
pyright = {
settings = {
python = {
analysis = {
autoSearchPaths = true,
typeCheckingMode = "basic",
},
pythonPath = vim.fn.exepath("python3"),
},
},
},
ts_ls = {
settings = {
completions = {
completeFunctionCalls = true,
},
},
},
}
local on_attach = function(client, bufnr)
configs.on_attach(client, bufnr)
client.server_capabilities.documentFormattingProvider = true
-- Use Trouble for references instead of Telescope (persistent split for browsing)
vim.keymap.set("n", "gr", "<cmd>Trouble lsp_references toggle<cr>",
{ buffer = bufnr, desc = "References (Trouble)" })
end
for name, opts in pairs(servers) do
local ok = pcall(function()
opts.on_init = configs.on_init
opts.on_attach = on_attach
opts.capabilities = configs.capabilities
vim.lsp.config(name, opts)
vim.lsp.enable({name})
end)
if not ok then
vim.notify("Failed to set up LSP: " .. name, vim.log.levels.ERROR)
end
end

View file

@ -0,0 +1,27 @@
return function()
local neotest_ns = vim.api.nvim_create_namespace("neotest")
vim.diagnostic.config({
virtual_text = {
format = function(diagnostic)
local message = diagnostic.message:gsub("\n", " "):gsub("\t", " "):gsub("%s+", " "):gsub("^%s+", "")
return message
end,
},
}, neotest_ns)
require("neotest").setup({
adapters = {
require("neotest-go"),
require("neotest-python")({
dap = { justMyCode = false },
args = { "-s", "-v", "--tb=short" },
}),
require("neotest-vitest"),
},
-- summary opens with neotest's default vsplit; edgy adopts it into the
-- right rail by ft (the old "botright split" override opened it bottom
-- full-width first, then edgy yanked it right — needless flicker).
-- Keymaps live in the plugin spec (lua/plugins/init.lua) so they can
-- lazy-load the plugin.
})
end

View file

@ -0,0 +1,19 @@
-- octo.nvim — in-editor GitHub PR lifecycle (browse, comment, review, approve,
-- merge). Requires `gh` on PATH (added in default.nix) and `gh auth login` once.
--
-- Entry points live on <leader>r in the plugin spec. Inside octo buffers the
-- builtin <localleader>-maps (localleader = "," set in init.lua) cover the rest:
--
-- PR buffer: ,po checkout ,pm merge ,psm squash-merge
-- ,prm rebase-merge ,ca add comment <C-b> browser
-- Review diff: ]q/[q next/prev file ]t/[t next/prev thread
-- ,ca comment ,sa suggestion ,rt resolve thread
-- ,vs submit review (then <C-a> approve / <C-m> comment /
-- <C-r> request changes in the submit window)
--
-- `enable_builtin` gives the bare :Octo command a picker over all subcommands —
-- the discoverable fallback for anything unbound.
return {
picker = "telescope", -- reuse the existing telescope + fzf-native install
enable_builtin = true,
}

View file

@ -0,0 +1,220 @@
local ts = require("telescope")
local actions = require("telescope.actions")
local layout_actions = require("telescope.actions.layout")
local undo_actions = require("telescope-undo.actions")
local h_pct = 0.90
local w_pct = 0.80
local w_limit = 100
-- Compact: vertical, preview hidden — for quick picks (files, buffers, branches)
local compact = {
preview = { hide_on_startup = true },
layout_strategy = "vertical",
layout_config = {
vertical = {
mirror = true,
prompt_position = "top",
width = function(_, cols, _)
return math.min(math.floor(w_pct * cols), w_limit)
end,
height = function(_, _, rows)
return math.floor(rows * h_pct)
end,
preview_cutoff = 10,
preview_height = 0.4,
},
},
}
-- Fullscreen: flex (adapts to terminal width), preview always visible — for grep, LSP, undo
local fullscreen = {
preview = { hide_on_startup = false },
layout_strategy = "flex",
layout_config = {
flex = { flip_columns = 120 },
horizontal = {
mirror = false,
prompt_position = "top",
width = function(_, cols, _)
return math.floor(cols * w_pct)
end,
height = function(_, _, rows)
return math.floor(rows * h_pct)
end,
preview_cutoff = 10,
preview_width = 0.5,
},
vertical = {
mirror = true,
prompt_position = "top",
width = function(_, cols, _)
return math.floor(cols * w_pct)
end,
height = function(_, _, rows)
return math.floor(rows * h_pct)
end,
preview_cutoff = 10,
preview_height = 0.5,
},
},
}
ts.setup({
defaults = vim.tbl_extend("error", compact, {
sorting_strategy = "ascending",
path_display = { "filename_first" },
file_ignore_patterns = { "node_modules", ".venv/", ".git/", "%.lock", "CLAUDE.md", "%AGENTS.md" },
mappings = {
i = {
["<C-S>"] = actions.to_fuzzy_refine,
["<C-o>"] = layout_actions.toggle_preview,
},
n = {
["o"] = layout_actions.toggle_preview,
["<C-c>"] = actions.close,
},
},
}),
pickers = {
find_files = vim.tbl_extend("force", compact, {
find_command = { "fd", "--type", "f", "-H", "--strip-cwd-prefix" },
}),
live_grep = fullscreen,
grep_string = fullscreen,
lsp_dynamic_workspace_symbols = fullscreen,
help_tags = fullscreen,
buffers = {
sort_lastused = true,
mappings = {
n = { ["dd"] = actions.delete_buffer },
},
},
git_branches = compact,
-- Big preview for reviewing changed files, but start hidden ('o' toggles it)
git_status = vim.tbl_extend("force", fullscreen, {
preview = { hide_on_startup = true },
}),
},
extensions = {
undo = vim.tbl_extend("force", fullscreen, {
vim_diff_opts = { ctxlen = 4 },
mappings = {
i = {
["<cr>"] = undo_actions.restore,
["<C-y>d"] = undo_actions.yank_deletions,
["<C-y>a"] = undo_actions.yank_additions,
},
n = {
["<cr>"] = undo_actions.restore,
["ya"] = undo_actions.yank_additions,
["yd"] = undo_actions.yank_deletions,
},
},
}),
},
})
ts.load_extension("fzf")
ts.load_extension("undo")
-- ── Cross-file hunk picker ────────────────────────────────────────────────
-- Flat, fuzzy-searchable list of every hunk dirty vs a base (default HEAD),
-- with a diff previewer. Built by parsing `git diff` directly rather than
-- gitsigns.get_hunks(), so it never races the async buffer attach and works
-- on files that aren't open yet.
local function parse_git_hunks(base)
base = base or "HEAD"
-- List form → no shell, so fish quoting can't interfere.
local lines = vim.fn.systemlist({ "git", "--no-pager", "diff", "--no-color", "-U3", base })
if vim.v.shell_error ~= 0 then
return {}
end
local results, cur_file, cur = {}, nil, nil
local function flush()
if cur then
table.insert(results, cur)
cur = nil
end
end
for _, line in ipairs(lines) do
if line:sub(1, 11) == "diff --git " then
flush() -- new file boundary: close out the previous file's last hunk
elseif line:sub(1, 6) == "+++ b/" then
local p = line:sub(7)
if p ~= "/dev/null" then
cur_file = p
end
elseif line:sub(1, 2) == "@@" then
flush()
local nstart = tonumber(line:match("^@@ %-%d+,?%d* %+(%d+)"))
cur = {
file = cur_file,
lnum = nstart or 1,
section = line:match("@@.-@@%s?(.*)") or "",
lines = { line },
adds = 0,
dels = 0,
}
elseif cur then
local c = line:sub(1, 1)
if c == "+" then
cur.adds = cur.adds + 1
elseif c == "-" then
cur.dels = cur.dels + 1
end
if c ~= "\\" then -- skip "\ No newline at end of file"
table.insert(cur.lines, line)
end
end
end
flush()
return results
end
local function hunks_picker(opts)
opts = opts or {}
local pickers = require("telescope.pickers")
local finders = require("telescope.finders")
local previewers = require("telescope.previewers")
local conf = require("telescope.config").values
local hunks = parse_git_hunks(opts.base)
if vim.tbl_isempty(hunks) then
vim.notify("No dirty hunks vs " .. (opts.base or "HEAD"), vim.log.levels.INFO)
return
end
pickers
.new(fullscreen, {
prompt_title = "Git Hunks (" .. (opts.base or "HEAD") .. ")",
finder = finders.new_table({
results = hunks,
entry_maker = function(e)
return {
value = e,
-- filename + lnum make the default select action jump straight to the hunk
filename = e.file,
lnum = e.lnum,
ordinal = e.file .. " " .. e.section,
display = string.format("%s:%d +%d -%d %s", e.file, e.lnum, e.adds, e.dels, e.section),
}
end,
}),
sorter = conf.generic_sorter({}),
previewer = previewers.new_buffer_previewer({
title = "Hunk Diff",
define_preview = function(self, entry)
vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, entry.value.lines)
vim.bo[self.state.bufnr].filetype = "diff"
end,
}),
})
:find()
end
return {
hunks_picker = hunks_picker,
}

View file

@ -0,0 +1,37 @@
local select = require("nvim-treesitter-textobjects.select").select_textobject
local move = require("nvim-treesitter-textobjects.move")
local swap = require("nvim-treesitter-textobjects.swap")
require("nvim-treesitter-textobjects").setup({
select = {
lookahead = true,
selection_modes = {
["@parameter.outer"] = "v",
["@function.outer"] = "V",
["@class.outer"] = "V",
},
include_surrounding_whitespace = false,
},
})
-- Select keymaps (visual + operator-pending)
local so = { "x", "o" }
vim.keymap.set(so, "af", function() select("@function.outer", "textobjects") end, { desc = "Around function" })
vim.keymap.set(so, "if", function() select("@function.inner", "textobjects") end, { desc = "Inside function" })
vim.keymap.set(so, "ac", function() select("@class.outer", "textobjects") end, { desc = "Around class" })
vim.keymap.set(so, "ic", function() select("@class.inner", "textobjects") end, { desc = "Inside class" })
vim.keymap.set(so, "aa", function() select("@parameter.outer", "textobjects") end, { desc = "Around parameter" })
vim.keymap.set(so, "ia", function() select("@parameter.inner", "textobjects") end, { desc = "Inside parameter" })
-- Move keymaps (normal + visual + operator-pending)
local nso = { "n", "x", "o" }
vim.keymap.set(nso, "]f", function() move.goto_next_start("@function.outer", "textobjects") end, { desc = "Next function" })
vim.keymap.set(nso, "[f", function() move.goto_previous_start("@function.outer", "textobjects") end, { desc = "Prev function" })
vim.keymap.set(nso, "]c", function() move.goto_next_start("@class.outer", "textobjects") end, { desc = "Next class" })
vim.keymap.set(nso, "[c", function() move.goto_previous_start("@class.outer", "textobjects") end, { desc = "Prev class" })
vim.keymap.set(nso, "]a", function() move.goto_next_start("@parameter.inner", "textobjects") end, { desc = "Next parameter" })
vim.keymap.set(nso, "[a", function() move.goto_previous_start("@parameter.inner", "textobjects") end, { desc = "Prev parameter" })
-- Swap keymaps (normal only)
vim.keymap.set("n", "<leader>sa", function() swap.swap_next("@parameter.inner") end, { desc = "Swap parameter forward" })
vim.keymap.set("n", "<leader>sA", function() swap.swap_previous("@parameter.inner") end, { desc = "Swap parameter backward" })

View file

@ -0,0 +1,48 @@
require("nvchad.mappings")
local map = vim.keymap.set
map("n", ";", ":", { desc = "CMD enter command mode" })
map("i", "jk", "<ESC>")
-- Telescope pickers
local builtin = require("telescope.builtin")
map("n", "<C-P>", builtin.find_files, { desc = "Find files" })
map("n", "<C-f>", builtin.live_grep, { desc = "Live grep" })
map("n", "<leader>fs", builtin.grep_string, { desc = "Grep string under cursor" })
map("n", "<leader>fS", builtin.lsp_dynamic_workspace_symbols, { desc = "Workspace symbols" })
map("n", "<leader>fb", builtin.buffers, { desc = "Buffers" })
map("n", "<leader>fh", builtin.help_tags, { desc = "Help tags" })
map("n", "<leader>co", builtin.git_branches, { desc = "Git branches" })
map("n", "<leader>fu", "<cmd>Telescope undo<cr>", { desc = "Undo tree" })
map("n", "<leader>fr", builtin.resume, { desc = "Resume last picker" })
map("n", "<leader>fo", builtin.oldfiles, { desc = "Recent files" })
-- Code review: fuzzy-jump between dirty hunks across files (custom picker).
-- Rebound into the <leader>r "review" group alongside diffview/octo. Unique vs
-- diffview: fuzzy-typeahead over hunk section names, which diffview can't do.
local hunks_picker = require("configs.telescope").hunks_picker
map("n", "<leader>rh", hunks_picker, { desc = "Fuzzy hunks vs HEAD" })
map("n", "<leader>rH", function()
vim.ui.input({ prompt = "Review against base: ", default = "origin/HEAD" }, function(base)
if base and base ~= "" then
hunks_picker({ base = base })
end
end)
end, { desc = "Fuzzy hunks vs base…" })
map("n", "<leader>gf", builtin.git_status, { desc = "Changed files (git status)" })
-- treesitter-textobjects remaps ]c/[c to next/prev class globally, which shadows
-- Vim's native ]c/[c "next/prev diff change" motion. In diff windows (diffview
-- review, :Gdiffsplit) restore native change-stepping buffer-locally so PR review
-- can walk changes the GitHub way. bang=true bypasses the global class-nav remap.
vim.api.nvim_create_autocmd("OptionSet", {
pattern = "diff",
callback = function()
if not vim.wo.diff then
return
end
map("n", "]c", function() vim.cmd.normal({ "]c", bang = true }) end, { buffer = 0, desc = "Next diff change" })
map("n", "[c", function() vim.cmd.normal({ "[c", bang = true }) end, { buffer = 0, desc = "Prev diff change" })
end,
})

View file

@ -0,0 +1,15 @@
require("nvchad.options")
-- add yours here!
local o = vim.o
o.cursorlineopt = "both" -- to enable cursorline!
-- Hugo template filetype detection
-- Detect .html files in Hugo layouts/templates directories as htmlhugo (vim-hugo filetype)
vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, {
pattern = { "*/layouts/*.html", "*/layouts/**/*.html" },
callback = function()
vim.bo.filetype = "htmlhugo"
end,
})

View file

@ -0,0 +1,482 @@
local function review_base()
local head = vim.fn.systemlist({ "git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD" })
if vim.v.shell_error == 0 and head[1] and head[1] ~= "" then
return head[1]
end
for _, b in ipairs({ "origin/main", "origin/master", "main", "master" }) do
vim.fn.system({ "git", "rev-parse", "--verify", "--quiet", b })
if vim.v.shell_error == 0 then
return b
end
end
return nil
end
local function review_pr_diff()
local base = review_base()
if not base then
vim.notify("diffview: no base branch found (origin/HEAD unset; no main/master)", vim.log.levels.ERROR)
return
end
vim.cmd("DiffviewOpen " .. base .. "...HEAD")
end
return {
{ "hrsh7th/nvim-cmp", enabled = false },
{
"stevearc/conform.nvim",
event = "BufWritePre", -- uncomment for format on save
opts = require("configs.conform"),
},
{
"nvim-telescope/telescope.nvim",
dependencies = {
{ "nvim-telescope/telescope-fzf-native.nvim", build = "make" },
"debugloop/telescope-undo.nvim",
},
config = function()
dofile(vim.g.base46_cache .. "telescope")
require("configs.telescope")
end,
},
-- These are some examples, uncomment them if you want to see them work!
{
"neovim/nvim-lspconfig",
config = function()
require("configs.lspconfig")
end,
},
{
"nvim-treesitter/nvim-treesitter",
branch = "main",
opts = {
ensure_installed = {
"vim",
"lua",
"vimdoc",
"html",
"css",
"python",
"typescript",
"go",
"zig",
"nix",
"terraform",
"markdown",
"markdown_inline",
"regex",
"bash",
"json",
"yaml",
},
},
},
{
"nvim-treesitter/nvim-treesitter-textobjects",
branch = "main",
lazy = false,
dependencies = { "nvim-treesitter/nvim-treesitter" },
config = function()
require("configs.treesitter-textobjects")
end,
},
{
"tpope/vim-fugitive",
cmd = {
"Git",
"G",
"Gdiffsplit",
"Gvdiffsplit",
"Gwrite",
"Gread",
"Ggrep",
"GMove",
"GDelete",
"GBrowse",
"Gstatus",
"Gedit",
"Glog",
},
keys = {
{ "<leader>gs", "<cmd>vertical Git<cr>", desc = "Open Fugitive" },
},
dependencies = {
"tpope/vim-rhubarb",
},
},
{
"nicolasgb/jj.nvim",
version = "*",
cmd = { "J" },
keys = {
{ "<leader>jj", "<cmd>J status<cr>", desc = "Jujutsu status" },
{ "<leader>jl", "<cmd>J log<cr>", desc = "Jujutsu log" },
{ "<leader>jd", "<cmd>J describe<cr>", desc = "Jujutsu describe" },
{ "<leader>jn", "<cmd>J new<cr>", desc = "Jujutsu new change" },
{ "<leader>je", "<cmd>J edit<cr>", desc = "Jujutsu edit" },
{ "<leader>jp", "<cmd>J git push<cr>", desc = "Jujutsu push" },
},
config = function()
require("jj").setup({})
end,
},
{
"folke/zen-mode.nvim",
cmd = "ZenMode",
keys = {
{ "<leader>z", "<cmd>ZenMode<cr>", desc = "Toggle ZenMode" },
},
opts = {
plugins = {
twilight = { enabled = false },
},
},
},
{
"folke/twilight.nvim",
keys = {
{ "<leader>tz", "<cmd>Twilight<cr>", desc = "Toggle Twilight" },
},
opts = {},
},
{
"ThePrimeagen/harpoon",
branch = "harpoon2",
dependencies = { "nvim-lua/plenary.nvim" },
config = function()
require("harpoon"):setup()
end,
keys = {
{
"<leader>ha",
function()
require("harpoon"):list():add()
end,
desc = "Harpoon add",
},
{
"<leader>hm",
function()
require("harpoon").ui:toggle_quick_menu(require("harpoon"):list())
end,
desc = "Harpoon menu",
},
{
"<leader>hH",
function()
require("harpoon"):list():clear()
end,
desc = "Harpoon clear all",
},
{
"<leader>1",
function()
require("harpoon"):list():select(1)
end,
desc = "Harpoon 1",
},
{
"<leader>2",
function()
require("harpoon"):list():select(2)
end,
desc = "Harpoon 2",
},
{
"<leader>3",
function()
require("harpoon"):list():select(3)
end,
desc = "Harpoon 3",
},
{
"<leader>4",
function()
require("harpoon"):list():select(4)
end,
desc = "Harpoon 4",
},
},
},
{
"folke/trouble.nvim",
opts = {
auto_close = false,
auto_refresh = true,
auto_preview = false,
warn_no_results = false,
-- win.position here is the single source of truth for placement;
-- edgy adopts each window at that edge (see configs/edgy.lua).
-- Bottom (trouble's default) = transient lists: diagnostics/qf/loclist.
-- Right = code-context panels you read alongside the buffer.
modes = {
diagnostics = {
auto_close = true, -- bottom panel closes when no diagnostics
},
lsp = { win = { position = "right" } },
-- focus=true: gr means "show me the list, let me pick one"
lsp_references = { win = { position = "right" }, focus = true },
},
},
cmd = "Trouble",
keys = {
{ "<leader>xx", "<cmd>Trouble diagnostics toggle<cr>", desc = "Workspace diagnostics" },
{ "<leader>xX", "<cmd>Trouble diagnostics toggle filter.buf=0<cr>", desc = "Buffer diagnostics" },
-- builtin symbols mode = lsp_document_symbols + right rail + the
-- curated kind filter (the old copy of that filter on `lsp` mode
-- dropped refs/defs items, which carry no symbol kind)
{ "<leader>cs", "<cmd>Trouble symbols toggle<cr>", desc = "Symbols outline" },
{ "<leader>cl", "<cmd>Trouble lsp toggle<cr>", desc = "LSP defs/refs/impls" },
{ "<leader>xq", "<cmd>Trouble qflist toggle<cr>", desc = "Quickfix" },
{ "<leader>xl", "<cmd>Trouble loclist toggle<cr>", desc = "Location list" },
},
},
{
-- Label the <leader> umbrellas so the which-key popup reads as a menu.
-- NvChad's whichkey opts is a function, so a plain table override can be
-- dropped during lazy's merge — extend with a function fragment instead
-- (lazy feeds it the accumulated opts and keeps the mutation).
"folke/which-key.nvim",
opts = function(_, opts)
opts.spec = opts.spec or {}
vim.list_extend(opts.spec, {
{ "<leader>c", group = "code" },
{ "<leader>f", group = "find" },
{ "<leader>g", group = "git" },
{ "<leader>h", group = "hunks · harpoon" },
{ "<leader>j", group = "jujutsu" },
{ "<leader>r", group = "review" },
{ "<leader>s", group = "swap" },
{ "<leader>t", group = "test · toggle" },
{ "<leader>x", group = "diagnostics" },
})
return opts
end,
},
{
"folke/noice.nvim",
event = "VeryLazy",
opts = {
lsp = {
signature = {
enabled = false,
},
-- override markdown rendering so LSP docs render through Treesitter
-- (cmp override dropped: nvim-cmp is disabled, blink renders its own docs)
override = {
["vim.lsp.util.convert_input_to_markdown_lines"] = true,
["vim.lsp.util.stylize_markdown"] = true,
},
},
-- you can enable a preset for easier configuration
presets = {
bottom_search = true, -- use a classic bottom cmdline for search
command_palette = true, -- position the cmdline and popupmenu together
long_message_to_split = true, -- long messages will be sent to a split
inc_rename = false, -- enables an input dialog for inc-rename.nvim
lsp_doc_border = true, -- add a border to hover docs and signature help
},
},
dependencies = {
"MunifTanjim/nui.nvim",
{ "rcarriga/nvim-notify", opts = { background_colour = "#000000" } },
},
},
{
"m4xshen/hardtime.nvim",
lazy = false,
dependencies = { "MunifTanjim/nui.nvim" },
opts = {},
},
{
"MeanderingProgrammer/render-markdown.nvim",
opts = {
file_types = { "markdown" },
},
ft = { "markdown" },
},
{
-- inline images in markdown buffers (e.g. newsboat ,f full-text view).
-- Ghostty implements the kitty graphics protocol, so use that backend.
"3rd/image.nvim",
ft = { "markdown" },
opts = {
backend = "kitty",
processor = "magick_rock",
integrations = {
markdown = {
enabled = true,
-- render only the image on the cursor's line — one at a
-- time, so a stack of images never overlaps the text
only_render_image_at_cursor = true,
download_remote_images = true,
},
},
-- bound height so image.nvim reserves the same number of rows it
-- renders (mismatch is what makes images overlap the alt text)
max_width_window_percentage = 80,
max_height_window_percentage = 50,
-- repaint when other windows overlap, so scroll/wrap don't leave
-- the image stamped over text
window_overlap_clear_enabled = true,
},
},
{
"folke/edgy.nvim",
event = "VeryLazy",
enabled = true,
init = function()
vim.opt.laststatus = 3
vim.opt.splitkeep = "screen"
end,
opts = require("configs.edgy"),
},
{
"saghen/blink.cmp",
dependencies = { "rafamadriz/friendly-snippets" },
event = "VeryLazy",
version = "*",
opts = require("configs.blink"),
},
{
"phelipetls/vim-hugo",
-- Load early to enable filetype detection for Hugo templates
lazy = false,
},
{
"lewis6991/gitsigns.nvim",
-- NvChad includes gitsigns by default; override its opts
opts = require("configs.gitsigns"),
},
{
"nvim-neotest/neotest",
dependencies = {
"nvim-neotest/nvim-nio",
"nvim-lua/plenary.nvim",
"antoinemadec/FixCursorHold.nvim",
"nvim-treesitter/nvim-treesitter",
"nvim-neotest/neotest-go",
"nvim-neotest/neotest-python",
"marilari88/neotest-vitest",
},
-- keys-gated (was lazy=false): nothing needs neotest before the first
-- test action, and it pulls in nio + 3 adapters at startup otherwise
keys = {
-- stylua: ignore start
{ "<leader>tr", function() require("neotest").run.run() end, desc = "Run nearest test" },
{ "<leader>tR", function() require("neotest").run.run(vim.fn.expand("%")) end, desc = "Run file tests" },
{ "<leader>tl", function() require("neotest").run.run_last() end, desc = "Run last test" },
{ "<leader>ts", function() require("neotest").run.stop() end, desc = "Stop test run" },
{ "<leader>tt", function() require("neotest").summary.toggle() end, desc = "Toggle test summary" },
{ "<leader>to", function() require("neotest").output.open({ enter = true, short = true }) end, desc = "Show test output" },
{ "<leader>tO", function() require("neotest").output_panel.toggle() end, desc = "Toggle output panel" },
{ "<leader>tW", function() require("neotest").watch.toggle(vim.fn.expand("%")) end, desc = "Watch file tests" },
{ "]t", function() require("neotest").jump.next({ status = "failed" }) end, desc = "Next failed test" },
{ "[t", function() require("neotest").jump.prev({ status = "failed" }) end, desc = "Prev failed test" },
-- stylua: ignore end
},
config = function()
require("configs.neotest")()
end,
},
{
"kosayoda/nvim-lightbulb",
event = "LspAttach",
opts = require("configs.lightbulb"),
},
-- ── PR review surface ─────────────────────────────────────────────────────
{
"sindrets/diffview.nvim",
cmd = {
"DiffviewOpen",
"DiffviewFileHistory",
"DiffviewClose",
"DiffviewToggleFiles",
"DiffviewFocusFiles",
"DiffviewRefresh",
},
keys = {
-- Triple-dot = merge-base diff = exact GitHub "Files changed" semantics.
-- Base resolved locally (review_base) so it works without origin/HEAD set.
{ "<leader>rd", review_pr_diff, desc = "Review: PR diff vs base" },
{ "<leader>rw", "<cmd>DiffviewOpen<cr>", desc = "Review: working-tree diff" },
{
"<leader>rB",
function()
vim.ui.input({ prompt = "Diff vs base: ", default = review_base() or "origin/main" }, function(b)
if b and b ~= "" then
vim.cmd("DiffviewOpen " .. b .. "...HEAD")
end
end)
end,
desc = "Review: diff vs base…",
},
{ "<leader>rf", "<cmd>DiffviewFileHistory %<cr>", desc = "Review: file history" },
{ "<leader>rl", "<cmd>DiffviewFileHistory<cr>", desc = "Review: branch history" },
{ "<leader>rq", "<cmd>DiffviewClose<cr>", desc = "Review: close diffview" },
},
opts = require("configs.diffview"),
-- <leader>r group label lives in the which-key spec above, with the rest.
},
{
"pwntester/octo.nvim",
cmd = "Octo",
dependencies = {
"nvim-lua/plenary.nvim",
"nvim-telescope/telescope.nvim",
"nvim-tree/nvim-web-devicons",
},
keys = {
-- Browse: pickers over PRs/issues; <C-o> checkout, <C-r> merge, <C-b>
-- browser directly from the picker.
{ "<leader>ro", "<cmd>Octo pr list<cr>", desc = "Review: list PRs" },
{ "<leader>ri", "<cmd>Octo issue list<cr>", desc = "Review: list issues" },
{ "<leader>rn", "<cmd>Octo pr create<cr>", desc = "Review: create PR" },
-- Review flow on the checked-out / opened PR.
{ "<leader>rc", "<cmd>Octo pr checkout<cr>", desc = "Review: checkout PR" },
{ "<leader>rs", "<cmd>Octo review start<cr>", desc = "Review: start review" },
{ "<leader>rr", "<cmd>Octo review resume<cr>", desc = "Review: resume review" },
-- Submit opens the review window: <C-a> approve, <C-m> comment,
-- <C-r> request changes.
{ "<leader>rS", "<cmd>Octo review submit<cr>", desc = "Review: submit review" },
-- Merge uses default_merge_method; ,pm/,psm/,prm in the PR buffer
-- pick the method explicitly.
{ "<leader>rm", "<cmd>Octo pr merge<cr>", desc = "Review: merge PR" },
{ "<leader>rM", "<cmd>Octo pr merge squash<cr>", desc = "Review: squash-merge PR" },
},
opts = require("configs.octo"),
},
{
"nvim-treesitter/nvim-treesitter-context",
event = { "BufReadPost", "BufNewFile" },
dependencies = { "nvim-treesitter/nvim-treesitter" },
-- Safe on treesitter branch="main": uses core vim.treesitter + its own
-- bundled queries, not the rewritten nvim-treesitter module API.
opts = {
max_lines = 4, -- cap sticky-header height on deep nesting
multiline_threshold = 1, -- collapse long signatures to one line
trim_scope = "outer", -- keep innermost scope when over max_lines
mode = "cursor",
multiwindow = true, -- show context in BOTH diffview panes
},
keys = {
-- [x, not [c: this config remaps ]c/[c to class-nav (via
-- treesitter-textobjects), so [x is the free key for scope-jump.
-- Native ]c/[c diff-change motion is restored in diff windows in mappings.lua.
{
"[x",
function()
require("treesitter-context").go_to_context(vim.v.count1)
end,
desc = "Jump to context",
},
{ "<leader>tC", "<cmd>TSContext toggle<cr>", desc = "Toggle sticky context" },
},
},
}

20
home/profiles/default.nix Normal file
View file

@ -0,0 +1,20 @@
{
"bogdan@framework13" = [
../.
./framework13
];
"deck@steamdeck" = [
./steamdeck
];
"nixos@iso" = [
./iso/base.nix
./iso
];
"bogdan@phantom" = [
../.
./phantom
];
}

View file

@ -0,0 +1,71 @@
{ config, inputs, ... }:
{
imports = [
# theme
../../theme
# editors
../../editors/nvim
# programs
../../programs
../../programs/keepassxc.nix
../../programs/wayland
# HM-generated Hyprland lua config (framework13-first; replaces the
# hand-authored system-level lua.nix once verified). See Change 2 plan.
../../programs/wayland/hyprland.nix
../../programs/gtk.nix
../../programs/yt-dlp.nix
../../programs/gpg.nix
# terminal emulators
../../terminal/emulators/ghostty.nix
../../services/wayland/hyprpaper.nix
../../services/mako.nix
../../services/meeting-guard.nix
../../services/podman.nix
../../services/gpg.nix
../../services/syncthing.nix
../../services/trayscale.nix
../../services/media/playerctl.nix
# Private home manager configuration
inputs.secrets.homeModules.default
];
# SOPS secret for OpenRouter API key (used by nvim 99 plugin)
sops.secrets.openrouter_api_key = {
sopsFile = "${inputs.secrets}/secrets/common/api-keys.yaml";
};
sops.secrets.deepseek_api_key = {
sopsFile = "${inputs.secrets}/secrets/common/api-keys.yaml";
};
# Z.AI Coding Plan key (used by opencode zai-coding-plan provider)
sops.secrets.zai_api_key = {
sopsFile = "${inputs.secrets}/secrets/common/api-keys.yaml";
};
# Alpha Vantage key (used by portfolio-update-prices)
sops.secrets.alphavantage_api_key = {
sopsFile = "${inputs.secrets}/secrets/common/api-keys.yaml";
};
# Datadog API keys (used by opencode dd-log tool)
sops.secrets.dd_api_key = {
sopsFile = "${inputs.secrets}/secrets/common/datadog.yaml";
};
sops.secrets.dd_app_key = {
sopsFile = "${inputs.secrets}/secrets/common/datadog.yaml";
};
# dd-log reads *_FILE variants so key material stays on disk (sops
# runtime paths), not in every process environment.
home.sessionVariables = {
DD_API_KEY_FILE = config.sops.secrets.dd_api_key.path;
DD_APP_KEY_FILE = config.sops.secrets.dd_app_key.path;
};
}

View file

@ -0,0 +1,12 @@
{ config, pkgs, ... }:
{
home = {
username = "nixos";
homeDirectory = "/home/nixos";
stateVersion = "25.11";
};
# Let Home Manager install and manage itself
programs.home-manager.enable = true;
}

View file

@ -0,0 +1,41 @@
{
imports = [
# Editors - vital!
../../editors/nvim
# Terminal configuration
../../terminal
../../terminal/emulators/ghostty.nix
# Wayland/Hyprland essentials
../../programs/wayland
../../programs/gtk.nix
../../programs/gpg.nix
# Desktop services
../../services/wayland/hyprpaper.nix
../../services/mako.nix
../../services/gpg.nix
../../services/media/playerctl.nix
../../services/trayscale.nix
# Tailscale auto-auth (prompts for YubiKey PIN after login)
./tailscale.nix
];
# Generic Hyprland settings for any display
wayland.windowManager.hyprland.settings = {
# Auto-detect monitors
monitor = [
", preferred, auto, 1"
];
# Generic touchpad settings
input = {
touchpad = {
natural_scroll = true;
tap-to-click = true;
};
};
};
}

View file

@ -0,0 +1,76 @@
# Tailscale auto-authentication for live ISO (user session)
# Runs after graphical login so pinentry-qt can prompt for YubiKey PIN
{ config, pkgs, inputs, ... }:
let
secretsFile = "${inputs.secrets}/secrets/iso/tailscale.yaml";
in {
# Systemd user service to authenticate Tailscale after graphical login
systemd.user.services.tailscale-autoconnect = {
Unit = {
Description = "Authenticate to Tailscale with YubiKey-decrypted auth key";
# Run after graphical session is ready (pinentry-qt needs display)
After = [ "graphical-session.target" ];
Wants = [ "graphical-session.target" ];
};
Service = {
Type = "oneshot";
RemainAfterExit = true;
# Environment for pinentry and age-plugin-yubikey
Environment = [
"PINENTRY_PROGRAM=${pkgs.pinentry-qt}/bin/pinentry-qt"
];
ExecStart = pkgs.writeShellScript "tailscale-autoconnect" ''
set -euo pipefail
echo "Tailscale auto-connect: checking status..."
# Check if already authenticated
status="$(${pkgs.tailscale}/bin/tailscale status -json 2>/dev/null | ${pkgs.jq}/bin/jq -r '.BackendState' 2>/dev/null || echo 'Unknown')"
if [ "$status" = "Running" ]; then
echo "Tailscale already running, skipping authentication"
exit 0
fi
echo "Tailscale auto-connect: decrypting auth key..."
echo "You may be prompted for your YubiKey PIN..."
# Decrypt the auth key using sops
# This will trigger pinentry-qt for the YubiKey PIV PIN
AUTH_KEY="$(${pkgs.sops}/bin/sops -d --extract '["tailscale_auth_key"]' ${secretsFile} 2>/dev/null)" || {
echo "ERROR: Failed to decrypt Tailscale auth key"
echo "Make sure your YubiKey is connected and try: tailscale up"
# Send desktop notification
${pkgs.libnotify}/bin/notify-send -u critical "Tailscale" "Failed to decrypt auth key. Run 'tailscale up' manually."
exit 1
}
if [ -z "$AUTH_KEY" ]; then
echo "ERROR: Auth key is empty"
exit 1
fi
echo "Tailscale auto-connect: authenticating..."
${pkgs.tailscale}/bin/tailscale up --authkey="$AUTH_KEY" --operator=nixos
echo "Tailscale authenticated successfully!"
${pkgs.libnotify}/bin/notify-send -u normal "Tailscale" "Connected to Tailscale network"
'';
};
Install = {
WantedBy = [ "graphical-session.target" ];
};
};
# Ensure required packages are available
home.packages = with pkgs; [
age-plugin-yubikey
sops
libnotify
];
}

View file

@ -0,0 +1,15 @@
# Phantom USB - Home Manager configuration
# Inherits from framework13 profile for familiar environment
# Adds phantom-specific overrides for ephemeral usage
{
imports = [
# Inherit the full framework13 experience
../framework13
# Override monitor config for unknown displays
./hyprland.nix
];
# Phantom-specific home settings can go here
# Most config comes from framework13 profile
}

View file

@ -0,0 +1,27 @@
# Phantom USB - Hyprland monitor configuration
# Auto-detect displays since we don't know what machine we'll boot on
{
wayland.windowManager.hyprland.settings = {
# Auto-configure any connected monitor
# This overrides the framework13 specific monitor config
monitor = [
# Prefer high resolution, auto-position, with scaling
",preferred,auto,1"
];
# Generic touchpad settings (works on most laptops)
device = {
name = "generic-touchpad";
natural_scroll = true;
};
# Input configuration for unknown hardware
input = {
# Enable tap-to-click for any touchpad
touchpad = {
tap-to-click = true;
natural_scroll = true;
};
};
};
}

View file

@ -0,0 +1,35 @@
{ config, pkgs, inputs, ... }:
{
imports = [
./syncthing.nix
../../programs/keepassxc.nix
../../programs/games/minecraft
../../programs/games/openrct2
# Configured Claude Code (settings/agents/MCP/plugins under ~/.claude). Required
# by the openrct2 coding-agent fork, whose in-game terminal launches `claude`.
../../terminal/programs/claude
./media.nix
];
home = {
username = "deck";
homeDirectory = "/home/deck";
stateVersion = "25.11";
};
# Let Home Manager install and manage itself
programs.home-manager.enable = true;
# claude-code (pulled in by programs.claude-code and baked into the openrct2
# coding-agent wrapper) is unfree. This host's Home Manager builds its OWN pkgs
# (useGlobalPkgs is not set for steamdeck — see system/default.nix), so the
# system-level allowUnfree in hosts/steamdeck/default.nix does not reach it.
# Opt in here too.
nixpkgs.config.allowUnfree = true;
# keepassxc.nix sets `programs.keepassxc.autostart = true`, which (on
# home-manager-unstable, used by this host) asserts xdg.autostart must be on.
# Framework13's stable HM lacks that assertion, so this is only needed here.
xdg.autostart.enable = true;
}

View file

@ -0,0 +1,98 @@
{ config, lib, pkgs, ... }:
let
mediaPaths = {
# Transmission lands completed files here (hosts/steamdeck/transmission.nix).
# The deck user's primary group is `users`, which owns this 0775 dir, so
# Kodi can read it. Previously pointed at ~/torrents — nothing was there.
torrents = "/srv/torrents/downloads";
};
# Pre-configure Kodi media sources so it's ready on first launch
kodiSources = pkgs.writeText "sources.xml" ''
<sources>
<video>
<default pathversion="1"></default>
<source>
<name>Torrents</name>
<path pathversion="1">${mediaPaths.torrents}/</path>
<allowsharing>true</allowsharing>
</source>
</video>
<music>
<default pathversion="1"></default>
<source>
<name>Torrents</name>
<path pathversion="1">${mediaPaths.torrents}/</path>
<allowsharing>true</allowsharing>
</source>
</music>
</sources>
'';
# Hardware-friendly defaults plus a large read-ahead cache. buffermode=1
# buffers all local + network sources, so playing a file that Transmission is
# still writing to (or off a NAS later) doesn't stutter on the Deck's storage.
kodiAdvancedSettings = pkgs.writeText "advancedsettings.xml" ''
<advancedsettings version="1.0">
<videoplayer>
<usestagingbuffer>true</usestagingbuffer>
</videoplayer>
<cache>
<buffermode>1</buffermode>
<memorysize>209715200</memorysize>
<readfactor>4</readfactor>
</cache>
</advancedsettings>
'';
# Seed for the web remote (Kore/Yatse phone apps, browser UI on :8080).
# NOT symlinked into place: Kodi rewrites guisettings.xml on every exit, so a
# read-only store symlink would break it. The activation script below copies
# this once, only if no guisettings exists yet, then Kodi owns the file.
kodiGuiSettings = pkgs.writeText "guisettings.xml" ''
<settings version="2">
<setting id="services.webserver" default="false">true</setting>
<setting id="services.webserverport" default="false">8080</setting>
<setting id="services.webserverusername" default="false">kodi</setting>
<setting id="services.esallinterfaces" default="false">true</setting>
</settings>
'';
in
{
home.packages = [
pkgs.kodi
];
# Read-only declarative config (Kodi only reads these)
home.file = {
".kodi/userdata/sources.xml".source = kodiSources;
".kodi/userdata/advancedsettings.xml".source = kodiAdvancedSettings;
};
# Writable seed for guisettings — copy once, leave Kodi to manage afterwards.
home.activation.kodiGuiSettings = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
guifile="$HOME/.kodi/userdata/guisettings.xml"
if [ ! -f "$guifile" ]; then
run mkdir -p "$HOME/.kodi/userdata"
run cp ${kodiGuiSettings} "$guifile"
run chmod u+w "$guifile"
fi
'';
# XDG desktop entry for Kodi - add to Steam via "Add a Non-Steam Game" in Desktop Mode
xdg.desktopEntries = {
kodi-gamemode = {
name = "Kodi";
genericName = "Media Center";
comment = "Browse and play local media";
exec = "${pkgs.kodi}/bin/kodi --standalone";
icon = "kodi";
categories = [ "AudioVideo" "Video" "Player" ];
terminal = false;
settings = {
StartupWMClass = "Kodi";
};
};
};
}

View file

@ -0,0 +1,138 @@
{ config, lib, pkgs, inputs, ... }:
let
# Secrets file location
secretsFile = "${inputs.secrets}/secrets/steamdeck/syncthing.yaml";
# Device IDs - obtained from Syncthing on each device (run:
# syncthing cli show system | grep myID); kept in the private
# flake's vars so they stay out of the public repo
deviceIds = inputs.secrets.vars.syncthing.deviceIds;
# Common versioning configuration
# Trashcan versioning moves deleted/replaced files to .stversions
versioningConfig = {
type = "trashcan";
params = {
cleanoutDays = "30"; # Keep deleted files for 30 days
};
};
in {
# SOPS secrets configuration for Syncthing GUI authentication
sops = {
age.keyFile = "/home/deck/.config/sops/age/keys.txt";
secrets = {
syncthing_gui_password = {
sopsFile = secretsFile;
};
};
};
services.syncthing = {
enable = true;
# Declarative configuration
overrideDevices = true;
overrideFolders = true;
# Note: We use 127.0.0.1 here because home-manager's syncthing-init service
# uses guiAddress for API calls (0.0.0.0 is not a valid connect address).
# The actual binding address (0.0.0.0:8384) is set via ExecStartPost below.
guiAddress = "127.0.0.1:8384";
# GUI credentials from SOPS secrets
guiCredentials = {
username = "deck";
passwordFile = config.sops.secrets.syncthing_gui_password.path;
};
settings = {
gui = {
theme = "dark";
# Note: address is set via systemd ExecStartPost below because
# home-manager's syncthing module overwrites settings.gui.address
# with guiAddress at the end of the init script.
};
options = {
localAnnounceEnabled = true;
globalAnnounceEnabled = true;
relaysEnabled = true;
natEnabled = true;
relayReconnectIntervalM = 30;
autoUpgradeIntervalH = 0; # Disabled - managed by Nix
crashReportingEnabled = false;
startBrowser = false;
};
devices = {
"Framework13" = {
id = deviceIds.framework13;
autoAcceptFolders = false;
addresses = [ "dynamic" ];
};
};
folders = {
# Passwords folder - bidirectional sync with Framework13
"Passwords" = {
id = "passwords";
path = "/home/deck/Passwords";
devices = [ "Framework13" ];
type = "sendreceive";
versioning = versioningConfig;
fsWatcherEnabled = true;
rescanIntervalS = 3600;
ignorePerms = true;
};
# Steam game recordings - send-only to Framework13
# Recordings made on Steam Deck sync to Framework13 for backup/editing
"Steam Recordings" = {
id = "steam-recordings";
path = "/home/deck/.local/share/Steam/userdata/1834090003/gamerecordings";
devices = [ "Framework13" ];
# Send-only: Steam Deck sends recordings but doesn't receive changes
type = "sendonly";
# No versioning needed for recordings (large files, append-only)
fsWatcherEnabled = true;
rescanIntervalS = 300; # Check every 5 minutes for new recordings
ignorePerms = true;
};
};
};
};
# Ensure syncthing and syncthing-init services wait for SOPS to decrypt secrets
systemd.user.services.syncthing = {
Unit = {
After = [ "sops-nix.service" ];
Wants = [ "sops-nix.service" ];
};
};
systemd.user.services.syncthing-init = {
Unit = {
After = [ "sops-nix.service" ];
Wants = [ "sops-nix.service" ];
};
Service = {
# After the init script runs, set the GUI address to 0.0.0.0:8384 for remote access.
# This is needed because home-manager's syncthing module uses guiAddress for both
# API calls (must be 127.0.0.1) and the final bind address (we want 0.0.0.0).
ExecStartPost = let
script = pkgs.writeShellScript "syncthing-set-gui-address" ''
API_KEY=$(${pkgs.gnugrep}/bin/grep -oP '(?<=<apikey>)[^<]+' ~/.local/state/syncthing/config.xml)
${pkgs.curl}/bin/curl -s -X PATCH \
-H "X-API-Key: $API_KEY" \
-d '{"address": "0.0.0.0:8384"}' \
http://127.0.0.1:8384/rest/config/gui
# Restart syncthing to apply the address change
${pkgs.curl}/bin/curl -s -X POST \
-H "X-API-Key: $API_KEY" \
http://127.0.0.1:8384/rest/system/restart
'';
in "${script}";
};
};
}

View file

@ -0,0 +1,50 @@
/*** arkenfox user-overrides.js `personal` profile (identified daily driver)
*
* Loaded AFTER arkenfox's user.js, so every pref here WINS over arkenfox.
* This file is OURS to own arkenfox itself says you MUST maintain overrides
* and read the wiki; treat it as the single place we relax/tighten arkenfox.
*
* Deliberately NO resistFingerprinting here: `personal` is logged-in, so
* blending into a crowd is pointless and just breaks sites. The ghost/anonymity
* job lives in Mullvad Browser (browsers/mullvad.nix), not this profile.
***/
/* --- light anti-fingerprinting: FPP, NOT RFP ---------------------------
* FPP randomizes canvas + audio (and curbs some enumeration) per-site,
* per-session, WITHOUT RFP's usability tax (no UTC clock, no letterbox, no
* forced theme). The right tier for a logged-in driver: it neutralizes the
* STABLE cross-site IDs (canvas/audio) that survive cookie partitioning
* exactly personal's anti-correlation goal. arkenfox leaves these at default
* (off in normal windows on non-strict ETP), so set them explicitly. */
user_pref("privacy.fingerprintingProtection", true); // [arkenfox: default/off in normal windows]
user_pref("privacy.fingerprintingProtection.pbmode", true); // also in private windows
/* FPP=true only ARMS the engine; which targets fire comes from remote-settings
* and did NOT include canvas (verified: toDataURL stayed at the unprotected
* hash). Force the canvas target locally so readback is noised per-session.
* `+Target` augments the remote set; CanvasRandomization is the silent one
* (CanvasImageExtractionPrompt would nag on every extraction omitted). */
user_pref("privacy.fingerprintingProtection.overrides", "+CanvasRandomization");
/* --- stay logged in across restarts ------------------------------------
* arkenfox 2811 wipes cookies + storage on shutdown -> logged out every
* restart. Keep the rest of the shutdown wipe (cache, formdata) but persist
* logins. This is the single change that makes arkenfox livable as a daily. */
user_pref("privacy.clearOnShutdown_v2.cookiesAndStorage", false); // [arkenfox: true]
/* --- daily-driver quality of life --------------------------------------- */
user_pref("browser.cache.disk.enable", true); // [arkenfox: false] still wiped on shutdown via clearOnShutdown_v2.cache
user_pref("browser.startup.page", 3); // [arkenfox: 0] restore previous session (cookies now persist)
user_pref("browser.sessionstore.privacy_level", 0); // [arkenfox: 2] let session restore actually work
/* --- our standing prefs (arkenfox leaves these at default/unset) -------- */
user_pref("network.trr.mode", 5); // DoH off: system DNS is Mullvad in-tunnel; browser DoH to Cloudflare bypasses it (DNS leak)
user_pref("signon.rememberSignons", false); // KeePassXC handles autofill, not Firefox's pw manager
/* --- AI off (arkenfox doesn't touch browser.ai.control) ----------------- */
user_pref("browser.ai.control.default", "blocked");
user_pref("browser.ai.control.linkPreviewKeyPoints", "blocked");
user_pref("browser.ai.control.pdfjsAltText", "blocked");
user_pref("browser.ai.control.sidebarChatbot", "blocked");
user_pref("browser.ai.control.smartTabGroups", "blocked");
user_pref("browser.ai.control.smartWindow", "blocked");
user_pref("browser.ai.control.translations", "blocked");

View file

@ -0,0 +1,70 @@
{ pkgs, ... }:
{
# personal — daily driver. Full arkenfox user.js + OUR maintained overrides
# (browsers/arkenfox-overrides.js). RFP stays OFF: you're logged
# in, so blending is pointless; the ghost job is Mullvad Browser.
# work — sacrificial / walled. Permissive for corp SSO; kept separate so
# employer MITM/MDM never touches personal.
stylix.targets.firefox.profileNames = [ "personal" "work" ];
programs.firefox =
let
# arkenfox user.js — hash-pinned. Applied to `personal`, then overridden
# by arkenfox-overrides.js (loaded after, so our prefs win).
arkenfoxUserJs =
let
version = "144.0";
in
pkgs.fetchurl {
url = "https://raw.githubusercontent.com/arkenfox/user.js/${version}/user.js";
hash = "sha256-5KszxpFImRdc9wNeDlei1/CKyIfY+VfxGZ5+Sbvn4z4=";
};
aiOff = {
"browser.ai.control.default" = "blocked";
"browser.ai.control.linkPreviewKeyPoints" = "blocked";
"browser.ai.control.pdfjsAltText" = "blocked";
"browser.ai.control.sidebarChatbot" = "blocked";
"browser.ai.control.smartTabGroups" = "blocked";
"browser.ai.control.smartWindow" = "blocked";
"browser.ai.control.translations" = "blocked";
};
# shared anti-cruft baseline (no fingerprint/cookie aggression here)
baseline = aiOff // {
"datareporting.healthreport.uploadEnabled" = false;
"toolkit.telemetry.enabled" = false;
"browser.newtabpage.activity-stream.feeds.telemetry" = false;
"extensions.pocket.enabled" = false;
"browser.newtabpage.activity-stream.showSponsored" = false;
"browser.newtabpage.activity-stream.showSponsoredTopSites" = false;
};
in
{
enable = true;
configPath = ".mozilla/firefox"; # keep legacy path (26.05 default moved to XDG); silences the warning
# daily driver — reuses existing on-disk dir → history + addons + containers intact.
# arkenfox first (it loses to anything written after it), then OUR overrides win.
profiles.personal = {
id = 0;
isDefault = true;
path = "4f3s13kd.default";
extraConfig =
builtins.readFile arkenfoxUserJs
+ "\n"
+ builtins.readFile ./arkenfox-overrides.js;
};
# permissive profile for corp SSO / internal tooling — starts empty, add addons manually
profiles.work = {
id = 1;
path = "work.work";
settings = baseline // {
"network.cookie.cookieBehavior" = 0; # accept all (cross-site SSO redirects)
"signon.rememberSignons" = true;
};
};
};
}

View file

@ -0,0 +1,7 @@
{ pkgs, ... }:
{
home.packages = with pkgs; [
ladybird
];
}

View file

@ -0,0 +1,34 @@
{ pkgs, inputs, ... }:
let
inherit (inputs.secrets.vars.i2pProxy) host port;
mullvad-browser-i2p = pkgs.mullvad-browser.override {
extraPrefs = ''
lockPref("network.proxy.type", 1); // 1 = manual
lockPref("network.proxy.http", "${host}");
lockPref("network.proxy.http_port", ${toString port});
lockPref("network.proxy.ssl", "${host}");
lockPref("network.proxy.ssl_port", ${toString port});
lockPref("network.proxy.share_proxy_settings", true);
// HTTP proxy resolves hostnames proxy-side; disable DoH/TRR so stuff
// never leaks to a clearnet resolver.
lockPref("network.trr.mode", 5); // 5 = off, never use TRR
lockPref("network.proxy.socks_remote_dns", true);
// I2P tunnels are end-to-end encrypted and a .i2p/b32 address is the
// hash of the destination's key (self-authenticating), so layered TLS is
// redundant. Stop HTTPS-Only mode from upgrading http:// eepsites and
// throwing "connection is not secure" warning pages. Safe here only
// because the browser is locked to the I2P proxy with no outproxy, so it
// cannot reach clearnet to downgrade a real site.
lockPref("dom.security.https_only_mode", false);
lockPref("dom.security.https_only_mode_ever_enabled", false);
lockPref("dom.security.https_first", false);
'';
};
in
{
home.packages = [ mullvad-browser-i2p ];
}

15
home/programs/default.nix Normal file
View file

@ -0,0 +1,15 @@
{
imports = [
./browsers/firefox.nix
./browsers/mullvad.nix
./browsers/ladybird.nix
./wayland
./messaging.nix
./office
./hister.nix
./development.nix
./media
./media/audacious
./media/newsboat
];
}

View file

@ -0,0 +1,20 @@
{ config, pkgs, ... }:
{
home.packages = with pkgs; [
(google-cloud-sdk.withExtraComponents (with google-cloud-sdk.components; [
gke-gcloud-auth-plugin
]))
sops
jq
yq
flyctl
kubectl
lazysql
sqlit-tui
lazydocker
lazyjournal
opensnitch-ui
posting
];
}

View file

@ -0,0 +1,285 @@
# Declarative Minecraft Configuration for Steam Deck
#
# This module provides:
# - Prism Launcher with multiple Java versions
# - Declarative mod management (mods defined in mods/<name>/default.nix)
# - Declarative server list (servers.nix)
# - Declarative game options (options.nix)
# - Automatic setup on home-manager switch
# - Steam integration helpers
#
# Directory Structure:
# minecraft/
# ├── default.nix # This file - main configuration
# ├── servers.nix # Server list configuration
# ├── options.nix # Game options (FOV, render distance, etc.)
# └── mods/
# ├── default.nix # Mod aggregator
# ├── fabric-api/ # Fabric API mod
# │ └── default.nix
# ├── yacl/ # Yet Another Config Lib
# │ └── default.nix
# └── controlify/ # Controller support
# └── default.nix
#
{ config, pkgs, lib, inputs, ... }:
let
# Minecraft version configuration
mcVersion = "1.21.11";
fabricLoaderVersion = "0.18.3";
instanceName = "${mcVersion}";
# Import configurations
modsConfig = import ./mods { inherit pkgs lib; };
serversConfig = import ./servers.nix { inherit pkgs lib inputs; };
optionsConfig = import ./options.nix { inherit lib; };
# Create a directory containing all mod files
modsDir = pkgs.linkFarmFromDrvs "minecraft-mods-${mcVersion}" modsConfig.files;
# Prism Launcher with multiple Java versions
prismlauncher = pkgs.prismlauncher.override {
jdks = [
pkgs.jdk21 # For 1.21.x
pkgs.jdk17 # For 1.18-1.20.x
pkgs.jdk8 # For older versions
];
gamemodeSupport = true;
};
# Instance configuration file content
instanceCfg = ''
[General]
AutoCloseConsole=false
AutomaticJava=true
CloseAfterLaunch=false
ConfigVersion=1.2
IgnoreJavaCompatibility=false
InstanceType=OneSix
LogPrePostOutput=true
ManagedPack=false
MaxMemAlloc=4096
MinMemAlloc=512
OverrideJavaLocation=false
OverrideMemory=false
iconKey=default
name=${instanceName}
notes=Declaratively managed by Nix
'';
# mmc-pack.json for Fabric loader configuration
mmcPackJson = builtins.toJSON {
formatVersion = 1;
components = [
{
cachedName = "LWJGL 3";
cachedVersion = "3.3.3";
cachedVolatile = true;
dependencyOnly = true;
uid = "org.lwjgl3";
version = "3.3.3";
}
{
cachedName = "Minecraft";
cachedRequires = [
{ suggests = "3.3.3"; uid = "org.lwjgl3"; }
];
cachedVersion = mcVersion;
important = true;
uid = "net.minecraft";
version = mcVersion;
}
{
cachedName = "Intermediary Mappings";
cachedRequires = [
{ equals = mcVersion; uid = "net.minecraft"; }
];
cachedVersion = mcVersion;
cachedVolatile = true;
dependencyOnly = true;
uid = "net.fabricmc.intermediary";
version = mcVersion;
}
{
cachedName = "Fabric Loader";
cachedRequires = [
{ uid = "net.fabricmc.intermediary"; }
];
cachedVersion = fabricLoaderVersion;
uid = "net.fabricmc.fabric-loader";
version = fabricLoaderVersion;
}
];
};
# Create a directory with all config files for the setup script
configFilesDir = pkgs.runCommand "minecraft-configs-${mcVersion}" {} ''
mkdir -p $out
${lib.concatStringsSep "\n" (lib.mapAttrsToList (path: value: ''
mkdir -p $out/$(dirname "${path}")
cat > $out/${path} << 'CONFIGEOF'
${value.text}
CONFIGEOF
'') allConfigFiles)}
'';
# Setup script for mod installation
setupMinecraftScript = pkgs.writeShellScriptBin "minecraft-setup" ''
set -euo pipefail
PRISM_DATA="$HOME/.local/share/PrismLauncher"
INSTANCE_NAME="${instanceName}"
INSTANCE_DIR="$PRISM_DATA/instances/$INSTANCE_NAME"
MODS_SRC="${modsDir}"
CONFIGS_SRC="${configFilesDir}"
echo "============================================"
echo " Minecraft Declarative Setup"
echo "============================================"
echo ""
echo "Instance: $INSTANCE_NAME"
echo "Minecraft: ${mcVersion}"
echo "Mod Loader: Fabric ${fabricLoaderVersion}"
echo ""
# Create instance directory structure
mkdir -p "$INSTANCE_DIR/.minecraft/mods"
mkdir -p "$INSTANCE_DIR/.minecraft/config"
# === INSTANCE METADATA ===
# Create instance.cfg if it doesn't exist
if [ ! -f "$INSTANCE_DIR/instance.cfg" ]; then
echo "Creating instance metadata..."
cat > "$INSTANCE_DIR/instance.cfg" << 'INSTANCECFG'
${instanceCfg}
INSTANCECFG
fi
# Create mmc-pack.json if it doesn't exist
if [ ! -f "$INSTANCE_DIR/mmc-pack.json" ]; then
echo "Creating mod loader configuration..."
cat > "$INSTANCE_DIR/mmc-pack.json" << 'MMCPACK'
${mmcPackJson}
MMCPACK
fi
# === MODS ===
echo "Syncing declarative mods..."
MANAGED_MODS="$INSTANCE_DIR/.minecraft/mods/.nix-managed"
# Remove previously managed mods
if [ -f "$MANAGED_MODS" ]; then
while IFS= read -r mod; do
if [ -f "$INSTANCE_DIR/.minecraft/mods/$mod" ]; then
echo " - Removing old: $mod"
rm -f "$INSTANCE_DIR/.minecraft/mods/$mod"
fi
done < "$MANAGED_MODS"
fi
> "$MANAGED_MODS"
# Install mods
echo ""
echo "Installing mods:"
for mod in "$MODS_SRC"/*; do
mod_name=$(basename "$mod")
echo " + $mod_name"
cp -f "$mod" "$INSTANCE_DIR/.minecraft/mods/$mod_name"
echo "$mod_name" >> "$MANAGED_MODS"
done
# === CONFIG FILES ===
# Install config files only if they don't exist (preserves user customizations)
echo ""
echo "Checking config files:"
configs_created=0
find "$CONFIGS_SRC" -type f | while read -r config_src; do
config_rel="''${config_src#$CONFIGS_SRC/}"
config_dest="$INSTANCE_DIR/.minecraft/$config_rel"
if [ ! -f "$config_dest" ]; then
mkdir -p "$(dirname "$config_dest")"
cp "$config_src" "$config_dest"
echo " + Created: $config_rel"
configs_created=$((configs_created + 1))
else
echo " ~ Exists: $config_rel (preserving user settings)"
fi
done
# === SERVERS ===
echo ""
${serversConfig.setupScript}
echo ""
echo "============================================"
echo " Setup Complete!"
echo "============================================"
echo ""
echo "Mods installed: $(wc -l < "$MANAGED_MODS")"
echo "Servers configured: ${toString (builtins.length serversConfig.servers)}"
echo ""
echo "Instance '$INSTANCE_NAME' is ready in Prism Launcher!"
echo ""
echo "To add to Steam Gaming Mode, run: minecraft-add-to-steam"
'';
# Steam integration helper
addToSteamScript = pkgs.writeShellScriptBin "minecraft-add-to-steam" ''
echo "============================================"
echo " Add Minecraft to Steam Gaming Mode"
echo "============================================"
echo ""
echo "To add Prism Launcher to Steam:"
echo ""
echo "1. Switch to Desktop Mode"
echo "2. Open Steam"
echo "3. Games Add a Non-Steam Game"
echo "4. Browse to: ${prismlauncher}/bin/prismlauncher"
echo "5. Click 'Add Selected Programs'"
echo ""
echo "Or select 'Minecraft (Controller)' from the list."
echo ""
echo "The game will appear in Gaming Mode!"
'';
# Combine all config files
allConfigFiles = modsConfig.allConfigFiles // {
# Add options.txt
"options.txt" = optionsConfig.configFile;
};
in {
home.packages = [
prismlauncher
setupMinecraftScript
addToSteamScript
pkgs.ferium # CLI mod manager for manual updates
];
# Run setup on home-manager switch
home.activation.setupMinecraftMods = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
run ${setupMinecraftScript}/bin/minecraft-setup
'';
# Desktop entry for Steam discovery
xdg.desktopEntries.minecraft-controller = {
name = "Minecraft (Controller)";
genericName = "Minecraft";
comment = "Minecraft Java Edition with controller support";
exec = "${prismlauncher}/bin/prismlauncher";
icon = "prismlauncher";
categories = [ "Game" ];
terminal = false;
settings = {
StartupWMClass = "PrismLauncher";
Keywords = "minecraft;game;gaming;";
};
};
# Config files are now installed by the setup script (copy if not exists)
# This preserves user customizations made in-game
}

View file

@ -0,0 +1,56 @@
# Controlify - Best controller support for Minecraft Java Edition
# https://modrinth.com/mod/controlify
#
# Features:
# - Steam Deck native support (back buttons, gyro, touchpads)
# - Auto-pause when Steam/Quick Access menu opens
# - Vibration/haptics and DualSense HD haptics
# - On-screen keyboard
# - Bedrock-like button guides
# - GUI navigation with controller
# - Gyroscope aiming support
#
# Dependencies: fabric-api, yacl
{ pkgs, lib, ... }:
{
# Mod metadata
name = "controlify";
description = "Adds the best controller support to Minecraft Java Edition";
homepage = "https://modrinth.com/mod/controlify";
# Mod file for Minecraft 1.21.11
file = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/DOUdJVEm/versions/4N7tPBpk/controlify-2.5.0%2B1.21.11-fabric.jar";
sha512 = "cba7986ff8519a6f99c206f46d7990fccb73d25b3120f99f01f2bdec6b4715826d27aff2f294a2536e01e95fbaf8466b33f107cc7e3e2368fab7ec6d83e7a69e";
name = "controlify.jar";
};
# Default configuration optimized for Steam Deck
configFiles = {
"controlify.json" = {
text = builtins.toJSON {
# Enable Steam Deck enhanced driver for back buttons, gyro, touchpads
enhanced_steam_deck_driver = true;
# Auto-calibrate controller deadzones on first use
auto_calibrate_deadzone = true;
# Show button guides like Bedrock Edition
show_button_guide = true;
# Enable vibration/haptics
vibration_enabled = true;
# Enable on-screen keyboard for text input
virtual_keyboard_enabled = true;
# Use Legacy Console Edition-like controls (optional)
# Set to true for more console-like feel
is_lce = false;
};
# Don't override if user has customized their settings
force = false;
};
};
}

View file

@ -0,0 +1,49 @@
# Minecraft Mods Aggregator
# This file collects all mod definitions from subdirectories
#
# To add a new mod:
# 1. Create a new folder: mods/<mod-name>/
# 2. Create default.nix with: { pkgs, lib, ... }: { name, description, homepage, file, configFiles }
# 3. The mod will be automatically included
{ pkgs, lib, ... }:
let
# Import a mod from its directory
importMod = name: import ./${name} { inherit pkgs lib; };
# List of enabled mods (directory names)
# Order matters for dependencies - load dependencies first
enabledMods = [
"fabric-api" # Core API - no dependencies
"yacl" # Config library - depends on fabric-api
"controlify" # Controller support - depends on fabric-api, yacl
];
# Import all enabled mods
mods = map importMod enabledMods;
# Create a lookup table by mod name
modsByName = lib.listToAttrs (map (mod: { name = mod.name; value = mod; }) mods);
in {
# List of all mod definitions
inherit mods;
# Lookup table for accessing mods by name
inherit modsByName;
# List of all mod files (derivations)
files = map (mod: mod.file) mods;
# Combined config files from all mods
# Returns: { "config/modname.json" = { text, force }; ... }
allConfigFiles = lib.foldl' (acc: mod:
acc // (lib.mapAttrs' (filename: value: {
name = "config/${filename}";
inherit value;
}) mod.configFiles)
) {} mods;
# Helper to get a specific mod
getMod = name: modsByName.${name} or (throw "Unknown mod: ${name}");
}

View file

@ -0,0 +1,20 @@
# Fabric API - Core modding API required by most Fabric mods
# https://modrinth.com/mod/fabric-api
{ pkgs, ... }:
{
# Mod metadata
name = "fabric-api";
description = "Essential hooks for modding with Fabric";
homepage = "https://modrinth.com/mod/fabric-api";
# Mod file for Minecraft 1.21.11
file = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/5oK85X7C/fabric-api-0.140.0%2B1.21.11.jar";
sha512 = "f33d3aa6d4da877975eb0f814f9ac8c02f9641e0192402445912ddab43269efcc685ef14d59fd8ee53deb9b6ff4521442e06e1de1fd1284b426711404db5350b";
name = "fabric-api.jar";
};
# No additional config files needed
configFiles = {};
}

View file

@ -0,0 +1,21 @@
# Yet Another Config Lib (YACL) - Configuration library
# https://modrinth.com/mod/yacl
# Required by: Controlify
{ pkgs, ... }:
{
# Mod metadata
name = "yacl";
description = "A builder-based configuration library for Minecraft";
homepage = "https://modrinth.com/mod/yacl";
# Mod file for Minecraft 1.21.11
file = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/1eAoo2KR/versions/NWFsMIGn/yet_another_config_lib_v3-3.8.1%2B1.21.11-fabric.jar";
sha512 = "3a4f015644454dffd423272a8b066e6b94d0f982184ca8c492d996a4f30eb8247b1648fe936413e178d4da3c9266c00e9b864eb9a91f0815348ba5662596986c";
name = "yacl.jar";
};
# No additional config files needed
configFiles = {};
}

View file

@ -0,0 +1,96 @@
# Declarative Minecraft Game Options
# Generates options.txt from Nix configuration
{ lib, ... }:
let
# Game options - modify these as needed
# Reference: https://minecraft.wiki/w/Options.txt
options = {
# Version (don't change unless you know what you're doing)
version = 3700;
# Video Settings
renderDistance = 12; # Chunks (2-32)
simulationDistance = 12; # Chunks
maxFps = 120; # FPS limit (10-260, or 260 for unlimited)
graphicsMode = 1; # 0=Fast, 1=Fancy, 2=Fabulous
ao = true; # Smooth lighting
enableVsync = false; # VSync
guiScale = 0; # 0=Auto, 1-4=Fixed
fullscreen = false;
entityDistanceScaling = 1.0; # Entity render distance (0.5-5.0)
mipmapLevels = 4; # Mipmap (0-4)
biomeBlendRadius = 2; # Biome blend (0-7)
renderClouds = "true"; # Clouds: "true", "false", "fast"
particles = 0; # 0=All, 1=Decreased, 2=Minimal
entityShadows = true;
# Camera/Controls
fov = 0.0; # FOV: -1.0 (30) to 1.0 (110), 0.0 = 70 (normal)
mouseSensitivity = 0.5; # 0.0-1.0
invertYMouse = false;
rawMouseInput = true;
autoJump = false; # Auto-jump (annoying, disable)
toggleCrouch = false;
toggleSprint = false;
bobView = true; # View bobbing
# Accessibility
gamma = 0.5; # Brightness: 0.0 (Moody) to 1.0 (Bright)
showSubtitles = false;
narrator = 0; # 0=Off, 1=All, 2=Chat, 3=System
darkMojangStudiosBackground = true; # Easier on eyes
# Chat/UI
chatVisibility = 0; # 0=Shown, 1=Commands only, 2=Hidden
chatOpacity = 1.0;
chatScale = 1.0;
chatWidth = 1.0;
chatHeightFocused = 1.0;
chatHeightUnfocused = 0.4375;
# Misc
pauseOnLostFocus = true; # Pause when window loses focus
advancedItemTooltips = false; # F3+H tooltips
realmsNotifications = false; # Disable Realms ads
tutorialStep = "none"; # Skip tutorial
skipMultiplayerWarning = true;
skipRealms32bitWarning = true;
# Language
lang = "en_us";
# Resource packs
resourcePacks = [ "vanilla" "Fabric Mods" ];
incompatibleResourcePacks = [];
# Main hand
mainHand = "right"; # "left" or "right"
};
# Convert Nix values to options.txt format
formatValue = v:
if builtins.isList v then
"[${lib.concatMapStringsSep "," (x: "\"${toString x}\"") v}]"
else if builtins.isBool v then
(if v then "true" else "false")
else if builtins.isString v then
v
else
toString v;
# Generate options.txt content
optionsText = lib.concatStringsSep "\n" (
lib.mapAttrsToList (k: v: "${k}:${formatValue v}") options
);
in {
inherit options optionsText;
# For use in xdg.configFile
configFile = {
text = optionsText;
# Don't force - let user customize in-game, only set defaults
force = false;
};
}

View file

@ -0,0 +1,58 @@
# Declarative Minecraft Server List
# Generates servers.dat (NBT format) from Nix configuration
{ pkgs, lib, inputs, ... }:
let
# Server list lives in the private flake's vars. Optional per-server keys:
# hideAddress = false; # hide IP in UI
# acceptTextures = 1; # 0=prompt, 1=enabled, 2=disabled
servers = inputs.secrets.vars.minecraft.servers;
# Python script to generate servers.dat using nbtlib
generateServersDat = pkgs.writers.writePython3Bin "generate-servers-dat" {
libraries = [ pkgs.python3Packages.nbtlib ];
} ''
import sys
import json
from nbtlib import File, Compound, List, String, Byte
servers_json = sys.argv[1]
output_path = sys.argv[2]
servers = json.loads(servers_json)
server_list = List[Compound]([
Compound({
'name': String(s['name']),
'ip': String(s['ip']),
'hideAddress': Byte(s.get('hideAddress', 0)),
**({"acceptTextures": Byte(s['acceptTextures'])}
if 'acceptTextures' in s else {})
})
for s in servers
])
nbt_file = File({'servers': server_list})
nbt_file.save(output_path, gzipped=False)
print(f"Generated {output_path} with {len(servers)} server(s)")
'';
# JSON representation of servers for the script
serversJson = builtins.toJSON servers;
in {
inherit servers generateServersDat serversJson;
# Setup script fragment to generate servers.dat
setupScript = ''
SERVERS_DAT="$INSTANCE_DIR/.minecraft/servers.dat"
# Only generate if servers.dat doesn't exist (preserve user additions)
if [ ! -f "$SERVERS_DAT" ]; then
echo "Generating servers.dat..."
${generateServersDat}/bin/generate-servers-dat '${serversJson}' "$SERVERS_DAT"
else
echo "servers.dat exists, skipping (delete to regenerate)"
fi
'';
}

View file

@ -0,0 +1,257 @@
# Declarative OpenRCT2 (Claude-playable "coding-agent" fork) for Steam Deck
#
# This installs jaysobel/OpenRCT2 @ coding-agent — a fork of OpenRCT2 that adds an
# in-game "AI agent terminal": OpenRCT2 spawns the `claude` CLI (Claude Code) in a
# pseudo-terminal and lets it drive the park through a small JSON-RPC bridge binary
# called `rctctl`. The base game is unchanged, so the original RCT2 game data is
# still REQUIRED to play.
#
# Packaging strategy
# We override nixpkgs' `openrct2` with `overrideAttrs` rather than rewriting it,
# so we inherit upstream's data provisioning (the objects/openmusic/opensfx/
# title-sequences fetchurls + `postUnpack`) and its install() logic untouched.
# We only swap in the fork source and layer on what the fork additionally needs:
# * src -> the jaysobel coding-agent commit (pinned by rev+hash)
# * python3 -> the fork's root CMakeLists unconditionally
# `find_package(Python3 ... REQUIRED)`s it (base does not)
# * libvterm-neovim -> the agent terminal renders ANSI via libvterm; CMake
# probes the pkg-config module `vterm` (the neovim fork's
# module/API) and hard-fails (FATAL_ERROR) when missing
# * cmakeFlags -> REPLACED: the fork uses DOWNLOAD_OPENMSX (the older
# option name), NOT 0.5.x's DOWNLOAD_OPENMUSIC, so the
# inherited flag would silently not disable the download
# * preConfigure="" -> DROPPED: this fork has no assets.json, so nixpkgs 0.5.x's
# "grep the asset URLs out of assets.json" check would fail;
# the asset versions are pinned in CMakeLists.txt instead
# * buildFlags -> `agent_bundle` (game + CLI + rctctl + graphics assets)
# * doInstallCheck -> false: versionCheckHook would assert a version string the
# fork does not report
# * postInstall -> copy the `rctctl` binary out (it has NO install() rule)
# and materialise the "repo root" the launcher requires
#
# In-game agent runtime
# When you open the in-game terminal the launcher (AIAgentLaunch.cpp) first
# insists on finding a "repo root" — a directory holding BOTH `rctctl/CMakeLists
# .txt` and `src/`, with the rctctl binary at `<root>/build/rctctl/rctctl` and the
# agent brief at `<root>/ai-agent-workspace/IN_GAME_AGENT.md`. It locates that
# root by walking up from the cwd / data dir / exe dir. Because the Nix store has
# none of that next to the binary, `postInstall` builds a minimal synthetic root
# under `$out/share/openrct2-agent/repo` and the wrapper `--chdir`s into it so the
# cwd probe matches on the first try. OpenRCT2 resolves its OWN data via the
# install prefix / XDG dirs (never cwd), so the chdir is safe for normal play.
#
# The terminal then launches the real `claude` binary, found by a plain PATH scan
# (`FindExecutable("claude")`). Two things make that reliable here:
# 1. The Steam Deck profile imports ../../terminal/programs/claude, enabling
# `programs.claude-code` — so the deck user has a fully configured Claude Code
# (settings, agents, MCP, plugins) under ~/.claude, read from $HOME at runtime.
# 2. We ALSO prepend `pkgs.claude-code` to this package's own wrapper PATH, so the
# openrct2 process sees `claude` even when launched from Steam Gaming Mode,
# which does NOT source the Home Manager session PATH. Both reference the same
# `pkgs.claude-code`, so the ~/.claude config applies either way.
# claude-code is unfree (this host already allows unfree for Steam). If `claude` is
# somehow absent the launcher degrades to a bundled REPL (`scripts/agent_bootstrap.sh`,
# needs python3 — also on the wrapper PATH); that fallback is spawned via a hardcoded
# `/bin/bash`, which exists on SteamOS but not on stock NixOS.
#
# RCT2 game data
# No build-time path is hardcoded. On first launch OpenRCT2 prompts for the RCT2
# install directory. This works whether RCT2 lives on the internal disk or an SD
# card. The Steam install is typically at:
# ~/.steam/steam/steamapps/common/RollerCoaster Tycoon 2 Triple Thrill Pack
#
{ config, pkgs, lib, ... }:
let
# jaysobel/OpenRCT2 @ coding-agent. Pinned to an exact commit; the fork has no
# release tags. The hash is the NAR hash of the unpacked fetchFromGitHub output.
openrct2 = pkgs.openrct2.overrideAttrs (old: {
pname = "openrct2-agent";
# Cosmetic only (doInstallCheck is disabled below). The fork tracks a pre-0.5.x
# tree, so we do NOT label it 0.5.x to avoid implying release parity. (We ship
# the inherited objects v1.7.9 data — a superset of the fork's pinned v1.7.3.)
version = "unstable-3c278a85";
src = pkgs.fetchFromGitHub {
owner = "jaysobel";
repo = "OpenRCT2";
rev = "3c278a8544d3f8f280e5043b5396c3a33751e20e";
sha256 = "sha256-AQyTFQDr+ORJROoBxhy6KnuCcl/tQqCT1SGjkzsjYig=";
};
# The fork's CMake unconditionally requires a Python3 interpreter at configure
# time (CLI-validation test harness registration). pkg-config + makeWrapper +
# versionCheckHook are already in the inherited nativeBuildInputs.
nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.python3 ];
# libvterm-neovim ships `vterm.h` + a pkg-config module named `vterm`, which is
# exactly what `PKG_CHECK_MODULES(LIBVTERM ... IMPORTED_TARGET vterm)` looks for
# and what the fork's terminal code targets (neovim libvterm 0.3.x API). The
# unrelated `libvterm` (0.99.x) would not satisfy the probe.
buildInputs = old.buildInputs ++ [ pkgs.libvterm-neovim ];
# Replaced wholesale. Same intent as upstream (don't download assets at build
# time; no Discord RPC) but using THIS fork's option names. Note the fork uses
# DOWNLOAD_OPENMSX, not 0.5.x's DOWNLOAD_OPENMUSIC. DOWNLOAD_REPLAYS is a
# CMAKE_DEPENDENT_OPTION that is forced OFF unless WITH_TESTS, so it is omitted.
cmakeFlags = [
(lib.cmakeBool "DOWNLOAD_OBJECTS" false)
(lib.cmakeBool "DOWNLOAD_TITLE_SEQUENCES" false)
(lib.cmakeBool "DOWNLOAD_OPENSFX" false)
(lib.cmakeBool "DOWNLOAD_OPENMSX" false)
(lib.cmakeBool "DISABLE_DISCORD_RPC" true)
];
# Convenience target that builds everything the agent needs in one shot:
# rctctl + openrct2 + openrct2-cli + graphics (the g2/fonts/tracks .dat
# generators). `make install` still installs the game/CLI/data regardless,
# because CMake's `install` target depends on `preinstall` -> `all`.
buildFlags = [ "agent_bundle" ];
# Upstream's preConfigure greps asset URLs out of assets.json to verify pinned
# versions. This fork has no assets.json (versions live in CMakeLists.txt via
# set(..._VERSION ...)), so that check would error out. Drop it — the inherited
# fetchurls/postUnpack still supply the data; see residual risk re: objects ver.
preConfigure = "";
# Extend the inherited postPatch (which rewrites the doc path in
# Platform.Linux.cpp — that "/usr/share/doc/openrct2" literal is still present
# exactly once on this fork) with a Linux/GCC portability fix for the fork's own
# agent code, which was written against macOS/clang: Diagnostic.cpp uses
# time/localtime/strftime without <ctime> (libc++ includes it transitively,
# libstdc++ does not).
postPatch = (old.postPatch or "") + ''
substituteInPlace src/openrct2/Diagnostic.cpp \
--replace-fail '#include "Diagnostic.h"' '#include <ctime>${"\n"}#include "Diagnostic.h"'
'';
# OpenRCT2 builds with global -Werror and a large strict warning set. The fork's
# own agent code was written against macOS/clang and trips several warnings that
# are GCC-only or behave differently there — e.g. ShellProcess.cpp's
# `errno == EAGAIN || errno == EWOULDBLOCK` (-Wlogical-op: those macros are equal
# on Linux) and AIAgentTerminal.cpp parameters shadowing window members (-Wshadow).
# We package this third-party fork rather than maintain it, so downgrade -Werror to
# plain warnings instead of patching each site. cc-wrapper appends NIX_CFLAGS_COMPILE
# after the project's own flags, so this wins.
NIX_CFLAGS_COMPILE = "-Wno-error";
# The fork reports a different version string than upstream's `version`, which
# would trip versionCheckHook. Disable the install check entirely.
doInstallCheck = false;
# Keep the inherited wrapProgram (rct1Path/rct2Path support — no-ops here since
# this module pins neither), then provision the agent bits on top.
postInstall = (old.postInstall or "") + ''
###########################################################################
# jaysobel coding-agent wiring (in-game Claude terminal)
###########################################################################
# postInstall runs from the CMake build dir (<src>/build); the source tree is
# its parent. agent_bundle built rctctl at build/rctctl/rctctl.
srcRoot=$(cd .. && pwd)
rctctlBin=$(find "$PWD" -type f -name rctctl -executable | head -n1)
if [ -z "$rctctlBin" ]; then
echo "openrct2-agent: rctctl binary not found after agent_bundle build" >&2
exit 1
fi
# rctctl has no install() rule. Put it on PATH so the agent child (claude or
# the bootstrap REPL) can discover it.
install -Dm755 "$rctctlBin" "$out/bin/rctctl"
# Synthetic "repo root" the launcher insists on. LooksLikeRepoRoot() only
# checks that rctctl/CMakeLists.txt and src/ exist; FindRctctlBinary() wants
# <root>/build/rctctl/rctctl; SeedWorkspaceReadme() copies <root>/ai-agent-
# workspace/IN_GAME_AGENT.md -> $HOME/.openrct2-agent/.claude/CLAUDE.md.
repo="$out/share/openrct2-agent/repo"
install -Dm644 "$srcRoot/rctctl/CMakeLists.txt" "$repo/rctctl/CMakeLists.txt"
install -Dm755 "$rctctlBin" "$repo/build/rctctl/rctctl"
install -Dm644 "$srcRoot/ai-agent-workspace/IN_GAME_AGENT.md" "$repo/ai-agent-workspace/IN_GAME_AGENT.md"
install -Dm755 "$srcRoot/scripts/agent_bootstrap.sh" "$repo/scripts/agent_bootstrap.sh"
mkdir -p "$repo/src" # marker dir; only its existence is checked
###########################################################################
# Agent terminal fonts
###########################################################################
# The fork's TerminalFontPipeline (AIAgentTerminal.cpp) loads TTFs by hardcoded
# filename from <data-dir>/data/fonts/ — i.e. $out/share/openrct2/data/fonts/.
# But CMake's install(DIRECTORY "data/" ...) FLATTENS data/ into
# $out/share/openrct2, so the bundled TTFs land in $out/share/openrct2/fonts and
# the loader's data/fonts/ path does not exist → no font loads → the agent
# console renders blank. Recreate the dir the loader expects, and make the
# primary monospace a JetBrains Mono NERD FONT (the Mono variant keeps every
# glyph single-cell) so Claude's TUI icons render instead of tofu. The two Noto
# Sans Symbols faces (installed by the flattened data/) stay as glyph fallbacks.
install -Dm644 ${pkgs.nerd-fonts.jetbrains-mono}/share/fonts/truetype/NerdFonts/JetBrainsMono/JetBrainsMonoNerdFontMono-Regular.ttf \
"$out/share/openrct2/data/fonts/JetBrainsMono-Regular.ttf"
install -Dm644 "$out/share/openrct2/fonts/NotoSansSymbols-Regular.ttf" \
"$out/share/openrct2/data/fonts/NotoSansSymbols-Regular.ttf"
install -Dm644 "$out/share/openrct2/fonts/NotoSansSymbols2-Regular.ttf" \
"$out/share/openrct2/data/fonts/NotoSansSymbols2-Regular.ttf"
# Re-wrap: chdir into the synthetic root so DetectRepoRoot()'s cwd seed
# matches, and make claude (the agent), rctctl, and python3 (REPL fallback)
# reachable on the openrct2 process PATH regardless of how it was launched —
# Steam Gaming Mode does not source the Home Manager session PATH. PATH is
# PREPENDED, so a claude already on PATH (from programs.claude-code) still wins.
wrapProgram "$out/bin/openrct2" \
--chdir "$repo" \
--prefix PATH : "$out/bin" \
--prefix PATH : ${lib.makeBinPath [ pkgs.claude-code pkgs.python3 pkgs.bash ]}
'';
meta = old.meta // {
description = "OpenRCT2 jaysobel coding-agent fork (Claude-playable RollerCoaster Tycoon 2)";
homepage = "https://github.com/jaysobel/OpenRCT2";
changelog = "https://github.com/jaysobel/OpenRCT2/commits/coding-agent";
mainProgram = "openrct2";
platforms = lib.platforms.linux;
broken = false;
};
});
# Steam integration helper
addToSteamScript = pkgs.writeShellScriptBin "openrct2-add-to-steam" ''
echo "============================================"
echo " Add OpenRCT2 to Steam Gaming Mode"
echo "============================================"
echo ""
echo "To add OpenRCT2 to Steam:"
echo ""
echo "1. Switch to Desktop Mode"
echo "2. Open Steam"
echo "3. Games -> Add a Non-Steam Game"
echo "4. Browse to: ${openrct2}/bin/openrct2"
echo "5. Click 'Add Selected Programs'"
echo ""
echo "Or select 'OpenRCT2' from the list."
echo ""
echo "On first launch, point OpenRCT2 at your RCT2 data, e.g.:"
echo " ~/.steam/steam/steamapps/common/RollerCoaster Tycoon 2 Triple Thrill Pack"
echo ""
echo "The game will appear in Gaming Mode!"
echo ""
echo "In-game: click the robot toolbar button to open the Claude agent terminal."
'';
in {
home.packages = [
openrct2
addToSteamScript
];
# Desktop entry for Steam discovery
xdg.desktopEntries.openrct2 = {
name = "OpenRCT2";
genericName = "RollerCoaster Tycoon 2";
comment = "Open source re-implementation of RollerCoaster Tycoon 2";
exec = "${openrct2}/bin/openrct2";
icon = "openrct2";
categories = [ "Game" "Simulation" ];
terminal = false;
settings = {
StartupWMClass = "openrct2";
Keywords = "rct;rollercoaster;tycoon;game;gaming;";
};
};
}

60
home/programs/gpg.nix Normal file
View file

@ -0,0 +1,60 @@
{ pkgs, ... }:
{
programs.gpg = {
enable = true;
# Use standard GPG settings
settings = {
# Use AES256 for symmetric encryption
personal-cipher-preferences = "AES256 AES192 AES";
personal-digest-preferences = "SHA512 SHA384 SHA256";
personal-compress-preferences = "ZLIB BZIP2 ZIP Uncompressed";
default-preference-list = "SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed";
cert-digest-algo = "SHA512";
s2k-digest-algo = "SHA512";
s2k-cipher-algo = "AES256";
charset = "utf-8";
no-comments = "";
no-emit-version = "";
no-greeting = "";
# Show fingerprints
keyid-format = "0xlong";
with-fingerprint = "";
list-options = "show-uid-validity";
verify-options = "show-uid-validity";
# Ensure cross-certification on subkeys
require-cross-certification = "";
require-secmem = "";
no-symkey-cache = "";
armor = "";
use-agent = "";
# Disable recipient key ID in messages (privacy)
throw-keyids = "";
# Default signing key (master key - GPG auto-selects signing subkey from inserted YubiKey)
default-key = "0x785150ECAABF7352";
# When encrypting, always include yourself as a recipient
default-recipient-self = "";
};
# Smart card daemon settings for YubiKey
scdaemonSettings = {
# Disable CCID driver to let PC/SC handle it
disable-ccid = true;
# Use PC/SC for card access
pcsc-driver = "${pkgs.pcsclite.lib}/lib/libpcsclite.so";
};
};
home.packages = [ pkgs.gnupg ];
}

10
home/programs/gtk.nix Normal file
View file

@ -0,0 +1,10 @@
{
gtk = {
enable = true;
gtk3.extraConfig = {
gtk-im-module = "fcitx";
};
};
}

53
home/programs/hister.nix Normal file
View file

@ -0,0 +1,53 @@
{ config, pkgs, ... }:
let
conf = config.xdg.configHome;
home = config.home.homeDirectory;
yamlFormat = pkgs.formats.yaml { };
settings = {
app = {
directory = "${conf}/hister";
search_url = "https://google.com/search?q={query}";
log_level = "info";
debug_sql = false;
};
server = {
address = "127.0.0.1:4433";
database = "${home}/Documents/hister.db";
};
hotkeys = {
"alt+k" = "select_previous_result";
"alt+j" = "select_next_result";
"/" = "focus_search_input";
enter = "open_result";
"alt+enter" = "open_result_in_new_tab";
"alt+o" = "open_query_in_search_engine";
"alt+v" = "view_result_popup";
tab = "autocomplete";
"?" = "show_hotkeys";
};
};
in
{
home.packages = with pkgs; [
hister
];
xdg.configFile."hister/config.yml".source = yamlFormat.generate "hister-config.yml" settings;
systemd.user.services.hister = {
Unit = {
Description = "Hister - Web history listener";
After = [ "default.target" ];
};
Service = {
ExecStart = "${pkgs.hister}/bin/hister listen";
Restart = "on-failure";
RestartSec = 5;
};
Install = {
WantedBy = [ "default.target" ];
};
};
}

View file

@ -0,0 +1,60 @@
{ config, ... }:
let
# Stylix is only wired on framework13; live-iso/phantom lack it.
hasStylix = config ? lib && config.lib ? stylix;
# KeePassXC ApplicationTheme: "auto" follows the desktop, else light/dark.
# config.stylix.polarity is "dark"/"light", which are valid theme values.
appTheme = if hasStylix then config.stylix.polarity else "auto";
in
{
programs.keepassxc = {
enable = true;
autostart = true;
settings = {
General = {
ConfigVersion = 2;
# Single running instance; reopen prior databases on launch.
SingleInstance = true;
RememberLastDatabases = true;
RememberLastKeyFiles = true;
OpenPreviousDatabasesOnStartup = true;
AutoSaveAfterEveryChange = true;
AutoSaveOnExit = true;
AutoReloadOnChange = true;
# No update nag (NixOS owns the package).
UpdateCheckMessageShown = true;
};
GUI = {
ApplicationTheme = appTheme;
CompactMode = true;
CheckForUpdates = false;
# Close button minimises instead of quitting -> process stays alive so
# Firefox's KeePassXC-Browser connection survives. Quit deliberately
# with Ctrl+Q. MinimizeToTray hides the window on Wayland/Hyprland
# (no visible tray icon without a tray host, which is fine).
MinimizeOnClose = true;
MinimizeToTray = true;
ShowTrayIcon = true;
};
Security = {
ClearClipboard = true;
ClearClipboardTimeout = 10;
LockDatabaseIdle = false;
LockDatabaseIdleSeconds = 900;
LockDatabaseScreenLock = true;
LockDatabaseOnUserSwitch = true;
PasswordsHidden = true;
HidePasswordPreviewPanel = true;
};
Browser = {
Enabled = true;
};
};
};
}

View file

@ -0,0 +1,7 @@
{ pkgs, config, lib, ... }:
{
home.packages = with pkgs; [
audacious
];
}

View file

@ -0,0 +1,21 @@
{ pkgs, ... }:
# media - control and enjoy audio/video
{
home.packages = with pkgs; [
# audio control
pulsemixer
pwvucontrol
crosspipe # helvum removed in nixpkgs 26.05 (unmaintained); crosspipe is the successor
# audio
amberol
librepods
# images
loupe
# videos
celluloid
grayjay
];
}

View file

@ -0,0 +1,142 @@
{ pkgs, ... }:
# newsboat — terminal RSS, vim-keyed. Feeds migrated from Read You
# (Read-You OPML export; tags = old reader categories). The filelist.io feed
# was deliberately left out: its URL embeds a private passkey — secret, not
# for git. Re-add via sops if needed.
let
# bookmark-cmd target: save the full article as a markdown note in the Keep
# vault. newsboat calls: <cmd> <url> <title> <description> <feed-title>
newsboat-clip = pkgs.writeShellApplication {
name = "newsboat-clip";
runtimeInputs = [ pkgs.python3Packages.trafilatura pkgs.coreutils ];
text = ''
url=''${1:-}
title=''${2:-untitled}
desc=''${3:-}
feed=''${4:-}
dir="$HOME/Documents/Keep/5 - Main Notes"
mkdir -p "$dir"
# filename: article title, path-hostile chars -> _, whitespace collapsed
safe=$(printf '%s' "$title" | tr '/\\:*?"<>|' '_________' | tr -s ' ' | sed 's/^ *//; s/ *$//')
[ -z "$safe" ] && safe=untitled
file="$dir/$safe.md"
n=1
while [ -e "$file" ]; do
file="$dir/$safe ($n).md"
n=$((n + 1))
done
# full text via trafilatura; fall back to feed description on failure
body=$(trafilatura -u "$url" --markdown 2>/dev/null || true)
[ -z "$body" ] && body="$desc"
# escape double-quotes for YAML frontmatter values
esc() { printf '%s' "$1" | sed 's/"/\\"/g'; }
today=$(date +%Y-%m-%d)
{
printf -- '---\n'
printf 'title: "%s"\n' "$(esc "$title")"
printf 'source: "%s"\n' "$(esc "$url")"
printf 'feed: "%s"\n' "$(esc "$feed")"
printf 'date: %s\n' "$today"
printf 'topic: ""\n'
printf 'tags: [clipping]\n'
printf -- '---\n\n'
printf '%s\n' "$body"
} > "$file"
'';
};
# notify-program target: notify-format string arrives as args
newsboat-notify = pkgs.writeShellApplication {
name = "newsboat-notify";
runtimeInputs = [ pkgs.libnotify ];
text = ''
notify-send --app-name=newsboat "Newsboat" "$*"
'';
};
in
{
home.packages = [
pkgs.newsboat
pkgs.python3Packages.trafilatura
newsboat-clip
newsboat-notify
];
# plain files instead of programs.newsboat so the urls list stays a
# readable one-line-per-feed file rather than 84 Nix attrsets
xdg.configFile."newsboat/urls".source = ./urls;
xdg.configFile."newsboat/config".text = ''
# behaviour
auto-reload yes
reload-time 30
reload-threads 8
show-read-feeds no
cleanup-on-quit yes
datetime-format "%Y-%m-%d"
text-width 100
# killfile — hide obvious junk; extend as needed
ignore-mode "display"
ignore-article "*" "title =~ \"[Ss]ponsored|[Aa]dvertisement\""
# triage
prepopulate-query-feeds yes
# newsboat 2.41 inverts the date direction: date-asc = NEWEST first
# (verified empirically; the man page says the opposite and is wrong here)
article-sort-order date-asc
articlelist-format "%4i %f %D %?T?|%-17T| ?%t"
feedlist-format "%4i %n %11u %t"
highlight article "[Tt]aiwan|[Kk]ubernetes|[Nn]ix([Oo][Ss])?|[Bb]ogdan|[Bb]uduroiu" yellow default bold
# capture → Obsidian (Keep vault, 5 - Main Notes/)
bookmark-cmd "${newsboat-clip}/bin/newsboat-clip"
bookmark-autopilot yes
# notifications (global — fires for any new articles)
notify-program "${newsboat-notify}/bin/newsboat-notify"
notify-format "newsboat: %d new (%n unread)"
notify-always no
# vim-style navigation
bind-key j down
bind-key k up
bind-key J next-feed articlelist
bind-key K prev-feed articlelist
bind-key g home
bind-key G end
bind-key h quit articlelist
bind-key l open feedlist
bind-key F edit-flags
bind-key b bookmark
bind-key A mark-feed-read
bind-key C mark-all-feeds-read
bind-key U show-urls
bind-key R reload-all
bind-key s sort
bind-key S rev-sort
# open links
browser "uwsm app -- xdg-open %u"
macro o set browser "uwsm app -- firefox --new-tab %u"; open-in-browser ; set browser "uwsm app -- xdg-open %u"
macro v set browser "uwsm app -- celluloid %u"; open-in-browser ; set browser "uwsm app -- xdg-open %u"
macro y set browser "echo %u | wl-copy"; open-in-browser ; set browser "uwsm app -- xdg-open %u"
# full-text on demand — fetch the real article into vim even on truncated feeds
macro f set browser "trafilatura -u %u --markdown --images 2>/dev/null | nvim -R -c 'set filetype=markdown' -"; open-in-browser ; set browser "uwsm app -- xdg-open %u"
# colors — palette slots inherit the stylix-themed terminal
color background default default
color listnormal default default
color listnormal_unread default default bold
color listfocus color0 color4
color listfocus_unread color0 color4 bold
color info color4 default bold
color article default default
'';
}

View file

@ -0,0 +1,70 @@
"query:★ Unread:unread = \"yes\""
"query:⚑ Read-later:flags # \"r\""
"query:Eng + AI:(tags # \"engineering\" or tags # \"ai\") and unread = \"yes\""
"query:Taiwan watch:title =~ \"[Tt]aiwan|[Cc]hina|[Pp][Ll][Aa]\""
https://github.com/ReadYouApp/ReadYou/releases.atom default "~ReadYou Releases"
https://www.aluxian.com/feed/ friends "~Alexandru ROSIANU"
https://blog.calebjay.com/index.xml friends "~Caleb's Blog"
http://danluu.com/atom.xml engineering "~Dan Luu"
https://domk.website/blog/feed.xml engineering "~Domk.website Blog"
https://jvns.ca/atom.xml engineering "~Julia Evans"
https://www.theregister.com/software/devops/headlines.atom engineering "~The Register - Software: Devops"
https://www.theregister.com/software/virtualization/headlines.atom engineering "~The Register - Software: Virtualization"
https://www.saxrag.com/feed.xml engineering "~saxrag"
https://sxyz.blog/index.xml engineering "~Sxyazis blog"
https://buduroiu.com/links/index.xml engineering "~links on Bogdan Buduroiu"
https://pagedout.institute/rss.xml engineering "~Paged Out! Magazine"
https://minimaxir.com/post/index.xml engineering "~Posts on Max Woolf's Blog"
https://www.youtube.com/feeds/videos.xml?channel_id=UCxAS_aK7sS2x_bqnlJHDSHw food "~America's Test Kitchen"
https://cafeandcowork.com/feed.xml food "~Cafe and Cowork"
https://pipedapi.kavin.rocks/feed/unauthenticated/rss?channels=UCn5TG-OvjnX9O1UC7fpyUhA food "~Hana Kitchen - 日式料理"
https://www.bingingwithbabish.com/recipes?format=RSS food "~Recipes - Binging With Babish"
http://feeds.seriouseats.com/seriouseatsfeaturesvideos food "~Serious Eats"
http://hunch.net/?feed=rss2 "machine learning" "~Machine Learning (Theory)"
http://amti.csis.org/feed/ military "~Asia Maritime Transparency Initiative"
https://xcancel.com/PLAOpsOSINT/rss military "~PLAOps / @PLAOpsOSINT"
https://xcancel.com/tingtingliuTVBS/rss military "~Tingting Liu 劉亭廷 / @tingtingliuTVBS"
https://warontherocks.com/feed/ military "~War on the Rocks"
https://xcancel.com/MoNDefense/rss military "~國防部 Ministry of National Defense, R.O.C. 🇹🇼 / @MoNDefense"
https://restofworld.org/feed/latest/ news "~Rest of World -"
https://www.15minutecity.com/blog?format=rss news "~blog - 15-Minute City"
https://www.anildash.com/feed.xml "opinion / independent" "~Anil Dash"
https://howtodothingswithmemes.substack.com/feed "opinion / independent" "~How To Do Things With Memes"
https://pluralistic.net/feed/ "opinion / independent" "~Pluralistic: Daily links from Cory Doctorow"
https://etymology.substack.com/feed "opinion / independent" "~The Etymology Nerd"
https://www.theindex.media/rss/ "opinion / independent" "~The Index."
https://www.unpopularfront.news/feed "opinion / independent" "~Unpopular Front"
https://www.joanwestenberg.com/rss/ "opinion / independent" "~Westenberg."
https://geohot.github.io/blog/feed.xml "opinion / independent" "~the singularity is nearer"
https://jkap.io/feed/ "opinion / independent" "~jae kaplan"
https://www.wheresyoured.at/feed "opinion / independent" "~Ed Zitron's Where's Your Ed At"
http://www.sciencedaily.com/newsfeed.xml science "~ScienceDaily"
http://news.nationalgeographic.com/index.rss science "~National Geographic News"
https://nautil.us/feed/ science "~Nautilus"
https://www.theregister.com/on_prem/personal_tech/headlines.atom tech "~The Register - On-Prem: Personal Tech"
https://www.theregister.com/on_prem/storage/headlines.atom tech "~The Register - On-Prem: Storage"
https://www.404media.co/rss/ tech "~404 Media"
https://buduroiu.com/index.xml tech "~Bogdan Buduroiu"
http://info.crunchbase.com/feed/ tech "~Crunchbase"
http://www.darkreading.com/rss/all.xml tech "~Dark Reading"
https://www.theverge.com/authors/kylie-robison/rss tech "~Kylie Robison | The Verge"
https://github.com/arkenfox/user.js/releases.atom tech "~Release notes from user.js"
https://tante.cc/feed/ tech "~Smashing Frames"
https://www.theepochtimes.com/c-tech/feed/ tech "~Tech News"
https://techwontsave.us/feed.xml tech "~Tech Wont Save Us"
https://www.theregister.com/security/headlines.atom tech "~The Register Security"
https://www.bleepingcomputer.com/feed/ tech "~BleepingComputer"
http://blog.project2049.net/feeds/posts/default 台灣新聞 "~Project 2049 Institute"
http://blog.readmoo.com/feed/ 台灣新聞 "~Readmoo閱讀最前線"
https://www.trendingonweibo.com/feed.xml 台灣新聞 "~Trending on Weibo | Developing stories from China"
https://feeds.feedburner.com/rsscna/mainland 台灣新聞 "~中央社即時新聞 兩岸透視"
https://feeds.feedburner.com/rsscna/politics 台灣新聞 "~中央社即時新聞 國內政治"
https://papers.takara.ai/api/feed ai "~Takara TLDR - Daily AI Papers"
https://chinarxiv.org/feed/atom.xml?category=ai_cs ai "~ChinaRxiv: in ai_cs"
https://rss.arxiv.org/atom/cs.ai ai "~cs.AI updates on arXiv.org"
https://axisofordinary.substack.com/feed ai "~Axis of Ordinary"
https://alexzhang13.github.io/feed.xml ai "~Alex Zhang"
https://novaramedia.com/feed/ "opinion / independent" "~Novara Media"
https://www.youtube.com/feeds/videos.xml?channel_id=UCOzMAa6IhV6uwYQATYG_2kg "opinion / independent" "~Novara Media (Youtube)"
https://simonwillison.net/atom/entries/ ai "~Simon Willison (entries)"
https://uruky.com/rss.xml tech "~Uruky"

View file

@ -0,0 +1,16 @@
{ pkgs, inputs, ... }:
let
pkgs-unstable = import inputs.nixpkgs-unstable {
inherit (pkgs.stdenv.hostPlatform) system;
config.allowUnfree = true;
};
in
{
home.packages = with pkgs; [
pkgs-unstable.telegram-desktop
discord-ptb
signal-desktop
element-desktop
];
}

View file

@ -0,0 +1,22 @@
{ pkgs, ... }:
{
imports = [
./thunar.nix
./zathura.nix
./obsidian
./thunderbird.nix
];
home.packages = with pkgs; [
invoice
libreoffice
onlyoffice-desktopeditors
gimp
pinta
slack
sheets
zulip-term
xournalpp
];
}

View file

@ -0,0 +1,262 @@
{ config
, lib
, pkgs
, ...
}:
let
# Shared app.json defaults. The obsidian module merges `settings` per
# top-level key (app/appearance/...), NOT deeply — a vault that sets
# `settings.app` replaces this whole block, so reuse it explicitly.
appDefaults = {
defaultViewMode = "preview";
tabSize = 2;
vimMode = true;
focusNewTab = true;
propertiesInDocument = "visible";
foldHeading = true;
foldIndent = true;
rightToLeft = false;
spellcheck = true;
autoPairBrackets = true;
autoPairMarkdown = true;
smartIndentList = true;
useTab = true;
autoConvertHtml = true;
promptDelete = false;
trashOption = "local";
alwaysUpdateLinks = false;
newFileLocation = "current";
newLinkFormat = "shortest";
useMarkdownLinks = false;
showUnsupportedFiles = true;
};
website = "${config.home.homeDirectory}/development/bruvduroiu/website";
blogsFolder = "${config.home.homeDirectory}/Documents/Keep/4 - Blogs";
# Single action: mirror the vault's `4 - Blogs` folder into the Hugo repo's content/.
# Everything else (build, commit, push, local preview) is handled manually outside
# Obsidian. rsync --delete gives true mirror semantics (deletions propagate); cp -a
# would leave ghosts of deleted posts behind.
blog-sync = pkgs.writeShellApplication {
name = "blog-sync";
runtimeInputs = with pkgs; [ rsync ];
text = ''
vault="${blogsFolder}"
repo="${website}"
# Safety: refuse to mirror an unseeded vault — rsync --delete would otherwise
# wipe every post in the repo.
if [ ! -d "$vault/blog" ] || [ ! -f "$vault/_index.md" ]; then
echo "blog-sync: '4 - Blogs' looks unseeded (missing blog/ or _index.md); aborting." >&2
exit 1
fi
rsync -a --delete --exclude='.obsidian' "$vault/" "$repo/content/"
echo "blog-sync: mirrored 4 - Blogs -> $repo/content"
'';
};
# One Shell-commands `data.json` command entry. Mirrors the plugin's
# newShellCommandConfiguration() exactly — the module writes data.json as a
# read-only store symlink, and Shell commands uses the loaded file as-is (it only
# merges defaults to backfill `debug`/`settings_version`), so every field a current
# install expects must be present or command registration can throw.
mkShellCommand = id: alias: command: {
inherit id alias;
platform_specific_commands.default = command;
shells = { };
icon = null;
confirm_execution = false;
ignore_error_codes = [ ];
input_contents.stdin = null;
output_handlers = {
stdout = { handler = "ignore"; convert_ansi_code = true; };
stderr = { handler = "notification"; convert_ansi_code = true; };
};
output_wrappers = { stdout = null; stderr = null; };
output_channel_order = "stdout-first";
output_handling_mode = "buffered";
execution_notification_mode = null;
events = { };
debounce = null;
command_palette_availability = "enabled";
preactions = [ ];
variable_default_values = { };
};
# Shell commands' built-in shells hardcode /bin/bash, /bin/dash, /bin/zsh in
# getBinaryPath() and spawn THAT (the configured shell string only drives matching) —
# none of those exist on NixOS, which provides only /bin/sh. So a built-in shell is
# unusable here by construction (an unset shell falls back to $SHELL=fish, which the
# plugin also doesn't support). Define a custom shell pinned to /bin/sh instead.
# Mirrors CustomShellModel.getDefaultConfiguration(); our command is an absolute-path
# binary, so a POSIX sh is all that is needed.
nixShellId = "nixos-binsh";
nixShell = {
id = nixShellId;
name = "NixOS /bin/sh";
description = "POSIX sh via /bin/sh built-in shells hardcode nonexistent /bin/bash and /bin/dash on NixOS.";
binary_path = "/bin/sh";
shell_arguments = [ "-c" "{{shell_command_content}}" ];
host_platform = "linux";
host_platform_configurations = { };
shell_platform = null;
escaper = "UnixShell";
path_translator = null;
shell_command_wrapper = null;
shell_command_test = null;
};
# Full SC_MainSettings default object (settings_version pinned to the packaged
# plugin version, so no migration runs against the read-only file) + our command.
shellcommandsSettings = {
settings_version = "0.23.0";
debug = false;
obsidian_command_palette_prefix = "Execute: ";
preview_variables_in_command_palette = true;
show_autocomplete_menu = true;
working_directory = "";
default_shells = { linux = nixShellId; };
environment_variable_path_augmentations = { };
show_installation_warnings = true;
error_message_duration = 20;
notification_message_duration = 10;
execution_notification_mode = "disabled";
output_channel_clipboard_also_outputs_to_notification = true;
output_channel_notification_decorates_output = true;
enable_events = true;
approve_modals_by_pressing_enter_key = true;
command_palette.re_execute_last_shell_command = {
enabled = true;
prefix = "Re-execute: ";
};
max_visible_lines_in_shell_command_fields = false;
shell_commands = [
(mkShellCommand "blog-sync" "Blog: sync vault -> repo content" "${blog-sync}/bin/blog-sync")
];
prompts = [ ];
builtin_variables = { };
custom_variables = [ ];
custom_variables_notify_changes_via = {
obsidian_uri = true;
output_assignment = true;
};
custom_shells = [ nixShell ];
output_wrappers = [ ];
};
in
{
# Scripts, templates, and bases are stored here as version-controlled backup.
# They live in the vault directly (synced via Syncthing).
# blog-sync is also exposed on PATH for use outside Obsidian.
home.packages = [ blog-sync ];
programs.obsidian = {
enable = true;
package = pkgs.obsidian;
vaults = {
Keep = {
enable = true;
target = "Documents/Keep";
settings = {
app = appDefaults // {
attachmentFolderPath = "9 - Attachments";
# All new notes land in 5 - Main Notes. Blogs start there too but the Blog Post
# template immediately tp.file.move()s them into 4 - Blogs/blog/<slug>/.
newFileLocation = "folder";
newFileFolderPath = "5 - Main Notes";
};
# Declarative custom hotkeys (.obsidian/hotkeys.json). Shell-commands command
# ids are `obsidian-shellcommands:shell-command-<id>`; Templater registers each
# enabled template-hotkey as `templater-obsidian:<vault-relative path>`.
hotkeys = {
"obsidian-shellcommands:shell-command-blog-sync" = [
{ modifiers = [ "Mod" "Shift" ]; key = "P"; }
];
"templater-obsidian:9 - Templates/Blog Post.md" = [
{ modifiers = [ "Mod" "Shift" ]; key = "B"; }
];
};
corePlugins = [
"backlink"
"bases"
"bookmarks"
"canvas"
"command-palette"
"editor-status"
"file-explorer"
"file-recovery"
"global-search"
"graph"
"note-composer"
"outgoing-link"
"outline"
"page-preview"
"slash-command"
"switcher"
"tag-pane"
"templates"
"word-count"
];
communityPlugins = [
{
pkg = pkgs.callPackage ./plugins/templater.nix { };
settings = {
trigger_on_file_creation = true;
auto_jump_to_cursor = true;
enable_system_commands = true;
syntax_highlighting = true;
shell_path = "fish";
command_palette = true;
templates_folder = "9 - Templates";
user_scripts_folder = "9 - Scripts";
# Register the Blog Post template as a hotkeyable command.
enabled_templates_hotkeys = [ "9 - Templates/Blog Post.md" ];
};
}
{
pkg = pkgs.callPackage ./plugins/dataview.nix { };
settings = {
enableDataviewJs = true;
enableInlineDataviewJs = true;
taskCompletionUseEmojiShorthand = true;
taskCompletionTracking = true;
defaultDateFormat = "dd/MM/yyyy";
defaultDateTimeFormat = "HH:mm - dd/MM/yyyy";
};
}
{
pkg = pkgs.callPackage ./plugins/maps.nix { };
settings = {
tileSets = [
{
id = "protomaps";
name = "Protomaps";
lightTiles = "https://tiles.openfreemap.org/styles/liberty";
darkTiles = "https://tiles.openfreemap.org/styles/dark";
}
];
};
}
{
# Single rsync command (see blog-sync above).
pkg = pkgs.callPackage ./plugins/shellcommands.nix { };
settings = shellcommandsSettings;
}
];
};
};
};
defaultSettings = {
appearance = {
baseFontSize = lib.mkForce 16;
};
app = appDefaults;
};
};
}

View file

@ -0,0 +1,43 @@
{ pkgs
, version ? "0.5.70"
}:
pkgs.stdenv.mkDerivation rec {
pname = "obsidian-dataview";
inherit version;
srcs = [
(pkgs.fetchurl {
url = "https://github.com/blacksmithgu/obsidian-dataview/releases/download/${version}/manifest.json";
sha256 = "sha256-kjXbRxEtqBuFWRx57LmuJXTl5yIHBW6XZHL5BhYoYYU=";
name = "manifest.json";
})
(pkgs.fetchurl {
url = "https://github.com/blacksmithgu/obsidian-dataview/releases/download/${version}/main.js";
sha256 = "sha256-a7HPcBCvrYMOc1dfyg4r+9MnnFYuPZ0k8tL0UWHrfQA=";
name = "main.js";
})
(pkgs.fetchurl {
url = "https://github.com/blacksmithgu/obsidian-dataview/releases/download/${version}/styles.css";
sha256 = "sha256-MwbdkDLgD5ibpyM6N/0lW8TT9DQM7mYXYulS8/aqHek=";
name = "styles.css";
})
];
dontBuild = true;
dontStrip = true;
unpackPhase = ''
for src in $srcs; do
cp $src ./
done
find . -name "*manifest.json" -exec mv {} manifest.json \;
find . -name "*main.js" -exec mv {} main.js \;
find . -name "*styles.css" -exec mv {} styles.css \;
'';
installPhase = ''
mkdir -p $out
cp manifest.json main.js styles.css $out/
'';
}

View file

@ -0,0 +1,26 @@
{ pkgs, ... }:
pkgs.buildNpmPackage rec {
pname = "obsidian.plugins.maps";
version = "0.1.6";
src = pkgs.fetchFromGitHub {
owner = "obsidianmd";
repo = "obsidian-maps";
rev = version;
hash = "sha256-KYWgPu08tV4vMpTZt4ygcenel1JBsXWdh7VFl9vVuU8=";
};
# obsidian-maps ships package-lock.json (npm), not pnpm-lock.yaml.
npmDepsHash = "sha256-KGTRLJkbJU8BXED6ziEFlSTLXNHPYIE6pWoWxiQXSMU=";
nativeBuildInputs = with pkgs; [ nodejs ];
dontNpmPrune = true; # hangs forever on both Linux/darwin
installPhase = ''
runHook preInstall
mkdir -p $out
cp manifest.json main.js styles.css $out/
runHook postInstall
'';
}

View file

@ -0,0 +1,35 @@
{ pkgs, ... }:
# Unlike templater.nix / dataview.nix (which build from source), Shell commands is
# fetched as its prebuilt release artifacts. Obsidian plugins are distributed as just
# three files (main.js / manifest.json / styles.css), so fetching the release avoids an
# npm/rollup build and the per-release npmDeps-hash churn that comes with it.
let
version = "0.23.0";
base = "https://github.com/Taitava/obsidian-shellcommands/releases/download/${version}";
mainJs = pkgs.fetchurl {
url = "${base}/main.js";
hash = "sha256-DF4kepHJbAr18+MzukO+QoVGlWbIGdWbfeEprTPRTYo=";
};
manifest = pkgs.fetchurl {
url = "${base}/manifest.json";
hash = "sha256-IK6Mz6iXICfTW0RX0htquGrryOOx3uXro02tGeRqJGI=";
};
styles = pkgs.fetchurl {
url = "${base}/styles.css";
hash = "sha256-O9g4DlqlP8RH6mpMFN37lBmPGm0APm5fmUtFnPloTFM=";
};
in
pkgs.runCommandLocal "obsidian.plugins.shellcommands-${version}"
{
inherit version;
# The obsidian HM module reads `pkg.manifestId` (falling back to manifest.json's id)
# to name the plugin's directory under .obsidian/plugins/.
passthru.manifestId = "obsidian-shellcommands";
}
''
mkdir -p $out
cp ${mainJs} $out/main.js
cp ${manifest} $out/manifest.json
cp ${styles} $out/styles.css
''

View file

@ -0,0 +1,32 @@
{ pkgs, ... }:
pkgs.buildNpmPackage rec {
pname = "obsidian.plugins.templater";
version = "2.16.2";
src = pkgs.fetchFromGitHub {
owner = "SilentVoid13";
repo = "Templater";
rev = version;
hash = "sha256-jFELX0gW0aLW/eY685hMBgrbECNMqg0zbumpF2q0HFk=";
};
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version src;
fetcherVersion = 3;
hash = "sha256-xI6Hz6UPgz+4B6w3cxVsTuYpgPwwpSkgWgdadPeR8XM=";
};
nativeBuildInputs = with pkgs; [ nodejs pnpm ]; # pnpmConfigHook needs pnpm on PATH (pnpm.configHook used to add it)
npmConfigHook = pkgs.pnpmConfigHook;
npmDeps = pnpmDeps;
dontNpmPrune = true; # hangs forever on both Linux/darwin
installPhase = ''
mkdir -p $out
cp ./manifest.json $out/manifest.json
cp ./main.js $out/main.js
cp ./styles.css $out/styles.css
'';
}

View file

@ -0,0 +1,53 @@
/**
* Templater user script: Fetch and cache moedict.tw data
* Returns an object with { meaning, zhuyin } - call once per template
* Usage:
* <%* const moe = await tp.user.moedict_fetch(tp.file.title) %>
* Then use: <% moe.meaning %> and <% moe.zhuyin %>
*/
async function moedict_fetch(word) {
const result = {
meaning: "TODO add meaning",
zhuyin: "TODO add zhuyin",
};
if (!word || word.trim() === "") {
return result;
}
try {
const response = await fetch(
`https://www.moedict.tw/a/${encodeURIComponent(word)}.json`,
);
if (!response.ok) {
return result;
}
const data = await response.json();
// Extract zhuyin from first heteronym
if (data.h?.length > 0 && data.h[0].b) {
result.zhuyin = data.h[0].b.replace(/\s+/g, " ").trim();
}
// Extract English meaning
if (data.translation?.English?.length > 0) {
const meanings = data.translation.English.filter(
(m) => !m.startsWith("CL:"),
).join("; ");
if (meanings) {
result.meaning = meanings;
}
} else if (data.English) {
result.meaning = data.English;
}
return result;
} catch (error) {
console.error("moedict_fetch error:", error);
return result;
}
}
module.exports = moedict_fetch;

View file

@ -0,0 +1,37 @@
<%*
// Blog Post — Hugo page-bundle scaffold.
// Backup of the vault file `9 - Templates/Blog Post.md`. Templates live in the vault
// (Syncthing-synced); this copy is version-controlled, like scripts/moedict_fetch.js.
const title = await tp.system.prompt("Post title");
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
// Move into a page bundle: 4 - Blogs/blog/<slug>/index.md (co-located images work).
await tp.file.rename("index");
await tp.file.move("/4 - Blogs/blog/" + slug + "/index");
-%>
---
title: "<% title %>"
date: <% tp.date.now("YYYY-MM-DDTHH:mm:ssZ") %>
topics: []
draft: true
---
<!--
Optional front matter (uncomment as needed):
categories: []
series: "" # name of the series this post belongs to
tools: [] # technologies mentioned (taxonomy)
layout: "default" # default | tutorial | longform
description: # auto-generated from first paragraph if unset
image: # auto-detects cover.* in the page bundle
-->
## Introduction
## Main Content
## Conclusion

View file

@ -0,0 +1,42 @@
{ pkgs, ... }:
{
home.packages = with pkgs; [
# xfce.thunar* moved to top-level in nixpkgs 26.05
thunar
thunar-volman
thunar-vcs-plugin
thunar-archive-plugin
thunar-media-tags-plugin
tumbler
];
xdg.configFile."Thunar/uca.xml".text = ''
<?xml version="1.0" encoding="UTF-8"?>
<actions>
<action>
<icon>utilities-x-terminal</icon>
<name>Open Terminal Here</name>
<submenu></submenu>
<unique-id>1747642747798278-1</unique-id>
<command>ghostty --working-directory %f</command>
<description>Open terminal here</description>
<range></range>
<patterns>*</patterns>
<startup-notify/>
<directories/>
</action>
<action>
<icon>nvim</icon>
<name>Open file with neovim</name>
<submenu></submenu>
<unique-id>1747716628944583-1</unique-id>
<command>ghostty --working-directory %d nvim %F</command>
<description>Open file with nvim </description>
<range></range>
<patterns>*</patterns>
<startup-notify/>
<directories/>
</action>
</actions>
'';
}

View file

@ -0,0 +1,19 @@
{ pkgs, ... }:
let
tbkeys = pkgs.fetchFirefoxAddon {
name = "tbkeys";
url = "https://github.com/wshanks/tbkeys/releases/download/v2.4.3/tbkeys.xpi";
hash = "sha256-2e+T5Nr5kc2s8EykFzWKaJZ2jPUDHh9Cqn4hCuDCLaM=";
};
in
{
programs.thunderbird = {
enable = true;
profiles.default = {
isDefault = true;
extensions = [ tbkeys ];
};
};
}

View file

@ -0,0 +1,21 @@
{ lib, ... }:
{
programs.zathura = {
enable = true;
options = {
recolor-lightcolor = lib.mkForce "rgba(0,0,0,0)";
default-bg = lib.mkForce "rgba(0,0,0,0.7)";
selection-notification = true;
selection-clipboard = "clipboard";
adjust-open = "best-fit";
pages-per-row = "1";
scroll-page-aware = "true";
scroll-full-overlap = "0.01";
scroll-step = "100";
zoom-min = "10";
};
};
}

View file

@ -0,0 +1,33 @@
{ pkgs, lib, ... }:
{
imports = [
./walker.nix
./waybar
./hyprlock.nix
./hypridle.nix
./wf-recorder.nix
];
home.packages = with pkgs; [
# screenshot: grim captures, satty annotates (wayland-native — no
# fractional-scale jank like flameshot's Qt overlay)
grim
slurp
satty
wl-clipboard
# standalone bluetooth TUI (deliberately no waybar module / tray)
bluetui
];
home.sessionVariables = {
QT_QPA_PLATFORM = "wayland";
SDL_VIDEODRIVER = "wayland";
XDG_SESSION_TYPE = "wayland";
};
systemd.user.targets.tray.Unit.Requires = lib.mkForce ["graphical-session.target"];
}

View file

@ -0,0 +1,32 @@
{ pkgs, lib, config, inputs, ... }: let
lock = "${pkgs.systemd}/bin/loginctl lock-session";
timeout = 300;
in
{
services.hypridle = {
enable = true;
package = inputs.hypridle.packages.${pkgs.stdenv.hostPlatform.system}.hypridle;
settings = {
general = {
before_sleep_cmd = "loginctl lock-session";
after_sleep_cmd = "hyprctl dispatch dpms on";
lock_cmd = "pgrep hyprlock || ${lib.getExe config.programs.hyprlock.package}";
};
listener = [
{
inherit timeout;
on-timeout = "hyprctl dispatch dpms off";
on-resume = "hyprctl dispatch dpms on";
}
{
on-timeout = lock;
}
];
};
};
}

View file

@ -0,0 +1,530 @@
{ config, lib, pkgs, ... }:
# Hyprland config generated natively by home-manager (configType = "lua").
#
# Replaces the hand-authored system/programs/hyprland/lua.nix: HM 26.05's
# wayland.windowManager.hyprland lua backend renders `settings` to hl.<name>(...)
# calls, with _var -> lua locals, _args -> multi-arg calls, and
# lib.generators.mkLuaInline -> verbatim raw lua. This sidesteps the
# nix-community/home-manager#9468 `$variable` bug that forced hand-authoring.
#
# Split: the NixOS module (system/programs/hyprland) keeps the UWSM session,
# portals and helper scripts and emits NO config; HM owns ~/.config/hypr/hyprland.lua.
#
# framework13-first: monitors/device/per-ws-monitor + stylix live here for now;
# generalised to a shared module + per-host overrides when phantom/iso migrate.
let
inherit (lib.generators) mkLuaInline;
# stylix border colours at HM level (framework13 has stylix; fall back to white).
hasStylix = config ? lib && config.lib ? stylix;
c = lib.optionalAttrs hasStylix config.lib.stylix.colors;
active = if hasStylix then "rgba(${c.base05}cc)" else "rgba(ffffffcc)";
inactive = if hasStylix then "rgba(${c.base04}44)" else "rgba(ffffff44)";
# framework13 touchpad accel + scroll curve (was the hosts/framework13 luaPrelude).
accelpoints = "0.000 0.391 1.375 3.422 7.000 12.578 20.625 31.609 46.000 64.266 86.875 114.297 146.000 185.453 230.125 281.484 340.000 406.141 480.375 563.172";
# bind helper: keys (raw lua expr) + dispatcher (raw lua expr) [+ opts attrs]
b = keys: disp: { _args = [ (mkLuaInline keys) (mkLuaInline disp) ]; };
bo = keys: disp: opts: { _args = [ (mkLuaInline keys) (mkLuaInline disp) opts ]; };
# plain-key bind (no mod expression — bare key string)
bk = key: disp: { _args = [ key (mkLuaInline disp) ]; };
bko = key: disp: opts: { _args = [ key (mkLuaInline disp) opts ]; };
# function-callback bind: key + raw lua body. Needed when one keypress runs
# several dispatches (Hyprland 0.55 lua binds accept a function as the action).
bkf = key: body: { _args = [ key (mkLuaInline "function() ${body} end") ]; };
# same but with a mod-expression key (raw lua expr like the `b` helper).
bf = keys: body: { _args = [ (mkLuaInline keys) (mkLuaInline "function() ${body} end") ]; };
# workspaces 1..10: switch / move / move-silent (was the genList in binds.nix /
# the for-loop in lua.nix). key 10 -> "0".
wsBinds = builtins.concatMap (i:
let key = toString (lib.mod i 10); in [
(b ''mod .. " + ${key}"'' "hl.dsp.focus({ workspace = ${toString i} })")
(b ''mod .. " + SHIFT + ${key}"'' "hl.dsp.window.move({ workspace = ${toString i} })")
(b ''mod .. " + CTRL + ${key}"'' "hl.dsp.window.move({ workspace = ${toString i}, follow = false })")
]) (lib.range 1 10);
# vim-style window marks a..z (was markBinds): set / jump submap bodies.
markLetters = lib.stringToCharacters "abcdefghijklmnopqrstuvwxyz";
markSubmapBinds = action:
(map (l: bk l ''hl.dsp.exec_cmd("hypr-mark ${action} ${l}")'') markLetters)
++ [ (bk "escape" ''hl.dsp.submap("reset")'') ];
# dialog windows: float + center per title (was dialogRules).
dialogTitles = [ "Open File" "Select a File" "Choose wallpaper" "Open Folder" "Save As" "Library" "File Upload" ];
dialogRules = builtins.concatMap (t: [
{ match = { title = "^(${t})(.*)$"; }; float = true; }
{ match = { title = "^(${t})(.*)$"; }; center = true; }
]) dialogTitles;
# place-submap window snapping lives in a Lua `place()` helper (see extraConfig).
# It replaces the old exec_cmd("hyprctl --batch \"dispatch ...\"") strings: Hyprland
# 0.55 reparses `hyprctl dispatch` args as Lua, so the plaintext dispatchers errored.
in {
wayland.windowManager.hyprland = {
enable = true;
package = null;
portalPackage = null;
systemd.enable = false;
configType = "lua";
# Raw Lua appended after the generated config. `place()` drives the `place`
# submap: float the active window, then size + position it as fractions of the
# focused monitor. Monitor width/height are PHYSICAL px, but exact move/resize
# work in LOGICAL space, so divide by scale (x/y are already logical).
extraConfig = ''
function place(fw, fh, fx, fy)
local m = hl.get_active_monitor()
local w = m.width / m.scale
local h = m.height / m.scale
hl.dispatch(hl.dsp.window.float({ action = "enable" }))
hl.dispatch(hl.dsp.window.resize({ x = math.floor(w * fw), y = math.floor(h * fh), exact = true }))
hl.dispatch(hl.dsp.window.move({ x = math.floor(m.x + w * fx), y = math.floor(m.y + h * fy), exact = true }))
end
-- cycle_next({ next = false, tiled = true }) is broken on monocle/master:
-- upstream routes it to layoutMessage("cyclenext, b") (ConfigActions.cpp),
-- which neither layout parses -- monocle wants exactly "cycleprev", master
-- only knows a "noloop" arg. Branch on the workspace layout and use the
-- layoutmsg path there; generic window cycle everywhere else.
function cycle_win(next)
local ws = hl.get_active_workspace()
local l = ws and ws.tiled_layout or ""
if l == "monocle" or l == "master" then
hl.dispatch(hl.dsp.layout(next and "cyclenext" or "cycleprev"))
else
hl.dispatch(hl.dsp.window.cycle_next({ tiled = true, next = next }))
end
end
'';
settings = {
# ----- variables (-> lua locals) ------------------------------------
mod = { _var = "SUPER"; };
menu = { _var = "walker"; };
terminal = { _var = "uwsm app -- ghostty"; };
fileManager = { _var = "uwsm app -- yazi"; };
browser = { _var = "uwsm app -- firefox"; };
workBrowser = { _var = "uwsm app -- firefox -P work --no-remote"; }; # -P matches HM profile name; --no-remote = separate instance alongside personal
hardBrowser = { _var = "uwsm app -- mullvad-browser"; }; # ghost: Tor-Browser-minus-Tor, blends into the anti-fingerprint crowd
webapp = { _var = mkLuaInline ''browser .. " --new-tab"''; };
# ----- env ----------------------------------------------------------
env = [
{ _args = [ "HYPRCURSOR_THEME" "rose-pine-hyprcursor" ]; }
{ _args = [ "HYPRCURSOR_SIZE" "24" ]; }
];
# ----- monitors + per-device (framework13) --------------------------
monitor = [
{ _args = [ { output = "DP-4"; mode = "3840x2160@60.00"; position = "0x0"; scale = 1; vrr = 1; } ]; }
{ _args = [ { output = "eDP-1"; mode = "preferred"; position = "3840x0"; scale = 1.3333; vrr = 1; } ]; }
];
device = [
{ _args = [ { name = "pixa3854:00-093a:0274-touchpad"; accel_profile = "custom ${accelpoints}"; scroll_points = accelpoints; natural_scroll = true; } ]; }
];
# ----- autostart (was exec-once) ------------------------------------
on = { _args = [ "hyprland.start" (mkLuaInline ''function()
hl.exec_cmd("uwsm finalize")
hl.exec_cmd("uwsm app -- waybar")
hl.exec_cmd("uwsm app -- wl-paste --type text --watch cliphist store")
hl.exec_cmd("uwsm app -- wl-paste --type image --watch cliphist store")
hl.exec_cmd("hyprlock")
hl.exec_cmd("uwsm app -- mako")
-- fcitx5 is owned by the NixOS i18n.inputMethod XDG-autostart systemd
-- unit (app-org.fcitx.Fcitx5@autostart.service). Launching it again
-- here raced that unit via --replace and left the survivor with an
-- empty IM group: apps connected over wayland_v2 but Ctrl+Space (and
-- even dbus -o) toggled nothing. Let systemd own the single instance.
hl.exec_cmd("[workspace 1 silent] uwsm app -- firefox")
hl.exec_cmd("[workspace 1 silent] uwsm app -- obsidian")
end'') ]; };
# ----- keyword config (hl.config) -----------------------------------
config = {
general = {
gaps_in = 1; gaps_out = 0; border_size = 1;
col = { active_border = active; inactive_border = inactive; };
resize_on_border = false; allow_tearing = false; layout = "dwindle";
};
decoration = {
rounding = 0;
shadow = { enabled = false; range = 4; render_power = 3; color = "rgba(00000055)"; };
blur = {
enabled = false; size = 6; passes = 1; ignore_opacity = true;
new_optimizations = true; special = true; popups = true; vibrancy = 0.1696;
};
};
cursor = { no_hardware_cursors = false; hide_on_key_press = true; };
master = {
mfact = 0.60; new_status = "slave"; new_on_top = false; allow_small_split = true;
# master on the RIGHT, stack on the left — better on the narrow laptop
# panel than center-master. (slave_count/fallback only apply if you
# cycle back to center via SUPER+CTRL+i.)
orientation = "right"; slave_count_for_center_master = 2; center_master_fallback = "left";
};
dwindle = {
preserve_split = true; # keep split orientation as windows come/go
force_split = 2; # always split to the right / below (predictable)
smart_split = false; # deterministic (not cursor-position based)
default_split_ratio = 1.0; # even 50/50 splits
};
scrolling = {
column_width = 0.5; fullscreen_on_one_column = true; follow_focus = true;
wrap_focus = true; explicit_column_widths = "0.333, 0.5, 0.667, 1.0";
};
misc = { force_default_wallpaper = 0; enable_swallow = true; swallow_regex = "^(com.mitchellh.ghostty)$"; vrr = 1; };
input = {
kb_layout = "us"; follow_mouse = 1; sensitivity = 0;
touchpad = { natural_scroll = true; disable_while_typing = true; };
};
group = {
auto_group = false;
groupbar = {
enabled = true; font_size = 10; height = 18; rounding = 0;
render_titles = true; scrolling = true;
col = { active = active; inactive = inactive; locked_active = active; locked_inactive = inactive; };
};
col = { border_active = active; border_inactive = inactive; border_locked_active = active; border_locked_inactive = inactive; };
};
binds = { window_direction_monitor_fallback = false; allow_workspace_cycles = true; };
animations = { enabled = false; };
};
# ----- gestures -----------------------------------------------------
gesture = [
{ fingers = 3; direction = "left"; action = mkLuaInline ''function() hl.dispatch(hl.dsp.layout("move +col")) end''; }
{ fingers = 3; direction = "right"; action = mkLuaInline ''function() hl.dispatch(hl.dsp.layout("move -col")) end''; }
# 4-finger vertical: pull the comms scratchpads up/down
{ fingers = 4; direction = "up"; action = mkLuaInline ''function() hl.dispatch(hl.dsp.workspace.toggle_special("chat")) end''; }
{ fingers = 4; direction = "down"; action = mkLuaInline ''function() hl.dispatch(hl.dsp.workspace.toggle_special("work")) end''; }
];
# ----- keybinds (was bind / bindm / bindle / bindl) -----------------
bind = [
# mouse (bindm)
(bo ''mod .. " + mouse:272"'' "hl.dsp.window.drag()" { mouse = true; })
(bo ''mod .. " + mouse:273"'' "hl.dsp.window.resize()" { mouse = true; })
(bo ''mod .. " + ALT + mouse:272"'' "hl.dsp.window.resize()" { mouse = true; })
# compositor
(b ''mod .. " + CTRL + ALT + Q"'' "hl.dsp.exit()")
(b ''mod .. " + Q"'' "hl.dsp.window.close()")
(b ''mod .. " + SHIFT + ESCAPE"'' ''hl.dsp.exec_cmd("systemctl suspend")'')
(b ''mod .. " + SHIFT + CTRL + ESCAPE"'' ''hl.dsp.exec_cmd("systemctl poweroff")'')
# tiling control
(b ''mod .. " + SHIFT + F"'' ''hl.dsp.window.fullscreen({ action = "toggle" })'')
(b ''mod .. " + G"'' "hl.dsp.group.toggle()")
(b ''mod .. " + F"'' ''hl.dsp.window.float({ action = "toggle" })'')
(b ''mod .. " + T"'' ''hl.dsp.layout("togglesplit")'')
(b ''mod .. " + SHIFT + P"'' "hl.dsp.window.pseudo()")
(b ''mod .. " + SHIFT + C"'' "hl.dsp.window.center()")
(b ''mod .. " + P"'' ''hl.dsp.window.pin({ action = "toggle" })'')
(b ''mod .. " + D"'' "hl.dsp.focus({ urgent_or_last = true })")
# window cycling — cycle_win() branches per layout (see extraConfig): the
# generic cycle_next reverse is broken upstream on monocle/master.
(bf ''mod .. " + TAB"'' "cycle_win(true)")
(bf ''mod .. " + SHIFT + TAB"'' "cycle_win(false)")
(b ''mod .. " + CTRL + G"'' "hl.dsp.window.move({ out_of_group = true })")
(b ''mod .. " + ALT + G"'' ''hl.dsp.exec_cmd("hyprctl dispatch lockgroups toggle")'')
# utilities
(b ''mod .. " + RETURN"'' "hl.dsp.exec_cmd(terminal)")
(b ''mod .. " + y"'' "hl.dsp.exec_cmd(fileManager)")
(b ''mod .. " + SPACE"'' "hl.dsp.exec_cmd(menu)")
(b ''mod .. " + CTRL + Q"'' ''hl.dsp.exec_cmd("hyprlock")'')
# walker providers
(b ''mod .. " + CTRL + V"'' ''hl.dsp.exec_cmd("walker -m clipboard")'')
(b ''mod .. " + W"'' ''hl.dsp.exec_cmd("walker -m windows")'')
(b ''mod .. " + CTRL + period"'' ''hl.dsp.exec_cmd("walker -m symbols")'')
(b ''mod .. " + CTRL + N"'' ''hl.dsp.exec_cmd("walker -m todo")'')
# keybind cheatsheet
(b ''mod .. " + slash"'' ''hl.dsp.exec_cmd("hypr-binds-menu")'')
# notifications
(b ''mod .. " + SHIFT + D"'' ''hl.dsp.exec_cmd("makoctl mode -t dnd; pkill -RTMIN+9 waybar")'')
(b ''mod .. " + CTRL + D"'' ''hl.dsp.exec_cmd("mako-history-menu")'')
# move focus
(b ''mod .. " + h"'' ''hl.dsp.focus({ direction = "left" })'')
(b ''mod .. " + l"'' ''hl.dsp.focus({ direction = "right" })'')
(b ''mod .. " + k"'' ''hl.dsp.focus({ direction = "up" })'')
(b ''mod .. " + j"'' ''hl.dsp.focus({ direction = "down" })'')
# group tab cycling (moved off SUPER+TAB, now the window cycler)
(bk "ALT + TAB" "hl.dsp.group.next()")
(bk "ALT + SHIFT + TAB" "hl.dsp.group.prev()")
# move window or group
(b ''mod .. " + SHIFT + h"'' ''hl.dsp.window.move({ direction = "l", group_aware = true })'')
(b ''mod .. " + SHIFT + l"'' ''hl.dsp.window.move({ direction = "r", group_aware = true })'')
(b ''mod .. " + SHIFT + k"'' ''hl.dsp.window.move({ direction = "u", group_aware = true })'')
(b ''mod .. " + SHIFT + j"'' ''hl.dsp.window.move({ direction = "d", group_aware = true })'')
# cycle workspaces
(b ''mod .. " + bracketleft"'' ''hl.dsp.focus({ workspace = "m-1" })'')
(b ''mod .. " + bracketright"'' ''hl.dsp.focus({ workspace = "m+1" })'')
(b ''mod .. " + ESCAPE"'' ''hl.dsp.focus({ workspace = "previous" })'')
# cycle monitors
(b ''mod .. " + SHIFT + bracketleft"'' ''hl.dsp.focus({ monitor = "l" })'')
(b ''mod .. " + SHIFT + bracketright"'' ''hl.dsp.focus({ monitor = "r" })'')
# send workspace to monitor
(b ''mod .. " + SHIFT + ALT + bracketleft"'' ''hl.dsp.workspace.move({ monitor = "l" })'')
(b ''mod .. " + SHIFT + ALT + bracketright"'' ''hl.dsp.workspace.move({ monitor = "r" })'')
# master: promote to master
(b ''mod .. " + CTRL + RETURN"'' ''hl.dsp.layout("swapwithmaster master")'')
# column resize (scrolling)
(b ''mod .. " + equal"'' ''hl.dsp.layout("colresize +conf")'')
(b ''mod .. " + minus"'' ''hl.dsp.layout("colresize -conf")'')
(b ''mod .. " + SHIFT + SPACE"'' ''hl.dsp.layout("orientationnext")'')
(b ''mod .. " + CTRL + SHIFT + SPACE"'' ''hl.dsp.layout("orientationprev")'')
# scroll layout
(b ''mod .. " + c"'' ''hl.dsp.layout("fit active")'')
(b ''mod .. " + SHIFT + c"'' ''hl.dsp.layout("fit visible")'')
(b ''mod .. " + CTRL + c"'' ''hl.dsp.layout("fit all")'')
(b ''mod .. " + period"'' ''hl.dsp.layout("move +col")'')
(b ''mod .. " + comma"'' ''hl.dsp.layout("move -col")'')
# theme toggle
(b ''mod .. " + F5"'' ''hl.dsp.exec_cmd("theme-switch")'')
# screenshot
(bk "SHIFT + Print" ''hl.dsp.exec_cmd("screenshot area")'')
(bk "Print" ''hl.dsp.exec_cmd("screenshot full")'')
# screen recording
(b ''mod .. " + Print"'' ''hl.dsp.exec_cmd("wf-recorder-toggle")'')
(b ''mod .. " + SHIFT + Print"'' ''hl.dsp.exec_cmd("wf-recorder-toggle area")'')
# special workspaces (chat-toggle retired -> plain toggle)
(b ''mod .. " + M"'' ''hl.dsp.workspace.toggle_special("chat")'')
(b ''mod .. " + SHIFT + M"'' ''hl.dsp.window.move({ workspace = "special:chat" })'')
(b ''mod .. " + B"'' ''hl.dsp.workspace.toggle_special("work")'')
(b ''mod .. " + SHIFT + B"'' ''hl.dsp.window.move({ workspace = "special:work" })'')
# z-order for floating windows
(b ''mod .. " + ALT + k"'' ''hl.dsp.window.alter_zorder({ mode = "top" })'')
(b ''mod .. " + ALT + j"'' ''hl.dsp.window.alter_zorder({ mode = "bottom" })'')
# submap entries
(b ''mod .. " + R"'' ''hl.dsp.submap("resize")'')
(b ''mod .. " + X"'' ''hl.dsp.submap("launch")'')
(b ''mod .. " + V"'' ''hl.dsp.submap("place")'')
(b ''mod .. " + N"'' ''hl.dsp.submap("mark")'')
(b ''mod .. " + apostrophe"'' ''hl.dsp.submap("jump")'')
# dynamic layout control --------------------------------------------
# scrolling tape (ws1/3/5 + specials): swap columns / promote / recentre.
# (consume/expel dropped — SUPER+SHIFT+h/l already moves windows between
# columns. layoutmsg is layout-scoped, so these no-op off scrolling.)
(b ''mod .. " + SHIFT + u"'' ''hl.dsp.layout("swapcol l")'')
(b ''mod .. " + SHIFT + o"'' ''hl.dsp.layout("swapcol r")'')
(b ''mod .. " + CTRL + u"'' ''hl.dsp.layout("promote")'')
(b ''mod .. " + CTRL + o"'' ''hl.dsp.layout("center")'')
# master stack (ws2 coding): add/remove a master window, cycle orientation
(b ''mod .. " + i"'' ''hl.dsp.layout("addmaster")'')
(b ''mod .. " + SHIFT + i"'' ''hl.dsp.layout("removemaster")'')
(b ''mod .. " + CTRL + i"'' ''hl.dsp.layout("orientationcycle")'')
# locked / repeating media + brightness (bindle / bindl)
(bko "XF86AudioRaiseVolume" ''hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+")'' { locked = true; repeating = true; })
(bko "XF86AudioLowerVolume" ''hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-")'' { locked = true; repeating = true; })
(bko "XF86AudioMute" ''hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle")'' { locked = true; repeating = true; })
(bko "XF86AudioMicMute" ''hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle")'' { locked = true; repeating = true; })
(bko "XF86MonBrightnessUp" ''hl.dsp.exec_cmd("brightnessctl -c backlight s 10%+")'' { locked = true; repeating = true; })
(bko "XF86MonBrightnessDown" ''hl.dsp.exec_cmd("brightnessctl -c backlight s 10%-")'' { locked = true; repeating = true; })
(bko "XF86AudioNext" ''hl.dsp.exec_cmd("playerctl next")'' { locked = true; })
(bko "XF86AudioPause" ''hl.dsp.exec_cmd("playerctl play-pause")'' { locked = true; })
(bko "XF86AudioPlay" ''hl.dsp.exec_cmd("playerctl play-pause")'' { locked = true; })
(bko "XF86AudioPrev" ''hl.dsp.exec_cmd("playerctl previous")'' { locked = true; })
] ++ wsBinds;
# ----- window rules -------------------------------------------------
window_rule = [
# tags
{ match = { class = "^([Ff]irefox|org.mozilla.firefox|[Ff]irefox-esr|[Ff]irefox-bin)$"; }; tag = "+browser"; }
{ match = { class = "^([Gg]oogle-chrome(-beta|-dev|-unstable)?)$"; }; tag = "+browser"; }
{ match = { class = "^([Cc]hromium)$"; }; tag = "+browser"; }
{ match = { class = "^([Cc]hromium-browser)$"; }; tag = "+browser"; }
{ match = { class = "^(com.mitchellh.ghostty)$"; }; tag = "+terminal"; }
{ match = { class = "^([Tt]hunderbird)$"; }; tag = "+im-work"; }
{ match = { class = "^([Ss]lack)$"; }; tag = "+im-work"; }
{ match = { class = "^([Dd]iscord).*$"; }; tag = "+im"; }
{ match = { class = "^(org.telegram.desktop)$"; }; tag = "+im"; }
{ match = { class = "^([Ss]ignal)$"; }; tag = "+im"; }
{ match = { class = "^([Tt]hunar|org.gnome.Nautilus)$"; }; tag = "+file-manager"; }
{ match = { class = "^(firefox)$"; title = "^(Picture-in-Picture)$"; }; tag = "+pip"; }
{ match = { class = "^(org.keepassxc.KeePassXC)$"; }; tag = "+passwd"; }
{ match = { class = ".*"; }; suppress_event = "maximize"; }
{ match = { class = "^$"; title = "^$"; xwayland = true; float = true; fullscreen = false; pin = false; }; no_focus = true; }
# opacity
{ match = { class = ".*"; }; opacity = "1.0 0.97"; }
{ match = { tag = "file-manager"; }; opacity = "0.97 0.8"; }
{ match = { tag = "im-work"; }; workspace = "special:work"; }
{ match = { tag = "im"; }; workspace = "special:chat"; }
# notes = obsidian beside firefox on ws1
{ match = { tag = "browser"; }; workspace = "1"; }
{ match = { class = "^(obsidian)$"; }; workspace = "1"; }
{ match = { title = "^(Grayjay)$"; }; float = false; }
{ match = { title = "^(Grayjay)$"; }; workspace = "4"; }
# picture-in-picture
{ match = { initial_title = "^Picture-in-Picture$"; }; opacity = "1 1"; }
{ match = { tag = "pip"; }; float = true; }
{ match = { tag = "pip"; }; pin = true; }
{ match = { tag = "pip"; }; no_focus = true; }
{ match = { tag = "pip"; }; size = "300 169"; }
{ match = { tag = "pip"; }; move = "100%-320 100%-190"; }
{ match = { tag = "pip"; }; keep_aspect_ratio = true; }
# keepassxc
{ match = { tag = "passwd"; }; float = true; }
{ match = { tag = "passwd"; }; center = true; }
{ match = { tag = "passwd"; }; size = "60% 70%"; }
{ match = { tag = "terminal"; }; tile = true; }
# scrolling layout column widths
{ match = { class = "^(obsidian)$"; }; scrolling_width = 0.333; }
{ match = { tag = "terminal"; }; scrolling_width = 0.5; }
{ match = { tag = "browser"; }; scrolling_width = 0.667; }
{ match = { tag = "im-work"; }; scrolling_width = 0.333; }
{ match = { tag = "im"; }; scrolling_width = 0.5; }
# screen sharing
{ match = { tag = "im"; }; no_screen_share = true; }
{ match = { tag = "im-work"; }; no_screen_share = true; }
{ match = { tag = "passwd"; }; no_screen_share = true; }
{ match = { class = "([Tt]hunar)"; title = "negative:(.*[Tt]hunar.*)"; }; center = true; }
# satty annotation editor
{ match = { class = "^(com.gabm.satty)$"; }; float = true; }
{ match = { class = "^(com.gabm.satty)$"; }; size = "80% 80%"; }
{ match = { class = "^(com.gabm.satty)$"; }; center = true; }
# smart borders
{ match = { float = false; workspace = "w[tv1]"; }; border_size = 0; }
{ match = { float = false; workspace = "w[tv1]"; }; rounding = 0; }
{ match = { float = false; workspace = "f[1]"; }; border_size = 0; }
{ match = { float = false; workspace = "f[1]"; }; rounding = 0; }
] ++ dialogRules;
# ----- workspace rules ----------------------------------------------
workspace_rule = [
# per-workspace layouts
{ workspace = "1"; layout = "scrolling"; }
{ workspace = "2"; layout = "master"; }
{ workspace = "3"; layout = "scrolling"; }
{ workspace = "4"; layout = "monocle"; }
{ workspace = "5"; layout = "scrolling"; }
{ workspace = "special:work"; layout = "scrolling"; }
{ workspace = "special:chat"; layout = "scrolling"; }
{ workspace = "special:work"; gaps_out = 40; gaps_in = 10; }
{ workspace = "special:chat"; gaps_out = 40; gaps_in = 10; }
# smart gaps
{ workspace = "w[tv1]"; gaps_out = 0; gaps_in = 0; }
{ workspace = "f[1]"; gaps_out = 0; gaps_in = 0; }
# per-workspace monitor pins (framework13)
{ workspace = "1"; monitor = "DP-4"; default = true; }
{ workspace = "2"; monitor = "DP-4"; }
{ workspace = "3"; monitor = "DP-4"; }
{ workspace = "4"; monitor = "eDP-1"; default = true; }
{ workspace = "5"; monitor = "eDP-1"; }
];
};
# ----- submaps --------------------------------------------------------
submaps = {
# resize: sticky, repeatable; exit with escape/return
resize.settings.bind = [
(bko "h" "hl.dsp.window.resize({ x = -100, y = 0, relative = true })" { repeating = true; })
(bko "l" "hl.dsp.window.resize({ x = 100, y = 0, relative = true })" { repeating = true; })
(bko "k" "hl.dsp.window.resize({ x = 0, y = -100, relative = true })" { repeating = true; })
(bko "j" "hl.dsp.window.resize({ x = 0, y = 100, relative = true })" { repeating = true; })
(bk "escape" ''hl.dsp.submap("reset")'')
(bk "RETURN" ''hl.dsp.submap("reset")'')
];
# launch: auto-resets to global after each action
launch = {
onDispatch = "reset";
settings.bind = [
(bk "A" ''hl.dsp.exec_cmd(webapp .. " https://openrouter.ai/chat")'')
(bk "B" "hl.dsp.exec_cmd(browser)")
(bk "W" "hl.dsp.exec_cmd(workBrowser)")
(bk "H" "hl.dsp.exec_cmd(hardBrowser)")
(bk "C" ''hl.dsp.exec_cmd(webapp .. " https://app.hey.com/calendar/weeks/")'')
(bk "D" ''hl.dsp.exec_cmd(terminal .. " -e lazydocker")'')
(bk "E" ''hl.dsp.exec_cmd(webapp .. " https://app.hey.com")'')
(bk "O" ''hl.dsp.exec_cmd("uwsm app -- obsidian")'')
(bk "R" ''hl.dsp.exec_cmd(terminal .. " -e newsboat")'')
(bk "slash" ''hl.dsp.exec_cmd("uwsm app -- keepassxc")'')
(bk "escape" ''hl.dsp.submap("reset")'')
];
};
# place: snap floating windows to zones; stays active for multiple placements
place.settings.bind = [
# halves — place(width, height, x, y) as monitor fractions
(bkf "h" "place(0.5, 1.0, 0.0, 0.0)")
(bkf "l" "place(0.5, 1.0, 0.5, 0.0)")
(bkf "k" "place(1.0, 0.5, 0.0, 0.0)")
(bkf "j" "place(1.0, 0.5, 0.0, 0.5)")
# center variants (centred = equal margins: x = (1 - w) / 2)
(bkf "c" "place(0.7, 0.7, 0.15, 0.15)")
(bkf "f" "place(0.95, 0.95, 0.025, 0.025)")
(bkf "m" "place(0.5, 0.5, 0.25, 0.25)")
# quadrants
(bkf "1" "place(0.5, 0.5, 0.0, 0.0)")
(bkf "2" "place(0.5, 0.5, 0.5, 0.0)")
(bkf "3" "place(0.5, 0.5, 0.0, 0.5)")
(bkf "4" "place(0.5, 0.5, 0.5, 0.5)")
# thirds (4K panel): u/i/o = left/middle/right column
(bkf "u" "place(0.333, 1.0, 0.0, 0.0)")
(bkf "i" "place(0.334, 1.0, 0.333, 0.0)")
(bkf "o" "place(0.333, 1.0, 0.667, 0.0)")
# two-thirds: SHIFT+u = left, SHIFT+o = right
(bkf "SHIFT + u" "place(0.667, 1.0, 0.0, 0.0)")
(bkf "SHIFT + o" "place(0.667, 1.0, 0.333, 0.0)")
# t = back to tiling and out of the submap
(bkf "t" ''hl.dispatch(hl.dsp.window.float({ action = "disable" })); hl.dispatch(hl.dsp.submap("reset"))'')
# incremental move (repeatable)
(bko "SHIFT + h" "hl.dsp.window.move({ x = -40, y = 0, relative = true })" { repeating = true; })
(bko "SHIFT + l" "hl.dsp.window.move({ x = 40, y = 0, relative = true })" { repeating = true; })
(bko "SHIFT + k" "hl.dsp.window.move({ x = 0, y = -40, relative = true })" { repeating = true; })
(bko "SHIFT + j" "hl.dsp.window.move({ x = 0, y = 40, relative = true })" { repeating = true; })
# incremental resize (repeatable)
(bko "equal" "hl.dsp.window.resize({ x = 40, y = 40, relative = true })" { repeating = true; })
(bko "minus" "hl.dsp.window.resize({ x = -40, y = -40, relative = true })" { repeating = true; })
(bk "escape" ''hl.dsp.submap("reset")'')
(bk "RETURN" ''hl.dsp.submap("reset")'')
];
# vim marks: set / jump, auto-reset after each letter
mark = { onDispatch = "reset"; settings.bind = markSubmapBinds "set"; };
jump = { onDispatch = "reset"; settings.bind = markSubmapBinds "jump"; };
};
};
}

View file

@ -0,0 +1,103 @@
{ config, inputs, pkgs, lib, ... }:
{
programs.hyprlock = {
enable = true;
package = inputs.hyprlock.packages.${pkgs.stdenv.hostPlatform.system}.hyprlock;
settings = {
general = {
disable_loading_bar = true;
immediate_render = true;
hide_cursor = false;
no_fade_in = true;
};
auth = {
fingerprint = {
enabled = true;
ready_message = "(Scan fingerprint to unlock)";
present_message = "Scanning fingerprint";
retry_delay = 250;
};
};
animation = [
"inputFieldDots, 1, 2, linear"
"fadeIn, 0"
];
background = lib.mkForce [
{
monitor = "";
path = builtins.toString config.stylix.image;
}
];
input-field = lib.mkForce [
{
monitor = "eDP-1";
size = "300, 50";
valign = "bottom";
position = "0%, 10%";
outline_thickness = 1;
font_color = "rgb(b6c4ff)";
outer_color = "rgba(180, 180, 180, 0.5)";
inner_color = "rgba(200, 200, 200, 0.1)";
check_color = "rgba(247, 193, 19, 0.5)";
fail_color = "rgba(255, 106, 134, 0.5)";
fade_on_empty = false;
placeholder_text = "Enter Password";
dots_spacing = 0.2;
dots_center = true;
dots_fade_time = 100;
shadow_color = "rgba(0, 0, 0, 0.1)";
shadow_size = 7;
shadow_passes = 2;
}
];
label = [
{
monitor = "";
text = "$TIME";
font_size = 150;
color = "rgb(b6c4ff)";
position = "0%, 30%";
valign = "center";
halign = "center";
shadow_color = "rgba(0, 0, 0, 0.1)";
shadow_size = 20;
shadow_passes = 2;
shadow_boost = 0.3;
}
{
monitor = "";
text = "cmd[update:3600000] date +'%a %b %d'";
font_size = 20;
color = "rgb(b6c4ff)";
position = "0%, 40%";
valign = "center";
halign = "center";
shadow_color = "rgba(0, 0, 0, 0.1)";
shadow_size = 20;
shadow_passes = 2;
shadow_boost = 0.3;
}
];
};
};
}

View file

@ -0,0 +1,280 @@
{ inputs, ... }:
{
imports = [
inputs.walker.homeManagerModules.default
];
programs.walker = {
enable = true;
runAsService = true;
config = {
# Global UX settings
force_keyboard_focus = false;
close_when_open = true;
click_to_close = true;
single_click_activation = true;
selection_wrap = false;
global_argument_delimiter = "#";
exact_search_prefix = "'";
theme = "default";
disable_mouse = false;
debug = false;
page_jump_items = 10;
hide_quick_activation = false;
hide_action_hints = false;
hide_action_hints_dmenu = true;
hide_return_action = false;
resume_last_query = false;
actions_as_menu = false;
app_launch_prefix = "uwsm app -- ";
# Shell anchoring for proper positioning
shell = {
anchor_top = true;
anchor_bottom = true;
anchor_left = true;
anchor_right = true;
};
# Column configuration for grid layouts
columns = {
symbols = 3;
};
# Placeholders for empty states
placeholders = {
default = {
input = "Search";
list = "No Results";
};
};
# Keybinds - preserving your vim-style navigation
keys = {
close = [ "esc" ];
next = [ "ctrl j" ]; # vim-style
prev = [ "ctrl k" ]; # vim-style
left = [ "Left" ];
right = [ "Right" ];
down = [ "ctrl j" ];
up = [ "ctrl k" ];
accept_typeahead = [ "tab" ];
trigger_labels = "lalt";
toggle_exact_search = [ "ctrl m" ];
resume_query = [ "ctrl r" ];
remove_from_history = [ "shift backspace" ];
quick_activate = [ "F1" "F2" "F3" "F4" ];
page_down = [ "Page_Down" ];
page_up = [ "Page_Up" ];
show_actions = [ "alt j" ];
activation_modifiers = {
keep_open = "shift";
alternate = "alt";
};
};
# Provider configuration
providers = {
default = [
"desktopapplications"
"calc"
"websearch"
];
empty = [ "desktopapplications" ];
ignore_preview = [ ];
max_results = 50;
# Prefix-based quick access
prefixes = [
{ prefix = ";"; provider = "providerlist"; }
{ prefix = ">"; provider = "runner"; }
{ prefix = "/"; provider = "files"; }
{ prefix = "."; provider = "symbols"; }
{ prefix = "!"; provider = "todo"; }
{ prefix = "%"; provider = "bookmarks"; }
{ prefix = "="; provider = "calc"; }
{ prefix = "@"; provider = "websearch"; }
{ prefix = ":"; provider = "clipboard"; }
{ prefix = "$"; provider = "windows"; }
];
# Provider-specific configuration
clipboard = {
time_format = "relative";
};
# Provider-specific actions
actions = {
fallback = [
{ action = "menus:open"; label = "open"; after = "Nothing"; }
{ action = "menus:default"; label = "run"; after = "Close"; }
{ action = "menus:parent"; label = "back"; bind = "Escape"; after = "Nothing"; }
{ action = "erase_history"; label = "clear hist"; bind = "ctrl h"; after = "AsyncReload"; }
];
dmenu = [
{ action = "select"; default = true; bind = "Return"; }
];
providerlist = [
{ action = "activate"; default = true; bind = "Return"; after = "ClearReload"; }
];
calc = [
{ action = "copy"; default = true; bind = "Return"; }
{ action = "delete"; bind = "ctrl d"; after = "AsyncReload"; }
{ action = "delete_all"; bind = "ctrl shift d"; after = "AsyncReload"; }
{ action = "save"; bind = "ctrl s"; after = "AsyncClearReload"; }
];
websearch = [
{ action = "search"; default = true; bind = "Return"; }
{ action = "open_url"; label = "open url"; default = true; bind = "Return"; }
];
desktopapplications = [
{ action = "start"; default = true; bind = "Return"; }
{ action = "start:keep"; label = "open+next"; bind = "shift Return"; after = "KeepOpen"; }
{ action = "new_instance"; label = "new instance"; bind = "ctrl Return"; }
{ action = "new_instance:keep"; label = "new+next"; bind = "ctrl alt Return"; after = "KeepOpen"; }
{ action = "pin"; bind = "ctrl p"; after = "AsyncReload"; }
{ action = "unpin"; bind = "ctrl p"; after = "AsyncReload"; }
{ action = "pinup"; bind = "ctrl n"; after = "AsyncReload"; }
{ action = "pindown"; bind = "ctrl m"; after = "AsyncReload"; }
];
files = [
{ action = "open"; default = true; bind = "Return"; }
{ action = "opendir"; label = "open dir"; bind = "ctrl Return"; }
{ action = "copypath"; label = "copy path"; bind = "ctrl shift c"; }
{ action = "copyfile"; label = "copy file"; bind = "ctrl c"; }
{ action = "localsend"; label = "localsend"; bind = "ctrl l"; }
{ action = "refresh_index"; label = "reload"; bind = "ctrl r"; after = "AsyncReload"; }
];
runner = [
{ action = "run"; default = true; bind = "Return"; }
{ action = "runterminal"; label = "run in terminal"; bind = "shift Return"; }
];
symbols = [
{ action = "run_cmd"; label = "select"; default = true; bind = "Return"; }
];
unicode = [
{ action = "run_cmd"; label = "select"; default = true; bind = "Return"; }
];
clipboard = [
{ action = "copy"; default = true; bind = "Return"; }
{ action = "remove"; bind = "ctrl d"; after = "AsyncClearReload"; }
{ action = "remove_all"; label = "clear"; bind = "ctrl shift d"; after = "AsyncClearReload"; }
{ action = "show_images_only"; label = "only images"; bind = "ctrl i"; after = "AsyncClearReload"; }
{ action = "show_text_only"; label = "only text"; bind = "ctrl i"; after = "AsyncClearReload"; }
{ action = "show_combined"; label = "show all"; bind = "ctrl i"; after = "AsyncClearReload"; }
{ action = "pause"; bind = "ctrl shift p"; }
{ action = "unpause"; bind = "ctrl shift p"; }
{ action = "unpin"; bind = "ctrl p"; after = "AsyncClearReload"; }
{ action = "pin"; bind = "ctrl p"; after = "AsyncClearReload"; }
{ action = "edit"; bind = "ctrl o"; }
{ action = "localsend"; bind = "ctrl l"; }
];
bookmarks = [
{ action = "save"; bind = "Return"; after = "AsyncClearReload"; }
{ action = "open"; default = true; bind = "Return"; }
{ action = "delete"; bind = "ctrl d"; after = "AsyncClearReload"; }
{ action = "change_category"; label = "Change category"; bind = "ctrl y"; after = "Nothing"; }
{ action = "change_browser"; label = "Change browser"; bind = "ctrl b"; after = "Nothing"; }
{ action = "import"; label = "Import"; bind = "ctrl i"; after = "AsyncClearReload"; }
{ action = "create"; bind = "ctrl a"; after = "AsyncClearReload"; }
{ action = "search"; bind = "ctrl a"; after = "AsyncClearReload"; }
];
todo = [
{ action = "save"; default = true; bind = "Return"; after = "AsyncClearReload"; }
{ action = "save_next"; label = "save & new"; bind = "shift Return"; after = "AsyncClearReload"; }
{ action = "delete"; bind = "ctrl d"; after = "AsyncClearReload"; }
{ action = "active"; default = true; bind = "Return"; after = "Nothing"; }
{ action = "inactive"; default = true; bind = "Return"; after = "Nothing"; }
{ action = "done"; bind = "ctrl f"; after = "Nothing"; }
{ action = "change_category"; bind = "ctrl y"; label = "change category"; after = "Nothing"; }
{ action = "clear"; bind = "ctrl x"; after = "AsyncClearReload"; }
{ action = "create"; bind = "ctrl a"; after = "AsyncClearReload"; }
{ action = "search"; bind = "ctrl a"; after = "AsyncClearReload"; }
];
};
};
# Builtins configuration
builtins = {
bookmarks = {
weight = 5;
placeholder = "Bookmarks";
name = "bookmarks";
icon = "bookmark";
switcher_only = true;
entries = [
{
label = "Walker";
url = "https://github.com/abenz1267/walker";
keywords = [ "walker" "github" ];
}
];
};
clipboard = {
exec = "wl-copy";
height = 5;
name = "clipboard";
avoid_line_breaks = true;
placeholder = "Clipboard";
image_height = 300;
max_entries = 10;
switcher_only = true;
};
commands = {
weight = 5;
icon = "utilities-terminal";
switcher_only = true;
name = "commands";
placeholder = "Commands";
};
switcher = {
weight = 5;
icon = "switcher";
placeholder = "Switcher";
prefix = "/";
};
theme = {
weight = 5;
icon = "switcher";
placeholder = "Theme";
prefix = "theme";
exec = "hyprland-theme-switcher";
};
translation = {
provider = "googlefree";
};
websearch = {
keep_selection = true;
weight = 5;
icon = "applications-internet";
name = "websearch";
placeholder = "Websearch";
entries = [
{
name = "Google";
url = "https://www.google.com/search?q=%TERM%";
}
{
name = "DuckDuckGo";
url = "https://duckduckgo.com/?q=%TERM%";
switcher_only = true;
}
];
};
};
};
};
}

View file

@ -0,0 +1,428 @@
{ config, pkgs, ... }:
let
c = config.lib.stylix.colors.withHashtag;
isDark = config.stylix.polarity == "dark";
bgAlpha = if isDark then "0.6" else "0.85";
hoverAlpha = if isDark then "0.9" else "0.95";
# monocle dots: cyan-ish base0C to complement the base0E workspace dots;
# light schemes wash out low pango alpha, so dim less aggressively there
monocleAccent = c.base0C;
monocleDimAlpha = if isDark then "40%" else "65%";
# monocle window dots: one dot per tiled window on the active workspace,
# active window filled — only when the workspace's layout is monocle
# (hyprctl activeworkspace -j exposes tiledLayout on 0.55). Empty text
# otherwise, which hides the module. Event-driven via the hyprland IPC
# event socket, so no polling.
monocle-indicator = pkgs.writeShellApplication {
name = "waybar-monocle-indicator";
runtimeInputs = with pkgs; [ jq socat ];
text = ''
emit() {
local ws layout wsid active out tooltip addr title
ws=$(hyprctl activeworkspace -j)
layout=$(jq -r .tiledLayout <<<"$ws")
if [ "$layout" != monocle ]; then
printf '{"text":""}\n'
return
fi
wsid=$(jq -r .id <<<"$ws")
active=$(hyprctl activewindow -j | jq -r .address)
out=""
tooltip=""
while IFS=$'\t' read -r addr title; do
if [ "$addr" = "$active" ]; then
out+="<span color='${monocleAccent}'>󰝥</span> "
tooltip+=" $title"$'\n'
else
out+="<span color='${monocleAccent}' alpha='${monocleDimAlpha}'>󰝦</span> "
tooltip+=" $title"$'\n'
fi
done < <(hyprctl clients -j \
| jq -r --argjson w "$wsid" \
'.[] | select(.workspace.id == $w and .mapped and (.floating | not)) | [.address, .title] | @tsv')
jq -cn --arg t "''${out% }" --arg tip "''${tooltip%$'\n'}" '{text: $t, tooltip: $tip}'
}
emit || true
socat -U - "UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" \
| while read -r ev; do
case "$ev" in
workspace*|focusedmon*|activewindowv2*|openwindow*|closewindow*|movewindow*|changefloatingmode*|configreloaded*)
emit || true ;;
esac
done
'';
};
in {
programs.waybar = {
enable = true;
style = ''
@define-color foreground ${if isDark then c.base05 else c.base00};
@define-color foreground-inactive ${if isDark then c.base04 else c.base03};
@define-color background ${if isDark then c.base00 else c.base04};
@define-color color9 ${c.base0E};
* {
font-family: JetBrainsMono Nerd Font;
font-size: 17px;
padding: 0;
margin: 0;
}
window#waybar {
all: unset;
color: @foreground;
}
.modules-left,
.modules-center,
.modules-right {
color: @foreground;
background: alpha(@background, ${bgAlpha});
margin: 5px 10px;
padding: 0 5px;
border-radius: 15px;
}
.modules-left {
padding: 0 5px;
}
.modules-center {
padding: 0 10px;
margin: 5 0 5 0;
}
#clock,
#battery,
#cpu,
#memory,
#disk,
#temperature,
#network,
#pulseaudio,
#custom-gpu,
#custom-dnd,
#custom-fcitx,
#idle_inhibitor,
#power-profiles-daemon {
padding: 0 10px;
border-radius: 15px;
}
#submap {
padding: 0 10px;
border-radius: 15px;
color: @color9;
font-style: italic;
}
#custom-dnd.dnd {
color: @color9;
}
#custom-fcitx.zh {
color: @color9;
}
#idle_inhibitor.activated {
color: @color9;
}
#clock:hover,
#battery:hover,
#cpu:hover,
#memory:hover,
#disk:hover,
#temperature:hover,
#network:hover,
#pulseaudio:hover,
#custom-gpu:hover,
#idle_inhibitor:hover,
#power-profiles-daemon:hover {
background: alpha(@background, ${hoverAlpha});
}
#workspaces {
padding: 0px 5px;
}
#custom-monocle {
padding: 0px 5px;
}
#workspaces button {
all: unset;
padding: 0px 5px;
color: alpha(@color9, 0.4);
transition: all 0.2s ease;
}
#workspaces button:hover {
color: alpha(@color9, 0.7);
border: none;
text-shadow: 0px 0px 1.5px alpha(@color9, 0.5);
transition: all 1s ease;
}
#workspaces button.active {
color: @color9;
border: none;
text-shadow: 0px 0px 2px alpha(@color9, 0.5);
}
#workspaces button.empty {
color: alpha(@foreground, 0.15);
border: none;
text-shadow: 0px 0px 1.5px alpha(@foreground, 0.1);
}
#workspaces button.empty:hover {
color: alpha(@foreground, 0.3);
border: none;
text-shadow: 0px 0px 1.5px alpha(@foreground, 0.3);
transition: all 1s ease;
}
#workspaces button.empty.active {
color: @color9;
border: none;
text-shadow: 0px 0px 2px alpha(@color9, 0.5);
}
#workspaces button.urgent {
color: ${c.base08};
border: none;
text-shadow: 0px 0px 2px alpha(${c.base08}, 0.7);
}
'';
settings = [{
layer = "top";
position = "top";
height = 24;
spacing = 5;
margin = "0";
modules-left = ["clock" "custom/timew" "custom/playerctl"];
modules-center = ["hyprland/submap" "hyprland/workspaces" "custom/monocle"];
modules-right = ["custom/fcitx" "pulseaudio" "battery" "power-profiles-daemon" "network" "group/hardware" "custom/gpu" "temperature" "idle_inhibitor" "custom/dnd" "privacy"];
"hyprland/workspaces" = {
format = "{icon}";
format-icons = {
"active" = "";
"default" = "";
"empty" = "";
# named special workspaces (waybar strips the "special:" prefix)
"chat" = "󰍩";
"work" = "󰒋";
};
on-click = "activate";
icon-size = 10;
sort-by-number= true;
show-special = true;
persistent-workspaces = {
"1" = [];
"2" = [];
"3" = [];
"4" = [];
"5" = [];
};
};
# monocle window index dots (see monocle-indicator above); scroll cycles
"custom/monocle" = {
format = "{}";
return-type = "json";
exec = "${monocle-indicator}/bin/waybar-monocle-indicator";
on-scroll-up = ''hyprctl dispatch "hl.dsp.layout('cyclenext')"'';
on-scroll-down = ''hyprctl dispatch "hl.dsp.layout('cycleprev')"'';
tooltip = true;
};
# visible only while a submap (resize/launch/place) is active
"hyprland/submap" = {
format = "󰌌 {}";
tooltip = false;
};
# mako do-not-disturb indicator; refreshed via RTMIN+9 from the toggle
# bind and from meeting-guard (auto-mute while mic/screenshare live)
"custom/dnd" = {
format = "{}";
return-type = "json";
exec = "m=$(makoctl mode); if echo \"$m\" | grep -qx dnd; then printf '{\"text\":\"󰂛\",\"tooltip\":\"do not disturb\",\"class\":\"dnd\"}'; elif echo \"$m\" | grep -qx meeting; then printf '{\"text\":\"󰍬\",\"tooltip\":\"in a call notifications muted\",\"class\":\"dnd\"}'; else printf '{\"text\":\"󰂚\",\"tooltip\":\"notifications on\"}'; fi";
interval = "once";
signal = 9;
on-click = "makoctl mode -t dnd; pkill -RTMIN+9 waybar";
on-click-right = "mako-history-menu";
};
"group/hardware" = {
orientation = "inherit";
modules = [
"cpu"
"memory"
"disk"
];
drawer = {
transition-duration = 250;
};
};
"custom/playerctl" = {
format = " 󰐊 {}";
return-type = "json";
max-length = 40;
exec = "playerctl -a metadata --format '{\"text\": \"{{artist}} - {{markup_escape(title)}}\", \"tooltip\": \"{{playerName}} : {{artist}} - {{markup_escape(title)}}\", \"alt\": \"{{status}}\", \"class\": \"{{status}}\"}' -F";
on-click = "playerctl play-pause";
on-click-right = "playerctl next";
};
"custom/timew" = {
format = "󰔟 {}";
exec = "timew get dom.active.json | jq -r '.tags | join(\" \")'";
exec-if = "timew get dom.active";
interval = 10;
};
"idle_inhibitor" = {
format = "{icon}";
"format-icons" = {
activated = "󰈈";
deactivated = "󰈉";
};
tooltip = true;
};
clock = {
format = "{:%H:%M}";
interval = 15;
format-alt = "󰃮 {:%Y-%m-%d}";
tooltip-format = "<big>{:%Y %B}</big>\n<tt><small>{calendar}</small></tt>";
calendar = {
mode = "month";
mode-mon-col = 3;
weeks-pos = "right";
on-scroll = 1;
on-click-right = "mode";
format = {
months = "<span color='#d3c6aa'><b>{}</b></span>";
days = "<span color='#e67e80'>{}</span>";
weeks = "<span color='#a7c080'><b>W{}</b></span>";
weekdays = "<span color='#7fbbb3'><b>{}</b></span>";
today = "<span color='#dbbc7f'><b><u>{}</u></b></span>";
};
};
actions = {
on-click-right = "mode";
on-click-forward = "tz_up";
on-click-backward = "tz_down";
on-scroll-up = "shift_up";
on-scroll-down = "shift_down";
};
};
cpu = {
format = "󰘚 {usage}%";
tooltip = true;
interval = 1;
on-click = "ghostty -e btop";
};
memory = {
format = "󰍛 {used:0.1f}G/{total:0.1f}G";
interval = 1;
on-click = "ghostty -e btop";
};
temperature = {
critical-threshold = 80;
format = "{icon} {temperatureC}°C";
format-icons = ["󱃃" "󰔏" "󱃂"];
on-click = "ghostty -e s-tui";
};
battery = {
states = {
good = 95;
warning = 30;
critical = 1;
};
format = "{icon} {capacity}%";
format-charging = "󰂄 {capacity}%";
format-plugged = "󰚥 {capacity}%";
format-alt = "{icon} {time}";
format-icons = ["󰂎" "󰁺" "󰁻" "󰁼" "󰁽" "󰁾" "󰁿" "󰂀" "󰂁" "󰂂" "󰁹"];
};
network = {
format-wifi = "󰖩 {essid} ({signalStrength}%)";
format-ethernet = "󰈀 {ifname}";
format-linked = "󰈀 {ifname} (No IP)";
format-disconnected = "󰖪 Disconnected";
format-alt = "{ifname}: {ipaddr}/{cidr}";
tooltip-format = "{ifname}: {ipaddr}";
on-click = "ghostty -e sudo nmtui";
};
power-profiles-daemon = {
format = "{icon}";
tooltip-format = "Power profile: {profile}\nDriver: {driver}";
tooltip = true;
format-icons = {
default = "󰗑";
performance = "󰓅";
balanced = "󰾅";
power-saver = "󰾆";
};
};
pulseaudio = {
format = "{icon} {volume}%";
# no dedicated bluetooth module (duplicates badly) — BT sinks just
# show as regular volume here
format-bluetooth = "{icon} {volume}%";
format-bluetooth-muted = "󰝟";
format-muted = "󰝟";
format-icons = {
headphone = "󰋋";
hands-free = "󰥰";
headset = "󰋎";
phone = "󰏲";
portable = "󰄝";
car = "󰄋";
default = ["󰕿" "󰖀" "󰕾"];
};
on-click = "pwvucontrol";
on-click-right = "pactl set-sink-mute @DEFAULT_SINK@ toggle";
on-scroll-up = "pactl set-sink-volume @DEFAULT_SINK@ +2%";
on-scroll-down = "pactl set-sink-volume @DEFAULT_SINK@ -2%";
};
"custom/gpu" = {
format = "󰢮 {}%";
exec = "cat /sys/class/drm/card*/device/gpu_busy_percent";
interval = 2;
tooltip-format = "GPU utilization";
};
disk = {
interval = 30;
format = "󰋊 {percentage_used}%";
path = "/";
on-click = "ghostty -e gdu /";
};
privacy = {
icon-spacing = 4;
icon-size = 16;
modules = [
{
type = "screenshare";
tooltip = true;
tooltip-icon-size = 24;
}
{
type = "audio-in";
tooltip = true;
tooltip-icon-size = 24;
}
];
};
}];
};
}

View file

@ -0,0 +1,113 @@
@define-color foreground #e6d9db;
@define-color foreground-inactive #aaaaaa;
@define-color background #2c2525;
@define-color color9 #00ffcc;
* {
font-family: JetBrainsMono Nerd Font;
font-size: 17px;
padding: 0;
margin: 0;
}
window#waybar {
all: unset;
}
.modules-left,
.modules-center,
.modules-right {
background: alpha(@background, 0.6);
margin: 5px 10px;
padding: 0 5px;
border-radius: 15px;
}
.modules-left {
padding: 0 5px;
}
.modules-center {
padding: 0 10px;
margin: 5 0 5 0;
}
#clock,
#battery,
#cpu,
#memory,
#disk,
#temperature,
#backlight,
#network,
#pulseaudio,
#wireplumber,
#custom-media,
#tray,
#mode,
#idle_inhibitor,
#scratchpad,
#power-profiles-daemon,
#language,
#mpd {
padding: 0 10px;
border-radius: 15px;
}
#clock:hover,
#battery:hover,
#cpu:hover,
#memory:hover,
#disk:hover,
#temperature:hover,
#backlight:hover,
#network:hover,
#pulseaudio:hover,
#wireplumber:hover,
#custom-media:hover,
#tray:hover,
#mode:hover,
#idle_inhibitor:hover,
#scratchpad:hover,
#power-profiles-daemon:hover,
#language:hover,
#mpd:hover {
background: rgba(26, 27, 38, 0.9);
}
#workspaces {
padding: 0px 5px;
}
#workspaces button {
all: unset;
padding: 0px 5px;
color: alpha(@color9, 0.4);
transition: all 0.2s ease;
}
#workspaces button:hover {
color: rgba(0, 0, 0, 0);
border: none;
text-shadow: 0px 0px 1.5px rgba(0, 0, 0, 0.5);
transition: all 1s ease;
}
#workspaces button.active {
color: @color9;
border: none;
text-shadow: 0px 0px 2px rgba(0, 0, 0, 0.5);
}
#workspaces button.empty {
color: rgba(0, 0, 0, 0);
border: none;
text-shadow: 0px 0px 1.5px rgba(0, 0, 0, 0.2);
}
#workspaces button.empty:hover {
color: rgba(0, 0, 0, 0);
border: none;
text-shadow: 0px 0px 1.5px rgba(0, 0, 0, 0.5);
transition: all 1s ease;
}
#workspaces button.empty.active {
color: @color9;
border: none;
text-shadow: 0px 0px 2px rgba(0, 0, 0, 0.5);
}

View file

@ -0,0 +1,83 @@
{ pkgs, ... }:
let
wf-recorder-toggle = pkgs.writeShellApplication {
name = "wf-recorder-toggle";
runtimeInputs = with pkgs; [
wf-recorder
slurp
libnotify
coreutils
procps # pgrep, pkill
];
text = ''
RECORDINGS_DIR="''${HOME}/Videos/Recordings"
LOOPBACK_PID_FILE="/tmp/wf-recorder-loopback.pid"
mkdir -p "$RECORDINGS_DIR"
cleanup_loopback() {
if [ -f "$LOOPBACK_PID_FILE" ]; then
kill "$(cat "$LOOPBACK_PID_FILE")" 2>/dev/null || true
rm -f "$LOOPBACK_PID_FILE"
fi
}
# If already recording, stop and exit
if pgrep -x wf-recorder > /dev/null; then
pkill -SIGINT wf-recorder
cleanup_loopback
notify-send "Recording stopped" "Saved to $RECORDINGS_DIR"
exit 0
fi
# Clean up any leftover loopback from a previous crashed session
cleanup_loopback
FILENAME="$RECORDINGS_DIR/recording-$(date +%Y%m%d-%H%M%S).mp4"
# Get the PipeWire object ID of the default audio sink.
# We use pw-loopback to capture the sink's audio output (what you hear)
# because PipeWire doesn't expose .monitor sources for Bluetooth sinks
# through PulseAudio compat — making pactl-based approaches unreliable.
SINK_ID="$(wpctl inspect @DEFAULT_AUDIO_SINK@ | head -1 | awk '{print $2}' | tr -d ',')"
if [ -z "$SINK_ID" ]; then
notify-send "Recording failed" "No default audio sink found"
exit 1
fi
# Create a PipeWire loopback that captures the sink's monitor output
# and exposes it as a virtual source for wf-recorder.
pw-loopback \
--capture-props="target.object=$SINK_ID stream.capture.sink=true" \
--playback-props="media.class=Audio/Source node.name=wf-recorder-audio" &
echo $! > "$LOOPBACK_PID_FILE"
sleep 0.5
ARGS=(--audio --audio-device=wf-recorder-audio -f "$FILENAME")
if [ "''${1:-}" = "area" ]; then
GEOMETRY=$(slurp)
if [ -z "$GEOMETRY" ]; then
cleanup_loopback
exit 1
fi
ARGS+=(-g "$GEOMETRY")
fi
wf-recorder "''${ARGS[@]}" &
disown
if [ "''${1:-}" = "area" ]; then
notify-send "Recording started" "Area recording..."
else
notify-send "Recording started" "Screen recording..."
fi
'';
};
in
{
home.packages = [
wf-recorder-toggle
];
}

7
home/programs/yt-dlp.nix Normal file
View file

@ -0,0 +1,7 @@
{ pkgs, ... }:
{
home.packages = with pkgs; [
yt-dlp
];
}

22
home/services/gpg.nix Normal file
View file

@ -0,0 +1,22 @@
{ pkgs, ... }:
{
services.gpg-agent = {
enable = true;
# Cache passphrases for 1 hour
defaultCacheTtl = 3600;
maxCacheTtl = 7200;
# Use Qt pinentry for GUI prompts (works with vim-fugitive on Hyprland)
pinentry.package = pkgs.pinentry-qt;
# Enable SSH support via GPG agent (uses YubiKey for SSH auth)
enableSshSupport = true;
# Cache SSH keys for 1 hour
defaultCacheTtlSsh = 3600;
# YubiKey authentication subkey keygrips for SSH
sshKeys = [
"139C66F1D36546BFD8FBBE5570384F1130CDFE6E" # YubiKey 2 (5C NFC) auth key - 0x05AB33B50A345D4D
"F25F34CF85F685E5C1B0DE428B8A7228EBDB03E5" # YubiKey 1 (NEO) auth key - 0x6D916A825C88D51F
];
};
}

41
home/services/mako.nix Normal file
View file

@ -0,0 +1,41 @@
{
services = {
mako = {
enable = true;
settings = {
sort = "-time";
border-radius = 2;
border-size = 3;
actions = true;
anchor = "top-right";
default-timeout = 5000;
icons = true;
layer = "overlay";
padding = "12,20";
width = 420;
height = 110;
margin = 10;
outer-margin = 20;
max-icon-size = 32;
# history pool consumed by mako-history-menu ($mod CTRL D)
max-history = 100;
"urgency=critical" = {
default-timeout = 0;
};
# do-not-disturb: toggled via `makoctl mode -t dnd` (bind + waybar)
"mode=dnd" = {
invisible = 1;
};
# auto-set by meeting-guard while mic/screenshare is live — separate
# mode so it never fights the manual dnd toggle
"mode=meeting" = {
invisible = 1;
};
};
};
};
}

View file

@ -0,0 +1,6 @@
{ pkgs, ... }:
{
home.packages = [ pkgs.playerctl ];
services.playerctld.enable = true;
}

View file

@ -0,0 +1,48 @@
{ pkgs, ... }:
# Auto-DND during calls: while any pipewire capture stream is running
# (mic or screenshare/camera consumption), mako gets a "meeting" mode —
# invisible notifications, so nothing pops over a shared screen. Uses its
# own mako mode rather than "dnd" so the manual SUPER+SHIFT+D toggle and
# this guard never fight over the same flag.
let
meeting-guard = pkgs.writeShellApplication {
name = "meeting-guard";
runtimeInputs = with pkgs; [ pipewire jq mako procps ];
text = ''
# clear stale state from a previous run
makoctl mode -r meeting >/dev/null || true
state=0
while true; do
n=$(pw-dump 2>/dev/null | jq '[.[]
| select(.type == "PipeWire:Interface:Node"
and ((.info.props["media.class"] // "") | test("^Stream/Input/(Audio|Video)$"))
and .info.state == "running")
] | length' || echo 0)
if [ "$n" -gt 0 ] && [ "$state" -eq 0 ]; then
makoctl mode -a meeting >/dev/null
state=1
pkill -RTMIN+9 waybar || true
elif [ "$n" -eq 0 ] && [ "$state" -eq 1 ]; then
makoctl mode -r meeting >/dev/null
state=0
pkill -RTMIN+9 waybar || true
fi
sleep 5
done
'';
};
in {
systemd.user.services.meeting-guard = {
Unit = {
Description = "auto-DND while a call (mic/screenshare) is active";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${meeting-guard}/bin/meeting-guard";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
}

5
home/services/podman.nix Normal file
View file

@ -0,0 +1,5 @@
{
home.sessionVariables = {
DOCKER_HOST = "unix://$XDG_RUNTIME_DIR/podman/podman.sock";
};
}

202
home/services/syncthing.nix Normal file
View file

<
@ -0,0 +1,202 @@
{ config, lib, pkgs, inputs, ... }:
let
# Secrets file location
secretsFile = "${inputs.secrets}/secrets/framework13/syncthing.yaml";
# Device IDs - obtained from Syncthing on each device; kept in the
# private flake's vars so they stay out of the public repo
deviceIds = inputs.secrets.vars.syncthing.deviceIds;
# Common versioning configuration for 3-2-1 backup strategy
# Trashcan versioning moves deleted/replaced files to .stversions
versioningConfig = {
type = "trashcan";
params = {
cleanoutDays = "30"; # Keep deleted files for 30 days
};
};
in {
# SOPS secrets configuration for Syncthing GUI authentication
sops = {
age.keyFile = "/home/bogdan/.config/sops/age/keys.txt";
# YubiKey is the sole recipient now; the activation service runs with a
# minimal PATH, so the plugin binary must be injected explicitly or the
# AGE-PLUGIN-YUBIKEY identity in keys.txt can't be resolved.
age.plugins = [ pkgs.age-plugin-yubikey ];
secrets = {
syncthing_gui_password = {
sopsFile = secretsFile;
};
};
};
services.syncthing = {
enable = true;
# Declarative configuration - overwrite any manual changes
overrideDevices = true;
overrideFolders = true;
# GUI accessible from all interfaces (secured by password)
# Access via: http://localhost:8384 or http://<tailscale-ip>:8384
guiAddress = "0.0.0.0:8384";
# GUI credentials from SOPS secrets
# HM 26.05 removed the standalone `passwordFile`; use `guiCredentials`.
guiCredentials = {
username = "bogdan";
passwordFile = config.sops.secrets.syncthing_gui_password.path;
};
settings = {
gui = {
theme = "dark";
# Hardcoded username for GUI authentication
user = "bogdan";
};
options = {
# Prefer local network syncing to save Tailscale/mobile data
# Local discovery on LAN - primary sync method
localAnnounceEnabled = true;
# Global discovery as fallback when not on same LAN
globalAnnounceEnabled = true;
# Relay servers as last resort (uses bandwidth)
relaysEnabled = true;
# NAT traversal for direct connections
natEnabled = true;
# Limit relay usage - prefer direct connections
relayReconnectIntervalM = 30;
# Automatically upgrade syncthing
autoUpgradeIntervalH = 0; # Disabled - managed by Nix
# Crash reporting
crashReportingEnabled = true;
# Start browser on startup
startBrowser = false;
};
# Devices to sync with
devices = {
"Pixel 10 Pro" = {
id = deviceIds.pixel;
# Auto-accept folders shared by this device
autoAcceptFolders = false;
# Addresses - let Syncthing discover automatically
# Prefers local discovery, falls back to global/relay
addresses = [ "dynamic" ];
};
"Steam Deck" = {
id = deviceIds.steamdeck;
autoAcceptFolders = false;
addresses = [ "dynamic" ];
};
"Boox Go Color 7" = {
id = deviceIds.boox;
autoAcceptFolders = false;
addresses = [ "dynamic" ];
};
};
# Folders to sync - matching restic backup paths
folders = {
# Documents folder - bidirectional sync with Pixel, send-only to Steam Deck
"Documents" = {
id = "docs";
path = "/home/bogdan/Documents";
devices = [ "Pixel 10 Pro" ];
# Bidirectional sync (Steam Deck is configured as receive-only on its end)
type = "sendreceive";
# Versioning for 3-2-1 backup (local versions)
versioning = versioningConfig;
# Watch for changes using inotify
fsWatcherEnabled = true;
# Rescan interval in seconds (fallback if inotify misses something)
rescanIntervalS = 3600; # 1 hour
# Ignore permissions (mobile devices have different permission models)
ignorePerms = true;
};
# Passwords folder - sync across all devices
"Passwords" = {
id = "passwords";
path = "/home/bogdan/Passwords";
devices = [ "Pixel 10 Pro" "Steam Deck" ];
type = "sendreceive";
versioning = versioningConfig;
fsWatcherEnabled = true;
rescanIntervalS = 3600;
ignorePerms = true;
};
# Steam game recordings from Steam Deck - receive-only
"Steam Recordings" = {
id = "steam-recordings";
path = "/home/bogdan/Videos/Steam Recordings";
devices = [ "Steam Deck" ];
# Receive-only: Framework13 receives recordings but doesn't modify them
type = "receiveonly";