@alfe.ai/integrations 0.1.6 → 0.2.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 +273 -3
- package/dist/index.js +662 -93
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { IntegrationManifest, IntegrationStateEntry, IntegrationStatus, IntegrationsStateFile, McpServerDeclaration, PluginInstall, SkillInstall } from "@alfe.ai/integration-manifest";
|
|
2
|
-
import { Manager } from "@alfe.ai/mcp-bundler";
|
|
2
|
+
import { Manager, StoredServerEntry } from "@alfe.ai/mcp-bundler";
|
|
3
3
|
|
|
4
4
|
//#region src/registry.d.ts
|
|
5
5
|
|
|
@@ -308,6 +308,18 @@ interface RuntimeApplier {
|
|
|
308
308
|
applyConfig(integrationId: string, config: Record<string, unknown>): Promise<void>;
|
|
309
309
|
/** Remove config previously applied by an integration */
|
|
310
310
|
removeConfig(integrationId: string): Promise<void>;
|
|
311
|
+
/**
|
|
312
|
+
* Optional: write a single raw config key/value directly to the runtime,
|
|
313
|
+
* bypassing the per-integration removal accounting that `applyConfig`
|
|
314
|
+
* maintains. Used by the daemon's `alfe.config_set` cloud-command so it can
|
|
315
|
+
* route per-runtime instead of shelling the runtime CLI directly.
|
|
316
|
+
*
|
|
317
|
+
* MUST NOT funnel through `applyConfig` — a raw single-key set is untracked
|
|
318
|
+
* and must not corrupt an integration's tracked config contribution.
|
|
319
|
+
* Appliers that can't honour a raw write omit this method; callers detect
|
|
320
|
+
* its absence and degrade gracefully.
|
|
321
|
+
*/
|
|
322
|
+
setConfigRaw?(key: string, value: string): Promise<void>;
|
|
311
323
|
/** Check if this runtime is available (e.g. workspace directory exists) */
|
|
312
324
|
isAvailable(): Promise<boolean>;
|
|
313
325
|
}
|
|
@@ -842,11 +854,238 @@ declare class OpenClawApplier implements RuntimeApplier {
|
|
|
842
854
|
* then removes them via `openclaw config unset`.
|
|
843
855
|
*/
|
|
844
856
|
removeConfig(integrationId: string): Promise<void>;
|
|
857
|
+
/**
|
|
858
|
+
* Raw single-key config write — `openclaw config set <key> <value>`.
|
|
859
|
+
*
|
|
860
|
+
* Deliberately bypasses the `_integrations` tracking that `applyConfig`
|
|
861
|
+
* maintains: this is a fire-and-forget write for the `alfe.config_set`
|
|
862
|
+
* cloud-command, not an integration contribution, so it must NOT be
|
|
863
|
+
* recorded for later `removeConfig` teardown. Reuses the serialized +
|
|
864
|
+
* retried queue so it can't interleave with the hot-reload that integration
|
|
865
|
+
* config writes trigger.
|
|
866
|
+
*/
|
|
867
|
+
setConfigRaw(key: string, value: string): Promise<void>;
|
|
845
868
|
isAvailable(): Promise<boolean>;
|
|
846
869
|
private readTracking;
|
|
847
870
|
private writeTracking;
|
|
848
871
|
}
|
|
849
872
|
//#endregion
|
|
873
|
+
//#region src/appliers/hermes-applier.d.ts
|
|
874
|
+
interface HermesApplierOptions {
|
|
875
|
+
/**
|
|
876
|
+
* Path to the Hermes home directory (e.g. ~/.hermes) — where config.yaml,
|
|
877
|
+
* .env, and plugins/ live. For Hermes, home == workspace.
|
|
878
|
+
*/
|
|
879
|
+
home?: string;
|
|
880
|
+
/**
|
|
881
|
+
* @deprecated Use `home`. Kept for parity with OpenClawApplier's option shape —
|
|
882
|
+
* when set, used as `home`.
|
|
883
|
+
*/
|
|
884
|
+
workspace?: string;
|
|
885
|
+
/** Path to the per-integration tracking file (defaults to {home}/.alfe-integrations.json) */
|
|
886
|
+
configPath?: string;
|
|
887
|
+
/** Max retries for a transient `hermes config set/unset` failure (default 3) */
|
|
888
|
+
configSetRetries?: number;
|
|
889
|
+
/** Backoff between `hermes config` retries, ms (default 750; set 0 in tests) */
|
|
890
|
+
configSetRetryDelayMs?: number;
|
|
891
|
+
}
|
|
892
|
+
declare class HermesApplier implements RuntimeApplier {
|
|
893
|
+
readonly runtime = "hermes";
|
|
894
|
+
private home;
|
|
895
|
+
private trackingPath;
|
|
896
|
+
private configSetRetries;
|
|
897
|
+
private configSetRetryDelayMs;
|
|
898
|
+
/** Serializes all `hermes config` writes so they never interleave. */
|
|
899
|
+
private configSetQueue;
|
|
900
|
+
constructor(options?: HermesApplierOptions);
|
|
901
|
+
/**
|
|
902
|
+
* Apply integration config to the Hermes runtime via `hermes config set`.
|
|
903
|
+
*
|
|
904
|
+
* Each leaf is applied as `hermes config set <dotted.key> <value>`. Each
|
|
905
|
+
* integration's config contribution is tracked in the tracking file so it can
|
|
906
|
+
* be cleanly removed later (mirrors OpenClawApplier's `_integrations`).
|
|
907
|
+
*/
|
|
908
|
+
applyConfig(integrationId: string, config: Record<string, unknown>): Promise<void>;
|
|
909
|
+
/**
|
|
910
|
+
* Remove config previously applied by an integration.
|
|
911
|
+
*
|
|
912
|
+
* Reads the tracking file to find which keys this integration set, then
|
|
913
|
+
* removes each via `hermes config unset`. Clears the tracking entry.
|
|
914
|
+
*/
|
|
915
|
+
removeConfig(integrationId: string): Promise<void>;
|
|
916
|
+
/**
|
|
917
|
+
* Raw single-key config write — `hermes config set <key> <value>`.
|
|
918
|
+
*
|
|
919
|
+
* Deliberately bypasses the `_integrations` tracking that `applyConfig`
|
|
920
|
+
* maintains: this is a fire-and-forget write for the `alfe.config_set`
|
|
921
|
+
* cloud-command, NOT an integration contribution, so it must not be recorded
|
|
922
|
+
* for later `removeConfig` teardown (doing so would corrupt the
|
|
923
|
+
* per-integration removal accounting). Reuses the serialized queue.
|
|
924
|
+
*/
|
|
925
|
+
setConfigRaw(key: string, value: string): Promise<void>;
|
|
926
|
+
/**
|
|
927
|
+
* Apply a plugin. The `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific
|
|
928
|
+
* (they carry the `openclaw` peer dep) and do NOT apply to Hermes — log + skip
|
|
929
|
+
* them. The manager catches per-plugin, so a skip here is a correct no-op.
|
|
930
|
+
*
|
|
931
|
+
* A genuine Hermes-native plugin spec (git URL / pip / local dir) would
|
|
932
|
+
* `hermes plugins install` then `hermes plugins enable`; no manifest declares
|
|
933
|
+
* one in Phase 1, so this path is a real attempt (not faked) but untrodden.
|
|
934
|
+
*/
|
|
935
|
+
applyPlugin(spec: string): Promise<void>;
|
|
936
|
+
/**
|
|
937
|
+
* Remove a plugin. OpenClaw-specific npm plugins were never installed into
|
|
938
|
+
* Hermes (applyPlugin skipped them), so removal is a no-op log. A native
|
|
939
|
+
* Hermes plugin would be disabled via `hermes plugins`.
|
|
940
|
+
*/
|
|
941
|
+
removePlugin(spec: string): Promise<void>;
|
|
942
|
+
applySkill(name: string): Promise<void>;
|
|
943
|
+
removeSkill(name: string): Promise<void>;
|
|
944
|
+
applyClawHubSkill(slug: string): Promise<void>;
|
|
945
|
+
removeClawHubSkill(slug: string): Promise<void>;
|
|
946
|
+
isAvailable(): Promise<boolean>;
|
|
947
|
+
/** Convenience: `hermes config set <args>`, serialized + retried. */
|
|
948
|
+
private runConfigSet;
|
|
949
|
+
/**
|
|
950
|
+
* Unset a single config key. The unset verb is isolated HERE so there is one
|
|
951
|
+
* place to change if the spike proves `hermes config unset` is unavailable.
|
|
952
|
+
*
|
|
953
|
+
* FALLBACK (do NOT pre-build): if `hermes config unset` does not exist, the
|
|
954
|
+
* substitute is `hermes config set <key> ""` (clear the value) or a
|
|
955
|
+
* read-merge-write of config.yaml. Not implemented now — `unset` is the
|
|
956
|
+
* documented verb; confirm in the Phase-0 spike before adding a fallback.
|
|
957
|
+
* TODO(phase-0 spike): confirm `hermes config unset <key>` exists.
|
|
958
|
+
*/
|
|
959
|
+
private unsetConfigKey;
|
|
960
|
+
/**
|
|
961
|
+
* Run `hermes config <args>` (set/unset), serialized against every other
|
|
962
|
+
* config write and retried with backoff. Throws an Error whose (scrubbed)
|
|
963
|
+
* message includes stderr after retries are exhausted, so the real cause
|
|
964
|
+
* propagates to the integration errorMessage without leaking the value.
|
|
965
|
+
*/
|
|
966
|
+
private runConfigCommand;
|
|
967
|
+
private readTracking;
|
|
968
|
+
private writeTracking;
|
|
969
|
+
}
|
|
970
|
+
//#endregion
|
|
971
|
+
//#region src/appliers/hermes-mcp-sync.d.ts
|
|
972
|
+
/**
|
|
973
|
+
* The slice of the bundler `Manager` this consumer needs. Kept structural (not a
|
|
974
|
+
* concrete-class dependency) so it stays trivially testable and so the consumer
|
|
975
|
+
* can never reach for producer-side mutation methods — it is read-only over the
|
|
976
|
+
* store plus a change subscription.
|
|
977
|
+
*/
|
|
978
|
+
interface McpStoreReader {
|
|
979
|
+
onChange(cb: () => void): () => void;
|
|
980
|
+
listServers(): {
|
|
981
|
+
id: string;
|
|
982
|
+
entry: StoredServerEntry;
|
|
983
|
+
}[];
|
|
984
|
+
}
|
|
985
|
+
interface HermesMcpSyncOptions {
|
|
986
|
+
/** The runtime-agnostic MCP store manager (read + onChange). */
|
|
987
|
+
manager: McpStoreReader;
|
|
988
|
+
/** Hermes home (== workspace == ~/.hermes). config.yaml + .env live here. */
|
|
989
|
+
home?: string;
|
|
990
|
+
/**
|
|
991
|
+
* The `ALFE_API_KEY` value to write into `~/.hermes/.env` so the
|
|
992
|
+
* `${ALFE_API_KEY}` refs in mirrored server envs resolve. In managed mode this
|
|
993
|
+
* is the daemon's own api key; self-hosted threads it explicitly. When absent,
|
|
994
|
+
* the `.env` write is skipped and a warning is logged (the servers would then
|
|
995
|
+
* start in zero-accounts degraded mode).
|
|
996
|
+
*/
|
|
997
|
+
apiKey?: string;
|
|
998
|
+
/**
|
|
999
|
+
* Trigger a runtime reload after a real change. MUST be the SAME callback the
|
|
1000
|
+
* daemon wires into `setRuntimeRestartNeededHandler` — there is exactly one
|
|
1001
|
+
* restart path. No-op when the runtime isn't running yet (the initial sync
|
|
1002
|
+
* writes config.yaml before Hermes starts, so no restart is needed then).
|
|
1003
|
+
*/
|
|
1004
|
+
requestRestart?: () => void;
|
|
1005
|
+
/** Override config.yaml path (defaults to {home}/config.yaml). For tests. */
|
|
1006
|
+
configPath?: string;
|
|
1007
|
+
/** Override .env path (defaults to {home}/.env). For tests. */
|
|
1008
|
+
envPath?: string;
|
|
1009
|
+
/**
|
|
1010
|
+
* Override the sidecar that records which `mcp_servers` ids this sync wrote
|
|
1011
|
+
* (defaults to {home}/.alfe-mcp-synced.json). Used so removals survive daemon
|
|
1012
|
+
* restarts and so we NEVER delete user-authored `mcp_servers` entries.
|
|
1013
|
+
*/
|
|
1014
|
+
trackingPath?: string;
|
|
1015
|
+
/**
|
|
1016
|
+
* Trailing debounce (ms) used to coalesce a burst of store mutations (one
|
|
1017
|
+
* integration install fires `addServer` once per server) into a single
|
|
1018
|
+
* config.yaml write + restart, avoiding a restart storm. Default 250; set 0 in
|
|
1019
|
+
* tests. The initial sync in `start()` is always immediate.
|
|
1020
|
+
*/
|
|
1021
|
+
debounceMs?: number;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Read-merge-write mirror of the MCP store into `~/.hermes/config.yaml`.
|
|
1025
|
+
*
|
|
1026
|
+
* Ownership / removal model: every entry returned by `manager.listServers()`
|
|
1027
|
+
* comes from the Alfe store (its `owner` is `integration:<id>` / `cli` /
|
|
1028
|
+
* `manual`) and is therefore Alfe-owned — these are the ids we write. User
|
|
1029
|
+
* authored `mcp_servers` entries live ONLY in config.yaml and never appear in
|
|
1030
|
+
* the store, so we never touch them. To know which config.yaml ids to REMOVE
|
|
1031
|
+
* when a server leaves the store, we persist the set of ids we last wrote to a
|
|
1032
|
+
* sidecar (`.alfe-mcp-synced.json`) and only ever delete from that set — exactly
|
|
1033
|
+
* how the bundler store tracks `_ownedOpenclawKeys` for its openclaw.json mirror.
|
|
1034
|
+
*/
|
|
1035
|
+
declare class HermesMcpSync {
|
|
1036
|
+
private readonly manager;
|
|
1037
|
+
private readonly home;
|
|
1038
|
+
private readonly apiKey?;
|
|
1039
|
+
private readonly requestRestart?;
|
|
1040
|
+
private readonly configPath;
|
|
1041
|
+
private readonly envPath;
|
|
1042
|
+
private readonly trackingPath;
|
|
1043
|
+
private readonly debounceMs;
|
|
1044
|
+
/** Ids of `mcp_servers` entries this sync last wrote — the removal set. */
|
|
1045
|
+
private syncedIds;
|
|
1046
|
+
/** Serializes syncs so two onChange-driven runs can't interleave file writes. */
|
|
1047
|
+
private queue;
|
|
1048
|
+
private unsubscribe?;
|
|
1049
|
+
private debounceTimer?;
|
|
1050
|
+
private started;
|
|
1051
|
+
constructor(opts: HermesMcpSyncOptions);
|
|
1052
|
+
/**
|
|
1053
|
+
* Begin mirroring: load the prior removal set, run one immediate sync (so
|
|
1054
|
+
* config.yaml reflects the current store before Hermes first starts), then
|
|
1055
|
+
* subscribe to store changes (debounced). Idempotent.
|
|
1056
|
+
*/
|
|
1057
|
+
start(): void;
|
|
1058
|
+
/** Stop subscribing and cancel any pending debounced sync. Idempotent. */
|
|
1059
|
+
stop(): void;
|
|
1060
|
+
/**
|
|
1061
|
+
* Mirror the current store into config.yaml + .env exactly once. Public so the
|
|
1062
|
+
* daemon (and tests) can await a deterministic sync. Serialized against any
|
|
1063
|
+
* other in-flight sync.
|
|
1064
|
+
*/
|
|
1065
|
+
syncOnce(): Promise<void>;
|
|
1066
|
+
private schedule;
|
|
1067
|
+
private syncNow;
|
|
1068
|
+
private computeDesired;
|
|
1069
|
+
/**
|
|
1070
|
+
* Transform a stored entry into a Hermes `mcp_servers` entry. SPIKE-PENDING
|
|
1071
|
+
* SEAM #3 lives here — the schema shape. stdio entries get `ALFE_API_KEY`
|
|
1072
|
+
* injected; remote entries pass through (Hermes supports `url`-based MCP).
|
|
1073
|
+
*/
|
|
1074
|
+
private toHermesEntry;
|
|
1075
|
+
private withAlfeApiKey;
|
|
1076
|
+
/** Read-merge-write `~/.hermes/.env` to set ALFE_API_KEY without clobbering other keys. */
|
|
1077
|
+
private ensureEnvApiKey;
|
|
1078
|
+
private loadSyncedIds;
|
|
1079
|
+
private persistSyncedIds;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Set `KEY=value` in a `.env`-style file, preserving every other line (comments,
|
|
1083
|
+
* blank lines, unrelated keys) and order. Returns whether the file changed. Not
|
|
1084
|
+
* a full dotenv parser — it only matches simple `KEY=` lines, which is all
|
|
1085
|
+
* `~/.hermes/.env` ever holds.
|
|
1086
|
+
*/
|
|
1087
|
+
declare function upsertEnvVar(envPath: string, key: string, value: string): boolean;
|
|
1088
|
+
//#endregion
|
|
850
1089
|
//#region src/lock.d.ts
|
|
851
1090
|
interface RuntimePluginEntry {
|
|
852
1091
|
/**
|
|
@@ -866,9 +1105,29 @@ interface RuntimeSkillEntry {
|
|
|
866
1105
|
sourceIntegration: string;
|
|
867
1106
|
integrationVersion: string;
|
|
868
1107
|
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Records that an integration applied runtime *config* (via the applier's
|
|
1110
|
+
* `applyConfig`) to a runtime — even when it contributed no plugins/skills.
|
|
1111
|
+
*
|
|
1112
|
+
* Without this, a config-only integration left no lock entry for its runtime,
|
|
1113
|
+
* so `removeEntries` returned nothing for it and `deactivate` never drove
|
|
1114
|
+
* `applier.removeConfig(...)` — leaking the applied config on removal. The
|
|
1115
|
+
* applier itself owns the actual config payload (e.g. OpenClaw's tracking
|
|
1116
|
+
* file); the lock only needs to know which integration touched which runtime.
|
|
1117
|
+
*/
|
|
1118
|
+
interface RuntimeConfigEntry {
|
|
1119
|
+
sourceIntegration: string;
|
|
1120
|
+
integrationVersion: string;
|
|
1121
|
+
}
|
|
869
1122
|
interface RuntimeDesiredState {
|
|
870
1123
|
plugins: RuntimePluginEntry[];
|
|
871
1124
|
skills: RuntimeSkillEntry[];
|
|
1125
|
+
/**
|
|
1126
|
+
* Integrations that applied config to this runtime. Optional for
|
|
1127
|
+
* back-compat with lock files written before this field existed — always
|
|
1128
|
+
* read it as `state.config ?? []`.
|
|
1129
|
+
*/
|
|
1130
|
+
config?: RuntimeConfigEntry[];
|
|
872
1131
|
}
|
|
873
1132
|
interface RuntimeLockFile {
|
|
874
1133
|
version: 1;
|
|
@@ -888,15 +1147,26 @@ declare class LockManager {
|
|
|
888
1147
|
write(lock: RuntimeLockFile): void;
|
|
889
1148
|
/**
|
|
890
1149
|
* Add entries for an integration activation in a specific runtime.
|
|
1150
|
+
*
|
|
1151
|
+
* Pass `opts.configApplied` when the integration applied runtime config so
|
|
1152
|
+
* the contribution is recorded even if it ships no plugins/skills — this is
|
|
1153
|
+
* what lets a config-only integration be torn down on deactivate.
|
|
891
1154
|
*/
|
|
892
|
-
addEntries(runtime: string, integrationId: string, version: string, plugins: PluginInstall[], skills: SkillInstall[], installPath: string
|
|
1155
|
+
addEntries(runtime: string, integrationId: string, version: string, plugins: PluginInstall[], skills: SkillInstall[], installPath: string, opts?: {
|
|
1156
|
+
configApplied?: boolean;
|
|
1157
|
+
}): void;
|
|
893
1158
|
/**
|
|
894
1159
|
* Remove all entries for a given integration across all runtimes.
|
|
895
1160
|
* Returns what was removed, keyed by runtime.
|
|
1161
|
+
*
|
|
1162
|
+
* A runtime is included in the result when the integration contributed
|
|
1163
|
+
* plugins, skills, OR config there — so `deactivate` drives
|
|
1164
|
+
* `applier.removeConfig` even for a config-only integration.
|
|
896
1165
|
*/
|
|
897
1166
|
removeEntries(integrationId: string): Record<string, {
|
|
898
1167
|
plugins: RuntimePluginEntry[];
|
|
899
1168
|
skills: RuntimeSkillEntry[];
|
|
1169
|
+
config: RuntimeConfigEntry[];
|
|
900
1170
|
}>;
|
|
901
1171
|
/**
|
|
902
1172
|
* Get the full desired state for a specific runtime.
|
|
@@ -964,4 +1234,4 @@ declare class IntegrationManagerAdapter implements IIntegrationManager {
|
|
|
964
1234
|
resetReinstallAttempts(integrationId: string): void;
|
|
965
1235
|
}
|
|
966
1236
|
//#endregion
|
|
967
|
-
export { type CredentialsResolver, DEFAULT_REGISTRY_TTL_MS, 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, OpenClawApplier, type OpenClawApplierOptions, type PlatformContext, Registry, type RegistryEntry, type RegistryFetcher, type RegistryIndex, type RegistryOptions, RegistryResolveError, type ResolveOptions, type ResolvedIntegration, Resolver, type RuntimeApplier, type RuntimeDesiredState, type RuntimeLockFile, type RuntimePluginEntry, type RuntimeSkillEntry, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext };
|
|
1237
|
+
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, OpenClawApplier, type OpenClawApplierOptions, 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, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };
|
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$
|
|
153
|
-
const log$
|
|
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$
|
|
216
|
+
await execFileAsync$2("git", [
|
|
215
217
|
"clone",
|
|
216
218
|
resolved.repository,
|
|
217
219
|
installPath
|
|
218
220
|
], { timeout: GIT_TIMEOUT_MS });
|
|
219
|
-
await execFileAsync$
|
|
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$
|
|
242
|
+
await execFileAsync$2("git", [
|
|
241
243
|
"clone",
|
|
242
244
|
resolved.repository,
|
|
243
245
|
tempDir
|
|
244
246
|
], { timeout: GIT_TIMEOUT_MS });
|
|
245
|
-
await execFileAsync$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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
|
-
|
|
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$
|
|
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,9 +1700,9 @@ 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
|
+
const delay$1 = (ms) => new Promise((resolve) => {
|
|
1633
1706
|
setTimeout(resolve, ms);
|
|
1634
1707
|
});
|
|
1635
1708
|
/**
|
|
@@ -1657,55 +1730,9 @@ function configSetErrorMessage(err, args) {
|
|
|
1657
1730
|
}
|
|
1658
1731
|
return `${target} failed: ${String(err)}`;
|
|
1659
1732
|
}
|
|
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
1733
|
async function readParentObject(parentPath) {
|
|
1707
1734
|
try {
|
|
1708
|
-
const { stdout } = await execFileAsync("openclaw", [
|
|
1735
|
+
const { stdout } = await execFileAsync$1("openclaw", [
|
|
1709
1736
|
"config",
|
|
1710
1737
|
"get",
|
|
1711
1738
|
parentPath
|
|
@@ -1732,8 +1759,8 @@ var OpenClawApplier = class {
|
|
|
1732
1759
|
this.agentWorkspace = options.agentWorkspace ?? join(home, "workspace");
|
|
1733
1760
|
this.skillsDir = options.skillsDir ?? DEFAULT_SKILLS_DIR;
|
|
1734
1761
|
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;
|
|
1762
|
+
this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES$1;
|
|
1763
|
+
this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS$1;
|
|
1737
1764
|
}
|
|
1738
1765
|
/** Convenience: `openclaw config set <args>`, serialized + retried. */
|
|
1739
1766
|
runConfigSet(setArgs, opts = {}) {
|
|
@@ -1754,11 +1781,11 @@ var OpenClawApplier = class {
|
|
|
1754
1781
|
const run = async () => {
|
|
1755
1782
|
let lastErr;
|
|
1756
1783
|
for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
|
|
1757
|
-
await execFileAsync("openclaw", ["config", ...args], { timeout });
|
|
1784
|
+
await execFileAsync$1("openclaw", ["config", ...args], { timeout });
|
|
1758
1785
|
return;
|
|
1759
1786
|
} catch (err) {
|
|
1760
1787
|
lastErr = err;
|
|
1761
|
-
if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay(this.configSetRetryDelayMs * 2 ** attempt);
|
|
1788
|
+
if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay$1(this.configSetRetryDelayMs * 2 ** attempt);
|
|
1762
1789
|
}
|
|
1763
1790
|
throw new Error(configSetErrorMessage(lastErr, args));
|
|
1764
1791
|
};
|
|
@@ -1771,14 +1798,14 @@ var OpenClawApplier = class {
|
|
|
1771
1798
|
await this.ensurePluginsAllow(pkg);
|
|
1772
1799
|
this.cleanupUntrackedExtensionInstall(pkg);
|
|
1773
1800
|
if (opts?.force && this.isPluginInstalled(pkg)) {
|
|
1774
|
-
log$
|
|
1801
|
+
log$3.info({
|
|
1775
1802
|
pkg,
|
|
1776
1803
|
spec
|
|
1777
1804
|
}, "Force mode — uninstalling plugin before reinstall");
|
|
1778
1805
|
try {
|
|
1779
1806
|
await this.removePlugin(pkg);
|
|
1780
1807
|
} catch (err) {
|
|
1781
|
-
log$
|
|
1808
|
+
log$3.warn({
|
|
1782
1809
|
pkg,
|
|
1783
1810
|
spec,
|
|
1784
1811
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -1796,12 +1823,12 @@ var OpenClawApplier = class {
|
|
|
1796
1823
|
const args = useUnsafeFlag ? [...baseArgs, "--dangerously-force-unsafe-install"] : baseArgs;
|
|
1797
1824
|
try {
|
|
1798
1825
|
try {
|
|
1799
|
-
await execFileAsync("openclaw", args, { timeout: 6e4 });
|
|
1826
|
+
await execFileAsync$1("openclaw", args, { timeout: 6e4 });
|
|
1800
1827
|
} catch (err) {
|
|
1801
1828
|
const errText = err instanceof Error ? `${err.message}\n${err.stderr ?? ""}` : String(err);
|
|
1802
1829
|
if (useUnsafeFlag && errText.includes("unknown option")) {
|
|
1803
|
-
log$
|
|
1804
|
-
await execFileAsync("openclaw", baseArgs, { timeout: 6e4 });
|
|
1830
|
+
log$3.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
|
|
1831
|
+
await execFileAsync$1("openclaw", baseArgs, { timeout: 6e4 });
|
|
1805
1832
|
} else throw err;
|
|
1806
1833
|
}
|
|
1807
1834
|
} catch (err) {
|
|
@@ -1809,7 +1836,7 @@ var OpenClawApplier = class {
|
|
|
1809
1836
|
setTimeout(r, 500);
|
|
1810
1837
|
});
|
|
1811
1838
|
if (!this.isPluginInstalled(pkg)) throw err;
|
|
1812
|
-
log$
|
|
1839
|
+
log$3.warn({
|
|
1813
1840
|
pkg,
|
|
1814
1841
|
spec,
|
|
1815
1842
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -1832,7 +1859,7 @@ var OpenClawApplier = class {
|
|
|
1832
1859
|
const wanted = Array.isArray(pkgs) ? pkgs : [pkgs];
|
|
1833
1860
|
let currentAllow = [];
|
|
1834
1861
|
try {
|
|
1835
|
-
const { stdout } = await execFileAsync("openclaw", [
|
|
1862
|
+
const { stdout } = await execFileAsync$1("openclaw", [
|
|
1836
1863
|
"config",
|
|
1837
1864
|
"get",
|
|
1838
1865
|
"plugins.allow"
|
|
@@ -1846,7 +1873,7 @@ var OpenClawApplier = class {
|
|
|
1846
1873
|
try {
|
|
1847
1874
|
await this.runConfigSet(["plugins.allow", JSON.stringify(updated)]);
|
|
1848
1875
|
} catch (err) {
|
|
1849
|
-
log$
|
|
1876
|
+
log$3.warn({
|
|
1850
1877
|
err: err instanceof Error ? err.message : String(err),
|
|
1851
1878
|
pkgs: wanted
|
|
1852
1879
|
}, "Failed to set plugins.allow via openclaw config set");
|
|
@@ -1910,12 +1937,12 @@ var OpenClawApplier = class {
|
|
|
1910
1937
|
recursive: true,
|
|
1911
1938
|
force: true
|
|
1912
1939
|
});
|
|
1913
|
-
log$
|
|
1940
|
+
log$3.info({
|
|
1914
1941
|
pkg,
|
|
1915
1942
|
removed: fullPath
|
|
1916
1943
|
}, "Removed untracked extensions/ install — will reinstall via npm path");
|
|
1917
1944
|
} catch (err) {
|
|
1918
|
-
log$
|
|
1945
|
+
log$3.warn({
|
|
1919
1946
|
pkg,
|
|
1920
1947
|
removed: fullPath,
|
|
1921
1948
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -1924,7 +1951,7 @@ var OpenClawApplier = class {
|
|
|
1924
1951
|
}
|
|
1925
1952
|
}
|
|
1926
1953
|
async removePlugin(spec) {
|
|
1927
|
-
await execFileAsync("openclaw", [
|
|
1954
|
+
await execFileAsync$1("openclaw", [
|
|
1928
1955
|
"plugins",
|
|
1929
1956
|
"uninstall",
|
|
1930
1957
|
"--force",
|
|
@@ -1938,21 +1965,21 @@ var OpenClawApplier = class {
|
|
|
1938
1965
|
return Promise.resolve();
|
|
1939
1966
|
}
|
|
1940
1967
|
async applyClawHubSkill(slug) {
|
|
1941
|
-
log$
|
|
1968
|
+
log$3.info({ slug }, "Installing skill from ClawHub");
|
|
1942
1969
|
try {
|
|
1943
|
-
await execFileAsync("openclaw", [
|
|
1970
|
+
await execFileAsync$1("openclaw", [
|
|
1944
1971
|
"skills",
|
|
1945
1972
|
"install",
|
|
1946
1973
|
slug
|
|
1947
1974
|
], { timeout: 6e4 });
|
|
1948
|
-
log$
|
|
1975
|
+
log$3.info({ slug }, "ClawHub skill installed");
|
|
1949
1976
|
} catch (err) {
|
|
1950
1977
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1951
1978
|
if (msg.includes("already exists") || msg.includes("Skill already exists")) {
|
|
1952
|
-
log$
|
|
1979
|
+
log$3.info({ slug }, "ClawHub skill already installed (skipped)");
|
|
1953
1980
|
return;
|
|
1954
1981
|
}
|
|
1955
|
-
log$
|
|
1982
|
+
log$3.error({
|
|
1956
1983
|
slug,
|
|
1957
1984
|
err: msg
|
|
1958
1985
|
}, "ClawHub skill install failed");
|
|
@@ -1965,7 +1992,7 @@ var OpenClawApplier = class {
|
|
|
1965
1992
|
recursive: true,
|
|
1966
1993
|
force: true
|
|
1967
1994
|
});
|
|
1968
|
-
log$
|
|
1995
|
+
log$3.info({ slug }, "ClawHub skill removed");
|
|
1969
1996
|
}
|
|
1970
1997
|
return Promise.resolve();
|
|
1971
1998
|
}
|
|
@@ -1996,7 +2023,7 @@ var OpenClawApplier = class {
|
|
|
1996
2023
|
try {
|
|
1997
2024
|
await this.runConfigSet([parentPath, JSON.stringify(merged)]);
|
|
1998
2025
|
} catch (err) {
|
|
1999
|
-
log$
|
|
2026
|
+
log$3.error({
|
|
2000
2027
|
err: err instanceof Error ? err.message : String(err),
|
|
2001
2028
|
parentPath
|
|
2002
2029
|
}, "Failed to set config subtree via openclaw config set");
|
|
@@ -2006,7 +2033,7 @@ var OpenClawApplier = class {
|
|
|
2006
2033
|
if (leaves.length > 0) try {
|
|
2007
2034
|
await this.runConfigSet(["--batch-json", JSON.stringify(leaves)]);
|
|
2008
2035
|
} catch (err) {
|
|
2009
|
-
log$
|
|
2036
|
+
log$3.error({
|
|
2010
2037
|
err: err instanceof Error ? err.message : String(err),
|
|
2011
2038
|
batch: leaves
|
|
2012
2039
|
}, "Failed to set config via openclaw config set --batch-json");
|
|
@@ -2028,7 +2055,7 @@ var OpenClawApplier = class {
|
|
|
2028
2055
|
for (const { path } of leaves) try {
|
|
2029
2056
|
await this.runConfigCommand(["unset", path]);
|
|
2030
2057
|
} catch (err) {
|
|
2031
|
-
log$
|
|
2058
|
+
log$3.warn({
|
|
2032
2059
|
err: err instanceof Error ? err.message : String(err),
|
|
2033
2060
|
path
|
|
2034
2061
|
}, "Failed to unset config via openclaw config unset");
|
|
@@ -2041,7 +2068,7 @@ var OpenClawApplier = class {
|
|
|
2041
2068
|
if (Object.keys(remaining).length === 0) await this.runConfigCommand(["unset", parentPath]);
|
|
2042
2069
|
else await this.runConfigSet([parentPath, JSON.stringify(remaining)]);
|
|
2043
2070
|
} catch (err) {
|
|
2044
|
-
log$
|
|
2071
|
+
log$3.warn({
|
|
2045
2072
|
err: err instanceof Error ? err.message : String(err),
|
|
2046
2073
|
parentPath
|
|
2047
2074
|
}, "Failed to update parent config during remove");
|
|
@@ -2050,9 +2077,292 @@ var OpenClawApplier = class {
|
|
|
2050
2077
|
tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
|
|
2051
2078
|
this.writeTracking(tracking);
|
|
2052
2079
|
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Raw single-key config write — `openclaw config set <key> <value>`.
|
|
2082
|
+
*
|
|
2083
|
+
* Deliberately bypasses the `_integrations` tracking that `applyConfig`
|
|
2084
|
+
* maintains: this is a fire-and-forget write for the `alfe.config_set`
|
|
2085
|
+
* cloud-command, not an integration contribution, so it must NOT be
|
|
2086
|
+
* recorded for later `removeConfig` teardown. Reuses the serialized +
|
|
2087
|
+
* retried queue so it can't interleave with the hot-reload that integration
|
|
2088
|
+
* config writes trigger.
|
|
2089
|
+
*/
|
|
2090
|
+
setConfigRaw(key, value) {
|
|
2091
|
+
return this.runConfigSet([key, value]);
|
|
2092
|
+
}
|
|
2093
|
+
isAvailable() {
|
|
2094
|
+
return Promise.resolve(existsSync(this.home));
|
|
2095
|
+
}
|
|
2096
|
+
readTracking() {
|
|
2097
|
+
if (!existsSync(this.trackingPath)) return {};
|
|
2098
|
+
try {
|
|
2099
|
+
return JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
2100
|
+
} catch {
|
|
2101
|
+
return {};
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
writeTracking(config) {
|
|
2105
|
+
mkdirSync(join(this.trackingPath, ".."), { recursive: true });
|
|
2106
|
+
writeFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
//#endregion
|
|
2110
|
+
//#region src/appliers/hermes-applier.ts
|
|
2111
|
+
/**
|
|
2112
|
+
* HermesApplier — applies integration config (and, later, native plugins) to
|
|
2113
|
+
* the Hermes runtime (Nous Research's Python agent).
|
|
2114
|
+
*
|
|
2115
|
+
* Hermes config lives in `~/.hermes/config.yaml` (YAML) and is mutated via the
|
|
2116
|
+
* `hermes config set/unset <dotted.key> <value>` CLI — we never write the YAML
|
|
2117
|
+
* file directly (mirrors the OpenClaw rule of letting the runtime own its own
|
|
2118
|
+
* config format). Per-integration contributions are tracked in a separate file
|
|
2119
|
+
* (`~/.hermes/.alfe-integrations.json`) so removal is precise.
|
|
2120
|
+
*
|
|
2121
|
+
* Scope (Phase 1, MCP-first hybrid):
|
|
2122
|
+
* - config: SUPPORTED — consumes `installs.runtimes.hermes.config` (the AI-proxy
|
|
2123
|
+
* routing keys: model.provider/base_url/api_key/model, etc.).
|
|
2124
|
+
* - plugins: the `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific and are
|
|
2125
|
+
* log-and-skipped here; a real Hermes-native plugin spec (git/pip/dir) would
|
|
2126
|
+
* `hermes plugins install`/`enable`, but no manifest declares one yet.
|
|
2127
|
+
* - skills: no-op (Hermes ships built-in skills; ClawHub is OpenClaw-only) —
|
|
2128
|
+
* deferred to a later per-capability phase.
|
|
2129
|
+
* - MCP: NOT handled here. MCP delivery for Hermes is a later phase (config-only
|
|
2130
|
+
* `mcp_servers:` via the runtime-agnostic store consumer). This applier owns
|
|
2131
|
+
* config + plugins only.
|
|
2132
|
+
*/
|
|
2133
|
+
const execFileAsync = promisify(execFile);
|
|
2134
|
+
const log$2 = createLogger("HermesApplier");
|
|
2135
|
+
const DEFAULT_HERMES_HOME$1 = join(homedir(), ".hermes");
|
|
2136
|
+
/**
|
|
2137
|
+
* Hermes config writes are serialized through one promise chain so concurrent
|
|
2138
|
+
* `applyConfig` calls (the manager can fan out across integrations) never
|
|
2139
|
+
* interleave their `hermes config set` writes against the same config.yaml.
|
|
2140
|
+
*
|
|
2141
|
+
* Whether Hermes hot-reloads config.yaml on each write — the way OpenClaw does,
|
|
2142
|
+
* which forced a retry/backoff there — is NOT yet spike-confirmed. We keep a
|
|
2143
|
+
* modest retry as a precaution; if the spike proves Hermes writes are
|
|
2144
|
+
* synchronous and race-free, the retry count can drop to 0 without API change.
|
|
2145
|
+
*/
|
|
2146
|
+
const CONFIG_SET_RETRIES = 3;
|
|
2147
|
+
const CONFIG_SET_RETRY_DELAY_MS = 750;
|
|
2148
|
+
const delay = (ms) => new Promise((resolve) => {
|
|
2149
|
+
setTimeout(resolve, ms);
|
|
2150
|
+
});
|
|
2151
|
+
/** `@alfe.ai/openclaw-*` packages are OpenClaw-specific plugins — not Hermes. */
|
|
2152
|
+
function isOpenClawPlugin(spec) {
|
|
2153
|
+
return stripPluginVersion(spec).startsWith("@alfe.ai/openclaw-");
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Serialize a config value for `hermes config set <key> <value>`. Strings pass
|
|
2157
|
+
* through verbatim (the proxy routing keys — provider/base_url/api_key/model —
|
|
2158
|
+
* are all strings, including `${ALFE_API_KEY}` env refs). Non-strings are
|
|
2159
|
+
* JSON-encoded so booleans/numbers/objects survive the CLI round-trip.
|
|
2160
|
+
*/
|
|
2161
|
+
function stringifyConfigValue(value) {
|
|
2162
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Describe a `config set/unset` target WITHOUT leaking values. The value
|
|
2166
|
+
* argument can be a secret (`model.api_key`), so it must never reach the
|
|
2167
|
+
* integration's user-facing `errorMessage`. Keep only the program + verb + key.
|
|
2168
|
+
*/
|
|
2169
|
+
function redactConfigTarget(args) {
|
|
2170
|
+
return `hermes config ${args.slice(0, 2).join(" ")}`.trim();
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
|
|
2174
|
+
* execFile error `message` is "Command failed: <full argv>" — which for
|
|
2175
|
+
* `config set` embeds the value payload — so we never surface it. We keep the
|
|
2176
|
+
* redacted target, the exit code, and `stderr` (hermes' own error text).
|
|
2177
|
+
*/
|
|
2178
|
+
function configErrorMessage(err, args) {
|
|
2179
|
+
const target = redactConfigTarget(args);
|
|
2180
|
+
if (err instanceof Error) {
|
|
2181
|
+
const e = err;
|
|
2182
|
+
const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
|
|
2183
|
+
const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
|
|
2184
|
+
return stderr ? `${target} failed${code}: ${stderr}` : `${target} failed${code}`;
|
|
2185
|
+
}
|
|
2186
|
+
return `${target} failed: ${String(err)}`;
|
|
2187
|
+
}
|
|
2188
|
+
var HermesApplier = class {
|
|
2189
|
+
runtime = "hermes";
|
|
2190
|
+
home;
|
|
2191
|
+
trackingPath;
|
|
2192
|
+
configSetRetries;
|
|
2193
|
+
configSetRetryDelayMs;
|
|
2194
|
+
/** Serializes all `hermes config` writes so they never interleave. */
|
|
2195
|
+
configSetQueue = Promise.resolve();
|
|
2196
|
+
constructor(options = {}) {
|
|
2197
|
+
this.home = options.home ?? options.workspace ?? DEFAULT_HERMES_HOME$1;
|
|
2198
|
+
this.trackingPath = options.configPath ?? join(this.home, ".alfe-integrations.json");
|
|
2199
|
+
this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES;
|
|
2200
|
+
this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS;
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Apply integration config to the Hermes runtime via `hermes config set`.
|
|
2204
|
+
*
|
|
2205
|
+
* Each leaf is applied as `hermes config set <dotted.key> <value>`. Each
|
|
2206
|
+
* integration's config contribution is tracked in the tracking file so it can
|
|
2207
|
+
* be cleanly removed later (mirrors OpenClawApplier's `_integrations`).
|
|
2208
|
+
*/
|
|
2209
|
+
async applyConfig(integrationId, config) {
|
|
2210
|
+
const tracking = this.readTracking();
|
|
2211
|
+
const integrations = tracking._integrations ?? {};
|
|
2212
|
+
integrations[integrationId] = config;
|
|
2213
|
+
tracking._integrations = integrations;
|
|
2214
|
+
this.writeTracking(tracking);
|
|
2215
|
+
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
|
|
2216
|
+
if (subtreesByParent.size > 0) log$2.warn({
|
|
2217
|
+
integrationId,
|
|
2218
|
+
parents: [...subtreesByParent.keys()]
|
|
2219
|
+
}, "Hermes applyConfig: skipping dotted-key subtree(s) — OpenClaw-plugin-shaped config does not apply to Hermes");
|
|
2220
|
+
for (const { path, value } of leaves) try {
|
|
2221
|
+
await this.runConfigSet([path, stringifyConfigValue(value)]);
|
|
2222
|
+
} catch (err) {
|
|
2223
|
+
log$2.error({
|
|
2224
|
+
err: err instanceof Error ? err.message : String(err),
|
|
2225
|
+
key: path
|
|
2226
|
+
}, "Failed to set config via hermes config set");
|
|
2227
|
+
throw err;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Remove config previously applied by an integration.
|
|
2232
|
+
*
|
|
2233
|
+
* Reads the tracking file to find which keys this integration set, then
|
|
2234
|
+
* removes each via `hermes config unset`. Clears the tracking entry.
|
|
2235
|
+
*/
|
|
2236
|
+
async removeConfig(integrationId) {
|
|
2237
|
+
const tracking = this.readTracking();
|
|
2238
|
+
const integrations = tracking._integrations ?? {};
|
|
2239
|
+
if (!(integrationId in integrations)) return;
|
|
2240
|
+
const integrationConfig = integrations[integrationId];
|
|
2241
|
+
const { leaves } = partitionEntries(flattenConfig(integrationConfig));
|
|
2242
|
+
for (const { path } of leaves) try {
|
|
2243
|
+
await this.unsetConfigKey(path);
|
|
2244
|
+
} catch (err) {
|
|
2245
|
+
log$2.warn({
|
|
2246
|
+
err: err instanceof Error ? err.message : String(err),
|
|
2247
|
+
key: path
|
|
2248
|
+
}, "Failed to unset config via hermes config unset");
|
|
2249
|
+
}
|
|
2250
|
+
tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
|
|
2251
|
+
this.writeTracking(tracking);
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Raw single-key config write — `hermes config set <key> <value>`.
|
|
2255
|
+
*
|
|
2256
|
+
* Deliberately bypasses the `_integrations` tracking that `applyConfig`
|
|
2257
|
+
* maintains: this is a fire-and-forget write for the `alfe.config_set`
|
|
2258
|
+
* cloud-command, NOT an integration contribution, so it must not be recorded
|
|
2259
|
+
* for later `removeConfig` teardown (doing so would corrupt the
|
|
2260
|
+
* per-integration removal accounting). Reuses the serialized queue.
|
|
2261
|
+
*/
|
|
2262
|
+
setConfigRaw(key, value) {
|
|
2263
|
+
return this.runConfigSet([key, value]);
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Apply a plugin. The `@alfe.ai/openclaw-*` npm plugins are OpenClaw-specific
|
|
2267
|
+
* (they carry the `openclaw` peer dep) and do NOT apply to Hermes — log + skip
|
|
2268
|
+
* them. The manager catches per-plugin, so a skip here is a correct no-op.
|
|
2269
|
+
*
|
|
2270
|
+
* A genuine Hermes-native plugin spec (git URL / pip / local dir) would
|
|
2271
|
+
* `hermes plugins install` then `hermes plugins enable`; no manifest declares
|
|
2272
|
+
* one in Phase 1, so this path is a real attempt (not faked) but untrodden.
|
|
2273
|
+
*/
|
|
2274
|
+
async applyPlugin(spec) {
|
|
2275
|
+
if (isOpenClawPlugin(spec)) {
|
|
2276
|
+
log$2.info({ spec }, "Hermes applyPlugin: skipping OpenClaw-specific npm plugin (not a Hermes plugin)");
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
log$2.info({ spec }, "Hermes applyPlugin: installing native Hermes plugin");
|
|
2280
|
+
await execFileAsync("hermes", [
|
|
2281
|
+
"plugins",
|
|
2282
|
+
"install",
|
|
2283
|
+
spec
|
|
2284
|
+
], { timeout: 6e4 });
|
|
2285
|
+
await execFileAsync("hermes", [
|
|
2286
|
+
"plugins",
|
|
2287
|
+
"enable",
|
|
2288
|
+
spec
|
|
2289
|
+
], { timeout: 3e4 });
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* Remove a plugin. OpenClaw-specific npm plugins were never installed into
|
|
2293
|
+
* Hermes (applyPlugin skipped them), so removal is a no-op log. A native
|
|
2294
|
+
* Hermes plugin would be disabled via `hermes plugins`.
|
|
2295
|
+
*/
|
|
2296
|
+
async removePlugin(spec) {
|
|
2297
|
+
if (isOpenClawPlugin(spec)) {
|
|
2298
|
+
log$2.info({ spec }, "Hermes removePlugin: skipping OpenClaw-specific npm plugin (was never installed on Hermes)");
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
log$2.info({ spec }, "Hermes removePlugin: disabling native Hermes plugin");
|
|
2302
|
+
await execFileAsync("hermes", [
|
|
2303
|
+
"plugins",
|
|
2304
|
+
"disable",
|
|
2305
|
+
spec
|
|
2306
|
+
], { timeout: 3e4 });
|
|
2307
|
+
}
|
|
2308
|
+
applySkill(name) {
|
|
2309
|
+
log$2.info({ name }, "Hermes applySkill: no-op (Hermes built-in skills; deferred)");
|
|
2310
|
+
return Promise.resolve();
|
|
2311
|
+
}
|
|
2312
|
+
removeSkill(name) {
|
|
2313
|
+
log$2.info({ name }, "Hermes removeSkill: no-op (Hermes built-in skills; deferred)");
|
|
2314
|
+
return Promise.resolve();
|
|
2315
|
+
}
|
|
2316
|
+
applyClawHubSkill(slug) {
|
|
2317
|
+
log$2.info({ slug }, "Hermes applyClawHubSkill: no-op (ClawHub is OpenClaw-only)");
|
|
2318
|
+
return Promise.resolve();
|
|
2319
|
+
}
|
|
2320
|
+
removeClawHubSkill(slug) {
|
|
2321
|
+
log$2.info({ slug }, "Hermes removeClawHubSkill: no-op (ClawHub is OpenClaw-only)");
|
|
2322
|
+
return Promise.resolve();
|
|
2323
|
+
}
|
|
2053
2324
|
isAvailable() {
|
|
2054
2325
|
return Promise.resolve(existsSync(this.home));
|
|
2055
2326
|
}
|
|
2327
|
+
/** Convenience: `hermes config set <args>`, serialized + retried. */
|
|
2328
|
+
runConfigSet(setArgs) {
|
|
2329
|
+
return this.runConfigCommand(["set", ...setArgs]);
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Unset a single config key. The unset verb is isolated HERE so there is one
|
|
2333
|
+
* place to change if the spike proves `hermes config unset` is unavailable.
|
|
2334
|
+
*
|
|
2335
|
+
* FALLBACK (do NOT pre-build): if `hermes config unset` does not exist, the
|
|
2336
|
+
* substitute is `hermes config set <key> ""` (clear the value) or a
|
|
2337
|
+
* read-merge-write of config.yaml. Not implemented now — `unset` is the
|
|
2338
|
+
* documented verb; confirm in the Phase-0 spike before adding a fallback.
|
|
2339
|
+
* TODO(phase-0 spike): confirm `hermes config unset <key>` exists.
|
|
2340
|
+
*/
|
|
2341
|
+
unsetConfigKey(key) {
|
|
2342
|
+
return this.runConfigCommand(["unset", key]);
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Run `hermes config <args>` (set/unset), serialized against every other
|
|
2346
|
+
* config write and retried with backoff. Throws an Error whose (scrubbed)
|
|
2347
|
+
* message includes stderr after retries are exhausted, so the real cause
|
|
2348
|
+
* propagates to the integration errorMessage without leaking the value.
|
|
2349
|
+
*/
|
|
2350
|
+
runConfigCommand(args) {
|
|
2351
|
+
const run = async () => {
|
|
2352
|
+
let lastErr;
|
|
2353
|
+
for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
|
|
2354
|
+
await execFileAsync("hermes", ["config", ...args], { timeout: 1e4 });
|
|
2355
|
+
return;
|
|
2356
|
+
} catch (err) {
|
|
2357
|
+
lastErr = err;
|
|
2358
|
+
if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay(this.configSetRetryDelayMs * 2 ** attempt);
|
|
2359
|
+
}
|
|
2360
|
+
throw new Error(configErrorMessage(lastErr, args));
|
|
2361
|
+
};
|
|
2362
|
+
const result = this.configSetQueue.then(run, run);
|
|
2363
|
+
this.configSetQueue = result.catch(() => void 0);
|
|
2364
|
+
return result;
|
|
2365
|
+
}
|
|
2056
2366
|
readTracking() {
|
|
2057
2367
|
if (!existsSync(this.trackingPath)) return {};
|
|
2058
2368
|
try {
|
|
@@ -2067,6 +2377,265 @@ var OpenClawApplier = class {
|
|
|
2067
2377
|
}
|
|
2068
2378
|
};
|
|
2069
2379
|
//#endregion
|
|
2380
|
+
//#region src/appliers/hermes-mcp-sync.ts
|
|
2381
|
+
/**
|
|
2382
|
+
* HermesMcpSync — Hermes-only CONSUMER of the runtime-agnostic MCP store
|
|
2383
|
+
* (Approach B: config.yaml mirror).
|
|
2384
|
+
*
|
|
2385
|
+
* Background: `McpApplier` writes resolved MCP servers (command/args/env, with
|
|
2386
|
+
* `{{config}}`/`{{credentials}}` already interpolated at apply time) into the
|
|
2387
|
+
* runtime-agnostic bundler store at `~/.alfe/mcp/servers.json`. OpenClaw consumes
|
|
2388
|
+
* that store over IPC (the daemon hosts the bundler children and the openclaw
|
|
2389
|
+
* plugin reaches them). Hermes cannot consume over IPC — it reads MCP servers
|
|
2390
|
+
* from its own `~/.hermes/config.yaml` under the top-level `mcp_servers:` key and
|
|
2391
|
+
* spawns the children itself. So Hermes needs its own consumer that mirrors the
|
|
2392
|
+
* store into `config.yaml`.
|
|
2393
|
+
*
|
|
2394
|
+
* This class is the parallel to OpenClaw's IPC consumption: it subscribes to
|
|
2395
|
+
* `manager.onChange()` and, **only when the active runtime is hermes** (the
|
|
2396
|
+
* daemon only constructs it for hermes agents), read-merge-writes the store into
|
|
2397
|
+
* `~/.hermes/config.yaml`, preserving user-authored `mcp_servers` and every other
|
|
2398
|
+
* config key.
|
|
2399
|
+
*
|
|
2400
|
+
* Two cross-cutting responsibilities make the mirrored servers actually work:
|
|
2401
|
+
*
|
|
2402
|
+
* 1. `ALFE_API_KEY` injection. The Alfe MCP servers (`@alfe.ai/<provider>-mcp`)
|
|
2403
|
+
* fetch their real provider credentials from the Alfe API at startup using
|
|
2404
|
+
* `ALFE_API_KEY`. For OpenClaw the daemon spawns the children, so they
|
|
2405
|
+
* inherit `ALFE_API_KEY` from the daemon's own `process.env`. Hermes spawns
|
|
2406
|
+
* the children itself in a separate process tree, so they would NOT inherit
|
|
2407
|
+
* it — without it they start in a silent zero-accounts degraded mode. We
|
|
2408
|
+
* therefore inject an `ALFE_API_KEY` reference into every mirrored stdio
|
|
2409
|
+
* server's env and write the actual secret into `~/.hermes/.env`
|
|
2410
|
+
* (read-merge-write, never clobbering other keys).
|
|
2411
|
+
*
|
|
2412
|
+
* 2. Restart. Writing `config.yaml` is assumed to require a runtime reload (vs a
|
|
2413
|
+
* hot-reload) — SPIKE-PENDING — so after a real change we trigger the EXISTING
|
|
2414
|
+
* runtime-restart path (the same callback the daemon's
|
|
2415
|
+
* `setRuntimeRestartNeededHandler` uses), never a second restart mechanism.
|
|
2416
|
+
*
|
|
2417
|
+
* The daemon gates its own bundler OFF for hermes (skips `loadIntoBundler` +
|
|
2418
|
+
* `warmup`) so the store is a pure ledger and the MCP children are spawned ONLY
|
|
2419
|
+
* by Hermes — avoiding a double-spawn of every server.
|
|
2420
|
+
*/
|
|
2421
|
+
const log$1 = createLogger("HermesMcpSync");
|
|
2422
|
+
const DEFAULT_HERMES_HOME = join(homedir(), ".hermes");
|
|
2423
|
+
/**
|
|
2424
|
+
* SPIKE-PENDING SEAM #1 — `${VAR}` interpolation from `~/.hermes/.env`.
|
|
2425
|
+
*
|
|
2426
|
+
* We inject `ALFE_API_KEY=${ALFE_API_KEY}` into every Alfe-owned stdio server's
|
|
2427
|
+
* env and put the real value in `~/.hermes/.env`. This assumes Hermes
|
|
2428
|
+
* interpolates `${VAR}` references in `mcp_servers.<id>.env` from `.env` at spawn
|
|
2429
|
+
* time. TODO(phase-0 spike): confirm. If Hermes does NOT interpolate, the
|
|
2430
|
+
* fallback (NOT built here) is to write the literal `ALFE_API_KEY` VALUE inline
|
|
2431
|
+
* into each `mcp_servers.<id>.env` and skip the `.env` file entirely — change
|
|
2432
|
+
* `withAlfeApiKey()` + `ensureEnvApiKey()` together in that one case.
|
|
2433
|
+
*/
|
|
2434
|
+
const ALFE_API_KEY_ENV_REF = "${ALFE_API_KEY}";
|
|
2435
|
+
const DEFAULT_DEBOUNCE_MS = 250;
|
|
2436
|
+
/**
|
|
2437
|
+
* Read-merge-write mirror of the MCP store into `~/.hermes/config.yaml`.
|
|
2438
|
+
*
|
|
2439
|
+
* Ownership / removal model: every entry returned by `manager.listServers()`
|
|
2440
|
+
* comes from the Alfe store (its `owner` is `integration:<id>` / `cli` /
|
|
2441
|
+
* `manual`) and is therefore Alfe-owned — these are the ids we write. User
|
|
2442
|
+
* authored `mcp_servers` entries live ONLY in config.yaml and never appear in
|
|
2443
|
+
* the store, so we never touch them. To know which config.yaml ids to REMOVE
|
|
2444
|
+
* when a server leaves the store, we persist the set of ids we last wrote to a
|
|
2445
|
+
* sidecar (`.alfe-mcp-synced.json`) and only ever delete from that set — exactly
|
|
2446
|
+
* how the bundler store tracks `_ownedOpenclawKeys` for its openclaw.json mirror.
|
|
2447
|
+
*/
|
|
2448
|
+
var HermesMcpSync = class {
|
|
2449
|
+
manager;
|
|
2450
|
+
home;
|
|
2451
|
+
apiKey;
|
|
2452
|
+
requestRestart;
|
|
2453
|
+
configPath;
|
|
2454
|
+
envPath;
|
|
2455
|
+
trackingPath;
|
|
2456
|
+
debounceMs;
|
|
2457
|
+
/** Ids of `mcp_servers` entries this sync last wrote — the removal set. */
|
|
2458
|
+
syncedIds = /* @__PURE__ */ new Set();
|
|
2459
|
+
/** Serializes syncs so two onChange-driven runs can't interleave file writes. */
|
|
2460
|
+
queue = Promise.resolve();
|
|
2461
|
+
unsubscribe;
|
|
2462
|
+
debounceTimer;
|
|
2463
|
+
started = false;
|
|
2464
|
+
constructor(opts) {
|
|
2465
|
+
this.manager = opts.manager;
|
|
2466
|
+
this.home = opts.home ?? DEFAULT_HERMES_HOME;
|
|
2467
|
+
this.apiKey = opts.apiKey;
|
|
2468
|
+
this.requestRestart = opts.requestRestart;
|
|
2469
|
+
this.configPath = opts.configPath ?? join(this.home, "config.yaml");
|
|
2470
|
+
this.envPath = opts.envPath ?? join(this.home, ".env");
|
|
2471
|
+
this.trackingPath = opts.trackingPath ?? join(this.home, ".alfe-mcp-synced.json");
|
|
2472
|
+
this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Begin mirroring: load the prior removal set, run one immediate sync (so
|
|
2476
|
+
* config.yaml reflects the current store before Hermes first starts), then
|
|
2477
|
+
* subscribe to store changes (debounced). Idempotent.
|
|
2478
|
+
*/
|
|
2479
|
+
start() {
|
|
2480
|
+
if (this.started) return;
|
|
2481
|
+
this.started = true;
|
|
2482
|
+
this.loadSyncedIds();
|
|
2483
|
+
this.syncOnce().catch((err) => {
|
|
2484
|
+
log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync: initial sync failed");
|
|
2485
|
+
});
|
|
2486
|
+
this.unsubscribe = this.manager.onChange(() => {
|
|
2487
|
+
this.schedule();
|
|
2488
|
+
});
|
|
2489
|
+
log$1.info({ configPath: this.configPath }, "Hermes MCP sync started — mirroring store into config.yaml");
|
|
2490
|
+
}
|
|
2491
|
+
/** Stop subscribing and cancel any pending debounced sync. Idempotent. */
|
|
2492
|
+
stop() {
|
|
2493
|
+
if (this.unsubscribe) {
|
|
2494
|
+
this.unsubscribe();
|
|
2495
|
+
this.unsubscribe = void 0;
|
|
2496
|
+
}
|
|
2497
|
+
if (this.debounceTimer) {
|
|
2498
|
+
clearTimeout(this.debounceTimer);
|
|
2499
|
+
this.debounceTimer = void 0;
|
|
2500
|
+
}
|
|
2501
|
+
this.started = false;
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Mirror the current store into config.yaml + .env exactly once. Public so the
|
|
2505
|
+
* daemon (and tests) can await a deterministic sync. Serialized against any
|
|
2506
|
+
* other in-flight sync.
|
|
2507
|
+
*/
|
|
2508
|
+
syncOnce() {
|
|
2509
|
+
const run = () => {
|
|
2510
|
+
this.syncNow();
|
|
2511
|
+
return Promise.resolve();
|
|
2512
|
+
};
|
|
2513
|
+
const result = this.queue.then(run, run);
|
|
2514
|
+
this.queue = result.catch(() => void 0);
|
|
2515
|
+
return result;
|
|
2516
|
+
}
|
|
2517
|
+
schedule() {
|
|
2518
|
+
if (this.debounceMs <= 0) {
|
|
2519
|
+
this.syncOnce().catch((err) => {
|
|
2520
|
+
log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync failed");
|
|
2521
|
+
});
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
2525
|
+
this.debounceTimer = setTimeout(() => {
|
|
2526
|
+
this.debounceTimer = void 0;
|
|
2527
|
+
this.syncOnce().catch((err) => {
|
|
2528
|
+
log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync failed");
|
|
2529
|
+
});
|
|
2530
|
+
}, this.debounceMs);
|
|
2531
|
+
this.debounceTimer.unref();
|
|
2532
|
+
}
|
|
2533
|
+
syncNow() {
|
|
2534
|
+
const desired = this.computeDesired();
|
|
2535
|
+
const desiredIds = new Set(desired.keys());
|
|
2536
|
+
const doc = parseDocument(existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "");
|
|
2537
|
+
const before = doc.toString();
|
|
2538
|
+
for (const [id, entry] of desired) doc.setIn(["mcp_servers", id], entry);
|
|
2539
|
+
for (const id of this.syncedIds) if (!desiredIds.has(id)) doc.deleteIn(["mcp_servers", id]);
|
|
2540
|
+
const after = doc.toString();
|
|
2541
|
+
let changed = before !== after;
|
|
2542
|
+
if (changed) {
|
|
2543
|
+
mkdirSync(dirname(this.configPath), { recursive: true });
|
|
2544
|
+
writeFileSync(this.configPath, after, "utf-8");
|
|
2545
|
+
log$1.info({
|
|
2546
|
+
added: [...desiredIds],
|
|
2547
|
+
removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
|
|
2548
|
+
}, "Hermes MCP sync: config.yaml mcp_servers updated");
|
|
2549
|
+
}
|
|
2550
|
+
this.syncedIds = desiredIds;
|
|
2551
|
+
this.persistSyncedIds();
|
|
2552
|
+
if (desiredIds.size > 0 && this.ensureEnvApiKey()) changed = true;
|
|
2553
|
+
if (changed) this.requestRestart?.();
|
|
2554
|
+
}
|
|
2555
|
+
computeDesired() {
|
|
2556
|
+
const desired = /* @__PURE__ */ new Map();
|
|
2557
|
+
for (const { id, entry } of this.manager.listServers()) desired.set(id, this.toHermesEntry(entry));
|
|
2558
|
+
return desired;
|
|
2559
|
+
}
|
|
2560
|
+
/**
|
|
2561
|
+
* Transform a stored entry into a Hermes `mcp_servers` entry. SPIKE-PENDING
|
|
2562
|
+
* SEAM #3 lives here — the schema shape. stdio entries get `ALFE_API_KEY`
|
|
2563
|
+
* injected; remote entries pass through (Hermes supports `url`-based MCP).
|
|
2564
|
+
*/
|
|
2565
|
+
toHermesEntry(entry) {
|
|
2566
|
+
const cfg = toServerConfig(entry);
|
|
2567
|
+
if ("command" in cfg) {
|
|
2568
|
+
const out = { command: cfg.command };
|
|
2569
|
+
if (cfg.args && cfg.args.length > 0) out.args = cfg.args;
|
|
2570
|
+
out.env = this.withAlfeApiKey(cfg.env);
|
|
2571
|
+
if (cfg.cwd) out.cwd = cfg.cwd;
|
|
2572
|
+
return out;
|
|
2573
|
+
}
|
|
2574
|
+
const out = { url: cfg.url };
|
|
2575
|
+
if (cfg.transport) out.transport = cfg.transport;
|
|
2576
|
+
if (cfg.headers) out.headers = cfg.headers;
|
|
2577
|
+
return out;
|
|
2578
|
+
}
|
|
2579
|
+
withAlfeApiKey(env) {
|
|
2580
|
+
const merged = { ...env ?? {} };
|
|
2581
|
+
if (!("ALFE_API_KEY" in merged)) merged.ALFE_API_KEY = ALFE_API_KEY_ENV_REF;
|
|
2582
|
+
return merged;
|
|
2583
|
+
}
|
|
2584
|
+
/** Read-merge-write `~/.hermes/.env` to set ALFE_API_KEY without clobbering other keys. */
|
|
2585
|
+
ensureEnvApiKey() {
|
|
2586
|
+
if (!this.apiKey) {
|
|
2587
|
+
log$1.warn("Hermes MCP sync: no ALFE_API_KEY available — mirrored MCP servers will start in zero-accounts degraded mode");
|
|
2588
|
+
return false;
|
|
2589
|
+
}
|
|
2590
|
+
return upsertEnvVar(this.envPath, "ALFE_API_KEY", this.apiKey);
|
|
2591
|
+
}
|
|
2592
|
+
loadSyncedIds() {
|
|
2593
|
+
if (!existsSync(this.trackingPath)) return;
|
|
2594
|
+
try {
|
|
2595
|
+
const data = JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
2596
|
+
if (Array.isArray(data.syncedIds)) this.syncedIds = new Set(data.syncedIds.filter((x) => typeof x === "string"));
|
|
2597
|
+
} catch {}
|
|
2598
|
+
}
|
|
2599
|
+
persistSyncedIds() {
|
|
2600
|
+
try {
|
|
2601
|
+
mkdirSync(dirname(this.trackingPath), { recursive: true });
|
|
2602
|
+
writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
|
|
2603
|
+
} catch (err) {
|
|
2604
|
+
log$1.warn({ err: errMsg$1(err) }, "Hermes MCP sync: failed to persist synced-id sidecar");
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
/**
|
|
2609
|
+
* Set `KEY=value` in a `.env`-style file, preserving every other line (comments,
|
|
2610
|
+
* blank lines, unrelated keys) and order. Returns whether the file changed. Not
|
|
2611
|
+
* a full dotenv parser — it only matches simple `KEY=` lines, which is all
|
|
2612
|
+
* `~/.hermes/.env` ever holds.
|
|
2613
|
+
*/
|
|
2614
|
+
function upsertEnvVar(envPath, key, value) {
|
|
2615
|
+
const desiredLine = `${key}=${value}`;
|
|
2616
|
+
const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
|
|
2617
|
+
const lines = existing.length > 0 ? existing.replace(/\n$/, "").split("\n") : [];
|
|
2618
|
+
const keyRe = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
2619
|
+
const idx = lines.findIndex((line) => keyRe.exec(line)?.[1] === key);
|
|
2620
|
+
let out;
|
|
2621
|
+
let changed;
|
|
2622
|
+
if (idx === -1) {
|
|
2623
|
+
out = [...lines, desiredLine];
|
|
2624
|
+
changed = true;
|
|
2625
|
+
} else {
|
|
2626
|
+
changed = lines[idx] !== desiredLine;
|
|
2627
|
+
out = lines.map((line, i) => i === idx ? desiredLine : line);
|
|
2628
|
+
}
|
|
2629
|
+
if (changed) {
|
|
2630
|
+
mkdirSync(dirname(envPath), { recursive: true });
|
|
2631
|
+
writeFileSync(envPath, out.join("\n") + "\n", "utf-8");
|
|
2632
|
+
}
|
|
2633
|
+
return changed;
|
|
2634
|
+
}
|
|
2635
|
+
function errMsg$1(err) {
|
|
2636
|
+
return err instanceof Error ? err.message : String(err);
|
|
2637
|
+
}
|
|
2638
|
+
//#endregion
|
|
2070
2639
|
//#region src/appliers/mcp-applier.ts
|
|
2071
2640
|
const log = createLogger("McpApplier");
|
|
2072
2641
|
const CONFIG_TEMPLATE_RE = /\{\{config\.([a-zA-Z0-9_]+)\}\}/g;
|
|
@@ -2246,4 +2815,4 @@ var IntegrationManagerAdapter = class {
|
|
|
2246
2815
|
}
|
|
2247
2816
|
};
|
|
2248
2817
|
//#endregion
|
|
2249
|
-
export { DEFAULT_REGISTRY_TTL_MS, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, OpenClawApplier, Registry, RegistryResolveError, Resolver, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext };
|
|
2818
|
+
export { DEFAULT_REGISTRY_TTL_MS, HermesApplier, HermesMcpSync, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, OpenClawApplier, Registry, RegistryResolveError, Resolver, 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.2.0",
|
|
4
4
|
"description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@auriclabs/logger": "^0.1.1",
|
|
16
|
-
"
|
|
16
|
+
"yaml": ">=2.8.3",
|
|
17
|
+
"@alfe.ai/integration-manifest": "^0.3.0",
|
|
17
18
|
"@alfe.ai/mcp-bundler": "^0.2.1"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|