diff --git a/modules/misc/news/2026/06/2026-06-11_12-00-00.nix b/modules/misc/news/2026/06/2026-06-11_12-00-00.nix new file mode 100644 index 0000000000..1708ea03ca --- /dev/null +++ b/modules/misc/news/2026/06/2026-06-11_12-00-00.nix @@ -0,0 +1,10 @@ +{ config, ... }: +{ + time = "2026-06-11T16:00:00+00:00"; + condition = config.programs.codex.enable; + message = '' + The `programs.codex.plugins` and `programs.codex.marketplaces` options + were added to configure Codex plugins and local plugin marketplaces + declaratively. + ''; +} diff --git a/modules/programs/codex.nix b/modules/programs/codex.nix index a81863c69b..71aef0ce36 100644 --- a/modules/programs/codex.nix +++ b/modules/programs/codex.nix @@ -11,6 +11,7 @@ let tomlFormat = pkgs.formats.toml { }; yamlFormat = pkgs.formats.yaml { }; + jsonFormat = pkgs.formats.json { }; packageVersion = if cfg.package != null then lib.getVersion cfg.package else "0.94.0"; isTomlConfig = lib.versionAtLeast packageVersion "0.2.0"; @@ -102,6 +103,57 @@ in ''; }; + plugins = lib.mkOption { + type = with lib.types; listOf (either package path); + default = [ ]; + description = '' + List of plugins to use when running Codex. + 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 installed into Codex's plugin cache and enabled through + {file}`CODEX_HOME/config.toml`. + + Warning: If using a derivation as the source for a plugin, make sure that + the derivation name matches the name of the plugin in the manifest file. + ''; + example = lib.literalExpression '' + [ + ./my-local-plugin + (fetchFromGitHub { + owner = "some-github-org"; + repo = "codex-plugin"; + rev = "779a68ebc2a75e4a184d2c87e5a43a758e6458a1"; + sha256 = "228fdd7e5908ea1d2f65218ecd9c71e1eefa0834d200d55fbb8bf8b5563acec0"; + }) + ] + ''; + }; + + marketplaces = lib.mkOption { + type = with lib.types; attrsOf (either package path); + default = { }; + description = '' + Custom marketplaces for Codex 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 + + Marketplaces are configured through {file}`CODEX_HOME/config.toml`. + ''; + example = lib.literalExpression '' + { + local-marketplace = ./my-local-marketplace; + gh-marketplace = fetchFromGitHub { + owner = "some-github-org"; + repo = "codex-marketplace"; + rev = "8a873a220b8427b25b03ce1a821593a24e098c34"; + sha256 = "5c2dce95122b5bb73fa547edabbb6c3061c2d193d11e51faecd4d22659e67279"; + }; + } + ''; + }; + skills = lib.mkOption { type = lib.types.either (lib.types.attrsOf (lib.types.either lib.types.lines lib.types.path)) lib.types.path; default = { }; @@ -187,6 +239,12 @@ in configDir = if useXdgDirectories then "${xdgConfigHome}/codex" else ".codex"; configFileName = if isTomlConfig then "config.toml" else "config.yaml"; skillsDir = "${configDir}/skills"; + homeRelativeConfigDir = lib.removePrefix "/" configDir; + pluginsMarketplaceName = "home-manager"; + pluginsDir = "${configDir}/plugins"; + pluginsCacheDir = "${pluginsDir}/cache"; + homeRelativePluginsCacheDir = "${homeRelativeConfigDir}/plugins/cache"; + rawSettings = if cfg.settings == null then { } else cfg.settings; # TODO: Remove this workaround once Codex supports symlinked SKILL.md # files again. Upstream only supports symlinking the containing skill @@ -218,7 +276,66 @@ in lib.nameValuePair "${configDir}/rules/${name}.rules" ( if lib.hm.strings.isPathLike content then { source = content; } else { text = content; } ); - + mkPluginName = + plugin: + let + manifestPath = plugin + "/.codex-plugin/plugin.json"; + manifestName = + if !lib.isDerivation plugin && builtins.pathExists manifestPath then + (builtins.fromJSON (builtins.readFile manifestPath)).name + else + null; + fallbackName = + if lib.isDerivation plugin then + plugin.pname or (lib.getName plugin) + else + baseNameOf (toString plugin); + in + builtins.unsafeDiscardStringContext (if manifestName != null then manifestName else fallbackName); + mkPluginVersion = + plugin: + let + manifestPath = plugin + "/.codex-plugin/plugin.json"; + manifestVersion = + if !lib.isDerivation plugin && builtins.pathExists manifestPath then + (builtins.fromJSON (builtins.readFile manifestPath)).version or null + else + null; + fallbackVersion = plugin.version or "0.0.0"; + in + builtins.unsafeDiscardStringContext ( + if manifestVersion != null then manifestVersion else fallbackVersion + ); + mkPluginCachePath = + plugin: + "${pluginsCacheDir}/${pluginsMarketplaceName}/${mkPluginName plugin}/${mkPluginVersion plugin}"; + mkPluginFileEntry = + plugin: + lib.nameValuePair (mkPluginCachePath plugin) { + source = plugin; + force = true; + }; + mkPluginConfigEntry = + plugin: + lib.nameValuePair "${mkPluginName plugin}@${pluginsMarketplaceName}" { + enabled = true; + }; + mkMarketplaceConfigEntry = _name: content: { + source_type = "local"; + source = "${content}"; + }; + mkPersonalMarketplacePluginEntry = plugin: { + name = mkPluginName plugin; + source = { + source = "local"; + path = "./${homeRelativePluginsCacheDir}/${pluginsMarketplaceName}/${mkPluginName plugin}/${mkPluginVersion plugin}"; + }; + policy = { + installation = "AVAILABLE"; + authentication = "ON_INSTALL"; + }; + category = "Productivity"; + }; transformedMcpServers = lib.optionalAttrs (cfg.enableMcpIntegration && config.programs.mcp.enable) ( lib.mapAttrs ( name: server: @@ -241,13 +358,44 @@ in ) config.programs.mcp.servers ); - settingMcpServers = lib.attrByPath [ "mcp_servers" ] { } cfg.settings; + generatedPluginSettings = + lib.optionalAttrs (cfg.plugins != [ ] || cfg.marketplaces != { }) { + features.plugins = true; + } + // lib.optionalAttrs (cfg.plugins != [ ]) { + plugins = lib.listToAttrs (map mkPluginConfigEntry cfg.plugins); + } + // lib.optionalAttrs (cfg.marketplaces != { }) { + marketplaces = lib.mapAttrs mkMarketplaceConfigEntry cfg.marketplaces; + }; + mergedSettingsWithoutMcp = lib.recursiveUpdate rawSettings generatedPluginSettings; + settingMcpServers = lib.attrByPath [ "mcp_servers" ] { } mergedSettingsWithoutMcp; mergedMcpServers = transformedMcpServers // settingMcpServers; mergedSettings = - cfg.settings // lib.optionalAttrs (mergedMcpServers != { }) { mcp_servers = mergedMcpServers; }; + mergedSettingsWithoutMcp + // lib.optionalAttrs (mergedMcpServers != { }) { mcp_servers = mergedMcpServers; }; in mkIf cfg.enable { assertions = [ + { + assertion = (cfg.plugins == [ ] && cfg.marketplaces == { }) || isTomlConfig; + message = "`programs.codex.plugins` and `programs.codex.marketplaces` require Codex 0.2.0 or later"; + } + { + assertion = lib.all ( + plugin: + !(lib.hm.strings.isPathLike plugin && !lib.isDerivation plugin) || lib.pathIsDirectory plugin + ) cfg.plugins; + message = "`programs.codex.plugins` entries must be directories"; + } + { + assertion = lib.all ( + marketplace: + !(lib.hm.strings.isPathLike marketplace && !lib.isDerivation marketplace) + || lib.pathIsDirectory marketplace + ) (lib.attrValues cfg.marketplaces); + message = "`programs.codex.marketplaces` entries must be directories"; + } { assertion = !lib.hm.strings.isPathLike cfg.skills || lib.pathIsDirectory cfg.skills; message = "`programs.codex.skills` must be a directory when set to a path"; @@ -263,10 +411,35 @@ in home = { packages = mkIf (cfg.package != null) [ cfg.package ]; + # This is needed because codex will convert the symlinked plugin directory into + # an actual directory (which will not be overwritten by home-manager) + activation.cleanCodexPluginCache = lib.mkIf (cfg.plugins != [ ]) ( + lib.hm.dag.entryBefore [ "linkGeneration" ] ( + lib.concatMapStringsSep "\n" ( + plugin: + let + path = "$HOME/${mkPluginCachePath plugin}"; + in + '' + if [ -d "${path}" ] && [ ! -L "${path}" ]; then + rm -rf "${path}" + fi + '' + ) cfg.plugins + ) + ); + file = { "${configDir}/${configFileName}" = lib.mkIf (mergedSettings != { }) { source = settingsFormat.generate "codex-config" mergedSettings; }; + ".agents/plugins/marketplace.json" = lib.mkIf (cfg.plugins != [ ]) { + source = jsonFormat.generate "codex-home-manager-marketplace" { + name = pluginsMarketplaceName; + interface.displayName = "Home Manager"; + plugins = map mkPersonalMarketplacePluginEntry cfg.plugins; + }; + }; "${configDir}/AGENTS.md" = if lib.isPath cfg.context then { source = cfg.context; } @@ -276,6 +449,7 @@ in }; } // lib.mapAttrs' mkSkillEntry skillSources + // lib.listToAttrs (map mkPluginFileEntry cfg.plugins) // lib.mapAttrs' mkRuleEntry cfg.rules; sessionVariables = mkIf useXdgDirectories { diff --git a/tests/modules/programs/codex/default.nix b/tests/modules/programs/codex/default.nix index 09580654df..7e8e9a03f0 100644 --- a/tests/modules/programs/codex/default.nix +++ b/tests/modules/programs/codex/default.nix @@ -6,6 +6,7 @@ codex-legacy-custom-instructions = ./legacy-custom-instructions.nix; codex-mcp-integration = ./mcp-integration.nix; codex-mcp-integration-with-override = ./mcp-integration-with-override.nix; + codex-plugins = ./plugins.nix; codex-rules = ./rules.nix; codex-skills-inline = ./skills-inline.nix; codex-skills-inline-null-package = ./skills-inline-null-package.nix; diff --git a/tests/modules/programs/codex/plugins.nix b/tests/modules/programs/codex/plugins.nix new file mode 100644 index 0000000000..9bd212b38a --- /dev/null +++ b/tests/modules/programs/codex/plugins.nix @@ -0,0 +1,38 @@ +{ config, ... }: +let + codexPackage = config.lib.test.mkStubPackage { + name = "codex"; + version = "0.94.0"; + }; +in +{ + programs.codex = { + enable = true; + package = codexPackage; + plugins = [ ./sample-plugin ]; + marketplaces.team = ./sample-marketplace; + }; + + nmt.script = '' + assertFileExists home-files/.codex/config.toml + assertFileContains home-files/.codex/config.toml '[features]' + assertFileContains home-files/.codex/config.toml 'plugins = true' + assertFileContains home-files/.codex/config.toml '[marketplaces.team]' + assertFileContains home-files/.codex/config.toml 'source_type = "local"' + assertFileContains home-files/.codex/config.toml 'source = "${./sample-marketplace}"' + assertFileContains home-files/.codex/config.toml '[plugins."sample-plugin@home-manager"]' + assertFileContains home-files/.codex/config.toml 'enabled = true' + + assertFileExists home-files/.agents/plugins/marketplace.json + assertFileContains home-files/.agents/plugins/marketplace.json '"name": "home-manager"' + assertFileContains home-files/.agents/plugins/marketplace.json '"name": "sample-plugin"' + assertFileContains home-files/.agents/plugins/marketplace.json '"path": "./.codex/plugins/cache/home-manager/sample-plugin/1.0.0"' + + assertLinkExists home-files/.codex/plugins/cache/home-manager/sample-plugin/1.0.0 + assertFileExists home-files/.codex/plugins/cache/home-manager/sample-plugin/1.0.0/.codex-plugin/plugin.json + assertFileContent \ + home-files/.codex/plugins/cache/home-manager/sample-plugin/1.0.0/.codex-plugin/plugin.json \ + ${./sample-plugin/.codex-plugin/plugin.json} + assertFileExists home-files/.codex/plugins/cache/home-manager/sample-plugin/1.0.0/skills/sample/SKILL.md + ''; +} diff --git a/tests/modules/programs/codex/sample-marketplace/marketplace.json b/tests/modules/programs/codex/sample-marketplace/marketplace.json new file mode 100644 index 0000000000..f5f449d8ab --- /dev/null +++ b/tests/modules/programs/codex/sample-marketplace/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "team", + "plugins": [ + { + "name": "sample-plugin", + "source": { + "source": "local", + "path": "./sample-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/tests/modules/programs/codex/sample-plugin/.codex-plugin/plugin.json b/tests/modules/programs/codex/sample-plugin/.codex-plugin/plugin.json new file mode 100644 index 0000000000..bab5109b30 --- /dev/null +++ b/tests/modules/programs/codex/sample-plugin/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "sample-plugin", + "version": "1.0.0", + "description": "Sample Codex plugin for Home Manager tests.", + "skills": "./skills" +} diff --git a/tests/modules/programs/codex/sample-plugin/skills/sample/SKILL.md b/tests/modules/programs/codex/sample-plugin/skills/sample/SKILL.md new file mode 100644 index 0000000000..ca1e2df523 --- /dev/null +++ b/tests/modules/programs/codex/sample-plugin/skills/sample/SKILL.md @@ -0,0 +1,8 @@ +--- +name: sample +description: Sample skill for Home Manager tests. +--- + +# Sample + +Test fixture content.