huaweicloud-devkit 1.1.0-next.6 → 1.1.0-next.8

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
@@ -8,7 +8,7 @@
8
8
 
9
9
  Help AI coding agents use Huawei Cloud safely and accurately — a single integration that gives agents cloud knowledge, CLI tooling, and safety guardrails.
10
10
 
11
- Supports OpenCode, CodeArts Agent, WorkBuddy, DeepSeek Harness (DSH), OfficeAce, OpenClaw, and AtomCode.
11
+ Supports OpenCode, CodeArts Agent, WorkBuddy, DeepSeek Harness (DSH), OfficeAce, Hermes, OpenClaw, and AtomCode.
12
12
 
13
13
  ## Prerequisites
14
14
 
@@ -98,6 +98,25 @@ npx --yes huaweicloud-devkit update --target officeace
98
98
  npx --yes huaweicloud-devkit uninstall --target officeace
99
99
  ```
100
100
 
101
+ ### Hermes
102
+
103
+ ```bash
104
+ npx --yes huaweicloud-devkit install --target hermes
105
+ ```
106
+
107
+ **Restart the Hermes session** after installation.
108
+
109
+ ```bash
110
+ npx --yes huaweicloud-devkit doctor --target hermes
111
+ npx --yes huaweicloud-devkit status --target hermes
112
+ npx --yes huaweicloud-devkit update --target hermes
113
+ npx --yes huaweicloud-devkit uninstall --target hermes
114
+ ```
115
+
116
+ > **Safety hooks**: The installer configures Hermes shell hooks (`config.yaml` → `hooks.pre_tool_call`) to intercept unsafe terminal commands such as credential file reads, environment variable dumps, and unapproved `hcloud` write operations. Hermes shows a consent prompt the first time; approve it or set `hooks_auto_accept: true` in `config.yaml` to auto-accept.
117
+ > **MCP Python SDK**: The installer automatically installs the `mcp` Python package required by Hermes for MCP tool discovery. If you see `[FAIL] Hermes MCP Python SDK` in `doctor`, run `pip3 install mcp` manually.
118
+ > **Windows**: Hermes on Windows stores data under `%LOCALAPPDATA%\hermes` instead of `~/.hermes`. The installer detects this automatically.
119
+
101
120
  ### OpenClaw
102
121
 
103
122
  ```bash
package/README.zh-CN.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  帮助 AI 编码助手安全、准确地使用华为云——一站式集成云知识、CLI 工具和安全护栏。
10
10
 
11
- 支持 OpenCode、码道(CodeArts Agent)、WorkBuddy、DeepSeek Harness(DSH)、OfficeAce、OpenClaw、AtomCode。
11
+ 支持 OpenCode、码道(CodeArts Agent)、WorkBuddy、DeepSeek Harness(DSH)、OfficeAce、Hermes、OpenClaw、AtomCode。
12
12
 
13
13
  ## 前置条件
14
14
 
@@ -98,6 +98,25 @@ npx --yes huaweicloud-devkit update --target officeace
98
98
  npx --yes huaweicloud-devkit uninstall --target officeace
