Compare commits

...

9 commits

Author SHA1 Message Date
5902011dc6
feat: add mcp-npx wrapper for Node.js MCP servers
Provides npx with Node.js in PATH for MCP server execution.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 01:26:28 +01:00
3fe859e21f
feat(hyprland): add immediate render rule to prevent frame flash
Applies immediate rendering to all windows to eliminate the garbage
frame flash that appears before first render.

Also documents JetBrains phantom window suppression limitation (RE2
doesn't support lookaheads, so can't exclude toolbox).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 01:26:20 +01:00
d6f943ba7f
feat(hyprland): pin workspaces to monitors and reduce blur noise
- Pins workspaces 1-5 to HDMI-A-1 (main ultrawide)
- Pins workspaces 6-8 to DP-3 (vertical display)
- Reduces blur noise from 0.3 to 0.05 for cleaner appearance

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 01:26:05 +01:00
85debcbcd4
feat: enable MCP servers and add happy-coder
- Enables official MCP servers (fetch, playwright, stackexchange, arxiv)
- Adds Charlie MCP servers (memory, comunica)
- Adds claudezilla MCP server
- Adds happy-coder mobile client from nixpkgs master
- Adds nodejs_22 for MCP server runtime

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 01:25:53 +01:00
f49a9c8019
refactor: use active-window utility in scripts
Migrates quick-zeal and spawn-term from compositor-specific APIs
(hyprctl, kdotool, niri msg) to the unified active-window utility.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 01:25:46 +01:00
9e49bd830d
feat: add compositor-agnostic window info utilities
- active-window: Get focused window info (class, pid) for Niri/Hyprland
- active-path: Get CWD of focused terminal window

These utilities abstract compositor-specific APIs for use in scripts.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 01:25:45 +01:00
8feb3957a2
fix: expose harbor package namespace to overlays
Adds harbor (self.packages) to overlay arguments, allowing custom
packages to reference each other via pkgs.harbor.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 01:25:36 +01:00
447da21da4
docs: add package search guidelines to CLAUDE.md
Documents the nixpkgs search workflow before writing custom packages.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 01:25:33 +01:00
a627589a48
chore: update flake inputs
Updates nixpkgs master channel.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-28 01:25:30 +01:00
14 changed files with 233 additions and 53 deletions

View file

@ -46,6 +46,15 @@ bosun.secrets.npmrc = { ... }; # Secret definitions
Profile definitions are in `modules/generic/profiles.nix`, implementations in `modules/nixos/profiles/`. Profile definitions are in `modules/generic/profiles.nix`, implementations in `modules/nixos/profiles/`.
## Adding packages
Before writing a custom `packages/` derivation, always check if the package already exists in nixpkgs:
- Stable: https://search.nixos.org/packages?channel=25.11&query=<name>
- Unstable: https://search.nixos.org/packages?channel=unstable&query=<name>
- Master (bleeding edge): check open PRs at https://github.com/NixOS/nixpkgs/pulls
If it exists in `unstable` or `master` but not stable, pull it via `overlays/unstable.nix` using `channels.unstable` or `channels.master`. Only write a custom `packages/` derivation as a last resort.
## Architecture patterns ## Architecture patterns
- **import-tree** auto-discovers and imports `.nix` files in `modules/flake/`. Files prefixed with `_` are excluded from auto-import. - **import-tree** auto-discovers and imports `.nix` files in `modules/flake/`. Files prefixed with `_` are excluded from auto-import.

6
flake.lock generated
View file

