diff --git a/modules/home/common.nix b/modules/home/common.nix index b51297d..ea14246 100644 --- a/modules/home/common.nix +++ b/modules/home/common.nix @@ -17,8 +17,7 @@ ./dev.nix ./nvim.nix ./task.nix - ./opencode.nix - ./pi.nix + ./llm.nix ]; config = { diff --git a/modules/home/dev.nix b/modules/home/dev.nix index c039771..d6847d9 100644 --- a/modules/home/dev.nix +++ b/modules/home/dev.nix @@ -1,102 +1,28 @@ -{ config, pkgs, lib, inputs, ... }: +{ pkgs, ... }: -let - cfg = config.dot.llm; -in { - options.dot.llm = { - enable = lib.mkEnableOption "LLM tools"; - claude-code.enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable claude-code"; - }; - opencode.enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable opencode"; - }; - gemini-cli.enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable gemini-cli"; - }; - pi.enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable pi"; - }; - pi.workMode = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Apply work machine (WSL NixOS) specific pi patches"; - }; - bubblewrap.enable = lib.mkOption { - type = lib.types.bool; - default = pkgs.stdenv.isLinux; - description = "Enable bubblewrap (Linux only)"; + config = { + programs.direnv = { + enable = true; + enableZshIntegration = true; + nix-direnv.enable = true; + stdlib = '' + : ''${XDG_CACHE_HOME:=$HOME/.cache} + declare -A direnv_layout_dirs + direnv_layout_dir() { + echo "''${direnv_layout_dirs[$PWD]:=$( + echo -n "$XDG_CACHE_HOME"/direnv/layouts/ + echo -n "$PWD" | sha1sum | cut -d ' ' -f 1 + )}" + } + ''; }; + + home.packages = with pkgs; [ + visidata + codespelunker + jq + unstable.tuicr + ]; }; - - config = lib.mkMerge [ - { - programs.direnv = { - enable = true; - enableZshIntegration = true; - nix-direnv.enable = true; - stdlib = '' - : ''${XDG_CACHE_HOME:=$HOME/.cache} - declare -A direnv_layout_dirs - direnv_layout_dir() { - echo "''${direnv_layout_dirs[$PWD]:=$( - echo -n "$XDG_CACHE_HOME"/direnv/layouts/ - echo -n "$PWD" | sha1sum | cut -d ' ' -f 1 - )}" - } - ''; - }; - - home.packages = with pkgs; [ - visidata - codespelunker - jq - unstable.tuicr - ]; - } - - (lib.mkIf cfg.enable { - home.packages = - (lib.optional cfg.claude-code.enable pkgs.unstable.claude-code) ++ - (lib.optional cfg.opencode.enable pkgs.unstable.opencode) ++ - (lib.optional cfg.gemini-cli.enable pkgs.unstable.gemini-cli) ++ - (lib.optional cfg.pi.enable ( - let - pi = inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.pi; - in - if cfg.pi.workMode then - # Work machine runs WSL NixOS, which needs the checks disabled and the - # Bun binary's ELF interpreter patched to the correct dynamic linker. - pi.overrideAttrs (oldAttrs: { - doInstallCheck = false; - - # Run patchelf after the package is installed/fixed up by nix - postFixup = (oldAttrs.postFixup or "") + '' - # 1. Target the actual Bun binary inside libexec, not the shell wrapper in bin/ - TARGET_BIN="$out/libexec/pi/pi" - - if [ -f "$TARGET_BIN" ]; then - # 2. Make it writable in the build store so patchelf can edit it - chmod +w "$TARGET_BIN" - - # 3. Running patchelf to set the interpreter forces it to rebuild the ELF headers - patchelf --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" "$TARGET_BIN" - fi - ''; - }) - else - pi - )) ++ - (lib.optional (cfg.bubblewrap.enable && pkgs.stdenv.isLinux) pkgs.unstable.bubblewrap); - }) - ]; } diff --git a/modules/home/llm.nix b/modules/home/llm.nix new file mode 100644 index 0000000..8f06cb8 --- /dev/null +++ b/modules/home/llm.nix @@ -0,0 +1,124 @@ +{ config, pkgs, lib, inputs, ... }: + +let + cfg = config.dot.llm; + piPath = "${config.dot.dotfilesPath}/modules/pi/agent"; + opencodePath = "${config.dot.dotfilesPath}/modules/opencode"; +in +{ + options.dot.llm = { + enable = lib.mkEnableOption "LLM tools"; + + claude-code.enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable claude-code"; + }; + + gemini-cli.enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable gemini-cli"; + }; + + bubblewrap.enable = lib.mkOption { + type = lib.types.bool; + default = pkgs.stdenv.isLinux; + description = "Enable bubblewrap (Linux only)"; + }; + + opencode = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable opencode"; + }; + + enableConfig = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable managed OpenCode configuration"; + }; + + workMode = lib.mkEnableOption "work-specific opencode configuration"; + }; + + pi = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable pi"; + }; + + enableConfig = lib.mkEnableOption "managed pi coding agent configuration" // { + default = true; + }; + + workMode = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Apply work machine (WSL NixOS) specific pi patches"; + }; + }; + }; + + config = lib.mkMerge [ + (lib.mkIf cfg.enable { + home.packages = + (lib.optional cfg.claude-code.enable pkgs.unstable.claude-code) ++ + (lib.optional cfg.opencode.enable pkgs.unstable.opencode) ++ + (lib.optional cfg.gemini-cli.enable pkgs.unstable.gemini-cli) ++ + (lib.optional cfg.pi.enable ( + let + pi = inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.pi; + in + if cfg.pi.workMode then + # Work machine runs WSL NixOS, which needs the checks disabled and the + # Bun binary's ELF interpreter patched to the correct dynamic linker. + pi.overrideAttrs (oldAttrs: { + doInstallCheck = false; + + # Run patchelf after the package is installed/fixed up by nix + postFixup = (oldAttrs.postFixup or "") + '' + # 1. Target the actual Bun binary inside libexec, not the shell wrapper in bin/ + TARGET_BIN="$out/libexec/pi/pi" + + if [ -f "$TARGET_BIN" ]; then + # 2. Make it writable in the build store so patchelf can edit it + chmod +w "$TARGET_BIN" + + # 3. Running patchelf to set the interpreter forces it to rebuild the ELF headers + patchelf --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" "$TARGET_BIN" + fi + ''; + }) + else + pi + )) ++ + (lib.optional (cfg.bubblewrap.enable && pkgs.stdenv.isLinux) pkgs.unstable.bubblewrap); + }) + + # pi coding agent configuration + (lib.mkIf cfg.pi.enableConfig { + home.file = { + ".pi/agent/settings.json".source = + config.lib.file.mkOutOfStoreSymlink + "${piPath}/${if cfg.pi.workMode then "settings.work" else "settings.home"}.json"; + ".pi/agent/extensions".source = + config.lib.file.mkOutOfStoreSymlink "${piPath}/extensions-common"; + } // lib.optionalAttrs cfg.pi.workMode { + ".pi/agent/extensions-work".source = + config.lib.file.mkOutOfStoreSymlink "${piPath}/extensions-work"; + ".pi/agent/models.json".source = + config.lib.file.mkOutOfStoreSymlink "${piPath}/models.work.json"; + }; + }) + + # OpenCode configuration + (lib.mkIf cfg.opencode.enableConfig { + xdg.configFile."opencode/opencode.json".source = + config.lib.file.mkOutOfStoreSymlink + "${opencodePath}/opencode.${if cfg.opencode.workMode then "work" else "home"}.json"; + }) + ]; +} diff --git a/modules/home/opencode.nix b/modules/home/opencode.nix deleted file mode 100644 index d3332ca..0000000 --- a/modules/home/opencode.nix +++ /dev/null @@ -1,99 +0,0 @@ -{ config, lib, inputs, ... }: - -let - cfg = config.dot.llm.opencode; -in -{ - options.dot.llm.opencode = { - enableConfig = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable managed OpenCode configuration"; - }; - - workMode = lib.mkEnableOption "work-specific opencode configuration"; - }; - - config = lib.mkIf cfg.enableConfig { - # Declare the sops secret - sops.secrets.langdock_api_key = { - # Uses defaultSopsFile from secrets.nix - }; - - # Create the OpenCode config.json template with secret substitution - sops.templates."opencode/opencode.json" = { - content = builtins.toJSON (let - baseConfig = { - "$schema" = "https://opencode.ai/config.json"; - default_agent = "plan"; - }; - - workConfig = { - enabled_providers = [ "anthropic" "langdock-openai" "ollama" ]; - model = "anthropic/claude-opus-4-6-default"; - small_model = "anthropic/claude-haiku-4-5-20251001"; - provider = { - langdock-openai = { - npm = "@ai-sdk/openai-compatible"; - name = "Langdock OpenAI"; - options = { - baseURL = "https://api.langdock.com/openai/eu/v1"; - apiKey = config.sops.placeholder.langdock_api_key; - }; - models = { - "gpt-5.6-sol" = { name = "GPT-5.6 Sol"; }; - "gpt-5.6-terra" = { name = "GPT-5.6 Terra"; }; - "gpt-5.6-luna" = { name = "GPT-5.6 Luna"; }; - }; - }; - anthropic = { - options = { - baseURL = "https://api.langdock.com/anthropic/eu/v1"; - apiKey = config.sops.placeholder.langdock_api_key; - }; - models = { - "claude-opus-5-default" = { name = "Opus 5"; }; - "claude-opus-4-8-default" = { name = "Opus 4.8"; }; - "claude-sonnet-5-default" = { name = "Sonnet 5"; }; - }; - whitelist = ["claude-opus-4-8-default" "claude-opus-5-default" "claude-sonnet-5-default"]; - }; - # ollama = { - # npm = "@ai-sdk/openai-compatible"; - # name = "Ollama (Local)"; - # options = { - # baseURL = "http://192.168.11.23:11434/v1"; - # }; - # models = { - # "qwen3-coder:30b" = { name = "Qwen 3 Coder 30B"; }; - # "codellama:34b-instruct" = { name = "CodeLlama 34B Instruct"; }; - # }; - # }; - }; - }; - - personalConfig = { - plugin = [ "opencode-gemini-auth@latest" ]; - model = "google/gemini-3-pro-preview"; - small_model = "google/gemini-3-flash-preview"; - enabled_providers = [ "google" ]; - }; - in - # Merge base config with the selected mode config - baseConfig // (if cfg.workMode then workConfig else personalConfig) - ); - }; - - # Link the generated config to the expected location - xdg.configFile."opencode/opencode.json".source = config.lib.file.mkOutOfStoreSymlink config.sops.templates."opencode/opencode.json".path; - - # Export the decrypted langdock API key as an env var (work config only). - # The secret lives in a runtime file, so we read it at shell startup rather - # than baking it into the Nix store via home.sessionVariables. - programs.zsh.initContent = lib.mkIf cfg.workMode '' - if [ -r "${config.sops.secrets.langdock_api_key.path}" ]; then - export LANGDOCK_API_KEY="$(cat "${config.sops.secrets.langdock_api_key.path}")" - fi - ''; - }; -} diff --git a/modules/home/pi.nix b/modules/home/pi.nix deleted file mode 100644 index e6c7a51..0000000 --- a/modules/home/pi.nix +++ /dev/null @@ -1,27 +0,0 @@ -{ config, lib, ... }: - -let - cfg = config.dot.llm.pi; - piPath = "${config.dot.dotfilesPath}/modules/pi/agent"; - piWorkMode = config.dot.llm.pi.workMode or false; -in -{ - options.dot.llm.pi = { - enableConfig = lib.mkEnableOption "managed pi coding agent configuration" // { - default = true; - }; - }; - - config = lib.mkIf cfg.enableConfig { - home.file = { - ".pi/agent/settings.json".source = - config.lib.file.mkOutOfStoreSymlink - "${piPath}/${if piWorkMode then "settings.work" else "settings.home"}.json"; - ".pi/agent/extensions".source = - config.lib.file.mkOutOfStoreSymlink "${piPath}/extensions-common"; - } // lib.optionalAttrs piWorkMode { - ".pi/agent/extensions-work".source = - config.lib.file.mkOutOfStoreSymlink "${piPath}/extensions-work"; - }; - }; -} diff --git a/modules/home/secrets.nix b/modules/home/secrets.nix index 3e09389..1293129 100644 --- a/modules/home/secrets.nix +++ b/modules/home/secrets.nix @@ -1,5 +1,11 @@ -{ inputs, config, ... }: +{ inputs, config, lib, ... }: +let + # Both pi and opencode talk to Langdock on work machines and expect the API + # key in the environment, so the secret is managed here rather than in the + # individual agent modules. + needsLangdock = config.dot.llm.pi.workMode || config.dot.llm.opencode.workMode; +in { imports = [ inputs.sops-nix.homeManagerModules.sops @@ -12,9 +18,24 @@ age = { keyFile = "${config.home.homeDirectory}/.config/sops/age/keys.txt"; }; + + secrets = lib.mkIf needsLangdock { + langdock_api_key = { + # Uses defaultSopsFile from secrets.nix + }; + }; }; home.sessionVariables = { SOPS_AGE_KEY_FILE = config.sops.age.keyFile; }; + + # Export the decrypted langdock API key as an env var. The secret lives in a + # runtime file, so it is read at shell startup rather than baked into the Nix + # store via home.sessionVariables. + programs.zsh.initContent = lib.mkIf needsLangdock '' + if [ -r "${config.sops.secrets.langdock_api_key.path}" ]; then + export LANGDOCK_API_KEY="$(cat "${config.sops.secrets.langdock_api_key.path}")" + fi + ''; } diff --git a/modules/opencode/opencode.home.json b/modules/opencode/opencode.home.json new file mode 100644 index 0000000..421c16d --- /dev/null +++ b/modules/opencode/opencode.home.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://opencode.ai/config.json", + "default_agent": "plan", + "plugin": [ + "opencode-gemini-auth@latest" + ], + "model": "google/gemini-3-pro-preview", + "small_model": "google/gemini-3-flash-preview", + "enabled_providers": [ + "google" + ] +} diff --git a/modules/opencode/opencode.work.json b/modules/opencode/opencode.work.json new file mode 100644 index 0000000..3ba1f8f --- /dev/null +++ b/modules/opencode/opencode.work.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://opencode.ai/config.json", + "default_agent": "plan", + "enabled_providers": [ + "anthropic", + "langdock-openai", + "sparkr", + "sparkl" + ], + "model": "anthropic/claude-opus-5-default", + "small_model": "anthropic/claude-sonnet-5-default", + "provider": { + "sparkr": { + "npm": "@ai-sdk/openai-compatible", + "name": "sparkr", + "options": { + "baseURL": "http://192.168.10.110:8000/v1", + "apiKey": "vllm" + }, + "models": { + "Qwen/Qwen3-Coder-Next-FP8": { + "name": "Qwen3 Coder Next FP8 (local vLLM)", + "tool_call": true, + "reasoning": false, + "limit": { "context": 262144, "output": 8192 } + } + } + }, + "sparkl": { + "npm": "@ai-sdk/openai-compatible", + "name": "sparkl", + "options": { + "baseURL": "http://192.168.10.111:8000/v1", + "apiKey": "vllm" + }, + "models": { + "Qwen/Qwen3-30B-A3B": { + "name": "Qwen3 30B A3B (local vLLM)", + "tool_call": true, + "reasoning": true, + "limit": { "context": 16384, "output": 8192 } + } + } + }, + "langdock-openai": { + "npm": "@ai-sdk/openai-compatible", + "name": "Langdock OpenAI", + "options": { + "baseURL": "https://api.langdock.com/openai/eu/v1", + "apiKey": "{env:LANGDOCK_API_KEY}" + }, + "models": { + "gpt-5.6-sol": { "name": "GPT-5.6 Sol" }, + "gpt-5.6-terra": { "name": "GPT-5.6 Terra" }, + "gpt-5.6-luna": { "name": "GPT-5.6 Luna" } + } + }, + "anthropic": { + "options": { + "baseURL": "https://api.langdock.com/anthropic/eu/v1", + "apiKey": "{env:LANGDOCK_API_KEY}" + }, + "models": { + "claude-opus-5-default": { "name": "Opus 5" }, + "claude-opus-4-8-default": { "name": "Opus 4.8" }, + "claude-sonnet-5-default": { "name": "Sonnet 5" } + }, + "whitelist": [ + "claude-opus-4-8-default", + "claude-opus-5-default", + "claude-sonnet-5-default" + ] + } + } +} diff --git a/modules/pi/agent/models.work.json b/modules/pi/agent/models.work.json new file mode 100644 index 0000000..b981f9b --- /dev/null +++ b/modules/pi/agent/models.work.json @@ -0,0 +1,51 @@ +{ + "providers": { + "sparkl": { + "baseUrl": "http://192.168.10.111:8000/v1", + "api": "openai-completions", + "apiKey": "vllm", + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + }, + "models": [ + { + "id": "Qwen/Qwen3-30B-A3B", + "name": "Qwen3 30B A3B (local vLLM)", + "reasoning": true, + "input": ["text"], + "contextWindow": 16384, + "maxTokens": 8192, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "thinkingFormat": "qwen-chat-template", + "maxTokensField": "max_tokens" + } + } + ] + }, + "sparkr": { + "baseUrl": "http://192.168.10.110:8000/v1", + "api": "openai-completions", + "apiKey": "vllm", + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + }, + "models": [ + { + "id": "Qwen/Qwen3-Coder-Next-FP8", + "name": "Qwen3 Coder Next FP8 (local vLLM)", + "reasoning": false, + "input": ["text"], + "contextWindow": 262144, + "maxTokens": 8192, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "maxTokensField": "max_tokens" + } + } + ] + } + } +} diff --git a/modules/pi/agent/settings.work.json b/modules/pi/agent/settings.work.json index e246e0d..8ac19a8 100644 --- a/modules/pi/agent/settings.work.json +++ b/modules/pi/agent/settings.work.json @@ -1,8 +1,8 @@ { "lastChangelogVersion": "0.81.1", "theme": "light", - "defaultProvider": "anthropic", - "defaultModel": "claude-opus-5-default", + "defaultProvider": "sparkr", + "defaultModel": "Qwen/Qwen3-Coder-Next-FP8", "defaultThinkingLevel": "high", "hideThinkingBlock": false, "retry": {