huaweicloud-devkit 1.1.0-next.5 → 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.5",
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.5",
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.5",
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.5",
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.5",
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.5",
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.5",
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
 
@@ -102,14 +115,27 @@ Setup is a **plugin-side preflight** — the developer should be asked a questio
102
115
  "local_dir": "/path/to/local/project",
103
116
  "remote_dir": "/workspace",
104
117
  "extract": true,
105
- "exclude": ["node_modules", ".git", "__pycache__"]
118
+ "exclude": [
119
+ "**/node_modules",
120
+ ".git",
121
+ "__pycache__",
122
+ ".next",
123
+ ".nuxt",
124
+ ".output",
125
+ ".turbo",
126
+ ".cache",
127
+ ".swc",
128
+ "dist",
129
+ "coverage",
130
+ "*.pyc"
131
+ ]
106
132
  }
107
133
  ```
108
134
 
109
135
  - `local_dir` (required): local project directory
110
136
  - `remote_dir` (optional, default `/workspace`): parent directory on sandbox
111
137
  - `extract` (optional, default `true`): extract tar.gz after upload
112
- - `exclude` (optional): patterns to exclude from archive
138
+ - `exclude` (optional): patterns to exclude from archive. **For web apps, always exclude dependency directories** (`node_modules`, `.next`, `.nuxt`, `.output`, `.turbo`, `.cache`) — these will be re-installed/built in the sandbox.
113
139
  - Result includes `md5` and `md5Verified` for integrity check
114
140
 
115
141
  ### upload_file (for single files)
@@ -170,11 +196,28 @@ devbridge auth login --huaweicloud --access-key "$AK" --secret-key "$SK"
170
196
  **Expose** (run the web server and the tunnel in the background, then read the URL from the log; the app lives in the workspace mount, e.g. `/workspace/<repo-name>`):
171
197
 
172
198
  ```bash
173
- cd /workspace/<repo-name> && nohup python3 -m http.server 8080 > /tmp/http.log 2>&1 &
174
- nohup devbridge host -p 8080 -e 8 > /tmp/host.log 2>&1 &
199
+ # 0. Pre-cleanup: remove stale tunnels to avoid quota exceeded
200
+ devbridge delete-all 2>/dev/null || true
201
+
202
+ # 1. Start tunnel
203
+ nohup devbridge host -p <port> -e 8 > /tmp/host.log 2>&1 &
175
204
  sleep 10 && cat /tmp/host.log
176
205
  ```
177
206
 
207
+ **Quota recovery**: if the tunnel creation fails with `10006: quota exceeded`:
208
+
209
+ ```bash
210
+ # Step A: List all tunnels (both active and stale)
211
+ devbridge ls --all
212
+ # Step B: Remove all stale tunnels
213
+ devbridge delete-all
214
+ # Step C: Retry tunnel creation
215
+ nohup devbridge host -p <port> -e 8 > /tmp/host.log 2>&1 &
216
+ sleep 10 && cat /tmp/host.log
217
+ ```
218
+
219
+ This eliminates the most common deployment failure — historical tunnels from previous sessions accumulating past the max=10 quota.
220
+
178
221
  - The public URL has the form `https://<id>-<port>.cn-north-4-bridge.myhuaweicloud.com` (from the `Tunnel URL:` line).
179
222
  - **Return this URL to the developer as the deployment result link.** Keep the host process running (do not close the session before handing over the URL).
180
223
  - Internal docs: https://huaweicloud.github.io/devspace-devbridge/
