diff --git a/docs/plans/2026-03-26-001-feat-extensible-claude-plugin-options-plan.md b/docs/plans/2026-03-26-001-feat-extensible-claude-plugin-options-plan.md new file mode 100644 index 0000000..7ffd7f3 --- /dev/null +++ b/docs/plans/2026-03-26-001-feat-extensible-claude-plugin-options-plan.md @@ -0,0 +1,254 @@ +--- +title: "feat: Extensible Claude plugin options" +type: feat +status: completed +date: 2026-03-26 +--- + +# feat: Extensible Claude plugin options + +## Enhancement Summary + +**Deepened on:** 2026-03-26 +**Sections enhanced:** 7 +**Research agents used:** architecture-strategist, code-simplicity-reviewer, pattern-recognition-specialist, security-sentinel, web-search-researcher, codebase-explorer + +### Key Improvements +1. Corrected marketplace type design — `attrsOf str` errors on duplicate keys even with identical values; marketplaces stay centralized in AI module +2. Dropped `lib.unique` — no other list merge in the repo uses it; duplicate installs are idempotent +3. Added shell escaping requirements — `lib.escapeShellArg` and jq `--arg` for defense in depth (fixes existing bugs too) + +### New Considerations Discovered +- The simplicity reviewer argues this is YAGNI for a single-maintainer dotfiles repo — the "minimal alternative" section presents a lighter option +- `types.str` does not merge even when values are identical — two modules cannot safely declare the same marketplace +- The existing activation script has unquoted Nix-to-bash interpolations (latent shell injection) +- Adding `"claude"` to `entryAfter` fixes a latent ordering bug in the current code + +--- + +## Overview + +The Claude Code plugin and marketplace configuration is hardcoded in the AI module via a static import of `config/plugins.nix`. No other home-manager module can contribute plugins. This plan introduces a custom home-manager option so any module can declare Claude plugins, and the NixOS module system merges them automatically — the same way `home.packages` or `programs.neovim.plugins` already work across modules. + +## Problem Statement / Motivation + +Language and tooling modules (go, python, kubernetes, etc.) already contribute packages, neovim plugins, and shell config. But if a language module wants a Claude LSP plugin for that language, the declaration must live in the AI module's `plugins.nix` rather than alongside the rest of that language's tooling. This scatters related concerns and forces the AI module to know about every language. + +### Research Insights + +**Counterpoint — is this worth doing?** The simplicity reviewer argues that for a single-maintainer dotfiles repo with ~13 plugins, the NixOS module option system is over-engineering. The current `plugins.nix` works, and "moving a string from one file to another" doesn't justify the structural change. This is a valid perspective — the decision depends on whether you value co-location of language tooling concerns over simplicity. + +**Precedent in the repo:** Five language modules (Go, Python, JavaScript, Swift, Rust) all follow identical patterns — each contributes `home.packages`, neovim plugins, and LSP Lua config. Adding Claude plugin declarations is a natural extension of this pattern. The AI module also hardcodes LSP server paths in `crush.json` — the same extensibility problem exists there. + +## Proposed Solution + +Define a custom home-manager option in the AI module for plugins. Keep marketplaces centralized — they are infrastructure the AI module owns. + +```nix +# NOTE: This is a custom option defined in this repo's AI module, +# not an upstream home-manager option. Any module that sets +# programs.claude.plugins must be imported alongside the AI module. +options.programs.claude = { + plugins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = "Claude Code plugins to install (format: plugin-name@marketplace)"; + }; +}; +``` + +The AI module sets its own plugin defaults and owns all marketplace configuration. Other modules contribute plugins additively. The activation script consumes the merged `config.programs.claude.plugins`. + +### Research Insights + +**Why no marketplace option:** `types.attrsOf types.str` does not allow multiple definitions of the same key, even with identical values. If both the Go module and AI module declared `claude-plugins-official = "anthropics/claude-plugins-official"`, Nix evaluation would fail. Since marketplaces are infrastructure (not language-specific), keeping them in the AI module avoids this entirely. + +**Naming convention:** `programs.claude` is the correct namespace — home-manager uses `programs.` for all CLI tools (`programs.git`, `programs.tmux`, `programs.neovim`). Low risk of upstream collision since a hypothetical home-manager Claude module would use different option names (`enable`, `package`, `settings`). + +**Defaults mechanism:** Use `default = [...]` in the `mkOption` declaration for the AI module's base plugins. Do NOT use `mkDefault` in the config block — it would lower priority and cause language module declarations to replace rather than extend the list. `listOf` merges by concatenation at the same priority, which is the desired additive behavior. + +### Minimal Alternative + +If the full options approach feels too heavy, a simpler path exists: + +Keep `plugins.nix` as the single source of truth. When adding a language-specific plugin, edit `plugins.nix` directly. This adds zero new code and zero new complexity. Revisit the options approach if a genuine second consumer materializes beyond "gopls-lsp might be nicer in the Go module." + +## Technical Considerations + +### Option definition location + +Keep the option definition in the AI module. Today every profile that includes language modules also includes AI (`full.nix` and `development.nix` both import AI; `minimal.nix` and `server.nix` import neither). Extracting a standalone options module adds indirection for a coupling that doesn't exist yet. If a future profile needs language modules without AI, extract then. + +This is the first custom option in any home-manager module in this repo. Add a comment at the definition site so future readers know it's custom, not upstream. + +### Additive semantics (not fully declarative) + +The activation script installs and updates but does not uninstall. This matches the current behavior and avoids needing to diff against `installed_plugins.json` and call `claude plugin uninstall`. Plugins removed from config persist on disk until manually removed. Document this limitation with a comment. + +### Research Insights + +**Security consideration:** A plugin you intend to remove (e.g., due to a vulnerability) will silently persist. For a personal dotfiles repo this is a nuisance, not a hazard. If declarative removal becomes important later, add a reconciliation step that reads `installed_plugins.json`, diffs against the declared list, and uninstalls extras. + +### Deduplication — not needed + +No other list merge in this repo applies deduplication (`home.packages`, `programs.neovim.plugins`, `programs.zsh.oh-my-zsh.plugins` all tolerate duplicates). If two modules declare the same plugin, the activation script runs `claude plugin update` twice — this is idempotent and harmless. Maintain single-declaration discipline instead: each plugin declared in exactly one module. + +### Marketplace management stays centralized + +Marketplaces remain static data in the AI module — not an option. This avoids `attrsOf str` merge conflicts and keeps infrastructure concerns separate from language-specific plugin declarations. + +### Marketplace registration — keep the idempotency check + +The original plan proposed always re-registering. The security review recommends keeping the skip-if-exists check: it reduces network calls during activation (fewer opportunities for MITM or DNS interference) and avoids unnecessary Git fetches. If a marketplace source URL changes, update `known_marketplaces.json` manually or add a content-addressed hash check. + +### Shell escaping (fixes existing bug) + +The current activation script interpolates Nix values directly into bash without quoting: + +```bash +# CURRENT (unsafe): +$DRY_RUN_CMD ${claude} plugin marketplace add ${source} +$DRY_RUN_CMD ${claude} plugin install ${plugin} + +# FIXED: +$DRY_RUN_CMD ${claude} plugin marketplace add ${lib.escapeShellArg source} +$DRY_RUN_CMD ${claude} plugin install ${lib.escapeShellArg plugin} +``` + +While current values are safe (they come from Nix expressions you control), the pattern is fragile. A marketplace source containing shell metacharacters would be interpreted by bash. Fix this regardless of whether the extensibility feature proceeds. + +Similarly, jq filter interpolations should use `--arg` instead of string interpolation: + +```bash +# CURRENT (fragile): +${jq} -e '.["${name}"]' "$KNOWN_MARKETPLACES" + +# FIXED: +${jq} --arg name ${lib.escapeShellArg name} -e '.[$name]' "$KNOWN_MARKETPLACES" +``` + +### Install/update scope mismatch (fixes existing bug) + +The current install/update logic (lines 114-121) checks if a plugin key exists in `installed_plugins.json` and routes to either `claude plugin install` (new) or `claude plugin update` (existing). But it doesn't check the **scope** of the installation. + +**The bug:** `strategy@shortrib-labs` is installed with `"scope": "project"` (bound to `/Users/chuck/workspace/vaults/Notes`). The jq check `.plugins["strategy@shortrib-labs"]` matches this entry and takes the `update` branch. But `claude plugin update` fails because it expects a user-scoped installation — the project-scoped install from a different directory isn't updatable in this context. + +**Fix options (in order of preference):** + +1. **Always use `install`** — if `claude plugin install` is idempotent (upgrades existing user-scoped installs), eliminate the install/update branching entirely. Simplest approach. + +2. **Filter by scope** — change the jq check to only match user-scoped installations: + ```bash + ${jq} --arg plugin ${lib.escapeShellArg plugin} \ + -e '.plugins[$plugin] | map(select(.scope == "user")) | length > 0' \ + "$INSTALLED_PLUGINS" + ``` + +3. **Fallback on failure** — try `update` first, fall back to `install` if it fails: + ```bash + $DRY_RUN_CMD ${claude} plugin update ${lib.escapeShellArg plugin} 2>/dev/null \ + || $DRY_RUN_CMD ${claude} plugin install ${lib.escapeShellArg plugin} + ``` + +Option 1 is preferred if `install` handles the "already installed" case gracefully. Test this manually first. + +### Activation ordering + +Add `"claude"` to the `entryAfter` list for `claudePlugins`. This fixes a latent ordering bug — the `claude` activation entry creates the config directory structure that `claudePlugins` depends on, but the current code doesn't express this dependency. + +### Research Insights + +**Error handling:** The current script silently continues on failure. Consider adding `set -euo pipefail` or per-command error checking so a typo in a plugin name doesn't pass silently. At minimum, echo warnings to stderr. + +**Consolidation opportunity:** The AI module currently has two separate `programs` attribute set declarations (lines 140-155 and 321-338). The options/config refactor is a good opportunity to consolidate into a single `programs` block. + +## System-Wide Impact + +- **Interaction graph**: Only the `claudePlugins` activation script changes. It reads merged option values instead of importing a static file. No other activation scripts are affected. +- **Error propagation**: Plugin install failures are non-fatal today (script continues). No change to this behavior, though adding stderr warnings is recommended. +- **State lifecycle risks**: None — the activation script is purely additive and idempotent. +- **API surface parity**: No other interfaces expose plugin management. +- **Integration test scenarios**: `darwin-rebuild switch` with (a) only AI module contributing plugins, (b) multiple modules contributing plugins. + +## Acceptance Criteria + +- [x] `options.programs.claude.plugins` declared as `listOf str` in AI module with comment noting it's custom +- [x] AI module sets base plugins via `default = [...]` in the option declaration +- [x] Marketplace configuration stays inline in the AI module (not an option) +- [x] `config/plugins.nix` removed (values moved inline) +- [x] Activation script consumes `config.programs.claude.plugins` instead of importing `plugins.nix` +- [x] All Nix-to-bash interpolations use `lib.escapeShellArg` +- [x] jq filters use `--arg` instead of string interpolation +- [x] Install/update logic handles project-scoped vs user-scoped plugins correctly +- [x] `claudePlugins` activation depends on `"claude"` in `entryAfter` +- [x] Marketplace idempotency check preserved +- [x] At least one language module (go) declares its Claude plugin alongside its other tooling +- [x] `darwin-rebuild switch` succeeds with no behavioral change to installed plugins + +## Success Metrics + +Successful `darwin-rebuild switch` producing the same set of installed plugins as before, plus the Go module's plugin declaration living alongside its other Go tooling. + +## Dependencies & Risks + +- **`claude plugin marketplace add` idempotency**: If the CLI errors on re-add with the same source, the idempotency check remains essential. Current behavior suggests the check is correct. +- **Import coupling**: Any module setting `programs.claude.plugins` implicitly depends on the AI module being in the profile's import tree. Today this coupling exists universally. Document it. +- **Upstream namespace collision**: Low probability. An official `programs.claude` home-manager module would likely use different option names. Easy to rename if it ever happens. + +## Implementation + +### Phase 1: Activation script bug fixes (independent, do first) + +**Files changed:** +- `home/modules/ai/default.nix` — fix shell escaping, jq interpolation, install/update scope logic, and `entryAfter` ordering + +Fixes: +1. All Nix-to-bash interpolations use `lib.escapeShellArg` +2. jq filters use `--arg` instead of string interpolation +3. Install/update logic handles project-scoped plugins (currently `strategy@shortrib-labs` fails update because it's project-scoped, not user-scoped) +4. Add `"claude"` to `entryAfter` for `claudePlugins` + +These fix existing bugs and are worth doing regardless of whether the extensibility feature proceeds. Can be its own commit/PR. + +### Phase 2: Define option and migrate activation script + +**Files changed:** +- `home/modules/ai/default.nix` — split into `options`/`config` blocks, add `options.programs.claude.plugins`, move marketplace/plugin data inline, update activation script to consume `config.programs.claude.plugins`, add `"claude"` to `entryAfter`, consolidate duplicate `programs` blocks +- `home/modules/ai/config/plugins.nix` — delete + +Steps: +1. Split the module into `options` and `config` blocks (first custom option in a home module, following the `systems/modules/hardening` precedent) +2. Declare `programs.claude.plugins` with base plugins as the `default` value +3. Keep marketplace data as a `let` binding (not an option) +4. Update `claudePlugins` activation to read from `config.programs.claude.plugins` +5. Add `"claude"` to `entryAfter` (fixes latent ordering bug) +6. Delete `config/plugins.nix` + +### Phase 3: Proof of concept — Go module + +**Files changed:** +- `home/modules/go/default.nix` — add `programs.claude.plugins` declaration +- `home/modules/ai/default.nix` — remove `gopls-lsp` from default plugin list + +Move `"gopls-lsp@claude-plugins-official"` to the Go module. The `claude-plugins-official` marketplace stays in the AI module since marketplaces are centralized infrastructure. + +### Phase 4 (optional, follow-up): Migrate remaining language-specific plugins + +Move `pyright-lsp`, `swift-lsp`, `typescript-lsp` to their respective language modules (Python, Swift, JavaScript — all exist). Only do this if the Go proof of concept works cleanly. + +## Sources & References + +### Internal References +- Existing custom option precedent: `systems/modules/hardening/default.nix` +- Homebrew merge pattern: system modules use `lib.mkMerge` with `lib.optionalAttrs supportsHomebrew` +- Implicit list merging across home modules: `home.packages`, `programs.neovim.plugins`, `programs.zsh.oh-my-zsh.plugins` +- Current plugin config: `home/modules/ai/config/plugins.nix` +- Current activation script: `home/modules/ai/default.nix:92-123` +- Language modules: `home/modules/{go,python,javascript,swift,rust}/default.nix` + +### External References +- [nix.dev Module System Deep Dive](https://nix.dev/tutorials/module-system/deep-dive.html) +- [NixOS Manual: Option Types](https://nlewo.github.io/nixos-manual-sphinx/development/option-types.xml.html) — `listOf` merges by concatenation; `attrsOf str` errors on duplicate keys +- [NixOS RFC 0042: config-option](https://github.com/NixOS/rfcs/blob/master/rfcs/0042-config-option.md) — `mkDefault` semantics +- [home-manager programs/tmux.nix](https://github.com/nix-community/home-manager/blob/master/modules/programs/tmux.nix) — upstream plugin management pattern +- [Developing NixOS and Home Manager Modules](https://mhu.dev/posts/2024-01-15-developing-nixos-modules/) diff --git a/home/modules/ai/config/plugins.nix b/home/modules/ai/config/plugins.nix deleted file mode 100644 index d837bab..0000000 --- a/home/modules/ai/config/plugins.nix +++ /dev/null @@ -1,18 +0,0 @@ -{ - marketplaces = { - "claude-plugins-official" = "anthropics/claude-plugins-official"; - "compound-engineering-plugin" = "git@github.com:EveryInc/compound-engineering-plugin.git"; - "compound-knowledge-marketplace" = "git@github.com:EveryInc/compound-knowledge-plugin.git"; - }; - plugins = [ - "gopls-lsp@claude-plugins-official" - "pyright-lsp@claude-plugins-official" - "swift-lsp@claude-plugins-official" - "typescript-lsp@claude-plugins-official" - "skill-creator@claude-plugins-official" - "claude-md-management@claude-plugins-official" - "compound-engineering@compound-engineering-plugin" - "compound-knowledge@compound-knowledge-marketplace" - "hookify@claude-plugins-official" - ]; -} diff --git a/home/modules/ai/default.nix b/home/modules/ai/default.nix index 98e5b1c..8b02000 100644 --- a/home/modules/ai/default.nix +++ b/home/modules/ai/default.nix @@ -1,350 +1,380 @@ { inputs, outputs, config, pkgs, lib, gitEmail, ... }: -let +let isDarwin = pkgs.stdenv.isDarwin; isLinux = pkgs.stdenv.isLinux; + cfg = config.programs.claude; + + # Marketplace configuration — centralized here because attrsOf str + # does not merge duplicate keys, even with identical values + marketplaces = { + "claude-plugins-official" = "anthropics/claude-plugins-official"; + "compound-engineering-plugin" = "EveryInc/compound-engineering-plugin.git"; + "compound-knowledge-marketplace" = "EveryInc/compound-knowledge-plugin.git"; + "last30days-skill" = "mvanhorn/last30days-skill"; + "replicatedhq" = "replicatedhq/replicated-claude-marketplace"; + "shortrib-labs" = "shortrib-labs/shortrib-claude-marketplace"; + }; in { - # AI and coding assistant tools - home = { - packages = with pkgs; [ - aider-chat - amp-cli - ollama - nur.repos.charmbracelet.crush - claude-code-transcripts - unstable.claude-code - unstable.fabric-ai - gemini-cli - goose-cli - unstable.github-mcp-server - (unstable.llm.withPlugins { - llm-anthropic = true; - llm-cmd = true; - llm-echo = true; - llm-fireworks = true; - llm-gemini = true; - llm-groq = true; - llm-jq = true; - llm-perplexity = true; - llm-python = true; - llm-templates-fabric = true; - llm-tools-quickjs = false; - llm-tools-simpleeval = true; - llm-tools-sqlite = true; - llm-venice = false; - }) - mbta-mcp-server - mods - repomix - rodney - showboat - ttok - ]; - - - # HACK because Claude code won't follow symlinks, replace with commented out file - # stuff below as soon as possible - activation = { - # Copy agents and commands to Claude config directories - claude = lib.hm.dag.entryAfter [ "writeBoundary" ] '' - for CLAUDE_CONFIG_DIR in ${config.xdg.configHome}/claude/replicated ${config.xdg.configHome}/claude/personal ; do - echo "Copying agents and commands to $CLAUDE_CONFIG_DIR..." - $DRY_RUN_CMD mkdir -p $CLAUDE_CONFIG_DIR/commands $CLAUDE_CONFIG_DIR/agents - $DRY_RUN_CMD cp -f ${./config/claude/commands}/* $CLAUDE_CONFIG_DIR/commands - $DRY_RUN_CMD cp -f ${./config/claude/agents}/* $CLAUDE_CONFIG_DIR/agents - done + # Custom option for Claude Code plugin management. + # This is defined in this repo's AI module, not by upstream home-manager. + # Any module that sets programs.claude.plugins must be imported alongside + # this module (today all profiles that include language modules also include AI). + options.programs.claude = { + plugins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + # Language LSP plugins — move to respective language modules as they adopt programs.claude.plugins + "pyright-lsp@claude-plugins-official" + "swift-lsp@claude-plugins-official" + "typescript-lsp@claude-plugins-official" + # Tool plugins + "skill-creator@claude-plugins-official" + "claude-md-management@claude-plugins-official" + "compound-knowledge@compound-knowledge-marketplace" + "last30days@last30days-skill" + "taste@shortrib-labs" + "strategy@shortrib-labs" + "writing@shortrib-labs" + "hookify@claude-plugins-official" + ]; + description = "Claude Code plugins to install (format: plugin-name@marketplace)"; + }; + }; - # Only on sochu: copy Replicated's auto-installed managed agents/commands - if [ "$(/bin/hostname -s)" = "sochu" ]; then - if [ -d ~/.claude/agents ] && [ -n "$(ls -A ~/.claude/agents 2>/dev/null)" ]; then - echo "Copying Replicated managed agents to the Replicated Claude config directory..." - $DRY_RUN_CMD cp -r ~/.claude/agents/* ${config.xdg.configHome}/claude/replicated/agents/ + config = { + # AI and coding assistant tools + home = { + packages = with pkgs; [ + aider-chat + amp-cli + ollama + nur.repos.charmbracelet.crush + claude-code-transcripts + unstable.claude-code + unstable.fabric-ai + gemini-cli + goose-cli + unstable.github-mcp-server + (unstable.llm.withPlugins { + llm-anthropic = true; + llm-cmd = true; + llm-echo = true; + llm-fireworks = true; + llm-gemini = true; + llm-groq = true; + llm-jq = true; + llm-perplexity = true; + llm-python = true; + llm-templates-fabric = true; + llm-tools-quickjs = false; + llm-tools-simpleeval = true; + llm-tools-sqlite = true; + llm-venice = false; + }) + mbta-mcp-server + mods + repomix + rodney + showboat + ttok + ]; + + + # HACK because Claude code won't follow symlinks, replace with commented out file + # stuff below as soon as possible + activation = { + # Copy agents and commands to Claude config directories + claude = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + for CLAUDE_CONFIG_DIR in ${config.xdg.configHome}/claude/replicated ${config.xdg.configHome}/claude/personal ; do + echo "Copying agents and commands to $CLAUDE_CONFIG_DIR..." + $DRY_RUN_CMD mkdir -p $CLAUDE_CONFIG_DIR/commands $CLAUDE_CONFIG_DIR/agents + $DRY_RUN_CMD cp -f ${./config/claude/commands}/* $CLAUDE_CONFIG_DIR/commands + $DRY_RUN_CMD cp -f ${./config/claude/agents}/* $CLAUDE_CONFIG_DIR/agents + done + + # Only on sochu: copy Replicated's auto-installed managed agents/commands + if [ "$(/bin/hostname -s)" = "sochu" ]; then + if [ -d ~/.claude/agents ] && [ -n "$(ls -A ~/.claude/agents 2>/dev/null)" ]; then + echo "Copying Replicated managed agents to the Replicated Claude config directory..." + $DRY_RUN_CMD cp -r ~/.claude/agents/* ${config.xdg.configHome}/claude/replicated/agents/ + fi + + if [ -d ~/.claude/commands ] && [ -n "$(ls -A ~/.claude/commands 2>/dev/null)" ]; then + echo "Copying Replicated managed commands to the Replicated Claude config directory..." + $DRY_RUN_CMD cp -r ~/.claude/commands/* ${config.xdg.configHome}/claude/replicated/commands/ + fi fi + ''; - if [ -d ~/.claude/commands ] && [ -n "$(ls -A ~/.claude/commands 2>/dev/null)" ]; then - echo "Copying Replicated managed commands to the Replicated Claude config directory..." - $DRY_RUN_CMD cp -r ~/.claude/commands/* ${config.xdg.configHome}/claude/replicated/commands/ + # Update mcpServers in Claude config files + claudeMcpServers = lib.hm.dag.entryAfter [ "sops-nix" ] ('' + MCP_SERVERS="$(cat ${config.sops.templates."mcp-servers.json".path})" + + if [ -n "$MCP_SERVERS" ]; then + for CONFIG_DIR in ${config.xdg.configHome}/claude/replicated ${config.xdg.configHome}/claude/personal ; do + CONFIG="$CONFIG_DIR/.claude.json" + [ -f "$CONFIG" ] || echo '{}' > "$CONFIG" + ${pkgs.jq}/bin/jq --argjson servers "$MCP_SERVERS" '.mcpServers = $servers' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG" + done fi - fi - ''; + '' ); # ++ lib.optionalString isDarwin '' + # if [ -n "$MCP_SERVERS" ]; then + # CONFIG="${config.home.homeDirectory}/Library/Application Support/Claude/claude_desktop_config.json" + # mkdir -p "$(dirname "$CONFIG")" + # [ -f "$CONFIG" ] || echo '{}' > "$CONFIG" + # ${pkgs.jq}/bin/jq --argjson servers "$MCP_SERVERS" '.mcpServers = $servers' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG" + # fi + # ''); - # Update mcpServers in Claude config files - claudeMcpServers = lib.hm.dag.entryAfter [ "sops-nix" ] ('' - MCP_SERVERS="$(cat ${config.sops.templates."mcp-servers.json".path})" + # Install Claude Code plugins declaratively + # NOTE: additive only — plugins removed from config persist on disk until manually removed + claudePlugins = let + jq = "${pkgs.jq}/bin/jq"; + claude = "${pkgs.unstable.claude-code}/bin/claude"; + in lib.hm.dag.entryAfter [ "writeBoundary" "installPackages" "claude" ] '' + export PATH="${pkgs.git}/bin:${pkgs.openssh}/bin:$PATH" - if [ -n "$MCP_SERVERS" ]; then - for CONFIG_DIR in ${config.xdg.configHome}/claude/replicated ${config.xdg.configHome}/claude/personal ; do - CONFIG="$CONFIG_DIR/.claude.json" - [ -f "$CONFIG" ] || echo '{}' > "$CONFIG" - ${pkgs.jq}/bin/jq --argjson servers "$MCP_SERVERS" '.mcpServers = $servers' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG" - done - fi - '' ); # ++ lib.optionalString isDarwin '' - # if [ -n "$MCP_SERVERS" ]; then - # CONFIG="${config.home.homeDirectory}/Library/Application Support/Claude/claude_desktop_config.json" - # mkdir -p "$(dirname "$CONFIG")" - # [ -f "$CONFIG" ] || echo '{}' > "$CONFIG" - # ${pkgs.jq}/bin/jq --argjson servers "$MCP_SERVERS" '.mcpServers = $servers' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG" - # fi - # ''); + for CLAUDE_CONFIG_DIR in ${config.xdg.configHome}/claude/personal ${config.xdg.configHome}/claude/replicated ; do + export CLAUDE_CONFIG_DIR + $DRY_RUN_CMD mkdir -p "$CLAUDE_CONFIG_DIR/plugins" - # Install Claude Code plugins declaratively - claudePlugins = let - pluginConfig = import ./config/plugins.nix; - inherit (pluginConfig) marketplaces plugins; - jq = "${pkgs.jq}/bin/jq"; - claude = "${pkgs.unstable.claude-code}/bin/claude"; - in lib.hm.dag.entryAfter [ "writeBoundary" "installPackages" ] '' - export PATH="${pkgs.git}/bin:${pkgs.openssh}/bin:$PATH" + KNOWN_MARKETPLACES="$CLAUDE_CONFIG_DIR/plugins/known_marketplaces.json" - for CLAUDE_CONFIG_DIR in ${config.xdg.configHome}/claude/personal ${config.xdg.configHome}/claude/replicated ; do - export CLAUDE_CONFIG_DIR - $DRY_RUN_CMD mkdir -p "$CLAUDE_CONFIG_DIR/plugins" + # Register marketplaces (skip if already known) + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: source: '' + if [ ! -f "$KNOWN_MARKETPLACES" ] || ! ${jq} --arg name ${lib.escapeShellArg name} -e '.[$name]' "$KNOWN_MARKETPLACES" > /dev/null 2>&1; then + $DRY_RUN_CMD ${claude} plugin marketplace add ${lib.escapeShellArg source} + fi + '') marketplaces)} - KNOWN_MARKETPLACES="$CLAUDE_CONFIG_DIR/plugins/known_marketplaces.json" - INSTALLED_PLUGINS="$CLAUDE_CONFIG_DIR/plugins/installed_plugins.json" + # Install plugins (install is idempotent — handles both new and existing plugins) + ${lib.concatStringsSep "\n" (map (plugin: '' + $DRY_RUN_CMD ${claude} plugin install ${lib.escapeShellArg plugin} + '') cfg.plugins)} + done + ''; + }; - # Register marketplaces - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: source: '' - if [ ! -f "$KNOWN_MARKETPLACES" ] || ! ${jq} -e '.["${name}"]' "$KNOWN_MARKETPLACES" > /dev/null 2>&1; then - $DRY_RUN_CMD ${claude} plugin marketplace add ${source} - fi - '') marketplaces)} + file = { + } // lib.optionalAttrs isDarwin { + "Library/Application Support/io.datasette.llm/templates" = { + source = ./config/llm/templates; + recursive = true; + }; + } // lib.optionalAttrs isLinux { + "/home/crdant/.config/io.datasette.llm/templates" = { + source = ./config/llm/templates; + recursive = true; + }; + }; + }; - # Install or update plugins - ${lib.concatStringsSep "\n" (map (plugin: '' - if [ ! -f "$INSTALLED_PLUGINS" ] || ! ${jq} -e '.plugins["${plugin}"]' "$INSTALLED_PLUGINS" > /dev/null 2>&1; then - $DRY_RUN_CMD ${claude} plugin install ${plugin} + programs = { + zsh = { + envExtra = '' + # set default for Claude config based on hostname + if [[ "$(whoami)" == "chuck" ]] ; then + export CLAUDE_CONFIG_DIR="${config.xdg.configHome}/claude/replicated" else - $DRY_RUN_CMD ${claude} plugin update ${plugin} + export CLAUDE_CONFIG_DIR="${config.xdg.configHome}/claude/personal" fi - '') plugins)} - done - ''; - }; - file = { - } // lib.optionalAttrs isDarwin { - "Library/Application Support/io.datasette.llm/templates" = { - source = ./config/llm/templates; - recursive = true; + # use MCP tool search in Claude Code + ENABLE_TOOL_SEARCH=true + ''; }; - } // lib.optionalAttrs isLinux { - "/home/crdant/.config/io.datasette.llm/templates" = { - source = ./config/llm/templates; - recursive = true; - }; - }; - }; - programs = { - zsh = { - envExtra = '' - # set default for Claude config based on hostname - if [[ "$(whoami)" == "chuck" ]] ; then - export CLAUDE_CONFIG_DIR="${config.xdg.configHome}/claude/replicated" - else - export CLAUDE_CONFIG_DIR="${config.xdg.configHome}/claude/personal" - fi + # AI-specific Neovim plugins + neovim = { + plugins = with pkgs.vimPlugins; [ + claude-code-nvim + neo-tree-nvim + nvim-aider + plenary-nvim + snacks-nvim + ]; - # use MCP tool search in Claude Code - ENABLE_TOOL_SEARCH=true - ''; + extraLuaConfig = lib.mkAfter '' + -- Aider integration + require('nvim_aider').setup({}) + -- Claude code integration + require('claude-code').setup({}) + ''; + }; }; - }; - - # uncomment when Claude code can handle symlinks - # xdg = { - # enable = true; - # configFile = { - # "claude/personal" = { - # source = ./config/claude; - # recursive = true; - # }; - # "claude/replicated" = { - # source = ./config/claude; - # recursive = true; - # }; - # }; - # }; + # uncomment when Claude code can handle symlinks + # xdg = { + # enable = true; + # configFile = { + # "claude/personal" = { + # source = ./config/claude; + # recursive = true; + # }; + # "claude/replicated" = { + # source = ./config/claude; + # recursive = true; + # }; + # }; + # }; - # AI-specific secrets - sops = { - secrets = { - "anthropic/apiKeys/chuck@replicated.com" = {}; - "anthropic/apiKeys/chuck@crdant.io" = {}; - "github/token" = {}; - "google/maps/apiKey" = {}; - "mbta/apiKey" = {}; - "firecrawl/api_key" = {}; - "omni/api_token" = {}; - "shortcut/api_token" = {}; - }; - templates = { - ".aider.conf.yml" = { - path = "${config.home.homeDirectory}/.aider.conf.yml"; - mode = "0600"; - content = - let - # Create the configuration data structure first - aiderConfig = { - model = "sonnet"; - # Use the placeholder directly - this is safe because sops handles the substitution - anthropic-api-key = config.sops.placeholder."anthropic/apiKeys/${gitEmail}"; - cache-prompts = true; - architect = true; - auto-accept-architect = false; - multiline = true; - vim = true; - watch-files = true; - notifications = true; - }; - # Generate the YAML from the data structure - yamlContent = (pkgs.formats.yaml { }).generate "aider-config" aiderConfig; - in builtins.readFile yamlContent; + # AI-specific secrets + sops = { + secrets = { + "anthropic/apiKeys/chuck@replicated.com" = {}; + "anthropic/apiKeys/chuck@crdant.io" = {}; + "github/token" = {}; + "google/maps/apiKey" = {}; + "mbta/apiKey" = {}; + "firecrawl/api_key" = {}; + "omni/api_token" = {}; + "shortcut/api_token" = {}; }; - - "crush/crush.json" = { - path = "${config.home.homeDirectory}/.local/share/crush/crush.json"; - mode = "0600"; - content = builtins.toJSON { - "$schema" = "https://charm.land/crush.json"; - providers = { - anthropic = { - api_key = config.sops.placeholder."anthropic/apiKeys/${gitEmail}"; - }; - }; - models = { - large = { - model = "claude-opus-4-20250514"; - provider = "anthropic"; - max_tokens = 32000; - }; - small = { - model = "claude-3-5-haiku-20241022"; - provider = "anthropic"; - max_tokens = 5000; - }; - }; - lsp = { - servers = { - "Go" = { - command = "${pkgs.gopls}/bin/gopls"; - }; - "Swift" = { - command = "${pkgs.sourcekit-lsp}/bin/sourcekit-lsp"; - }; - "Rust" = { - command = "${pkgs.rust-analyzer}/bin/rust-analyzer"; - }; - "Python" = { - command = "${pkgs.pyright}/bin/pyright-langserver"; + templates = { + ".aider.conf.yml" = { + path = "${config.home.homeDirectory}/.aider.conf.yml"; + mode = "0600"; + content = + let + # Create the configuration data structure first + aiderConfig = { + model = "sonnet"; + # Use the placeholder directly - this is safe because sops handles the substitution + anthropic-api-key = config.sops.placeholder."anthropic/apiKeys/${gitEmail}"; + cache-prompts = true; + architect = true; + auto-accept-architect = false; + multiline = true; + vim = true; + watch-files = true; + notifications = true; }; - "JavaScript" = { - command = "${pkgs.typescript-language-server}/bin/typescript-language-server --stdio"; - }; - "TypeScript" = { - command = "${pkgs.typescript-language-server}/bin/typescript-language-server --stdio"; + # Generate the YAML from the data structure + yamlContent = (pkgs.formats.yaml { }).generate "aider-config" aiderConfig; + in builtins.readFile yamlContent; + }; + + "crush/crush.json" = { + path = "${config.home.homeDirectory}/.local/share/crush/crush.json"; + mode = "0600"; + content = builtins.toJSON { + "$schema" = "https://charm.land/crush.json"; + providers = { + anthropic = { + api_key = config.sops.placeholder."anthropic/apiKeys/${gitEmail}"; }; - "Markdown" = { - command = "${pkgs.markdown-oxide}/bin/markdown-oxide"; + }; + models = { + large = { + model = "claude-opus-4-20250514"; + provider = "anthropic"; + max_tokens = 32000; }; - "Nix" = { - command = "${pkgs.nil}/bin/nil"; + small = { + model = "claude-3-5-haiku-20241022"; + provider = "anthropic"; + max_tokens = 5000; }; }; - }; - mcp = { - servers = import ./config/mcp.nix { inherit config pkgs; }; - }; - }; - }; - - "goose/config.yaml" = { - path = "${config.home.homeDirectory}/.config/goose/config.yaml"; - mode = "0600"; - content = - let - # Same pattern for goose config - gooseConfig = { - GOOSE_PROVIDER = "claude-code"; - GOOSE_MODE = "smart_approve"; - extensions = { - computercontroller = { - display_name = "Computer Controller"; - enabled = true; - name = "computercontroller"; - timeout = 300; - type = "builtin"; + lsp = { + servers = { + "Go" = { + command = "${pkgs.gopls}/bin/gopls"; + }; + "Swift" = { + command = "${pkgs.sourcekit-lsp}/bin/sourcekit-lsp"; + }; + "Rust" = { + command = "${pkgs.rust-analyzer}/bin/rust-analyzer"; + }; + "Python" = { + command = "${pkgs.pyright}/bin/pyright-langserver"; }; - developer = { - display_name = "Developer Tools"; - enabled = true; - name = "developer"; - timeout = 300; - type = "builtin"; + "JavaScript" = { + command = "${pkgs.typescript-language-server}/bin/typescript-language-server --stdio"; }; - memory = { - display_name = "Memory"; - enabled = true; - name = "memory"; - timeout = 300; - type = "builtin"; + "TypeScript" = { + command = "${pkgs.typescript-language-server}/bin/typescript-language-server --stdio"; }; - repomix = { - display_name = "Repomix"; - description = "Pack your codebase into AI-friendly formats"; - cmd = "${pkgs.nodejs_22}/bin/npx"; - args = [ "-y" "repomix" "--mcp" ]; - enabled = true; - name = "repomix"; - timeout = 300; - type = "stdio"; + "Markdown" = { + command = "${pkgs.markdown-oxide}/bin/markdown-oxide"; + }; + "Nix" = { + command = "${pkgs.nil}/bin/nil"; }; }; }; - yamlContent = (pkgs.formats.yaml { }).generate "goose-config" gooseConfig; - in builtins.readFile yamlContent; - }; + mcp = { + servers = import ./config/mcp.nix { inherit config pkgs; }; + }; + }; + }; + + "goose/config.yaml" = { + path = "${config.home.homeDirectory}/.config/goose/config.yaml"; + mode = "0600"; + content = + let + # Same pattern for goose config + gooseConfig = { + GOOSE_PROVIDER = "claude-code"; + GOOSE_MODE = "smart_approve"; + extensions = { + computercontroller = { + display_name = "Computer Controller"; + enabled = true; + name = "computercontroller"; + timeout = 300; + type = "builtin"; + }; + developer = { + display_name = "Developer Tools"; + enabled = true; + name = "developer"; + timeout = 300; + type = "builtin"; + }; + memory = { + display_name = "Memory"; + enabled = true; + name = "memory"; + timeout = 300; + type = "builtin"; + }; + repomix = { + display_name = "Repomix"; + description = "Pack your codebase into AI-friendly formats"; + cmd = "${pkgs.nodejs_22}/bin/npx"; + args = [ "-y" "repomix" "--mcp" ]; + enabled = true; + name = "repomix"; + timeout = 300; + type = "stdio"; + }; + }; + }; + yamlContent = (pkgs.formats.yaml { }).generate "goose-config" gooseConfig; + in builtins.readFile yamlContent; + }; - # MCP servers configuration for Claude - "mcp-servers.json" = { - path = "${config.xdg.dataHome}/claude/mcp-servers.json"; - mode = "0600"; - content = builtins.toJSON (import ./config/mcp.nix { inherit config pkgs; }); + # MCP servers configuration for Claude + "mcp-servers.json" = { + path = "${config.xdg.dataHome}/claude/mcp-servers.json"; + mode = "0600"; + content = builtins.toJSON (import ./config/mcp.nix { inherit config pkgs; }); + }; }; }; - }; - - # AI-specific Neovim plugins - programs = { - neovim = { - plugins = with pkgs.vimPlugins; [ - claude-code-nvim - neo-tree-nvim - nvim-aider - plenary-nvim - snacks-nvim - ]; - - extraLuaConfig = lib.mkAfter '' - -- Aider integration - require('nvim_aider').setup({}) - -- Claude code integration - require('claude-code').setup({}) - ''; - }; - }; - xdg = { - configFile = { - } // lib.optionalAttrs isDarwin { - "llm/templates" = { - source = ./config/llm/templates; - recursive = true; + xdg = { + configFile = { + } // lib.optionalAttrs isDarwin { + "llm/templates" = { + source = ./config/llm/templates; + recursive = true; + }; }; }; }; - } diff --git a/home/modules/development/default.nix b/home/modules/development/default.nix index d945f67..7519d5a 100644 --- a/home/modules/development/default.nix +++ b/home/modules/development/default.nix @@ -137,6 +137,11 @@ in { }; }; + # Claude Code plugin for development workflows + claude.plugins = [ + "compound-engineering@compound-engineering-plugin" + ]; + neovim = { plugins = with pkgs.vimPlugins; [ vim-fugitive diff --git a/home/modules/go/default.nix b/home/modules/go/default.nix index 3483a0d..be531c3 100644 --- a/home/modules/go/default.nix +++ b/home/modules/go/default.nix @@ -45,6 +45,11 @@ in { }; }; + # Claude Code plugin for Go language support + programs.claude.plugins = [ + "gopls-lsp@claude-plugins-official" + ]; + xdg = { configFile = { "nvim/lua/gopls.lua" = {