hermes-connect-services 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,16 @@ node hermes-connect-services/bin/sync-hermes-config.mjs
19
19
  node hermes-connect-services/bin/sync-hermes-config.mjs --install-hooks
20
20
  ```
21
21
 
22
- 两步可以合成一次带 `--install-hooks` 的运行。配置文件位置自动探测:
22
+ 两步可以合成一次带 `--install-hooks` 的运行。
23
+
24
+ 也可以走 npm(四个端点包 + 本包装在同一项目里,脚本会自动在 `node_modules/` 找兄弟包):
25
+
26
+ ```sh
27
+ npm i zcode-trae-connect zcode-workbuddy-connect zcode-qoder-connect zcode-qwenwork-connect hermes-connect-services
28
+ npx hermes-connect-sync --install-hooks
29
+ ```
30
+
31
+ 配置文件位置自动探测:
23
32
 
24
33
  | 平台 | 路径 |
25
34
  |---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermes-connect-services",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Bring zcode-ai-connect's four loopback endpoints to hermes: lazy-start them on session start and sync them into hermes' config.yaml as OpenAI-compatible providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "bin",
25
25
  "src",
26
+ "tests",
26
27
  "README.md",
27
28
  "LICENSE"
28
29
  ],
package/src/services.mjs CHANGED
@@ -15,12 +15,21 @@ import { existsSync } from 'node:fs';
15
15
  import { readFile } from 'node:fs/promises';
16
16
  import { mkdir, appendFile } from 'node:fs/promises';
17
17
  import { spawn } from 'node:child_process';
18
- import { dirname, join, resolve } from 'node:path';
18
+ import { dirname, join, resolve, basename } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
 
21
- /** Suite root = parent of this directory; overridable for relocation/tests. */
21
+ /**
22
+ * Suite root = the directory the sibling tools live in.
23
+ *
24
+ * In a git checkout that is the repo root; in an npm install the module sits
25
+ * at `<proj>/node_modules/hermes-connect-services/src/`, so one level up from
26
+ * `node_modules` is what we want — otherwise every path would gain a second
27
+ * `node_modules`.
28
+ */
29
+ const PACKAGE_PARENT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
22
30
  export const SUITE_ROOT = resolve(
23
- process.env.AI_CONNECT_SUITE_ROOT ?? dirname(dirname(dirname(fileURLToPath(import.meta.url)))),
31
+ process.env.AI_CONNECT_SUITE_ROOT
32
+ ?? (basename(PACKAGE_PARENT) === 'node_modules' ? dirname(PACKAGE_PARENT) : PACKAGE_PARENT),
24
33
  );
25
34
 
26
35
  export const SERVICES = [
@@ -56,8 +65,39 @@ export const SERVICES = [
56
65
 
57
66
  export const HOME = process.env.USERPROFILE ?? process.env.HOME ?? '.';
58
67
 
59
- export const cliPath = (service) => join(SUITE_ROOT, service.tool, 'bin', 'cli.mjs');
60
- export const toolRoot = (service) => join(SUITE_ROOT, service.tool);
68
+ const toolDirCache = new Map();
69
+
70
+ /**
71
+ * Where a sibling tool actually lives.
72
+ *
73
+ * Three layouts have to work, because this module ships both as a git checkout
74
+ * and as an npm package:
75
+ *
76
+ * <repo>/hermes-connect-services/ + <repo>/zcode-trae-connect/ (checkout)
77
+ * <proj>/node_modules/zcode-trae-connect/ (npm install)
78
+ * <prefix>/node_modules/zcode-trae-connect/ (npm -g)
79
+ *
80
+ * `AI_CONNECT_SUITE_ROOT` wins when set, which is the escape hatch for any
81
+ * layout not covered above.
82
+ */
83
+ export function toolDir(service) {
84
+ const cached = toolDirCache.get(service.id);
85
+ if (cached) return cached;
86
+
87
+ const candidates = [
88
+ process.env.AI_CONNECT_SUITE_ROOT ? join(process.env.AI_CONNECT_SUITE_ROOT, service.tool) : null,
89
+ join(SUITE_ROOT, service.tool),
90
+ join(SUITE_ROOT, 'node_modules', service.tool),
91
+ join(dirname(SUITE_ROOT), service.tool),
92
+ ].filter(Boolean);
93
+
94
+ const resolved = candidates.find((dir) => existsSync(join(dir, 'bin', 'cli.mjs'))) ?? candidates[1];
95
+ toolDirCache.set(service.id, resolved);
96
+ return resolved;
97
+ }
98
+
99
+ export const cliPath = (service) => join(toolDir(service), 'bin', 'cli.mjs');
100
+ export const toolRoot = (service) => toolDir(service);
61
101
  export const stateDir = (service) => join(HOME, service.state);
62
102
 
63
103
  /** Read `{token, port}` from the tool's state dir, falling back to its default port. */
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regressions for the YAML patcher — the one piece of this module that can
4
+ * corrupt a user's config, so it gets tests instead of good intentions.
5
+ *
6
+ * Run: node hermes-connect-services/tests/test-yaml-patch.mjs
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { patchManagedBlock, readManagedBlock, readManagedChildren } from '../src/yaml-patch.mjs';
10
+
11
+ const MARKER = 'zcode-ai-connect';
12
+ const sample = [
13
+ 'model:',
14
+ ' provider: nous',
15
+ 'providers:',
16
+ ' xiaoxin:',
17
+ ' name: xiaoxin',
18
+ ' base_url: https://example.test',
19
+ 'fallback_providers: []',
20
+ '',
21
+ '# trailing comment that must survive',
22
+ 'display:',
23
+ ' language: zh',
24
+ '',
25
+ ].join('\n');
26
+
27
+ let passed = 0;
28
+ const check = (name, fn) => {
29
+ fn();
30
+ passed += 1;
31
+ console.log(` ok ${name}`);
32
+ };
33
+
34
+ console.log('test-yaml-patch');
35
+
36
+ check('inserts a managed region inside an existing mapping', () => {
37
+ const { text, action } = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "http://127.0.0.1:1/v1"', { marker: MARKER });
38
+ assert.equal(action, 'inserted');
39
+ assert.match(text, /providers:\n xiaoxin:/);
40
+ assert.match(text, /# >>> zcode-ai-connect >>>\n {2}zc-trae:/);
41
+ assert.match(text, /fallback_providers: \[\]/);
42
+ });
43
+
44
+ check('replaces its own region and leaves foreign keys alone', () => {
45
+ const first = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER }).text;
46
+ const second = patchManagedBlock(first, 'providers', 'zc-trae:\n base_url: "b"', { marker: MARKER }).text;
47
+ assert.match(second, /base_url: "b"/);
48
+ assert.match(second, / xiaoxin:/);
49
+ assert.equal(readManagedChildren(second, 'providers', { marker: MARKER }).length, 1);
50
+ });
51
+
52
+ check('is idempotent: re-patching identical content writes no change', () => {
53
+ const once = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER }).text;
54
+ const twice = patchManagedBlock(once, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER });
55
+ assert.equal(twice.changed, false);
56
+ assert.equal(twice.text, once);
57
+ });
58
+
59
+ check('removal restores the original bytes exactly', () => {
60
+ const once = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER }).text;
61
+ const gone = patchManagedBlock(once, 'providers', '', { marker: MARKER }).text;
62
+ assert.equal(gone, sample);
63
+ });
64
+
65
+ check('creates the top-level key when it is missing', () => {
66
+ const { text, action } = patchManagedBlock(sample, 'model_overrides', 'zc-trae:\n "id":\n supports_tools: true', { marker: MARKER });
67
+ assert.equal(action, 'inserted');
68
+ assert.match(text, /\nmodel_overrides:\n {2}# >>> zcode-ai-connect >>>/);
69
+ assert.match(text, /display:\n {2}language: zh/);
70
+ });
71
+
72
+ check('never touches content outside its own mapping', () => {
73
+ const { text } = patchManagedBlock(sample, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER });
74
+ assert.equal(readManagedBlock(text, 'model', { marker: MARKER }), '');
75
+ assert.match(text, /model:\n {2}provider: nous/);
76
+ });
77
+
78
+ check('readManagedChildren round-trips every provider id', () => {
79
+ const body = [
80
+ 'zc-trae:',
81
+ ' base_url: "http://127.0.0.1:39272/v1"',
82
+ ' models:',
83
+ ' "a": {}',
84
+ 'zc-workbuddy:',
85
+ ' base_url: "http://127.0.0.1:39271/v1"',
86
+ ].join('\n');
87
+ const once = patchManagedBlock(sample, 'providers', body, { marker: MARKER }).text;
88
+ const children = readManagedChildren(once, 'providers', { marker: MARKER });
89
+ assert.deepEqual(children.map((c) => c.key), ['zc-trae', 'zc-workbuddy']);
90
+ assert.match(children[0].body, /models:\n {4}"a": \{\}/);
91
+ // Feeding them back unchanged must be a no-op — this is what keeps `--only`
92
+ // from silently deleting the providers it was not asked about.
93
+ const twice = patchManagedBlock(once, 'providers', children.map((c) => c.body).join('\n'), { marker: MARKER });
94
+ assert.equal(twice.text, once);
95
+ });
96
+
97
+ check('survives an unterminated marker (hand-edit gone wrong)', () => {
98
+ // A hand-edit that dropped the closing marker leaves an orphan inside the
99
+ // providers mapping — the next sync must repair it, not stack a second one.
100
+ const broken = sample.replace(
101
+ 'fallback_providers: []',
102
+ ` # >>> ${MARKER} >>>\n zc-trae:\n base_url: "a"\nfallback_providers: []`,
103
+ );
104
+ const repaired = patchManagedBlock(broken, 'providers', 'zc-trae:\n base_url: "a"', { marker: MARKER });
105
+ assert.equal(repaired.text.match(new RegExp(`# >>> ${MARKER} >>>`, 'g')).length, 1);
106
+ assert.match(repaired.text, /# <<< zcode-ai-connect <<</);
107
+ });
108
+
109
+ console.log(`\n${passed} passed`);