@alfe.ai/integrations 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import { homedir, platform, tmpdir } from "node:os";
5
5
  import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { buildConfigValidationSchema, parseManifestFile } from "@alfe.ai/integration-manifest";
7
7
  import { createLogger } from "@auriclabs/logger";
8
+ import { parseDocument } from "yaml";
9
+ import { toServerConfig } from "@alfe.ai/mcp-bundler";
8
10
  //#region src/registry.ts
9
11
  /** Default cache TTL — refetch the registry index after this many ms. */
10
12
  const DEFAULT_REGISTRY_TTL_MS = 6e4;
@@ -149,8 +151,8 @@ var Resolver = class {
149
151
  * are installed at the root integrations directory so all hook scripts
150
152
  * can resolve them via Node's upward module resolution.
151
153
  */
152
- const execFileAsync$1 = promisify(execFile);
153
- const log$2 = createLogger("Installer");
154
+ const execFileAsync$2 = promisify(execFile);
155
+ const log$4 = createLogger("Installer");
154
156
  const INTEGRATIONS_DIR = join(homedir(), ".alfe", "integrations");
155
157
  const GIT_TIMEOUT_MS = 6e4;
156
158
  const NPM_TIMEOUT_MS = 6e4;
@@ -211,12 +213,12 @@ var Installer = class {
211
213
  */
212
214
  async cloneDirect(resolved, installPath) {
213
215
  try {
214
- await execFileAsync$1("git", [
216
+ await execFileAsync$2("git", [
215
217
  "clone",
216
218
  resolved.repository,
217
219
  installPath
218
220
  ], { timeout: GIT_TIMEOUT_MS });
219
- await execFileAsync$1("git", ["checkout", resolved.commit], {
221
+ await execFileAsync$2("git", ["checkout", resolved.commit], {
220
222
  cwd: installPath,
221
223
  timeout: GIT_TIMEOUT_MS
222
224
  });
@@ -237,12 +239,12 @@ var Installer = class {
237
239
  const subdir = resolved.subdir;
238
240
  const tempDir = mkdtempSync(join(tmpdir(), `alfe-clone-${resolved.id}-`));
239
241
  try {
240
- await execFileAsync$1("git", [
242
+ await execFileAsync$2("git", [
241
243
  "clone",
242
244
  resolved.repository,
243
245
  tempDir
244
246
  ], { timeout: GIT_TIMEOUT_MS });
245
- await execFileAsync$1("git", ["checkout", resolved.commit], {
247
+ await execFileAsync$2("git", ["checkout", resolved.commit], {
246
248
  cwd: tempDir,
247
249
  timeout: GIT_TIMEOUT_MS
248
250
  });
@@ -312,7 +314,7 @@ var Installer = class {
312
314
  dependencies: { ...SHARED_PACKAGES }
313
315
  };
314
316
  writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n", "utf-8");
315
- log$2.info("Installing shared @alfe.ai packages for integration hooks");
317
+ log$4.info("Installing shared @alfe.ai packages for integration hooks");
316
318
  await this.runNpmInstall(this.basePath);
317
319
  this.sharedPackagesReady = true;
318
320
  }
@@ -322,12 +324,12 @@ var Installer = class {
322
324
  */
323
325
  async installLocalDependencies(installPath) {
324
326
  if (!existsSync(join(installPath, "package.json"))) return;
325
- log$2.info({ path: installPath }, "Installing integration-specific npm dependencies");
327
+ log$4.info({ path: installPath }, "Installing integration-specific npm dependencies");
326
328
  await this.runNpmInstall(installPath);
327
329
  }
328
330
  async runNpmInstall(cwd) {
329
331
  try {
330
- await execFileAsync$1("npm", [
332
+ await execFileAsync$2("npm", [
331
333
  "install",
332
334
  "--production",
333
335
  "--no-audit",
@@ -564,12 +566,17 @@ var LockManager = class {
564
566
  }
565
567
  /**
566
568
  * Add entries for an integration activation in a specific runtime.
569
+ *
570
+ * Pass `opts.configApplied` when the integration applied runtime config so
571
+ * the contribution is recorded even if it ships no plugins/skills — this is
572
+ * what lets a config-only integration be torn down on deactivate.
567
573
  */
568
- addEntries(runtime, integrationId, version, plugins, skills, installPath) {
574
+ addEntries(runtime, integrationId, version, plugins, skills, installPath, opts) {
569
575
  const lock = this.read();
570
576
  if (!(runtime in lock.runtimes)) lock.runtimes[runtime] = {
571
577
  plugins: [],
572
- skills: []
578
+ skills: [],
579
+ config: []
573
580
  };
574
581
  const state = lock.runtimes[runtime];
575
582
  for (const plugin of plugins) if (!state.plugins.some((p) => p.package === plugin.package && p.sourceIntegration === integrationId)) state.plugins.push({
@@ -587,11 +594,22 @@ var LockManager = class {
587
594
  integrationVersion: version
588
595
  });
589
596
  }
597
+ if (opts?.configApplied) {
598
+ state.config ??= [];
599
+ if (!state.config.some((c) => c.sourceIntegration === integrationId)) state.config.push({
600
+ sourceIntegration: integrationId,
601
+ integrationVersion: version
602
+ });
603
+ }
590
604
  this.write(lock);
591
605
  }
592
606
  /**
593
607
  * Remove all entries for a given integration across all runtimes.
594
608
  * Returns what was removed, keyed by runtime.
609
+ *
610
+ * A runtime is included in the result when the integration contributed
611
+ * plugins, skills, OR config there — so `deactivate` drives
612
+ * `applier.removeConfig` even for a config-only integration.
595
613
  */
596
614
  removeEntries(integrationId) {
597
615
  const lock = this.read();
@@ -599,12 +617,15 @@ var LockManager = class {
599
617
  for (const [runtime, state] of Object.entries(lock.runtimes)) {
600
618
  const removedPlugins = state.plugins.filter((p) => p.sourceIntegration === integrationId);
601
619
  const removedSkills = state.skills.filter((s) => s.sourceIntegration === integrationId);
602
- if (removedPlugins.length > 0 || removedSkills.length > 0) removed[runtime] = {
620
+ const removedConfig = (state.config ?? []).filter((c) => c.sourceIntegration === integrationId);
621
+ if (removedPlugins.length > 0 || removedSkills.length > 0 || removedConfig.length > 0) removed[runtime] = {
603
622
  plugins: removedPlugins,
604
- skills: removedSkills
623
+ skills: removedSkills,
624
+ config: removedConfig
605
625
  };
606
626
  state.plugins = state.plugins.filter((p) => p.sourceIntegration !== integrationId);
607
627
  state.skills = state.skills.filter((s) => s.sourceIntegration !== integrationId);
628
+ if (state.config) state.config = state.config.filter((c) => c.sourceIntegration !== integrationId);
608
629
  }
609
630
  this.write(lock);
610
631
  return removed;
@@ -1163,18 +1184,22 @@ var IntegrationManager = class {
1163
1184
  this.log.info(`Applying skill ${skillName} to ${runtimeName}`);
1164
1185
  await applier.applySkill(skillName, srcPath);
1165
1186
  }
1187
+ let runtimeConfigApplied = false;
1166
1188
  if (runtimeConfig && Object.keys(runtimeConfig).length > 0) {
1167
1189
  const agentConfig = entry.config;
1168
1190
  const interpolatedConfig = interpolateSelfConfig(runtimeConfig, agentConfig);
1169
1191
  this.log.info(`Applying config for ${integrationId} to ${runtimeName}`);
1170
1192
  await applier.applyConfig(integrationId, interpolatedConfig);
1171
1193
  configApplied = true;
1194
+ runtimeConfigApplied = true;
1172
1195
  }
1173
1196
  const appliedPlugins = plugins.filter((p) => !pluginFailures.includes(p.package));
1174
- if (appliedPlugins.length > 0 || skills.length > 0) this.lockManager.addEntries(runtimeName, integrationId, manifest.version, appliedPlugins, skills, installPath);
1197
+ if (appliedPlugins.length > 0 || skills.length > 0 || runtimeConfigApplied) this.lockManager.addEntries(runtimeName, integrationId, manifest.version, appliedPlugins, skills, installPath, { configApplied: runtimeConfigApplied });
1175
1198
  }
1176
1199
  const mcpServers = manifest.mcp_servers ?? [];
1177
- if (mcpServers.length > 0) if (!this.mcpApplier) this.log.warn(`Integration "${integrationId}" declares ${String(mcpServers.length)} mcp_server(s) but no mcpApplier is wired — skipping MCP registration`);
1200
+ const runtimeSupportsMcp = !supportedAgents || supportedAgents.length === 0 || [...this.runtimeAppliers.keys()].some((r) => supportedAgents.includes(r));
1201
+ if (mcpServers.length > 0 && !runtimeSupportsMcp) this.log.warn(`Integration "${integrationId}" declares ${String(mcpServers.length)} mcp_server(s) but no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${supportedAgents.join(", ")}) — skipping MCP registration`);
1202
+ else if (mcpServers.length > 0) if (!this.mcpApplier) this.log.warn(`Integration "${integrationId}" declares ${String(mcpServers.length)} mcp_server(s) but no mcpApplier is wired — skipping MCP registration`);
1178
1203
  else {
1179
1204
  const secretEntries = this.secrets.get(integrationId);
1180
1205
  const mergedConfig = {
@@ -1592,6 +1617,54 @@ var IntegrationManager = class {
1592
1617
  }
1593
1618
  };
1594
1619
  //#endregion
1620
+ //#region src/appliers/config-flatten.ts
1621
+ function flattenConfig(obj, prefix = "") {
1622
+ const entries = [];
1623
+ for (const [key, val] of Object.entries(obj)) {
1624
+ if (key.includes(".")) {
1625
+ entries.push({
1626
+ kind: "subtree",
1627
+ parentPath: prefix,
1628
+ dottedKey: key,
1629
+ value: val
1630
+ });
1631
+ continue;
1632
+ }
1633
+ const path = prefix ? `${prefix}.${key}` : key;
1634
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) entries.push(...flattenConfig(val, path));
1635
+ else entries.push({
1636
+ kind: "leaf",
1637
+ path,
1638
+ value: val
1639
+ });
1640
+ }
1641
+ return entries;
1642
+ }
1643
+ function partitionEntries(entries) {
1644
+ const leaves = [];
1645
+ const subtreesByParent = /* @__PURE__ */ new Map();
1646
+ for (const entry of entries) {
1647
+ if (entry.kind === "leaf") {
1648
+ leaves.push({
1649
+ path: entry.path,
1650
+ value: entry.value
1651
+ });
1652
+ continue;
1653
+ }
1654
+ if (!entry.parentPath) throw new Error(`config-flatten: top-level config keys containing dots are not supported (key: "${entry.dottedKey}")`);
1655
+ let bucket = subtreesByParent.get(entry.parentPath);
1656
+ if (!bucket) {
1657
+ bucket = /* @__PURE__ */ new Map();
1658
+ subtreesByParent.set(entry.parentPath, bucket);
1659
+ }
1660
+ bucket.set(entry.dottedKey, entry.value);
1661
+ }
1662
+ return {
1663
+ leaves,
1664
+ subtreesByParent
1665
+ };
1666
+ }
1667
+ //#endregion
1595
1668
  //#region src/appliers/openclaw-applier.ts
1596
1669
  /**
1597
1670
  * OpenClawApplier — applies plugins, skills, and config to the OpenClaw runtime.
@@ -1602,8 +1675,8 @@ var IntegrationManager = class {
1602
1675
  * config file (openclaw.json) without clobbering. Per-integration tracking
1603
1676
  * is stored in a separate tracking file (config.json) for clean removal.
1604
1677
  */
1605
- const execFileAsync = promisify(execFile);
1606
- const log$1 = createLogger("OpenClawApplier");
1678
+ const execFileAsync$1 = promisify(execFile);
1679
+ const log$3 = createLogger("OpenClawApplier");
1607
1680
  const DEFAULT_SKILLS_DIR = join(homedir(), ".alfe", "skills");
1608
1681
  /**
1609
1682
  * Bundled OpenClaw plugins that ship inside the runtime (not installed as npm
@@ -1627,12 +1700,42 @@ const BUNDLED_BASELINE_ALLOW = ["browser"];
1627
1700
  * chain and retry transient failures with a short backoff so the reload has
1628
1701
  * time to settle between writes.
1629
1702
  */
1630
- const CONFIG_SET_RETRIES = 3;
1631
- const CONFIG_SET_RETRY_DELAY_MS = 750;
1632
- const delay = (ms) => new Promise((resolve) => {
1703
+ const CONFIG_SET_RETRIES$1 = 3;
1704
+ const CONFIG_SET_RETRY_DELAY_MS$1 = 750;
1705
+ /**
1706
+ * Sentinel OpenClaw returns from `config get` in place of a sensitive value —
1707
+ * the key IS set, OpenClaw is just hiding it. Verify-after-write must treat a
1708
+ * read-back of this as "present/matches" rather than a mismatch (see
1709
+ * `configValueMatches`).
1710
+ */
1711
+ const OPENCLAW_REDACTED = "__OPENCLAW_REDACTED__";
1712
+ const delay$1 = (ms) => new Promise((resolve) => {
1633
1713
  setTimeout(resolve, ms);
1634
1714
  });
1635
1715
  /**
1716
+ * Structural equality for config read-back comparison. Primitives compare by
1717
+ * `Object.is`; arrays are order-SENSITIVE (config arrays are positional);
1718
+ * plain objects are order-INSENSITIVE (key order in JSON is not meaningful).
1719
+ * Used only to confirm a value landed after a `config set` exited non-zero.
1720
+ */
1721
+ function deepEqual(a, b) {
1722
+ if (Object.is(a, b)) return true;
1723
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
1724
+ const aIsArr = Array.isArray(a);
1725
+ const bIsArr = Array.isArray(b);
1726
+ if (aIsArr !== bIsArr) return false;
1727
+ if (aIsArr && bIsArr) {
1728
+ if (a.length !== b.length) return false;
1729
+ return a.every((v, i) => deepEqual(v, b[i]));
1730
+ }
1731
+ const ao = a;
1732
+ const bo = b;
1733
+ const aKeys = Object.keys(ao);
1734
+ const bKeys = Object.keys(bo);
1735
+ if (aKeys.length !== bKeys.length) return false;
1736
+ return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k]));
1737
+ }
1738
+ /**
1636
1739
  * Describe a `config set` target WITHOUT leaking values. The value argument is
1637
1740
  * the JSON payload (model config, the gateway loopback token via the alfe hook,
1638
1741
  * etc.) and must never reach the integration's `errorMessage`, which projects
@@ -1657,55 +1760,9 @@ function configSetErrorMessage(err, args) {
1657
1760
  }
1658
1761
  return `${target} failed: ${String(err)}`;
1659
1762
  }
1660
- function flattenConfig(obj, prefix = "") {
1661
- const entries = [];
1662
- for (const [key, val] of Object.entries(obj)) {
1663
- if (key.includes(".")) {
1664
- entries.push({
1665
- kind: "subtree",
1666
- parentPath: prefix,
1667
- dottedKey: key,
1668
- value: val
1669
- });
1670
- continue;
1671
- }
1672
- const path = prefix ? `${prefix}.${key}` : key;
1673
- if (val !== null && typeof val === "object" && !Array.isArray(val)) entries.push(...flattenConfig(val, path));
1674
- else entries.push({
1675
- kind: "leaf",
1676
- path,
1677
- value: val
1678
- });
1679
- }
1680
- return entries;
1681
- }
1682
- function partitionEntries(entries) {
1683
- const leaves = [];
1684
- const subtreesByParent = /* @__PURE__ */ new Map();
1685
- for (const entry of entries) {
1686
- if (entry.kind === "leaf") {
1687
- leaves.push({
1688
- path: entry.path,
1689
- value: entry.value
1690
- });
1691
- continue;
1692
- }
1693
- if (!entry.parentPath) throw new Error(`OpenClawApplier: top-level config keys containing dots are not supported (key: "${entry.dottedKey}")`);
1694
- let bucket = subtreesByParent.get(entry.parentPath);
1695
- if (!bucket) {
1696
- bucket = /* @__PURE__ */ new Map();
1697
- subtreesByParent.set(entry.parentPath, bucket);
1698
- }
1699
- bucket.set(entry.dottedKey, entry.value);
1700
- }
1701
- return {
1702
- leaves,
1703
- subtreesByParent
1704
- };
1705
- }
1706
1763
  async function readParentObject(parentPath) {
1707
1764
  try {
1708
- const { stdout } = await execFileAsync("openclaw", [
1765
+ const { stdout } = await execFileAsync$1("openclaw", [
1709
1766
  "config",
1710
1767
  "get",
1711
1768
  parentPath
@@ -1732,8 +1789,8 @@ var OpenClawApplier = class {
1732
1789
  this.agentWorkspace = options.agentWorkspace ?? join(home, "workspace");
1733
1790
  this.skillsDir = options.skillsDir ?? DEFAULT_SKILLS_DIR;
1734
1791
  this.trackingPath = options.configPath ?? join(this.home, "config.json");
1735
- this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES;
1736
- this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS;
1792
+ this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES$1;
1793
+ this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS$1;
1737
1794
  }
1738
1795
  /** Convenience: `openclaw config set <args>`, serialized + retried. */
1739
1796
  runConfigSet(setArgs, opts = {}) {
@@ -1754,11 +1811,11 @@ var OpenClawApplier = class {
1754
1811
  const run = async () => {
1755
1812
  let lastErr;
1756
1813
  for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
1757
- await execFileAsync("openclaw", ["config", ...args], { timeout });
1814
+ await execFileAsync$1("openclaw", ["config", ...args], { timeout });
1758
1815
  return;
1759
1816
  } catch (err) {
1760
1817
  lastErr = err;
1761
- if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay(this.configSetRetryDelayMs * 2 ** attempt);
1818
+ if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay$1(this.configSetRetryDelayMs * 2 ** attempt);
1762
1819
  }
1763
1820
  throw new Error(configSetErrorMessage(lastErr, args));
1764
1821
  };
@@ -1766,19 +1823,58 @@ var OpenClawApplier = class {
1766
1823
  this.configSetQueue = result.catch(() => void 0);
1767
1824
  return result;
1768
1825
  }
1826
+ /**
1827
+ * Read back a single config path and compare it to the value we tried to
1828
+ * write. Used by `applyConfig`'s verify-after-write: a `config set` can exit
1829
+ * non-zero (a hot-reload races the write, OpenClaw's clobber protection
1830
+ * fires) even though the value actually landed, exactly like `applyPlugin`'s
1831
+ * install-then-check tolerance.
1832
+ *
1833
+ * OpenClaw REDACTS sensitive values on `config get`, returning the literal
1834
+ * `__OPENCLAW_REDACTED__` instead of the real value. Treat that as a match:
1835
+ * the key exists and OpenClaw is hiding it, so a deep-equal against the
1836
+ * intended value would otherwise be a false negative and force a re-throw.
1837
+ */
1838
+ async configValueMatches(path, expected) {
1839
+ try {
1840
+ const { stdout } = await execFileAsync$1("openclaw", [
1841
+ "config",
1842
+ "get",
1843
+ path
1844
+ ], { timeout: 1e4 });
1845
+ const actual = JSON.parse(stdout.trim());
1846
+ if (actual === OPENCLAW_REDACTED) return true;
1847
+ return deepEqual(actual, expected);
1848
+ } catch {
1849
+ return false;
1850
+ }
1851
+ }
1852
+ /**
1853
+ * Run a read-back `check` with the same retry/backoff cadence as the config
1854
+ * writes — the settling hot-reload may still be rewriting openclaw.json when
1855
+ * we first read back, so a single check can be a false negative. Returns true
1856
+ * on the first success, false once all attempts are exhausted.
1857
+ */
1858
+ async verifyApplied(check) {
1859
+ for (let attempt = 0; attempt <= this.configSetRetries; attempt++) {
1860
+ if (await check()) return true;
1861
+ if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay$1(this.configSetRetryDelayMs * 2 ** attempt);
1862
+ }
1863
+ return false;
1864
+ }
1769
1865
  async applyPlugin(spec, _installPath, opts) {
1770
1866
  const pkg = stripPluginVersion(spec);
1771
1867
  await this.ensurePluginsAllow(pkg);
1772
1868
  this.cleanupUntrackedExtensionInstall(pkg);
1773
1869
  if (opts?.force && this.isPluginInstalled(pkg)) {
1774
- log$1.info({
1870
+ log$3.info({
1775
1871
  pkg,
1776
1872
  spec
1777
1873
  }, "Force mode — uninstalling plugin before reinstall");
1778
1874
  try {
1779
1875
  await this.removePlugin(pkg);
1780
1876
  } catch (err) {
1781
- log$1.warn({
1877
+ log$3.warn({
1782
1878
  pkg,
1783
1879
  spec,
1784
1880
  err: err instanceof Error ? err.message : String(err)
@@ -1796,12 +1892,12 @@ var OpenClawApplier = class {
1796
1892
  const args = useUnsafeFlag ? [...baseArgs, "--dangerously-force-unsafe-install"] : baseArgs;
1797
1893
  try {
1798
1894
  try {
1799
- await execFileAsync("openclaw", args, { timeout: 6e4 });
1895
+ await execFileAsync$1("openclaw", args, { timeout: 6e4 });
1800
1896
  } catch (err) {
1801
1897
  const errText = err instanceof Error ? `${err.message}\n${err.stderr ?? ""}` : String(err);
1802
1898
  if (useUnsafeFlag && errText.includes("unknown option")) {
1803
- log$1.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
1804
- await execFileAsync("openclaw", baseArgs, { timeout: 6e4 });
1899
+ log$3.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
1900
+ await execFileAsync$1("openclaw", baseArgs, { timeout: 6e4 });
1805
1901
  } else throw err;
1806
1902
  }
1807
1903
  } catch (err) {
@@ -1809,7 +1905,7 @@ var OpenClawApplier = class {
1809
1905
  setTimeout(r, 500);
1810
1906
  });
1811
1907
  if (!this.isPluginInstalled(pkg)) throw err;
1812
- log$1.warn({
1908
+ log$3.warn({
1813
1909
  pkg,
1814
1910
  spec,
1815
1911
  err: err instanceof Error ? err.message : String(err)
@@ -1832,7 +1928,7 @@ var OpenClawApplier = class {
1832
1928
  const wanted = Array.isArray(pkgs) ? pkgs : [pkgs];
1833
1929
  let currentAllow = [];
1834
1930
  try {
1835
- const { stdout } = await execFileAsync("openclaw", [
1931
+ const { stdout } = await execFileAsync$1("openclaw", [
1836
1932
  "config",
1837
1933
  "get",
1838
1934
  "plugins.allow"
@@ -1846,7 +1942,7 @@ var OpenClawApplier = class {
1846
1942
  try {
1847
1943
  await this.runConfigSet(["plugins.allow", JSON.stringify(updated)]);
1848
1944
  } catch (err) {
1849
- log$1.warn({
1945
+ log$3.warn({
1850
1946
  err: err instanceof Error ? err.message : String(err),
1851
1947
  pkgs: wanted
1852
1948
  }, "Failed to set plugins.allow via openclaw config set");
@@ -1910,12 +2006,12 @@ var OpenClawApplier = class {
1910
2006
  recursive: true,
1911
2007
  force: true
1912
2008
  });
1913
- log$1.info({
2009
+ log$3.info({
1914
2010
  pkg,
1915
2011
  removed: fullPath
1916
2012
  }, "Removed untracked extensions/ install — will reinstall via npm path");
1917
2013
  } catch (err) {
1918
- log$1.warn({
2014
+ log$3.warn({
1919
2015
  pkg,
1920
2016
  removed: fullPath,
1921
2017
  err: err instanceof Error ? err.message : String(err)
@@ -1924,7 +2020,7 @@ var OpenClawApplier = class {
1924
2020
  }
1925
2021
  }
1926
2022
  async removePlugin(spec) {
1927
- await execFileAsync("openclaw", [
2023
+ await execFileAsync$1("openclaw", [
1928
2024
  "plugins",
1929
2025
  "uninstall",
1930
2026
  "--force",
@@ -1938,21 +2034,21 @@ var OpenClawApplier = class {
1938
2034
  return Promise.resolve();
1939
2035
  }
1940
2036
  async applyClawHubSkill(slug) {
1941
- log$1.info({ slug }, "Installing skill from ClawHub");
2037
+ log$3.info({ slug }, "Installing skill from ClawHub");
1942
2038
  try {
1943
- await execFileAsync("openclaw", [
2039
+ await execFileAsync$1("openclaw", [
1944
2040
  "skills",
1945
2041
  "install",
1946
2042
  slug
1947
2043
  ], { timeout: 6e4 });
1948
- log$1.info({ slug }, "ClawHub skill installed");
2044
+ log$3.info({ slug }, "ClawHub skill installed");
1949
2045
  } catch (err) {
1950
2046
  const msg = err instanceof Error ? err.message : String(err);
1951
2047
  if (msg.includes("already exists") || msg.includes("Skill already exists")) {
1952
- log$1.info({ slug }, "ClawHub skill already installed (skipped)");
2048
+ log$3.info({ slug }, "ClawHub skill already installed (skipped)");
1953
2049
  return;
1954
2050
  }
1955
- log$1.error({
2051
+ log$3.error({
1956
2052
  slug,
1957
2053
  err: msg
1958
2054
  }, "ClawHub skill install failed");
@@ -1965,7 +2061,7 @@ var OpenClawApplier = class {
1965
2061
  recursive: true,
1966
2062
  force: true
1967
2063
  });
1968
- log$1.info({ slug }, "ClawHub skill removed");
2064
+ log$3.info({ slug }, "ClawHub skill removed");
1969
2065
  }
1970
2066
  return Promise.resolve();
1971
2067
  }
@@ -1996,7 +2092,14 @@ var OpenClawApplier = class {
1996
2092
  try {
1997
2093
  await this.runConfigSet([parentPath, JSON.stringify(merged)]);
1998
2094
  } catch (err) {
1999
- log$1.error({
2095
+ if (await this.verifyApplied(async () => {
2096
+ const after = await readParentObject(parentPath);
2097
+ return [...dottedKvs.entries()].every(([k, v]) => deepEqual(after[k], v));
2098
+ })) {
2099
+ log$3.warn({ parentPath }, "openclaw config set exited non-zero but config landed — continuing");
2100
+ continue;
2101
+ }
2102
+ log$3.error({
2000
2103
  err: err instanceof Error ? err.message : String(err),
2001
2104
  parentPath
2002
2105
  }, "Failed to set config subtree via openclaw config set");
@@ -2006,11 +2109,16 @@ var OpenClawApplier = class {
2006
2109
  if (leaves.length > 0) try {
2007
2110
  await this.runConfigSet(["--batch-json", JSON.stringify(leaves)]);
2008
2111
  } catch (err) {
2009
- log$1.error({
2010
- err: err instanceof Error ? err.message : String(err),
2011
- batch: leaves
2012
- }, "Failed to set config via openclaw config set --batch-json");
2013
- throw err;
2112
+ if (await this.verifyApplied(async () => {
2113
+ return (await Promise.all(leaves.map((l) => this.configValueMatches(l.path, l.value)))).every(Boolean);
2114
+ })) log$3.warn({ count: leaves.length }, "openclaw config set --batch-json exited non-zero but config landed — continuing");
2115
+ else {
2116
+ log$3.error({
2117
+ err: err instanceof Error ? err.message : String(err),
2118
+ batch: leaves
2119
+ }, "Failed to set config via openclaw config set --batch-json");
2120
+ throw err;
2121
+ }
2014
2122
  }
2015
2123
  }
2016
2124
  /**
@@ -2028,7 +2136,7 @@ var OpenClawApplier = class {
2028
2136
  for (const { path } of leaves) try {
2029
2137
  await this.runConfigCommand(["unset", path]);
2030
2138
  } catch (err) {
2031
- log$1.warn({
2139
+ log$3.warn({
2032
2140
  err: err instanceof Error ? err.message : String(err),
2033
2141
  path
2034
2142
  }, "Failed to unset config via openclaw config unset");
@@ -2041,7 +2149,7 @@ var OpenClawApplier = class {
2041
2149
  if (Object.keys(remaining).length === 0) await this.runConfigCommand(["unset", parentPath]);
2042
2150
  else await this.runConfigSet([parentPath, JSON.stringify(remaining)]);
2043
2151
  } catch (err) {
2044
- log$1.warn({
2152
+ log$3.warn({
2045
2153
  err: err instanceof Error ? err.message : String(err),
2046
2154
  parentPath
2047
2155
  }, "Failed to update parent config during remove");
@@ -2050,9 +2158,292 @@ var OpenClawApplier = class {
2050
2158
  tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
2051
2159
  this.writeTracking(tracking);
2052
2160
  }
2161
+ /**
2162
+ * Raw single-key config write — `openclaw config set <key> <value>`.
2163
+ *
2164
+ * Deliberately bypasses the `_integrations` tracking that `applyConfig`
2165
+ * maintains: this is a fire-and-forget write for the `alfe.config_set`
2166
+ * cloud-command, not an integration contribution, so it must NOT be
2167
+ * recorded for later `removeConfig` teardown. Reuses the serialized +
2168
+ * retried queue so it can't interleave with the hot-reload that integration
2169
+ * config writes trigger.
2170
+ */
2171
+ setConfigRaw(key, value) {
2172
+ return this.runConfigSet([key, value]);
2173
+ }
2174
+ isAvailable() {
2175
+ return Promise.resolve(existsSync(this.home));
2176
+ }
2177
+ readTracking() {
2178
+ if (!existsSync(this.trackingPath)) return {};
2179
+ try {
2180
+ return JSON.parse(readFileSync(this.trackingPath, "utf-8"));
2181
+ } catch {
2182
+ return {};
2183
+ }
2184
+ }
2185
+ writeTracking(config) {
2186
+ mkdirSync(join(this.trackingPath, ".."), { recursive: true });
2187
+ writeFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
2188
+ }
2189
+ };
2190
+ //#endregion
2191
+ //#region src/appliers/hermes-applier.ts
2192
+ /**
2193
+ * HermesApplier — applies integration config (and, later, native plugins) to
2194
+ * the Hermes runtime (Nous Research's Python agent).
2195
+ *
2196
+ * Hermes config lives in `~/.hermes/config.yaml` (YAML) and is mutated via the
2197
+ * `hermes config set/unset <dotted.key> <value>` CLI — we never write the YAML
2198
+ * file directly (mirrors the OpenClaw rule of letting the runtime own its own
2199
+ * config format). Per-integration contributions are tracked in a separate file
2200
+ * (`~/.hermes/.alfe-integrations.json`) so removal is precise.
2201
+ *
2202
+ * Scope (Phase 1, MCP-first hybrid):
2203
+ * - config: SUPPORTED — consumes `installs.runtimes.hermes.config` (the AI-proxy
2204
+ * routing keys: model.provider/base_url/api_key/model, etc.).
2205
+ * - plugins: the `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific and are
2206
+ * log-and-skipped here; a real Hermes-native plugin spec (git/pip/dir) would
2207
+ * `hermes plugins install`/`enable`, but no manifest declares one yet.
2208
+ * - skills: no-op (Hermes ships built-in skills; ClawHub is OpenClaw-only) —
2209
+ * deferred to a later per-capability phase.
2210
+ * - MCP: NOT handled here. MCP delivery for Hermes is a later phase (config-only
2211
+ * `mcp_servers:` via the runtime-agnostic store consumer). This applier owns
2212
+ * config + plugins only.
2213
+ */
2214
+ const execFileAsync = promisify(execFile);
2215
+ const log$2 = createLogger("HermesApplier");
2216
+ const DEFAULT_HERMES_HOME$1 = join(homedir(), ".hermes");
2217
+ /**
2218
+ * Hermes config writes are serialized through one promise chain so concurrent
2219
+ * `applyConfig` calls (the manager can fan out across integrations) never
2220
+ * interleave their `hermes config set` writes against the same config.yaml.
2221
+ *
2222
+ * Whether Hermes hot-reloads config.yaml on each write — the way OpenClaw does,
2223
+ * which forced a retry/backoff there — is NOT yet spike-confirmed. We keep a
2224
+ * modest retry as a precaution; if the spike proves Hermes writes are
2225
+ * synchronous and race-free, the retry count can drop to 0 without API change.
2226
+ */
2227
+ const CONFIG_SET_RETRIES = 3;
2228
+ const CONFIG_SET_RETRY_DELAY_MS = 750;
2229
+ const delay = (ms) => new Promise((resolve) => {
2230
+ setTimeout(resolve, ms);
2231
+ });
2232
+ /** `@alfe.ai/openclaw-*` packages are OpenClaw-specific plugins — not Hermes. */
2233
+ function isOpenClawPlugin(spec) {
2234
+ return stripPluginVersion(spec).startsWith("@alfe.ai/openclaw-");
2235
+ }
2236
+ /**
2237
+ * Serialize a config value for `hermes config set <key> <value>`. Strings pass
2238
+ * through verbatim (the proxy routing keys — provider/base_url/api_key/model —
2239
+ * are all strings, including `${ALFE_API_KEY}` env refs). Non-strings are
2240
+ * JSON-encoded so booleans/numbers/objects survive the CLI round-trip.
2241
+ */
2242
+ function stringifyConfigValue(value) {
2243
+ return typeof value === "string" ? value : JSON.stringify(value);
2244
+ }
2245
+ /**
2246
+ * Describe a `config set/unset` target WITHOUT leaking values. The value
2247
+ * argument can be a secret (`model.api_key`), so it must never reach the
2248
+ * integration's user-facing `errorMessage`. Keep only the program + verb + key.
2249
+ */
2250
+ function redactConfigTarget(args) {
2251
+ return `hermes config ${args.slice(0, 2).join(" ")}`.trim();
2252
+ }
2253
+ /**
2254
+ * Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
2255
+ * execFile error `message` is "Command failed: <full argv>" — which for
2256
+ * `config set` embeds the value payload — so we never surface it. We keep the
2257
+ * redacted target, the exit code, and `stderr` (hermes' own error text).
2258
+ */
2259
+ function configErrorMessage(err, args) {
2260
+ const target = redactConfigTarget(args);
2261
+ if (err instanceof Error) {
2262
+ const e = err;
2263
+ const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
2264
+ const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
2265
+ return stderr ? `${target} failed${code}: ${stderr}` : `${target} failed${code}`;
2266
+ }
2267
+ return `${target} failed: ${String(err)}`;
2268
+ }
2269
+ var HermesApplier = class {
2270
+ runtime = "hermes";
2271
+ home;
2272
+ trackingPath;
2273
+ configSetRetries;
2274
+ configSetRetryDelayMs;
2275
+ /** Serializes all `hermes config` writes so they never interleave. */
2276
+ configSetQueue = Promise.resolve();
2277
+ constructor(options = {}) {
2278
+ this.home = options.home ?? options.workspace ?? DEFAULT_HERMES_HOME$1;
2279
+ this.trackingPath = options.configPath ?? join(this.home, ".alfe-integrations.json");
2280
+ this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES;
2281
+ this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS;
2282
+ }
2283
+ /**
2284
+ * Apply integration config to the Hermes runtime via `hermes config set`.
2285
+ *
2286
+ * Each leaf is applied as `hermes config set <dotted.key> <value>`. Each
2287
+ * integration's config contribution is tracked in the tracking file so it can
2288
+ * be cleanly removed later (mirrors OpenClawApplier's `_integrations`).
2289
+ */
2290
+ async applyConfig(integrationId, config) {
2291
+ const tracking = this.readTracking();
2292
+ const integrations = tracking._integrations ?? {};
2293
+ integrations[integrationId] = config;
2294
+ tracking._integrations = integrations;
2295
+ this.writeTracking(tracking);
2296
+ const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
2297
+ if (subtreesByParent.size > 0) log$2.warn({
2298
+ integrationId,
2299
+ parents: [...subtreesByParent.keys()]
2300
+ }, "Hermes applyConfig: skipping dotted-key subtree(s) — OpenClaw-plugin-shaped config does not apply to Hermes");
2301
+ for (const { path, value } of leaves) try {
2302
+ await this.runConfigSet([path, stringifyConfigValue(value)]);
2303
+ } catch (err) {
2304
+ log$2.error({
2305
+ err: err instanceof Error ? err.message : String(err),
2306
+ key: path
2307
+ }, "Failed to set config via hermes config set");
2308
+ throw err;
2309
+ }
2310
+ }
2311
+ /**
2312
+ * Remove config previously applied by an integration.
2313
+ *
2314
+ * Reads the tracking file to find which keys this integration set, then
2315
+ * removes each via `hermes config unset`. Clears the tracking entry.
2316
+ */
2317
+ async removeConfig(integrationId) {
2318
+ const tracking = this.readTracking();
2319
+ const integrations = tracking._integrations ?? {};
2320
+ if (!(integrationId in integrations)) return;
2321
+ const integrationConfig = integrations[integrationId];
2322
+ const { leaves } = partitionEntries(flattenConfig(integrationConfig));
2323
+ for (const { path } of leaves) try {
2324
+ await this.unsetConfigKey(path);
2325
+ } catch (err) {
2326
+ log$2.warn({
2327
+ err: err instanceof Error ? err.message : String(err),
2328
+ key: path
2329
+ }, "Failed to unset config via hermes config unset");
2330
+ }
2331
+ tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
2332
+ this.writeTracking(tracking);
2333
+ }
2334
+ /**
2335
+ * Raw single-key config write — `hermes config set <key> <value>`.
2336
+ *
2337
+ * Deliberately bypasses the `_integrations` tracking that `applyConfig`
2338
+ * maintains: this is a fire-and-forget write for the `alfe.config_set`
2339
+ * cloud-command, NOT an integration contribution, so it must not be recorded
2340
+ * for later `removeConfig` teardown (doing so would corrupt the
2341
+ * per-integration removal accounting). Reuses the serialized queue.
2342
+ */
2343
+ setConfigRaw(key, value) {
2344
+ return this.runConfigSet([key, value]);
2345
+ }
2346
+ /**
2347
+ * Apply a plugin. The `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific
2348
+ * (they carry the `openclaw` peer dep) and do NOT apply to Hermes — log + skip
2349
+ * them. The manager catches per-plugin, so a skip here is a correct no-op.
2350
+ *
2351
+ * A genuine Hermes-native plugin spec (git URL / pip / local dir) would
2352
+ * `hermes plugins install` then `hermes plugins enable`; no manifest declares
2353
+ * one in Phase 1, so this path is a real attempt (not faked) but untrodden.
2354
+ */
2355
+ async applyPlugin(spec) {
2356
+ if (isOpenClawPlugin(spec)) {
2357
+ log$2.info({ spec }, "Hermes applyPlugin: skipping OpenClaw-specific npm plugin (not a Hermes plugin)");
2358
+ return;
2359
+ }
2360
+ log$2.info({ spec }, "Hermes applyPlugin: installing native Hermes plugin");
2361
+ await execFileAsync("hermes", [
2362
+ "plugins",
2363
+ "install",
2364
+ spec
2365
+ ], { timeout: 6e4 });
2366
+ await execFileAsync("hermes", [
2367
+ "plugins",
2368
+ "enable",
2369
+ spec
2370
+ ], { timeout: 3e4 });
2371
+ }
2372
+ /**
2373
+ * Remove a plugin. OpenClaw-specific npm plugins were never installed into
2374
+ * Hermes (applyPlugin skipped them), so removal is a no-op log. A native
2375
+ * Hermes plugin would be disabled via `hermes plugins`.
2376
+ */
2377
+ async removePlugin(spec) {
2378
+ if (isOpenClawPlugin(spec)) {
2379
+ log$2.info({ spec }, "Hermes removePlugin: skipping OpenClaw-specific npm plugin (was never installed on Hermes)");
2380
+ return;
2381
+ }
2382
+ log$2.info({ spec }, "Hermes removePlugin: disabling native Hermes plugin");
2383
+ await execFileAsync("hermes", [
2384
+ "plugins",
2385
+ "disable",
2386
+ spec
2387
+ ], { timeout: 3e4 });
2388
+ }
2389
+ applySkill(name) {
2390
+ log$2.info({ name }, "Hermes applySkill: no-op (Hermes built-in skills; deferred)");
2391
+ return Promise.resolve();
2392
+ }
2393
+ removeSkill(name) {
2394
+ log$2.info({ name }, "Hermes removeSkill: no-op (Hermes built-in skills; deferred)");
2395
+ return Promise.resolve();
2396
+ }
2397
+ applyClawHubSkill(slug) {
2398
+ log$2.info({ slug }, "Hermes applyClawHubSkill: no-op (ClawHub is OpenClaw-only)");
2399
+ return Promise.resolve();
2400
+ }
2401
+ removeClawHubSkill(slug) {
2402
+ log$2.info({ slug }, "Hermes removeClawHubSkill: no-op (ClawHub is OpenClaw-only)");
2403
+ return Promise.resolve();
2404
+ }
2053
2405
  isAvailable() {
2054
2406
  return Promise.resolve(existsSync(this.home));
2055
2407
  }
2408
+ /** Convenience: `hermes config set <args>`, serialized + retried. */
2409
+ runConfigSet(setArgs) {
2410
+ return this.runConfigCommand(["set", ...setArgs]);
2411
+ }
2412
+ /**
2413
+ * Unset a single config key. The unset verb is isolated HERE so there is one
2414
+ * place to change if the spike proves `hermes config unset` is unavailable.
2415
+ *
2416
+ * FALLBACK (do NOT pre-build): if `hermes config unset` does not exist, the
2417
+ * substitute is `hermes config set <key> ""` (clear the value) or a
2418
+ * read-merge-write of config.yaml. Not implemented now — `unset` is the
2419
+ * documented verb; confirm in the Phase-0 spike before adding a fallback.
2420
+ * TODO(phase-0 spike): confirm `hermes config unset <key>` exists.
2421
+ */
2422
+ unsetConfigKey(key) {
2423
+ return this.runConfigCommand(["unset", key]);
2424
+ }
2425
+ /**
2426
+ * Run `hermes config <args>` (set/unset), serialized against every other
2427
+ * config write and retried with backoff. Throws an Error whose (scrubbed)
2428
+ * message includes stderr after retries are exhausted, so the real cause
2429
+ * propagates to the integration errorMessage without leaking the value.
2430
+ */
2431
+ runConfigCommand(args) {
2432
+ const run = async () => {
2433
+ let lastErr;
2434
+ for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
2435
+ await execFileAsync("hermes", ["config", ...args], { timeout: 1e4 });
2436
+ return;
2437
+ } catch (err) {
2438
+ lastErr = err;
2439
+ if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay(this.configSetRetryDelayMs * 2 ** attempt);
2440
+ }
2441
+ throw new Error(configErrorMessage(lastErr, args));
2442
+ };
2443
+ const result = this.configSetQueue.then(run, run);
2444
+ this.configSetQueue = result.catch(() => void 0);
2445
+ return result;
2446
+ }
2056
2447
  readTracking() {
2057
2448
  if (!existsSync(this.trackingPath)) return {};
2058
2449
  try {
@@ -2067,6 +2458,265 @@ var OpenClawApplier = class {
2067
2458
  }
2068
2459
  };
2069
2460
  //#endregion
2461
+ //#region src/appliers/hermes-mcp-sync.ts
2462
+ /**
2463
+ * HermesMcpSync — Hermes-only CONSUMER of the runtime-agnostic MCP store
2464
+ * (Approach B: config.yaml mirror).
2465
+ *
2466
+ * Background: `McpApplier` writes resolved MCP servers (command/args/env, with
2467
+ * `{{config}}`/`{{credentials}}` already interpolated at apply time) into the
2468
+ * runtime-agnostic bundler store at `~/.alfe/mcp/servers.json`. OpenClaw consumes
2469
+ * that store over IPC (the daemon hosts the bundler children and the openclaw
2470
+ * plugin reaches them). Hermes cannot consume over IPC — it reads MCP servers
2471
+ * from its own `~/.hermes/config.yaml` under the top-level `mcp_servers:` key and
2472
+ * spawns the children itself. So Hermes needs its own consumer that mirrors the
2473
+ * store into `config.yaml`.
2474
+ *
2475
+ * This class is the parallel to OpenClaw's IPC consumption: it subscribes to
2476
+ * `manager.onChange()` and, **only when the active runtime is hermes** (the
2477
+ * daemon only constructs it for hermes agents), read-merge-writes the store into
2478
+ * `~/.hermes/config.yaml`, preserving user-authored `mcp_servers` and every other
2479
+ * config key.
2480
+ *
2481
+ * Two cross-cutting responsibilities make the mirrored servers actually work:
2482
+ *
2483
+ * 1. `ALFE_API_KEY` injection. The Alfe MCP servers (`@alfe.ai/<provider>-mcp`)
2484
+ * fetch their real provider credentials from the Alfe API at startup using
2485
+ * `ALFE_API_KEY`. For OpenClaw the daemon spawns the children, so they
2486
+ * inherit `ALFE_API_KEY` from the daemon's own `process.env`. Hermes spawns
2487
+ * the children itself in a separate process tree, so they would NOT inherit
2488
+ * it — without it they start in a silent zero-accounts degraded mode. We
2489
+ * therefore inject an `ALFE_API_KEY` reference into every mirrored stdio
2490
+ * server's env and write the actual secret into `~/.hermes/.env`
2491
+ * (read-merge-write, never clobbering other keys).
2492
+ *
2493
+ * 2. Restart. Writing `config.yaml` is assumed to require a runtime reload (vs a
2494
+ * hot-reload) — SPIKE-PENDING — so after a real change we trigger the EXISTING
2495
+ * runtime-restart path (the same callback the daemon's
2496
+ * `setRuntimeRestartNeededHandler` uses), never a second restart mechanism.
2497
+ *
2498
+ * The daemon gates its own bundler OFF for hermes (skips `loadIntoBundler` +
2499
+ * `warmup`) so the store is a pure ledger and the MCP children are spawned ONLY
2500
+ * by Hermes — avoiding a double-spawn of every server.
2501
+ */
2502
+ const log$1 = createLogger("HermesMcpSync");
2503
+ const DEFAULT_HERMES_HOME = join(homedir(), ".hermes");
2504
+ /**
2505
+ * SPIKE-PENDING SEAM #1 — `${VAR}` interpolation from `~/.hermes/.env`.
2506
+ *
2507
+ * We inject `ALFE_API_KEY=${ALFE_API_KEY}` into every Alfe-owned stdio server's
2508
+ * env and put the real value in `~/.hermes/.env`. This assumes Hermes
2509
+ * interpolates `${VAR}` references in `mcp_servers.<id>.env` from `.env` at spawn
2510
+ * time. TODO(phase-0 spike): confirm. If Hermes does NOT interpolate, the
2511
+ * fallback (NOT built here) is to write the literal `ALFE_API_KEY` VALUE inline
2512
+ * into each `mcp_servers.<id>.env` and skip the `.env` file entirely — change
2513
+ * `withAlfeApiKey()` + `ensureEnvApiKey()` together in that one case.
2514
+ */
2515
+ const ALFE_API_KEY_ENV_REF = "${ALFE_API_KEY}";
2516
+ const DEFAULT_DEBOUNCE_MS = 250;
2517
+ /**
2518
+ * Read-merge-write mirror of the MCP store into `~/.hermes/config.yaml`.
2519
+ *
2520
+ * Ownership / removal model: every entry returned by `manager.listServers()`
2521
+ * comes from the Alfe store (its `owner` is `integration:<id>` / `cli` /
2522
+ * `manual`) and is therefore Alfe-owned — these are the ids we write. User
2523
+ * authored `mcp_servers` entries live ONLY in config.yaml and never appear in
2524
+ * the store, so we never touch them. To know which config.yaml ids to REMOVE
2525
+ * when a server leaves the store, we persist the set of ids we last wrote to a
2526
+ * sidecar (`.alfe-mcp-synced.json`) and only ever delete from that set — exactly
2527
+ * how the bundler store tracks `_ownedOpenclawKeys` for its openclaw.json mirror.
2528
+ */
2529
+ var HermesMcpSync = class {
2530
+ manager;
2531
+ home;
2532
+ apiKey;
2533
+ requestRestart;
2534
+ configPath;
2535
+ envPath;
2536
+ trackingPath;
2537
+ debounceMs;
2538
+ /** Ids of `mcp_servers` entries this sync last wrote — the removal set. */
2539
+ syncedIds = /* @__PURE__ */ new Set();
2540
+ /** Serializes syncs so two onChange-driven runs can't interleave file writes. */
2541
+ queue = Promise.resolve();
2542
+ unsubscribe;
2543
+ debounceTimer;
2544
+ started = false;
2545
+ constructor(opts) {
2546
+ this.manager = opts.manager;
2547
+ this.home = opts.home ?? DEFAULT_HERMES_HOME;
2548
+ this.apiKey = opts.apiKey;
2549
+ this.requestRestart = opts.requestRestart;
2550
+ this.configPath = opts.configPath ?? join(this.home, "config.yaml");
2551
+ this.envPath = opts.envPath ?? join(this.home, ".env");
2552
+ this.trackingPath = opts.trackingPath ?? join(this.home, ".alfe-mcp-synced.json");
2553
+ this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
2554
+ }
2555
+ /**
2556
+ * Begin mirroring: load the prior removal set, run one immediate sync (so
2557
+ * config.yaml reflects the current store before Hermes first starts), then
2558
+ * subscribe to store changes (debounced). Idempotent.
2559
+ */
2560
+ start() {
2561
+ if (this.started) return;
2562
+ this.started = true;
2563
+ this.loadSyncedIds();
2564
+ this.syncOnce().catch((err) => {
2565
+ log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync: initial sync failed");
2566
+ });
2567
+ this.unsubscribe = this.manager.onChange(() => {
2568
+ this.schedule();
2569
+ });
2570
+ log$1.info({ configPath: this.configPath }, "Hermes MCP sync started — mirroring store into config.yaml");
2571
+ }
2572
+ /** Stop subscribing and cancel any pending debounced sync. Idempotent. */
2573
+ stop() {
2574
+ if (this.unsubscribe) {
2575
+ this.unsubscribe();
2576
+ this.unsubscribe = void 0;
2577
+ }
2578
+ if (this.debounceTimer) {
2579
+ clearTimeout(this.debounceTimer);
2580
+ this.debounceTimer = void 0;
2581
+ }
2582
+ this.started = false;
2583
+ }
2584
+ /**
2585
+ * Mirror the current store into config.yaml + .env exactly once. Public so the
2586
+ * daemon (and tests) can await a deterministic sync. Serialized against any
2587
+ * other in-flight sync.
2588
+ */
2589
+ syncOnce() {
2590
+ const run = () => {
2591
+ this.syncNow();
2592
+ return Promise.resolve();
2593
+ };
2594
+ const result = this.queue.then(run, run);
2595
+ this.queue = result.catch(() => void 0);
2596
+ return result;
2597
+ }
2598
+ schedule() {
2599
+ if (this.debounceMs <= 0) {
2600
+ this.syncOnce().catch((err) => {
2601
+ log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync failed");
2602
+ });
2603
+ return;
2604
+ }
2605
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
2606
+ this.debounceTimer = setTimeout(() => {
2607
+ this.debounceTimer = void 0;
2608
+ this.syncOnce().catch((err) => {
2609
+ log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync failed");
2610
+ });
2611
+ }, this.debounceMs);
2612
+ this.debounceTimer.unref();
2613
+ }
2614
+ syncNow() {
2615
+ const desired = this.computeDesired();
2616
+ const desiredIds = new Set(desired.keys());
2617
+ const doc = parseDocument(existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "");
2618
+ const before = doc.toString();
2619
+ for (const [id, entry] of desired) doc.setIn(["mcp_servers", id], entry);
2620
+ for (const id of this.syncedIds) if (!desiredIds.has(id)) doc.deleteIn(["mcp_servers", id]);
2621
+ const after = doc.toString();
2622
+ let changed = before !== after;
2623
+ if (changed) {
2624
+ mkdirSync(dirname(this.configPath), { recursive: true });
2625
+ writeFileSync(this.configPath, after, "utf-8");
2626
+ log$1.info({
2627
+ added: [...desiredIds],
2628
+ removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
2629
+ }, "Hermes MCP sync: config.yaml mcp_servers updated");
2630
+ }
2631
+ this.syncedIds = desiredIds;
2632
+ this.persistSyncedIds();
2633
+ if (desiredIds.size > 0 && this.ensureEnvApiKey()) changed = true;
2634
+ if (changed) this.requestRestart?.();
2635
+ }
2636
+ computeDesired() {
2637
+ const desired = /* @__PURE__ */ new Map();
2638
+ for (const { id, entry } of this.manager.listServers()) desired.set(id, this.toHermesEntry(entry));
2639
+ return desired;
2640
+ }
2641
+ /**
2642
+ * Transform a stored entry into a Hermes `mcp_servers` entry. SPIKE-PENDING
2643
+ * SEAM #3 lives here — the schema shape. stdio entries get `ALFE_API_KEY`
2644
+ * injected; remote entries pass through (Hermes supports `url`-based MCP).
2645
+ */
2646
+ toHermesEntry(entry) {
2647
+ const cfg = toServerConfig(entry);
2648
+ if ("command" in cfg) {
2649
+ const out = { command: cfg.command };
2650
+ if (cfg.args && cfg.args.length > 0) out.args = cfg.args;
2651
+ out.env = this.withAlfeApiKey(cfg.env);
2652
+ if (cfg.cwd) out.cwd = cfg.cwd;
2653
+ return out;
2654
+ }
2655
+ const out = { url: cfg.url };
2656
+ if (cfg.transport) out.transport = cfg.transport;
2657
+ if (cfg.headers) out.headers = cfg.headers;
2658
+ return out;
2659
+ }
2660
+ withAlfeApiKey(env) {
2661
+ const merged = { ...env ?? {} };
2662
+ if (!("ALFE_API_KEY" in merged)) merged.ALFE_API_KEY = ALFE_API_KEY_ENV_REF;
2663
+ return merged;
2664
+ }
2665
+ /** Read-merge-write `~/.hermes/.env` to set ALFE_API_KEY without clobbering other keys. */
2666
+ ensureEnvApiKey() {
2667
+ if (!this.apiKey) {
2668
+ log$1.warn("Hermes MCP sync: no ALFE_API_KEY available — mirrored MCP servers will start in zero-accounts degraded mode");
2669
+ return false;
2670
+ }
2671
+ return upsertEnvVar(this.envPath, "ALFE_API_KEY", this.apiKey);
2672
+ }
2673
+ loadSyncedIds() {
2674
+ if (!existsSync(this.trackingPath)) return;
2675
+ try {
2676
+ const data = JSON.parse(readFileSync(this.trackingPath, "utf-8"));
2677
+ if (Array.isArray(data.syncedIds)) this.syncedIds = new Set(data.syncedIds.filter((x) => typeof x === "string"));
2678
+ } catch {}
2679
+ }
2680
+ persistSyncedIds() {
2681
+ try {
2682
+ mkdirSync(dirname(this.trackingPath), { recursive: true });
2683
+ writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
2684
+ } catch (err) {
2685
+ log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync: failed to persist synced-id sidecar");
2686
+ }
2687
+ }
2688
+ };
2689
+ /**
2690
+ * Set `KEY=value` in a `.env`-style file, preserving every other line (comments,
2691
+ * blank lines, unrelated keys) and order. Returns whether the file changed. Not
2692
+ * a full dotenv parser — it only matches simple `KEY=` lines, which is all
2693
+ * `~/.hermes/.env` ever holds.
2694
+ */
2695
+ function upsertEnvVar(envPath, key, value) {
2696
+ const desiredLine = `${key}=${value}`;
2697
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
2698
+ const lines = existing.length > 0 ? existing.replace(/\n$/, "").split("\n") : [];
2699
+ const keyRe = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/;
2700
+ const idx = lines.findIndex((line) => keyRe.exec(line)?.[1] === key);
2701
+ let out;
2702
+ let changed;
2703
+ if (idx === -1) {
2704
+ out = [...lines, desiredLine];
2705
+ changed = true;
2706
+ } else {
2707
+ changed = lines[idx] !== desiredLine;
2708
+ out = lines.map((line, i) => i === idx ? desiredLine : line);
2709
+ }
2710
+ if (changed) {
2711
+ mkdirSync(dirname(envPath), { recursive: true });
2712
+ writeFileSync(envPath, out.join("\n") + "\n", "utf-8");
2713
+ }
2714
+ return changed;
2715
+ }
2716
+ function errMsg$1(err) {
2717
+ return err instanceof Error ? err.message : String(err);
2718
+ }
2719
+ //#endregion
2070
2720
  //#region src/appliers/mcp-applier.ts
2071
2721
  const log = createLogger("McpApplier");
2072
2722
  const CONFIG_TEMPLATE_RE = /\{\{config\.([a-zA-Z0-9_]+)\}\}/g;
@@ -2246,4 +2896,4 @@ var IntegrationManagerAdapter = class {
2246
2896
  }
2247
2897
  };
2248
2898
  //#endregion
2249
- export { DEFAULT_REGISTRY_TTL_MS, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, OpenClawApplier, Registry, RegistryResolveError, Resolver, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext };
2899
+ export { DEFAULT_REGISTRY_TTL_MS, HermesApplier, HermesMcpSync, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, OpenClawApplier, Registry, RegistryResolveError, Resolver, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };