@alfe.ai/integrations 0.4.1 → 0.5.0
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.d.ts +198 -1
- package/dist/index.js +561 -72
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -1508,6 +1508,203 @@ declare class HermesMcpSync {
|
|
|
1508
1508
|
*/
|
|
1509
1509
|
declare function upsertEnvVar(envPath: string, key: string, value: string): boolean;
|
|
1510
1510
|
//#endregion
|
|
1511
|
+
//#region src/appliers/claude-code-applier.d.ts
|
|
1512
|
+
interface ClaudeCodeApplierOptions {
|
|
1513
|
+
/**
|
|
1514
|
+
* Path to the claude-code home directory (e.g. ~/.claude-code) — where the
|
|
1515
|
+
* config ledger and the generated `--mcp-config` live. For claude-code,
|
|
1516
|
+
* home == workspace.
|
|
1517
|
+
*/
|
|
1518
|
+
home?: string;
|
|
1519
|
+
/**
|
|
1520
|
+
* @deprecated Use `home`. Kept for parity with the OpenClaw/Hermes applier
|
|
1521
|
+
* option shape — when set, used as `home`.
|
|
1522
|
+
*/
|
|
1523
|
+
workspace?: string;
|
|
1524
|
+
/** Path to the per-integration config ledger (defaults to {home}/.alfe-integrations.json). */
|
|
1525
|
+
configPath?: string;
|
|
1526
|
+
/**
|
|
1527
|
+
* Path to Claude Code's native skills directory (defaults to
|
|
1528
|
+
* ~/.claude/skills). Override for tests.
|
|
1529
|
+
*/
|
|
1530
|
+
skillsDir?: string;
|
|
1531
|
+
}
|
|
1532
|
+
declare class ClaudeCodeApplier implements RuntimeApplier {
|
|
1533
|
+
readonly runtime = "claude-code";
|
|
1534
|
+
private home;
|
|
1535
|
+
/** `~/.claude-code/.alfe-integrations.json` — per-integration config ledger + raw-key store. */
|
|
1536
|
+
private ledgerPath;
|
|
1537
|
+
/** `~/.claude/skills` — where native Claude Code skills are copied. */
|
|
1538
|
+
private skillsDir;
|
|
1539
|
+
constructor(options?: ClaudeCodeApplierOptions);
|
|
1540
|
+
/**
|
|
1541
|
+
* Record an integration's config contribution in the ledger. Claude Code has
|
|
1542
|
+
* no integration-config CLI, so this is a pure ledger write — it makes the
|
|
1543
|
+
* per-integration accounting honest (so `removeConfig` and the lock's
|
|
1544
|
+
* config-only tracking work) without shelling anything. Overwrites the prior
|
|
1545
|
+
* contribution for this integration (convergent by construction — a full
|
|
1546
|
+
* replace, no stale-key diff needed since nothing is applied to a runtime).
|
|
1547
|
+
*/
|
|
1548
|
+
applyConfig(integrationId: string, config: Record<string, unknown>): Promise<void>;
|
|
1549
|
+
/** Clear an integration's config contribution from the ledger. */
|
|
1550
|
+
removeConfig(integrationId: string): Promise<void>;
|
|
1551
|
+
/**
|
|
1552
|
+
* Raw single-key config write for the daemon's `alfe.config_set` cloud-command.
|
|
1553
|
+
*
|
|
1554
|
+
* Deliberately bypasses the `_integrations` per-integration accounting (a raw
|
|
1555
|
+
* set is untracked — routing it through `applyConfig` would corrupt an
|
|
1556
|
+
* integration's removal accounting). Stored under a separate `_raw` map in the
|
|
1557
|
+
* same ledger file. Inert as far as the runtime goes (Claude Code has no config
|
|
1558
|
+
* CLI to push it into), but it degrades cleanly — the daemon gets an OK ack
|
|
1559
|
+
* instead of `CONFIG_SET_UNSUPPORTED`, and the value round-trips via
|
|
1560
|
+
* `getConfigRaw`.
|
|
1561
|
+
*/
|
|
1562
|
+
setConfigRaw(key: string, value: string): Promise<void>;
|
|
1563
|
+
/**
|
|
1564
|
+
* Read a raw config key back from the ledger — the untracked read companion to
|
|
1565
|
+
* `setConfigRaw`, used by the daemon's config-reconcile pass (value-diff before
|
|
1566
|
+
* write; verify a write landed). Returns the exact string `setConfigRaw` stored,
|
|
1567
|
+
* or `undefined` when the key was never set. Never throws.
|
|
1568
|
+
*/
|
|
1569
|
+
getConfigRaw(key: string): Promise<string | undefined>;
|
|
1570
|
+
/**
|
|
1571
|
+
* Apply a plugin. The `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific
|
|
1572
|
+
* and do NOT load in Claude Code — log + skip them. No claude-code-native
|
|
1573
|
+
* plugin spec exists, so any non-openclaw spec is also skipped (rather than a
|
|
1574
|
+
* blind shell of an unknown installer). The manager catches per-plugin, so a
|
|
1575
|
+
* skip here is a correct no-op.
|
|
1576
|
+
*/
|
|
1577
|
+
applyPlugin(spec: string): Promise<void>;
|
|
1578
|
+
/** Remove a plugin. Nothing was installed (applyPlugin skipped it) — no-op log. */
|
|
1579
|
+
removePlugin(spec: string): Promise<void>;
|
|
1580
|
+
/**
|
|
1581
|
+
* Copy a skill directory into `~/.claude/skills/<name>/`. Claude Code reads
|
|
1582
|
+
* native Agent Skills from there, so an Alfe skill dir lands as a first-class
|
|
1583
|
+
* Claude Code skill. Idempotent: an existing target is replaced (a
|
|
1584
|
+
* newer-version re-apply must overwrite).
|
|
1585
|
+
*/
|
|
1586
|
+
applySkill(name: string, srcPath: string): Promise<void>;
|
|
1587
|
+
/** Remove a skill directory from `~/.claude/skills/<name>/`. */
|
|
1588
|
+
removeSkill(name: string): Promise<void>;
|
|
1589
|
+
/**
|
|
1590
|
+
* Install a ClawHub skill natively. ClawHub is an OpenClaw registry, but the
|
|
1591
|
+
* skill payload is a plain directory — for claude-code we treat the slug as the
|
|
1592
|
+
* skill name and expect the caller to have staged the directory. Absent a
|
|
1593
|
+
* staged source we log + skip (no OpenClaw `skills install` CLI on this
|
|
1594
|
+
* runtime); a future claude-code ClawHub fetch would stage the dir then copy it
|
|
1595
|
+
* exactly like `applySkill`.
|
|
1596
|
+
*
|
|
1597
|
+
* NOTE: unlike OpenClaw's `applyClawHubSkill` (which THROWS on a real install
|
|
1598
|
+
* failure so an all-skills-fail activation is marked `error`), there is no
|
|
1599
|
+
* install to fail here — the skill either lands from a staged directory or is a
|
|
1600
|
+
* clean no-op. We never throw for a claude-code ClawHub skill.
|
|
1601
|
+
*/
|
|
1602
|
+
applyClawHubSkill(slug: string): Promise<void>;
|
|
1603
|
+
/** Remove a ClawHub-installed skill directory (by slug == dir name). */
|
|
1604
|
+
removeClawHubSkill(slug: string): Promise<void>;
|
|
1605
|
+
isAvailable(): Promise<boolean>;
|
|
1606
|
+
private copySkillDir;
|
|
1607
|
+
private deleteSkillDir;
|
|
1608
|
+
private readLedger;
|
|
1609
|
+
private writeLedger;
|
|
1610
|
+
}
|
|
1611
|
+
//#endregion
|
|
1612
|
+
//#region src/appliers/claude-code-mcp-sync.d.ts
|
|
1613
|
+
interface ClaudeCodeMcpSyncOptions {
|
|
1614
|
+
/** The runtime-agnostic MCP store manager (read + onChange). */
|
|
1615
|
+
manager: McpStoreReader;
|
|
1616
|
+
/** Claude-code home (== ~/.claude-code). mcp-config.json lives here. */
|
|
1617
|
+
home?: string;
|
|
1618
|
+
/**
|
|
1619
|
+
* The `ALFE_API_KEY` value injected into every generated stdio server's env so
|
|
1620
|
+
* the Alfe MCP servers resolve their provider credentials. In managed mode
|
|
1621
|
+
* this is the daemon's own api key; self-hosted threads it explicitly. When
|
|
1622
|
+
* absent, injection is skipped and a warning is logged (the servers would then
|
|
1623
|
+
* start in zero-accounts degraded mode). Unlike Hermes there is no `.env`
|
|
1624
|
+
* side-file — the value is written directly into the generated env block.
|
|
1625
|
+
*/
|
|
1626
|
+
apiKey?: string;
|
|
1627
|
+
/**
|
|
1628
|
+
* Trigger a runtime reload after a real change. Accepted for parity with
|
|
1629
|
+
* `HermesMcpSyncOptions` so the daemon can wire claude-code identically — but
|
|
1630
|
+
* Claude Code reads `--mcp-config` fresh per `claude -p` spawn, so this sync
|
|
1631
|
+
* NEVER calls it. Kept optional and unused; see the file header.
|
|
1632
|
+
*/
|
|
1633
|
+
requestRestart?: () => void;
|
|
1634
|
+
/** Override the mcp-config.json path (defaults to {home}/mcp-config.json). For tests. */
|
|
1635
|
+
configPath?: string;
|
|
1636
|
+
/**
|
|
1637
|
+
* Override the sidecar that records which `mcpServers` ids this sync wrote
|
|
1638
|
+
* (defaults to {home}/.alfe-mcp-synced.json). Used so removals survive daemon
|
|
1639
|
+
* restarts (a full regenerate makes it strictly informational here, but kept
|
|
1640
|
+
* for parity + forensics).
|
|
1641
|
+
*/
|
|
1642
|
+
trackingPath?: string;
|
|
1643
|
+
/**
|
|
1644
|
+
* Trailing debounce (ms) used to coalesce a burst of store mutations (one
|
|
1645
|
+
* integration install fires `addServer` once per server) into a single file
|
|
1646
|
+
* write, avoiding a write storm. Default 250; set 0 in tests. The initial sync
|
|
1647
|
+
* in `start()` is always immediate.
|
|
1648
|
+
*/
|
|
1649
|
+
debounceMs?: number;
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Full-regenerate writer of the MCP store into `~/.claude-code/mcp-config.json`.
|
|
1653
|
+
*
|
|
1654
|
+
* Ownership / removal model: every entry returned by `manager.listServers()`
|
|
1655
|
+
* comes from the Alfe store (its `owner` is `integration:<id>` / `cli` /
|
|
1656
|
+
* `manual`) and is therefore Alfe-owned. Unlike the Hermes config.yaml mirror
|
|
1657
|
+
* (which must preserve user-authored `mcp_servers` in a shared file), the
|
|
1658
|
+
* `--mcp-config` file is Alfe-generated and Alfe-owned end to end — Claude Code's
|
|
1659
|
+
* OWN user-authored MCP servers live in `~/.claude.json` / project `.mcp.json`,
|
|
1660
|
+
* NOT this generated file. So we fully regenerate it from the store on every
|
|
1661
|
+
* change (no read-merge-write needed). We still persist the written-id set to a
|
|
1662
|
+
* sidecar for parity + forensics, but removal is implicit in the regenerate.
|
|
1663
|
+
*/
|
|
1664
|
+
declare class ClaudeCodeMcpSync {
|
|
1665
|
+
private readonly manager;
|
|
1666
|
+
private readonly home;
|
|
1667
|
+
private readonly apiKey?;
|
|
1668
|
+
private readonly configPath;
|
|
1669
|
+
private readonly trackingPath;
|
|
1670
|
+
private readonly debounceMs;
|
|
1671
|
+
/** Ids of `mcpServers` entries this sync last wrote — informational sidecar. */
|
|
1672
|
+
private syncedIds;
|
|
1673
|
+
/** Serializes syncs so two onChange-driven runs can't interleave file writes. */
|
|
1674
|
+
private queue;
|
|
1675
|
+
private unsubscribe?;
|
|
1676
|
+
private debounceTimer?;
|
|
1677
|
+
private started;
|
|
1678
|
+
constructor(opts: ClaudeCodeMcpSyncOptions);
|
|
1679
|
+
/**
|
|
1680
|
+
* Begin mirroring: load the prior removal set, run one immediate sync (so the
|
|
1681
|
+
* `--mcp-config` file reflects the current store before the host next spawns
|
|
1682
|
+
* `claude -p`), then subscribe to store changes (debounced). Idempotent.
|
|
1683
|
+
*/
|
|
1684
|
+
start(): void;
|
|
1685
|
+
/** Stop subscribing and cancel any pending debounced sync. Idempotent. */
|
|
1686
|
+
stop(): void;
|
|
1687
|
+
/**
|
|
1688
|
+
* Regenerate the `--mcp-config` file from the current store exactly once.
|
|
1689
|
+
* Public so the daemon (and tests) can await a deterministic sync. Serialized
|
|
1690
|
+
* against any other in-flight sync.
|
|
1691
|
+
*/
|
|
1692
|
+
syncOnce(): Promise<void>;
|
|
1693
|
+
private schedule;
|
|
1694
|
+
private syncNow;
|
|
1695
|
+
private computeDesired;
|
|
1696
|
+
/**
|
|
1697
|
+
* Transform a stored entry into a Claude Code `--mcp-config` entry. stdio
|
|
1698
|
+
* entries get the literal `ALFE_API_KEY` injected; remote entries pass through
|
|
1699
|
+
* with the documented `type` discriminator (`sse` → `sse`, `streamable-http`
|
|
1700
|
+
* → `http`).
|
|
1701
|
+
*/
|
|
1702
|
+
private toClaudeCodeEntry;
|
|
1703
|
+
private withAlfeApiKey;
|
|
1704
|
+
private loadSyncedIds;
|
|
1705
|
+
private persistSyncedIds;
|
|
1706
|
+
}
|
|
1707
|
+
//#endregion
|
|
1511
1708
|
//#region src/lock.d.ts
|
|
1512
1709
|
interface RuntimePluginEntry {
|
|
1513
1710
|
/**
|
|
@@ -1677,4 +1874,4 @@ declare class IntegrationManagerAdapter implements IIntegrationManager {
|
|
|
1677
1874
|
resetReinstallAttempts(integrationId: string): void;
|
|
1678
1875
|
}
|
|
1679
1876
|
//#endregion
|
|
1680
|
-
export { type CredentialsResolver, DEFAULT_REGISTRY_TTL_MS, HermesApplier, type HermesApplierOptions, HermesMcpSync, type HermesMcpSyncOptions, type HookEnvOptions, type HookResult, type IIntegrationManager, type InstalledInfo, Installer, InstallerError, type IntegrationConfigureParams, type IntegrationHealthParams, type IntegrationInfo, type IntegrationInstallParams, IntegrationManager, IntegrationManagerAdapter, type IntegrationManagerOptions, type IntegrationRemoveParams, type LoadOptions, LockManager, McpApplier, type McpApplierOptions, type McpStoreReader, NoopOpenClawCliLock, OpenClawApplier, type OpenClawApplierOptions, type OpenClawCliLock, type PlatformContext, Registry, type RegistryEntry, type RegistryFetcher, type RegistryIndex, type RegistryOptions, RegistryResolveError, type ResolveOptions, type ResolvedIntegration, Resolver, type RuntimeApplier, type RuntimeConfigEntry, type RuntimeDesiredState, type RuntimeLockFile, type RuntimePluginEntry, type RuntimeSkillEntry, SerialOpenClawCliLock, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };
|
|
1877
|
+
export { ClaudeCodeApplier, type ClaudeCodeApplierOptions, ClaudeCodeMcpSync, type ClaudeCodeMcpSyncOptions, type CredentialsResolver, DEFAULT_REGISTRY_TTL_MS, HermesApplier, type HermesApplierOptions, HermesMcpSync, type HermesMcpSyncOptions, type HookEnvOptions, type HookResult, type IIntegrationManager, type InstalledInfo, Installer, InstallerError, type IntegrationConfigureParams, type IntegrationHealthParams, type IntegrationInfo, type IntegrationInstallParams, IntegrationManager, IntegrationManagerAdapter, type IntegrationManagerOptions, type IntegrationRemoveParams, type LoadOptions, LockManager, McpApplier, type McpApplierOptions, type McpStoreReader, NoopOpenClawCliLock, OpenClawApplier, type OpenClawApplierOptions, type OpenClawCliLock, type PlatformContext, Registry, type RegistryEntry, type RegistryFetcher, type RegistryIndex, type RegistryOptions, RegistryResolveError, type ResolveOptions, type ResolvedIntegration, Resolver, type RuntimeApplier, type RuntimeConfigEntry, type RuntimeDesiredState, type RuntimeLockFile, type RuntimePluginEntry, type RuntimeSkillEntry, SerialOpenClawCliLock, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { promisify } from "node:util";
|
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { homedir, platform, tmpdir } from "node:os";
|
|
6
|
-
import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { chmodSync, closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { buildConfigValidationSchema, parseManifestFile } from "@alfe.ai/integration-manifest";
|
|
8
8
|
import { createLogger } from "@auriclabs/logger";
|
|
9
9
|
import { parseDocument } from "yaml";
|
|
@@ -153,7 +153,7 @@ var Resolver = class {
|
|
|
153
153
|
* can resolve them via Node's upward module resolution.
|
|
154
154
|
*/
|
|
155
155
|
const execFileAsync$2 = promisify(execFile);
|
|
156
|
-
const log$
|
|
156
|
+
const log$6 = createLogger("Installer");
|
|
157
157
|
const INTEGRATIONS_DIR = join(homedir(), ".alfe", "integrations");
|
|
158
158
|
const GIT_TIMEOUT_MS = 6e4;
|
|
159
159
|
const NPM_TIMEOUT_MS = 6e4;
|
|
@@ -342,7 +342,7 @@ var Installer = class {
|
|
|
342
342
|
force: true
|
|
343
343
|
});
|
|
344
344
|
} catch (err) {
|
|
345
|
-
log$
|
|
345
|
+
log$6.warn({
|
|
346
346
|
dir: entry.name,
|
|
347
347
|
err: err instanceof Error ? err.message : String(err)
|
|
348
348
|
}, "Failed to sweep orphaned staging dir");
|
|
@@ -397,7 +397,7 @@ var Installer = class {
|
|
|
397
397
|
dependencies: { ...SHARED_PACKAGES }
|
|
398
398
|
};
|
|
399
399
|
writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n", "utf-8");
|
|
400
|
-
log$
|
|
400
|
+
log$6.info("Installing shared @alfe.ai packages for integration hooks");
|
|
401
401
|
await this.runNpmInstall(this.basePath);
|
|
402
402
|
this.sharedPackagesReady = true;
|
|
403
403
|
}
|
|
@@ -407,7 +407,7 @@ var Installer = class {
|
|
|
407
407
|
*/
|
|
408
408
|
async installLocalDependencies(installPath) {
|
|
409
409
|
if (!existsSync(join(installPath, "package.json"))) return;
|
|
410
|
-
log$
|
|
410
|
+
log$6.info({ path: installPath }, "Installing integration-specific npm dependencies");
|
|
411
411
|
await this.runNpmInstall(installPath);
|
|
412
412
|
}
|
|
413
413
|
async runNpmInstall(cwd) {
|
|
@@ -1302,15 +1302,27 @@ var IntegrationManager = class {
|
|
|
1302
1302
|
}
|
|
1303
1303
|
if (pluginFailures.length > 0 && pluginFailures.length === plugins.length) throw new Error(`All plugins failed to install: ${pluginFailures.join(", ")}`);
|
|
1304
1304
|
if (pluginFailures.length > 0) this.log.warn(`Continuing activation with ${String(pluginFailures.length)} failed plugin(s): ${pluginFailures.join(", ")}`);
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1305
|
+
const skillFailures = [];
|
|
1306
|
+
for (const skill of skills) {
|
|
1307
|
+
const skillLabel = skill.clawhub ?? skill.path ?? "unknown";
|
|
1308
|
+
try {
|
|
1309
|
+
if (skill.clawhub) {
|
|
1310
|
+
this.log.info(`Installing ClawHub skill ${skill.clawhub} to ${runtimeName}`);
|
|
1311
|
+
await applier.applyClawHubSkill(skill.clawhub);
|
|
1312
|
+
} else if (skill.path) {
|
|
1313
|
+
const skillName = skill.path.split("/").pop() ?? skill.path;
|
|
1314
|
+
const srcPath = join(installPath, skill.path);
|
|
1315
|
+
this.log.info(`Applying skill ${skillName} to ${runtimeName}`);
|
|
1316
|
+
await applier.applySkill(skillName, srcPath);
|
|
1317
|
+
}
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1320
|
+
this.log.error(`Failed to apply skill ${skillLabel}: ${msg}`);
|
|
1321
|
+
skillFailures.push(skillLabel);
|
|
1322
|
+
}
|
|
1313
1323
|
}
|
|
1324
|
+
if (skillFailures.length > 0 && skillFailures.length === skills.length) throw new Error(`All skills failed to install: ${skillFailures.join(", ")}`);
|
|
1325
|
+
if (skillFailures.length > 0) this.log.warn(`Continuing activation with ${String(skillFailures.length)} failed skill(s): ${skillFailures.join(", ")}`);
|
|
1314
1326
|
let runtimeConfigApplied = false;
|
|
1315
1327
|
if (runtimeConfig && Object.keys(runtimeConfig).length > 0) {
|
|
1316
1328
|
const agentConfig = entry.config;
|
|
@@ -2086,7 +2098,7 @@ function partitionEntries(entries) {
|
|
|
2086
2098
|
* is stored in a separate tracking file (config.json) for clean removal.
|
|
2087
2099
|
*/
|
|
2088
2100
|
const execFileAsync$1 = promisify(execFile);
|
|
2089
|
-
const log$
|
|
2101
|
+
const log$5 = createLogger("OpenClawApplier");
|
|
2090
2102
|
const DEFAULT_SKILLS_DIR = join(homedir(), ".alfe", "skills");
|
|
2091
2103
|
/**
|
|
2092
2104
|
* Bundled OpenClaw plugins that ship inside the runtime (not installed as npm
|
|
@@ -2113,6 +2125,22 @@ const BUNDLED_BASELINE_ALLOW = ["browser"];
|
|
|
2113
2125
|
const CONFIG_SET_RETRIES$1 = 3;
|
|
2114
2126
|
const CONFIG_SET_RETRY_DELAY_MS$1 = 750;
|
|
2115
2127
|
/**
|
|
2128
|
+
* Boot-safe exec timeout for the OUT-OF-BAND `setConfigRaw` write (the daemon's
|
|
2129
|
+
* config-reconcile pass / `alfe.config_set`). Unlike integration `applyConfig`,
|
|
2130
|
+
* which runs under the gateway RuntimeGate with the runtime SUSPENDED (no CPU
|
|
2131
|
+
* contention, so the 10s execFile default is plenty), `setConfigRaw` fires while
|
|
2132
|
+
* `openclaw gateway run` is live and — on a fresh managed box — still BOOTING.
|
|
2133
|
+
* On a 2-vCPU Hetzner cx-class instance the `openclaw config set` CLI cold-start
|
|
2134
|
+
* takes >10s under that boot contention, so the 10s default SIGKILLs the child
|
|
2135
|
+
* mid-write and every retry lands inside the same ~30s boot window and times out
|
|
2136
|
+
* identically (observed in prod: two `config set agents.defaults.model` attempts
|
|
2137
|
+
* logged exactly 10s apart with no exit line, then a 3rd succeeded once load
|
|
2138
|
+
* eased). 60s clears the cold-start-under-contention worst case while still
|
|
2139
|
+
* bounding a genuinely hung CLI. This is ONLY for `setConfigRaw` — the
|
|
2140
|
+
* integration `applyConfig` paths keep the 10s default (they're not contended).
|
|
2141
|
+
*/
|
|
2142
|
+
const CONFIG_SET_RAW_TIMEOUT_MS = 6e4;
|
|
2143
|
+
/**
|
|
2116
2144
|
* Max `openclaw config get` spawns in flight at once during the batch
|
|
2117
2145
|
* verify-after-write. A naive `Promise.all(leaves.map(...))` fires one CLI
|
|
2118
2146
|
* process per leaf simultaneously — a 36-leaf model-provider batch on a 2-vCPU
|
|
@@ -2481,7 +2509,7 @@ var OpenClawApplier = class {
|
|
|
2481
2509
|
return;
|
|
2482
2510
|
} catch (err) {
|
|
2483
2511
|
if (!isMalformedStateDbError(err)) throw err;
|
|
2484
|
-
log$
|
|
2512
|
+
log$5.warn({ args: args.slice(0, 2) }, "openclaw CLI failed with a malformed state DB — attempting self-heal then one retry");
|
|
2485
2513
|
if (!await this.healMalformedStateDb()) throw err;
|
|
2486
2514
|
await execFileAsync$1("openclaw", args, opts);
|
|
2487
2515
|
}
|
|
@@ -2507,7 +2535,7 @@ var OpenClawApplier = class {
|
|
|
2507
2535
|
async healMalformedStateDb() {
|
|
2508
2536
|
const now = Date.now();
|
|
2509
2537
|
if (now - this.lastHealAt < HEAL_MIN_INTERVAL_MS) {
|
|
2510
|
-
log$
|
|
2538
|
+
log$5.warn({ sinceLastHealMs: now - this.lastHealAt }, "openclaw state DB malformed but a heal ran within the rate-limit window — skipping to avoid a heal→corrupt→heal loop");
|
|
2511
2539
|
return false;
|
|
2512
2540
|
}
|
|
2513
2541
|
this.lastHealAt = now;
|
|
@@ -2527,19 +2555,19 @@ var OpenClawApplier = class {
|
|
|
2527
2555
|
quarantined.push(dest);
|
|
2528
2556
|
}
|
|
2529
2557
|
if (quarantined.length === 0) {
|
|
2530
|
-
log$
|
|
2558
|
+
log$5.warn({ stateDir }, "malformed openclaw state DB reported but no state/openclaw.sqlite* files found to quarantine");
|
|
2531
2559
|
this.lastHealAt = now - HEAL_MIN_INTERVAL_MS + FAILED_HEAL_COOLDOWN_MS;
|
|
2532
2560
|
return false;
|
|
2533
2561
|
}
|
|
2534
|
-
log$
|
|
2562
|
+
log$5.warn({
|
|
2535
2563
|
stateDir,
|
|
2536
2564
|
quarantined
|
|
2537
2565
|
}, "quarantined malformed openclaw state DB — recreating via `openclaw plugins list` (upstream openclaw/openclaw#71689)");
|
|
2538
2566
|
await execFileAsync$1("openclaw", ["plugins", "list"], { timeout: 9e4 });
|
|
2539
|
-
log$
|
|
2567
|
+
log$5.warn({ stateDir }, "recreated openclaw state DB after quarantine");
|
|
2540
2568
|
return true;
|
|
2541
2569
|
} catch (err) {
|
|
2542
|
-
log$
|
|
2570
|
+
log$5.warn({
|
|
2543
2571
|
stateDir,
|
|
2544
2572
|
quarantined,
|
|
2545
2573
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -2556,7 +2584,7 @@ var OpenClawApplier = class {
|
|
|
2556
2584
|
const pinnedVersion = pluginSpecVersion(spec);
|
|
2557
2585
|
const claim = this.appliedPluginPins.get(pkg);
|
|
2558
2586
|
if (pinnedVersion !== void 0 && claim !== void 0 && claim.version !== pinnedVersion && claim.integrationId !== opts?.integrationId) {
|
|
2559
|
-
log$
|
|
2587
|
+
log$5.warn({
|
|
2560
2588
|
pkg,
|
|
2561
2589
|
requestedVersion: pinnedVersion,
|
|
2562
2590
|
keptVersion: claim.version,
|
|
@@ -2571,7 +2599,7 @@ var OpenClawApplier = class {
|
|
|
2571
2599
|
const installedVersion = this.installedPluginVersion(pkg);
|
|
2572
2600
|
const versionsComparable = pinnedVersion !== void 0 && installedVersion !== void 0;
|
|
2573
2601
|
if (versionsComparable && installedVersion === pinnedVersion) {
|
|
2574
|
-
if (opts?.force) log$
|
|
2602
|
+
if (opts?.force) log$5.info({
|
|
2575
2603
|
pkg,
|
|
2576
2604
|
spec,
|
|
2577
2605
|
version: installedVersion
|
|
@@ -2583,7 +2611,7 @@ var OpenClawApplier = class {
|
|
|
2583
2611
|
this.recordPluginPin(pkg, pinnedVersion, opts?.integrationId);
|
|
2584
2612
|
return;
|
|
2585
2613
|
}
|
|
2586
|
-
log$
|
|
2614
|
+
log$5.info({
|
|
2587
2615
|
pkg,
|
|
2588
2616
|
spec,
|
|
2589
2617
|
installedVersion,
|
|
@@ -2593,7 +2621,7 @@ var OpenClawApplier = class {
|
|
|
2593
2621
|
try {
|
|
2594
2622
|
await this.removePluginUnlocked(pkg);
|
|
2595
2623
|
} catch (err) {
|
|
2596
|
-
log$
|
|
2624
|
+
log$5.warn({
|
|
2597
2625
|
pkg,
|
|
2598
2626
|
spec,
|
|
2599
2627
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -2615,7 +2643,7 @@ var OpenClawApplier = class {
|
|
|
2615
2643
|
} catch (err) {
|
|
2616
2644
|
const errText = err instanceof Error ? `${err.message}\n${err.stderr ?? ""}` : String(err);
|
|
2617
2645
|
if (useUnsafeFlag && errText.includes("unknown option")) {
|
|
2618
|
-
log$
|
|
2646
|
+
log$5.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
|
|
2619
2647
|
await this.execOpenClawHealing(baseArgs, { timeout: 6e4 });
|
|
2620
2648
|
} else throw err;
|
|
2621
2649
|
}
|
|
@@ -2624,7 +2652,7 @@ var OpenClawApplier = class {
|
|
|
2624
2652
|
setTimeout(r, 500);
|
|
2625
2653
|
});
|
|
2626
2654
|
if (!this.isPluginInstalled(pkg)) throw err;
|
|
2627
|
-
log$
|
|
2655
|
+
log$5.warn({
|
|
2628
2656
|
pkg,
|
|
2629
2657
|
spec,
|
|
2630
2658
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -2687,7 +2715,7 @@ var OpenClawApplier = class {
|
|
|
2687
2715
|
const missing = [...new Set([...wanted, ...BUNDLED_BASELINE_ALLOW])].filter((id) => !currentAllow.includes(id));
|
|
2688
2716
|
if (missing.length === 0) return;
|
|
2689
2717
|
if (!readTrustworthy) {
|
|
2690
|
-
log$
|
|
2718
|
+
log$5.warn({
|
|
2691
2719
|
pkgs: wanted,
|
|
2692
2720
|
missing
|
|
2693
2721
|
}, "plugins.allow read was not trustworthy (non-empty parse failure or command error) — skipping the allow-list write to avoid replacing a value not proven to be a superset");
|
|
@@ -2701,7 +2729,7 @@ var OpenClawApplier = class {
|
|
|
2701
2729
|
"--replace"
|
|
2702
2730
|
]);
|
|
2703
2731
|
} catch (err) {
|
|
2704
|
-
log$
|
|
2732
|
+
log$5.warn({
|
|
2705
2733
|
err: err instanceof Error ? err.message : String(err),
|
|
2706
2734
|
pkgs: wanted
|
|
2707
2735
|
}, "Failed to set plugins.allow via openclaw config set");
|
|
@@ -2786,12 +2814,12 @@ var OpenClawApplier = class {
|
|
|
2786
2814
|
recursive: true,
|
|
2787
2815
|
force: true
|
|
2788
2816
|
});
|
|
2789
|
-
log$
|
|
2817
|
+
log$5.info({
|
|
2790
2818
|
pkg,
|
|
2791
2819
|
removed: fullPath
|
|
2792
2820
|
}, "Removed untracked extensions/ install — will reinstall via npm path");
|
|
2793
2821
|
} catch (err) {
|
|
2794
|
-
log$
|
|
2822
|
+
log$5.warn({
|
|
2795
2823
|
pkg,
|
|
2796
2824
|
removed: fullPath,
|
|
2797
2825
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -2825,24 +2853,25 @@ var OpenClawApplier = class {
|
|
|
2825
2853
|
}
|
|
2826
2854
|
applyClawHubSkill(slug) {
|
|
2827
2855
|
return this.cliLock.run(async () => {
|
|
2828
|
-
log$
|
|
2856
|
+
log$5.info({ slug }, "Installing skill from ClawHub");
|
|
2829
2857
|
try {
|
|
2830
2858
|
await execFileAsync$1("openclaw", [
|
|
2831
2859
|
"skills",
|
|
2832
2860
|
"install",
|
|
2833
2861
|
slug
|
|
2834
2862
|
], { timeout: 6e4 });
|
|
2835
|
-
log$
|
|
2863
|
+
log$5.info({ slug }, "ClawHub skill installed");
|
|
2836
2864
|
} catch (err) {
|
|
2837
2865
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2838
2866
|
if (msg.includes("already exists") || msg.includes("Skill already exists")) {
|
|
2839
|
-
log$
|
|
2867
|
+
log$5.info({ slug }, "ClawHub skill already installed (skipped)");
|
|
2840
2868
|
return;
|
|
2841
2869
|
}
|
|
2842
|
-
log$
|
|
2870
|
+
log$5.error({
|
|
2843
2871
|
slug,
|
|
2844
2872
|
err: msg
|
|
2845
2873
|
}, "ClawHub skill install failed");
|
|
2874
|
+
throw new Error(`ClawHub skill install failed for "${slug}": ${msg}`);
|
|
2846
2875
|
}
|
|
2847
2876
|
});
|
|
2848
2877
|
}
|
|
@@ -2853,7 +2882,7 @@ var OpenClawApplier = class {
|
|
|
2853
2882
|
recursive: true,
|
|
2854
2883
|
force: true
|
|
2855
2884
|
});
|
|
2856
|
-
log$
|
|
2885
|
+
log$5.info({ slug }, "ClawHub skill removed");
|
|
2857
2886
|
}
|
|
2858
2887
|
return Promise.resolve();
|
|
2859
2888
|
}
|
|
@@ -2908,10 +2937,10 @@ var OpenClawApplier = class {
|
|
|
2908
2937
|
const after = await readParentObject(parentPath);
|
|
2909
2938
|
return [...dottedKvs.entries()].every(([k, v]) => deepSubset(v, after[k]));
|
|
2910
2939
|
})) {
|
|
2911
|
-
log$
|
|
2940
|
+
log$5.warn({ parentPath }, "openclaw config set exited non-zero but config landed — continuing");
|
|
2912
2941
|
continue;
|
|
2913
2942
|
}
|
|
2914
|
-
log$
|
|
2943
|
+
log$5.error({
|
|
2915
2944
|
err: err instanceof Error ? err.message : String(err),
|
|
2916
2945
|
parentPath
|
|
2917
2946
|
}, "Failed to set config subtree via openclaw config set");
|
|
@@ -2927,9 +2956,9 @@ var OpenClawApplier = class {
|
|
|
2927
2956
|
} catch (err) {
|
|
2928
2957
|
if (await this.verifyApplied(async () => {
|
|
2929
2958
|
return (await mapWithConcurrency(leaves, VERIFY_GET_CONCURRENCY, (l) => this.configValueMatches(l.path, l.value))).every(Boolean);
|
|
2930
|
-
})) log$
|
|
2959
|
+
})) log$5.warn({ count: leaves.length }, "openclaw config set --batch-json exited non-zero but config landed — continuing");
|
|
2931
2960
|
else {
|
|
2932
|
-
log$
|
|
2961
|
+
log$5.error({
|
|
2933
2962
|
err: err instanceof Error ? err.message : String(err),
|
|
2934
2963
|
batch: leaves
|
|
2935
2964
|
}, "Failed to set config via openclaw config set --batch-json");
|
|
@@ -2981,7 +3010,7 @@ var OpenClawApplier = class {
|
|
|
2981
3010
|
"--replace"
|
|
2982
3011
|
]);
|
|
2983
3012
|
} catch (err) {
|
|
2984
|
-
log$
|
|
3013
|
+
log$5.warn({
|
|
2985
3014
|
err: err instanceof Error ? err.message : String(err),
|
|
2986
3015
|
parentPath
|
|
2987
3016
|
}, "Failed to update parent config during subtree key drop");
|
|
@@ -3019,19 +3048,19 @@ var OpenClawApplier = class {
|
|
|
3019
3048
|
unsetParents.add(parentPath);
|
|
3020
3049
|
try {
|
|
3021
3050
|
await this.runConfigCommandUnlocked(["unset", parentPath]);
|
|
3022
|
-
log$
|
|
3051
|
+
log$5.warn({
|
|
3023
3052
|
path,
|
|
3024
3053
|
parentPath
|
|
3025
3054
|
}, "Leaf unset rejected as an invalid partial custom provider — unset the parent subtree instead");
|
|
3026
3055
|
} catch (parentErr) {
|
|
3027
|
-
log$
|
|
3056
|
+
log$5.warn({
|
|
3028
3057
|
err: parentErr instanceof Error ? parentErr.message : String(parentErr),
|
|
3029
3058
|
parentPath
|
|
3030
3059
|
}, "Failed to unset parent subtree after an invalid-partial leaf unset");
|
|
3031
3060
|
}
|
|
3032
3061
|
return;
|
|
3033
3062
|
}
|
|
3034
|
-
log$
|
|
3063
|
+
log$5.warn({
|
|
3035
3064
|
err: err instanceof Error ? err.message : String(err),
|
|
3036
3065
|
path
|
|
3037
3066
|
}, "Failed to unset config via openclaw config unset");
|
|
@@ -3048,7 +3077,7 @@ var OpenClawApplier = class {
|
|
|
3048
3077
|
* config writes trigger.
|
|
3049
3078
|
*/
|
|
3050
3079
|
setConfigRaw(key, value) {
|
|
3051
|
-
return this.cliLock.run(() => this.runConfigSetUnlocked([key, value]));
|
|
3080
|
+
return this.cliLock.run(() => this.runConfigSetUnlocked([key, value], { timeout: CONFIG_SET_RAW_TIMEOUT_MS }));
|
|
3052
3081
|
}
|
|
3053
3082
|
/**
|
|
3054
3083
|
* Read a single config key back — `openclaw config get <key>` — and normalize
|
|
@@ -3137,7 +3166,7 @@ var OpenClawApplier = class {
|
|
|
3137
3166
|
* config + plugins only.
|
|
3138
3167
|
*/
|
|
3139
3168
|
const execFileAsync = promisify(execFile);
|
|
3140
|
-
const log$
|
|
3169
|
+
const log$4 = createLogger("HermesApplier");
|
|
3141
3170
|
const DEFAULT_HERMES_HOME$1 = join(homedir(), ".hermes");
|
|
3142
3171
|
/**
|
|
3143
3172
|
* Hermes config writes are serialized through one promise chain so concurrent
|
|
@@ -3155,7 +3184,7 @@ const delay = (ms) => new Promise((resolve) => {
|
|
|
3155
3184
|
setTimeout(resolve, ms);
|
|
3156
3185
|
});
|
|
3157
3186
|
/** `@alfe.ai/openclaw-*` packages are OpenClaw-specific plugins — not Hermes. */
|
|
3158
|
-
function isOpenClawPlugin(spec) {
|
|
3187
|
+
function isOpenClawPlugin$1(spec) {
|
|
3159
3188
|
return stripPluginVersion(spec).startsWith("@alfe.ai/openclaw-");
|
|
3160
3189
|
}
|
|
3161
3190
|
/**
|
|
@@ -3227,7 +3256,7 @@ var HermesApplier = class {
|
|
|
3227
3256
|
if (staleLeafPaths.length > 0) try {
|
|
3228
3257
|
await this.deleteConfigKeys(staleLeafPaths);
|
|
3229
3258
|
} catch (err) {
|
|
3230
|
-
log$
|
|
3259
|
+
log$4.warn({
|
|
3231
3260
|
err: err instanceof Error ? err.message : String(err),
|
|
3232
3261
|
integrationId
|
|
3233
3262
|
}, "Failed to delete stale Hermes config keys during applyConfig diff");
|
|
@@ -3236,7 +3265,7 @@ var HermesApplier = class {
|
|
|
3236
3265
|
const nextKvs = subtreesByParent.get(parent);
|
|
3237
3266
|
return [...prevKvs.keys()].some((k) => !nextKvs?.has(k));
|
|
3238
3267
|
}).map(([parent]) => parent);
|
|
3239
|
-
if (staleSubtreeParents.length > 0) log$
|
|
3268
|
+
if (staleSubtreeParents.length > 0) log$4.warn({
|
|
3240
3269
|
integrationId,
|
|
3241
3270
|
parents: staleSubtreeParents
|
|
3242
3271
|
}, "Hermes applyConfig diff: skipping stale dotted-key subtree(s) — OpenClaw-plugin-shaped config does not apply to Hermes");
|
|
@@ -3244,14 +3273,14 @@ var HermesApplier = class {
|
|
|
3244
3273
|
integrations[integrationId] = config;
|
|
3245
3274
|
tracking._integrations = integrations;
|
|
3246
3275
|
this.writeTracking(tracking);
|
|
3247
|
-
if (subtreesByParent.size > 0) log$
|
|
3276
|
+
if (subtreesByParent.size > 0) log$4.warn({
|
|
3248
3277
|
integrationId,
|
|
3249
3278
|
parents: [...subtreesByParent.keys()]
|
|
3250
3279
|
}, "Hermes applyConfig: skipping dotted-key subtree(s) — OpenClaw-plugin-shaped config does not apply to Hermes");
|
|
3251
3280
|
for (const { path, value } of leaves) try {
|
|
3252
3281
|
await this.runConfigSet([path, stringifyConfigValue(value)]);
|
|
3253
3282
|
} catch (err) {
|
|
3254
|
-
log$
|
|
3283
|
+
log$4.error({
|
|
3255
3284
|
err: err instanceof Error ? err.message : String(err),
|
|
3256
3285
|
key: path
|
|
3257
3286
|
}, "Failed to set config via hermes config set");
|
|
@@ -3274,7 +3303,7 @@ var HermesApplier = class {
|
|
|
3274
3303
|
try {
|
|
3275
3304
|
await this.deleteConfigKeys(leaves.map(({ path }) => path));
|
|
3276
3305
|
} catch (err) {
|
|
3277
|
-
log$
|
|
3306
|
+
log$4.warn({
|
|
3278
3307
|
err: err instanceof Error ? err.message : String(err),
|
|
3279
3308
|
integrationId
|
|
3280
3309
|
}, "Failed to delete Alfe config keys from ~/.hermes/config.yaml");
|
|
@@ -3338,11 +3367,11 @@ var HermesApplier = class {
|
|
|
3338
3367
|
* one in Phase 1, so this path is a real attempt (not faked) but untrodden.
|
|
3339
3368
|
*/
|
|
3340
3369
|
async applyPlugin(spec) {
|
|
3341
|
-
if (isOpenClawPlugin(spec)) {
|
|
3342
|
-
log$
|
|
3370
|
+
if (isOpenClawPlugin$1(spec)) {
|
|
3371
|
+
log$4.info({ spec }, "Hermes applyPlugin: skipping OpenClaw-specific npm plugin (not a Hermes plugin)");
|
|
3343
3372
|
return;
|
|
3344
3373
|
}
|
|
3345
|
-
log$
|
|
3374
|
+
log$4.info({ spec }, "Hermes applyPlugin: installing native Hermes plugin");
|
|
3346
3375
|
await execFileAsync("hermes", [
|
|
3347
3376
|
"plugins",
|
|
3348
3377
|
"install",
|
|
@@ -3360,11 +3389,11 @@ var HermesApplier = class {
|
|
|
3360
3389
|
* Hermes plugin would be disabled via `hermes plugins`.
|
|
3361
3390
|
*/
|
|
3362
3391
|
async removePlugin(spec) {
|
|
3363
|
-
if (isOpenClawPlugin(spec)) {
|
|
3364
|
-
log$
|
|
3392
|
+
if (isOpenClawPlugin$1(spec)) {
|
|
3393
|
+
log$4.info({ spec }, "Hermes removePlugin: skipping OpenClaw-specific npm plugin (was never installed on Hermes)");
|
|
3365
3394
|
return;
|
|
3366
3395
|
}
|
|
3367
|
-
log$
|
|
3396
|
+
log$4.info({ spec }, "Hermes removePlugin: disabling native Hermes plugin");
|
|
3368
3397
|
await execFileAsync("hermes", [
|
|
3369
3398
|
"plugins",
|
|
3370
3399
|
"disable",
|
|
@@ -3372,19 +3401,19 @@ var HermesApplier = class {
|
|
|
3372
3401
|
], { timeout: 3e4 });
|
|
3373
3402
|
}
|
|
3374
3403
|
applySkill(name) {
|
|
3375
|
-
log$
|
|
3404
|
+
log$4.info({ name }, "Hermes applySkill: no-op (Hermes built-in skills; deferred)");
|
|
3376
3405
|
return Promise.resolve();
|
|
3377
3406
|
}
|
|
3378
3407
|
removeSkill(name) {
|
|
3379
|
-
log$
|
|
3408
|
+
log$4.info({ name }, "Hermes removeSkill: no-op (Hermes built-in skills; deferred)");
|
|
3380
3409
|
return Promise.resolve();
|
|
3381
3410
|
}
|
|
3382
3411
|
applyClawHubSkill(slug) {
|
|
3383
|
-
log$
|
|
3412
|
+
log$4.info({ slug }, "Hermes applyClawHubSkill: no-op (ClawHub is OpenClaw-only)");
|
|
3384
3413
|
return Promise.resolve();
|
|
3385
3414
|
}
|
|
3386
3415
|
removeClawHubSkill(slug) {
|
|
3387
|
-
log$
|
|
3416
|
+
log$4.info({ slug }, "Hermes removeClawHubSkill: no-op (ClawHub is OpenClaw-only)");
|
|
3388
3417
|
return Promise.resolve();
|
|
3389
3418
|
}
|
|
3390
3419
|
isAvailable() {
|
|
@@ -3501,7 +3530,7 @@ var HermesApplier = class {
|
|
|
3501
3530
|
* `warmup`) so the store is a pure ledger and the MCP children are spawned ONLY
|
|
3502
3531
|
* by Hermes — avoiding a double-spawn of every server.
|
|
3503
3532
|
*/
|
|
3504
|
-
const log$
|
|
3533
|
+
const log$3 = createLogger("HermesMcpSync");
|
|
3505
3534
|
const DEFAULT_HERMES_HOME = join(homedir(), ".hermes");
|
|
3506
3535
|
/**
|
|
3507
3536
|
* SEAM #1 — `${VAR}` interpolation from `~/.hermes/.env`. CONFIRMED (Hermes
|
|
@@ -3513,7 +3542,7 @@ const DEFAULT_HERMES_HOME = join(homedir(), ".hermes");
|
|
|
3513
3542
|
* resolves at spawn time. The inline-literal fallback is therefore not needed.
|
|
3514
3543
|
*/
|
|
3515
3544
|
const ALFE_API_KEY_ENV_REF = "${ALFE_API_KEY}";
|
|
3516
|
-
const DEFAULT_DEBOUNCE_MS = 250;
|
|
3545
|
+
const DEFAULT_DEBOUNCE_MS$1 = 250;
|
|
3517
3546
|
/**
|
|
3518
3547
|
* Read-merge-write mirror of the MCP store into `~/.hermes/config.yaml`.
|
|
3519
3548
|
*
|
|
@@ -3550,7 +3579,7 @@ var HermesMcpSync = class {
|
|
|
3550
3579
|
this.configPath = opts.configPath ?? join(this.home, "config.yaml");
|
|
3551
3580
|
this.envPath = opts.envPath ?? join(this.home, ".env");
|
|
3552
3581
|
this.trackingPath = opts.trackingPath ?? join(this.home, ".alfe-mcp-synced.json");
|
|
3553
|
-
this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
3582
|
+
this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS$1;
|
|
3554
3583
|
}
|
|
3555
3584
|
/**
|
|
3556
3585
|
* Begin mirroring: load the prior removal set, run one immediate sync (so
|
|
@@ -3562,12 +3591,12 @@ var HermesMcpSync = class {
|
|
|
3562
3591
|
this.started = true;
|
|
3563
3592
|
this.loadSyncedIds();
|
|
3564
3593
|
this.syncOnce().catch((err) => {
|
|
3565
|
-
log$
|
|
3594
|
+
log$3.warn({ err: errMsg$2(err) }, "Hermes MCP sync: initial sync failed");
|
|
3566
3595
|
});
|
|
3567
3596
|
this.unsubscribe = this.manager.onChange(() => {
|
|
3568
3597
|
this.schedule();
|
|
3569
3598
|
});
|
|
3570
|
-
log$
|
|
3599
|
+
log$3.info({ configPath: this.configPath }, "Hermes MCP sync started — mirroring store into config.yaml");
|
|
3571
3600
|
}
|
|
3572
3601
|
/** Stop subscribing and cancel any pending debounced sync. Idempotent. */
|
|
3573
3602
|
stop() {
|
|
@@ -3598,7 +3627,7 @@ var HermesMcpSync = class {
|
|
|
3598
3627
|
schedule() {
|
|
3599
3628
|
if (this.debounceMs <= 0) {
|
|
3600
3629
|
this.syncOnce().catch((err) => {
|
|
3601
|
-
log$
|
|
3630
|
+
log$3.warn({ err: errMsg$2(err) }, "Hermes MCP sync failed");
|
|
3602
3631
|
});
|
|
3603
3632
|
return;
|
|
3604
3633
|
}
|
|
@@ -3606,7 +3635,7 @@ var HermesMcpSync = class {
|
|
|
3606
3635
|
this.debounceTimer = setTimeout(() => {
|
|
3607
3636
|
this.debounceTimer = void 0;
|
|
3608
3637
|
this.syncOnce().catch((err) => {
|
|
3609
|
-
log$
|
|
3638
|
+
log$3.warn({ err: errMsg$2(err) }, "Hermes MCP sync failed");
|
|
3610
3639
|
});
|
|
3611
3640
|
}, this.debounceMs);
|
|
3612
3641
|
this.debounceTimer.unref();
|
|
@@ -3623,7 +3652,7 @@ var HermesMcpSync = class {
|
|
|
3623
3652
|
if (changed) {
|
|
3624
3653
|
mkdirSync(dirname(this.configPath), { recursive: true });
|
|
3625
3654
|
writeFileSync(this.configPath, after, "utf-8");
|
|
3626
|
-
log$
|
|
3655
|
+
log$3.info({
|
|
3627
3656
|
added: [...desiredIds],
|
|
3628
3657
|
removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
|
|
3629
3658
|
}, "Hermes MCP sync: config.yaml mcp_servers updated");
|
|
@@ -3665,7 +3694,7 @@ var HermesMcpSync = class {
|
|
|
3665
3694
|
/** Read-merge-write `~/.hermes/.env` to set ALFE_API_KEY without clobbering other keys. */
|
|
3666
3695
|
ensureEnvApiKey() {
|
|
3667
3696
|
if (!this.apiKey) {
|
|
3668
|
-
log$
|
|
3697
|
+
log$3.warn("Hermes MCP sync: no ALFE_API_KEY available — mirrored MCP servers will start in zero-accounts degraded mode");
|
|
3669
3698
|
return false;
|
|
3670
3699
|
}
|
|
3671
3700
|
return upsertEnvVar(this.envPath, "ALFE_API_KEY", this.apiKey);
|
|
@@ -3682,7 +3711,7 @@ var HermesMcpSync = class {
|
|
|
3682
3711
|
mkdirSync(dirname(this.trackingPath), { recursive: true });
|
|
3683
3712
|
writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
|
|
3684
3713
|
} catch (err) {
|
|
3685
|
-
log$
|
|
3714
|
+
log$3.warn({ err: errMsg$2(err) }, "Hermes MCP sync: failed to persist synced-id sidecar");
|
|
3686
3715
|
}
|
|
3687
3716
|
}
|
|
3688
3717
|
};
|
|
@@ -3713,6 +3742,466 @@ function upsertEnvVar(envPath, key, value) {
|
|
|
3713
3742
|
}
|
|
3714
3743
|
return changed;
|
|
3715
3744
|
}
|
|
3745
|
+
function errMsg$2(err) {
|
|
3746
|
+
return err instanceof Error ? err.message : String(err);
|
|
3747
|
+
}
|
|
3748
|
+
//#endregion
|
|
3749
|
+
//#region src/appliers/claude-code-applier.ts
|
|
3750
|
+
/**
|
|
3751
|
+
* ClaudeCodeApplier — applies integration capability to the claude-code runtime
|
|
3752
|
+
* (a self-hosted Claude Code agent driven by the `alfe-claude-host`).
|
|
3753
|
+
*
|
|
3754
|
+
* The claude-code runtime is deliberately thin on the applier surface. Chat,
|
|
3755
|
+
* memory, and voice reach a claude-code agent via the host + MCP servers (see
|
|
3756
|
+
* `ClaudeCodeMcpSync`), NOT via applied plugins — so the core `alfe` integration
|
|
3757
|
+
* is effectively a no-op here. What DOES land:
|
|
3758
|
+
*
|
|
3759
|
+
* - **Plugins**: the `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific
|
|
3760
|
+
* (they carry the `openclaw` extension / plugin-SDK peer dep) and cannot load
|
|
3761
|
+
* inside Claude Code — `applyPlugin` / `removePlugin` log-and-skip them, the
|
|
3762
|
+
* same as `HermesApplier`. No claude-code-native plugin spec exists.
|
|
3763
|
+
* - **Skills**: a REAL win vs Hermes — Claude Code has native Agent Skills read
|
|
3764
|
+
* from `~/.claude/skills/<name>/`. `applySkill` copies an Alfe skill directory
|
|
3765
|
+
* there; `applyClawHubSkill` fetches the ClawHub skill and stages it the same
|
|
3766
|
+
* way; the `remove*` variants delete the directory.
|
|
3767
|
+
* - **Config**: Claude Code has no integration-config CLI analogous to
|
|
3768
|
+
* `openclaw config set` / `hermes config set`, so config is a mostly-inert
|
|
3769
|
+
* surface. We track per-integration contributions in a ledger
|
|
3770
|
+
* (`~/.claude-code/.alfe-integrations.json`) so the lifecycle accounting stays
|
|
3771
|
+
* honest (a config-only integration is still lock-tracked and torn down on
|
|
3772
|
+
* removal), but there is no runtime CLI to shell. `setConfigRaw` /
|
|
3773
|
+
* `getConfigRaw` are ledger-backed raw key writes for the daemon's
|
|
3774
|
+
* `alfe.config_set` cloud-command — they MUST exist (the daemon hard-fails
|
|
3775
|
+
* `alfe.config_set` and the config reconciler no-ops when the applier omits
|
|
3776
|
+
* them), and they degrade cleanly rather than error.
|
|
3777
|
+
*
|
|
3778
|
+
* Constructor shape mirrors `HermesApplier` (`{ home, workspace?, configPath? }`)
|
|
3779
|
+
* so the daemon constructs it identically. `home` is the `~/.claude-code`
|
|
3780
|
+
* workspace; skills go to `~/.claude/skills` (Claude Code's global skills dir,
|
|
3781
|
+
* derived from the OS home, NOT the claude-code workspace).
|
|
3782
|
+
*/
|
|
3783
|
+
const log$2 = createLogger("ClaudeCodeApplier");
|
|
3784
|
+
const DEFAULT_CLAUDE_CODE_HOME$1 = join(homedir(), ".claude-code");
|
|
3785
|
+
/** Claude Code reads native Agent Skills from `~/.claude/skills/<name>/`. */
|
|
3786
|
+
const DEFAULT_CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
|
|
3787
|
+
/** `@alfe.ai/openclaw-*` packages are OpenClaw-specific plugins — not Claude Code. */
|
|
3788
|
+
function isOpenClawPlugin(spec) {
|
|
3789
|
+
return stripPluginVersion(spec).startsWith("@alfe.ai/openclaw-");
|
|
3790
|
+
}
|
|
3791
|
+
var ClaudeCodeApplier = class {
|
|
3792
|
+
runtime = "claude-code";
|
|
3793
|
+
home;
|
|
3794
|
+
/** `~/.claude-code/.alfe-integrations.json` — per-integration config ledger + raw-key store. */
|
|
3795
|
+
ledgerPath;
|
|
3796
|
+
/** `~/.claude/skills` — where native Claude Code skills are copied. */
|
|
3797
|
+
skillsDir;
|
|
3798
|
+
constructor(options = {}) {
|
|
3799
|
+
this.home = options.home ?? options.workspace ?? DEFAULT_CLAUDE_CODE_HOME$1;
|
|
3800
|
+
this.ledgerPath = options.configPath ?? join(this.home, ".alfe-integrations.json");
|
|
3801
|
+
this.skillsDir = options.skillsDir ?? DEFAULT_CLAUDE_SKILLS_DIR;
|
|
3802
|
+
}
|
|
3803
|
+
/**
|
|
3804
|
+
* Record an integration's config contribution in the ledger. Claude Code has
|
|
3805
|
+
* no integration-config CLI, so this is a pure ledger write — it makes the
|
|
3806
|
+
* per-integration accounting honest (so `removeConfig` and the lock's
|
|
3807
|
+
* config-only tracking work) without shelling anything. Overwrites the prior
|
|
3808
|
+
* contribution for this integration (convergent by construction — a full
|
|
3809
|
+
* replace, no stale-key diff needed since nothing is applied to a runtime).
|
|
3810
|
+
*/
|
|
3811
|
+
applyConfig(integrationId, config) {
|
|
3812
|
+
const ledger = this.readLedger();
|
|
3813
|
+
const integrations = ledger._integrations ?? {};
|
|
3814
|
+
integrations[integrationId] = config;
|
|
3815
|
+
ledger._integrations = integrations;
|
|
3816
|
+
this.writeLedger(ledger);
|
|
3817
|
+
log$2.info({ integrationId }, "Claude Code applyConfig: recorded config in ledger (no runtime CLI to apply)");
|
|
3818
|
+
return Promise.resolve();
|
|
3819
|
+
}
|
|
3820
|
+
/** Clear an integration's config contribution from the ledger. */
|
|
3821
|
+
removeConfig(integrationId) {
|
|
3822
|
+
const ledger = this.readLedger();
|
|
3823
|
+
const integrations = ledger._integrations ?? {};
|
|
3824
|
+
if (!(integrationId in integrations)) return Promise.resolve();
|
|
3825
|
+
ledger._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
|
|
3826
|
+
this.writeLedger(ledger);
|
|
3827
|
+
return Promise.resolve();
|
|
3828
|
+
}
|
|
3829
|
+
/**
|
|
3830
|
+
* Raw single-key config write for the daemon's `alfe.config_set` cloud-command.
|
|
3831
|
+
*
|
|
3832
|
+
* Deliberately bypasses the `_integrations` per-integration accounting (a raw
|
|
3833
|
+
* set is untracked — routing it through `applyConfig` would corrupt an
|
|
3834
|
+
* integration's removal accounting). Stored under a separate `_raw` map in the
|
|
3835
|
+
* same ledger file. Inert as far as the runtime goes (Claude Code has no config
|
|
3836
|
+
* CLI to push it into), but it degrades cleanly — the daemon gets an OK ack
|
|
3837
|
+
* instead of `CONFIG_SET_UNSUPPORTED`, and the value round-trips via
|
|
3838
|
+
* `getConfigRaw`.
|
|
3839
|
+
*/
|
|
3840
|
+
setConfigRaw(key, value) {
|
|
3841
|
+
const ledger = this.readLedger();
|
|
3842
|
+
const raw = ledger._raw ?? {};
|
|
3843
|
+
raw[key] = value;
|
|
3844
|
+
ledger._raw = raw;
|
|
3845
|
+
this.writeLedger(ledger);
|
|
3846
|
+
return Promise.resolve();
|
|
3847
|
+
}
|
|
3848
|
+
/**
|
|
3849
|
+
* Read a raw config key back from the ledger — the untracked read companion to
|
|
3850
|
+
* `setConfigRaw`, used by the daemon's config-reconcile pass (value-diff before
|
|
3851
|
+
* write; verify a write landed). Returns the exact string `setConfigRaw` stored,
|
|
3852
|
+
* or `undefined` when the key was never set. Never throws.
|
|
3853
|
+
*/
|
|
3854
|
+
getConfigRaw(key) {
|
|
3855
|
+
try {
|
|
3856
|
+
const value = (this.readLedger()._raw ?? {})[key];
|
|
3857
|
+
return Promise.resolve(typeof value === "string" ? value : void 0);
|
|
3858
|
+
} catch {
|
|
3859
|
+
return Promise.resolve(void 0);
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
/**
|
|
3863
|
+
* Apply a plugin. The `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific
|
|
3864
|
+
* and do NOT load in Claude Code — log + skip them. No claude-code-native
|
|
3865
|
+
* plugin spec exists, so any non-openclaw spec is also skipped (rather than a
|
|
3866
|
+
* blind shell of an unknown installer). The manager catches per-plugin, so a
|
|
3867
|
+
* skip here is a correct no-op.
|
|
3868
|
+
*/
|
|
3869
|
+
applyPlugin(spec) {
|
|
3870
|
+
if (isOpenClawPlugin(spec)) {
|
|
3871
|
+
log$2.info({ spec }, "Claude Code applyPlugin: skipping OpenClaw-specific npm plugin (not a Claude Code plugin)");
|
|
3872
|
+
return Promise.resolve();
|
|
3873
|
+
}
|
|
3874
|
+
log$2.info({ spec }, "Claude Code applyPlugin: no claude-code-native plugin mechanism — skipping");
|
|
3875
|
+
return Promise.resolve();
|
|
3876
|
+
}
|
|
3877
|
+
/** Remove a plugin. Nothing was installed (applyPlugin skipped it) — no-op log. */
|
|
3878
|
+
removePlugin(spec) {
|
|
3879
|
+
log$2.info({ spec }, "Claude Code removePlugin: no-op (no plugin was installed on Claude Code)");
|
|
3880
|
+
return Promise.resolve();
|
|
3881
|
+
}
|
|
3882
|
+
/**
|
|
3883
|
+
* Copy a skill directory into `~/.claude/skills/<name>/`. Claude Code reads
|
|
3884
|
+
* native Agent Skills from there, so an Alfe skill dir lands as a first-class
|
|
3885
|
+
* Claude Code skill. Idempotent: an existing target is replaced (a
|
|
3886
|
+
* newer-version re-apply must overwrite).
|
|
3887
|
+
*/
|
|
3888
|
+
applySkill(name, srcPath) {
|
|
3889
|
+
return this.copySkillDir(name, srcPath);
|
|
3890
|
+
}
|
|
3891
|
+
/** Remove a skill directory from `~/.claude/skills/<name>/`. */
|
|
3892
|
+
removeSkill(name) {
|
|
3893
|
+
return this.deleteSkillDir(name);
|
|
3894
|
+
}
|
|
3895
|
+
/**
|
|
3896
|
+
* Install a ClawHub skill natively. ClawHub is an OpenClaw registry, but the
|
|
3897
|
+
* skill payload is a plain directory — for claude-code we treat the slug as the
|
|
3898
|
+
* skill name and expect the caller to have staged the directory. Absent a
|
|
3899
|
+
* staged source we log + skip (no OpenClaw `skills install` CLI on this
|
|
3900
|
+
* runtime); a future claude-code ClawHub fetch would stage the dir then copy it
|
|
3901
|
+
* exactly like `applySkill`.
|
|
3902
|
+
*
|
|
3903
|
+
* NOTE: unlike OpenClaw's `applyClawHubSkill` (which THROWS on a real install
|
|
3904
|
+
* failure so an all-skills-fail activation is marked `error`), there is no
|
|
3905
|
+
* install to fail here — the skill either lands from a staged directory or is a
|
|
3906
|
+
* clean no-op. We never throw for a claude-code ClawHub skill.
|
|
3907
|
+
*/
|
|
3908
|
+
applyClawHubSkill(slug) {
|
|
3909
|
+
log$2.info({ slug }, "Claude Code applyClawHubSkill: no claude-code ClawHub fetch — skipping (native skill dirs land via applySkill)");
|
|
3910
|
+
return Promise.resolve();
|
|
3911
|
+
}
|
|
3912
|
+
/** Remove a ClawHub-installed skill directory (by slug == dir name). */
|
|
3913
|
+
removeClawHubSkill(slug) {
|
|
3914
|
+
return this.deleteSkillDir(slug);
|
|
3915
|
+
}
|
|
3916
|
+
isAvailable() {
|
|
3917
|
+
return Promise.resolve(existsSync(this.home));
|
|
3918
|
+
}
|
|
3919
|
+
copySkillDir(name, srcPath) {
|
|
3920
|
+
if (!existsSync(srcPath)) {
|
|
3921
|
+
log$2.warn({
|
|
3922
|
+
name,
|
|
3923
|
+
srcPath
|
|
3924
|
+
}, "Claude Code applySkill: source directory missing — skipping");
|
|
3925
|
+
return Promise.resolve();
|
|
3926
|
+
}
|
|
3927
|
+
const dest = join(this.skillsDir, name);
|
|
3928
|
+
try {
|
|
3929
|
+
mkdirSync(this.skillsDir, { recursive: true });
|
|
3930
|
+
rmSync(dest, {
|
|
3931
|
+
recursive: true,
|
|
3932
|
+
force: true
|
|
3933
|
+
});
|
|
3934
|
+
cpSync(srcPath, dest, { recursive: true });
|
|
3935
|
+
log$2.info({
|
|
3936
|
+
name,
|
|
3937
|
+
dest
|
|
3938
|
+
}, "Claude Code applySkill: copied skill into ~/.claude/skills");
|
|
3939
|
+
} catch (err) {
|
|
3940
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3941
|
+
log$2.error({
|
|
3942
|
+
name,
|
|
3943
|
+
dest,
|
|
3944
|
+
err: message
|
|
3945
|
+
}, "Claude Code applySkill: failed to copy skill");
|
|
3946
|
+
throw new Error(`Failed to copy skill ${name} into ${dest}: ${message}`);
|
|
3947
|
+
}
|
|
3948
|
+
return Promise.resolve();
|
|
3949
|
+
}
|
|
3950
|
+
deleteSkillDir(name) {
|
|
3951
|
+
const dest = join(this.skillsDir, name);
|
|
3952
|
+
try {
|
|
3953
|
+
rmSync(dest, {
|
|
3954
|
+
recursive: true,
|
|
3955
|
+
force: true
|
|
3956
|
+
});
|
|
3957
|
+
log$2.info({
|
|
3958
|
+
name,
|
|
3959
|
+
dest
|
|
3960
|
+
}, "Claude Code removeSkill: removed skill from ~/.claude/skills");
|
|
3961
|
+
} catch (err) {
|
|
3962
|
+
log$2.warn({
|
|
3963
|
+
name,
|
|
3964
|
+
dest,
|
|
3965
|
+
err: err instanceof Error ? err.message : String(err)
|
|
3966
|
+
}, "Claude Code removeSkill: failed to remove skill directory");
|
|
3967
|
+
}
|
|
3968
|
+
return Promise.resolve();
|
|
3969
|
+
}
|
|
3970
|
+
readLedger() {
|
|
3971
|
+
if (!existsSync(this.ledgerPath)) return {};
|
|
3972
|
+
try {
|
|
3973
|
+
return JSON.parse(readFileSync(this.ledgerPath, "utf-8"));
|
|
3974
|
+
} catch {
|
|
3975
|
+
return {};
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3978
|
+
writeLedger(ledger) {
|
|
3979
|
+
mkdirSync(join(this.ledgerPath, ".."), { recursive: true });
|
|
3980
|
+
writeFileSync(this.ledgerPath, JSON.stringify(ledger, null, 2) + "\n", "utf-8");
|
|
3981
|
+
}
|
|
3982
|
+
};
|
|
3983
|
+
//#endregion
|
|
3984
|
+
//#region src/appliers/claude-code-mcp-sync.ts
|
|
3985
|
+
/**
|
|
3986
|
+
* ClaudeCodeMcpSync — claude-code-only CONSUMER of the runtime-agnostic MCP
|
|
3987
|
+
* store (parallel to `HermesMcpSync`, Approach B: a generated config file the
|
|
3988
|
+
* runtime reads).
|
|
3989
|
+
*
|
|
3990
|
+
* Background: `McpApplier` writes resolved MCP servers (command/args/env, with
|
|
3991
|
+
* `{{config}}`/`{{credentials}}` already interpolated at apply time) into the
|
|
3992
|
+
* runtime-agnostic bundler store at `~/.alfe/mcp/servers.json`. OpenClaw consumes
|
|
3993
|
+
* that store over IPC (the daemon hosts the bundler children and the openclaw
|
|
3994
|
+
* plugin reaches them). The claude-code runtime cannot consume over IPC — the
|
|
3995
|
+
* `alfe-claude-host` spawns `claude -p --mcp-config <file>` per turn and Claude
|
|
3996
|
+
* Code itself spawns the MCP children. So claude-code needs its own consumer that
|
|
3997
|
+
* materialises the store into the `--mcp-config` JSON file the host passes.
|
|
3998
|
+
*
|
|
3999
|
+
* This class is the parallel to OpenClaw's IPC consumption: it subscribes to
|
|
4000
|
+
* `manager.onChange()` and, **only when the active runtime is claude-code** (the
|
|
4001
|
+
* daemon only constructs it for claude-code agents), writes the store's servers
|
|
4002
|
+
* into `~/.claude-code/mcp-config.json` in Claude Code's `--mcp-config` shape:
|
|
4003
|
+
*
|
|
4004
|
+
* { "mcpServers": { "<id>": { "command", "args", "env" } | { "url", ... } } }
|
|
4005
|
+
*
|
|
4006
|
+
* This is Anthropic's documented `.mcp.json` / `--mcp-config` schema — `command`
|
|
4007
|
+
* / `args` / `env` for stdio servers and `type: "sse"|"http"` + `url` + `headers`
|
|
4008
|
+
* for remote servers, under a top-level `mcpServers` map keyed by server id.
|
|
4009
|
+
*
|
|
4010
|
+
* Two cross-cutting responsibilities make the generated servers actually work:
|
|
4011
|
+
*
|
|
4012
|
+
* 1. `ALFE_API_KEY` injection. The Alfe MCP servers (`@alfe.ai/<provider>-mcp`)
|
|
4013
|
+
* fetch their real provider credentials from the Alfe API at startup using
|
|
4014
|
+
* `ALFE_API_KEY`. For OpenClaw the daemon spawns the children, so they
|
|
4015
|
+
* inherit `ALFE_API_KEY` from the daemon's own `process.env`. Claude Code
|
|
4016
|
+
* spawns the children itself in a separate process tree (per turn), so they
|
|
4017
|
+
* would NOT inherit it — without it they start in a silent zero-accounts
|
|
4018
|
+
* degraded mode. We therefore inject the actual `ALFE_API_KEY` value into
|
|
4019
|
+
* every generated stdio server's env. Unlike Hermes (which supports
|
|
4020
|
+
* `${VAR}` interpolation from `~/.hermes/.env`), Claude Code's `--mcp-config`
|
|
4021
|
+
* env block is a plain map with NO documented `${VAR}` expansion, so we write
|
|
4022
|
+
* the literal secret into the generated file. The file lives under
|
|
4023
|
+
* `~/.claude-code/` (0600) alongside other agent state and is regenerated,
|
|
4024
|
+
* never checked in.
|
|
4025
|
+
*
|
|
4026
|
+
* 2. Restart. Claude Code reads `--mcp-config` fresh on every `claude -p` spawn
|
|
4027
|
+
* (the host spawns per turn), so a regenerated file is picked up on the NEXT
|
|
4028
|
+
* turn with no runtime restart. Unlike Hermes (which reads config.yaml at
|
|
4029
|
+
* boot and needs a reload), this sync does NOT trigger a runtime restart —
|
|
4030
|
+
* the per-turn spawn model makes it unnecessary. This is the deliberate
|
|
4031
|
+
* divergence from `HermesMcpSync` (which owns a `requestRestart` callback);
|
|
4032
|
+
* we keep the same options shape for daemon-wiring parity but never restart.
|
|
4033
|
+
*/
|
|
4034
|
+
const log$1 = createLogger("ClaudeCodeMcpSync");
|
|
4035
|
+
const DEFAULT_CLAUDE_CODE_HOME = join(homedir(), ".claude-code");
|
|
4036
|
+
const DEFAULT_DEBOUNCE_MS = 250;
|
|
4037
|
+
/**
|
|
4038
|
+
* Full-regenerate writer of the MCP store into `~/.claude-code/mcp-config.json`.
|
|
4039
|
+
*
|
|
4040
|
+
* Ownership / removal model: every entry returned by `manager.listServers()`
|
|
4041
|
+
* comes from the Alfe store (its `owner` is `integration:<id>` / `cli` /
|
|
4042
|
+
* `manual`) and is therefore Alfe-owned. Unlike the Hermes config.yaml mirror
|
|
4043
|
+
* (which must preserve user-authored `mcp_servers` in a shared file), the
|
|
4044
|
+
* `--mcp-config` file is Alfe-generated and Alfe-owned end to end — Claude Code's
|
|
4045
|
+
* OWN user-authored MCP servers live in `~/.claude.json` / project `.mcp.json`,
|
|
4046
|
+
* NOT this generated file. So we fully regenerate it from the store on every
|
|
4047
|
+
* change (no read-merge-write needed). We still persist the written-id set to a
|
|
4048
|
+
* sidecar for parity + forensics, but removal is implicit in the regenerate.
|
|
4049
|
+
*/
|
|
4050
|
+
var ClaudeCodeMcpSync = class {
|
|
4051
|
+
manager;
|
|
4052
|
+
home;
|
|
4053
|
+
apiKey;
|
|
4054
|
+
configPath;
|
|
4055
|
+
trackingPath;
|
|
4056
|
+
debounceMs;
|
|
4057
|
+
/** Ids of `mcpServers` entries this sync last wrote — informational sidecar. */
|
|
4058
|
+
syncedIds = /* @__PURE__ */ new Set();
|
|
4059
|
+
/** Serializes syncs so two onChange-driven runs can't interleave file writes. */
|
|
4060
|
+
queue = Promise.resolve();
|
|
4061
|
+
unsubscribe;
|
|
4062
|
+
debounceTimer;
|
|
4063
|
+
started = false;
|
|
4064
|
+
constructor(opts) {
|
|
4065
|
+
this.manager = opts.manager;
|
|
4066
|
+
this.home = opts.home ?? DEFAULT_CLAUDE_CODE_HOME;
|
|
4067
|
+
this.apiKey = opts.apiKey;
|
|
4068
|
+
this.configPath = opts.configPath ?? join(this.home, "mcp-config.json");
|
|
4069
|
+
this.trackingPath = opts.trackingPath ?? join(this.home, ".alfe-mcp-synced.json");
|
|
4070
|
+
this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
4071
|
+
}
|
|
4072
|
+
/**
|
|
4073
|
+
* Begin mirroring: load the prior removal set, run one immediate sync (so the
|
|
4074
|
+
* `--mcp-config` file reflects the current store before the host next spawns
|
|
4075
|
+
* `claude -p`), then subscribe to store changes (debounced). Idempotent.
|
|
4076
|
+
*/
|
|
4077
|
+
start() {
|
|
4078
|
+
if (this.started) return;
|
|
4079
|
+
this.started = true;
|
|
4080
|
+
this.loadSyncedIds();
|
|
4081
|
+
this.syncOnce().catch((err) => {
|
|
4082
|
+
log$1.warn({ err: errMsg$1(err) }, "Claude Code MCP sync: initial sync failed");
|
|
4083
|
+
});
|
|
4084
|
+
this.unsubscribe = this.manager.onChange(() => {
|
|
4085
|
+
this.schedule();
|
|
4086
|
+
});
|
|
4087
|
+
log$1.info({ configPath: this.configPath }, "Claude Code MCP sync started — mirroring store into mcp-config.json");
|
|
4088
|
+
}
|
|
4089
|
+
/** Stop subscribing and cancel any pending debounced sync. Idempotent. */
|
|
4090
|
+
stop() {
|
|
4091
|
+
if (this.unsubscribe) {
|
|
4092
|
+
this.unsubscribe();
|
|
4093
|
+
this.unsubscribe = void 0;
|
|
4094
|
+
}
|
|
4095
|
+
if (this.debounceTimer) {
|
|
4096
|
+
clearTimeout(this.debounceTimer);
|
|
4097
|
+
this.debounceTimer = void 0;
|
|
4098
|
+
}
|
|
4099
|
+
this.started = false;
|
|
4100
|
+
}
|
|
4101
|
+
/**
|
|
4102
|
+
* Regenerate the `--mcp-config` file from the current store exactly once.
|
|
4103
|
+
* Public so the daemon (and tests) can await a deterministic sync. Serialized
|
|
4104
|
+
* against any other in-flight sync.
|
|
4105
|
+
*/
|
|
4106
|
+
syncOnce() {
|
|
4107
|
+
const run = () => {
|
|
4108
|
+
this.syncNow();
|
|
4109
|
+
return Promise.resolve();
|
|
4110
|
+
};
|
|
4111
|
+
const result = this.queue.then(run, run);
|
|
4112
|
+
this.queue = result.catch(() => void 0);
|
|
4113
|
+
return result;
|
|
4114
|
+
}
|
|
4115
|
+
schedule() {
|
|
4116
|
+
if (this.debounceMs <= 0) {
|
|
4117
|
+
this.syncOnce().catch((err) => {
|
|
4118
|
+
log$1.warn({ err: errMsg$1(err) }, "Claude Code MCP sync failed");
|
|
4119
|
+
});
|
|
4120
|
+
return;
|
|
4121
|
+
}
|
|
4122
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
4123
|
+
this.debounceTimer = setTimeout(() => {
|
|
4124
|
+
this.debounceTimer = void 0;
|
|
4125
|
+
this.syncOnce().catch((err) => {
|
|
4126
|
+
log$1.warn({ err: errMsg$1(err) }, "Claude Code MCP sync failed");
|
|
4127
|
+
});
|
|
4128
|
+
}, this.debounceMs);
|
|
4129
|
+
this.debounceTimer.unref();
|
|
4130
|
+
}
|
|
4131
|
+
syncNow() {
|
|
4132
|
+
const desired = this.computeDesired();
|
|
4133
|
+
const desiredIds = new Set(desired.keys());
|
|
4134
|
+
if (desiredIds.size === 0 && this.syncedIds.size === 0 && !existsSync(this.configPath)) return;
|
|
4135
|
+
const file = { mcpServers: Object.fromEntries(desired) };
|
|
4136
|
+
const next = JSON.stringify(file, null, 2) + "\n";
|
|
4137
|
+
if ((existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "") !== next) {
|
|
4138
|
+
mkdirSync(dirname(this.configPath), { recursive: true });
|
|
4139
|
+
writeFileSync(this.configPath, next, {
|
|
4140
|
+
encoding: "utf-8",
|
|
4141
|
+
mode: 384
|
|
4142
|
+
});
|
|
4143
|
+
chmodSync(this.configPath, 384);
|
|
4144
|
+
log$1.info({
|
|
4145
|
+
added: [...desiredIds],
|
|
4146
|
+
removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
|
|
4147
|
+
}, "Claude Code MCP sync: mcp-config.json regenerated");
|
|
4148
|
+
}
|
|
4149
|
+
this.syncedIds = desiredIds;
|
|
4150
|
+
this.persistSyncedIds();
|
|
4151
|
+
}
|
|
4152
|
+
computeDesired() {
|
|
4153
|
+
const desired = /* @__PURE__ */ new Map();
|
|
4154
|
+
for (const { id, entry } of this.manager.listServers()) desired.set(id, this.toClaudeCodeEntry(entry));
|
|
4155
|
+
return desired;
|
|
4156
|
+
}
|
|
4157
|
+
/**
|
|
4158
|
+
* Transform a stored entry into a Claude Code `--mcp-config` entry. stdio
|
|
4159
|
+
* entries get the literal `ALFE_API_KEY` injected; remote entries pass through
|
|
4160
|
+
* with the documented `type` discriminator (`sse` → `sse`, `streamable-http`
|
|
4161
|
+
* → `http`).
|
|
4162
|
+
*/
|
|
4163
|
+
toClaudeCodeEntry(entry) {
|
|
4164
|
+
const cfg = toServerConfig(entry);
|
|
4165
|
+
if ("command" in cfg) {
|
|
4166
|
+
const out = { command: cfg.command };
|
|
4167
|
+
if (cfg.args && cfg.args.length > 0) out.args = cfg.args;
|
|
4168
|
+
const env = this.withAlfeApiKey(cfg.env);
|
|
4169
|
+
if (Object.keys(env).length > 0) out.env = env;
|
|
4170
|
+
return out;
|
|
4171
|
+
}
|
|
4172
|
+
const out = {
|
|
4173
|
+
type: cfg.transport === "sse" ? "sse" : "http",
|
|
4174
|
+
url: cfg.url
|
|
4175
|
+
};
|
|
4176
|
+
if (cfg.headers) out.headers = cfg.headers;
|
|
4177
|
+
return out;
|
|
4178
|
+
}
|
|
4179
|
+
withAlfeApiKey(env) {
|
|
4180
|
+
const merged = { ...env ?? {} };
|
|
4181
|
+
if ("ALFE_API_KEY" in merged) return merged;
|
|
4182
|
+
if (!this.apiKey) {
|
|
4183
|
+
log$1.warn("Claude Code MCP sync: no ALFE_API_KEY available — generated MCP servers will start in zero-accounts degraded mode");
|
|
4184
|
+
return merged;
|
|
4185
|
+
}
|
|
4186
|
+
merged.ALFE_API_KEY = this.apiKey;
|
|
4187
|
+
return merged;
|
|
4188
|
+
}
|
|
4189
|
+
loadSyncedIds() {
|
|
4190
|
+
if (!existsSync(this.trackingPath)) return;
|
|
4191
|
+
try {
|
|
4192
|
+
const data = JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
4193
|
+
if (Array.isArray(data.syncedIds)) this.syncedIds = new Set(data.syncedIds.filter((x) => typeof x === "string"));
|
|
4194
|
+
} catch {}
|
|
4195
|
+
}
|
|
4196
|
+
persistSyncedIds() {
|
|
4197
|
+
try {
|
|
4198
|
+
mkdirSync(dirname(this.trackingPath), { recursive: true });
|
|
4199
|
+
writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
|
|
4200
|
+
} catch (err) {
|
|
4201
|
+
log$1.warn({ err: errMsg$1(err) }, "Claude Code MCP sync: failed to persist synced-id sidecar");
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
};
|
|
3716
4205
|
function errMsg$1(err) {
|
|
3717
4206
|
return err instanceof Error ? err.message : String(err);
|
|
3718
4207
|
}
|
|
@@ -3931,4 +4420,4 @@ var IntegrationManagerAdapter = class {
|
|
|
3931
4420
|
}
|
|
3932
4421
|
};
|
|
3933
4422
|
//#endregion
|
|
3934
|
-
export { DEFAULT_REGISTRY_TTL_MS, HermesApplier, HermesMcpSync, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, NoopOpenClawCliLock, OpenClawApplier, Registry, RegistryResolveError, Resolver, SerialOpenClawCliLock, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };
|
|
4423
|
+
export { ClaudeCodeApplier, ClaudeCodeMcpSync, DEFAULT_REGISTRY_TTL_MS, HermesApplier, HermesMcpSync, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, NoopOpenClawCliLock, OpenClawApplier, Registry, RegistryResolveError, Resolver, SerialOpenClawCliLock, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfe.ai/integrations",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"@auriclabs/logger": "^0.1.1",
|
|
16
16
|
"yaml": ">=2.8.3",
|
|
17
17
|
"@alfe.ai/integration-manifest": "^0.3.1",
|
|
18
|
-
"@alfe.ai/mcp-bundler": "^0.3.
|
|
18
|
+
"@alfe.ai/mcp-bundler": "^0.3.1"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist"
|