Files
home-manager/modules/programs/claude-code.nix
Austin Horstman e28654b710 claude-code: mark hook scripts executable
Inline hooks are scripts, so write them with the executable bit set. Add NMT assertions covering default and custom config directories.

(cherry picked from commit d0af9b8bf3)
2026-06-01 19:47:05 -05:00

741 lines
23 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
literalExpression
mkChangedOptionModule
mkOption
nameValuePair
optionalAttrs
;
cfg = config.programs.claude-code;
jsonFormat = pkgs.formats.json { };
upstreamConfigDir = "${config.home.homeDirectory}/.claude";
mkMcpServer =
server:
(removeAttrs server [ "disabled" ])
// (optionalAttrs (server ? url) { type = "http"; })
// (optionalAttrs (server ? command) { type = "stdio"; })
// {
enabled = !(server.disabled or false);
};
transformedMcpServers = optionalAttrs (cfg.enableMcpIntegration && config.programs.mcp.enable) (
lib.mapAttrs (_name: mkMcpServer) config.programs.mcp.servers
);
mkContentOption =
{
description,
example ? null,
}:
mkOption (
{
type = lib.types.attrsOf (lib.types.either lib.types.lines lib.types.path);
default = { };
inherit description;
}
// optionalAttrs (example != null) { inherit example; }
);
mkDirOption =
{ description, example }:
mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
inherit description example;
};
in
{
meta.maintainers = [ lib.maintainers.khaneliman ];
imports = [
(mkChangedOptionModule
[ "programs" "claude-code" "memory" "text" ]
[ "programs" "claude-code" "context" ]
(config: lib.getAttrFromPath [ "programs" "claude-code" "memory" "text" ] config)
)
(mkChangedOptionModule
[ "programs" "claude-code" "memory" "source" ]
[ "programs" "claude-code" "context" ]
(config: lib.getAttrFromPath [ "programs" "claude-code" "memory" "source" ] config)
)
(mkChangedOptionModule
[ "programs" "claude-code" "skillsDir" ]
[ "programs" "claude-code" "skills" ]
(config: lib.getAttrFromPath [ "programs" "claude-code" "skillsDir" ] config)
)
];
options.programs.claude-code = {
enable = lib.mkEnableOption "Claude Code, Anthropic's official CLI";
package = lib.mkPackageOption pkgs "claude-code" { nullable = true; };
finalPackage = mkOption {
type = lib.types.package;
readOnly = true;
internal = true;
description = "Resulting customized claude-code package.";
};
enableMcpIntegration = mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to integrate the MCP servers config from
{option}`programs.mcp.servers` into
{option}`programs.claude-code.mcpServers`.
Note: Settings defined in {option}`programs.mcp.servers` are merged
with {option}`programs.claude-code.mcpServers`, with Claude Code servers
taking precedence.
'';
};
configDir = mkOption {
type = lib.types.str;
default = upstreamConfigDir;
defaultText = literalExpression ''"''${config.home.homeDirectory}/.claude"'';
example = literalExpression ''"''${config.xdg.configHome}/claude"'';
description = ''
Directory holding Claude Code's configuration files.
Defaults to {file}`~/.claude`, matching the upstream
{command}`claude` CLI default. The {env}`CLAUDE_CONFIG_DIR`
environment variable is exported automatically whenever the
directory differs from this default so the CLI reads
configuration from the same location.
'';
};
settings = mkOption {
inherit (jsonFormat) type;
default = { };
example = {
theme = "dark";
permissions = {
allow = [
"Bash(git diff:*)"
"Edit"
];
ask = [ "Bash(git push:*)" ];
deny = [
"WebFetch"
"Bash(curl:*)"
"Read(./.env)"
"Read(./secrets/**)"
];
additionalDirectories = [ "../docs/" ];
defaultMode = "acceptEdits";
disableBypassPermissionsMode = "disable";
};
model = "claude-3-5-sonnet-20241022";
hooks = {
PreToolUse = [
{
matcher = "Bash";
hooks = [
{
type = "command";
command = "echo 'Running command: $CLAUDE_TOOL_INPUT'";
}
];
}
];
PostToolUse = [
{
matcher = "Edit|MultiEdit|Write";
hooks = [
{
type = "command";
command = "nix fmt $(jq -r '.tool_input.file_path' <<< '$CLAUDE_TOOL_INPUT')";
}
];
}
];
};
statusLine = {
type = "command";
command = "input=$(cat); echo \"[$(echo \"$input\" | jq -r '.model.display_name')] 📁 $(basename \"$(echo \"$input\" | jq -r '.workspace.current_dir')\")\"";
padding = 0;
};
includeCoAuthoredBy = false;
};
description = "JSON configuration for Claude Code settings.json";
};
context = mkOption {
type = lib.types.either lib.types.lines lib.types.path;
default = "";
description = ''
Global context for Claude Code.
The value is either:
- Inline content as a string
- A path to a file containing the content
The configured content is written to
{file}`CLAUDE.md` inside {option}`programs.claude-code.configDir`
(default {file}`~/.claude/CLAUDE.md`).
'';
example = literalExpression "./claude-memory.md";
};
plugins = lib.mkOption {
type = with lib.types; listOf (either package path);
default = [ ];
description = ''
List of plugins to use when running Claude Code.
Each entry is either:
- A path to the plugin directory
- The plugin package, whether a nix package or the output of a fetcher
Plugins are enabled via a `--plugin-dir` argument in the wrapper script.
'';
example = literalExpression ''
[
./my-local-plugin
fetchFromGithub {
owner = "some-github-org";
repo = "claude-plugin";
rev = "779a68ebc2a75e4a184d2c87e5a43a758e6458a1";
sha256 = "228fdd7e5908ea1d2f65218ecd9c71e1eefa0834d200d55fbb8bf8b5563acec0";
}
]
'';
};
marketplaces = lib.mkOption {
type = with lib.types; attrsOf (either package path);
default = { };
description = ''
Custom marketplaces for Claude Code plugins.
The attribute name becomes the marketplace name, and the value is either:
- A path to the marketplace directory
- The marketplace package, whether a nix package or the output of a fetcher
'';
example = literalExpression ''
{
local-marketplace = ./my-local-marketplace;
gh-marketplace = fetchFromGithub {
owner = "some-github-org";
repo = "claude-marketplace";
rev = "8a873a220b8427b25b03ce1a821593a24e098c34";
sha256 = "5c2dce95122b5bb73fa547edabbb6c3061c2d193d11e51faecd4d22659e67279";
};
}
'';
};
agents = mkContentOption {
description = ''
Custom agents for Claude Code.
The attribute name becomes the agent filename, and the value is either:
- Inline content as a string with frontmatter
- A path to a file containing the agent content with frontmatter
Agents are stored in the {file}`agents/` subdirectory of
{option}`programs.claude-code.configDir`.
'';
example = literalExpression ''
{
code-reviewer = '''
---
name: code-reviewer
description: Specialized code review agent
tools: Read, Edit, Grep
---
You are a senior software engineer specializing in code reviews.
Focus on code quality, security, and maintainability.
''';
documentation = ./agents/documentation.md;
}
'';
};
commands = mkContentOption {
description = ''
Custom commands for Claude Code.
The attribute name becomes the command filename, and the value is either:
- Inline content as a string
- A path to a file containing the command content
Commands are stored in the {file}`commands/` subdirectory of
{option}`programs.claude-code.configDir`.
'';
example = literalExpression ''
{
changelog = '''
---
allowed-tools: Bash(git log:*), Bash(git diff:*)
argument-hint: [version] [change-type] [message]
description: Update CHANGELOG.md with new entry
---
Parse the version, change type, and message from the input
and update the CHANGELOG.md file accordingly.
''';
fix-issue = ./commands/fix-issue.md;
commit = '''
---
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
description: Create a git commit with proper message
---
## Context
- Current git status: !`git status`
- Current git diff: !`git diff HEAD`
- Recent commits: !`git log --oneline -5`
## Task
Based on the changes above, create a single atomic git commit with a descriptive message.
''';
}
'';
};
hooks = mkOption {
type = lib.types.attrsOf lib.types.lines;
default = { };
description = ''
Custom hooks for Claude Code.
The attribute name becomes the hook filename, and the value is the hook script content.
Hooks are stored in the {file}`hooks/` subdirectory of
{option}`programs.claude-code.configDir`.
'';
example = {
pre-edit = ''
#!/usr/bin/env bash
echo "About to edit file: $1"
'';
post-commit = ''
#!/usr/bin/env bash
echo "Committed with message: $1"
'';
};
};
rules = mkContentOption {
description = ''
Modular rule files for Claude Code.
The attribute name becomes the rule filename, and the value is either:
- Inline content as a string
- A path to a file containing the rule content
Rules are stored in the {file}`rules/` subdirectory of
{option}`programs.claude-code.configDir`. All markdown files in
that directory are automatically loaded as project memory.
'';
example = literalExpression ''
{
code-style = '''
# Code Style Guidelines
- Use consistent formatting
- Follow language conventions
''';
testing = '''
# Testing Conventions
- Write tests for all new features
- Maintain test coverage above 80%
''';
security = ./rules/security.md;
}
'';
};
rulesDir = mkDirOption {
description = ''
Path to a directory containing rule files for Claude Code.
Rule files from this directory will be symlinked into the
{file}`rules/` subdirectory of
{option}`programs.claude-code.configDir`. All markdown files in
this directory are automatically loaded as project memory.
'';
example = literalExpression "./rules";
};
agentsDir = mkDirOption {
description = ''
Path to a directory containing agent files for Claude Code.
Agent files from this directory will be symlinked into the
{file}`agents/` subdirectory of
{option}`programs.claude-code.configDir`.
'';
example = literalExpression "./agents";
};
commandsDir = mkDirOption {
description = ''
Path to a directory containing command files for Claude Code.
Command files from this directory will be symlinked into the
{file}`commands/` subdirectory of
{option}`programs.claude-code.configDir`.
'';
example = literalExpression "./commands";
};
hooksDir = mkDirOption {
description = ''
Path to a directory containing hook files for Claude Code.
Hook files from this directory will be symlinked into the
{file}`hooks/` subdirectory of
{option}`programs.claude-code.configDir`.
'';
example = literalExpression "./hooks";
};
outputStyles = mkContentOption {
description = ''
Custom output styles for Claude Code.
The attribute name becomes the base of the output style filename.
The value is either:
- Inline content as a string
- A path to a file
In both cases, the contents will be written to
{file}`output-styles/<name>.md` inside
{option}`programs.claude-code.configDir`.
'';
example = literalExpression ''
{
concise = ./output-styles/concise.md;
detailed = '''
# Detailed Output Style
Contents will be used verbatim for the detailed output format.
''';
}
'';
};
skills = mkOption {
type = lib.types.either (lib.types.attrsOf (
lib.types.oneOf [
lib.types.lines
lib.types.path
lib.types.str
]
)) lib.types.path;
default = { };
description = ''
Custom skills for Claude Code.
This option can be either:
- An attribute set defining skills
- A path to a directory containing skill folders
If an attribute set is used, the attribute name becomes the
skill directory name, and the value is either:
- Inline content as a string (creates {file}`skills/<name>/SKILL.md`)
- A path to a file (creates {file}`skills/<name>/SKILL.md`)
- A path to a directory (creates {file}`skills/<name>/` with all files)
This also accepts Nix store paths, for example a skill directory
from a package.
If a path is used, it is expected to contain one folder per
skill name, each containing a {file}`SKILL.md`. The directory is
symlinked into the {file}`skills/` subdirectory of
{option}`programs.claude-code.configDir`.
'';
example = literalExpression ''
{
xlsx = ./skills/xlsx/SKILL.md;
data-analysis = ./skills/data-analysis;
pdf-processing = '''
---
name: pdf-processing
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction.
---
# PDF Processing
## Quick start
Use pdfplumber to extract text from PDFs:
```python
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
text = pdf.pages[0].extract_text()
```
''';
# A skill can also be a subdirectory within a package source (store path)
beads = "''${pkgs.beads.src}/claude-plugin/skills/beads";
}
'';
};
lspServers = mkOption {
type = lib.types.attrsOf jsonFormat.type;
default = { };
description = ''
LSP (Language Server Protocol) servers configuration.
'';
example = {
go = {
command = "gopls";
args = [ "serve" ];
extensionToLanguage = {
".go" = "go";
};
};
typescript = {
command = "typescript-language-server";
args = [ "--stdio" ];
extensionToLanguage = {
".ts" = "typescript";
".tsx" = "typescriptreact";
".js" = "javascript";
".jsx" = "javascriptreact";
};
};
};
};
mcpServers = mkOption {
type = lib.types.attrsOf jsonFormat.type;
default = { };
description = "MCP (Model Context Protocol) servers configuration";
example = {
github = {
type = "http";
url = "https://api.githubcopilot.com/mcp/";
};
filesystem = {
type = "stdio";
command = "npx";
args = [
"-y"
"@modelcontextprotocol/server-filesystem"
"/tmp"
];
};
database = {
type = "stdio";
command = "npx";
args = [
"-y"
"@bytebase/dbhub"
"--dsn"
"postgresql://user:pass@localhost:5432/db"
];
env = {
DATABASE_URL = "postgresql://user:pass@localhost:5432/db";
};
};
customTransport = {
type = "websocket";
url = "wss://example.com/mcp";
customOption = "value";
timeout = 5000;
};
};
};
};
config =
let
mkSourceEntry = content: if lib.isPath content then { source = content; } else { text = content; };
isStorePathString =
content: builtins.isString content && lib.hasPrefix "${builtins.storeDir}/" content;
isPathLikeContent = content: lib.isPath content || isStorePathString content;
mkMarkdownEntries =
subdir: attrs:
lib.mapAttrs' (
name: content: nameValuePair "${cfg.configDir}/${subdir}/${name}.md" (mkSourceEntry content)
) attrs;
mkHookEntries =
attrs:
lib.mapAttrs' (
name: content:
nameValuePair "${cfg.configDir}/hooks/${name}" {
text = content;
executable = true;
}
) attrs;
mkRecursiveDirAttrs =
subdir: dir:
optionalAttrs (dir != null) {
"${cfg.configDir}/${subdir}" = {
source = dir;
recursive = true;
};
};
mkSkillEntry =
name: content:
if isPathLikeContent content && lib.pathIsDirectory content then
nameValuePair "${cfg.configDir}/skills/${name}" {
source = content;
recursive = true;
}
else
nameValuePair "${cfg.configDir}/skills/${name}/SKILL.md" (
if isPathLikeContent content then { source = content; } else { text = content; }
);
mkMarketplaceEntry = _name: content: {
source = {
source = "directory";
path = content;
};
};
mkInstalledMarketplaceEntry =
name: content:
(mkMarketplaceEntry name content)
// {
installLocation = content;
lastUpdated = "1970-01-01T00:00:00Z";
};
in
lib.mkIf cfg.enable {
assertions =
let
exclusiveInlineDirNames = [
"rules"
"agents"
"commands"
"hooks"
];
mkExclusiveAssertion = inline: {
assertion = !(cfg.${inline} != { } && cfg.${inline + "Dir"} != null);
message = "Cannot specify both `programs.claude-code.${inline}` and `programs.claude-code.${inline}Dir`";
};
in
[
{
assertion =
(cfg.mcpServers == { } && cfg.lspServers == { } && !cfg.enableMcpIntegration && cfg.plugins == [ ])
|| cfg.package != null;
message = "`programs.claude-code.package` cannot be null when `mcpServers`, `lspServers`, `enableMcpIntegration`, or `plugins` is configured";
}
{
assertion = !isPathLikeContent cfg.skills || lib.pathIsDirectory cfg.skills;
message = "`programs.claude-code.skills` must be a directory when set to a path";
}
]
++ map mkExclusiveAssertion exclusiveInlineDirNames;
programs.claude-code.finalPackage =
let
mergedMcpServers = transformedMcpServers // cfg.mcpServers;
pluginFiles =
lib.optional (mergedMcpServers != { }) {
name = ".mcp.json";
path = jsonFormat.generate "claude-code-mcp.json" { mcpServers = mergedMcpServers; };
}
++ lib.optional (cfg.lspServers != { }) {
name = ".lsp.json";
path = jsonFormat.generate "claude-code-lsp.json" cfg.lspServers;
};
pluginDir = pkgs.runCommand "claude-code-hm-plugin" { } (
''
install -Dm644 ${
jsonFormat.generate "claude-code-plugin.json" {
name = "claude-code-home-manager";
}
} $out/.claude-plugin/plugin.json
''
+ lib.concatLines (
map (pluginFile: "install -Dm644 ${pluginFile.path} $out/${pluginFile.name}") pluginFiles
)
);
allPluginPaths = (if pluginFiles != [ ] then [ pluginDir ] else [ ]) ++ cfg.plugins;
wrapperArgs = lib.flatten (
map (p: [
"--plugin-dir"
"${p}"
]) allPluginPaths
);
in
if allPluginPaths != [ ] then
pkgs.symlinkJoin {
name = "claude-code";
paths = [ cfg.package ];
postBuild = ''
mv $out/bin/claude $out/bin/.claude-wrapped
cat > $out/bin/claude <<EOF
#! ${pkgs.bash}/bin/bash -e
exec -a "\$0" "$out/bin/.claude-wrapped" ${lib.escapeShellArgs wrapperArgs} "\$@"
EOF
chmod +x $out/bin/claude
'';
inherit (cfg.package) meta;
}
else
cfg.package;
home = {
packages = lib.mkIf (cfg.package != null) [ cfg.finalPackage ];
sessionVariables = lib.mkIf (cfg.configDir != upstreamConfigDir) {
CLAUDE_CONFIG_DIR = cfg.configDir;
};
file = lib.mkMerge [
(lib.mkIf (cfg.settings != { } || cfg.marketplaces != { }) {
"${cfg.configDir}/settings.json".source = jsonFormat.generate "claude-code-settings.json" (
cfg.settings
// {
"$schema" = "https://json.schemastore.org/claude-code-settings.json";
}
// optionalAttrs (cfg.marketplaces != { }) {
extraKnownMarketplaces = lib.mapAttrs mkMarketplaceEntry cfg.marketplaces;
}
);
})
(
if lib.isPath cfg.context then
{
"${cfg.configDir}/CLAUDE.md".source = cfg.context;
}
else
(lib.mkIf (cfg.context != "") {
"${cfg.configDir}/CLAUDE.md".text = cfg.context;
})
)
(lib.mkIf (cfg.marketplaces != { }) {
"${cfg.configDir}/plugins/known_marketplaces.json".source =
jsonFormat.generate "claude-code-known-marketplaces.json" (
lib.mapAttrs mkInstalledMarketplaceEntry cfg.marketplaces
);
})
(mkMarkdownEntries "agents" cfg.agents)
(mkMarkdownEntries "commands" cfg.commands)
(mkMarkdownEntries "rules" cfg.rules)
(mkRecursiveDirAttrs "agents" cfg.agentsDir)
(mkRecursiveDirAttrs "commands" cfg.commandsDir)
(mkRecursiveDirAttrs "hooks" cfg.hooksDir)
(mkRecursiveDirAttrs "rules" cfg.rulesDir)
(lib.mkIf (isPathLikeContent cfg.skills) {
"${cfg.configDir}/skills" = {
source = cfg.skills;
recursive = true;
};
})
(mkHookEntries cfg.hooks)
(lib.optionalAttrs (builtins.isAttrs cfg.skills) (lib.mapAttrs' mkSkillEntry cfg.skills))
(mkMarkdownEntries "output-styles" cfg.outputStyles)
];
};
};
}