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

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,24 @@ 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
+ > **Windows**: Hermes on Windows stores data under `%LOCALAPPDATA%\hermes` instead of `~/.hermes`. The installer detects this automatically.
118
+
101
119
  ### OpenClaw
102
120
 
103
121
  ```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,24 @@ 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
+ > **Windows**:Hermes 在 Windows 上数据存储在 `%LOCALAPPDATA%\hermes` 而非 `~/.hermes`,安装器会自动检测适配。
118
+
101
119
  ### OpenClaw
102
120
 
103
121
  ```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.7",
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.7",
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.7",
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.7",
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.7",
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.7",
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.7",
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
  }
@@ -557,6 +577,8 @@ export async function uploadProjectWithSession(
557
577
  throw new Error(`sandbox upload project: path is not a directory: ${localDir}`);
558
578
  }
559
579
 
580
+ rotateUploadLog();
581
+
560
582
  const projectName = basename(localDir);
561
583
  const targetParentDir = remoteDir || '/workspace';
562
584
  const archiveRemotePath = `${targetParentDir}/${projectName}.tar.gz`;
@@ -567,6 +589,15 @@ export async function uploadProjectWithSession(
567
589
 
568
590
  uploadLog(`uploadProject: ${localDir} -> ${archiveRemotePath} (archive=${archiveSize} bytes, md5=${expectedMd5})`);
569
591
 
592
+ const SIZE_50MB = 50 * 1024 * 1024;
593
+ if (archiveSize > SIZE_50MB) {
594
+ uploadLog(
595
+ `uploadProject: archive size ${(archiveSize / (1024 * 1024)).toFixed(1)}MB exceeds 50MB. ` +
596
+ `Dependencies or platform binaries may have been included. ` +
597
+ `Ensure exclude list contains "**/node_modules" to match all nesting levels.`,
598
+ );
599
+ }
600
+
570
601
  let result;
571
602
  let tunnelError;
572
603
  for (let attempt = 0; attempt < UPLOAD_MAX_RETRIES; attempt++) {
@@ -577,7 +608,8 @@ export async function uploadProjectWithSession(
577
608
  break;
578
609
  } catch (error) {
579
610
  tunnelError = error;
580
- uploadLog(`uploadProject: attempt ${attempt + 1}/${UPLOAD_MAX_RETRIES} failed: ${error.message}`);
611
+ const errorType = error.code || error.name || 'unknown';
612
+ uploadLog(`uploadProject: attempt ${attempt + 1}/${UPLOAD_MAX_RETRIES} failed [${errorType}]: ${error.message}`);
581
613
  await new Promise((r) => setTimeout(r, 5000));
582
614
  }
583
615
  }
@@ -2035,28 +2035,99 @@ 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
+
2038
2101
  async function installHermes() {
2039
2102
  const skillsSrc = join(PLUGIN_ROOT, 'skills');
2040
2103
  const srcDir = join(PLUGIN_ROOT, 'src');
2041
2104
  const safetyDir = join(PLUGIN_ROOT, 'safety');
2105
+ const hooksDir = join(PLUGIN_ROOT, 'hooks');
2042
2106
  const pluginDest = hermesPluginsDir();
2107
+ const skipMcp = process.argv.includes('--skip-mcp-server');
2043
2108
 
2044
2109
  copyDir(skillsSrc, hermesSkillsDir());
2045
2110
  console.log(` Skills -> ${hermesSkillsDir()}`);
2046
2111
 
2047
- copyDir(srcDir, join(pluginDest, 'src'));
2048
- console.log(` MCP Server -> ${join(pluginDest, 'src')}`);
2112
+ if (!skipMcp) {
2113
+ copyDir(srcDir, join(pluginDest, 'src'));
2114
+ console.log(` MCP Server -> ${join(pluginDest, 'src')}`);
2115
+ }
2049
2116
  copyDir(safetyDir, join(pluginDest, 'safety'));
2050
2117
  console.log(` Safety Policy -> ${join(pluginDest, 'safety')}`);
2118
+ copyDir(hooksDir, join(pluginDest, 'hooks'));
2119
+ console.log(` Safety Hooks -> ${join(pluginDest, 'hooks')}`);
2051
2120
 
2052
- ensureHermesMcpConfig();
2053
- installRuntimeDeps(pluginDest);
2121
+ if (!skipMcp) ensureHermesMcpConfig();
2122
+ ensureHermesHooksConfig();
2123
+ if (!skipMcp) installRuntimeDeps(pluginDest);
2054
2124
  }
2055
2125
 
2056
2126
  async function updateHermes() {
2057
2127
  const skillsSrc = join(PLUGIN_ROOT, 'skills');
2058
2128
  const srcDir = join(PLUGIN_ROOT, 'src');
2059
2129
  const safetyDir = join(PLUGIN_ROOT, 'safety');
2130
+ const hooksDir = join(PLUGIN_ROOT, 'hooks');
2060
2131
  const pluginDest = hermesPluginsDir();
2061
2132
 
2062
2133
  copyDir(skillsSrc, hermesSkillsDir());
@@ -2066,7 +2137,10 @@ async function updateHermes() {
2066
2137
  console.log(` MCP Server updated -> ${join(pluginDest, 'src')}`);
2067
2138
  copyDir(safetyDir, join(pluginDest, 'safety'));
2068
2139
  console.log(` Safety Policy updated -> ${join(pluginDest, 'safety')}`);
2140
+ copyDir(hooksDir, join(pluginDest, 'hooks'));
2141
+ console.log(` Safety Hooks updated -> ${join(pluginDest, 'hooks')}`);
2069
2142
  ensureHermesMcpConfig();
2143
+ ensureHermesHooksConfig();
2070
2144
  mkdirSync(pluginDest, { recursive: true });
2071
2145
  writeFileSync(join(pluginDest, '.installed'), new Date().toISOString());
2072
2146
  installRuntimeDeps(pluginDest);
@@ -2092,9 +2166,10 @@ function uninstallHermes() {
2092
2166
  }
2093
2167
 
2094
2168
  if (removeIfExists(hermesPluginsDir())) {
2095
- console.log(' Removed MCP server and safety policy');
2169
+ console.log(' Removed MCP server, safety policy and hooks');
2096
2170
  }
2097
2171
  removeHermesMcpConfigBlock();
2172
+ removeHermesHooksConfigBlock();
2098
2173
  }
2099
2174
 
2100
2175
  function hermesStatus() {
@@ -2106,6 +2181,9 @@ function hermesStatus() {
2106
2181
  console.log(
2107
2182
  ` Safety Policy: ${existsSync(join(pluginDir, 'safety', 'policy.json')) ? '\x1b[32mInstalled\x1b[0m' : '\x1b[31mNot installed\x1b[0m'}`,
2108
2183
  );
2184
+ console.log(
2185
+ ` Safety Hooks: ${existsSync(join(pluginDir, 'hooks', 'huaweicloud-safety.py')) ? '\x1b[32mInstalled\x1b[0m' : '\x1b[31mNot installed\x1b[0m'}`,
2186
+ );
2109
2187
  let skillCount = 0;
2110
2188
  if (existsSync(skillsDir)) {
2111
2189
  skillCount = readdirSync(skillsDir, { withFileTypes: true }).filter(
@@ -2119,13 +2197,17 @@ function hermesStatus() {
2119
2197
  if (existsSync(configPath)) {
2120
2198
  try {
2121
2199
  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'}`);
2200
+ const mcpConfigured = config.includes('mcp_servers:') && config.includes('huaweicloud-devkit');
2201
+ const hooksConfigured = config.includes('hooks:') && config.includes('huaweicloud-safety.py');
2202
+ console.log(` MCP config: ${mcpConfigured ? '\x1b[32mConfigured\x1b[0m' : '\x1b[31mNot configured\x1b[0m'}`);
2203
+ console.log(` Hooks config: ${hooksConfigured ? '\x1b[32mConfigured\x1b[0m' : '\x1b[31mNot configured\x1b[0m'}`);
2124
2204
  } catch {
2125
2205
  console.log(` MCP config: \x1b[31mInvalid\x1b[0m`);
2206
+ console.log(` Hooks config: \x1b[31mInvalid\x1b[0m`);
2126
2207
  }
2127
2208
  } else {
2128
2209
  console.log(` MCP config: \x1b[31mNot found\x1b[0m`);
2210
+ console.log(` Hooks config: \x1b[31mNot found\x1b[0m`);
2129
2211
  }
2130
2212
  }
2131
2213
 
@@ -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')) {