From 627fc9a32bb80d97923540fc0d3e9661961462ba Mon Sep 17 00:00:00 2001 From: alexion Date: Wed, 29 Jul 2026 11:54:41 -0400 Subject: [PATCH] feat(nix): expose skill for project delivery --- INSTALL.md | 17 +++---- checks/home-manager-module.nix | 77 ++++++++---------------------- flake.nix | 9 ++++ home-manager-module.nix | 87 +++++++++------------------------- package.nix | 7 +-- 5 files changed, 65 insertions(+), 132 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 382d0fa..0b73612 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -69,31 +69,32 @@ Use the module instead ([ADR 0020](.claude/adr/0020-home-manager-module-for-decl ``` `programs.gitea-axi.enable` installs the CLI, always. -The Claude Code context — the Agent Skill and the SessionStart hook — follows the harness: it is declared under a single toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, on by default, and lands only when `programs.claude-code.enable` is also on. +The Claude Code SessionStart hook follows the harness: it is declared under `programs.gitea-axi.enableClaudeCodeIntegration`, on by default, and lands only when `programs.claude-code.enable` is also on. Enable gitea-axi on a host without Claude Code and you get the CLI and nothing else — no assertion, no toggles to turn off. Importing the module without setting `enable` changes nothing at all. The hook is declared through `programs.claude-code`'s own settings option, so a configuration that already sets its own `settings.hooks.SessionStart` gets gitea-axi's merged in alongside rather than colliding. -The Skill is written straight into Claude Code's skills directory, so it composes with your own skills whether you declare them as `programs.claude-code.skills. = ...` or as a single path standing for a whole directory — there is no form you have to switch to. +The Agent Skill is exposed as `packages..gitea-axi-skill` for project-local delivery through an agent-agnostic dev-shell helper. #### Options | Option | Default | Meaning | | --- | --- | --- | | `programs.gitea-axi.enable` | `false` | Install the CLI. | -| `programs.gitea-axi.package` | your `pkgs`' build of the package | The package to install, or `null` to declare the context without the binary. | -| `programs.gitea-axi.enableClaudeCodeIntegration` | `true` | Declare the Agent Skill and SessionStart hook when Claude Code is enabled. | +| `programs.gitea-axi.package` | your `pkgs`' build of the package | The package to install, or `null` to declare the hook without the binary. | +| `programs.gitea-axi.enableClaudeCodeIntegration` | `true` | Declare the SessionStart hook when Claude Code is enabled. | -`package = null` declares the context without installing the binary — for instance when you install gitea-axi system-wide through `environment.systemPackages`. -The hook records the bare name `gitea-axi` and lets `PATH` resolve it ([ADR 0019](.claude/adr/0019-hook-records-search-path-name.md)), so a binary installed anywhere on `PATH` satisfies it, and the Skill is still taken from the default build. +`package = null` declares the hook without installing the binary — for instance when you install gitea-axi system-wide through `environment.systemPackages`. +The hook records the bare name `gitea-axi` and lets `PATH` resolve it ([ADR 0019](.claude/adr/0019-hook-records-search-path-name.md)), so a binary installed anywhere on `PATH` satisfies it. -Turn `enableClaudeCodeIntegration` off to keep the CLI declarative while writing the Skill and hook yourself with `setup`. +Turn `enableClaudeCodeIntegration` off to keep the CLI declarative while writing the hook yourself with `setup`. #### What the module does not cover -Only the Claude Code integration is declarative. +Only the Claude Code SessionStart hook is declarative. The Codex and OpenCode files that `setup hooks` also writes have no home-manager module owning them, so gitea-axi does not write them declaratively either. On a declarative system those targets are unmanaged and therefore writable, so `gitea-axi setup hooks` still installs them. +The Agent Skill is packaged for project-local delivery, not installed globally by this module. ## Verifying an install diff --git a/checks/home-manager-module.nix b/checks/home-manager-module.nix index e7c0a25..f19c798 100644 --- a/checks/home-manager-module.nix +++ b/checks/home-manager-module.nix @@ -8,11 +8,11 @@ # the file mechanism, only which files a generation contains. Building the home # files derivation needs neither the Claude Code binary nor a running agent. # -# The configurations cover the composition risks the reshape introduced: the -# Skill coexisting with an operator's own skills in both the attribute-set and -# whole-directory forms, the explicit sibling-enable gate that keeps the Skill -# off a host without Claude Code, and the hook merging into an operator's own -# SessionStart list rather than replacing it. +# The configurations cover the remaining composition risks: the explicit +# sibling-enable gate that keeps the hook off a host without Claude Code, the +# hook merging into an operator's own SessionStart list rather than replacing it, +# and the Agent Skill staying out of global harness directories because project +# dev shells deliver it through the agent-agnostic skills helper. { pkgs, home-manager, @@ -20,25 +20,6 @@ package, }: let - inherit (pkgs) lib; - - # A skill the operator declares as their own, as one attribute-set entry. Its - # SKILL.md is what the attribute-set assertion looks for beside the module's. - operatorAttrSkill = pkgs.runCommandLocal "operator-attr-skill" { } '' - mkdir -p "$out" - printf '%s\n' "the operator's own attribute-set skill" > "$out/SKILL.md" - ''; - - # A whole directory of skills, one folder per skill, for the path form of the - # operator's own `programs.claude-code.skills`. The Claude Code module installs - # this recursively; the module's own nested Skill entry has to drop in beside - # its contents rather than collide with a single link over the directory. - operatorSkillsDir = pkgs.runCommandLocal "operator-skills-dir" { } '' - mkdir -p "$out/operator-dir-skill" - printf '%s\n' "the operator's own whole-directory skill" \ - > "$out/operator-dir-skill/SKILL.md" - ''; - # A distinctive command so the merged-hook assertion can tell the operator's # own SessionStart hook apart from gitea-axi's in the generated settings.json. operatorHook = { @@ -74,29 +55,18 @@ let ]; }).config.home-files; - # An operator on Claude Code who declares their own skill as an attribute-set - # entry: the module's Skill and theirs must both land, each at its own name. - attrSetSkills = homeFiles { - programs.claude-code.enable = true; - # A store path (not a bare derivation): the skills value type takes a path, - # and a derivation would be read as the attribute-set branch of the option. - programs.claude-code.skills.operator-attr-skill = "${operatorAttrSkill}"; - }; - - # An operator who declares their own skills as a whole directory (path form): - # the module's Skill lands beside the directory's contents. A regression to a - # non-recursive path-form install would fail this build as a file collision. - wholeDirSkills = homeFiles { - programs.claude-code.enable = true; - programs.claude-code.skills = "${operatorSkillsDir}"; - }; - - # Claude Code disabled: the explicit sibling-enable gate must leave no - # gitea-axi Skill entry in the generation. + # Claude Code disabled: the sibling-enable gate in the Claude Code module must + # leave no settings file or Skill entry in the generation. claudeCodeOff = homeFiles { programs.claude-code.enable = false; }; + # Claude Code enabled: the module contributes only the SessionStart hook, not + # a global Skill entry. + claudeCodeOn = homeFiles { + programs.claude-code.enable = true; + }; + # An operator with their own SessionStart hook: the module's hook must merge # into that list rather than replace it. mergedHook = homeFiles { @@ -107,27 +77,22 @@ in pkgs.runCommandLocal "gitea-axi-home-manager-module-check" { # Forcing each derivation as a build input is what actually builds the home - # files tree under every configuration; the whole-directory build would fail - # here, before any assertion runs, on a non-recursive-install regression. + # files tree under every configuration. inherit - attrSetSkills - wholeDirSkills claudeCodeOff + claudeCodeOn mergedHook ; } '' - echo "attribute-set skills: module's Skill and operator's both land" - test -f "$attrSetSkills/.claude/skills/gitea-axi/SKILL.md" - test -f "$attrSetSkills/.claude/skills/operator-attr-skill/SKILL.md" - - echo "whole-directory skills: module's Skill lands beside the directory" - test -f "$wholeDirSkills/.claude/skills/gitea-axi/SKILL.md" - test -f "$wholeDirSkills/.claude/skills/operator-dir-skill/SKILL.md" - - echo "Claude Code disabled: no gitea-axi Skill entry is written" + echo "Claude Code disabled: no hook settings or global Skill entry is written" + test ! -e "$claudeCodeOff/.claude/settings.json" test ! -e "$claudeCodeOff/.claude/skills/gitea-axi" + echo "Claude Code enabled: hook lands, but the Skill is not globally installed" + grep -q '"gitea-axi"' "$claudeCodeOn/.claude/settings.json" + test ! -e "$claudeCodeOn/.claude/skills/gitea-axi" + echo "operator's own SessionStart hook: the module's hook merges in" # Match the commands as quoted JSON string values, not by their position or # the emitter's colon spacing: both must be present for a merge (rather than diff --git a/flake.nix b/flake.nix index c1187ec..611d18c 100644 --- a/flake.nix +++ b/flake.nix @@ -47,6 +47,15 @@ packages = forAllSystems ( { pkgs, ... }: rec { gitea-axi = pkgs.callPackage ./package.nix { }; + gitea-axi-skill = pkgs.runCommandLocal "gitea-axi-skill" + { + passthru.skillName = "gitea-axi"; + } + '' + mkdir -p "$out" + cp -R ${gitea-axi.skill}/. "$out/" + test -f "$out/SKILL.md" + ''; default = gitea-axi; } ); diff --git a/home-manager-module.nix b/home-manager-module.nix index 3f2cf5c..79e8d39 100644 --- a/home-manager-module.nix +++ b/home-manager-module.nix @@ -1,17 +1,9 @@ -# A home-manager module installing gitea-axi and, when a harness is present, -# its ambient context — the bundled Agent Skill and the SessionStart hook -# (ADR 0020, reshaped by ADR 0021). +# A home-manager module installing gitea-axi and, when Claude Code is present, +# its SessionStart hook. # -# `programs.gitea-axi.enable` installs the CLI, always. The Claude Code context -# follows the harness: it is declared under one per-harness toggle and lands -# only when `programs.claude-code.enable` is also on. gitea-axi is a working CLI -# without a harness, so enabling it on a host with no Claude Code installs the -# binary and nothing else, with no assertion. -# -# The module is a wiring layer and nothing more. Both artefacts come from the -# package's published attributes, so what a declarative configuration installs -# and what `gitea-axi setup` writes imperatively are the same two artefacts, and -# neither is restated here. +# `programs.gitea-axi.enable` installs the CLI, always. +# The Agent Skill is exposed as a package output for agent-agnostic delivery by +# a project's dev shell or an operator's shared skill delivery module. # # Importing this module changes nothing until `programs.gitea-axi.enable` is set. { @@ -24,15 +16,13 @@ let cfg = config.programs.gitea-axi; # The package the declarations are read out of. `package = null` opts out of - # putting the binary on PATH, not out of the configuration — an operator who - # installs gitea-axi system-wide still wants the Skill — so the declarations + # putting the binary on PATH, not out of the hook declaration — an operator who + # installs gitea-axi system-wide still wants the hook — so the declarations # fall back to the default build, which in that arrangement is already in the # closure anyway. sourcePackage = if cfg.package != null then cfg.package else defaultPackage; defaultPackage = pkgs.callPackage ./package.nix { }; - - claudeCode = config.programs.claude-code; in { options.programs.gitea-axi = { @@ -44,12 +34,11 @@ in defaultText = lib.literalExpression "pkgs.callPackage ./package.nix { }"; description = '' The gitea-axi package to install, or `null` to declare the ambient - context without installing the binary — for an operator who supplies it - another way, such as `environment.systemPackages`. + Claude Code hook without installing the binary — for an operator who + supplies it another way, such as `environment.systemPackages`. The SessionStart hook records a name resolved on `PATH`, so a binary - installed elsewhere satisfies it. With `null` the Agent Skill is still - taken from the default build. + installed elsewhere satisfies it. ''; }; @@ -57,16 +46,15 @@ in type = lib.types.bool; default = true; description = '' - Whether to declare gitea-axi's Claude Code context — the bundled Agent - Skill and the SessionStart hook — alongside the CLI. + Whether to declare gitea-axi's Claude Code SessionStart hook alongside + the CLI. - Both artefacts land only when `programs.claude-code.enable` is also on; - with it off they are silently absent, matching how home-manager's own + The hook lands only when `programs.claude-code.enable` is also on; + with it off it is silently absent, matching how home-manager's own `enableBashIntegration`-style toggles behave against a disabled sibling. Turn this off to install gitea-axi declaratively while writing the - Claude Code context by hand or with `gitea-axi setup`. The toggle - generalises: a future harness reads as `enableCodexIntegration`. + Claude Code hook by hand or with `gitea-axi setup`. ''; }; }; @@ -75,44 +63,13 @@ in lib.mkMerge [ (lib.mkIf (cfg.package != null) { home.packages = [ cfg.package ]; }) - # The two Claude Code artefacts move together under one per-harness - # toggle. They are gated by different mechanisms internally — the Skill by - # an explicit `claude-code.enable` condition, the hook by the Claude Code - # module's own gate — because of how each is declared, below. - (lib.mkIf cfg.enableClaudeCodeIntegration ( - lib.mkMerge [ - # The hook is declared through the Claude Code module's own settings - # option. That composes it with an operator's own SessionStart hooks - # instead of colliding, and the module drops the declaration for free - # when it is disabled — so no explicit `claude-code.enable` gate here. - { programs.claude-code.settings.hooks.SessionStart = [ sourcePackage.sessionStartHook ]; } - - # The Skill is written as an ordinary file into Claude Code's skills - # directory, rather than contributed to `programs.claude-code.skills`. - # That composes with both forms of the operator's own skills option — - # an attribute set and a single path for a whole directory — because - # it never touches that option's type, so the path-form collision - # disappears at its root instead of being escaped by a toggle. - # - # Writing through home.file does not inherit the Claude Code module's - # `enable`-gate the way declaring through its options does, so the - # Skill is gated on `claude-code.enable` explicitly. The gate is also - # what keeps package realisation lazy: home.file reads the Skill's - # source directory while evaluating, so an ungated write would realise - # the package on every host — even one with no Claude Code that - # installs nothing from it. - # - # `configDir` is read from the Claude Code module rather than - # hardcoded, so the Skill lands beside that module's own skills - # wherever the operator points it. - (lib.mkIf claudeCode.enable { - home.file."${claudeCode.configDir}/skills/gitea-axi" = { - source = sourcePackage.skill; - recursive = true; - }; - }) - ] - )) + (lib.mkIf cfg.enableClaudeCodeIntegration { + # The hook is declared through the Claude Code module's own settings + # option. That composes it with an operator's own SessionStart hooks + # instead of colliding, and the module drops the declaration for free + # when it is disabled. + programs.claude-code.settings.hooks.SessionStart = [ sourcePackage.sessionStartHook ]; + }) ] ); } diff --git a/package.nix b/package.nix index f99694b..3fe9fe6 100644 --- a/package.nix +++ b/package.nix @@ -179,9 +179,10 @@ buildNpmPackage (finalAttrs: { # different majors. inherit nodejs; - # The bundled Agent Skill's directory, for a configuration that installs it - # declaratively. A directory rather than the SKILL.md inside it, so a Skill - # that grows helper files stays one reference. + # The bundled Agent Skill's directory, for a configuration that packages it + # into an agent-agnostic project-local delivery mechanism. A directory rather + # than the SKILL.md inside it, so a Skill that grows helper files stays one + # reference. skill = "${finalAttrs.finalPackage}/${skillSubdir}"; # The SessionStart hook entry, verbatim as it belongs in a Claude Code