Files
copnix/modules/home/agents/home-claude.ulu.nix
2026-10-26 10:43:12 +03:00

464 lines
17 KiB
Nix

{ ... }:
{
flake.homeModules.home-agents-claude =
{
config,
lib,
pkgs,
...
}:
let
inherit (lib.lists) concatMap singleton;
inherit (lib.strings) concatLines;
inherit (lib.generators) toJSON;
inherit (lib.meta) getExe;
allowed.commands = [
"rg*"
"ls*"
"jj bookmark list*"
"jj config get*"
"jj config list*"
"jj config path*"
"jj diff*"
"jj evolog*"
"jj file annotate*"
"jj file list*"
"jj file search*"
"jj file show*"
"jj file track*"
"jj git colocation status*"
"jj git remote list*"
"jj git root*"
"jj help*"
"jj interdiff*"
"jj log*"
"jj op diff*"
"jj op log*"
"jj op show*"
"jj operation diff*"
"jj operation log*"
"jj operation show*"
"jj resolve --list"
"jj root*"
"jj show*"
"jj sparse list*"
"jj st*"
"jj status*"
"jj tag list*"
"jj util completion*"
"jj util config-schema*"
"jj util markdown-help*"
"jj version*"
"jj workspace list*"
"jj workspace root*"
"gh auth status*"
"gh cache list*"
"gh gist list*"
"gh gist view*"
"gh issue list*"
"gh issue status*"
"gh issue view*"
"gh label list*"
"gh pr checks*"
"gh pr diff*"
"gh pr list*"
"gh pr status*"
"gh pr view*"
"gh release list*"
"gh release view*"
"gh repo list*"
"gh repo view*"
"gh run list*"
"gh run view*"
"gh status*"
"gh workflow list*"
"gh workflow view*"
# not `nix develop*`: `nix develop -c <cmd>` would run anything
"nix build*"
"nix eval*"
"nix flake archive*"
"nix flake check*"
"nix flake metadata*"
"nix flake show*"
"nix path-info*"
"nixfmt*"
"cargo clippy*"
"cargo nextest*"
];
forbidden.rules = [
{
commands = singleton "git*";
justification = "Use `jj` for version control.";
}
{
commands = [
"nix-build*"
"nix-shell*"
"nix-instantiate*"
"nix-env*"
"nix-channel*"
];
justification = "Use nix3 commands (`nix build`, `nix develop`, `nix flake check`, ...) instead of legacy nix-* commands.";
}
{
commands = singleton "cargo check*";
justification = "Use `cargo clippy` instead of `cargo check`.";
}
{
commands = singleton "cargo test*";
justification = "Use `cargo nextest` instead of `cargo test`.";
}
{
commands = [
"find /nix/store"
"find /nix/store *"
"find /nix/store/"
"find /nix/store/ *"
];
justification = "Don't `find` over the entire `/nix/store`. Search specific store paths obtained from the flake instead.";
}
];
instructions =
[
"Never write a manual `imports = [ ... ]` list in a dendritic (*.ulu.nix) flake; discovery is automatic."
"Prefer nix3 commands (`nix build`, `nix flake check`, ...) over nix2/nix1 commands."
]
++ (forbidden.rules |> map ({ justification, ... }: justification))
|> map (instruction: ''
- ${instruction}
'')
|> concatLines;
allowed.paths = [
"/etc/profiles"
"/nix/store"
];
# more bittersweet
statusLine = pkgs.writers.writeNuBin "claude-code-statusline" /* nu */ ''
def format-duration [ms: int] {
let total_s = $ms // 1000
let h = $total_s // 3600
let m = ($total_s mod 3600) // 60
let s = $total_s mod 60
if $h > 0 {
$"($h)h($m | fill -a r -w 2 -c '0')m($s | fill -a r -w 2 -c '0')s"
} else if $m > 0 {
$"($m)m($s | fill -a r -w 2 -c '0')s"
} else {
$"($s)s"
}
}
def color-for-pct [pct: number] {
let pct_int = $pct | math floor | into int
if $pct_int >= 80 {
"\e[31m"
} else if $pct_int >= 50 {
"\e[33m"
} else {
"\e[32m"
}
}
def format-rate-limits [input: record] {
let session_pct = try { $input | get rate_limits.five_hour.used_percentage } catch { null }
let week_pct = try { $input | get rate_limits.seven_day.used_percentage } catch { null }
let session_part = if $session_pct != null {
let c = color-for-pct $session_pct
let v = $session_pct | math round --precision 0 | into int
$"session: ($c)($v)%\e[0m"
} else { "" }
let week_part = if $week_pct != null {
let c = color-for-pct $week_pct
let v = $week_pct | math round --precision 0 | into int
$"week: ($c)($v)%\e[0m"
} else { "" }
[$session_part $week_part] | where {|x| $x | is-not-empty} | str join " "
}
def get-jj-info [] {
let root_result = do { jj --ignore-working-copy root } | complete
if $root_result.exit_code != 0 { return "" }
# --ignore-working-copy so a killed render can't leave the working
# copy stale; the watchman snapshot trigger keeps the data fresh.
let bookmark = (do { jj --ignore-working-copy log -r @ --no-graph -T 'bookmarks.map(|b| b.name()).join(", ")' } | complete | get stdout | str trim)
let change = (do { jj --ignore-working-copy log -r @ --no-graph -T 'change_id.shortest(8)' } | complete | get stdout | str trim)
let is_empty_str = (do { jj --ignore-working-copy log -r @ --no-graph -T 'empty' } | complete | get stdout | str trim)
let dirty = if $is_empty_str == "false" { "*" } else { "" }
let has_conflict = (do { jj --ignore-working-copy log -r @ --no-graph -T 'conflict' } | complete | get stdout | str trim)
let conflict_marker = if $has_conflict == "true" { " \e[31m!conflict\e[0m" } else { "" }
let ref_part = if ($bookmark | is-not-empty) {
$" | \e[36m($bookmark)($dirty)\e[0m"
} else if ($change | is-not-empty) {
$" | \e[35m($change)($dirty)\e[0m"
} else { "" }
$"($ref_part)($conflict_marker)"
}
# --- Main ---
let input = (^cat | from json)
let usage_info = format-rate-limits $input
let model_name = ($input | get model?.display_name? | default ($input | get model?.id? | default "unknown"))
let used_pct = ($input | get context_window?.used_percentage? | default null)
let total_cost = ($input | get cost?.total_cost_usd? | default 0)
let total_input = ($input | get context_window?.s_in? | default ($input | get context_window?.total_input_tokens? | default 0))
let total_output = ($input | get context_window?.s_out? | default ($input | get context_window?.total_output_tokens? | default 0))
let duration_ms = ($input | get cost?.total_duration_ms? | default 0)
let api_duration_ms = ($input | get cost?.total_api_duration_ms? | default 0)
let lines_added = ($input | get cost?.total_lines_added? | default 0)
let lines_removed = ($input | get cost?.total_lines_removed? | default 0)
let exceeds_200k = ($input | get exceeds_200k_tokens? | default false)
let cache_read = ($input | get context_window?.cache_read_tokens? | default 0)
let cache_create = ($input | get context_window?.cache_creation_tokens? | default 0)
let total_tokens = $total_input + $total_output
def format-tokens [n: int] {
if $n >= 1_000_000 {
$"($n / 1_000_000.0 | math round --precision 1)M"
} else if $n >= 1_000 {
$"($n / 1_000.0 | math round --precision 1)k"
} else {
$"($n)"
}
}
let in_display = (format-tokens ($total_input | into int))
let out_display = (format-tokens ($total_output | into int))
let tok_display = $"($in_display)/($out_display)"
let cache_total = $cache_read + $cache_create
let cache_display = if $cache_total > 0 {
let cache_pct = ($cache_read * 100 / $cache_total | math round --precision 0 | into int)
let cache_color = if $cache_pct >= 70 {
"\e[32m"
} else if $cache_pct >= 40 {
"\e[33m"
} else {
"\e[31m"
}
$" cache:($cache_color)($cache_pct)%\e[0m"
} else { "" }
let context_display = if $used_pct != null {
let color = color-for-pct $used_pct
let pct_str = $used_pct | math round --precision 1
$"($color)($pct_str)%\e[0m"
} else { "--" }
let cost_cents = ($total_cost * 100 | math round | into int)
let cost_dollars = $cost_cents // 100
let cost_frac = ($cost_cents mod 100 | math abs | into string | fill -a r -w 2 -c '0')
let cost_display = $"$($cost_dollars).($cost_frac)"
let elapsed_display = (format-duration ($duration_ms | into int))
let wait_display = (format-duration ($api_duration_ms | into int))
let churn_display = $"\e[32m+($lines_added)\e[0m/\e[31m-($lines_removed)\e[0m"
let marker_200k = if $exceeds_200k { " | \e[31m!200k\e[0m" } else { "" }
def format-cwd [dir: string] {
if ($dir | is-empty) { return "" }
let jj_root = try { do { cd $dir; jj workspace root } | complete } catch { {exit_code: 1, stdout: ""} }
if $jj_root.exit_code == 0 {
let root = ($jj_root.stdout | str trim)
let home = ($env.HOME? | default "")
let root_display = if ($home | is-not-empty) and ($root | str starts-with $home) {
let rel = ($root | str replace $home "" | str trim -l -c '/')
$"~/($rel)"
} else {
$root
}
let root_parts = ($root_display | split row "/")
let base = if ($root_parts | length) <= 5 {
$root_display
} else {
let tail = ($root_parts | last 5 | str join "/")
$"…/($tail)"
}
let subpath = if ($dir | str starts-with $root) {
$dir | str replace $root "" | str trim -l -c '/'
} else { "" }
if ($subpath | is-not-empty) {
$"\e[36m($base)\e[0m → \e[34m($subpath)\e[0m"
} else {
$"\e[36m($base)\e[0m"
}
} else {
let home = ($env.HOME? | default "")
let display = if ($home | is-not-empty) and ($dir | str starts-with $home) {
let rel = ($dir | str replace $home "" | str trim -l -c '/')
$"~/($rel)"
} else {
$dir
}
let parts = ($display | split row "/")
let shortened = if ($parts | length) <= 5 {
$display
} else {
let tail = ($parts | last 5 | str join "/")
$"…/($tail)"
}
$shortened
}
}
let cwd_raw = ($input | get workspace?.current_dir? | default "")
let cwd_display = if ($cwd_raw | is-not-empty) {
let formatted = (format-cwd $cwd_raw)
$" | ($formatted)"
} else { "" }
let jj_info = get-jj-info
let quota_section = if ($usage_info | is-not-empty) {
" | (usage) " + $usage_info
} else { "" }
print -n $"($model_name) | Ctx: ($context_display) | ($tok_display)($cache_display) | ($cost_display) | t:($elapsed_display) w:($wait_display) | ($churn_display)($marker_200k)($jj_info)($quota_section)($cwd_display)"
'';
settingsPath = "${config.home.homeDirectory}/.claude/settings.json";
managedSettings =
pkgs.writeText "claude-code-settings.json"
<| toJSON { } {
"$schema" = "https://json.schemastore.org/claude-code-settings.json";
cleanupPeriodDays = 365 * 1000;
permissions.allow =
[ ]
++ map (cmd: "Bash(${cmd})") allowed.commands
++ map (path: "Read(${path}/**)") allowed.paths
++ [
"Glob"
"Grep"
"LSP"
"WebFetch"
"WebSearch"
"TaskCreate"
"TaskUpdate"
"TaskGet"
"TaskList"
"TaskOutput"
"TaskStop"
];
permissions.deny =
forbidden.rules |> concatMap (rule: rule.commands |> map (command: "Bash(${command})"));
env.CLAUDE_BASH_NO_LOGIN = "1";
env.CLAUDE_CODE_EAGER_FLUSH = "1";
env.CLAUDE_CODE_FORCE_GLOBAL_CACHE = "1";
env.MCP_CONNECTION_NONBLOCKING = "1";
env.USE_BUILTIN_RIPGREP = "0";
# rea mcp ghidra provider
env.GHIDRA_INSTALL_DIR = "${pkgs.ghidra}/lib/ghidra";
env.GHIDRA_HEADLESS_MAXMEM = "6G";
env.REA_GHIDRA_STARTUP_TIMEOUT_MS = "3600000";
# bittersweet
alwaysThinkingEnabled = true;
env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING = "1";
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY = "20";
env.CLAUDE_CODE_PLAN_V2_AGENT_COUNT = "5";
env.CLAUDE_CODE_PLAN_V2_EXPLORE_AGENT_COUNT = "5";
env.DISABLE_AUTO_COMPACT = "1";
env.ENABLE_MCP_LARGE_OUTPUT_FILES = "1";
env.ENABLE_TOOL_SEARCH = "auto:5";
env.MAX_THINKING_TOKENS = "31999";
# less slops unfortunately
skipWebFetchPreflight = true;
env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY = "1";
env.DISABLE_AUTOUPDATER = "1";
env.DISABLE_ERROR_REPORTING = "1";
env.DISABLE_INSTALLATION_CHECKS = "1";
# env.DISABLE_TELEMETRY = "1";
# tools: jj workspaces track claude-code's own git-style worktrees.
hooks.WorktreeCreate = singleton {
hooks = singleton {
type = "command";
command = /* bash */ ''jj workspace add "$(cat /dev/stdin | jq '.name' --raw-output)"'';
};
};
hooks.WorktreeRemove = singleton {
hooks = singleton {
type = "command";
command = /* bash */ ''jj workspace forget "$(cat /dev/stdin | jq '.worktree_path' --raw-output)"'';
};
};
enabledPlugins."code-review@claude-plugins-official" = true;
enabledPlugins."code-simplifier@claude-plugins-official" = true;
enabledPlugins."ralph-loop@claude-plugins-official" = true;
# visuals
attribution.commit = "";
attribution.pr = "";
env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE = "1";
env.CLAUDE_CODE_HIDE_ACCOUNT_INFO = "1";
env.DISABLE_COST_WARNINGS = "1";
statusLine.type = "command";
statusLine.command = getExe statusLine;
spinnerVerbs.mode = "replace";
spinnerVerbs.verbs = [
"Redeeming"
"Clodding"
"Burningtokens"
"Slopping"
"Clanking"
"Flushing"
"4get'ing"
"Plundering"
"Ignoring"
"Increasing ram prices"
"Making shit up"
"Hallucinating"
"Esef thinking"
"Doing something"
"Bain mode"
"Hyprland sucks"
"Stealing big one"
"Selling your data"
"Outsourcing to Nürburgring Betriebsgesellschaft mbH Otto-Flimm-Straße 53520 Nürburg"
"Compiling Earth"
"Absolute Cinema"
"Trvth Nvke"
"Glory to Caledonia"
"Mohamed Salah hat-trick"
"Sebasitan Vettel ring ding ding ding"
];
};
in
{
home.file.".claude/CLAUDE.md".text = instructions;
home.activation.claudeCodeWritableSettings = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
_target="${settingsPath}"
_jq="${getExe pkgs.jq}"
run mkdir -p "$(dirname "$_target")"
if [ -s "$_target" ] && "$_jq" empty "$_target" 2>/dev/null; then
_merged="$(mktemp)"
"$_jq" --slurp '.[0] * .[1]' "$_target" ${managedSettings} > "$_merged"
run install -m 0644 "$_merged" "$_target"
rm -f "$_merged"
else
run install -m 0644 ${managedSettings} "$_target"
fi
'';
};
}