nix-openclaw/nix/modules/home-manager/clawdis.nix
2026-01-03 21:42:41 +01:00

811 lines
26 KiB
Nix

{ config, lib, pkgs, ... }:
let
cfg = config.programs.clawdis;
homeDir = config.home.homeDirectory;
appPackage = if cfg.appPackage != null then cfg.appPackage else cfg.package;
mkBaseConfig = workspaceDir: {
gateway = { mode = "local"; };
agent = { workspace = workspaceDir; };
};
mkTelegramConfig = inst: lib.optionalAttrs inst.providers.telegram.enable {
telegram = {
enabled = true;
tokenFile = inst.providers.telegram.botTokenFile;
allowFrom = inst.providers.telegram.allowFrom;
groups = inst.providers.telegram.groups;
};
};
mkRoutingConfig = inst: {
routing = {
queue = {
mode = inst.routing.queue.mode;
bySurface = inst.routing.queue.bySurface;
};
};
};
instanceModule = { name, config, ... }: {
options = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable this Clawdis instance.";
};
package = lib.mkOption {
type = lib.types.package;
default = cfg.package;
description = "Clawdis batteries-included package.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = if name == "default"
then "${homeDir}/.clawdis"
else "${homeDir}/.clawdis-${name}";
description = "State directory for this Clawdis instance (logs, sessions, config).";
};
workspaceDir = lib.mkOption {
type = lib.types.str;
default = "${config.stateDir}/workspace";
description = "Workspace directory for this Clawdis instance.";
};
configPath = lib.mkOption {
type = lib.types.str;
default = "${config.stateDir}/clawdis.json";
description = "Path to generated Clawdis config JSON.";
};
logPath = lib.mkOption {
type = lib.types.str;
default = if name == "default"
then "/tmp/clawdis/clawdis-gateway.log"
else "/tmp/clawdis/clawdis-gateway-${name}.log";
description = "Log path for this Clawdis gateway instance.";
};
gatewayPort = lib.mkOption {
type = lib.types.int;
default = 18789;
description = "Gateway port used by the Clawdis desktop app.";
};
gatewayPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Local path to Clawdis 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).";
};
providers.telegram = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable Telegram provider.";
};
botTokenFile = lib.mkOption {
type = lib.types.str;
default = "";
description = "Path to Telegram bot token file.";
};
allowFrom = lib.mkOption {
type = lib.types.listOf lib.types.int;
default = [];
description = "Allowed Telegram chat IDs.";
};
groups = lib.mkOption {
type = lib.types.attrs;
default = {};
description = "Per-group Telegram overrides (mirrors upstream telegram.groups config).";
};
};
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 = cfg.plugins;
description = "Plugins enabled for this instance.";
};
providers.anthropic = {
apiKeyFile = lib.mkOption {
type = lib.types.str;
default = "";
description = "Path to Anthropic API key file (used to set ANTHROPIC_API_KEY).";
};
};
routing.queue = {
mode = lib.mkOption {
type = lib.types.enum [ "queue" "interrupt" ];
default = "interrupt";
description = "Queue mode when a run is active.";
};
bySurface = lib.mkOption {
type = lib.types.attrs;
default = {
telegram = "interrupt";
discord = "queue";
webchat = "queue";
};
description = "Per-surface queue mode overrides.";
};
};
launchd.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Run Clawdis gateway via launchd (macOS).";
};
launchd.label = lib.mkOption {
type = lib.types.str;
default = if name == "default"
then "com.steipete.clawdis.gateway"
else "com.steipete.clawdis.gateway.${name}";
description = "launchd label for this instance.";
};
app.install.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Install Clawdis.app for this instance.";
};
app.install.path = lib.mkOption {
type = lib.types.str;
default = "${homeDir}/Applications/Clawdis.app";
description = "Destination path for this instance's Clawdis.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).";
};
};
configOverrides = lib.mkOption {
type = lib.types.attrs;
default = {};
description = "Additional Clawdis config to merge into the generated JSON.";
};
};
};
defaultInstance = {
enable = cfg.enable;
package = cfg.package;
stateDir = cfg.stateDir;
workspaceDir = cfg.workspaceDir;
configPath = "${cfg.stateDir}/clawdis.json";
logPath = "/tmp/clawdis/clawdis-gateway.log";
gatewayPort = 18789;
providers = cfg.providers;
routing = cfg.routing;
launchd = cfg.launchd;
plugins = cfg.plugins;
configOverrides = {};
appDefaults = {
enable = true;
attachExistingOnly = true;
};
app = {
install = {
enable = false;
path = "${homeDir}/Applications/Clawdis.app";
};
};
};
instances = if cfg.instances != {}
then cfg.instances
else lib.optionalAttrs cfg.enable { default = defaultInstance; };
enabledInstances = lib.filterAttrs (_: inst: inst.enable) instances;
managedSkillsDir = "${homeDir}/.clawdis/skills";
documentsEnabled = cfg.documents != null;
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;
instanceWorkspaceDirs = lib.mapAttrsToList (_: inst: resolvePath inst.workspaceDir) enabledInstances;
documentsAssertions = lib.optionals documentsEnabled [
{
assertion = builtins.pathExists cfg.documents;
message = "programs.clawdis.documents must point to an existing directory.";
}
{
assertion = builtins.pathExists (cfg.documents + "/AGENTS.md");
message = "Missing AGENTS.md in programs.clawdis.documents.";
}
{
assertion = builtins.pathExists (cfg.documents + "/SOUL.md");
message = "Missing SOUL.md in programs.clawdis.documents.";
}
{
assertion = builtins.pathExists (cfg.documents + "/TOOLS.md");
message = "Missing TOOLS.md in programs.clawdis.documents.";
}
];
documentsGuard =
lib.optionalString documentsEnabled (
let
guardLine = file: ''
if [ -e "${file}" ] && [ ! -L "${file}" ]; then
echo "Clawdis 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
pluginLinesFor = instName: inst:
let
plugins = resolvedPluginsByInstance.${instName} or [];
render = p: "- " + p.name + " (" + p.source + ")";
lines = if plugins == [] then [ "- (none)" ] else map render plugins;
in
[
""
"### Instance: ${instName}"
] ++ lines;
reportLines =
[
"<!-- BEGIN NIX-REPORT -->"
""
"## 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 "clawdis-tools-report.md" reportText
else
null;
toolsWithReport =
if documentsEnabled then
pkgs.runCommand "clawdis-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
{};
resolvePlugin = plugin: let
flake = builtins.getFlake plugin.source;
clawdisPlugin =
if flake ? clawdisPlugin then flake.clawdisPlugin
else throw "clawdisPlugin missing in ${plugin.source}";
needs = clawdisPlugin.needs or {};
in {
source = plugin.source;
name = clawdisPlugin.name or (throw "clawdisPlugin.name missing in ${plugin.source}");
skills = clawdisPlugin.skills or [];
packages = clawdisPlugin.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.clawdis.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 []));
pluginStateDirsFor = instName:
let
dirs = lib.flatten (map (p: p.needs.stateDirs) (resolvedPluginsByInstance.${instName} or []));
in
map (dir: resolvePath ("~/" + dir)) dirs;
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: !(envFor p ? req)) p.needs.requiredEnv;
configMissingStateDir = p:
(p.config.settings or {}) != {} && (p.needs.stateDirs or []) == [];
mkAssertion = p:
let missing = missingFor p;
in {
assertion = missing == [];
message = "programs.clawdis.instances.${instName}: plugin ${p.name} missing required env: ${lib.concatStringsSep \", \" missing}";
};
mkConfigAssertion = p: {
assertion = !(configMissingStateDir p);
message = "programs.clawdis.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
skillEntriesFor = p:
map (skillPath: {
name = ".clawdis/skills/${p.name}/${builtins.baseNameOf skillPath}";
value = { source = skillPath; recursive = true; };
}) p.skills;
allEntries =
lib.flatten (lib.concatLists (lib.mapAttrsToList (_: plugins: map skillEntriesFor plugins) resolvedPluginsByInstance));
in
lib.listToAttrs (lib.flatten allEntries);
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);
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 (_: plugins:
map (p:
map (skillPath:
".clawdis/skills/${p.name}/${builtins.baseNameOf skillPath}"
) p.skills
) plugins
) resolvedPluginsByInstance));
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}";
}
];
mkInstanceConfig = name: inst: let
gatewayPackage =
if inst.gatewayPath != null then
pkgs.callPackage ../../packages/clawdis-gateway.nix {
src = builtins.path {
path = inst.gatewayPath;
name = "clawdis-gateway-src";
};
pnpmDepsHash = inst.gatewayPnpmDepsHash;
}
else
inst.package;
pluginPackages = pluginPackagesFor name;
pluginEnvAll = pluginEnvAllFor name;
baseConfig = mkBaseConfig inst.workspaceDir;
mergedConfig = lib.recursiveUpdate
(lib.recursiveUpdate baseConfig (lib.recursiveUpdate (mkTelegramConfig inst) (mkRoutingConfig inst)))
inst.configOverrides;
configJson = builtins.toJSON mergedConfig;
gatewayWrapper = pkgs.writeShellScriptBin "clawdis-gateway-${name}" ''
set -euo pipefail
if [ "${toString (pluginPackages != [])}" = "true" ]; then
export PATH="${lib.makeBinPath pluginPackages}:$PATH"
fi
${lib.concatStringsSep "\n" (map (entry: "export ${entry.key}=\"${entry.value}\"") pluginEnvAll)}
if [ -n "${inst.providers.anthropic.apiKeyFile}" ]; then
if [ ! -f "${inst.providers.anthropic.apiKeyFile}" ]; then
echo "Anthropic API key file not found: ${inst.providers.anthropic.apiKeyFile}" >&2
exit 1
fi
ANTHROPIC_API_KEY="$(cat "${inst.providers.anthropic.apiKeyFile}")"
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "Anthropic API key file is empty: ${inst.providers.anthropic.apiKeyFile}" >&2
exit 1
fi
export ANTHROPIC_API_KEY
fi
exec "${gatewayPackage}/bin/clawdis" "$@"
'';
in {
homeFile = {
name = inst.configPath;
value = { text = configJson; };
};
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/clawdis-gateway-${name}" ];
RunAtLoad = true;
KeepAlive = true;
WorkingDirectory = inst.stateDir;
StandardOutPath = inst.logPath;
StandardErrorPath = inst.logPath;
EnvironmentVariables = {
CLAWDIS_CONFIG_PATH = inst.configPath;
CLAWDIS_STATE_DIR = inst.stateDir;
CLAWDIS_IMAGE_BACKEND = "sips";
CLAWDIS_NIX_MODE = "1";
};
};
};
};
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/Clawdis.app";
recursive = true;
force = true;
};
};
package = gatewayPackage;
};
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;
assertions = lib.flatten (lib.mapAttrsToList (name: inst: [
{
assertion = !inst.providers.telegram.enable || inst.providers.telegram.botTokenFile != "";
message = "programs.clawdis.instances.${name}.providers.telegram.botTokenFile must be set when Telegram is enabled.";
}
{
assertion = !inst.providers.telegram.enable || (lib.length inst.providers.telegram.allowFrom > 0);
message = "programs.clawdis.instances.${name}.providers.telegram.allowFrom must be non-empty when Telegram is enabled.";
}
]) enabledInstances);
in {
options.programs.clawdis = {
enable = lib.mkEnableOption "Clawdis (batteries-included)";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.clawdis;
description = "Clawdis batteries-included package.";
};
appPackage = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = "Optional Clawdis app package (defaults to package if unset).";
};
installApp = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Install Clawdis.app at the default location.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "${homeDir}/.clawdis";
description = "State directory for Clawdis (logs, sessions, config).";
};
workspaceDir = lib.mkOption {
type = lib.types.str;
default = "${homeDir}/.clawdis/workspace";
description = "Workspace directory for Clawdis 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.";
};
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.";
};
providers.telegram = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable Telegram provider.";
};
botTokenFile = lib.mkOption {
type = lib.types.str;
default = "";
description = "Path to Telegram bot token file.";
};
allowFrom = lib.mkOption {
type = lib.types.listOf lib.types.int;
default = [];
description = "Allowed Telegram chat IDs.";
};
};
providers.anthropic = {
apiKeyFile = lib.mkOption {
type = lib.types.str;
default = "";
description = "Path to Anthropic API key file (used to set ANTHROPIC_API_KEY).";
};
};
routing.queue = {
mode = lib.mkOption {
type = lib.types.enum [ "queue" "interrupt" ];
default = "interrupt";
description = "Queue mode when a run is active.";
};
bySurface = lib.mkOption {
type = lib.types.attrs;
default = {
telegram = "interrupt";
discord = "queue";
webchat = "queue";
};
description = "Per-surface queue mode overrides.";
};
};
launchd.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Run Clawdis gateway via launchd (macOS).";
};
instances = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule instanceModule);
default = {};
description = "Named Clawdis instances (prod/test).";
};
};
config = lib.mkIf (cfg.enable || cfg.instances != {}) {
assertions = assertions ++ [
{
assertion = lib.length (lib.attrNames appDefaultsEnabled) <= 1;
message = "Only one Clawdis instance may enable appDefaults.";
}
] ++ documentsAssertions ++ pluginAssertions ++ pluginSkillAssertions;
home.packages = lib.unique (map (item: item.package) instanceConfigs);
home.file =
(lib.listToAttrs (map (item: item.homeFile) instanceConfigs))
// (lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && appPackage != null && cfg.installApp) {
"Applications/Clawdis.app" = {
source = "${appPackage}/Applications/Clawdis.app";
recursive = true;
force = true;
};
})
// (lib.listToAttrs appInstalls)
// documentsFiles
// pluginSkillsFiles
// pluginConfigFiles;
home.activation.clawdisDocumentGuard = lib.mkIf documentsEnabled (
lib.hm.dag.entryBefore [ "writeBoundary" ] ''
set -euo pipefail
${documentsGuard}
''
);
home.activation.clawdisDirs = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
/bin/mkdir -p ${lib.concatStringsSep " " (lib.concatMap (item: item.dirs) instanceConfigs)}
/bin/mkdir -p ${lib.concatStringsSep " " (lib.flatten (map pluginStateDirsFor (lib.attrNames enabledInstances)))}
'';
home.activation.clawdisPluginGuard = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
set -euo pipefail
${pluginGuards}
'';
home.activation.clawdisAppDefaults = lib.mkIf (pkgs.stdenv.hostPlatform.isDarwin && appDefaults != {}) (
lib.hm.dag.entryAfter [ "writeBoundary" ] ''
/usr/bin/defaults write com.steipete.Clawdis clawdis.gateway.attachExistingOnly -bool ${lib.boolToString (appDefaults.attachExistingOnly or true)}
/usr/bin/defaults write com.steipete.Clawdis gatewayPort -int ${toString (appDefaults.gatewayPort or 18789)}
''
);
launchd.agents = lib.mkMerge (map (item: item.launchdAgent) instanceConfigs);
};
}