treewide: support store path strings for skills dirs

Filtered or generated skill directories can evaluate to Nix store path
strings rather than path values. The previous top-level skills handling
only used lib.isPath, so those directory values fell through instead of
being linked or expanded.

Treat store-path strings as path-like for the top-level skills option in
Codex, Claude Code, Gemini CLI, and OpenCode, matching the behavior
already used for individual skill entries. Add NMT coverage for
store-path-string skill directories.
This commit is contained in:
Austin Horstman
2026-05-01 09:26:25 -05:00
parent 94db028632
commit d181e6ac2a
12 changed files with 185 additions and 87 deletions

View File

@@ -514,7 +514,8 @@ in
let
mkSourceEntry = content: if lib.isPath content then { source = content; } else { text = content; };
isStorePathString = content: builtins.isString content && lib.hasPrefix builtins.storeDir content;
isStorePathString =
content: builtins.isString content && lib.hasPrefix "${builtins.storeDir}/" content;
isPathLikeContent = content: lib.isPath content || isStorePathString content;
mkMarkdownEntries =
@@ -587,7 +588,7 @@ in
message = "`programs.claude-code.package` cannot be null when `mcpServers`, `lspServers`, `enableMcpIntegration`, or `plugins` is configured";
}
{
assertion = !lib.isPath cfg.skills || lib.pathIsDirectory cfg.skills;
assertion = !isPathLikeContent cfg.skills || lib.pathIsDirectory cfg.skills;
message = "`programs.claude-code.skills` must be a directory when set to a path";
}
]
@@ -680,7 +681,7 @@ in
(mkRecursiveDirAttrs "commands" cfg.commandsDir)
(mkRecursiveDirAttrs "hooks" cfg.hooksDir)
(mkRecursiveDirAttrs "rules" cfg.rulesDir)
(lib.mkIf (lib.isPath cfg.skills) {
(lib.mkIf (isPathLikeContent cfg.skills) {
".claude/skills" = {
source = cfg.skills;
recursive = true;

View File

@@ -191,7 +191,8 @@ in
# TODO: Remove this workaround once Codex supports symlinked SKILL.md
# files again. Upstream only supports symlinking the containing skill
# directory today: https://github.com/openai/codex/issues/10470
isStorePathString = content: builtins.isString content && lib.hasPrefix builtins.storeDir content;
isStorePathString =
content: builtins.isString content && lib.hasPrefix "${builtins.storeDir}/" content;
isPathLikeContent = content: lib.isPath content || isStorePathString content;
mkSkillDir =
content:
@@ -201,7 +202,7 @@ in
skillSources =
if builtins.isAttrs cfg.skills then
cfg.skills
else if lib.isPath cfg.skills && lib.pathIsDirectory cfg.skills then
else if isPathLikeContent cfg.skills && lib.pathIsDirectory cfg.skills then
lib.mapAttrs (name: _type: cfg.skills + "/${name}") (builtins.readDir cfg.skills)
else
{ };
@@ -250,7 +251,7 @@ in
mkIf cfg.enable {
assertions = [
{
assertion = !lib.isPath cfg.skills || lib.pathIsDirectory cfg.skills;
assertion = !isPathLikeContent cfg.skills || lib.pathIsDirectory cfg.skills;
message = "`programs.codex.skills` must be a directory when set to a path";
}
{

View File

@@ -227,82 +227,88 @@ in
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
programs.gemini-cli.settings.mcpServers = lib.mkIf (
cfg.enableMcpIntegration && config.programs.mcp.enable
) (lib.mapAttrs (_n: lib.mkDefault) config.programs.mcp.servers);
}
{
home = {
packages = lib.mkIf (cfg.package != null) [ cfg.package ];
file.".gemini/settings.json" = lib.mkIf (cfg.settings != { }) {
source = jsonFormat.generate "gemini-cli-settings.json" cfg.settings;
};
sessionVariables = lib.mkIf (cfg.defaultModel != null) {
GEMINI_MODEL = cfg.defaultModel;
};
};
}
{
home.file = lib.mapAttrs' (
n: v: lib.nameValuePair ".gemini/${n}.md" (if lib.isPath v then { source = v; } else { text = v; })
) cfg.context;
}
{
home.file = lib.mapAttrs' (
n: v:
lib.nameValuePair ".gemini/commands/${n}.toml" {
source = tomlFormat.generate "gemini-cli-command-${n}.toml" {
inherit (v) description prompt;
config =
let
isStorePathString =
content: builtins.isString content && lib.hasPrefix "${builtins.storeDir}/" content;
isPathLikeContent = content: lib.isPath content || isStorePathString content;
in
lib.mkIf cfg.enable (
lib.mkMerge [
{
programs.gemini-cli.settings.mcpServers = lib.mkIf (
cfg.enableMcpIntegration && config.programs.mcp.enable
) (lib.mapAttrs (_n: lib.mkDefault) config.programs.mcp.servers);
}
{
home = {
packages = lib.mkIf (cfg.package != null) [ cfg.package ];
file.".gemini/settings.json" = lib.mkIf (cfg.settings != { }) {
source = jsonFormat.generate "gemini-cli-settings.json" cfg.settings;
};
}
) cfg.commands;
}
{
home.file = lib.mapAttrs' (
n: v:
lib.nameValuePair ".gemini/policies/${n}.toml" {
source =
if builtins.isPath v || builtins.isString v || lib.isDerivation v then
v
else
tomlFormat.generate "gemini-cli-policy-${n}.toml" v;
}
) cfg.policies;
}
{
assertions = [
{
assertion = !lib.isPath cfg.skills || lib.pathIsDirectory cfg.skills;
message = "`programs.gemini-cli.skills` must be a directory when set to a path";
}
];
}
{
home.file =
if lib.isPath cfg.skills then
{
".gemini/skills" = {
source = cfg.skills;
recursive = true;
sessionVariables = lib.mkIf (cfg.defaultModel != null) {
GEMINI_MODEL = cfg.defaultModel;
};
};
}
{
home.file = lib.mapAttrs' (
n: v: lib.nameValuePair ".gemini/${n}.md" (if lib.isPath v then { source = v; } else { text = v; })
) cfg.context;
}
{
home.file = lib.mapAttrs' (
n: v:
lib.nameValuePair ".gemini/commands/${n}.toml" {
source = tomlFormat.generate "gemini-cli-command-${n}.toml" {
inherit (v) description prompt;
};
}
else
lib.mapAttrs' (
n: v:
if lib.isPath v && lib.pathIsDirectory v then
lib.nameValuePair ".gemini/skills/${n}" {
source = v;
) cfg.commands;
}
{
home.file = lib.mapAttrs' (
n: v:
lib.nameValuePair ".gemini/policies/${n}.toml" {
source =
if builtins.isPath v || builtins.isString v || lib.isDerivation v then
v
else
tomlFormat.generate "gemini-cli-policy-${n}.toml" v;
}
) cfg.policies;
}
{
assertions = [
{
assertion = !isPathLikeContent cfg.skills || lib.pathIsDirectory cfg.skills;
message = "`programs.gemini-cli.skills` must be a directory when set to a path";
}
];
}
{
home.file =
if isPathLikeContent cfg.skills then
{
".gemini/skills" = {
source = cfg.skills;
recursive = true;
}
else
lib.nameValuePair ".gemini/skills/${n}/SKILL.md" (
if lib.isPath v then { source = v; } else { text = v; }
)
) cfg.skills;
}
]
);
};
}
else
lib.mapAttrs' (
n: v:
if isPathLikeContent v && lib.pathIsDirectory v then
lib.nameValuePair ".gemini/skills/${n}" {
source = v;
recursive = true;
}
else
lib.nameValuePair ".gemini/skills/${n}/SKILL.md" (
if isPathLikeContent v then { source = v; } else { text = v; }
)
) cfg.skills;
}
]
);
}

