@dzhechkov/cloudru-hub 0.1.4
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/.dz-manifest.json +105 -0
- package/CHANGELOG.md +130 -0
- package/LICENSE +67 -0
- package/README.md +131 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/cloudru-hub.js +8 -0
- package/data/dialects.json +73 -0
- package/data/pointer-section.md +22 -0
- package/data/tools-classification.json +732 -0
- package/data/tools-list-snapshot.json +585 -0
- package/docs/LOCAL-TESTING.md +77 -0
- package/package.json +67 -0
- package/sbom.json +253 -0
- package/src/classification.js +66 -0
- package/src/cli.js +174 -0
- package/src/dialects.js +172 -0
- package/src/install.js +252 -0
- package/src/resolve.js +101 -0
- package/templates/.claude/hooks/cloudru-ssh-guard.cjs +80 -0
- package/test/classification.test.mjs +151 -0
- package/test/dialects.test.mjs +314 -0
- package/test/hook.test.mjs +67 -0
- package/test/install.test.mjs +145 -0
- package/test/mutation-registry.json +135 -0
- package/test/pack-holds.test.mjs +96 -0
- package/test/resolve.test.mjs +81 -0
package/sbom.json
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bomFormat": "CycloneDX",
|
|
3
|
+
"specVersion": "1.5",
|
|
4
|
+
"version": 1,
|
|
5
|
+
"metadata": {
|
|
6
|
+
"component": {
|
|
7
|
+
"type": "library",
|
|
8
|
+
"name": "cloudru-hub"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"components": [
|
|
12
|
+
{
|
|
13
|
+
"type": "file",
|
|
14
|
+
"name": "CHANGELOG.md",
|
|
15
|
+
"hashes": [
|
|
16
|
+
{
|
|
17
|
+
"alg": "SHA-256",
|
|
18
|
+
"content": "106646f93a18c0335ff739c61df9432b9a79b743b53941f15e9bcc64b35504a4"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"type": "file",
|
|
24
|
+
"name": "LICENSE",
|
|
25
|
+
"hashes": [
|
|
26
|
+
{
|
|
27
|
+
"alg": "SHA-256",
|
|
28
|
+
"content": "68da42b10dcc095cdfd7249557cee30285664e5dea00dc85475ff047d975616a"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"type": "file",
|
|
34
|
+
"name": "README.md",
|
|
35
|
+
"hashes": [
|
|
36
|
+
{
|
|
37
|
+
"alg": "SHA-256",
|
|
38
|
+
"content": "38e29682c26399d930823f68ff35febfc2e0045e3912e1665ed071c9b73c084c"
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"type": "file",
|
|
44
|
+
"name": "THIRD_PARTY_NOTICES.md",
|
|
45
|
+
"hashes": [
|
|
46
|
+
{
|
|
47
|
+
"alg": "SHA-256",
|
|
48
|
+
"content": "109cffdaaf9402e88f6cc35a0497ba49db015b329fce87ba20762f34dc95c051"
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"type": "file",
|
|
54
|
+
"name": "bin/cloudru-hub.js",
|
|
55
|
+
"hashes": [
|
|
56
|
+
{
|
|
57
|
+
"alg": "SHA-256",
|
|
58
|
+
"content": "fcdb6806036feff444611483f4216477c83bc53d28ebca4736370c671f7f72db"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"type": "file",
|
|
64
|
+
"name": "data/dialects.json",
|
|
65
|
+
"hashes": [
|
|
66
|
+
{
|
|
67
|
+
"alg": "SHA-256",
|
|
68
|
+
"content": "8007dd5da843074d497617bdf3709cc3dc516a138171781b5ef0aa3ff596fa4d"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"type": "file",
|
|
74
|
+
"name": "data/pointer-section.md",
|
|
75
|
+
"hashes": [
|
|
76
|
+
{
|
|
77
|
+
"alg": "SHA-256",
|
|
78
|
+
"content": "6f4557d97b90936ef153dc90eac4bdceb6846b6d08d8aec45c86baac328cca60"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "file",
|
|
84
|
+
"name": "data/tools-classification.json",
|
|
85
|
+
"hashes": [
|
|
86
|
+
{
|
|
87
|
+
"alg": "SHA-256",
|
|
88
|
+
"content": "13d6a3e87d767d0015f6b2af0223633fa99e49c69c9a8adcec8f6fce6e46d36e"
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"type": "file",
|
|
94
|
+
"name": "data/tools-list-snapshot.json",
|
|
95
|
+
"hashes": [
|
|
96
|
+
{
|
|
97
|
+
"alg": "SHA-256",
|
|
98
|
+
"content": "56221fa8170f2b32ab6d3a907261badc07695f47ae9ac4adca4e43d2ea5ee06f"
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"type": "file",
|
|
104
|
+
"name": "docs/LOCAL-TESTING.md",
|
|
105
|
+
"hashes": [
|
|
106
|
+
{
|
|
107
|
+
"alg": "SHA-256",
|
|
108
|
+
"content": "092142c00295c54866330f2159a13284d04e2f35a749b32dff0ca4f2ab28ea54"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"type": "file",
|
|
114
|
+
"name": "package.json",
|
|
115
|
+
"hashes": [
|
|
116
|
+
{
|
|
117
|
+
"alg": "SHA-256",
|
|
118
|
+
"content": "24333e792a9cbdbc5d23f10d47969f3d3e984a2acc000c903b4003ffcf42d02f"
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"type": "file",
|
|
124
|
+
"name": "src/classification.js",
|
|
125
|
+
"hashes": [
|
|
126
|
+
{
|
|
127
|
+
"alg": "SHA-256",
|
|
128
|
+
"content": "8c1096273d2a2c9c6336c476096cb1e064184c6876d5466470fb69705ba3f265"
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"type": "file",
|
|
134
|
+
"name": "src/cli.js",
|
|
135
|
+
"hashes": [
|
|
136
|
+
{
|
|
137
|
+
"alg": "SHA-256",
|
|
138
|
+
"content": "db981ad0078c80ba7a859057638a2576d7f02d87ca2891f8083a92a8147742dd"
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"type": "file",
|
|
144
|
+
"name": "src/dialects.js",
|
|
145
|
+
"hashes": [
|
|
146
|
+
{
|
|
147
|
+
"alg": "SHA-256",
|
|
148
|
+
"content": "a826cea19ff38e24ad7fd36c6ec85a51e2cd1d985d1fa92941f77bcf353db1de"
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"type": "file",
|
|
154
|
+
"name": "src/install.js",
|
|
155
|
+
"hashes": [
|
|
156
|
+
{
|
|
157
|
+
"alg": "SHA-256",
|
|
158
|
+
"content": "5d087b289aaf8cd2e1b4cffbd2227eb3bdc7264cddc0877561e08bc0ea6d6fd2"
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"type": "file",
|
|
164
|
+
"name": "src/resolve.js",
|
|
165
|
+
"hashes": [
|
|
166
|
+
{
|
|
167
|
+
"alg": "SHA-256",
|
|
168
|
+
"content": "5cbd0861c2973fab62271269b10f748b9638e74f40e8ea034f78ac63bed58001"
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"type": "file",
|
|
174
|
+
"name": "templates/.claude/hooks/cloudru-ssh-guard.cjs",
|
|
175
|
+
"hashes": [
|
|
176
|
+
{
|
|
177
|
+
"alg": "SHA-256",
|
|
178
|
+
"content": "ad8c4ea89b28a366fa72e3507dbef9f774f4f2777e6bd2289a429329ab94cc0c"
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"type": "file",
|
|
184
|
+
"name": "test/classification.test.mjs",
|
|
185
|
+
"hashes": [
|
|
186
|
+
{
|
|
187
|
+
"alg": "SHA-256",
|
|
188
|
+
"content": "25290e9a8715d13670d617e23cb47c4c2d71b1506a8d98e5b682733b626a18b8"
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"type": "file",
|
|
194
|
+
"name": "test/dialects.test.mjs",
|
|
195
|
+
"hashes": [
|
|
196
|
+
{
|
|
197
|
+
"alg": "SHA-256",
|
|
198
|
+
"content": "9b83b2e2cbbee4ead33af61db840833ce4aa2dd0cf5a4448d72d5886098639c6"
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"type": "file",
|
|
204
|
+
"name": "test/hook.test.mjs",
|
|
205
|
+
"hashes": [
|
|
206
|
+
{
|
|
207
|
+
"alg": "SHA-256",
|
|
208
|
+
"content": "d53b58e853b6a37342a3a0fade782bced6283ea2ec376e2ac954b23f9ec68c17"
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"type": "file",
|
|
214
|
+
"name": "test/install.test.mjs",
|
|
215
|
+
"hashes": [
|
|
216
|
+
{
|
|
217
|
+
"alg": "SHA-256",
|
|
218
|
+
"content": "e986b29346672bf75b384ab6d8657265b8ecb8f62df6563dca317c1fc63fc8fc"
|
|
219
|
+
}
|
|
220
|
+
]
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"type": "file",
|
|
224
|
+
"name": "test/mutation-registry.json",
|
|
225
|
+
"hashes": [
|
|
226
|
+
{
|
|
227
|
+
"alg": "SHA-256",
|
|
228
|
+
"content": "23baf0c1c20afc5bbbfcd72ae9c553ce8a774c25328609e33f43e11cf4ca5581"
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"type": "file",
|
|
234
|
+
"name": "test/pack-holds.test.mjs",
|
|
235
|
+
"hashes": [
|
|
236
|
+
{
|
|
237
|
+
"alg": "SHA-256",
|
|
238
|
+
"content": "a083ff619f1d97b3e0e1b95dec6e98b56c4296da5171e2eb41c713dd983c8d69"
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"type": "file",
|
|
244
|
+
"name": "test/resolve.test.mjs",
|
|
245
|
+
"hashes": [
|
|
246
|
+
{
|
|
247
|
+
"alg": "SHA-256",
|
|
248
|
+
"content": "6b524f69c5ed03aaafab67e734e58092033423b86feade28ad03f516feba2380"
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Golden tool classification (ADR-004 Confirmation, layer 1) — loads
|
|
3
|
+
// data/tools-classification.json and derives the permission rule sets the installer
|
|
4
|
+
// emits. Deny-by-default: a live tool absent from the classification is a DRIFT
|
|
5
|
+
// FINDING, never silently allowed.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const CLASSIFICATION_FILE = path.join(__dirname, '..', 'data', 'tools-classification.json');
|
|
11
|
+
const SNAPSHOT_FILE = path.join(__dirname, '..', 'data', 'tools-list-snapshot.json');
|
|
12
|
+
|
|
13
|
+
// The MCP tool-name prefix normalization (`mcp__cloudru-vm__*` vs `mcp__cloudru_vm__*`)
|
|
14
|
+
// is ❌ NOT VERIFIED until a live Claude Code probe (feature README, open blocker 3).
|
|
15
|
+
// Until then we emit BOTH spellings — an unmatched permission rule is inert, and the
|
|
16
|
+
// deny-by-default direction stays safe regardless of which spelling the runtime uses.
|
|
17
|
+
const SERVER_PREFIXES = ['mcp__cloudru-vm__', 'mcp__cloudru_vm__'];
|
|
18
|
+
|
|
19
|
+
function loadClassification() {
|
|
20
|
+
return JSON.parse(fs.readFileSync(CLASSIFICATION_FILE, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadSnapshot() {
|
|
24
|
+
return JSON.parse(fs.readFileSync(SNAPSHOT_FILE, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Names by permission action, from the golden file. */
|
|
28
|
+
function namesByPermission(cls = loadClassification()) {
|
|
29
|
+
const out = { allow: [], ask: [], deny: [] };
|
|
30
|
+
for (const [name, c] of Object.entries(cls.tools)) out[c.permission].push(name);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Full permission rule strings (both prefix spellings) the installer writes. */
|
|
35
|
+
function permissionRules(cls = loadClassification()) {
|
|
36
|
+
const by = namesByPermission(cls);
|
|
37
|
+
const expand = (names) => names.flatMap((n) => SERVER_PREFIXES.map((p) => p + n)).sort();
|
|
38
|
+
return { allow: expand(by.allow), ask: expand(by.ask), deny: expand(by.deny) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Drift check against a LIVE tools/list (ADR-004 Confirmation: "падает, если появился
|
|
43
|
+
* тул, неизвестный классификатору ИЛИ мутирующий тул отсутствует в шаблоне").
|
|
44
|
+
* @param {string[]} liveNames
|
|
45
|
+
* @returns {{unknown:string[], missing:string[]}} unknown = live tool not classified;
|
|
46
|
+
* missing = classified tool absent from the live list (engine drift the other way).
|
|
47
|
+
*/
|
|
48
|
+
function coverageGaps(liveNames, cls = loadClassification()) {
|
|
49
|
+
const classified = new Set(Object.keys(cls.tools));
|
|
50
|
+
const live = new Set(liveNames);
|
|
51
|
+
return {
|
|
52
|
+
unknown: liveNames.filter((n) => !classified.has(n)).sort(),
|
|
53
|
+
missing: [...classified].filter((n) => !live.has(n)).sort(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
CLASSIFICATION_FILE,
|
|
59
|
+
SNAPSHOT_FILE,
|
|
60
|
+
SERVER_PREFIXES,
|
|
61
|
+
loadClassification,
|
|
62
|
+
loadSnapshot,
|
|
63
|
+
namesByPermission,
|
|
64
|
+
permissionRules,
|
|
65
|
+
coverageGaps,
|
|
66
|
+
};
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// cloudru-hub CLI — thin npm launcher for the cloudru-vm MCP engine (ADR-002).
|
|
3
|
+
// Zero dependencies, CommonJS, boots from any cwd (dz release smoke contract).
|
|
4
|
+
|
|
5
|
+
const { spawn, spawnSync } = require('child_process');
|
|
6
|
+
const { resolveEngine } = require('./resolve');
|
|
7
|
+
const { loadClassification, loadSnapshot, coverageGaps } = require('./classification');
|
|
8
|
+
const { install } = require('./install');
|
|
9
|
+
const { compileSkill, writeCompiled } = require('./dialects');
|
|
10
|
+
const PKG = require('../package.json');
|
|
11
|
+
|
|
12
|
+
function showHelp(write) {
|
|
13
|
+
write(`cloudru-hub ${PKG.version} — unofficial Cloud.ru Evolution launcher (NOT affiliated with Cloud.ru)
|
|
14
|
+
|
|
15
|
+
The cloudru-vm engine binary is resolved at RUNTIME, never bundled in this package.
|
|
16
|
+
Engine additions authored by Тимур (publication approved — grant record in LICENSE Part 2);
|
|
17
|
+
launcher, adapters and CLI foundation by Dmitry Zhechkov (MIT).
|
|
18
|
+
|
|
19
|
+
Usage: cloudru-hub <command> [options]
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
resolve [--json] Locate the engine binary (env CLOUDRU_VM_BIN → ~/.cloudru-hub/config.json
|
|
23
|
+
enginePath → optional platform package) and verify its sha256 pin.
|
|
24
|
+
self-test [--json] End-to-end local check: resolve → sha256 → engine version → live
|
|
25
|
+
tools/list probe → golden-classification coverage. Exit 0 = all green.
|
|
26
|
+
mcp Run the resolved engine as an MCP stdio server (what .mcp.json points at).
|
|
27
|
+
install --target <t> Emit the per-target integration per ADR-005 tiering:
|
|
28
|
+
claude-code (full: .mcp.json + permission brake + PreToolUse veto, with an
|
|
29
|
+
EXECUTED veto probe), codex (plan-only), agents-md|gemini (pointer-only),
|
|
30
|
+
openclaude|opencode|cursor|windsurf|copilot (refused: DEGRADED until live probe).
|
|
31
|
+
[--dir <path>] Project directory (default: cwd). [--npx] use the npx form in .mcp.json.
|
|
32
|
+
compile-skill --canonical <dir> --target <t> --out <dir>
|
|
33
|
+
Generate a target dialect of the canonical skill tree (ADR-006). The
|
|
34
|
+
canonical corpus is NOT shipped here; point --canonical at the skill/
|
|
35
|
+
directory of an engine distribution.
|
|
36
|
+
version Print launcher version and pinned engine hashes.
|
|
37
|
+
help This text.
|
|
38
|
+
|
|
39
|
+
Local testing (this server, no publish): docs/LOCAL-TESTING.md`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const flags = new Set();
|
|
44
|
+
const opts = new Map();
|
|
45
|
+
const positional = [];
|
|
46
|
+
for (let i = 0; i < argv.length; i++) {
|
|
47
|
+
const a = argv[i];
|
|
48
|
+
if (a === '--json' || a === '--npx' || a === '--help' || a === '-h') flags.add(a.replace(/^-+/, ''));
|
|
49
|
+
else if (a.startsWith('--')) { opts.set(a.slice(2), argv[i + 1]); i++; }
|
|
50
|
+
else positional.push(a);
|
|
51
|
+
}
|
|
52
|
+
return { flags, opts, positional };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function jsonRpcProbe(binPath, timeoutMs = 20_000) {
|
|
56
|
+
const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) + '\n';
|
|
57
|
+
const r = spawnSync(binPath, ['mcp'], { input: req, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
|
|
58
|
+
if (r.error) return { error: String(r.error.message || r.error) };
|
|
59
|
+
const line = String(r.stdout || '').split('\n').find((l) => l.trim().startsWith('{'));
|
|
60
|
+
if (!line) return { error: 'engine produced no JSON-RPC response line' };
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(line);
|
|
63
|
+
const tools = parsed && parsed.result && Array.isArray(parsed.result.tools) ? parsed.result.tools : null;
|
|
64
|
+
if (!tools) return { error: 'tools/list response carries no result.tools' };
|
|
65
|
+
return { tools };
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return { error: 'unparseable tools/list response: ' + e.message };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cmdResolve(flags, write) {
|
|
72
|
+
const r = resolveEngine();
|
|
73
|
+
if (r.error) { write(r.error); return 1; }
|
|
74
|
+
if (flags.has('json')) { write(JSON.stringify(r, null, 2)); return 0; }
|
|
75
|
+
write(`engine ${r.path}`);
|
|
76
|
+
write(`source ${r.source}`);
|
|
77
|
+
write(`sha256 ${r.sha256}`);
|
|
78
|
+
write(`pinned ${r.pinned || '<no pin for ' + r.key + '>'}`);
|
|
79
|
+
write(`verified ${r.verified ? 'yes — matches the pinned baseline' : 'NO — not the pinned baseline (tolerated for env/config paths)'}`);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cmdSelfTest(flags, write) {
|
|
84
|
+
const out = { steps: [], ok: false };
|
|
85
|
+
const step = (name, ok, detail) => { out.steps.push({ name, ok, detail }); if (!flags.has('json')) write(`${ok ? '✓' : '✗'} ${name}: ${detail}`); return ok; };
|
|
86
|
+
|
|
87
|
+
const r = resolveEngine();
|
|
88
|
+
if (r.error) { step('resolve', false, r.error.split('\n')[0]); if (flags.has('json')) write(JSON.stringify(out, null, 2)); return 1; }
|
|
89
|
+
step('resolve', true, `${r.path} (source: ${r.source})`);
|
|
90
|
+
step('sha256', true, `${r.sha256}${r.verified ? ' — MATCHES pinned baseline' : ' — no pin match (dev build?)'}`);
|
|
91
|
+
|
|
92
|
+
const ver = spawnSync(r.path, ['version'], { encoding: 'utf8', timeout: 10_000 });
|
|
93
|
+
const verOk = ver.status === 0;
|
|
94
|
+
step('engine-version', verOk, verOk ? String(ver.stdout || '').trim() : `exit ${ver.status}`);
|
|
95
|
+
|
|
96
|
+
const probe = jsonRpcProbe(r.path);
|
|
97
|
+
if (probe.error) { step('tools-list', false, probe.error); if (flags.has('json')) write(JSON.stringify(out, null, 2)); return 1; }
|
|
98
|
+
const names = probe.tools.map((t) => t.name);
|
|
99
|
+
const snap = loadSnapshot();
|
|
100
|
+
step('tools-list', true, `${names.length} tools live (snapshot: ${snap.toolCount})`);
|
|
101
|
+
|
|
102
|
+
const gaps = coverageGaps(names);
|
|
103
|
+
const gapsOk = gaps.unknown.length === 0;
|
|
104
|
+
step('classification-coverage', gapsOk, gapsOk
|
|
105
|
+
? `every live tool is classified (${Object.keys(loadClassification().tools).length} golden entries)${gaps.missing.length ? `; ${gaps.missing.length} classified tool(s) absent live: ${gaps.missing.join(', ')}` : ''}`
|
|
106
|
+
: `UNCLASSIFIED live tools (deny-by-default — update data/tools-classification.json): ${gaps.unknown.join(', ')}`);
|
|
107
|
+
|
|
108
|
+
out.ok = out.steps.every((s) => s.ok);
|
|
109
|
+
if (flags.has('json')) write(JSON.stringify(out, null, 2));
|
|
110
|
+
else write(out.ok ? 'self-test: ALL GREEN' : 'self-test: FAILED');
|
|
111
|
+
return out.ok ? 0 : 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cmdMcp() {
|
|
115
|
+
const r = resolveEngine();
|
|
116
|
+
if (r.error) { process.stderr.write(r.error + '\n'); return 1; }
|
|
117
|
+
const child = spawn(r.path, ['mcp'], { stdio: 'inherit' });
|
|
118
|
+
child.on('exit', (code, signal) => process.exit(signal ? 1 : code === null ? 1 : code));
|
|
119
|
+
child.on('error', (e) => { process.stderr.write('cloudru-hub mcp: ' + e.message + '\n'); process.exit(1); });
|
|
120
|
+
return null; // async — exit via child
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function main(argv, write = (s) => process.stdout.write(s + '\n')) {
|
|
124
|
+
const { flags, opts, positional } = parseArgs(argv);
|
|
125
|
+
const cmd = positional[0] || 'help';
|
|
126
|
+
if (flags.has('help') || flags.has('h') || cmd === 'help') { showHelp(write); return 0; }
|
|
127
|
+
|
|
128
|
+
switch (cmd) {
|
|
129
|
+
case 'version':
|
|
130
|
+
write(`cloudru-hub ${PKG.version}`);
|
|
131
|
+
write(`pinned engine: ${PKG.cloudruHub.engineVersion}`);
|
|
132
|
+
for (const [k, v] of Object.entries(PKG.cloudruHub.binaryHashes)) write(` ${k} sha256 ${v}`);
|
|
133
|
+
return 0;
|
|
134
|
+
case 'resolve':
|
|
135
|
+
return cmdResolve(flags, write);
|
|
136
|
+
case 'self-test':
|
|
137
|
+
return cmdSelfTest(flags, write);
|
|
138
|
+
case 'mcp': {
|
|
139
|
+
const rc = cmdMcp();
|
|
140
|
+
return rc === null ? null : rc;
|
|
141
|
+
}
|
|
142
|
+
case 'install': {
|
|
143
|
+
const target = opts.get('target');
|
|
144
|
+
if (!target) { write('cloudru-hub install: --target is required (claude-code|codex|agents-md|gemini|…)'); return 1; }
|
|
145
|
+
const res = install(target, opts.get('dir') || process.cwd(), { npx: flags.has('npx') });
|
|
146
|
+
for (const line of res.lines) write(line);
|
|
147
|
+
return res.code;
|
|
148
|
+
}
|
|
149
|
+
case 'compile-skill': {
|
|
150
|
+
const canonical = opts.get('canonical');
|
|
151
|
+
const target = opts.get('target');
|
|
152
|
+
const outDir = opts.get('out');
|
|
153
|
+
if (!canonical || !target || !outDir) { write('cloudru-hub compile-skill: --canonical <dir> --target <t> --out <dir> are all required'); return 1; }
|
|
154
|
+
const result = compileSkill(canonical, target);
|
|
155
|
+
write(`compile-skill ${target}: ${Object.keys(result.files).length} file(s), router ${result.report.routerChars} chars, ${result.report.dangling.length} dangling link(s) unlinked`);
|
|
156
|
+
for (const d of result.report.dangling) write(` dangling: ${d}`);
|
|
157
|
+
if (!result.ok) { for (const e of result.report.errors) write(` ERROR: ${e}`); return 1; }
|
|
158
|
+
writeCompiled(result, outDir);
|
|
159
|
+
write(`compile-skill: wrote ${outDir}`);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
default:
|
|
163
|
+
write(`cloudru-hub: unknown command "${cmd}"`);
|
|
164
|
+
showHelp(write);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (require.main === module) {
|
|
170
|
+
const rc = main(process.argv.slice(2));
|
|
171
|
+
if (rc !== null) process.exitCode = rc;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { main, jsonRpcProbe };
|
package/src/dialects.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Skill dialect compiler — ADR-006: one canonical source, per-target variants are
|
|
3
|
+
// GENERATED from data/dialects.json, never hand-forked. The canonical corpus is
|
|
4
|
+
// UPSTREAM CONTENT and is NOT shipped in this package (ADR-001 guardrail: zero
|
|
5
|
+
// upstream bytes); the compiler reads it from a directory the caller points at
|
|
6
|
+
// (typically `skill/cloudru-hub/` next to the runtime-resolved engine).
|
|
7
|
+
//
|
|
8
|
+
// Layer-1 guarantees enforced HERE, not by reviewer judgment (ADR-006 Confirmation):
|
|
9
|
+
// - dialect: no forbidden Hermes token survives in any emitted variant (hard fail);
|
|
10
|
+
// - links: a dangling relative .md link is UNLINKED (text kept) and counted —
|
|
11
|
+
// "инструкция указывает в пустоту" is closed for all targets at once;
|
|
12
|
+
// - size: router ≤ routerMaxChars for lossy layouts (hard fail), reported for all;
|
|
13
|
+
// - determinism: compilation is a pure function of (canonical bytes, config) — the
|
|
14
|
+
// test compiles twice and requires byte-identity.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const DIALECTS_FILE = path.join(__dirname, '..', 'data', 'dialects.json');
|
|
20
|
+
|
|
21
|
+
function loadDialects() {
|
|
22
|
+
return JSON.parse(fs.readFileSync(DIALECTS_FILE, 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function listFiles(dir, base = dir) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
28
|
+
const full = path.join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) out.push(...listFiles(full, base));
|
|
30
|
+
else out.push(path.relative(base, full));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function applyReplacements(text, replacements) {
|
|
36
|
+
let out = text;
|
|
37
|
+
for (const r of replacements) out = out.replace(new RegExp(r.find, r.flags || 'g'), r.replace);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Keep only `name:`/`description:` (with indented continuations) in the frontmatter —
|
|
42
|
+
* ADR-006 Consequences: `metadata.hermes` and friends do not travel. */
|
|
43
|
+
function filterFrontmatter(text) {
|
|
44
|
+
if (!text.startsWith('---\n')) return text;
|
|
45
|
+
const end = text.indexOf('\n---', 4);
|
|
46
|
+
if (end === -1) return text;
|
|
47
|
+
const head = text.slice(4, end).split('\n');
|
|
48
|
+
const kept = [];
|
|
49
|
+
let keeping = false;
|
|
50
|
+
for (const line of head) {
|
|
51
|
+
if (/^(name|description):/.test(line)) { keeping = true; kept.push(line); continue; }
|
|
52
|
+
if (/^\S/.test(line)) { keeping = false; continue; }
|
|
53
|
+
if (keeping) kept.push(line); // indented continuation of a kept key
|
|
54
|
+
}
|
|
55
|
+
return '---\n' + kept.join('\n') + text.slice(end);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const LINK_RE = /\[([^\]]*)\]\(([^)#\s]+[^)]*)\)/g;
|
|
59
|
+
|
|
60
|
+
/** Escape a literal string for use inside a RegExp. */
|
|
61
|
+
function escapeRegExp(s) {
|
|
62
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Unlink dangling relative links against the EMITTED layout; return {text, dangling[]}. */
|
|
66
|
+
function resolveLinks(text, fileRel, emittedFiles) {
|
|
67
|
+
const have = new Set(emittedFiles.map((f) => f.split(path.sep).join('/')));
|
|
68
|
+
const dangling = [];
|
|
69
|
+
const out = text.replace(LINK_RE, (m, label, target) => {
|
|
70
|
+
if (/^[a-z]+:\/\//i.test(target) || target.startsWith('mailto:')) return m;
|
|
71
|
+
const clean = target.split('#')[0];
|
|
72
|
+
if (clean === '') return m;
|
|
73
|
+
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fileRel.split(path.sep).join('/')), clean));
|
|
74
|
+
if (have.has(resolved)) return m;
|
|
75
|
+
dangling.push(`${fileRel}: (${target})`);
|
|
76
|
+
return label; // keep the words, drop the pointer into the void
|
|
77
|
+
});
|
|
78
|
+
return { text: out, dangling };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compile the canonical skill tree for one target.
|
|
83
|
+
* @returns {{ok:boolean, files:Record<string,string>, report:{dangling:string[],
|
|
84
|
+
* forbidden:string[], routerChars:number, errors:string[]}}}
|
|
85
|
+
* `files` maps relative path -> emitted content (caller writes them; keeps this pure).
|
|
86
|
+
*/
|
|
87
|
+
function compileSkill(canonicalDir, target, config = loadDialects()) {
|
|
88
|
+
const targetCfg = config.targets[target];
|
|
89
|
+
const errors = [];
|
|
90
|
+
if (!targetCfg) {
|
|
91
|
+
return { ok: false, files: {}, report: { dangling: [], forbidden: [], routerChars: 0, errors: [`unknown dialect target "${target}" (known: ${Object.keys(config.targets).join(', ')})`] } };
|
|
92
|
+
}
|
|
93
|
+
const rels = listFiles(canonicalDir);
|
|
94
|
+
const emitted = {};
|
|
95
|
+
for (const rel of rels) {
|
|
96
|
+
const full = path.join(canonicalDir, rel);
|
|
97
|
+
if (rel.endsWith('.md')) {
|
|
98
|
+
let text = fs.readFileSync(full, 'utf8');
|
|
99
|
+
text = applyReplacements(text, targetCfg.replacements);
|
|
100
|
+
if (rel === 'SKILL.md') text = filterFrontmatter(text);
|
|
101
|
+
emitted[rel] = text;
|
|
102
|
+
} else {
|
|
103
|
+
emitted[rel] = fs.readFileSync(full, 'utf8');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// link resolution against the emitted layout
|
|
108
|
+
const emittedList = Object.keys(emitted);
|
|
109
|
+
const dangling = [];
|
|
110
|
+
for (const rel of emittedList) {
|
|
111
|
+
if (!rel.endsWith('.md')) continue;
|
|
112
|
+
const r = resolveLinks(emitted[rel], rel, emittedList);
|
|
113
|
+
emitted[rel] = r.text;
|
|
114
|
+
dangling.push(...r.dangling);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// dialect gate: forbidden tokens must be GONE (hard fail — a lying variant never ships)
|
|
118
|
+
const forbidden = [];
|
|
119
|
+
for (const rel of emittedList) {
|
|
120
|
+
if (!rel.endsWith('.md')) continue;
|
|
121
|
+
for (const token of config.forbiddenHermesTokens) {
|
|
122
|
+
if (emitted[rel].includes(token)) forbidden.push(`${rel}: "${token}"`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// codename gate (2026-08-10, cross-model QE MEDIUM; token set expanded AM-2): the token
|
|
126
|
+
// DENYLIST above only catches the 8 enumerated dialect constructs — compiling the real
|
|
127
|
+
// corpus returned ok=true while the bare codename survived 5× (MEASURED). This is the
|
|
128
|
+
// POSITIVE invariant instead: NO occurrence of an engine-IDENTITY codename, in ANY case,
|
|
129
|
+
// in ANY emitted file (not just .md) — so the next unknown leak spelling is caught too.
|
|
130
|
+
// The set now covers the full engine identity: `hermes`, plus the go-module owner
|
|
131
|
+
// `dzhechko`, the CLI name `cloudru-vm-cli`, and `captainkeys` (captainkeys.go).
|
|
132
|
+
// WORD-BOUNDARY (\b…\b), not raw substring: NECESSARY because a substring `dzhechko`
|
|
133
|
+
// over-blocks the legitimately-shipped published scope `@dzhechkov` (dzhechko + v) — a
|
|
134
|
+
// must-ship token, MEASURED. Word boundaries also drop harmless false positives like
|
|
135
|
+
// "thermes"/"arithmetic" WITHOUT weakening the net against any OBSERVED real leak shape
|
|
136
|
+
// (`~/.hermes/`, `hermes-agent`, prose "Hermes", Cyrillic-suffixed `hermesовский` — all
|
|
137
|
+
// word-bounded; \b treats Cyrillic/`.`/`-`/`/` as non-word, so those still hard-fail).
|
|
138
|
+
// Trade-off: a codename embedded inside a longer ALL-Latin word (e.g. `hermesbot`) is
|
|
139
|
+
// NOT caught — but no such spelling has been observed and the compile fails LOUD at
|
|
140
|
+
// build time on the maintainer's controlled corpus. The denylist is kept for the
|
|
141
|
+
// construct-level tokens (tool_search, hermes cron, …) whose value is the precise
|
|
142
|
+
// per-construct report; the codename scan is the net under it. New legitimate upstream
|
|
143
|
+
// mentions must gain a scrub replacement in data/dialects.json — the compile fails
|
|
144
|
+
// loudly until they do (never ships a leak).
|
|
145
|
+
for (const rel of emittedList) {
|
|
146
|
+
for (const codename of config.forbiddenCodenames || []) {
|
|
147
|
+
const hits = (emitted[rel].match(new RegExp('\\b' + escapeRegExp(codename) + '\\b', 'gi')) || []).length;
|
|
148
|
+
if (hits > 0) forbidden.push(`${rel}: codename "${codename}" x${hits} (case-insensitive, word-boundary)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (forbidden.length > 0) errors.push(`forbidden Hermes-dialect tokens survive: ${forbidden.join('; ')}`);
|
|
152
|
+
|
|
153
|
+
// size gate
|
|
154
|
+
const routerChars = (emitted['SKILL.md'] || '').length;
|
|
155
|
+
if (targetCfg.layout !== 'lossless' && routerChars > config.routerMaxChars) {
|
|
156
|
+
errors.push(`router SKILL.md is ${routerChars} chars > ${config.routerMaxChars} cap for lossy target ${target}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { ok: errors.length === 0, files: emitted, report: { dangling, forbidden, routerChars, errors } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Write a compile result to disk (only when ok). */
|
|
163
|
+
function writeCompiled(result, outDir) {
|
|
164
|
+
if (!result.ok) throw new Error('refusing to write a failed compile: ' + result.report.errors.join('; '));
|
|
165
|
+
for (const [rel, content] of Object.entries(result.files)) {
|
|
166
|
+
const dest = path.join(outDir, rel);
|
|
167
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
168
|
+
fs.writeFileSync(dest, content);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { DIALECTS_FILE, loadDialects, compileSkill, writeCompiled, filterFrontmatter, resolveLinks };
|