99
99
  ```
100
100
 
101
+ ### Hermes
102
+
103
+ ```bash
104
+ npx --yes huaweicloud-devkit install --target hermes
105
+ ```
106
+
107
+ 安装后**重启 Hermes 会话**。
108
+
109
+ ```bash
110
+ npx --yes huaweicloud-devkit doctor --target hermes
111
+ npx --yes huaweicloud-devkit status --target hermes
112
+ npx --yes huaweicloud-devkit update --target hermes
113
+ npx --yes huaweicloud-devkit uninstall --target hermes
114
+ ```
115
+
116
+ > **安全钩子(Safety hooks)**:安装器会在 `config.yaml` 中写入 shell hooks 配置(`hooks.pre_tool_call`),拦截不安全的终端命令,如读取凭据文件、导出环境变量、未审批的 `hcloud` 写操作。Hermes 首次使用时会弹出同意提示,可批准或设置 `hooks_auto_accept: true` 自动批准。
117
+ > **MCP Python SDK**:安装器会自动安装 Hermes 所需的 `mcp` Python 包。如果 doctor 显示 `[FAIL] Hermes MCP Python SDK`,手动执行 `pip3 install mcp`。
118
+ > **Windows**:Hermes 在 Windows 上数据存储在 `%LOCALAPPDATA%\hermes` 而非 `~/.hermes`,安装器会自动检测适配。
119
+
101
120
  ### OpenClaw
102
121
 
103
122
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
- "version": "1.1.0-next.6",
3
+ "version": "1.1.0-next.8",
4
4
  "description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
5
5
  "type": "module",
6
6
  "files": [
@@ -17,7 +17,7 @@
17
17
  "mcpServers": "./.mcp.json",
18
18
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
19
19
  "skills": "./skills/",
20
- "version": "1.1.0-next.6",
20
+ "version": "1.1.0-next.8",
21
21
  "author": {
22
22
  "name": "HuaweiCloud Mate",
23
23
  "url": "https://github.com/huaweicloud"
@@ -20,7 +20,7 @@
20
20
  "mcpServers": "./.mcp.json",
21
21
  "description": "Agent toolkit that helps coding agents use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities safely and accurately.",
22
22
  "skills": "./skills/",
23
- "version": "1.1.0-next.6",
23
+ "version": "1.1.0-next.8",
24
24
  "author": {
25
25
  "name": "HuaweiCloud Mate",
26
26
  "url": "https://github.com/huaweicloud"
@@ -17,7 +17,7 @@
17
17
  "mcpServers": "./.mcp.json",
18
18
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
19
19
  "skills": "./skills/",
20
- "version": "1.1.0-next.6",
20
+ "version": "1.1.0-next.8",
21
21
  "author": {
22
22
  "name": "HuaweiCloud Mate",
23
23
  "url": "https://github.com/huaweicloud"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
- "version": "1.1.0-next.6",
3
+ "version": "1.1.0-next.8",
4
4
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
5
5
  "author": {
6
6
  "name": "HuaweiCloud Mate",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huaweicloud-devkit",
3
- "version": "1.1.0-next.6",
3
+ "version": "1.1.0-next.8",
4
4
  "description": "Guide coding agents to use Huawei Cloud Skills, KooCLI, APIs, SDKs, and future MCP capabilities with safer execution and less context.",
5
5
  "author": {
6
6
  "name": "HuaweiCloud Mate",
@@ -53,16 +53,23 @@ READ_OPERATION_RE = re.compile(r"\b(List|Show|Get|Describe|NovaList|NovaShow)\w*
53
53
  READ_OPERATION_RE = re.compile(r"\b(List|Show|Get|Describe|NovaList|NovaShow)\w*", re.I)
54
54
 
55
55
 
56
- def deny(reason):
57
- json.dump(
58
- {
59
- "hookSpecificOutput": {
60
- "hookEventName": "PreToolUse",
61
- "permissionDecision": "deny",
62
- "permissionDecisionReason": DENY_PREFIX + reason,
56
+ def deny(reason, hermes=False):
57
+ sys.stdout.write(
58
+ json.dumps(
59
+ {
60
+ "action": "block",
61
+ "message": DENY_PREFIX + reason,
63
62
  }
64
- },
65
- sys.stdout,
63
+ if hermes
64
+ else {
65
+ "hookSpecificOutput": {
66
+ "hookEventName": "PreToolUse",
67
+ "permissionDecision": "deny",
68
+ "permissionDecisionReason": DENY_PREFIX + reason,
69
+ }
70
+ }
71
+ )
72
+ + "\n"
66
73
  )
67
74
  sys.exit(0)
68
75
 
@@ -139,18 +146,19 @@ def main():
139
146
 
140
147
  tool_name = data.get("tool_name", "")
141
148
  text = command_text(data.get("tool_input", {}))
149
+ hermes = "hook_event_name" in data
142
150
 
143
151
  if CONFIG_FILE_RE and CONFIG_FILE_RE.search(text):
144
- deny("reading Huawei Cloud credential/profile files can expose AK/SK or tokens. Use redacted toolkit tools.")
152
+ deny("reading Huawei Cloud credential/profile files can expose AK/SK or tokens. Use redacted toolkit tools.", hermes)
145
153
  if ENV_DUMP_RE.search(text):
146
- deny("dumping cloud credential environment variables is not allowed.")
154
+ deny("dumping cloud credential environment variables is not allowed.", hermes)
147
155
  if SECRET_READ_RE and SECRET_READ_RE.search(text):
148
- deny("direct secret value retrieval would put plaintext secrets into the agent context.")
156
+ deny("direct secret value retrieval would put plaintext secrets into the agent context.", hermes)
149
157
  denied_rule = first_denied_command_rule(text)
150
158
  if denied_rule:
151
- deny(f"{denied_rule.get('message')} Remediation: {denied_rule.get('remediation')}")
152
- if tool_name == "Bash" and HCLOUD_RE.search(text) and WRITE_OPERATION_RE.search(text) and not READ_OPERATION_RE.search(text):
153
- deny("unapproved Huawei Cloud write operations must be planned first and explicitly approved by the user.")
159
+ deny(f"{denied_rule.get('message')} Remediation: {denied_rule.get('remediation')}", hermes)
160
+ if tool_name in ("Bash", "terminal") and HCLOUD_RE.search(text) and WRITE_OPERATION_RE.search(text) and not READ_OPERATION_RE.search(text):
161
+ deny("unapproved Huawei Cloud write operations must be planned first and explicitly approved by the user.", hermes)
154
162
 
155
163
  allow()
156
164
 
@@ -2,7 +2,7 @@
2
2
  "name": "huaweicloud-devkit",
3
3
  "id": "huaweicloud-devkit",
4
4
  "displayName": "HuaweiCloud DevKit",
5
- "version": "1.1.0-next.6",
5
+ "version": "1.1.0-next.8",
6
6
  "family": "bundle-plugin",
7
7
  "bundleFormat": "codex",
8
8
  "description": "Guide coding agents to use Huawei Cloud safely — KooCLI, APIs, SDKs, 28 MCP tools, skills, and safety guardrails.",
@@ -72,6 +72,8 @@ Domain expertise for Huawei Cloud Sandbox (DevStation) instances and workspace t
72
72
  { "timeout_ms": 300000 }
73
73
  ```
