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 +10 -1
- package/package.json +2 -1
- package/src/services.mjs +45 -5
- package/tests/test-yaml-patch.mjs +109 -0
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
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`);
|