diff --git a/.github/workflows/update-flake-lock.yml b/.github/workflows/update-flake-lock.yml new file mode 100644 index 00000000..7c8d8f2d --- /dev/null +++ b/.github/workflows/update-flake-lock.yml @@ -0,0 +1,57 @@ +name: Update Flake Lock + +on: + schedule: + - cron: '0 3 * * 1' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: update-flake-lock + cancel-in-progress: false + +jobs: + update-flake-lock: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Update flake.lock + run: nix flake update + + - name: Validate flake + run: nix flake check + + - name: Test build + run: nix build .#default + + - name: Commit flake.lock changes + run: | + if git diff --quiet -- flake.lock; then + echo "No flake.lock changes detected" + exit 0 + fi + + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add flake.lock + git commit -m "chore(nix): update flake.lock [skip ci]" + git push + + - name: Summary + if: always() + run: | + echo "## Flake Lock Update" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- ✅ flake update attempted" >> $GITHUB_STEP_SUMMARY + echo "- ✅ flake check executed" >> $GITHUB_STEP_SUMMARY + echo "- ✅ build executed" >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index 77231544..31ce0ec2 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,25 @@ Add a login endpoint --- +## For Nix Users + +If you use Nix, OAC includes a Home Manager flake module: + +```nix +{ + programs.opencode.oac = { + enable = true; + profile = "developer"; + }; +} +``` + +Import `oac.homeManagerModules.default`, then apply with `home-manager switch --flake .#my-user`. + +For the full setup, available `programs.opencode.oac.*` options, context reference behavior, bootstrap files, and built-in permission settings, see [docs/getting-started/nix-home-manager.md](docs/getting-started/nix-home-manager.md). + +--- + ## 💡 The Context System: Your Secret Weapon **The problem with AI code:** It doesn't match your patterns. You spend hours refactoring. diff --git a/docs/README.md b/docs/README.md index ba654a2c..dc21b1f9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Welcome to the OpenAgents Control documentation! This directory contains all doc - **[Installation Guide](getting-started/installation.md)** - Complete installation guide with collision handling - **[Collision Handling](getting-started/collision-handling.md)** - Quick reference for install script collision strategies +- **[Nix Home Manager Guide](getting-started/nix-home-manager.md)** - Install OAC with the flake module and configure `programs.opencode.oac` ### Features @@ -50,7 +51,8 @@ docs/ ├── README.md # This file ├── getting-started/ │ ├── installation.md # Installation guide -│ └── collision-handling.md # Collision handling reference +│ ├── collision-handling.md # Collision handling reference +│ └── nix-home-manager.md # Nix/Home Manager flake module guide ├── features/ │ ├── system-builder/ │ │ ├── README.md # System builder quick start @@ -70,6 +72,9 @@ docs/ **...install OpenAgents Control** → [Installation Guide](getting-started/installation.md) +**...install OpenAgents Control with Nix/Home Manager** +→ [Nix Home Manager Guide](getting-started/nix-home-manager.md) + **...understand collision handling** → [Collision Handling](getting-started/collision-handling.md) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 61949bef..62a6b01f 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -2,6 +2,8 @@ Complete guide to installing OpenAgents Control components using the automated installer script. +> Use Nix/Home Manager instead? See the [Nix Home Manager Guide](./nix-home-manager.md). + --- ## Quick Start diff --git a/docs/getting-started/nix-home-manager.md b/docs/getting-started/nix-home-manager.md new file mode 100644 index 00000000..2cb1cefc --- /dev/null +++ b/docs/getting-started/nix-home-manager.md @@ -0,0 +1,336 @@ +# OpenAgents Control with Nix Home Manager + +Use this guide if you want to install OpenAgents Control through the repository's built-in flake module instead of the shell installer. + +## When to use this + +This setup is a good fit if you already manage your OpenCode configuration with Nix and want OAC installation to be reproducible. + +If you do **not** already use Nix or Home Manager, the standard installer is simpler: + +- [Main README quick start](../../README.md#-quick-start) +- [Installation Guide](./installation.md) + +--- + +## Quick start + +Add OAC as a flake input and import its Home Manager module: + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + home-manager.url = "github:nix-community/home-manager"; + home-manager.inputs.nixpkgs.follows = "nixpkgs"; + + oac.url = "github:darrenhinde/OpenAgentsControl"; + }; + + outputs = { nixpkgs, home-manager, oac, ... }: { + homeConfigurations.my-user = home-manager.lib.homeManagerConfiguration { + pkgs = import nixpkgs { system = "x86_64-linux"; }; + modules = [ + oac.homeManagerModules.default + { + programs.opencode.oac = { + enable = true; + profile = "developer"; + }; + } + ]; + }; + }; +} +``` + +Apply the configuration: + +```bash +home-manager switch --flake .#my-user +``` + +--- + +## What the module does by default + +When you enable `programs.opencode.oac`, the module: + +- imports `oac.homeManagerModules.default` +- enables `programs.opencode` automatically +- uses this flake's source tree by default +- rewrites `.opencode/context` references in installed files by default +- points rewritten context references at the pinned source-backed context path by default +- installs the bootstrap context files required for reliable context discovery +- enables a default set of OpenCode permission rules for OAC context access and project `.tmp` access + +--- + +## Common configuration + +### Minimal configuration + +```nix +{ + programs.opencode.oac = { + enable = true; + profile = "developer"; + }; +} +``` + +### Add extra components + +```nix +{ + programs.opencode.oac = { + enable = true; + profile = "developer"; + components = [ + "agent:openagent" + "command:add-context" + "context:core/*" + ]; + }; +} +``` + +### Install only custom-selected components + +Set `profile = null` to skip profile presets and install only the components you specify: + +```nix +{ + programs.opencode.oac = { + enable = true; + profile = null; + components = [ + "agent:openagent" + "command:add-context" + ]; + }; +} +``` + +### Exclude components from a profile + +```nix +{ + programs.opencode.oac = { + enable = true; + profile = "full"; + excludeComponents = [ + "plugin:notify" + ]; + }; +} +``` + +--- + +## Important options + +### Core options + +| Option | Default | What it does | +| --- | --- | --- | +| `enable` | `false` | Turns on OAC installation through Home Manager. | +| `enableOpencode` | `true` | Enables `programs.opencode` automatically when OAC is enabled. | +| `source` | flake source | Uses a custom OAC source tree containing `registry.json` and `.opencode/`. | +| `profile` | `"developer"` | Installs a preset profile: `essential`, `developer`, `business`, `full`, or `advanced`. Use `null` for custom-only selection. | +| `components` | `[]` | Adds extra registry components on top of the selected profile. | +| `excludeComponents` | `[]` | Removes specific components after profile and dependency expansion. | +| `includeDependencies` | `true` | Includes transitive dependencies from `registry.json`. | + +### Installation layout options + +| Option | Default | What it does | +| --- | --- | --- | +| `targetRoot` | `"opencode"` | Base directory under `$XDG_CONFIG_HOME` where files are installed. | +| `layout.agent` | `"agent"` | Destination directory name for agent files. | +| `layout.command` | `"command"` | Destination directory name for command files. | +| `layout.context` | `"context"` | Destination directory name for context files. | +| `layout.tool` | `"tool"` | Destination directory name for tool files. | +| `layout.plugin` | `"plugin"` | Destination directory name for plugins. | +| `layout.skills` | `"skills"` | Destination directory name for skills. | +| `layout.config` | `""` | Destination directory for files that are not under `.opencode/`. Empty keeps them at the target root. | +| `pathOverrides` | `{}` | Overrides exact generated destination paths for specific source files. | +| `force` | `false` | Sets `xdg.configFile..force` for generated files. | + +### Context and rewrite options + +| Option | Default | What it does | +| --- | --- | --- | +| `rewriteContextReferences` | `true` | Rewrites `.opencode/context` references inside installed files. | +| `contextReferencePath` | `null` | Overrides the path used for rewritten context references. | +| `extraFiles` | `{}` | Adds extra files under `targetRoot` after generated profile files. | +| `overrides` | `{}` | Replaces final installed files under `targetRoot` after all generation steps. | + +### Permission and advanced options + +| Option | Default | What it does | +| --- | --- | --- | +| `enableBuiltinPermissions` | `true` | Enables the module's built-in OpenCode permission rules. | +| `allowOacContextRead` | `true` | Allows reads to the OAC context reference path, denies edits there, and requires approval for matching bash commands. | +| `allowTmpDirFullAccess` | `true` | Allows reads, edits, and built-in `ls` / `mkdir` patterns for project `.tmp`. | +| `installAdditionalPaths` | `false` | Installs profile `additionalPaths` recursively as XDG config files. | +| `additionalPathsPrefix` | `"additional"` | Target prefix used when `installAdditionalPaths = true`. | + +--- + +## Bootstrap context files + +These bootstrap files are installed unless explicitly excluded: + +- `$XDG_CONFIG_HOME/opencode/context/navigation.md` +- `$XDG_CONFIG_HOME/opencode/context/core/config/paths.json` + +These are canonical discovery files and do **not** follow `pathOverrides`. + +--- + +## Context reference behavior + +By default, `contextReferencePath = null` resolves to the pinned source-backed context directory: + +```nix +{ + programs.opencode.oac.contextReferencePath = null; +} +``` + +That means rewritten references point at the flake source / Nix store path for `.opencode/context`, not the Home Manager symlink tree. This avoids symlink traversal issues during context discovery. + +If you want rewritten references to point at your config directory instead: + +```nix +{ config, ... }: +{ + programs.opencode.oac.contextReferencePath = "${config.xdg.configHome}/opencode/context"; +} +``` + +If you want to keep installed file contents unchanged, disable rewriting: + +```nix +{ + programs.opencode.oac.rewriteContextReferences = false; +} +``` + +--- + +## Built-in permissions + +The module can install a default permission policy for OpenCode. + +### Default behavior + +- `enableBuiltinPermissions = true` +- `allowOacContextRead = true` +- `allowTmpDirFullAccess = true` + +This means: + +- the OAC context reference path is readable +- edits to that context reference path are denied +- bash commands targeting that path require approval +- project `.tmp` reads and edits are allowed +- built-in `ls` and `mkdir` patterns for `.tmp` are allowed + +### Example: customize permissions + +```nix +{ + programs.opencode.oac = { + enable = true; + enableBuiltinPermissions = true; + allowOacContextRead = true; + allowTmpDirFullAccess = false; + }; +} +``` + +### Example: disable module-managed permissions completely + +```nix +{ + programs.opencode.oac = { + enable = true; + enableBuiltinPermissions = false; + }; +} +``` + +Use that if you want to manage OpenCode permissions yourself through `programs.opencode.settings`. + +--- + +## Path customization examples + +### Change the install root + +```nix +{ + programs.opencode.oac.targetRoot = "opencode-team"; +} +``` + +This installs files under `$XDG_CONFIG_HOME/opencode-team/...`. + +### Override a generated file path + +```nix +{ + programs.opencode.oac.pathOverrides = { + ".opencode/agent/core/openagent.md" = "agents/openagent.md"; + }; +} +``` + +### Add your own files after profile generation + +```nix +{ + programs.opencode.oac.extraFiles = { + "context/project-intelligence/technical-domain.md" = ./technical-domain.md; + }; +} +``` + +### Replace a generated file entirely + +```nix +{ + programs.opencode.oac.overrides = { + "agent/core/openagent.md" = ./openagent.md; + }; +} +``` + +--- + +## Notes about the `advanced` profile + +The `advanced` profile can reference `additionalPaths` entries from `registry.json`. + +Those are **not** installed by default, which matches the current shell installer behavior. To include them: + +```nix +{ + programs.opencode.oac = { + enable = true; + profile = "advanced"; + installAdditionalPaths = true; + }; +} +``` + +--- + +## Related docs + +- [Main README](../../README.md) +- [Installation Guide](./installation.md) +- [Documentation Index](../README.md) +- [OpenCode CLI Docs](https://opencode.ai/docs) diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..dc819644 --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1781497404, + "narHash": "sha256-9GAF8sSsnkyCVCWkomXR0T+zdSxyUlfPt6neQidimdg=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "1285cd3d6882a9847f2d56ed5541b3350c8a6162", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1781074563, + "narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "home-manager": "home-manager", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..c165f2ca --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "Nix flake module for OpenAgentsControl (OAC) + Home Manager OpenCode"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + ... + }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + forEachSystem = + f: + builtins.listToAttrs ( + builtins.map (system: { + name = system; + value = f system; + }) systems + ); + in + { + homeManagerModules = { + oac = import ./nix/modules/home-manager/oac.nix { oacSource = self; }; + default = self.homeManagerModules.oac; + }; + + packages = forEachSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + default = pkgs.writeText "oac-flake-module" "OpenAgentsControl Home Manager module"; + } + ); + }; +} diff --git a/nix/modules/home-manager/oac.nix b/nix/modules/home-manager/oac.nix new file mode 100644 index 00000000..dd4aa037 --- /dev/null +++ b/nix/modules/home-manager/oac.nix @@ -0,0 +1,677 @@ +{ + oacSource ? null, +}: +{ lib, config, ... }: +let + inherit (lib) + mkEnableOption + mkIf + mkMerge + mkOption + mkDefault + types + nameValuePair + mapAttrs' + ; + + cfg = config.programs.opencode.oac; + + source = if cfg.source != null then cfg.source else oacSource; + + registry = + if source == null then null else builtins.fromJSON (builtins.readFile "${source}/registry.json"); + + components = if registry == null then { } else registry.components; + contexts = components.contexts or [ ]; + profiles = if registry == null then { } else registry.profiles; + + parseSpec = + spec: + let + match = builtins.match "([^:]+):(.+)" spec; + in + if match == null then + null + else + { + type = builtins.elemAt match 0; + id = builtins.elemAt match 1; + }; + + hasWildcard = spec: builtins.match ".*\*.*" spec != null; + + toRegistryKey = + type: + if type == "agent" then + "agents" + else if type == "subagent" then + "subagents" + else if type == "command" then + "commands" + else if type == "tool" then + "tools" + else if type == "plugin" then + "plugins" + else if type == "skill" then + "skills" + else if type == "context" then + "contexts" + else if type == "config" then + "config" + else if lib.hasSuffix "s" type then + type + else + "${type}s"; + + findById = + items: id: + let + matches = builtins.filter ( + item: (item.id or null) == id || lib.elem id (item.aliases or [ ]) + ) items; + in + if matches == [ ] then null else builtins.head matches; + + findContextByPathId = + id: + let + pathMd = ".opencode/context/${id}.md"; + pathAsIs = ".opencode/context/${id}"; + matches = builtins.filter ( + item: (item.path or "") == pathMd || (item.path or "") == pathAsIs + ) contexts; + in + if matches == [ ] then null else builtins.head matches; + + resolveComponent = + spec: + let + parsed = parseSpec spec; + in + if parsed == null then + null + else if parsed.type == "context" && lib.hasInfix "/" parsed.id then + findContextByPathId parsed.id + else + findById (components.${toRegistryKey parsed.type} or [ ]) parsed.id; + + stripMdSuffix = path: if lib.hasSuffix ".md" path then lib.removeSuffix ".md" path else path; + + expandContextPattern = + pattern: + let + match = builtins.match "(.*)\*.*" pattern; + prefixRaw = if match == null then pattern else builtins.elemAt match 0; + prefix = lib.removeSuffix "/" prefixRaw; + prefixWithSlash = if prefix == "" then "" else "${prefix}/"; + fullPrefix = ".opencode/context/${prefixWithSlash}"; + matches = builtins.filter (item: lib.hasPrefix fullPrefix (item.path or "")) contexts; + in + map (item: "context:${stripMdSuffix (lib.removePrefix ".opencode/context/" item.path)}") matches; + + expandSpec = + spec: + let + parsed = parseSpec spec; + in + if parsed == null then + [ ] + else if parsed.type == "context" && hasWildcard spec then + expandContextPattern parsed.id + else + [ spec ]; + + selectedProfile = + if cfg.profile == null then + null + else if builtins.hasAttr cfg.profile profiles then + builtins.getAttr cfg.profile profiles + else + null; + + profileComponents = if selectedProfile == null then [ ] else selectedProfile.components or [ ]; + profileAdditionalPaths = + if selectedProfile == null then [ ] else selectedProfile.additionalPaths or [ ]; + + bootstrapContextSpecs = [ + "context:root-navigation" + "context:context-paths-config" + ]; + + initialSpecs = lib.unique ( + lib.concatMap expandSpec (profileComponents ++ cfg.components ++ bootstrapContextSpecs) + ); + + dependenciesFor = + spec: + let + component = resolveComponent spec; + dependencies = if component == null then [ ] else component.dependencies or [ ]; + expandDependency = + dep: + let + parsed = parseSpec dep; + in + if parsed != null && parsed.type == "context" && hasWildcard dep then + expandContextPattern parsed.id + else + [ dep ]; + in + lib.unique (lib.concatMap expandDependency dependencies); + + resolveAllDependencies = + seen: queue: + if queue == [ ] then + seen + else + let + current = builtins.head queue; + rest = builtins.tail queue; + in + if lib.elem current seen then + resolveAllDependencies seen rest + else + let + deps = dependenciesFor current; + in + resolveAllDependencies (seen ++ [ current ]) (deps ++ rest); + + resolvedSpecs = + if cfg.includeDependencies then resolveAllDependencies [ ] initialSpecs else initialSpecs; + + finalSpecs = builtins.filter (spec: !(lib.elem spec cfg.excludeComponents)) resolvedSpecs; + + requiredBootstrapSpecs = builtins.filter ( + spec: !(lib.elem spec cfg.excludeComponents) + ) bootstrapContextSpecs; + + bootstrapResolvedComponents = map (spec: { + inherit spec; + component = resolveComponent spec; + }) requiredBootstrapSpecs; + + missingBootstrapComponents = builtins.filter ( + entry: entry.component == null + ) bootstrapResolvedComponents; + + bootstrapExpectedSourceFiles = lib.unique ( + lib.concatMap ( + entry: + if entry.component == null then + [ ] + else if entry.component ? files then + entry.component.files + else + [ entry.component.path ] + ) bootstrapResolvedComponents + ); + + sourceFiles = lib.unique ( + lib.concatMap ( + spec: + let + component = resolveComponent spec; + in + if component == null then + [ ] + else if component ? files then + component.files + else + [ component.path ] + ) finalSpecs + ); + + missingBootstrapSourcePaths = builtins.filter ( + path: !(builtins.pathExists "${source}/${path}") + ) bootstrapExpectedSourceFiles; + + layoutMap = { + agent = cfg.layout.agent; + command = cfg.layout.command; + context = cfg.layout.context; + tool = cfg.layout.tool; + plugin = cfg.layout.plugin; + skills = cfg.layout.skills; + config = cfg.layout.config; + }; + + mapSourceRelativePath = + sourcePath: + if lib.hasPrefix ".opencode/" sourcePath then + let + rel = lib.removePrefix ".opencode/" sourcePath; + segments = lib.splitString "/" rel; + headSegment = builtins.head segments; + tailSegments = builtins.tail segments; + mappedHead = + if builtins.hasAttr headSegment layoutMap then + builtins.getAttr headSegment layoutMap + else + headSegment; + mappedSegments = if mappedHead == "" then tailSegments else [ mappedHead ] ++ tailSegments; + in + lib.concatStringsSep "/" mappedSegments + else if cfg.layout.config == "" then + sourcePath + else + "${cfg.layout.config}/${sourcePath}"; + + mapRelativePath = + sourcePath: + if builtins.hasAttr sourcePath cfg.pathOverrides then + builtins.getAttr sourcePath cfg.pathOverrides + else + mapSourceRelativePath sourcePath; + + withTargetRoot = rel: if cfg.targetRoot == "" then rel else "${cfg.targetRoot}/${rel}"; + + contextReferencePath = + if cfg.contextReferencePath != null then + cfg.contextReferencePath + else + "${source}/.opencode/context"; + + contextReferencePathForPermissions = builtins.unsafeDiscardStringContext contextReferencePath; + + expandPermissionPaths = path: [ + path + "${path}/**" + ]; + + oacContextPermissionPaths = lib.unique ( + lib.concatMap expandPermissionPaths [ + contextReferencePathForPermissions + ] + ); + + oacContextBashPatterns = lib.unique [ + "* ${contextReferencePathForPermissions}*" + ]; + + tmpDirPermissionPaths = expandPermissionPaths ".tmp"; + + tmpDirBashPatterns = [ + "ls .tmp*" + "ls * .tmp*" + "ls \".tmp*" + "ls * \".tmp*" + "mkdir * tmp*" + "mkdir tmp*" + "mkdir \".tmp*" + "mkdir * \".tmp*" + ]; + + mkPermissionRules = + patterns: action: + builtins.listToAttrs (map (pattern: nameValuePair pattern (mkDefault action)) patterns); + + contextDirectoryPermissionSettings = { + permission = { + external_directory = mkPermissionRules oacContextPermissionPaths "allow"; + read = mkPermissionRules oacContextPermissionPaths "allow"; + edit = mkPermissionRules oacContextPermissionPaths "deny"; + bash = mkPermissionRules oacContextBashPatterns "ask"; + }; + }; + + tmpDirFullAccessSettings = { + permission = { + read = mkPermissionRules tmpDirPermissionPaths "allow"; + edit = mkPermissionRules tmpDirPermissionPaths "allow"; + bash = mkPermissionRules tmpDirBashPatterns "allow"; + }; + }; + + rewriteContextRefs = + text: + if cfg.rewriteContextReferences then + builtins.replaceStrings + [ + "@.opencode/context/" + ".opencode/context" + "~/.config/opencode/context" + ] + [ + "@${contextReferencePath}/" + contextReferencePath + contextReferencePath + ] + text + else + text; + + mkFileEntry = + pathMapper: sourcePath: + let + src = "${source}/${sourcePath}"; + destRel = pathMapper sourcePath; + key = withTargetRoot destRel; + fileValue = + if cfg.rewriteContextReferences then + { + text = rewriteContextRefs (builtins.readFile src); + } + else + { + source = src; + }; + in + nameValuePair key ({ force = cfg.force; } // fileValue); + + mkGeneratedFileEntry = mkFileEntry mapRelativePath; + + mkBootstrapFileEntry = mkFileEntry mapSourceRelativePath; + + generatedFileEntries = builtins.listToAttrs (map mkGeneratedFileEntry sourceFiles); + + bootstrapFileEntries = builtins.listToAttrs (map mkBootstrapFileEntry bootstrapExpectedSourceFiles); + + additionalPaths = if cfg.installAdditionalPaths then profileAdditionalPaths else [ ]; + + mkAdditionalPathEntry = + relPath: + let + cleanRel = lib.removeSuffix "/" relPath; + src = "${source}/${cleanRel}"; + destRel = + if cfg.additionalPathsPrefix == "" then cleanRel else "${cfg.additionalPathsPrefix}/${cleanRel}"; + key = withTargetRoot destRel; + base = { + source = src; + force = cfg.force; + }; + recursiveAttrs = if lib.pathIsDirectory src then { recursive = true; } else { }; + in + nameValuePair key (base // recursiveAttrs); + + additionalPathEntries = builtins.listToAttrs (map mkAdditionalPathEntry additionalPaths); + + mkUserEntry = + key: value: + nameValuePair (withTargetRoot key) ( + { force = cfg.force; } // (if lib.isPath value then { source = value; } else { text = value; }) + ); + + extraFileEntries = mapAttrs' mkUserEntry cfg.extraFiles; + overrideEntries = mapAttrs' mkUserEntry cfg.overrides; + + missingFiles = builtins.filter (path: !(builtins.pathExists "${source}/${path}")) sourceFiles; +in +{ + options.programs.opencode.oac = { + enable = mkEnableOption "OpenAgentsControl profile installation for Home Manager OpenCode"; + + enableOpencode = mkOption { + type = types.bool; + default = true; + description = "Enable `programs.opencode` automatically when OAC is enabled."; + }; + + enableBuiltinPermissions = mkOption { + type = types.bool; + default = true; + description = '' + Enable OAC's built-in OpenCode permission rules. + + When enabled, this module can add the OAC context read policy and the project `.tmp` + access policy according to `allowOacContextRead` and `allowTmpDirFullAccess`. Set this + to `false` to disable all permission rules generated by this module while still installing + OAC files and preserving any permission settings you define directly in + `programs.opencode.settings`. + ''; + }; + + allowOacContextRead = mkOption { + type = types.bool; + default = true; + description = '' + Add default OpenCode permission rules for the OAC context reference path. + + This allows reads to the pinned OAC context path while denying edits and requiring + `ask` approval for bash commands that target that path. Set to `false` to disable + this policy. This option only has an effect when `enableBuiltinPermissions` is true. + ''; + }; + + allowTmpDirFullAccess = mkOption { + type = types.bool; + default = true; + description = '' + Add default OpenCode permission rules for the project `.tmp` directory. + + This allows reads and edits for both `.tmp` and `.tmp/**` and adds allow rules for the + built-in `ls` and `mkdir` command patterns used to inspect or create `.tmp` directories. + Set to `false` to disable this policy. This option only has an effect when + `enableBuiltinPermissions` is true. + ''; + }; + + source = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + OAC source tree containing `registry.json` and `.opencode/` files. + + By default this uses the source tree of this flake. + ''; + }; + + profile = mkOption { + type = types.nullOr ( + types.enum [ + "essential" + "developer" + "business" + "full" + "advanced" + ] + ); + default = "developer"; + description = '' + Profile to install from OAC `registry.json`. + + Set to `null` for custom-only component selection. + ''; + }; + + components = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ + "agent:openagent" + "command:add-context" + "context:core/*" + ]; + description = "Extra components to install on top of the selected profile."; + }; + + excludeComponents = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "plugin:notify" ]; + description = "Component specs to exclude after profile/dependency expansion."; + }; + + includeDependencies = mkOption { + type = types.bool; + default = true; + description = "Resolve and include transitive dependencies from `registry.json`."; + }; + + targetRoot = mkOption { + type = types.str; + default = "opencode"; + description = '' + Base directory under `$XDG_CONFIG_HOME` where OAC files are installed. + + Example: `opencode` -> `$XDG_CONFIG_HOME/opencode/...` + ''; + }; + + layout = { + agent = mkOption { + type = types.str; + default = "agent"; + description = "Directory name for OAC agent files."; + }; + + command = mkOption { + type = types.str; + default = "command"; + description = "Directory name for OAC command files."; + }; + + context = mkOption { + type = types.str; + default = "context"; + description = "Directory name for OAC context files."; + }; + + tool = mkOption { + type = types.str; + default = "tool"; + description = "Directory name for OAC tools."; + }; + + plugin = mkOption { + type = types.str; + default = "plugin"; + description = "Directory name for OAC plugins."; + }; + + skills = mkOption { + type = types.str; + default = "skills"; + description = "Directory name for OAC skills."; + }; + + config = mkOption { + type = types.str; + default = ""; + description = '' + Directory name for config-root files that are not under `.opencode/` (for example `env.example`). + + Empty string keeps them at the target root. + ''; + }; + }; + + pathOverrides = mkOption { + type = types.attrsOf types.str; + default = { }; + example = { + ".opencode/agent/core/openagent.md" = "agents/openagent.md"; + }; + description = "Exact source-path to destination-path overrides for generated OAC files."; + }; + + rewriteContextReferences = mkOption { + type = types.bool; + default = true; + description = '' + Rewrite `.opencode/context` references in installed file content to a global config path, + mirroring the installer's global path rewrite behavior. + ''; + }; + + contextReferencePath = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Explicit path used for rewritten context references. + + When null, defaults to the pinned OAC source context directory in the Nix store. + This keeps context discovery on immutable real paths instead of Home Manager symlinks. + ''; + }; + + installAdditionalPaths = mkOption { + type = types.bool; + default = false; + description = '' + Install profile `additionalPaths` entries (currently used by `advanced`) as recursive xdg config files. + + The install script currently reports these as manual downloads. + ''; + }; + + additionalPathsPrefix = mkOption { + type = types.str; + default = "additional"; + description = "Destination prefix under `targetRoot` for profile `additionalPaths` when enabled."; + }; + + force = mkOption { + type = types.bool; + default = false; + description = "Set `xdg.configFile..force` for generated OAC files."; + }; + + extraFiles = mkOption { + type = types.attrsOf (types.either types.lines types.path); + default = { }; + example = { + "context/project-intelligence/technical-domain.md" = ./technical-domain.md; + }; + description = "Additional files installed under `targetRoot` after generated profile files."; + }; + + overrides = mkOption { + type = types.attrsOf (types.either types.lines types.path); + default = { }; + example = { + "agent/core/openagent.md" = ./openagent.md; + }; + description = "Final override files/text installed under `targetRoot` (applied last)."; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = source != null; + message = "programs.opencode.oac.source is null and no default oacSource was provided by the flake module."; + } + { + assertion = builtins.pathExists "${source}/registry.json"; + message = "OAC source does not contain registry.json."; + } + { + assertion = missingFiles == [ ]; + message = + "Some resolved OAC files were not found in source: " + lib.concatStringsSep ", " missingFiles; + } + { + assertion = missingBootstrapComponents == [ ]; + message = + "Required bootstrap components could not be resolved from registry/source: " + + lib.concatStringsSep ", " (map (entry: entry.spec) missingBootstrapComponents); + } + { + assertion = missingBootstrapSourcePaths == [ ]; + message = + "Resolved bootstrap components reference source paths that do not exist: " + + lib.concatStringsSep ", " missingBootstrapSourcePaths; + } + ]; + + warnings = + lib.optional + (cfg.profile == "advanced" && profileAdditionalPaths != [ ] && !cfg.installAdditionalPaths) + "programs.opencode.oac.profile=advanced includes additionalPaths in registry.json, but installAdditionalPaths=false so they are skipped (matching install.sh behavior)."; + + programs.opencode.enable = mkIf cfg.enableOpencode (mkDefault true); + + programs.opencode.settings = mkMerge [ + (mkIf (cfg.enableBuiltinPermissions && cfg.allowOacContextRead) contextDirectoryPermissionSettings) + (mkIf (cfg.enableBuiltinPermissions && cfg.allowTmpDirFullAccess) tmpDirFullAccessSettings) + ]; + + xdg.configFile = + generatedFileEntries + // bootstrapFileEntries + // additionalPathEntries + // extraFileEntries + // overrideEntries; + }; +}