74
74
 
75
+ **Session recovery**: if `exec_with_session` returns `session is not ready`, the WebSocket connection has dropped. Do NOT retry the same session — fall back to `exec_one_shot` for that command instead. To recover state (cd, env vars), reconstruct them explicitly in the one-shot command.
76
+
75
77
  ## Workflow
76
78
 
77
79
  Setup is a **plugin-side preflight** — the developer should be asked a question only once, when the agreement actually needs signing:
@@ -84,10 +86,21 @@ Setup is a **plugin-side preflight** — the developer should be asked a questio
84
86
  3. **Sign agreement only** (`HDKIT_NOT_AGREEMENT`): **STOP and do NOT sign on your own.** Ask the developer: "Huawei Cloud sandbox requires signing the latest developer service agreement. May I sign it for you?" Then **wait for the developer to explicitly agree** (e.g. "签署" / "确认" / "sign it"). Only after explicit consent call `huaweicloud_sandbox_sign_agreement` and return its result (`signed`/`signedCount`) to the developer. **Never sign a legal agreement on the developer's behalf without their explicit, unambiguous consent.** Do not expose the underlying sandbox/DevBridge service as a separate entity the developer must understand or sign up for
85
87
  4. **Both missing** (`HDKIT_NOT_REALNAME_AND_AGREEMENT`): present **both** requirements together in one message — the real-name verification steps (console, step 2) **and** the agreement-signing request (step 3, wait for explicit consent) — so the developer can complete both at once
86
88
  5. **Connect**: `huaweicloud_sandbox_connect` — returns `session_id`, `dev_stage_id`, `connection_id`, `connection_address`
