feat: make config schema-only

- replace manual config builders with schema-typed config merge
- split home-manager module into smaller files (<400 LOC)
- update docs + template for new config shape
- stub config-options check for config.lib.file
- document no-deprecation policy

Tests: not run (CI pending)
This commit is contained in:
DJTBOT
2026-01-30 23:40:59 +01:00
parent 8309a27f01
commit 952a04f349
12 changed files with 1151 additions and 1256 deletions
+2
View File
@@ -12,6 +12,7 @@ Defaults:
- Declarative config only.
- Batteriesincluded install is the baseline.
- Breaking changes are acceptable pre1.0.0 (move fast, keep docs accurate).
- No deprecations; use breaking changes.
- NO INLINE SCRIPTS EVER.
- NEVER send any message (iMessage, email, SMS, etc.) without explicit user confirmation:
- Always show the full message text and ask: “Im going to send this: <message>. Send? (y/n)”
@@ -57,3 +58,4 @@ Nix file policy:
- No inline file contents in Nix code, ever.
- Always reference explicit file paths (keep docs as real files in the repo).
- No inline scripts in Nix code, ever (use repo scripts and reference their paths).
- No files longer than 400 LOC without user alignment; refactor as you go.
+33 -26
View File
@@ -428,12 +428,14 @@ Deliverables: flake output, env overrides, AGENTS.md, skill update.
## Configuration
> **Note:** You probably don't need to write this yourself. Your AI agent handles this when you use the [Quick Start](#quick-start) copypasta. These examples are here for reference.
>
> **Breaking change:** Nix now only emits config from `programs.openclaw.config` / `instances.<name>.config` (schema-typed). Legacy provider/routing/agent options are removed.
### What Openclaw needs (minimum)
1. **Telegram bot token** - create via [@BotFather](https://t.me/BotFather), save to a file
2. **Your Telegram user ID** - get from [@userinfobot](https://t.me/userinfobot)
3. **Anthropic API key** - from [console.anthropic.com](https://console.anthropic.com), save to a file
1. **Telegram bot token file** - create via [@BotFather](https://t.me/BotFather), set `channels.telegram.tokenFile`
2. **Your Telegram user ID** - get from [@userinfobot](https://t.me/userinfobot), set `channels.telegram.allowFrom`
3. **Provider API keys** - set via environment (e.g., `ANTHROPIC_API_KEY`) or `config.env.vars` (avoid secrets in store)
That's it. Everything else has sensible defaults.
@@ -445,13 +447,11 @@ The simplest setup:
{
programs.openclaw = {
enable = true;
providers.telegram = {
enable = true;
botTokenFile = "/run/agenix/telegram-bot-token"; # any file path works
allowFrom = [ 12345678 ]; # your Telegram user ID
};
providers.anthropic = {
apiKeyFile = "/run/agenix/anthropic-api-key"; # any file path works
config = {
channels.telegram = {
tokenFile = "/run/agenix/telegram-bot-token"; # any file path works
allowFrom = [ 12345678 ]; # your Telegram user ID
};
};
# Built-ins (tools + skills) shipped via nix-steipete-tools.
@@ -472,15 +472,9 @@ Uses `instances.default` to unlock per-group mention rules. If `instances` is se
{
programs.openclaw = {
documents = ./documents;
instances.default = {
enable = true;
package = pkgs.openclaw; # batteries-included
stateDir = "~/.openclaw";
workspaceDir = "~/.openclaw/workspace";
providers.telegram = {
enable = true;
botTokenFile = "/run/agenix/telegram-bot-token";
config = {
channels.telegram = {
tokenFile = "/run/agenix/telegram-bot-token";
allowFrom = [
12345678 # you (DM)
-1001234567890 # couples group (no @mention required)
@@ -492,9 +486,13 @@ Uses `instances.default` to unlock per-group mention rules. If `instances` is se
"-1002345678901" = { requireMention = true; }; # noisy group
};
};
};
providers.anthropic.apiKeyFile = "/run/agenix/anthropic-api-key";
instances.default = {
enable = true;
package = pkgs.openclaw; # batteries-included
stateDir = "~/.openclaw";
workspaceDir = "~/.openclaw/workspace";
launchd.enable = true;
# Plugins (prod: pinned GitHub). Built-ins are via nix-steipete-tools.
@@ -544,14 +542,23 @@ inputs = {
};
let
prodConfig = {
channels.telegram = {
tokenFile = "/run/agenix/telegram-prod";
allowFrom = [ 12345678 ];
};
};
devConfig = {
channels.telegram = {
tokenFile = "/run/agenix/telegram-dev";
allowFrom = [ 12345678 ];
};
};
prod = {
enable = true;
# Prod gateway pin (comes from nix-openclaw input @ v0.1.0 above).
package = inputs.nix-openclaw.packages.${pkgs.system}.openclaw-gateway;
providers.telegram.enable = true;
providers.telegram.botTokenFile = "/run/agenix/telegram-prod";
providers.telegram.allowFrom = [ 12345678 ];
providers.anthropic.apiKeyFile = "/run/agenix/anthropic-api-key";
config = prodConfig;
plugins = [ { source = "github:owner/your-plugin"; } ];
};
in {
@@ -564,7 +571,7 @@ in {
dev = prod // {
# Dev uses the same pinned macOS app (from nix-openclaw input),
# but overrides the gateway package to a local checkout.
providers.telegram.botTokenFile = "/run/agenix/telegram-dev";
config = devConfig;
gatewayPort = 18790;
# Local gateway checkout (path). App stays pinned.
gatewayPath = "/Users/you/code/openclaw";
+6
View File
@@ -67,6 +67,11 @@ let
type = lib.types.bool;
default = false;
};
lib = lib.mkOption {
type = lib.types.attrs;
default = {};
};
};
};
@@ -78,6 +83,7 @@ let
config = {
home.homeDirectory = "/tmp";
programs.git.enable = false;
lib.file.mkOutOfStoreSymlink = path: path;
programs.openclaw = {
enable = true;
launchd.enable = false;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,245 @@
{ config, lib, pkgs, ... }:
let
openclawLib = import ./lib.nix { inherit config lib pkgs; };
cfg = openclawLib.cfg;
homeDir = openclawLib.homeDir;
appPackage = openclawLib.appPackage;
defaultInstance = {
enable = cfg.enable;
package = openclawLib.defaultPackage;
stateDir = cfg.stateDir;
workspaceDir = cfg.workspaceDir;
configPath = "${cfg.stateDir}/openclaw.json";
logPath = "/tmp/openclaw/openclaw-gateway.log";
gatewayPort = 18789;
gatewayPath = null;
gatewayPnpmDepsHash = lib.fakeHash;
launchd = cfg.launchd;
systemd = cfg.systemd;
plugins = openclawLib.effectivePlugins;
config = {};
appDefaults = {
enable = true;
attachExistingOnly = true;
};
app = {
install = {
enable = false;
path = "${homeDir}/Applications/Openclaw.app";
};
};
};
instances = if cfg.instances != {}
then cfg.instances
else lib.optionalAttrs cfg.enable { default = defaultInstance; };
enabledInstances = lib.filterAttrs (_: inst: inst.enable) instances;
plugins = import ./plugins.nix { inherit lib pkgs openclawLib enabledInstances; };
files = import ./files.nix {
inherit config lib pkgs openclawLib enabledInstances plugins;
};
mkInstanceConfig = name: inst: let
gatewayPackage =
if inst.gatewayPath != null then
pkgs.callPackage ../../packages/openclaw-gateway.nix {
gatewaySrc = builtins.path {
path = inst.gatewayPath;
name = "openclaw-gateway-src";
};
pnpmDepsHash = inst.gatewayPnpmDepsHash;
}
else
inst.package;
pluginPackages = plugins.pluginPackagesFor name;
pluginEnvAll = plugins.pluginEnvAllFor name;
mergedConfig = lib.recursiveUpdate cfg.config inst.config;
configJson = builtins.toJSON mergedConfig;
configFile = pkgs.writeText "openclaw-${name}.json" configJson;
gatewayWrapper = pkgs.writeShellScriptBin "openclaw-gateway-${name}" ''
set -euo pipefail
if [ -n "${lib.makeBinPath pluginPackages}" ]; then
export PATH="${lib.makeBinPath pluginPackages}:$PATH"
fi
${lib.concatStringsSep "\n" (map (entry: "export ${entry.key}=\"${entry.value}\"") pluginEnvAll)}
exec "${gatewayPackage}/bin/openclaw" "$@"
'';
appDefaults = lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && inst.appDefaults.enable) {
attachExistingOnly = inst.appDefaults.attachExistingOnly;
gatewayPort = inst.gatewayPort;
};
appInstall = if !(pkgs.stdenv.hostPlatform.isDarwin && inst.app.install.enable && appPackage != null) then
null
else {
name = lib.removePrefix "${homeDir}/" inst.app.install.path;
value = {
source = "${appPackage}/Applications/Openclaw.app";
recursive = true;
force = true;
};
};
package = gatewayPackage;
in {
homeFile = {
name = openclawLib.toRelative inst.configPath;
value = { text = configJson; };
};
configFile = configFile;
configPath = inst.configPath;
dirs = [ inst.stateDir inst.workspaceDir (builtins.dirOf inst.logPath) ];
launchdAgent = lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && inst.launchd.enable) {
"${inst.launchd.label}" = {
enable = true;
config = {
Label = inst.launchd.label;
ProgramArguments = [
"${gatewayWrapper}/bin/openclaw-gateway-${name}"
"gateway"
"--port"
"${toString inst.gatewayPort}"
];
RunAtLoad = true;
KeepAlive = true;
WorkingDirectory = inst.stateDir;
StandardOutPath = inst.logPath;
StandardErrorPath = inst.logPath;
EnvironmentVariables = {
HOME = homeDir;
MOLTBOT_CONFIG_PATH = inst.configPath;
MOLTBOT_STATE_DIR = inst.stateDir;
MOLTBOT_IMAGE_BACKEND = "sips";
MOLTBOT_NIX_MODE = "1";
CLAWDBOT_CONFIG_PATH = inst.configPath;
CLAWDBOT_STATE_DIR = inst.stateDir;
CLAWDBOT_IMAGE_BACKEND = "sips";
CLAWDBOT_NIX_MODE = "1";
};
};
};
};
systemdService = lib.optionalAttrs (pkgs.stdenv.hostPlatform.isLinux && inst.systemd.enable) {
"${inst.systemd.unitName}" = {
Unit = {
Description = "Openclaw gateway (${name})";
};
Service = {
ExecStart = "${gatewayWrapper}/bin/openclaw-gateway-${name} gateway --port ${toString inst.gatewayPort}";
WorkingDirectory = inst.stateDir;
Restart = "always";
RestartSec = "1s";
Environment = [
"HOME=${homeDir}"
"MOLTBOT_CONFIG_PATH=${inst.configPath}"
"MOLTBOT_STATE_DIR=${inst.stateDir}"
"MOLTBOT_NIX_MODE=1"
"CLAWDBOT_CONFIG_PATH=${inst.configPath}"
"CLAWDBOT_STATE_DIR=${inst.stateDir}"
"CLAWDBOT_NIX_MODE=1"
];
StandardOutput = "append:${inst.logPath}";
StandardError = "append:${inst.logPath}";
};
};
};
appDefaults = appDefaults;
appInstall = appInstall;
package = package;
};
instanceConfigs = lib.mapAttrsToList mkInstanceConfig enabledInstances;
appInstalls = lib.filter (item: item != null) (map (item: item.appInstall) instanceConfigs);
appDefaults = lib.foldl' (acc: item: lib.recursiveUpdate acc item.appDefaults) {} instanceConfigs;
appDefaultsEnabled = lib.filterAttrs (_: inst: inst.appDefaults.enable) enabledInstances;
in {
config = lib.mkIf (cfg.enable || cfg.instances != {}) {
assertions = [
{
assertion = lib.length (lib.attrNames appDefaultsEnabled) <= 1;
message = "Only one Openclaw instance may enable appDefaults.";
}
] ++ files.documentsAssertions ++ files.skillAssertions ++ plugins.pluginAssertions ++ plugins.pluginSkillAssertions;
home.packages = lib.unique (
(map (item: item.package) instanceConfigs)
++ (lib.optionals cfg.exposePluginPackages plugins.pluginPackagesAll)
);
home.file =
(lib.listToAttrs (map (item: item.homeFile) instanceConfigs))
// (lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && appPackage != null && cfg.installApp) {
"Applications/Openclaw.app" = {
source = "${appPackage}/Applications/Openclaw.app";
recursive = true;
force = true;
};
})
// (lib.listToAttrs appInstalls)
// files.documentsFiles
// files.skillFiles
// plugins.pluginSkillsFiles
// plugins.pluginConfigFiles
// (lib.optionalAttrs cfg.reloadScript.enable {
".local/bin/openclaw-reload" = {
executable = true;
source = ../openclaw-reload.sh;
};
});
home.activation.openclawDocumentGuard = lib.mkIf files.documentsEnabled (
lib.hm.dag.entryBefore [ "writeBoundary" ] ''
set -euo pipefail
${files.documentsGuard}
''
);
home.activation.openclawDirs = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
/bin/mkdir -p ${lib.concatStringsSep " " (lib.concatMap (item: item.dirs) instanceConfigs)}
${lib.optionalString (plugins.pluginStateDirsAll != []) "/bin/mkdir -p ${lib.concatStringsSep " " plugins.pluginStateDirsAll}"}
'';
home.activation.openclawConfigFiles = lib.hm.dag.entryAfter [ "openclawDirs" ] ''
set -euo pipefail
${lib.concatStringsSep "\n" (map (item: "/bin/ln -sfn ${item.configFile} ${item.configPath}") instanceConfigs)}
'';
home.activation.openclawPluginGuard = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
set -euo pipefail
${plugins.pluginGuards}
'';
home.activation.openclawAppDefaults = lib.mkIf (pkgs.stdenv.hostPlatform.isDarwin && appDefaults != {}) (
lib.hm.dag.entryAfter [ "writeBoundary" ] ''
/usr/bin/defaults write com.steipete.Openclaw openclaw.gateway.attachExistingOnly -bool ${lib.boolToString (appDefaults.attachExistingOnly or true)}
/usr/bin/defaults write com.steipete.Openclaw gatewayPort -int ${toString (appDefaults.gatewayPort or 18789)}
''
);
home.activation.openclawLaunchdRelink = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin (
lib.hm.dag.entryAfter [ "linkGeneration" ] ''
/usr/bin/env bash ${../openclaw-launchd-relink.sh}
''
);
systemd.user.services = lib.mkIf pkgs.stdenv.hostPlatform.isLinux (
lib.mkMerge (map (item: item.systemdService) instanceConfigs)
);
launchd.agents = lib.mkMerge (map (item: item.launchdAgent) instanceConfigs);
};
}
@@ -0,0 +1,7 @@
{ config, lib, pkgs, ... }:
{
imports = [
./options.nix
./config.nix
];
}
+207
View File
@@ -0,0 +1,207 @@
{ config, lib, pkgs, openclawLib, enabledInstances, plugins }:
let
cfg = openclawLib.cfg;
resolvePath = openclawLib.resolvePath;
toRelative = openclawLib.toRelative;
toolSets = openclawLib.toolSets;
documentsEnabled = cfg.documents != null;
instanceWorkspaceDirs = map (inst: resolvePath inst.workspaceDir) (lib.attrValues enabledInstances);
renderSkill = skill:
let
frontmatterLines = [
"---"
"name: ${skill.name}"
"description: ${skill.description or ""}"
]
++ lib.optionals (skill ? homepage && skill.homepage != null) [ "homepage: ${skill.homepage}" ]
++ lib.optionals (skill ? openclaw && skill.openclaw != null) [
"openclaw:"
" ${builtins.toJSON skill.openclaw}"
]
++ [ "---" ];
frontmatter = lib.concatStringsSep "\n" frontmatterLines;
body = if skill ? body then skill.body else "";
in
"${frontmatter}\n\n${body}\n";
skillAssertions =
let
names = map (skill: skill.name) cfg.skills;
nameCounts = lib.foldl' (acc: name: acc // { "${name}" = (acc.${name} or 0) + 1; }) {} names;
duplicateNames = lib.attrNames (lib.filterAttrs (_: v: v > 1) nameCounts);
in
if duplicateNames == [] then [] else [
{
assertion = false;
message = "programs.openclaw.skills has duplicate names: ${lib.concatStringsSep ", " duplicateNames}";
}
];
skillFiles =
let
entriesForInstance = instName: inst:
let
base = "${toRelative (resolvePath inst.workspaceDir)}/skills";
entryFor = skill:
let
mode = skill.mode or "symlink";
source = if skill ? source && skill.source != null then resolvePath skill.source else null;
in
if mode == "inline" then
{
name = "${base}/${skill.name}/SKILL.md";
value = { text = renderSkill skill; };
}
else if mode == "copy" then
{
name = "${base}/${skill.name}";
value = {
source = builtins.path {
name = "openclaw-skill-${skill.name}";
path = source;
};
recursive = true;
};
}
else
{
name = "${base}/${skill.name}";
value = {
source = config.lib.file.mkOutOfStoreSymlink source;
recursive = true;
};
};
in
map entryFor cfg.skills;
in
lib.listToAttrs (lib.flatten (lib.mapAttrsToList entriesForInstance enabledInstances));
documentsAssertions = lib.optionals documentsEnabled [
{
assertion = builtins.pathExists cfg.documents;
message = "programs.openclaw.documents must point to an existing directory.";
}
{
assertion = builtins.pathExists (cfg.documents + "/AGENTS.md");
message = "Missing AGENTS.md in programs.openclaw.documents.";
}
{
assertion = builtins.pathExists (cfg.documents + "/SOUL.md");
message = "Missing SOUL.md in programs.openclaw.documents.";
}
{
assertion = builtins.pathExists (cfg.documents + "/TOOLS.md");
message = "Missing TOOLS.md in programs.openclaw.documents.";
}
];
documentsGuard =
lib.optionalString documentsEnabled (
let
guardLine = file: ''
if [ -e "${file}" ] && [ ! -L "${file}" ]; then
echo "Openclaw documents are managed by Nix. Please adopt ${file} into your documents directory and re-run." >&2
exit 1
fi
'';
guardForDir = dir: ''
${guardLine "${dir}/AGENTS.md"}
${guardLine "${dir}/SOUL.md"}
${guardLine "${dir}/TOOLS.md"}
'';
in
lib.concatStringsSep "\n" (map guardForDir instanceWorkspaceDirs)
);
toolsReport =
if documentsEnabled then
let
toolNames = toolSets.toolNames or [];
renderPkgName = pkg:
if pkg ? pname then pkg.pname else lib.getName pkg;
renderPlugin = plugin:
let
pkgNames = map renderPkgName (lib.filter (p: p != null) plugin.packages);
pkgSuffix =
if pkgNames == []
then ""
else " " + (lib.concatStringsSep ", " pkgNames);
in
"- " + plugin.name + pkgSuffix + " (" + plugin.source + ")";
pluginLinesFor = instName: inst:
let
pluginsForInstance = plugins.resolvedPluginsByInstance.${instName} or [];
lines = if pluginsForInstance == [] then [ "- (none)" ] else map renderPlugin pluginsForInstance;
in
[
""
"### Instance: ${instName}"
] ++ lines;
reportLines =
[
"<!-- BEGIN NIX-REPORT -->"
""
"## Nix-managed tools"
""
"### Built-in toolchain"
]
++ (if toolNames == [] then [ "- (none)" ] else map (name: "- " + name) toolNames)
++ [
""
"## Nix-managed plugin report"
""
"Plugins enabled per instance (last-wins on name collisions):"
]
++ lib.concatLists (lib.mapAttrsToList pluginLinesFor enabledInstances)
++ [
""
"Tools: batteries-included toolchain + plugin-provided CLIs."
""
"<!-- END NIX-REPORT -->"
];
reportText = lib.concatStringsSep "\n" reportLines;
in
pkgs.writeText "openclaw-tools-report.md" reportText
else
null;
toolsWithReport =
if documentsEnabled then
pkgs.runCommand "openclaw-tools-with-report.md" {} ''
cat ${cfg.documents + "/TOOLS.md"} > $out
echo "" >> $out
cat ${toolsReport} >> $out
''
else
null;
documentsFiles =
if documentsEnabled then
let
mkDocFiles = dir: {
"${toRelative (dir + "/AGENTS.md")}" = {
source = cfg.documents + "/AGENTS.md";
};
"${toRelative (dir + "/SOUL.md")}" = {
source = cfg.documents + "/SOUL.md";
};
"${toRelative (dir + "/TOOLS.md")}" = {
source = toolsWithReport;
};
};
in
lib.mkMerge (map mkDocFiles instanceWorkspaceDirs)
else
{};
in {
inherit
documentsEnabled
documentsAssertions
documentsGuard
documentsFiles
skillAssertions
skillFiles;
}
+72
View File
@@ -0,0 +1,72 @@
{ config, lib, pkgs }:
let
cfg = config.programs.openclaw;
homeDir = config.home.homeDirectory;
autoExcludeTools = lib.optionals config.programs.git.enable [ "git" ];
effectiveExcludeTools = lib.unique (cfg.excludeTools ++ autoExcludeTools);
toolOverrides = {
toolNamesOverride = cfg.toolNames;
excludeToolNames = effectiveExcludeTools;
};
toolOverridesEnabled = cfg.toolNames != null || effectiveExcludeTools != [];
toolSets = import ../../tools/extended.nix ({ inherit pkgs; } // toolOverrides);
defaultPackage =
if toolOverridesEnabled && cfg.package == pkgs.openclaw
then (pkgs.openclawPackages.withTools toolOverrides).openclaw
else cfg.package;
appPackage = if cfg.appPackage != null then cfg.appPackage else defaultPackage;
generatedConfigOptions = import ../../generated/openclaw-config-options.nix { lib = lib; };
firstPartySources = let
stepieteRev = "76188dc559493e752f23a53d4563b77dea7c0428";
stepieteNarHash = "sha256-MlQ4G7MIkFDIvekIiucJEZP0FmeyOpwadI1dCq0EmSk=";
stepiete = tool:
"github:openclaw/nix-steipete-tools?dir=tools/${tool}&rev=${stepieteRev}&narHash=${stepieteNarHash}";
in {
summarize = stepiete "summarize";
peekaboo = stepiete "peekaboo";
oracle = stepiete "oracle";
poltergeist = stepiete "poltergeist";
sag = stepiete "sag";
camsnap = stepiete "camsnap";
gogcli = stepiete "gogcli";
bird = stepiete "bird";
sonoscli = stepiete "sonoscli";
imsg = stepiete "imsg";
};
firstPartyPlugins = lib.filter (p: p != null) (lib.mapAttrsToList (name: source:
if (cfg.firstParty.${name}.enable or false) then { inherit source; } else null
) firstPartySources);
effectivePlugins = cfg.plugins ++ firstPartyPlugins;
resolvePath = p:
if lib.hasPrefix "~/" p then
"${homeDir}/${lib.removePrefix "~/" p}"
else
p;
toRelative = p:
if lib.hasPrefix "${homeDir}/" p then
lib.removePrefix "${homeDir}/" p
else
p;
in {
inherit
cfg
homeDir
toolOverrides
toolOverridesEnabled
toolSets
defaultPackage
appPackage
generatedConfigOptions
firstPartySources
firstPartyPlugins
effectivePlugins
resolvePath
toRelative;
}
@@ -0,0 +1,142 @@
{ lib, openclawLib }:
{ name, config, ... }:
{
options = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable this Openclaw instance.";
};
package = lib.mkOption {
type = lib.types.package;
default = openclawLib.defaultPackage;
description = "Openclaw batteries-included package.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = if name == "default"
then "${openclawLib.homeDir}/.openclaw"
else "${openclawLib.homeDir}/.openclaw-${name}";
description = "State directory for this Openclaw instance (logs, sessions, config).";
};
workspaceDir = lib.mkOption {
type = lib.types.str;
default = "${config.stateDir}/workspace";
description = "Workspace directory for this Openclaw instance.";
};
configPath = lib.mkOption {
type = lib.types.str;
default = "${config.stateDir}/openclaw.json";
description = "Path to generated Openclaw config JSON.";
};
logPath = lib.mkOption {
type = lib.types.str;
default = if name == "default"
then "/tmp/openclaw/openclaw-gateway.log"
else "/tmp/openclaw/openclaw-gateway-${name}.log";
description = "Log path for this Openclaw gateway instance.";
};
gatewayPort = lib.mkOption {
type = lib.types.int;
default = 18789;
description = "Gateway port used by the Openclaw desktop app.";
};
gatewayPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Local path to Openclaw gateway source (dev only).";
};
gatewayPnpmDepsHash = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = lib.fakeHash;
description = "pnpmDeps hash for local gateway builds (omit to let Nix suggest the correct hash).";
};
plugins = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
source = lib.mkOption {
type = lib.types.str;
description = "Plugin source pointer (e.g., github:owner/repo or path:/...).";
};
config = lib.mkOption {
type = lib.types.attrs;
default = {};
description = "Plugin-specific configuration (env/files/etc).";
};
};
});
default = openclawLib.effectivePlugins;
description = "Plugins enabled for this instance (includes first-party toggles).";
};
config = lib.mkOption {
type = lib.types.submodule { options = openclawLib.generatedConfigOptions; };
default = {};
description = "Openclaw config (schema-typed).";
};
launchd.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Run Openclaw gateway via launchd (macOS).";
};
launchd.label = lib.mkOption {
type = lib.types.str;
default = if name == "default"
then "com.steipete.openclaw.gateway"
else "com.steipete.openclaw.gateway.${name}";
description = "launchd label for this instance.";
};
systemd.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Run Openclaw gateway via systemd user service (Linux).";
};
systemd.unitName = lib.mkOption {
type = lib.types.str;
default = if name == "default"
then "openclaw-gateway"
else "openclaw-gateway-${name}";
description = "systemd user service unit name for this instance.";
};
app.install.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Install Openclaw.app for this instance.";
};
app.install.path = lib.mkOption {
type = lib.types.str;
default = "${openclawLib.homeDir}/Applications/Openclaw.app";
description = "Destination path for this instance's Openclaw.app bundle.";
};
appDefaults = {
enable = lib.mkOption {
type = lib.types.bool;
default = name == "default";
description = "Configure macOS app defaults for this instance.";
};
attachExistingOnly = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Attach existing gateway only (macOS).";
};
};
};
}
@@ -0,0 +1,212 @@
{ config, lib, pkgs, ... }:
let
openclawLib = import ./lib.nix { inherit config lib pkgs; };
instanceModule = import ./options-instance.nix { inherit lib openclawLib; };
mkSkillOption = lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Skill name (used as the directory name).";
};
description = lib.mkOption {
type = lib.types.str;
default = "";
description = "Short description for the skill frontmatter.";
};
homepage = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional homepage URL for the skill frontmatter.";
};
body = lib.mkOption {
type = lib.types.str;
default = "";
description = "Optional skill body (markdown).";
};
openclaw = lib.mkOption {
type = lib.types.nullOr lib.types.attrs;
default = null;
description = "Optional openclaw metadata for the skill frontmatter.";
};
mode = lib.mkOption {
type = lib.types.enum [ "symlink" "copy" "inline" ];
default = "symlink";
description = "Install mode for the skill (symlink/copy/inline).";
};
source = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Source path for the skill (required for symlink/copy).";
};
};
};
in {
options.programs.openclaw = {
enable = lib.mkEnableOption "Openclaw (batteries-included)";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.openclaw;
description = "Openclaw batteries-included package.";
};
toolNames = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "Override the built-in toolchain names (see nix/tools/extended.nix).";
};
excludeTools = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "Tool names to remove from the built-in toolchain.";
};
appPackage = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = "Optional Openclaw app package (defaults to package if unset).";
};
installApp = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Install Openclaw.app at the default location.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "${openclawLib.homeDir}/.openclaw";
description = "State directory for Openclaw (logs, sessions, config).";
};
workspaceDir = lib.mkOption {
type = lib.types.str;
default = "${openclawLib.homeDir}/.openclaw/workspace";
description = "Workspace directory for Openclaw agent skills.";
};
documents = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to a documents directory containing AGENTS.md, SOUL.md, and TOOLS.md.";
};
skills = lib.mkOption {
type = lib.types.listOf mkSkillOption;
default = [];
description = "Declarative skills installed into each instance workspace.";
};
plugins = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
source = lib.mkOption {
type = lib.types.str;
description = "Plugin source pointer (e.g., github:owner/repo or path:/...).";
};
config = lib.mkOption {
type = lib.types.attrs;
default = {};
description = "Plugin-specific configuration (env/files/etc).";
};
};
});
default = [];
description = "Plugins enabled for the default instance (merged with first-party toggles).";
};
firstParty = {
summarize.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the summarize plugin (first-party).";
};
peekaboo.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the peekaboo plugin (first-party).";
};
oracle.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the oracle plugin (first-party).";
};
poltergeist.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the poltergeist plugin (first-party).";
};
sag.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the sag plugin (first-party).";
};
camsnap.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the camsnap plugin (first-party).";
};
gogcli.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the gogcli plugin (first-party).";
};
bird.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the bird plugin (first-party).";
};
sonoscli.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the sonoscli plugin (first-party).";
};
imsg.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the imsg plugin (first-party).";
};
};
launchd.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Run Openclaw gateway via launchd (macOS).";
};
systemd.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Run Openclaw gateway via systemd user service (Linux).";
};
instances = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule instanceModule);
default = {};
description = "Named Openclaw instances (prod/test).";
};
exposePluginPackages = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Add plugin packages to home.packages so CLIs are on PATH.";
};
reloadScript = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Install openclaw-reload helper for no-sudo config refresh + gateway restart.";
};
};
config = lib.mkOption {
type = lib.types.submodule { options = openclawLib.generatedConfigOptions; };
default = {};
description = "Openclaw config (schema-typed).";
};
};
}
@@ -0,0 +1,216 @@
{ lib, pkgs, openclawLib, enabledInstances }:
let
resolvePath = openclawLib.resolvePath;
toRelative = openclawLib.toRelative;
resolvePlugin = plugin: let
flake = builtins.getFlake plugin.source;
system = pkgs.stdenv.hostPlatform.system;
openclawPluginRaw =
if flake ? openclawPlugin then flake.openclawPlugin
else throw "openclawPlugin missing in ${plugin.source}";
openclawPlugin =
if builtins.isFunction openclawPluginRaw
then openclawPluginRaw system
else openclawPluginRaw;
resolvedPlugin =
if openclawPlugin == null
then throw "openclawPlugin is null in ${plugin.source} for ${system}"
else openclawPlugin;
needs = resolvedPlugin.needs or {};
in {
source = plugin.source;
name = resolvedPlugin.name or (throw "openclawPlugin.name missing in ${plugin.source}");
skills = resolvedPlugin.skills or [];
packages = resolvedPlugin.packages or [];
needs = {
stateDirs = needs.stateDirs or [];
requiredEnv = needs.requiredEnv or [];
};
config = plugin.config or {};
};
resolvedPluginsByInstance =
lib.mapAttrs (instName: inst:
let
resolved = map resolvePlugin inst.plugins;
counts = lib.foldl' (acc: p:
acc // { "${p.name}" = (acc.${p.name} or 0) + 1; }
) {} resolved;
duplicates = lib.attrNames (lib.filterAttrs (_: v: v > 1) counts);
byName = lib.foldl' (acc: p: acc // { "${p.name}" = p; }) {} resolved;
ordered = lib.attrValues byName;
in
if duplicates == []
then ordered
else lib.warn
"programs.openclaw.instances.${instName}: duplicate plugin names detected (${lib.concatStringsSep ", " duplicates}); last entry wins."
ordered
) enabledInstances;
pluginPackagesFor = instName:
lib.flatten (map (p: p.packages) (resolvedPluginsByInstance.${instName} or []));
pluginPackagesAll =
lib.flatten (map pluginPackagesFor (lib.attrNames enabledInstances));
pluginStateDirsFor = instName:
let
dirs = lib.flatten (map (p: p.needs.stateDirs) (resolvedPluginsByInstance.${instName} or []));
in
map (dir: resolvePath ("~/" + dir)) dirs;
pluginStateDirsAll =
lib.flatten (map pluginStateDirsFor (lib.attrNames enabledInstances));
pluginEnvFor = instName:
let
entries = resolvedPluginsByInstance.${instName} or [];
toPairs = p:
let
env = (p.config.env or {});
required = p.needs.requiredEnv;
in
map (k: { key = k; value = env.${k} or ""; plugin = p.name; }) required;
in
lib.flatten (map toPairs entries);
pluginEnvAllFor = instName:
let
entries = resolvedPluginsByInstance.${instName} or [];
toPairs = p:
let env = (p.config.env or {});
in map (k: { key = k; value = env.${k}; plugin = p.name; }) (lib.attrNames env);
in
lib.flatten (map toPairs entries);
pluginAssertions =
lib.flatten (lib.mapAttrsToList (instName: inst:
let
plugins = resolvedPluginsByInstance.${instName} or [];
envFor = p: (p.config.env or {});
missingFor = p:
lib.filter (req: !(builtins.hasAttr req (envFor p))) p.needs.requiredEnv;
configMissingStateDir = p:
(p.config.settings or {}) != {} && (p.needs.stateDirs or []) == [];
mkAssertion = p:
let
missing = missingFor p;
in {
assertion = missing == [];
message = "programs.openclaw.instances.${instName}: plugin ${p.name} missing required env: ${lib.concatStringsSep ", " missing}";
};
mkConfigAssertion = p: {
assertion = !(configMissingStateDir p);
message = "programs.openclaw.instances.${instName}: plugin ${p.name} provides settings but declares no stateDirs (needed for config.json).";
};
in
(map mkAssertion plugins) ++ (map mkConfigAssertion plugins)
) enabledInstances);
pluginSkillsFiles =
let
entriesForInstance = instName: inst:
let
base = "${toRelative (resolvePath inst.workspaceDir)}/skills";
skillEntriesFor = p:
map (skillPath: {
name = "${base}/${p.name}/${builtins.baseNameOf skillPath}";
value = { source = skillPath; recursive = true; };
}) p.skills;
plugins = resolvedPluginsByInstance.${instName} or [];
in
lib.flatten (map skillEntriesFor plugins);
in
lib.listToAttrs (lib.flatten (lib.mapAttrsToList entriesForInstance enabledInstances));
pluginConfigFiles =
let
entryFor = instName: inst:
let
plugins = resolvedPluginsByInstance.${instName} or [];
mkEntries = p:
let
cfg = p.config.settings or {};
dir =
if (p.needs.stateDirs or []) == []
then null
else lib.head (p.needs.stateDirs or []);
in
if cfg == {} then
[]
else
(if dir == null then
throw "plugin ${p.name} provides settings but no stateDirs are defined"
else [
{
name = toRelative (resolvePath ("~/" + dir + "/config.json"));
value = { text = builtins.toJSON cfg; };
}
]);
in
lib.flatten (map mkEntries plugins);
entries = lib.flatten (lib.mapAttrsToList entryFor enabledInstances);
in
lib.listToAttrs entries;
pluginSkillAssertions =
let
skillTargets =
lib.flatten (lib.concatLists (lib.mapAttrsToList (instName: inst:
let
base = "${toRelative (resolvePath inst.workspaceDir)}/skills";
plugins = resolvedPluginsByInstance.${instName} or [];
in
map (p:
map (skillPath:
"${base}/${p.name}/${builtins.baseNameOf skillPath}"
) p.skills
) plugins
) enabledInstances));
counts = lib.foldl' (acc: path:
acc // { "${path}" = (acc.${path} or 0) + 1; }
) {} skillTargets;
duplicates = lib.attrNames (lib.filterAttrs (_: v: v > 1) counts);
in
if duplicates == [] then [] else [
{
assertion = false;
message = "Duplicate skill paths detected: ${lib.concatStringsSep ", " duplicates}";
}
];
pluginGuards =
let
renderCheck = entry: ''
if [ -z "${entry.value}" ]; then
echo "Missing env ${entry.key} for plugin ${entry.plugin} in instance ${entry.instance}." >&2
exit 1
fi
if [ ! -f "${entry.value}" ] || [ ! -s "${entry.value}" ]; then
echo "Required file for ${entry.key} not found or empty: ${entry.value} (plugin ${entry.plugin}, instance ${entry.instance})." >&2
exit 1
fi
'';
entriesForInstance = instName:
map (entry: entry // { instance = instName; }) (pluginEnvFor instName);
entries = lib.flatten (map entriesForInstance (lib.attrNames enabledInstances));
in
lib.concatStringsSep "\n" (map renderCheck entries);
in {
inherit
resolvedPluginsByInstance
pluginPackagesFor
pluginPackagesAll
pluginStateDirsFor
pluginStateDirsAll
pluginEnvFor
pluginEnvAllFor
pluginAssertions
pluginSkillsFiles
pluginConfigFiles
pluginSkillAssertions
pluginGuards;
}
+8 -10
View File
@@ -30,24 +30,22 @@
programs.openclaw = {
# REPLACE: path to your managed documents directory
documents = ./documents;
instances.default = {
enable = true;
providers.telegram = {
enable = true;
# Schema-typed Openclaw config (from upstream)
config = {
channels.telegram = {
# REPLACE: path to your bot token file
botTokenFile = "<tokenPath>";
tokenFile = "<tokenPath>";
# REPLACE: your Telegram user ID (get from @userinfobot)
allowFrom = [ <allowFrom> ];
# Group defaults (required in Nix mode):
groups = {
"*" = { requireMention = true; };
};
};
providers.anthropic = {
# REPLACE: path to your Anthropic API key file
apiKeyFile = "<anthropicKeyPath>";
};
};
instances.default = {
enable = true;
plugins = [
# Example plugin without config:
{ source = "github:acme/hello-world"; }