@ -778,11 +778,11 @@
}, },
"master": { "master": {
"locked": { "locked": {
"lastModified": 1771456066, "lastModified": 1771495899,
"narHash": "sha256-CLuGt3yg70gnhSam+0qpcWgPnUdY98wVeH4lByklol4=", "narHash": "sha256-UlAN9PHsBx1Kk65gR/KvLfO74zQcOjNZ+d/0td5T8eM=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "be6cf55e819f4d362aa6be60254bbb2537f9a5cb", "rev": "f4f54061a12ebbdd03d7c53eed54d1e135840624",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -24,6 +24,9 @@
nodePackages.typescript-language-server nodePackages.typescript-language-server
nil # nix lsp nil # nix lsp
# Node.js for MCP servers
nodejs_22
# trurl # Parsing and manipulating URLs via CLI # trurl # Parsing and manipulating URLs via CLI
pandoc # Document converter pandoc # Document converter
ripgrep # Grep file search ripgrep # Grep file search
@ -39,6 +42,7 @@
gitui gitui
tea tea
harbor.agent-deck # Terminal session manager for AI coding agents harbor.agent-deck # Terminal session manager for AI coding agents
happy-coder # Claude Code mobile client (happy.engineering)
harbor.oryx # TUI for sniffing network traffic using eBPF harbor.oryx # TUI for sniffing network traffic using eBPF
# BMAD # BMAD
@ -83,33 +87,48 @@
enable = true; enable = true;
# package = inputs.unstable.${system}.claude-code; # package = inputs.unstable.${system}.claude-code;
# mcpServers = { mcpServers = {
# fetch = { # Official MCP servers (require Node.js)
# args = ["-y" "@modelcontextprotocol/server-fetch"]; fetch = {
# command = "npx"; command = "npx";
# type = "stdio"; args = ["-y" "@modelcontextprotocol/server-fetch"];
# }; type = "stdio";
# playwright = { };
# args = ["-y" "@modelcontextprotocol/server-playwright"]; playwright = {
# command = "npx"; command = "npx";
# type = "stdio"; args = ["-y" "@modelcontextprotocol/server-playwright"];
# }; type = "stdio";
# stackexchange = { };
# args = ["-y" "mcp-server-stackexchange"]; stackexchange = {
# command = "npx"; command = "npx";
# type = "stdio"; args = ["-y" "mcp-server-stackexchange"];
# }; type = "stdio";
# arxiv = { };
# args = ["-y" "mcp-server-arxiv"]; arxiv = {
# command = "npx"; command = "npx";
# type = "stdio"; args = ["-y" "mcp-server-arxiv"];
# }; type = "stdio";
# claudezilla = { };
# command = "bun";
# args = ["/home/toph/code/vendor/claudezilla/mcp/server.js"]; # Custom MCP servers
# type = "stdio"; claudezilla = {
# }; command = "bun";
# }; args = ["/home/toph/code/vendor/claudezilla/mcp/server.js"];
type = "stdio";
};
# Charlie MCP servers (local)
charlie-memory = {
command = "bun";
args = ["/home/toph/agent/mcp/memory/index.js"];
type = "stdio";
};
charlie-comunica = {
command = "bun";
args = ["/home/toph/agent/mcp/comunica/index.js"];
type = "stdio";
};
};
}; };
}; };
} }

View file

@ -46,7 +46,7 @@
enabled = true; enabled = true;
size = 4; size = 4;
passes = 2; passes = 2;
noise = 0.3; noise = 0.05;
}; };
shadow = { shadow = {
enabled = true; enabled = true;
@ -69,6 +69,16 @@
# Single tiled window on main monitor: give it breathing room # Single tiled window on main monitor: give it breathing room
workspace = [ workspace = [
# Pin workspaces to monitors
"1, monitor:HDMI-A-1, default:true"
"2, monitor:HDMI-A-1"
"3, monitor:HDMI-A-1"
"4, monitor:HDMI-A-1"
"5, monitor:HDMI-A-1"
"6, monitor:DP-3, default:true"
"7, monitor:DP-3"
"8, monitor:DP-3"
# Single tiled window on main monitor: give it breathing room
"w[t1] m[HDMI-A-1], gapsout:15 840 15 840" "w[t1] m[HDMI-A-1], gapsout:15 840 15 840"
]; ];

View file

@ -1,6 +1,9 @@
{...}: { {...}: {
wayland.windowManager.hyprland.settings = { wayland.windowManager.hyprland.settings = {
windowrulev2 = [ windowrulev2 = [
# Prevent garbage frame flash before first render
"immediate, class:.*"
# Privacy: block from screen capture # Privacy: block from screen capture
"noscreenshare, class:^(1password)$" "noscreenshare, class:^(1password)$"
"noscreenshare, class:^(thunderbird)$" "noscreenshare, class:^(thunderbird)$"
@ -21,8 +24,9 @@
# Kitty: slight transparency # Kitty: slight transparency
"opacity 0.97 0.97, class:^(kitty)$" "opacity 0.97 0.97, class:^(kitty)$"
# JetBrains: suppress phantom windows # JetBrains: suppress phantom windows (RE2 doesn't support lookaheads,
"nofocus, class:^jetbrains-(?!toolbox), floating:1, title:^win\\d+$" # so we can't exclude toolbox here — apply per-IDE if needed)
# "nofocus, class:^(jetbrains-idea|jetbrains-rider|jetbrains-clion)$, floating:1, title:^win\\d+$"
]; ];
}; };
} }

View file

@ -24,6 +24,7 @@
inherit lib; inherit lib;
pkgs = prev; pkgs = prev;
}; };
harbor = inputs.self.packages.${system} or {};
}) })
]; ];
}; };