87
- 6. **Inject credentials** (optional): `huaweicloud_sandbox_credentials` — enables cloud API access from sandbox
88
- 7. **Execute commands**: use `huaweicloud_sandbox_exec_with_session` for interactive work (cd, env setup), or `huaweicloud_sandbox_exec_one_shot` for long-running builds/installs. See [Tool Selection Guide](#tool-selection-guide).
89
- 8. **Upload project** (if deploying local code): `huaweicloud_sandbox_upload_project` to transfer a local project directory into `/workspace/<name>` — uses HTTP tunnel, much faster than base64 for multi-file projects. Use `huaweicloud_sandbox_upload_file` for single files.
90
- 9. **Expose the deployed app and return the public URL**: set up a public access link for the app port and give the URL to the developer as the final result (see "Expose the deployed app (public URL)") — this step uses an internal tunnel mechanism that must never be mentioned to the developer
89
+ 6. **Cleanup previous deployments** (after first connect to a sandbox): nginx configs and DevBridge tunnels from previous deployments can cause port conflicts and quota errors. Run cleanup immediately after connect:
90
+
91
+ ```bash
92
+ # Remove stale nginx configs from previous deployments
93
+ sudo rm -f /etc/nginx/conf.d/app.conf /etc/nginx/conf.d/*.conf.bak 2>/dev/null
94
+ # Remove stale DevBridge tunnels
95
+ devbridge delete-all 2>/dev/null || true
96
+ # Reload nginx to apply
97
+ sudo nginx -s reload 2>/dev/null || true
98
+ ```
99
+
100
+ 7. **Inject credentials** (optional): `huaweicloud_sandbox_credentials` — enables cloud API access from sandbox
101
+ 8. **Execute commands**: use `huaweicloud_sandbox_exec_with_session` for interactive work (cd, env setup), or `huaweicloud_sandbox_exec_one_shot` for long-running builds/installs. See [Tool Selection Guide](#tool-selection-guide).
102
+ 9. **Upload project** (if deploying local code): `huaweicloud_sandbox_upload_project` to transfer a local project directory into `/workspace/<name>` — uses HTTP tunnel, much faster than base64 for multi-file projects. Use `huaweicloud_sandbox_upload_file` for single files.
103
+ 10. **Expose the deployed app and return the public URL**: set up a public access link for the app port and give the URL to the developer as the final result (see "Expose the deployed app (public URL)") — this step uses an internal tunnel mechanism that must never be mentioned to the developer
91
104
 
92
105
  ## File Transfer (local → sandbox)
93
106
 
@@ -103,7 +116,7 @@ Setup is a **plugin-side preflight** — the developer should be asked a questio
103
116
  "remote_dir": "/workspace",
104
117
  "extract": true,
105
118
  "exclude": [
106
- "node_modules",
119
+ "**/node_modules",
107
120
  ".git",
108
121
  "__pycache__",
109
122
  ".next",
@@ -240,7 +253,7 @@ Follow the standard [Workflow](#workflow) steps 1-6 to connect to the sandbox, t
240
253
  "local_dir": "<projectPath>",
241
254
  "remote_dir": "/workspace",
242
255
  "exclude": [
243
- "node_modules",
256
+ "**/node_modules",
244
257
  ".git",
245
258
  "__pycache__",
246
259
  ".next",
@@ -258,20 +271,20 @@ Follow the standard [Workflow](#workflow) steps 1-6 to connect to the sandbox, t
258
271
 
259
272
  **Always exclude build artifacts and dependency directories** — they will be re-installed/built inside the sandbox:
260
273
 
261
- | Pattern | Why excluded |
262
- | -------------- | ---------------------------------------------- |
263
- | `node_modules` | Dependencies — reinstall in sandbox |
264
- | `.git` | Version control — not needed for deployment |
265
- | `__pycache__` | Python bytecode cache |
266
- | `.next` | Next.js build output — rebuild in sandbox |
267
- | `.nuxt` | Nuxt build cache — rebuild in sandbox |
268
- | `.output` | Nuxt production output — rebuild in sandbox |
269
- | `.turbo` | Turborepo cache — re-run in sandbox |
270
- | `.cache` | Generic tool cache (Parcel, Storybook, etc.) |
271
- | `.swc` | Taro/Webpack SWC cache — regenerate in sandbox |
272
- | `dist` | Build output — rebuild in sandbox |
273
- | `coverage` | Test coverage reports — not needed for deploy |
274
- | `*.pyc` | Python compiled files |
274
+ | Pattern | Why excluded |
275
+ | ----------------- | ---------------------------------------------- |
276
+ | `**/node_modules` | Dependencies — reinstall in sandbox |
277
+ | `.git` | Version control — not needed for deployment |
278
+ | `__pycache__` | Python bytecode cache |
279
+ | `.next` | Next.js build output — rebuild in sandbox |
280
+ | `.nuxt` | Nuxt build cache — rebuild in sandbox |
281
+ | `.output` | Nuxt production output — rebuild in sandbox |
282
+ | `.turbo` | Turborepo cache — re-run in sandbox |
283
+ | `.cache` | Generic tool cache (Parcel, Storybook, etc.) |
284
+ | `.swc` | Taro/Webpack SWC cache — regenerate in sandbox |
285
+ | `dist` | Build output — rebuild in sandbox |
286
+ | `coverage` | Test coverage reports — not needed for deploy |
287
+ | `*.pyc` | Python compiled files |
275
288
 
276
289
  **Post-upload permission fix**: after `upload_project` extracts the project, fix file permissions lost during transfer (native binaries from other platforms, .bin symlinks):
277
290
 
@@ -420,7 +433,14 @@ if timeout_error; then
420
433
  tail -30 /tmp/build.log 2>/dev/null
421
434
  echo "=== Checking output ==="
422
435
  if [ -d <outputDir> ] && [ "$(ls -A <outputDir> 2>/dev/null)" ]; then
423
- echo "Build output detected despite timeout — continuing with deployment"
436
+ # Verify at least one key output file exists (not just empty dir from broken build)
437
+ if [ -f <outputDir>/index.html ] || [ -f <outputDir>/server.js ] || [ -f <outputDir>/app.js ]; then
438
+ echo "Build output detected despite timeout — continuing with deployment"
439
+ else
440
+ echo "ERROR: Output directory exists but missing expected files (index.html/server.js). Build may have failed silently."
441
+ echo "Full log: /tmp/build.log"
442
+ exit 1
443
+ fi
424
444
  else
425
445
  echo "ERROR: Build did not complete. Output directory empty or missing."
426
446
  echo "Full log: /tmp/build.log"
@@ -1,7 +1,16 @@
1
1
  import { spawn, execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { createConnection as netConnect } from 'node:net';
4
- import { existsSync, readFileSync, statSync, mkdirSync, rmSync, createReadStream, appendFileSync } from 'node:fs';
4
+ import {
5
+ existsSync,
6
+ readFileSync,
7
+ statSync,
8
+ mkdirSync,
9
+ rmSync,
10
+ createReadStream,
11
+ appendFileSync,
12
+ unlinkSync,
13
+ } from 'node:fs';
5
14
  import { createHash, randomBytes } from 'node:crypto';
6
15
  import { join, dirname, basename } from 'node:path';
7
16
  import { tmpdir } from 'node:os';
@@ -269,6 +278,17 @@ function uploadLog(message) {
269
278
  } catch {}
270
279
  }
271
280
 
281
+ function rotateUploadLog(maxBytes = 100 * 1024) {
282
+ try {
283
+ if (existsSync(UPLOAD_LOG_PATH)) {
284
+ const stat = statSync(UPLOAD_LOG_PATH);
285
+ if (stat.size > maxBytes) {
286
+ unlinkSync(UPLOAD_LOG_PATH);
287
+ }
288
+ }
289
+ } catch {}
290
+ }
291
+
272
292
  function generateUploadToken() {
273
293
  return randomBytes(16).toString('hex');
274
294
  }
@@ -279,14 +299,20 @@ async function createTarGz(localDir, exclude = []) {
279
299
  mkdirSync(archiveDir, { recursive: true });
280
300
  const archivePath = join(archiveDir, archiveName);
281
301
 
282
- const args = [
283
- ...exclude.flatMap((p) => ['--exclude', p]),
284
- '-czf',
285
- archivePath,
286
- '-C',
287
- dirname(localDir),
288
- basename(localDir),
289
- ];
302
+ const args = [];
303
+ for (const pattern of exclude) {
304
+ if (pattern.startsWith('**/')) {
305
+ const base = pattern.slice(3);
306
+ for (let depth = 0; depth <= 4; depth++) {
307
+ const prefix = depth === 0 ? '' : '*/'.repeat(depth);
308
+ args.push('--exclude', `${prefix}${base}`);
309
+ }
310
+ } else {
311
+ args.push('--exclude', pattern);
312
+ }
313
+ }
314
+ args.push('-czf', archivePath, '-C', dirname(localDir), basename(localDir));
315
+
290
316
  await execFileAsync('tar', args);
291
317
  return archivePath;
292
318
  }
@@ -557,6 +583,8 @@ export async function uploadProjectWithSession(
557
583
  throw new Error(`sandbox upload project: path is not a directory: ${localDir}`);
558
584
  }
559
585
 
586
+ rotateUploadLog();
587
+
560
588
  const projectName = basename(localDir);
561
589
  const targetParentDir = remoteDir || '/workspace';
562
590
  const archiveRemotePath = `${targetParentDir}/${projectName}.tar.gz`;
@@ -567,6 +595,15 @@ export async function uploadProjectWithSession(
567
595
 
568
596
  uploadLog(`uploadProject: ${localDir} -> ${archiveRemotePath} (archive=${archiveSize} bytes, md5=${expectedMd5})`);
569
597
 
598
+ const SIZE_50MB = 50 * 1024 * 1024;
599
+ if (archiveSize > SIZE_50MB) {
600
+ uploadLog(
601
+ `uploadProject: archive size ${(archiveSize / (1024 * 1024)).toFixed(1)}MB exceeds 50MB. ` +
602
+ `Dependencies or platform binaries may have been included. ` +
603
+ `Ensure exclude list contains "**/node_modules" to match all nesting levels.`,
604
+ );
605
+ }
606
+
570
607
  let result;
571
608
  let tunnelError;
572
609
  for (let attempt = 0; attempt < UPLOAD_MAX_RETRIES; attempt++) {
@@ -577,7 +614,8 @@ export async function uploadProjectWithSession(
577
614
  break;
578
615
  } catch (error) {
579
616
  tunnelError = error;
580
- uploadLog(`uploadProject: attempt ${attempt + 1}/${UPLOAD_MAX_RETRIES} failed: ${error.message}`);
617
+ const errorType = error.code || error.name || 'unknown';
618
+ uploadLog(`uploadProject: attempt ${attempt + 1}/${UPLOAD_MAX_RETRIES} failed [${errorType}]: ${error.message}`);
581
619
  await new Promise((r) => setTimeout(r, 5000));
582
620
  }
583
621
  }
@@ -2035,28 +2035,135 @@ function removeHermesMcpConfigBlock() {
2035
2035
  console.log(' MCP config cleaned');
2036
2036
  }
2037
2037
 
2038
+ function ensureHermesHooksConfig() {
2039
+ const configPath = hermesConfigFile();
2040
+ const pluginDest = hermesPluginsDir();
2041
+ const hookScript = join(pluginDest, 'hooks', 'huaweicloud-safety.py').replace(/\\/g, '/');
2042
+
2043
+ const blockLines = [
2044
+ 'hooks:',
2045
+ ' pre_tool_call:',
2046
+ ' - matcher: "terminal"',
2047
+ ` command: "python3 ${hookScript}"`,
2048
+ ' timeout: 5',
2049
+ ];
2050
+ const block = blockLines.join('\n');
2051
+
2052
+ let existing = '';
2053
+ if (existsSync(configPath)) {
2054
+ try {
2055
+ existing = readFileSync(configPath, 'utf8');
2056
+ } catch {}
2057
+ if (existing.includes('hooks:') && existing.includes('huaweicloud-safety.py')) {
2058
+ console.log(` Hooks config unchanged: ${configPath}`);
2059
+ return false;
2060
+ }
2061
+ }
2062
+ mkdirSync(dirname(configPath), { recursive: true });
2063
+ const newContent = existing ? `${existing.trimEnd()}\n\n${block}\n` : `${block}\n`;
2064
+ writeFileSync(configPath, newContent);
2065
+ console.log(` Hooks config updated: ${configPath}`);
2066
+ return true;
2067
+ }
2068
+
2069
+ function removeHermesHooksConfigBlock() {
2070
+ const configPath = hermesConfigFile();
2071
+ if (!existsSync(configPath)) return;
2072
+ const lines = readFileSync(configPath, 'utf8').split(/\r?\n/);
2073
+ const out = [];
2074
+ let skip = false;
2075
+ for (const line of lines) {
2076
+ if (/^\s*huaweicloud-safety\.py/.test(line.trim())) {
2077
+ skip = true;
2078
+ continue;
2079
+ }
2080
+ if (skip) {
2081
+ if (line.trim() === '') continue;
2082
+ if (/^\s{2,}/.test(line) && line.trim() !== '') continue;
2083
+ skip = false;
2084
+ }
2085
+ if (line.trim() === 'hooks:') {
2086
+ continue;
2087
+ }
2088
+ if (line.trim() === 'pre_tool_call:') {
2089
+ continue;
2090
+ }
2091
+ if (/^\s*- matcher:\s*"terminal"/.test(line)) {
2092
+ continue;
2093
+ }
2094
+ out.push(line);
2095
+ }
2096
+ const cleaned = out.join('\n').trimEnd();
2097
+ writeFileSync(configPath, cleaned ? `${cleaned}\n` : '');
2098
+ console.log(' Hooks config cleaned');
2099
+ }
2100
+
2101
+ function ensureHermesMcpSdk() {
2102
+ const pythonBin = process.platform === 'win32' ? 'python' : 'python3';
2103
+ try {
2104
+ const r = spawnSync(pythonBin, ['-c', 'import mcp; print("ok")'], { encoding: 'utf8', timeout: 5000 });
2105
+ if (r.status === 0 && r.stdout.trim() === 'ok') {
2106
+ console.log(' MCP Python SDK: \x1b[32mAlready installed\x1b[0m');
2107
+ return true;
2108
+ }
2109
+ } catch {}
2110
+ console.log(' Installing Hermes MCP Python SDK...');
2111
+ try {
2112
+ const pip = spawnSync(pythonBin, ['-m', 'pip', 'install', 'mcp', '--quiet'], { encoding: 'utf8', timeout: 60000 });
2113
+ if (pip.status === 0) {
2114
+ console.log(' MCP Python SDK: \x1b[32mInstalled\x1b[0m');
2115
+ return true;
2116
+ }
2117
+ console.log(` MCP Python SDK: \x1b[33mInstall failed\x1b[0m`);
2118
+ console.log(` \x1b[33m Run manually: ${pythonBin} -m pip install mcp\x1b[0m`);
2119
+ } catch (error) {
2120
+ console.log(` MCP Python SDK: \x1b[33m${error.message}\x1b[0m`);
2121
+ console.log(` \x1b[33m Run manually: ${pythonBin} -m pip install mcp\x1b[0m`);
2122
+ }
2123
+ return false;
2124
+ }
2125
+
2126
+ function hermesMcpSdkOk() {
2127
+ const pythonBin = process.platform === 'win32' ? 'python' : 'python3';
2128
+ try {
2129
+ const r = spawnSync(pythonBin, ['-c', 'import mcp; print("ok")'], { encoding: 'utf8', timeout: 5000 });
2130
+ return r.status === 0 && r.stdout.trim() === 'ok';
2131
+ } catch {
2132
+ return false;
2133
+ }
2134
+ }
2135
+
2038
2136
  async function installHermes() {
2039
2137
  const skillsSrc = join(PLUGIN_ROOT, 'skills');
2040
2138
  const srcDir = join(PLUGIN_ROOT, 'src');
2041
2139
  const safetyDir = join(PLUGIN_ROOT, 'safety');
2140
+ const hooksDir = join(PLUGIN_ROOT, 'hooks');
2042
2141
  const pluginDest = hermesPluginsDir();
2142
+ const skipMcp = process.argv.includes('--skip-mcp-server');
2043
2143
 
2044
2144
  copyDir(skillsSrc, hermesSkillsDir());
2045
2145
  console.log(` Skills -> ${hermesSkillsDir()}`);
2046
2146
 
2047
- copyDir(srcDir, join(pluginDest, 'src'));
2048
- console.log(` MCP Server -> ${join(pluginDest, 'src')}`);
2147
+ if (!skipMcp) {
2148
+ copyDir(srcDir, join(pluginDest, 'src'));
2149
+ console.log(` MCP Server -> ${join(pluginDest, 'src')}`);
2150
+ }
2049
2151
  copyDir(safetyDir, join(pluginDest, 'safety'));
2050
2152
  console.log(` Safety Policy -> ${join(pluginDest, 'safety')}`);
2153
+ copyDir(hooksDir, join(pluginDest, 'hooks'));
2154
+ console.log(` Safety Hooks -> ${join(pluginDest, 'hooks')}`);
2051
2155
 
2052
- ensureHermesMcpConfig();
2053
- installRuntimeDeps(pluginDest);
2156
+ if (!skipMcp) ensureHermesMcpConfig();
2157
+ ensureHermesHooksConfig();
2158
+ if (!skipMcp) installRuntimeDeps(pluginDest);
2159
+ if (!skipMcp) ensureHermesMcpSdk();
2054
2160
  }
2055
2161
 
2056
2162
  async function updateHermes() {
2057
2163
  const skillsSrc = join(PLUGIN_ROOT, 'skills');
2058
2164
  const srcDir = join(PLUGIN_ROOT, 'src');
2059
2165
  const safetyDir = join(PLUGIN_ROOT, 'safety');
2166
+ const hooksDir = join(PLUGIN_ROOT, 'hooks');
2060
2167
  const pluginDest = hermesPluginsDir();
2061
2168
 
2062
2169
  copyDir(skillsSrc, hermesSkillsDir());
@@ -2066,7 +2173,11 @@ async function updateHermes() {
2066
2173
  console.log(` MCP Server updated -> ${join(pluginDest, 'src')}`);
2067
2174
  copyDir(safetyDir, join(pluginDest, 'safety'));
2068
2175
  console.log(` Safety Policy updated -> ${join(pluginDest, 'safety')}`);
2176
+ copyDir(hooksDir, join(pluginDest, 'hooks'));
2177
+ console.log(` Safety Hooks updated -> ${join(pluginDest, 'hooks')}`);
2069
2178
  ensureHermesMcpConfig();
2179
+ ensureHermesHooksConfig();
2180
+ ensureHermesMcpSdk();
2070
2181
  mkdirSync(pluginDest, { recursive: true });
2071
2182
  writeFileSync(join(pluginDest, '.installed'), new Date().toISOString());
2072
2183
  installRuntimeDeps(pluginDest);
@@ -2092,9 +2203,10 @@ function uninstallHermes() {
2092
2203
  }
2093
2204
 
2094
2205
  if (removeIfExists(hermesPluginsDir())) {
2095
- console.log(' Removed MCP server and safety policy');
2206
+ console.log(' Removed MCP server, safety policy and hooks');
2096
2207
  }
2097
2208
  removeHermesMcpConfigBlock();
2209
+ removeHermesHooksConfigBlock();
2098
2210
  }
2099
2211
 
2100
2212
  function hermesStatus() {
@@ -2106,6 +2218,10 @@ function hermesStatus() {
2106
2218
  console.log(
2107
2219
  ` Safety Policy: ${existsSync(join(pluginDir, 'safety', 'policy.json')) ? '\x1b[32mInstalled\x1b[0m' : '\x1b[31mNot installed\x1b[0m'}`,
2108
2220
  );
2221
+ console.log(
2222
+ ` Safety Hooks: ${existsSync(join(pluginDir, 'hooks', 'huaweicloud-safety.py')) ? '\x1b[32mInstalled\x1b[0m' : '\x1b[31mNot installed\x1b[0m'}`,
2223
+ );
2224
+ console.log(` MCP Python SDK: ${hermesMcpSdkOk() ? '\x1b[32mReady\x1b[0m' : '\x1b[31mMissing\x1b[0m'}`);
2109
2225
  let skillCount = 0;
2110
2226
  if (existsSync(skillsDir)) {
2111
2227
  skillCount = readdirSync(skillsDir, { withFileTypes: true }).filter(
@@ -2119,13 +2235,17 @@ function hermesStatus() {
2119
2235
  if (existsSync(configPath)) {
2120
2236
  try {
2121
2237
  const config = readFileSync(configPath, 'utf8');
2122
- const configured = config.includes('mcp_servers:') && config.includes('huaweicloud-devkit');
2123
- console.log(` MCP config: ${configured ? '\x1b[32mConfigured\x1b[0m' : '\x1b[31mNot configured\x1b[0m'}`);
2238
+ const mcpConfigured = config.includes('mcp_servers:') && config.includes('huaweicloud-devkit');
2239
+ const hooksConfigured = config.includes('hooks:') && config.includes('huaweicloud-safety.py');
2240
+ console.log(` MCP config: ${mcpConfigured ? '\x1b[32mConfigured\x1b[0m' : '\x1b[31mNot configured\x1b[0m'}`);
2241
+ console.log(` Hooks config: ${hooksConfigured ? '\x1b[32mConfigured\x1b[0m' : '\x1b[31mNot configured\x1b[0m'}`);
2124
2242
  } catch {
2125
2243
  console.log(` MCP config: \x1b[31mInvalid\x1b[0m`);
2244
+ console.log(` Hooks config: \x1b[31mInvalid\x1b[0m`);
2126
2245
  }
2127
2246
  } else {
2128
2247
  console.log(` MCP config: \x1b[31mNot found\x1b[0m`);
2248
+ console.log(` Hooks config: \x1b[31mNot found\x1b[0m`);
2129
2249
  }
2130
2250
  }
2131
2251
 
@@ -2684,6 +2804,14 @@ async function cmdDoctor() {
2684
2804
  mcpCfgTarget ? `Found in ${mcpCfgTarget} config` : 'Run: npx huaweicloud-devkit install',
2685
2805
  );
2686
2806
 
2807
+ // Hermes MCP Python SDK (only checked when Hermes config is found)
2808
+ if (
2809
+ mcpCfgTarget === 'Hermes Agent' ||
2810
+ (existsSync(hermesConfigFile()) && readFileSync(hermesConfigFile(), 'utf8').includes('mcp_servers'))
2811
+ ) {
2812
+ check('Hermes MCP Python SDK', hermesMcpSdkOk(), 'Run: pip3 install mcp');
2813
+ }
2814
+
2687
2815
  // hcloud CLI
2688
2816
  const hcloudBin = findHcloudBin() || process.env.HCLOUD_BIN || 'hcloud';
2689
2817
  const hcloudCheck = spawnSync(`"${hcloudBin}" version`, [], {
@@ -38,15 +38,36 @@ function closeQuietly(ws) {
38
38
  }
39
39
  }
40
40
 
41
- function escapeRegExp(text) {
42
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
43
- }
44
-
45
41
  function stripEchoedLine(text, line, options = {}) {
46
- const escaped = escapeRegExp(line);
47
- const stripped = text.replace(new RegExp(`(^|\\r?\\n)${escaped}\\r?\\n?`), '$1');
48
- if (!options.allowAttached || stripped !== text) return stripped;
49
- return text.replace(new RegExp(`${escaped}(?:\\r?\\n){0,2}$`), '');
42
+ if (text.startsWith(line)) {
43
+ text = text.slice(line.length);
44
+ text = text.replace(/^\r?\n/, '');
45
+ if (!options.allowAttached) return text;
46
+ }
47
+
48
+ for (const nl of ['\r\n', '\n']) {
49
+ const idx = text.indexOf(nl + line);
50
+ if (idx !== -1) {
51
+ const before = text.slice(0, idx + nl.length);
52
+ const after = text.slice(idx + nl.length + line.length);
53
+ text = before + after.replace(/^\r?\n/, '');
54
+ if (!options.allowAttached) return text;
55
+ break;
56
+ }
57
+ }
58
+
59
+ if (options.allowAttached) {
60
+ for (let trail = 2; trail >= 0; trail--) {
61
+ for (const nl of ['\r\n', '\n']) {
62
+ const suffix = nl.repeat(trail);
63
+ if (text.endsWith(line + suffix)) {
64
+ return text.slice(0, text.length - line.length - suffix.length);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ return text;
50
71
  }
51
72
 
52
73
  function buildMarkers(nonce = randomBytes(8).toString('hex')) {