@haven_ai/connect 0.1.20-alpha.0 → 0.1.22-alpha.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/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import crypto from 'crypto';
3
3
  import { Wallet } from 'ethers';
4
- import { mkdir, rm, chmod, access, writeFile, readFile } from 'fs/promises';
4
+ import { mkdir, rm, chmod, access, writeFile, readFile, unlink } from 'fs/promises';
5
5
  import { homedir, platform } from 'os';
6
6
  import { join, resolve, dirname } from 'path';
7
7
  import { execFile, spawn } from 'child_process';
8
8
  import { promisify } from 'util';
9
+ import { parseDocument, isMap, stringify } from 'yaml';
9
10
  import { registeredToolNames, MCP_VERSION, ensureConsent, computeConsentHash, loadCredentials, consentInputFromClient } from '@haven_ai/mcp';
10
11
  import { HAVEN_MINIMUM_NODE_VERSION, isSupportedNodeVersion, unsupportedNodeVersionMessage, SKILL_FOLDER_NAME, HAVEN_SKILL_MD } from '@haven_ai/sdk';
11
12
  import { ensureSignerConsent, computeSignerConsentHash, loadSignerCredentials, createEdgeSigner, toolSchemas } from '@haven_ai/signer';
@@ -230,9 +231,9 @@ var MCP_RUNTIME_MANIFEST = {
230
231
  mcpPackage: "@haven_ai/mcp",
231
232
  mcpVersion: MCP_VERSION,
232
233
  sdkPackage: "@haven_ai/sdk",
233
- sdkVersion: "0.1.20-alpha.0",
234
+ sdkVersion: "0.1.22-alpha.0",
234
235
  signerPackage: "@haven_ai/signer",
235
- signerVersion: "0.1.20-alpha.0",
236
+ signerVersion: "0.1.22-alpha.0",
236
237
  // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
237
238
  // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
238
239
  // so the guard that was supposed to enforce the floor waved Node v23 through
@@ -254,13 +255,20 @@ function signerPackageSpec() {
254
255
  }
255
256
 
256
257
  // src/config-writers.ts
258
+ var HERMES_API_KEY_ENV = "MCP_HAVEN_API_KEY";
259
+ var HermesConfigRecoveryError = class extends Error {
260
+ constructor() {
261
+ super("Hermes configuration recovery did not complete");
262
+ this.name = "HermesConfigRecoveryError";
263
+ }
264
+ };
257
265
  var InvalidCodexTomlError = class extends Error {
258
266
  constructor(message) {
259
267
  super(message);
260
268
  this.name = "InvalidCodexTomlError";
261
269
  }
262
270
  };
263
- async function writeRuntimeConfig(input) {
271
+ async function writeRuntimeConfig(input, deps = {}) {
264
272
  switch (input.runtime) {
265
273
  case "codex-cli":
266
274
  case "codex-desktop":
@@ -273,6 +281,8 @@ async function writeRuntimeConfig(input) {
273
281
  return writeJsonRuntimeConfig(input, vscodeInsidersConfigPath(input.homeDir), "servers");
274
282
  case "claude-desktop":
275
283
  return writeJsonRuntimeConfig(input, claudeDesktopConfigPath(input.homeDir), "mcpServers");
284
+ case "hermes":
285
+ return writeHermesConfig(input, deps);
276
286
  default:
277
287
  return {
278
288
  hostedConfigured: false,
@@ -326,6 +336,115 @@ function mergeJsonMcpConfig(existingJson, serverRoot, hostedServer, signerServer
326
336
  return `${JSON.stringify(config, null, 2)}
327
337
  `;
328
338
  }
339
+ function mergeHermesYaml(existingYaml, hostedServer, signerServer) {
340
+ if (!existingYaml?.trim()) return renderHermesYaml({ haven: hostedServer, "haven-signer": signerServer });
341
+ const doc = parseDocument(existingYaml, { keepSourceTokens: true });
342
+ if (doc.errors.length > 0 || !isMap(doc.contents)) {
343
+ throw new Error("Hermes config must be a YAML object");
344
+ }
345
+ const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
346
+ const existingServers = mcpPair && isMap(mcpPair.value) ? mcpPair.value.toJSON() : {};
347
+ const servers = isRecord(existingServers) ? existingServers : {};
348
+ const mergedServers = {
349
+ ...servers,
350
+ haven: hostedServer,
351
+ "haven-signer": signerServer
352
+ };
353
+ if (!mcpPair) {
354
+ return appendHermesMcpServers(existingYaml, mergedServers);
355
+ }
356
+ if (!mcpPair.value?.range) return replaceEmptyHermesMcpServers(existingYaml, mcpPair.key?.range, mergedServers);
357
+ return replaceHermesMcpServers(existingYaml, mcpPair.key?.range, mcpPair.value.range, mergedServers);
358
+ }
359
+ function mergeHermesEnv(existingEnv, apiKey) {
360
+ if (/[\r\n]/.test(apiKey)) throw new Error("Hermes API key must be a single line");
361
+ const assignment = `${HERMES_API_KEY_ENV}=${apiKey}`;
362
+ if (!existingEnv) return `${assignment}
363
+ `;
364
+ const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
365
+ const hasTrailingNewline = /\r?\n$/.test(existingEnv);
366
+ const lines = existingEnv.split(/\r?\n/);
367
+ if (hasTrailingNewline) lines.pop();
368
+ let found = false;
369
+ const merged = lines.flatMap((line) => {
370
+ if (isHermesEnvAssignment(line)) {
371
+ if (found) return [];
372
+ found = true;
373
+ return [assignment];
374
+ }
375
+ if (isAmbiguousHermesEnvLine(line)) {
376
+ throw new Error("Hermes environment contains an ambiguous managed key");
377
+ }
378
+ return [line];
379
+ });
380
+ if (!found) {
381
+ return `${existingEnv}${hasTrailingNewline ? "" : lineEnding}${assignment}${lineEnding}`;
382
+ }
383
+ return `${merged.join(lineEnding)}${hasTrailingNewline ? lineEnding : ""}`;
384
+ }
385
+ function isHermesEnvAssignment(line) {
386
+ return /^\s*(?:export[ \t]+)?MCP_HAVEN_API_KEY[ \t]*=/.test(line);
387
+ }
388
+ function isAmbiguousHermesEnvLine(line) {
389
+ return /^\s*(?:export[ \t]+)?MCP_HAVEN_API_KEY\b/.test(line);
390
+ }
391
+ function appendHermesMcpServers(source, servers) {
392
+ const documentEnd = /(?:^|\n)[ \t]*\.\.\.[ \t]*(?:#[^\n]*)?\r?\n?$/.exec(source);
393
+ if (documentEnd) {
394
+ const markerStart = documentEnd.index + (documentEnd[0].startsWith("\n") ? 1 : 0);
395
+ const beforeMarker = source.slice(0, markerStart);
396
+ return `${beforeMarker}${beforeMarker.endsWith("\n") ? "" : "\n"}${renderHermesMcpServers(servers, "")}
397
+ ${source.slice(markerStart)}`;
398
+ }
399
+ const separator = source.endsWith("\n") ? "" : "\n";
400
+ return `${source}${separator}${renderHermesMcpServers(servers, "")}
401
+ `;
402
+ }
403
+ function replaceHermesMcpServers(source, keyRange, valueRange, servers) {
404
+ const valueStart = valueRange[0];
405
+ const valueEnd = valueRange[1];
406
+ const valueLineStart = source.lastIndexOf("\n", valueStart - 1) + 1;
407
+ const keyStartsOnValueLine = keyRange?.[0] !== void 0 && keyRange[0] >= valueLineStart;
408
+ const nestedIndent = `${source.slice(valueLineStart, keyRange?.[0] ?? valueLineStart)} `;
409
+ if (keyStartsOnValueLine) {
410
+ const keyStart = keyRange[0];
411
+ const keyEnd = keyRange[1];
412
+ const replacement2 = `${source.slice(keyStart, keyEnd)}:
413
+ ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
414
+ const needsTrailingNewline2 = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
415
+ return `${source.slice(0, keyStart)}${replacement2}${needsTrailingNewline2 ? "\n" : ""}${source.slice(valueEnd)}`;
416
+ }
417
+ const indent = source.slice(valueLineStart, valueStart);
418
+ const rendered = stringify(servers, { lineWidth: 120 }).trimEnd();
419
+ const replacement = rendered.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
420
+ const needsTrailingNewline = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
421
+ return `${source.slice(0, valueStart)}${replacement}${needsTrailingNewline ? "\n" : ""}${source.slice(valueEnd)}`;
422
+ }
423
+ function replaceEmptyHermesMcpServers(source, keyRange, servers) {
424
+ if (!keyRange) throw new Error("Hermes config mcp_servers key is invalid");
425
+ const keyStart = keyRange[0];
426
+ const keyEnd = keyRange[1];
427
+ const lineStart = source.lastIndexOf("\n", keyStart - 1) + 1;
428
+ const lineEnd = source.indexOf("\n", keyEnd);
429
+ const nestedIndent = `${source.slice(lineStart, keyStart)} `;
430
+ const replacement = `${source.slice(keyStart, keyEnd)}:
431
+ ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
432
+ return `${source.slice(0, keyStart)}${replacement}${lineEnd === -1 ? "\n" : source.slice(lineEnd)}`;
433
+ }
434
+ function renderHermesYaml(servers) {
435
+ return `${renderHermesMcpServers(servers, "")}
436
+ `;
437
+ }
438
+ function renderHermesMcpServers(servers, indent) {
439
+ return `mcp_servers:
440
+ ${renderHermesMcpServerEntries(servers, `${indent} `)}`;
441
+ }
442
+ function renderHermesMcpServerEntries(servers, indent) {
443
+ return stringify(servers, { lineWidth: 120 }).trimEnd().split("\n").map((line) => `${indent}${line}`).join("\n");
444
+ }
445
+ function isRecord(value) {
446
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
447
+ }
329
448
  function mergeCodexToml(existingToml, localMcpCommand) {
330
449
  let next = removeTomlTableTree(removeTomlTableTree(existingToml, "mcp_servers.haven"), "mcp_servers.haven_signer");
331
450
  next = next.trimEnd();
@@ -396,6 +515,82 @@ async function writeJsonRuntimeConfig(input, target, serverRoot) {
396
515
  };
397
516
  }
398
517
  }
518
+ async function writeHermesConfig(input, deps) {
519
+ const target = hermesConfigPath(input.homeDir);
520
+ const envTarget = hermesEnvPath(input.homeDir);
521
+ try {
522
+ const [existing, existingEnv] = await Promise.all([readOptional(target), readOptional(envTarget)]);
523
+ const hostedServer = {
524
+ ...buildHostedServer(input.hostedMcpUrl, `\${${HERMES_API_KEY_ENV}}`, input.runtime),
525
+ enabled: true
526
+ };
527
+ const signerServer = {
528
+ ...buildSignerServer(resolveSignerLaunchSpec(input), input.runtime),
529
+ enabled: true
530
+ };
531
+ const merged = mergeHermesYaml(
532
+ existing,
533
+ hostedServer,
534
+ signerServer
535
+ );
536
+ const mergedEnv = mergeHermesEnv(existingEnv, input.apiKey);
537
+ const writeText = deps.writeOwnerOnlyText ?? writeOwnerOnlyText;
538
+ await writeText(envTarget, mergedEnv);
539
+ try {
540
+ await writeText(target, merged);
541
+ } catch (err) {
542
+ const recovered = await restoreHermesFiles(target, existing, envTarget, existingEnv, writeText);
543
+ if (!recovered) throw new HermesConfigRecoveryError();
544
+ throw err;
545
+ }
546
+ return {
547
+ hostedConfigured: true,
548
+ signerConfigured: true,
549
+ localMcpConfigured: false,
550
+ runtimeMcpMode: "hosted_plus_signer",
551
+ target: "Hermes Agent config",
552
+ changed: existing !== merged || existingEnv !== mergedEnv,
553
+ restartRequired: true,
554
+ messages: [
555
+ `Updated Haven MCP entries in ${target}; stored the hosted MCP identity in ${envTarget}.`,
556
+ "Restart Hermes (start a new session; gateway users: /restart), then verify with `hermes mcp list`, `hermes mcp test haven`, and `hermes mcp test haven-signer`.",
557
+ "If no mcp_* tools appear after restart, ensure the MCP SDK is installed in Hermes: pip install mcp"
558
+ ]
559
+ };
560
+ } catch (err) {
561
+ const recoveryIncomplete = err instanceof HermesConfigRecoveryError;
562
+ return {
563
+ hostedConfigured: false,
564
+ signerConfigured: false,
565
+ localMcpConfigured: false,
566
+ runtimeMcpMode: "hosted_plus_signer",
567
+ target: "Hermes Agent config",
568
+ changed: false,
569
+ restartRequired: true,
570
+ messages: [recoveryIncomplete ? "Could not update Hermes Agent config. Recovery did not complete; inspect the Hermes configuration before retrying." : "Could not update Hermes Agent config. Existing configuration was left unchanged."],
571
+ errorCode: "runtime_config_write_failed"
572
+ };
573
+ }
574
+ }
575
+ async function restoreHermesFiles(configPath, existingConfig, envPath, existingEnv, writeText) {
576
+ const [envResult, configResult] = await Promise.allSettled([
577
+ restoreOptionalText(envPath, existingEnv, writeText),
578
+ restoreOptionalText(configPath, existingConfig, writeText)
579
+ ]);
580
+ return envResult.status === "fulfilled" && configResult.status === "fulfilled";
581
+ }
582
+ async function restoreOptionalText(path, existing, writeText) {
583
+ if (existing !== null) {
584
+ await writeText(path, existing);
585
+ return;
586
+ }
587
+ try {
588
+ await unlink(path);
589
+ } catch (err) {
590
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
591
+ throw err;
592
+ }
593
+ }
399
594
  async function writeCodexConfig(input) {
400
595
  const target = codexConfigPath(input.homeDir);
401
596
  const local = input.mode === "local";
@@ -686,6 +881,15 @@ function claudeDesktopConfigPath(homeDir = homedir()) {
686
881
  }
687
882
  return resolve(homeDir, ".config", "Claude", "claude_desktop_config.json");
688
883
  }
884
+ function hermesConfigPath(homeDir) {
885
+ return join(hermesHomePath(homeDir), "config.yaml");
886
+ }
887
+ function hermesEnvPath(homeDir) {
888
+ return join(hermesHomePath(homeDir), ".env");
889
+ }
890
+ function hermesHomePath(homeDir) {
891
+ return process.env.HERMES_HOME ?? join(homeDir ?? homedir(), ".hermes");
892
+ }
689
893
  function configTargetLabel(runtime) {
690
894
  switch (runtime) {
691
895
  case "codex-cli":
@@ -1229,6 +1433,12 @@ var RUNTIME_PROFILES = {
1229
1433
  restartMode: "restart-app",
1230
1434
  canWriteRuntimeConfig: true
1231
1435
  },
1436
+ hermes: {
1437
+ id: "hermes",
1438
+ label: "Hermes Agent",
1439
+ restartMode: "restart-session",
1440
+ canWriteRuntimeConfig: true
1441
+ },
1232
1442
  other: {
1233
1443
  id: "other",
1234
1444
  label: "Other agent runtime",
@@ -1266,6 +1476,10 @@ var RUNTIME_ALIASES = {
1266
1476
  "claude_desktop": "claude-desktop",
1267
1477
  claudesktop: "claude-desktop",
1268
1478
  desktop: "claude-desktop",
1479
+ hermes: "hermes",
1480
+ "hermes-agent": "hermes",
1481
+ hermes_agent: "hermes",
1482
+ hermesagent: "hermes",
1269
1483
  other: "other",
1270
1484
  manual: "other"
1271
1485
  };
@@ -1293,6 +1507,7 @@ function detectRuntime(env) {
1293
1507
  if (env.CLAUDECODE || env.CLAUDE_CODE || env.CLAUDECODE_CWD) return "claude-code";
1294
1508
  if (env.CODEX_SANDBOX || env.CODEX_HOME || env.CODEX_CWD) return "codex-cli";
1295
1509
  if (env.VSCODE_CWD || env.VSCODE_IPC_HOOK_CLI || env.TERM_PROGRAM === "vscode") return "vscode";
1510
+ if (env.HERMES_HOME || env.HERMES_AGENT) return "hermes";
1296
1511
  return null;
1297
1512
  }
1298
1513
  async function acknowledgeLocalSignerConsent(signerPath, log) {
@@ -1702,7 +1917,7 @@ function localRuntimePrepareErrorCode(err) {
1702
1917
  }
1703
1918
 
1704
1919
  // src/runtime.ts
1705
- var CONNECTOR_VERSION = "0.1.20-alpha.0";
1920
+ var CONNECTOR_VERSION = "0.1.22-alpha.0";
1706
1921
  async function runConnect(options, deps = {}) {
1707
1922
  assertSupportedNodeVersion(deps.nodeVersion, MCP_RUNTIME_MANIFEST.minimumNodeVersion);
1708
1923
  const connectorVersion = options.connectorVersion ?? CONNECTOR_VERSION;
@@ -1935,7 +2150,7 @@ function helpText() {
1935
2150
  "Options:",
1936
2151
  " --setup <token> Short-lived setup token from Haven.",
1937
2152
  " --api <url> Haven backend API URL. Defaults to HAVEN_API_URL or http://localhost:3001.",
1938
- " --runtime <name> Agent runtime hint, such as claude-code, codex-cli, codex-desktop, cursor, vscode, or claude-desktop.",
2153
+ " --runtime <name> Agent runtime hint, such as claude-code, codex-cli, codex-desktop, cursor, vscode, claude-desktop, or hermes.",
1939
2154
  " --credentials-dir <path> Credential directory fallback. Defaults to ~/.haven/agents.",
1940
2155
  " --environment-label <text> Non-sensitive label shown in Haven setup review.",
1941
2156
  " --ack-local-tools Write the one-time local Haven tools acknowledgement during setup.",