@@ -208,14 +251,80 @@ Follow the standard [Workflow](#workflow) steps 1-6 to connect to the sandbox, t
208
251
  ```json
209
252
  {
210
253
  "local_dir": "<projectPath>",
211
- "remote_dir": "/workspace"
254
+ "remote_dir": "/workspace",
255
+ "exclude": [
256
+ "**/node_modules",
257
+ ".git",
258
+ "__pycache__",
259
+ ".next",
260
+ ".nuxt",
261
+ ".output",
262
+ ".turbo",
263
+ ".cache",
264
+ ".swc",
265
+ "dist",
266
+ "coverage",
267
+ "*.pyc"
268
+ ]
212
269
  }
213
270
  ```
214
271
 
215
- The project will be extracted to `/workspace/<dirname>/`.
272
+ **Always exclude build artifacts and dependency directories** — they will be re-installed/built inside the sandbox:
273
+
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 |
288
+
289
+ **Post-upload permission fix**: after `upload_project` extracts the project, fix file permissions lost during transfer (native binaries from other platforms, .bin symlinks):
290
+
291
+ ```bash
292
+ # Fix executable permissions on node_modules/.bin (lost during cross-platform transfer)
293
+ chmod -R +x /workspace/<dirname>/node_modules/.bin 2>/dev/null || true
294
+ # Fix world-read on all files (sandbox default umask may restrict)
295
+ chmod -R o+rX /workspace/<dirname> 2>/dev/null || true
296
+ ```
216
297
 
217
298
  ### Step 3: Sandbox Environment Readiness
218
299
 
300
+ Install OS-level dependencies **before** uploading the project (independent of project code, can run in parallel if desired).
301
+
302
+ #### 3a: Detect OS and package manager
303
+
304
+ ```bash
305
+ source /etc/os-release 2>/dev/null
306
+ echo "OS_DETECTED=${ID:-unknown}|${ID_LIKE:-}"
307
+ if command -v apt-get >/dev/null 2>&1; then echo "PKG_MGR=apt"; elif command -v yum >/dev/null 2>&1; then echo "PKG_MGR=yum"; elif command -v dnf >/dev/null 2>&1; then echo "PKG_MGR=dnf"; elif command -v apk >/dev/null 2>&1; then echo "PKG_MGR=apk"; else echo "PKG_MGR=unknown"; fi
308
+ ```
309
+
310
+ Use the detected `PKG_MGR` for all package installations below.
311
+
312
+ #### 3b: Install nginx (before project upload)
313
+
314
+ ```bash
315
+ # Use the detected PKG_MGR from step 3a
316
+ case "$PKG_MGR" in
317
+ apt) sudo apt-get update -qq && sudo apt-get install -y -qq nginx ;;
318
+ yum) sudo yum install -y nginx ;;
319
+ dnf) sudo dnf install -y nginx ;;
320
+ esac
321
+ sudo nginx -t && echo "nginx: ready"
322
+ ```
323
+
324
+ If nginx cannot be installed, skip to Python HTTP server fallback (see `references/nginx-templates.md`).
325
+
326
+ #### 3c: Verify remaining tools
327
+
219
328
  Before installing project dependencies, verify the sandbox has the required runtime tools. Run this pre-flight check via `exec_one_shot`:
220
329
 
221
330
  ```bash
@@ -228,7 +337,6 @@ if command -v git >/dev/null 2>&1; then git --version; else echo "MISSING: git";
228
337
  if command -v python3 >/dev/null 2>&1; then python3 --version; else echo "MISSING: python3"; fi
229
338
  if command -v curl >/dev/null 2>&1; then curl --version | head -1; else echo "MISSING: curl"; fi
230
339
  if command -v wget >/dev/null 2>&1; then wget --version | head -1; else echo "MISSING: wget"; fi
231
- if dpkg -l build-essential >/dev/null 2>&1; then echo "build-essential: found"; else echo "MISSING: build-essential"; fi
232
340
  if command -v make >/dev/null 2>&1; then make --version | head -1; else echo "MISSING: make"; fi
233
341
 
234
342
  # Framework-specific tools (install on demand)
@@ -239,41 +347,120 @@ if command -v hugo >/dev/null 2>&1; then hugo version; else echo "MISSING: hugo"
239
347
  if command -v devbridge >/dev/null 2>&1; then devbridge version; else echo "MISSING: devbridge"; fi
240
348
  ```
241
349
 
242
- **Install only missing tools** — parse the pre-flight output and install only tools reported as `MISSING`. Skip tools already present:
243
-
244
- | Missing Tool | Install Command |
245
- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
246
- | Node.js | Follow [Node.js in the sandbox](#nodejs-in-the-sandbox) |
247
- | nginx | `sudo apt-get update -qq && sudo apt-get install -y -qq nginx` |
248
- | curl | `sudo apt-get update -qq && sudo apt-get install -y -qq curl` |
249
- | wget | `sudo apt-get update -qq && sudo apt-get install -y -qq wget` |
250
- | build-essential | `sudo apt-get update -qq && sudo apt-get install -y -qq build-essential` |
251
- | make | `sudo apt-get update -qq && sudo apt-get install -y -qq make` |
252
- | pnpm | `npm i -g pnpm` |
253
- | yarn | `npm i -g yarn` |
254
- | Hugo | `curl -fsSL https://github.com/gohugoio/hugo/releases/download/v0.140.0/hugo_extended_0.140.0_linux-amd64.tar.gz -o /tmp/hugo.tar.gz && sudo tar -xzf /tmp/hugo.tar.gz -C /usr/local/bin hugo && rm /tmp/hugo.tar.gz` |
255
- | DevBridge | `curl -fsSL https://res-hd.hc-cdn.cn/sharedata/hdspace/devbridge/install.sh \| bash && export PATH=$PATH:$HOME/.huawei/bin` |
350
+ **Install only missing tools** — parse the pre-flight output and install only tools reported as `MISSING`. Use OS-aware commands:
256
351
 
257
- **If nginx cannot be installed**, skip to Python HTTP server fallback (see `references/nginx-templates.md`).
352
+ | Missing Tool | Install Command (apt) | Install Command (yum/dnf) |
353
+ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
354
+ | Node.js | Follow [Node.js in the sandbox](#nodejs-in-the-sandbox) | Same |
355
+ | nginx | `sudo apt-get update -qq && sudo apt-get install -y -qq nginx` | `sudo yum install -y nginx` |
356
+ | curl | `sudo apt-get update -qq && sudo apt-get install -y -qq curl` | `sudo yum install -y curl` |
357
+ | wget | `sudo apt-get update -qq && sudo apt-get install -y -qq wget` | `sudo yum install -y wget` |
358
+ | make | `sudo apt-get update -qq && sudo apt-get install -y -qq make` | `sudo yum install -y make` |
359
+ | pnpm | `npm i -g pnpm` | Same |
360
+ | yarn | `npm i -g yarn` | Same |
361
+ | Hugo | `curl -fsSL https://github.com/gohugoio/hugo/releases/download/v0.140.0/hugo_extended_0.140.0_linux-amd64.tar.gz -o /tmp/hugo.tar.gz && sudo tar -xzf /tmp/hugo.tar.gz -C /usr/local/bin hugo && rm /tmp/hugo.tar.gz` | Same |
362
+ | DevBridge | `curl -fsSL https://res-hd.hc-cdn.cn/sharedata/hdspace/devbridge/install.sh \| bash && export PATH=$PATH:$HOME/.huawei/bin` | Same |
258
363
 
259
364
  **If Node.js is missing**, install it first — all build workflows depend on it. Stop and report to the developer if Node.js installation fails.
260
365
 
261
366
  ### Step 4: Install and Build
262
367
 
263
- Use `exec_one_shot` for the build pipeline (long-running, more stable).
368
+ #### 4a: Inject Environment Variables
264
369
 
265
- **Install dependencies** — skip if `node_modules` already exists:
370
+ Before any project commands, parse `.env*` files and inject them into the shell environment. Prisma, Drizzle, and other ORM/database tools do NOT auto-read framework-level env files:
371
+
372
+ ```bash
373
+ cd /workspace/<dirname>
374
+ # Load env files if present (most specific first)
375
+ for f in .env.local .env.development.local .env.development .env; do
376
+ if [ -f "$f" ]; then
377
+ set -a && source "$f" 2>/dev/null; set +a
378
+ echo "Loaded env: $f"
379
+ fi
380
+ done
381
+ # Verify key variables for common tools
382
+ echo "DATABASE_URL=${DATABASE_URL:-<NOT SET>}"
383
+ echo "NODE_ENV=${NODE_ENV:-development}"
384
+ ```
385
+
386
+ This must run via `exec_with_session` so the exported variables persist for subsequent build commands in the same session.
387
+
388
+ #### 4b: Install Dependencies
389
+
390
+ Use `exec_one_shot` for install (no shared state needed). Skip if `node_modules` already exists:
266
391
 
267
392
  ```bash
268
393
  cd /workspace/<dirname> && [ -d node_modules ] && echo "SKIP: node_modules exists" || <installCmd>
269
394
  ```
270
395
 
271
- Wait for install to complete, then:
396
+ Wait for install to complete. For large projects on aarch64 sandboxes (1000+ packages), set `timeout_ms` to 180000 (3 min).
397
+
398
+ #### 4c: Build
399
+
400
+ **Timeout strategy by framework type:**
401
+
402
+ | Type | timeout_ms | Rationale |
403
+ | ------------------------------ | --------------- | ------------------------------------- |
404
+ | SPA / SSG | 300000 (5 min) | Vite/Webpack builds typically < 3 min |
405
+ | Cross-platform (Taro, uni-app) | 900000 (15 min) | Webpack5 H5 slow on aarch64, 7-8 min |
406
+ | SSR (Next.js, Nuxt) | 600000 (10 min) | Full-stack compilation + SSG pages |
407
+ | Monorepo | 600000 (10 min) | Multiple apps, shared packages |
408
+ | `null` (no build) | N/A | Skip |
409
+
410
+ **Build with `exec_one_shot`:**
411
+
412
+ ```bash
413
+ cd /workspace/<dirname> && [ -d <outputDir> ] && echo "SKIP: <outputDir> exists" || (umask 022 && <buildCmd> 2>&1 | tee /tmp/build.log)
414
+ ```
415
+
416
+ Always pipe build output through `tee /tmp/build.log` — captures stderr+stdout so diagnostics are available even if the command times out.
272
417
 
273
- **Build** — only if build output doesn't already exist:
418
+ **OutDir verification**: before building for the first time, check the project's actual output directory (not just the default from framework detection). Projects can override outDir in config (e.g., VitePress `outDir: '../dist'`):
274
419
 
275
420
  ```bash
276
- cd /workspace/<dirname> && [ -d <outputDir> ] && echo "SKIP: <outputDir> exists" || (umask 022 && <buildCmd>)
421
+ # Check for custom outDir in common config files
422
+ grep -r "outDir\|outputDir\|dest\|distDir" /workspace/<dirname>/.vitepress/config.* 2>/dev/null || true
423
+ ```
424
+
425
+ If a custom outDir is found, use that instead of the framework-detected default for all subsequent checks.
426
+
427
+ **Post-timeout recovery**: if `exec_one_shot` returns a timeout error (Request timed out), do NOT fail immediately. First dump any captured build log, then check the output directory:
428
+
429
+ ```bash
430
+ # If timeout occurred, show captured output and verify build
431
+ if timeout_error; then
432
+ echo "=== Build log (tail) ==="
433
+ tail -30 /tmp/build.log 2>/dev/null
434
+ echo "=== Checking output ==="
435
+ if [ -d <outputDir> ] && [ "$(ls -A <outputDir> 2>/dev/null)" ]; then
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
444
+ else
445
+ echo "ERROR: Build did not complete. Output directory empty or missing."
446
+ echo "Full log: /tmp/build.log"
447
+ exit 1
448
+ fi
449
+ fi
450
+ ```
451
+
452
+ For SSR frameworks, also verify the server entry point exists: `test -f <outputDir>/server.js || test -f node_modules/next/dist/server/next-server.js`.
453
+
454
+ **Build progress visibility**: for very large builds, touch a marker file before starting and use `exec_with_session` to poll intermediate logs:
455
+
456
+ ```bash
457
+ # Before build:
458
+ touch /tmp/build-start && echo "Build started at $(date)"
459
+
460
+ # During build via exec_with_session (separate call for polling):
461
+ cat .next/trace 2>/dev/null | tail -5 # Next.js build trace
462
+ # or
463
+ tail -5 /tmp/build.log 2>/dev/null
277
464
  ```
278
465
 
279
466
  - `cd /workspace/<dirname>/<subAppPath>` for Monorepo sub-apps.
@@ -281,11 +468,55 @@ cd /workspace/<dirname> && [ -d <outputDir> ] && echo "SKIP: <outputDir> exists"
281
468
  - For Hugo/static sites where `installCmd` is `null`, skip install entirely.
282
469
  - For static sites where `buildCmd` is `null`, skip build entirely.
283
470
 
284
- **Real-time logging**: each `exec_one_shot` call returns stdout. Show build progress to the developer as it arrives.
471
+ ### Step 5: Port Availability Check
472
+
473
+ **Before configuring nginx or starting the app**, verify the target ports are free. Port conflicts from previous deployments cause silent failures:
474
+
475
+ ```bash
476
+ # Check ports from framework detection
477
+ check_port() {
478
+ PORT=$1
479
+ # Prefer lsof (most portable), fallback to netstat, then ss
480
+ if command -v lsof >/dev/null 2>&1; then
481
+ PID=$(lsof -ti :$PORT 2>/dev/null)
482
+ if [ -n "$PID" ]; then
483
+ echo "PORT_IN_USE:$PORT (PID=$PID)"
484
+ kill -9 $PID 2>/dev/null && echo "Killed PID $PID on port $PORT"
485
+ else
486
+ echo "PORT_FREE:$PORT"
487
+ fi
488
+ elif command -v netstat >/dev/null 2>&1; then
489
+ PID=$(netstat -tlnp 2>/dev/null | grep ":$PORT " | awk '{print $NF}' | sed 's|/.*||')
490
+ if [ -n "$PID" ] && [ "$PID" != "-" ]; then
491
+ echo "PORT_IN_USE:$PORT (PID=$PID)"
492
+ kill -9 $PID 2>/dev/null && echo "Killed PID $PID on port $PORT"
493
+ else
494
+ echo "PORT_FREE:$PORT"
495
+ fi
496
+ else
497
+ # Last resort: ss (iproute2)
498
+ PID=$(ss -tlnp 2>/dev/null | grep ":$PORT " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
499
+ if [ -n "$PID" ]; then
500
+ echo "PORT_IN_USE:$PORT (PID=$PID)"
501
+ kill -9 $PID 2>/dev/null && echo "Killed PID $PID on port $PORT"
502
+ else
503
+ echo "PORT_FREE:$PORT"
504
+ fi
505
+ fi
506
+ }
507
+ check_port <port>
508
+ # For SSR, also check the Node port
509
+ check_port <nodePort>
510
+ ```
511
+
512
+ | Scenario | Port | Action if occupied |
513
+ | ---------------------- | ------------------------------------ | -------------------------------------- |
514
+ | SPA/SSG/Cross-platform | nginx port (from `framework.detect`) | Kill old process, then configure nginx |
515
+ | SSR | nginx public port + Node app port | Kill old processes on both ports |
285
516
 
286
- **Timeout**: for large projects, set `timeout_ms` to 300000 (5 min) or higher.
517
+ If the port cannot be freed (different user/process), increment to the next available port: `<port>+1`, update all subsequent nginx config and DevBridge references accordingly.
287
518
 
288
- ### Step 5: Configure Nginx
519
+ #### Configure Nginx
289
520
 
290
521
  Check `references/nginx-templates.md` for the correct template based on `nginxType`:
291
522
 
@@ -311,7 +542,7 @@ If the status code is not 2xx/3xx:
311
542
  - **000 (connection refused)** — nginx not listening: check `sudo nginx -t` for config errors
312
543
  - **Other** — check nginx error log: `sudo tail -20 /var/log/nginx/error.log`
313
544
 
314
- > If `curl` is unavailable, check port listening via `/proc`: `cat /proc/net/tcp | awk '{print $2}' | grep -q "$(printf '%04X' <port>)" && echo "port <port> listening" || echo "port <port> NOT listening"`
545
+ > If `curl` is unavailable, check port via `lsof -i :<port>` or `netstat -tlnp | grep :<port>`
315
546
 
316
547
  ### Step 6: Start the App
317
548
 
@@ -324,9 +555,31 @@ Follow the standard [Expose the deployed app](#expose-the-deployed-app-public-ur
324
555
 
325
556
  Use `exec_with_session` to background DevBridge. For SSR, DevBridge tunnels the nginx public port (not the Node port directly).
326
557
 
558
+ **Pre-flight**: always run `devbridge delete-all` before creating a new tunnel to prevent `10006: quota exceeded` from accumulated stale tunnels. If you still get quota error, list tunnels with `devbridge ls --all`, delete stale ones, and retry.
559
+
327
560
  ### Step 8: Return URL
328
561
 
329
- Extract the tunnel URL from DevBridge output and return it to the developer. For cross-platform H5 apps, also mention QR code scanning on mobile.
562
+ Extract the tunnel URL from DevBridge output and return it to the developer.
563
+
564
+ **For cross-platform H5 apps** (Taro, uni-app), also generate a QR code for mobile scanning:
565
+
566
+ ```bash
567
+ TUNNEL_URL="<extracted-tunnel-url>"
568
+
569
+ # Method 1 (preferred): PNG via curl API (works on all terminals)
570
+ curl -s "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$TUNNEL_URL")" -o /workspace/qr.png
571
+ chmod o+r /workspace/qr.png
572
+ echo "QR code saved. Scan QR to access on mobile."
573
+ echo "Desktop URL: $TUNNEL_URL"
574
+
575
+ # Method 2 (fallback): terminal ANSI QR (requires qrencode, may not render on all terminals)
576
+ # apt-get install -y qrencode || yum install -y qrencode
577
+ # qrencode -t ANSI256 -m 1 -s 2 "$TUNNEL_URL"
578
+ ```
579
+
580
+ If the sandbox cannot reach `api.qrserver.com`, fall back to installing `qrencode` for terminal QR output. Always `chmod o+r` the generated QR image file.
581
+
582
+ Return both the QR code and the URL to the developer. For cross-platform apps, mention: "手机扫描二维码即可访问".
330
583
 
331
584
  ## References
332
585
 
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: huawei-voucher
3
+ description: '查询和领取华为云激励代金券(一人一次)。当用户询问插件能力、首次使用,或提到"领券/代金券/优惠券/激励金/领取"等话题时,先查询领取状态:未领取(claimed=false)则主动提示可领取,已领取(claimed=true)则不打扰。Triggers: voucher, coupon, incentive, 领券, 代金券, 优惠券, 激励金, 领取. NOT for: 账单/费用查询(用 huawei-billing)。'
4
+ version: 1
5
+ ---
6
+
7
+ # 华为云激励代金券
8
+
9
+ ## 主动提示准则(核心)
10
+
11
+ 此能力仅在用户尚未领取时才有价值,需避免已领取用户被反复打扰:
12
+
13
+ 1. 当用户**询问插件能力 / 首次使用 / 提到福利领取类话题**时,先调用 `huaweicloud_voucher_status` 查询领取状态。
14
+ 2. 若 `claimed=false` → 主动告知"可领取华为云代金券(一人一次)",并询问是否现在领取。
15
+ 3. 若 `claimed=true` → 静默处理,不再提及代金券,不打扰用户。
16
+
17
+ ## 流程
18
+
19
+ ### 1. 查询状态
20
+
21
+ 调用 `huaweicloud_voucher_status`(生产环境无需 `domain_id`,测试环境需传 `domain_id`)。
22
+
23
+ 返回 `{ claimed, message }`:
24
+
25
+ - `claimed=true` + `message="已领取"` → 已领取,告知结果即可,不再引导
26
+ - `claimed=false` + `message="未领取"` → 未领取,可引导领取
27
+
28
+ ### 2. 领取代金券
29
+
30
+ 调用 `huaweicloud_voucher_claim`(生产环境无需 `domain_id`)。
31
+
32
+ 返回 `{ claimed, voucherId, amount, message }`。
33
+
34
+ ### 3. 结果解读(错误码 UX)
35
+
36
+ | 返回 message 关键特征 | 含义 | 对用户表述 |
37
+ | -------------------------------------------- | --------------------------- | -------------------- |
38
+ | `领取成功` | 发券成功 | 告知券已到账 |
39
+ | `已领取过` / `已领取` | 该账号已领过 | 告知已领取,不打扰 |
40
+ | `本月代金券总额度已用完,所有账号均无法领取` | 全局额度用尽(HD.60630042) | 告知下月再试 |
41
+ | `请先完成实名认证` | 账号未实名(HD.60630022) | 引导去控制台实名认证 |
42
+ | `发券失败: <原文>` | 其他错误 | 原样转达,不展开 |
43
+
44
+ ## 环境注意
45
+
46
+ - 生产环境:`voucher_status` / `voucher_claim` 无需传 `domain_id`,后端从 IAM 自动解析账号。
47
+ - 测试环境:需传 `domain_id`(华为云账号 ID);未传时返回 `测试环境需提供 domain_id`,此时可用 `hcloud IAM KeystoneListAuthDomains` 查询账号 ID 后补传。
48
+
49
+ ## 注意事项
50
+
51
+ - 每个账号(domain_id)仅可领取一次,重复调用返回已领取。
52
+ - 领取结果以激励服务返回为准,不要凭本地认知猜测。
@@ -43,6 +43,7 @@ Do not rely on training data for facts. Huawei Cloud services, pricing, quotas,
43
43
  | troubleshoot | Troubleshooting Routing | error, bug, failed, AccessDenied, quota | Handoff to huaweicloud-troubleshooting |
44
44
  | api-sdk | API/SDK Routing | API call, SDK, REST, integration, code example | Handoff to huaweicloud-api-and-sdk |
45
45
  | report-issue | Issue Reporting | wrong service, you picked wrong, incorrect | Read references/report-issue.md |
46
+ | voucher | Voucher Routing | 领券, 代金券, 优惠券, 激励金, 领取, voucher, coupon, incentive | Handoff to huawei-voucher |
46
47
 
47
48
  ## Service Map
48
49
 
@@ -70,6 +71,7 @@ Do not rely on training data for facts. Huawei Cloud services, pricing, quotas,
70
71
  | CI/CD pipeline | CloudDeploy | huawei-deployment |
71
72
  | Temporary runtime / web app preview | Sandbox (DevStation) | huawei-sandbox |
72
73
  | Getting started | Account setup | huaweicloud-cli-and-auth |
74
+ | Incentive voucher | Voucher | huawei-voucher |
73
75
 
74
76
  **Web app scenario layering**: for "deploy a web app", prefer `huawei-sandbox` for free/quick try (hello world, prototype, demo, temporary preview — temporary runtime + public URL, ~8h validity, zero billed resources); route to `huawei-functiongraph` / `huawei-ecs` (or `huawei-cce`) for production / long-term / custom-domain / high-availability hosting.
75
77
 
@@ -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
 
@@ -648,7 +648,7 @@ export const TOOL_DEFINITIONS = [
648
648
  },
649
649
  {
650
650
  name: 'huaweicloud_voucher_status',
651
- description: 'Query the Huawei Cloud incentive voucher claim status.',
651
+ description: '查询代金券领取状态。',
652
652
  inputSchema: {
653
653
  type: 'object',
654
654
  properties: {
@@ -661,7 +661,7 @@ export const TOOL_DEFINITIONS = [
661
661
  },
662
662
  {
663
663
  name: 'huaweicloud_voucher_claim',
664
- description: 'Claim the Huawei Cloud incentive voucher. Automatically checks if already claimed.',
664
+ description: '领取代金券(一人一次)。重复领取会返回已领取。',
665
665
  inputSchema: {
666
666
  type: 'object',
667
667
  properties: {
@@ -1190,6 +1190,11 @@ function serviceCatalog(intent = '') {
1190
1190
  skills: ['huawei-dds-dcs'],
1191
1191
  services: ['DDS', 'DCS'],
1192
1192
  },
1193
+ {
1194
+ keywords: ['voucher', 'coupon', 'incentive', 'credit', '领券', '代金券', '优惠券', '激励金', '领取'],
1195
+ skills: ['huawei-voucher'],
1196
+ services: ['Incentive Voucher'],
1197
+ },
1193
1198
  ];
1194
1199
  const matched = [];
1195
1200
  const tokens = new Set(it.split(/[\s,./-]+/).filter((t) => t.length > 0));
@@ -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')) {