View File

@@ -62,6 +62,10 @@ let
}
else
cfg.package;
isStorePathString =
content: builtins.isString content && lib.hasPrefix "${builtins.storeDir}/" content;
isPathLikeContent = content: lib.isPath content || isStorePathString content;
in
{
meta.maintainers = with lib.maintainers; [ delafthi ];
@@ -435,7 +439,7 @@ in
message = "`programs.opencode.tools` must be a directory when set to a path";
}
{
assertion = !lib.isPath cfg.skills || lib.pathIsDirectory cfg.skills;
assertion = !isPathLikeContent cfg.skills || lib.pathIsDirectory cfg.skills;
message = "`programs.opencode.skills` must be a directory when set to a path";
}
{
@@ -522,7 +526,7 @@ in
recursive = true;
};
"opencode/skills" = mkIf (lib.isPath cfg.skills) {
"opencode/skills" = mkIf (isPathLikeContent cfg.skills) {
source = cfg.skills;
recursive = true;
};
@@ -558,17 +562,14 @@ in
)
// lib.mapAttrs' (
name: content:
if
(lib.isPath content && lib.pathIsDirectory content)
|| (builtins.isString content && lib.hasPrefix builtins.storeDir content)
then
if isPathLikeContent content && lib.pathIsDirectory content then
lib.nameValuePair "opencode/skills/${name}" {
source = content;
recursive = true;
}
else
lib.nameValuePair "opencode/skills/${name}/SKILL.md" (
if lib.isPath content then { source = content; } else { text = content; }
if isPathLikeContent content then { source = content; } else { text = content; }
)
) (if builtins.isAttrs cfg.skills then cfg.skills else { })
// lib.optionalAttrs (builtins.isAttrs cfg.themes) (

