mirror of
https://github.com/openclaw/nix-openclaw.git
synced 2026-07-25 13:45:53 -04:00
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:
@@ -12,6 +12,7 @@ Defaults:
|
||||
- Declarative config only.
|
||||
- Batteries‑included install is the baseline.
|
||||
- Breaking changes are acceptable pre‑1.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: “I’m 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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"; }
|
||||
|
||||
Reference in New Issue
Block a user