-
Notifications
You must be signed in to change notification settings - Fork 35
Expand file tree
/
Copy pathclaude.nix
More file actions
223 lines (195 loc) · 8.37 KB
/
claude.nix
File metadata and controls
223 lines (195 loc) · 8.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# Claude Code configuration with hybrid settings and 1MCP aggregator.
#
# This module:
# - Adds shell alias with --mcp-config flag (Homebrew installs the binary)
# - Runs 1MCP LaunchAgent to aggregate all MCP servers (macOS only)
# - Symlinks config directories from configs/claude/ for live editing
#
# Settings architecture:
# - managed-settings.json (Nix-generated at /Library/Application Support/ClaudeCode/):
# Path-dependent settings needing nixConfigDirectory. See darwin/claude-managed-settings.nix.
# - settings.json (symlinked from configs/claude/): All other settings. Edit without rebuild.
#
# MCP architecture:
# - Server definitions: configs/claude/1mcp.json (symlinked to ~/.config/1mcp/mcp.json)
# - 1MCP runs as a LaunchAgent (macOS); both CLI and Desktop connect to localhost:3050
# - Secrets: 1Password Environment at ~/.claude/secrets.env
{
config,
lib,
pkgs,
osConfig ? null,
...
}:
let
inherit (config.lib.file) mkOutOfStoreSymlink;
inherit (config.home) homeDirectory;
inherit (config.home.user-info) nixConfigDirectory;
inherit (pkgs.stdenv) isDarwin;
claudeDir = "${nixConfigDirectory}/configs/claude";
# Configuration ----------------------------------------------------------------------------------
# Edit these values to customize your setup. Everything below is implementation.
# External skills from skills.sh, installed via activation script.
# Add entries here, then `nh darwin switch` to reconcile.
# Format: "owner/repo --skill skill-name"
externalSkills = [
"anthropics/skills --skill docx"
"anthropics/skills --skill mcp-builder"
"anthropics/skills --skill pdf"
];
# 1MCP server port (default 3050 to avoid conflicts with common dev servers)
mcpPort = "3050";
# Claude Desktop app preferences (macOS only)
desktopPreferences = {
chromeExtensionEnabled = true;
quickEntryDictationShortcut = "capslock";
};
# Implementation ---------------------------------------------------------------------------------
mcpConfigPath = "${homeDirectory}/.claude/mcp.json";
# CLI MCP config - connects to 1MCP via SSE
cliMcpConfig.mcpServers."1mcp" = {
type = "sse";
url = "http://localhost:${mcpPort}/sse";
};
# Helper for human-readable JSON files
toFormattedJSON =
data:
pkgs.runCommand "formatted.json"
{
nativeBuildInputs = [ pkgs.jq ];
passAsFile = [ "json" ];
json = builtins.toJSON data;
}
''
jq . "$jsonPath" > $out
'';
# --- macOS-only (guarded by mkIf isDarwin; lazy evaluation prevents errors on Linux) ---
# GUI apps don't inherit shell PATH, so we build it from nix-darwin config
desktopPath =
builtins.replaceStrings [ "$HOME" "$USER" ] [ homeDirectory config.home.username ]
osConfig.environment.systemPath;
# 1MCP launcher script - sources secrets and starts the aggregator
start1mcp = pkgs.writeShellScript "start-1mcp" ''
set -euo pipefail
SECRETS_FILE="$HOME/.claude/secrets.env"
# Source secrets from 1Password Environment (may be a named pipe from 1Password).
# When secrets.env is a FIFO, reading blocks until 1Password is ready. At boot,
# 1Password isn't serving yet, so we use timeout to avoid hanging indefinitely.
# On failure we exit 1 and let launchd KeepAlive retry.
if [[ -p "$SECRETS_FILE" ]]; then
content=$(${pkgs.coreutils}/bin/timeout 15 cat "$SECRETS_FILE" 2>/dev/null) || {
echo "1Password not ready (FIFO read timed out). launchd will retry." >&2
exit 1
}
[[ -n "$content" ]] || { echo "Empty secrets from FIFO. launchd will retry." >&2; exit 1; }
set -a
eval "$content"
set +a
echo "Secrets loaded from 1Password." >&2
elif [[ -r "$SECRETS_FILE" ]]; then
set -a
source "$SECRETS_FILE"
set +a
else
echo "$SECRETS_FILE not found. launchd will retry." >&2
exit 1
fi
# Add Nix paths for GUI app compatibility
export PATH="$PATH:/run/current-system/sw/bin:$HOME/.nix-profile/bin"
# Start 1MCP aggregator (uses default config at ~/.config/1mcp/mcp.json)
exec npx -y @1mcp/agent --port ${mcpPort} --enable-async-loading
'';
# Desktop MCP config - uses proxy to connect to the running 1MCP LaunchAgent
desktopConfig = {
mcpServers."1mcp" = {
command = "npx";
args = [
"-y"
"@1mcp/agent"
"proxy"
"--url"
"http://localhost:${mcpPort}/mcp"
];
env.PATH = desktopPath;
};
preferences = desktopPreferences;
};
in
lib.mkMerge [
# Cross-platform configuration -------------------------------------------------------------------
{
# Shell alias adds --mcp-config flag (Homebrew installs the binary via cask)
home.shellAliases.claude = "claude --mcp-config ${mcpConfigPath}";
# 1MCP server config (symlinked for live editing)
xdg.configFile."1mcp/mcp.json".source = mkOutOfStoreSymlink "${claudeDir}/1mcp.json";
home.file = {
# Generated CLI config (points to 1MCP)
".claude/mcp.json".source = toFormattedJSON cliMcpConfig;
# Symlinked for live editing (no rebuild needed)
".claude/settings.json".source = mkOutOfStoreSymlink "${claudeDir}/settings.json";
".claude/CLAUDE.md".source = mkOutOfStoreSymlink "${claudeDir}/CLAUDE-USER.md";
".claude/skills".source = mkOutOfStoreSymlink "${claudeDir}/skills";
".claude/agents".source = mkOutOfStoreSymlink "${claudeDir}/agents";
".claude/rules".source = mkOutOfStoreSymlink "${claudeDir}/rules";
".claude/hooks".source = mkOutOfStoreSymlink "${claudeDir}/hooks";
".claude/statusline.sh".source = mkOutOfStoreSymlink "${claudeDir}/statusline.sh";
};
# External skills ------------------------------------------------------------------------------
# Nuke and repave on each activation. Uses direct file removal instead of `skills remove`
# (which has interactive TUI prompts that hang under home-manager's non-interactive activation).
home.activation.installClaudeSkills =
let
npx = "${pkgs.nodejs}/bin/npx";
skillsDir = "${homeDirectory}/.claude/skills";
agentsDir = "${homeDirectory}/.agents/skills";
in
lib.hm.dag.entryAfter [ "writeBoundary" ] ''
export PATH="${pkgs.nodejs}/bin:${pkgs.git}/bin:$PATH"
export DISABLE_TELEMETRY=1
echo "Reconciling Claude Code skills from skills.sh..."
# Remove external skill symlinks (pointing into ~/.agents/) and their cloned sources
${pkgs.findutils}/bin/find -H "${skillsDir}" -maxdepth 1 -type l -lname '*/\.agents/*' -delete 2>/dev/null || true
rm -rf "${agentsDir}" 2>/dev/null || true
${lib.concatMapStringsSep "\n " (
s: "run --silence ${npx} -y skills add ${s} -g -a claude-code -y || true"
) externalSkills}
# Fix broken relative symlinks: skills.sh creates paths like ../../.agents/skills/<name>
# which resolve incorrectly because ~/.claude/skills is itself a symlink into the nix-config
# repo. Replace them with absolute symlinks pointing to ~/.agents/skills/<name>.
for link in $(${pkgs.findutils}/bin/find -H "${skillsDir}" -maxdepth 1 -type l -lname '*/\.agents/*' 2>/dev/null); do
name=$(${pkgs.coreutils}/bin/basename "$link")
target="${agentsDir}/$name"
if [ -d "$target" ]; then
rm "$link"
ln -s "$target" "$link"
fi
done
'';
}
# macOS-only configuration -----------------------------------------------------------------------
(lib.mkIf isDarwin {
# Desktop app config (points to 1MCP via proxy for GUI PATH compatibility)
home.file."Library/Application Support/Claude/claude_desktop_config.json".source =
toFormattedJSON desktopConfig;
# 1MCP LaunchAgent - keeps the aggregator running when secrets are available
# Uses PathState to only run when 1Password has mounted the secrets file
launchd.agents."1mcp" = {
enable = true;
config = {
Label = "com.malo.1mcp";
ProgramArguments = [ "${start1mcp}" ];
RunAtLoad = true;
KeepAlive = {
PathState = {
"${homeDirectory}/.claude/secrets.env" = true;
};
};
StandardOutPath = "${homeDirectory}/Library/Logs/1mcp.log";
StandardErrorPath = "${homeDirectory}/Library/Logs/1mcp.error.log";
EnvironmentVariables = {
PATH = desktopPath;
};
};
};
})
]