View File

@@ -14,6 +14,7 @@
claude-code-commands-dir = ./commands-dir.nix;
claude-code-hooks-dir = ./hooks-dir.nix;
claude-code-skills-dir = ./skills-dir.nix;
claude-code-skills-store-path-dir = ./skills-store-path-dir.nix;
claude-code-skills-subdir = ./skills-subdir.nix;
claude-code-agents-path = ./agents-path.nix;
claude-code-commands-path = ./commands-path.nix;

View File

@@ -0,0 +1,20 @@
{ pkgs, ... }:
let
src = pkgs.writeTextDir "skills/external-skill/SKILL.md" ''
# External Skill
'';
in
{
programs.claude-code = {
enable = true;
skills = "${src}/skills";
};
nmt.script = ''
assertFileExists home-files/.claude/skills/external-skill/SKILL.md
assertLinkExists home-files/.claude/skills/external-skill/SKILL.md
assertFileContent home-files/.claude/skills/external-skill/SKILL.md \
"${src}/skills/external-skill/SKILL.md"
'';
}

View File

@@ -12,5 +12,6 @@
codex-skills-inline-legacy-path = ./skills-inline-legacy-path.nix;
codex-skills-dir = ./skills-dir.nix;
codex-skills-store-path = ./skills-store-path.nix;
codex-skills-store-path-dir = ./skills-store-path-dir.nix;
codex-skills-path-not-directory = ./skills-path-not-directory.nix;
}

View File

@@ -0,0 +1,25 @@
{ pkgs, ... }:
let
src = pkgs.writeTextDir "skills/external-skill/SKILL.md" ''
---
name: external-skill
description: Store path skill directory fixture.
---
# External Skill
'';
in
{
programs.codex = {
enable = true;
skills = "${src}/skills";
};
nmt.script = ''
assertLinkExists home-files/.codex/skills/external-skill
assertFileExists home-files/.codex/skills/external-skill/SKILL.md
assertFileContent home-files/.codex/skills/external-skill/SKILL.md \
"${src}/skills/external-skill/SKILL.md"
'';
}

View File

@@ -3,6 +3,7 @@
gemini-cli-context = ./context.nix;
gemini-cli-skills = ./skills.nix;
gemini-cli-skills-dir = ./skills-dir.nix;
gemini-cli-skills-store-path-dir = ./skills-store-path-dir.nix;
gemini-cli-skills-path-not-directory = ./skills-path-not-directory.nix;
gemini-cli-mcp-servers = ./mcp.nix;
}

View File

@@ -0,0 +1,20 @@
{ pkgs, ... }:
let
src = pkgs.writeTextDir "skills/external-skill/SKILL.md" ''
# External Skill
'';
in
{
programs.gemini-cli = {
enable = true;
skills = "${src}/skills";
};
nmt.script = ''
assertFileExists home-files/.gemini/skills/external-skill/SKILL.md
assertLinkExists home-files/.gemini/skills/external-skill/SKILL.md
assertFileContent home-files/.gemini/skills/external-skill/SKILL.md \
"${src}/skills/external-skill/SKILL.md"
'';
}

View File

@@ -17,6 +17,7 @@
opencode-skills-inline = ./skills-inline.nix;
opencode-skills-path = ./skills-path.nix;
opencode-skills-store-path = ./skills-store-path.nix;
opencode-skills-store-path-dir = ./skills-store-path-dir.nix;
opencode-skills-directory = ./skills-directory.nix;
opencode-skills-bulk-directory = ./skills-bulk-directory.nix;
opencode-themes-inline = ./themes-inline.nix;

View File

@@ -0,0 +1,20 @@
{ pkgs, ... }:
let
src = pkgs.writeTextDir "skills/external-skill/SKILL.md" ''
# External Skill
'';
in
{
programs.opencode = {
enable = true;
skills = "${src}/skills";
};
nmt.script = ''
assertFileExists home-files/.config/opencode/skills/external-skill/SKILL.md
assertLinkExists home-files/.config/opencode/skills/external-skill/SKILL.md
assertFileContent home-files/.config/opencode/skills/external-skill/SKILL.md \
"${src}/skills/external-skill/SKILL.md"
'';
}