View file

@ -17,5 +17,5 @@
# open-webui # open-webui
; ;
inherit (channels.master) install-nothing marimo; inherit (channels.master) install-nothing marimo happy-coder;
} }

View file

@ -0,0 +1,116 @@
#!/usr/bin/env bash
# active-path <pid>
#
# Given a PID (e.g. from active-window), output the active working directory
# or project root for that process. Handles:
# - VSCode/Electron: parses --folder-uri from the process tree
# - Terminals (kitty, etc.): finds the deepest meaningful cwd in the tree
# - Fallback: own cwd
set -o errexit
set -o nounset
set -o pipefail
PID="${1:?Usage: active-path <pid>}"
PROJECT_MARKERS=(.git Cargo.toml package.json Gemfile pyproject.toml go.mod flake.nix)
# Walk up from a path looking for a project root marker.
# Returns the marker directory, or the original path if nothing is found.
find_project_root() {
local dir="$1"
local d="$dir"
while [[ "$d" != "/" && -n "$d" ]]; do
for marker in "${PROJECT_MARKERS[@]}"; do
[[ -e "$d/$marker" ]] && { printf '%s\n' "$d"; return 0; }
done
d="$(dirname "$d")"
done
printf '%s\n' "$dir"
}
# Decode a percent-encoded URI path component (file:/// paths are rarely
# exotic, but spaces and non-ASCII chars do occur).
url_decode() {
printf '%b' "${1//%/\\x}"
}
# Read null-terminated argv of a pid and look for --folder-uri.
# Prints the decoded path and returns 0 if found, else returns 1.
folder_uri_from_cmdline() {
local pid="$1"
local prev=""
while IFS= read -r -d $'\0' arg; do
case "$arg" in
--folder-uri=file://*)
url_decode "${arg#--folder-uri=file://}"
return 0
;;
file://*)
if [[ "$prev" == "--folder-uri" ]]; then
url_decode "${arg#file://}"
return 0
fi
;;
esac
prev="$arg"
done < "/proc/$pid/cmdline" 2>/dev/null
return 1
}
# Collect PIDs of all processes in the subtree rooted at $1.
all_pids() {
local pid="$1"
printf '%s\n' "$pid"
for child in $(pgrep -P "$pid" 2>/dev/null); do
all_pids "$child"
done
}
# Given a list of PIDs, return the most specific (longest) cwd that lives
# under the user's home directory. Falls back to any non-root, non-proc cwd.
best_cwd_from_pids() {
local best=""
for pid in "$@"; do
local cwd
cwd=$(readlink -f "/proc/$pid/cwd" 2>/dev/null) || continue
[[ "$cwd" == "/" || "$cwd" == "/root" || "$cwd" == "${HOME}" ]] && continue
[[ "$cwd" =~ ^/(proc|sys|run|dev) ]] && continue
# Prefer paths under home; among those prefer longer (more specific) ones
if [[ "$cwd" == "${HOME}"/* ]]; then
if [[ -z "$best" || "${#cwd}" -gt "${#best}" ]]; then
best="$cwd"
fi
elif [[ -z "$best" ]]; then
best="$cwd"
fi
done
[[ -n "$best" ]] && printf '%s\n' "$best"
}
# --- Main ---
readarray -t TREE < <(all_pids "$PID")
# 1. VSCode / Electron: search entire process tree for --folder-uri
for pid in "${TREE[@]}"; do
if path=$(folder_uri_from_cmdline "$pid" 2>/dev/null); then
find_project_root "$path"
exit 0
fi
done
# 2. Terminal / generic: find the best cwd among all processes in the tree
# (prefers the shell or its running child over the terminal emulator itself,
# because terminal emulators typically sit at $HOME while the shell has moved)
if cwd=$(best_cwd_from_pids "${TREE[@]}"); then
find_project_root "$cwd"
exit 0
fi
# 3. Absolute fallback: own cwd, even if it's /
own=$(readlink -f "/proc/$PID/cwd" 2>/dev/null || true)
[[ -n "$own" ]] && find_project_root "$own" && exit 0
exit 1

View file

@ -0,0 +1,6 @@
{pkgs, ...}:
pkgs.writeShellApplication {
name = "active-path";
runtimeInputs = [pkgs.procps];
text = builtins.readFile ./active-path;
}

View file

@ -0,0 +1,19 @@
{pkgs, ...}:
pkgs.writeShellApplication {
name = "active-window";
runtimeInputs = [pkgs.jq];
text = ''
if [[ -n "''${NIRI_SOCKET:-}" ]]; then
FOCUSED_ID=$(niri msg --json focused-window | jq '.id')
niri msg --json windows \
| jq --argjson id "$FOCUSED_ID" \
'[.[] | select(.id == $id)] | first
| {compositor: "niri", class: .app_id, pid: .pid}'
elif [[ -n "''${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
hyprctl activewindow -j \
| jq '{compositor: "hyprland", class: .class, pid: .pid}'
else
printf '{"compositor":"unknown","class":null,"pid":null}\n'
fi
'';
}

View file

@ -0,0 +1,6 @@
{ pkgs }:
pkgs.writeShellScriptBin "mcp-npx" ''
# Wrapper that provides npx with Node.js in PATH
exec ${pkgs.nodejs_22}/bin/npx "$@"
''

View file

@ -1,5 +1,6 @@
{pkgs, ...}: {pkgs, ...}:
pkgs.writeShellApplication { pkgs.writeShellApplication {
name = "quick-zeal"; name = "quick-zeal";
runtimeInputs = [pkgs.harbor.active-window pkgs.jq];
text = builtins.readFile ./quick-zeal; text = builtins.readFile ./quick-zeal;
} }

View file

@ -6,13 +6,14 @@ extract_major_version() {
} }
# Detects the focused window and checks if it's Kitty # Detects the focused window and checks if it's Kitty
ACTIVE_WINDOW=$(hyprctl activewindow -j | jq -r '.class') WINDOW=$(active-window)
ACTIVE_WINDOW=$(printf '%s' "$WINDOW" | jq -r '.class // empty')
PID=$(printf '%s' "$WINDOW" | jq -r '.pid // empty')
# Check if the focused window is a Kitty terminal and if it's in a Git repository. # Check if the focused window is a Kitty terminal and if it's in a Git repository.
# If so, determine the project type and open Zeal with the appropriate argument # If so, determine the project type and open Zeal with the appropriate argument
zeal_argument="" zeal_argument=""
if [[ $ACTIVE_WINDOW == "kitty" ]]; then if [[ $ACTIVE_WINDOW == "kitty" ]]; then
PID=$(hyprctl activewindow -j | jq -r '.pid')
CHILD_PID=$(pgrep -P "$PID" | tail -1) CHILD_PID=$(pgrep -P "$PID" | tail -1)
SHELL_CWD=$(readlink -f "/proc/${CHILD_PID}/cwd") SHELL_CWD=$(readlink -f "/proc/${CHILD_PID}/cwd")

View file

@ -1,25 +1,13 @@
{pkgs, ...}: {pkgs, ...}:
pkgs.writeNushellApplication { pkgs.writeNushellApplication {
name = "spawn-term"; name = "spawn-term";
runtimeInputs = with pkgs; [kdotool]; runtimeInputs = [pkgs.harbor.active-window];
text = '' text = ''
let compositor = $env.XDG_CURRENT_DESKTOP? | default "" let window = (active-window | from json)
let window_info = {
let window_info = if ($compositor | str contains "niri") { is_kitty: ($window.class? == "kitty"),
let focused_window = (niri msg --json focused-window | from json | get id?) pid: $window.pid?
if ($focused_window | is-empty) {
{ is_kitty: false, pid: null }
} else {
let info = (niri msg --json windows | from json | where id == $focused_window | first)
{ is_kitty: ($info.app_id? == "kitty"), pid: $info.pid? }
}
} else {
let focused_window = (kdotool getactivewindow)
{
is_kitty: ((kdotool getwindowclassname $focused_window) == "kitty"),
pid: (kdotool getwindowpid $focused_window | into int)
}
} }
if $window_info.is_kitty { if $window_info.is_kitty {