rainskills 0.1.0-rc.0

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.
Files changed (133) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +374 -0
  3. package/bin/rainskills.js +71 -0
  4. package/install.sh +1919 -0
  5. package/package.json +55 -0
  6. package/rainbond-app-assistant/SKILL.md +1319 -0
  7. package/rainbond-app-assistant/evals/01-linked-topology-missing.expected.yaml +21 -0
  8. package/rainbond-app-assistant/evals/01-linked-topology-missing.response.md +53 -0
  9. package/rainbond-app-assistant/evals/02-linked-topology-building.expected.yaml +19 -0
  10. package/rainbond-app-assistant/evals/02-linked-topology-building.response.md +53 -0
  11. package/rainbond-app-assistant/evals/03-runtime-healthy-delivery-unknown.expected.yaml +19 -0
  12. package/rainbond-app-assistant/evals/03-runtime-healthy-delivery-unknown.response.md +53 -0
  13. package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.expected.yaml +21 -0
  14. package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.response.md +58 -0
  15. package/rainbond-app-assistant/evals/05-delivered-explicit-dev-to-test.expected.yaml +21 -0
  16. package/rainbond-app-assistant/evals/05-delivered-explicit-dev-to-test.response.md +80 -0
  17. package/rainbond-app-assistant/evals/06-manual-validation-stop.expected.yaml +16 -0
  18. package/rainbond-app-assistant/evals/06-manual-validation-stop.response.md +58 -0
  19. package/rainbond-app-assistant/evals/07-cluster-capacity-blocked.expected.yaml +15 -0
  20. package/rainbond-app-assistant/evals/07-cluster-capacity-blocked.response.md +53 -0
  21. package/rainbond-app-assistant/evals/08-code-build-handoff-stop.expected.yaml +21 -0
  22. package/rainbond-app-assistant/evals/08-code-build-handoff-stop.response.md +56 -0
  23. package/rainbond-app-assistant/evals/09-source-backed-failure-no-fallback.expected.yaml +22 -0
  24. package/rainbond-app-assistant/evals/09-source-backed-failure-no-fallback.response.md +56 -0
  25. package/rainbond-app-assistant/evals/10-identity-ambiguous-stop.expected.yaml +21 -0
  26. package/rainbond-app-assistant/evals/10-identity-ambiguous-stop.response.md +45 -0
  27. package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.expected.yaml +28 -0
  28. package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.response.md +64 -0
  29. package/rainbond-app-assistant/evals/12-complex-suite-needs-descriptor-stop.expected.yaml +24 -0
  30. package/rainbond-app-assistant/evals/12-complex-suite-needs-descriptor-stop.response.md +45 -0
  31. package/rainbond-app-assistant/evals/13-explicit-git-url-locks-source.expected.yaml +28 -0
  32. package/rainbond-app-assistant/evals/13-explicit-git-url-locks-source.response.md +53 -0
  33. package/rainbond-app-assistant/evals/14-strategy-switch-cleans-residue.expected.yaml +22 -0
  34. package/rainbond-app-assistant/evals/14-strategy-switch-cleans-residue.response.md +53 -0
  35. package/rainbond-app-assistant/evals/15-turn-discipline-reuse-alias.expected.yaml +23 -0
  36. package/rainbond-app-assistant/evals/15-turn-discipline-reuse-alias.response.md +53 -0
  37. package/rainbond-app-assistant/schemas/app-assistant-result.schema.yaml +279 -0
  38. package/rainbond-app-assistant/scripts/run_app_assistant_evals.py +80 -0
  39. package/rainbond-app-assistant/scripts/validate_app_assistant_output.py +935 -0
  40. package/rainbond-app-version-assistant/SKILL.md +444 -0
  41. package/rainbond-delivery-verifier/SKILL.md +372 -0
  42. package/rainbond-delivery-verifier/evals/01-delivered-verified.expected.yaml +19 -0
  43. package/rainbond-delivery-verifier/evals/01-delivered-verified.response.md +31 -0
  44. package/rainbond-delivery-verifier/evals/02-delivered-manual-validation.expected.yaml +19 -0
  45. package/rainbond-delivery-verifier/evals/02-delivered-manual-validation.response.md +31 -0
  46. package/rainbond-delivery-verifier/evals/03-partially-delivered-building.expected.yaml +20 -0
  47. package/rainbond-delivery-verifier/evals/03-partially-delivered-building.response.md +31 -0
  48. package/rainbond-delivery-verifier/evals/04-blocked-capacity.expected.yaml +18 -0
  49. package/rainbond-delivery-verifier/evals/04-blocked-capacity.response.md +31 -0
  50. package/rainbond-delivery-verifier/evals/05-blocked-no-usable-url.expected.yaml +20 -0
  51. package/rainbond-delivery-verifier/evals/05-blocked-no-usable-url.response.md +31 -0
  52. package/rainbond-delivery-verifier/evals/06-blocked-runtime-unhealthy.expected.yaml +19 -0
  53. package/rainbond-delivery-verifier/evals/06-blocked-runtime-unhealthy.response.md +31 -0
  54. package/rainbond-delivery-verifier/schemas/delivery-verification-result.schema.yaml +84 -0
  55. package/rainbond-delivery-verifier/scripts/run_delivery_verifier_evals.py +78 -0
  56. package/rainbond-delivery-verifier/scripts/validate_delivery_verifier_output.py +728 -0
  57. package/rainbond-env-sync/SKILL.md +496 -0
  58. package/rainbond-fullstack-bootstrap/SKILL.md +262 -0
  59. package/rainbond-fullstack-bootstrap/evals/01-reuse-only.expected.yaml +27 -0
  60. package/rainbond-fullstack-bootstrap/evals/01-reuse-only.prompt.md +1 -0
  61. package/rainbond-fullstack-bootstrap/evals/01-reuse-only.response.md +43 -0
  62. package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.expected.yaml +27 -0
  63. package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.prompt.md +1 -0
  64. package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.response.md +44 -0
  65. package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.expected.yaml +26 -0
  66. package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.prompt.md +1 -0
  67. package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.response.md +43 -0
  68. package/rainbond-fullstack-bootstrap/evals/04-template-skip.expected.yaml +31 -0
  69. package/rainbond-fullstack-bootstrap/evals/04-template-skip.prompt.md +1 -0
  70. package/rainbond-fullstack-bootstrap/evals/04-template-skip.response.md +43 -0
  71. package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.expected.yaml +33 -0
  72. package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.prompt.md +1 -0
  73. package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.response.md +43 -0
  74. package/rainbond-fullstack-bootstrap/evals/06-external-artifact-unreachable.expected.yaml +23 -0
  75. package/rainbond-fullstack-bootstrap/evals/06-external-artifact-unreachable.response.md +39 -0
  76. package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.expected.yaml +29 -0
  77. package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.response.md +43 -0
  78. package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.expected.yaml +30 -0
  79. package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.prompt.md +1 -0
  80. package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.response.md +43 -0
  81. package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.expected.yaml +32 -0
  82. package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.prompt.md +1 -0
  83. package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.response.md +41 -0
  84. package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.expected.yaml +34 -0
  85. package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.prompt.md +1 -0
  86. package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.response.md +43 -0
  87. package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.expected.yaml +46 -0
  88. package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.prompt.md +1 -0
  89. package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.response.md +48 -0
  90. package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.expected.yaml +43 -0
  91. package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.prompt.md +1 -0
  92. package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.response.md +63 -0
  93. package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.expected.yaml +47 -0
  94. package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.prompt.md +1 -0
  95. package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.response.md +60 -0
  96. package/rainbond-fullstack-bootstrap/modules/10-context-loading.md +157 -0
  97. package/rainbond-fullstack-bootstrap/modules/20-scope-and-boundaries.md +105 -0
  98. package/rainbond-fullstack-bootstrap/modules/30-creation-rules.md +337 -0
  99. package/rainbond-fullstack-bootstrap/modules/40-source-and-package-rules.md +245 -0
  100. package/rainbond-fullstack-bootstrap/modules/50-workflow-and-convergence.md +198 -0
  101. package/rainbond-fullstack-bootstrap/modules/60-verification-and-handoffs.md +143 -0
  102. package/rainbond-fullstack-bootstrap/modules/70-output-contract.md +152 -0
  103. package/rainbond-fullstack-bootstrap/references/manifest-v1-reference.md +150 -0
  104. package/rainbond-fullstack-bootstrap/references/quick-reference.md +101 -0
  105. package/rainbond-fullstack-bootstrap/references/source-build-parameter-guide.md +264 -0
  106. package/rainbond-fullstack-bootstrap/references/sql-init-recipe.md +83 -0
  107. package/rainbond-fullstack-bootstrap/schemas/bootstrap-result.schema.yaml +217 -0
  108. package/rainbond-fullstack-bootstrap/scripts/run_bootstrap_evals.py +74 -0
  109. package/rainbond-fullstack-bootstrap/scripts/validate_bootstrap_output.py +615 -0
  110. package/rainbond-fullstack-troubleshooter/SKILL.md +870 -0
  111. package/rainbond-fullstack-troubleshooter/evals/01-source-build-failed.expected.yaml +29 -0
  112. package/rainbond-fullstack-troubleshooter/evals/01-source-build-failed.response.md +55 -0
  113. package/rainbond-fullstack-troubleshooter/evals/02-source-build-still-running.expected.yaml +20 -0
  114. package/rainbond-fullstack-troubleshooter/evals/02-source-build-still-running.response.md +53 -0
  115. package/rainbond-fullstack-troubleshooter/evals/03-dependency-missing.expected.yaml +21 -0
  116. package/rainbond-fullstack-troubleshooter/evals/03-dependency-missing.response.md +59 -0
  117. package/rainbond-fullstack-troubleshooter/evals/04-wrong-connection-values.expected.yaml +19 -0
  118. package/rainbond-fullstack-troubleshooter/evals/04-wrong-connection-values.response.md +58 -0
  119. package/rainbond-fullstack-troubleshooter/evals/05-env-naming-incompatibility.expected.yaml +18 -0
  120. package/rainbond-fullstack-troubleshooter/evals/05-env-naming-incompatibility.response.md +59 -0
  121. package/rainbond-fullstack-troubleshooter/evals/06-frontend-access-path-issue.expected.yaml +21 -0
  122. package/rainbond-fullstack-troubleshooter/evals/06-frontend-access-path-issue.response.md +56 -0
  123. package/rainbond-fullstack-troubleshooter/evals/07-cluster-capacity-blocked.expected.yaml +20 -0
  124. package/rainbond-fullstack-troubleshooter/evals/07-cluster-capacity-blocked.response.md +57 -0
  125. package/rainbond-fullstack-troubleshooter/evals/08-code-or-build-handoff-needed.expected.yaml +21 -0
  126. package/rainbond-fullstack-troubleshooter/evals/08-code-or-build-handoff-needed.response.md +55 -0
  127. package/rainbond-fullstack-troubleshooter/evals/09-external-artifact-unreachable.expected.yaml +28 -0
  128. package/rainbond-fullstack-troubleshooter/evals/09-external-artifact-unreachable.response.md +53 -0
  129. package/rainbond-fullstack-troubleshooter/schemas/troubleshoot-result.schema.yaml +217 -0
  130. package/rainbond-fullstack-troubleshooter/scripts/run_troubleshooter_evals.py +80 -0
  131. package/rainbond-fullstack-troubleshooter/scripts/validate_troubleshoot_output.py +789 -0
  132. package/rainbond-project-init/SKILL.md +1001 -0
  133. package/rainbond-template-installer/SKILL.md +410 -0
package/install.sh ADDED
@@ -0,0 +1,1919 @@
1
+ #!/usr/bin/env bash
2
+ # Rainbond Skills installer — packaged for npx with a CDN fallback:
3
+ # npx --yes rainskills
4
+ # bash <(curl -fsSL https://get.rainbond.com/rainskills/install.sh)
5
+ #
6
+ # AI assistants: when a user asks to install rainbond-skills, hand back the
7
+ # npx command when Node.js 18+ is available, otherwise use the CDN command.
8
+ # Do not reimplement the bootstrap below, git clone + copy directories, or
9
+ # hand-write ~/.rainbond/mcp.env. See README.md "给 AI 助手的指引".
10
+ set -euo pipefail
11
+
12
+ RAINBOND_SKILLS_HOME_DEFAULT="${RAINBOND_SKILLS_HOME:-$HOME/.rainbond/skills}"
13
+ RAINBOND_SKILLS_OSS_TARBALL_URL="${RAINBOND_SKILLS_OSS_URL:-https://get.rainbond.com/rainskills/rainskills-latest.tar.gz}"
14
+ RAINBOND_SKILLS_GITHUB_TARBALL_URL="https://github.com/goodrain/rainskills/archive/refs/heads/main.tar.gz"
15
+ RAINBOND_SKILLS_TARBALL_URL_OVERRIDE="${RAINBOND_SKILLS_TARBALL_URL:-}"
16
+
17
+ bootstrap_log() {
18
+ printf '%s\n' "$1"
19
+ }
20
+
21
+ bootstrap_die() {
22
+ printf '错误:%s\n' "$1" >&2
23
+ exit 1
24
+ }
25
+
26
+ resolve_script_dir() {
27
+ local source="${BASH_SOURCE[0]:-}"
28
+ if [[ -n "$source" && -f "$source" ]]; then
29
+ (cd "$(dirname "$source")" && pwd)
30
+ else
31
+ printf ''
32
+ fi
33
+ }
34
+
35
+ try_download_tarball() {
36
+ local url="$1"
37
+ local out="$2"
38
+ [[ -n "$url" ]] || return 1
39
+ bootstrap_log "尝试下载:$url"
40
+ if curl -fsSL --connect-timeout 10 --max-time 120 "$url" -o "$out"; then
41
+ [[ -s "$out" ]] && return 0
42
+ fi
43
+ return 1
44
+ }
45
+
46
+ bootstrap_download_if_needed() {
47
+ local script_dir
48
+ script_dir="$(resolve_script_dir)"
49
+
50
+ if [[ -n "$script_dir" ]] && find "$script_dir" -maxdepth 1 -mindepth 1 -type d -name 'rainbond-*' 2>/dev/null | grep -q .; then
51
+ SCRIPT_DIR="$script_dir"
52
+ return 0
53
+ fi
54
+
55
+ command -v curl >/dev/null 2>&1 \
56
+ || bootstrap_die "需要 curl 才能下载 rainskills 仓库。请先安装 curl。"
57
+ command -v tar >/dev/null 2>&1 \
58
+ || bootstrap_die "需要 tar 才能解压 rainskills 仓库。"
59
+
60
+ local install_root="$RAINBOND_SKILLS_HOME_DEFAULT"
61
+ local tarball="${install_root}.download.tar.gz"
62
+
63
+ mkdir -p "$(dirname "$install_root")"
64
+ mkdir -p "$install_root"
65
+ rm -f "$tarball"
66
+
67
+ # 下载源优先级:用户显式覆盖 > OSS(国内快) > GitHub(海外/兜底)
68
+ local downloaded_from=""
69
+ for candidate in \
70
+ "$RAINBOND_SKILLS_TARBALL_URL_OVERRIDE" \
71
+ "$RAINBOND_SKILLS_OSS_TARBALL_URL" \
72
+ "$RAINBOND_SKILLS_GITHUB_TARBALL_URL"; do
73
+ if try_download_tarball "$candidate" "$tarball"; then
74
+ downloaded_from="$candidate"
75
+ break
76
+ fi
77
+ done
78
+
79
+ if [[ -z "$downloaded_from" ]]; then
80
+ bootstrap_die "所有 tarball 源都拉不下来。可手工执行:
81
+ curl -fsSL $RAINBOND_SKILLS_GITHUB_TARBALL_URL | tar -xz --strip-components=1 -C $install_root"
82
+ fi
83
+
84
+ bootstrap_log "解压到:${install_root}(来源:${downloaded_from})"
85
+ tar -xzf "$tarball" --strip-components=1 -C "$install_root" \
86
+ || bootstrap_die "解压 tarball 失败:$tarball"
87
+ rm -f "$tarball"
88
+
89
+ local target_script="$install_root/install.sh"
90
+ [[ -f "$target_script" ]] \
91
+ || bootstrap_die "下载后未找到 $target_script"
92
+ chmod +x "$target_script" 2>/dev/null || true
93
+
94
+ bootstrap_log "切换到 $target_script 继续执行……"
95
+ if [[ -r /dev/tty ]]; then
96
+ exec bash "$target_script" "$@" </dev/tty
97
+ else
98
+ exec bash "$target_script" "$@"
99
+ fi
100
+ }
101
+
102
+ should_skip_bootstrap_for_refresh() {
103
+ local arg
104
+ for arg in "$@"; do
105
+ case "$arg" in
106
+ refresh)
107
+ return 0
108
+ ;;
109
+ --)
110
+ return 1
111
+ ;;
112
+ esac
113
+ done
114
+ return 1
115
+ }
116
+
117
+ if should_skip_bootstrap_for_refresh "$@"; then
118
+ SCRIPT_DIR=""
119
+ else
120
+ bootstrap_download_if_needed "$@"
121
+ fi
122
+
123
+ DEFAULT_TARGET="all"
124
+ ACTION=""
125
+ TARGET=""
126
+ FORCE=0
127
+ CUSTOM_DEST=""
128
+ INSTALL_COUNT_NEW=0
129
+ INSTALL_COUNT_UPDATED=0
130
+ INSTALL_COUNT_UNCHANGED=0
131
+ INSTALL_COUNT_FORCED=0
132
+ SKIP_MCP=0
133
+ NON_INTERACTIVE=0
134
+ ALLOW_INSECURE_HTTP=0
135
+ RAINBOND_URL_INPUT="${RAINBOND_URL:-}"
136
+ RAINBOND_USERNAME_INPUT="${RAINBOND_USERNAME:-}"
137
+ RAINBOND_PASSWORD_INPUT="${RAINBOND_PASSWORD:-}"
138
+ RAINBOND_TOKEN_INPUT="${RAINBOND_JWT:-}"
139
+ RAINBOND_TOKEN_FROM_FLAG=0
140
+ RAINBOND_URL_FROM_FLAG=0
141
+ RAINBOND_CACHED_URL="${RAINBOND_URL:-}"
142
+ DEPLOYMENT_MODE_INPUT=""
143
+ SAAS_DEFAULT_URL="https://run.rainbond.com"
144
+ LOGIN_TIMEOUT="${RAINBOND_LOGIN_TIMEOUT:-300}"
145
+ JWT_MIN_TTL_SECONDS="${RAINBOND_JWT_MIN_TTL_SECONDS:-600}"
146
+ ACTIVE_SHELL_RC=""
147
+ VALIDATED_TOKEN=""
148
+ OBTAINED_RAINBOND_TOKEN=""
149
+ RAINSKILLS_INSTALL_REPORT_URL="https://log.rainbond.com/api/rainskills/installations"
150
+ RAINSKILLS_INSTALL_ATTEMPT_ID=""
151
+ RAINSKILLS_INSTALL_EID=""
152
+ RAINSKILLS_INSTALL_CLIENT="unknown"
153
+ RAINSKILLS_INSTALL_ACTION="install"
154
+ RAINSKILLS_INSTALL_FAILURE_STAGE="bootstrap"
155
+ RAINSKILLS_INSTALL_FAILURE_CATEGORY="invalid_arguments"
156
+ RAINSKILLS_INSTALL_TERMINAL_REPORTED=0
157
+ RAINSKILLS_BROWSER_LOGIN_SERVER_PID=""
158
+ RAINSKILLS_BROWSER_LOGIN_READER_PID=""
159
+ RAINSKILLS_BROWSER_LOGIN_RESULT_FILE=""
160
+
161
+ usage() {
162
+ cat <<'EOF'
163
+ Usage:
164
+ npx --yes rainskills [target] [options]
165
+ ./install.sh
166
+ ./install.sh claude
167
+ ./install.sh codex
168
+ ./install.sh all
169
+ ./install.sh --dest <path>
170
+ ./install.sh all --saas
171
+ ./install.sh all --self-hosted --rainbond-url <url>
172
+ ./install.sh all --non-interactive --rainbond-url <url> --token <jwt>
173
+ ./install.sh refresh
174
+
175
+ Options:
176
+ claude Install and configure Claude Code
177
+ codex Install and configure Codex
178
+ all Install and configure both platforms
179
+ refresh Re-run browser login and rewrite ~/.rainbond/mcp.env only
180
+ (skips skill copy and Codex/Claude MCP re-registration;
181
+ use when MCP returns 401/403 because the JWT expired —
182
+ remember to restart Claude Code / Codex afterwards)
183
+ --dest PATH Install skills to a custom directory only
184
+ --force Overwrite existing installed skills
185
+ --skip-mcp Skip Rainbond MCP setup
186
+ --saas Use Rainbond Cloud (https://run.rainbond.com)
187
+ --self-hosted Use a self-hosted Rainbond Console (requires --rainbond-url)
188
+ --non-interactive Require all installer inputs through flags or env vars
189
+ --rainbond-url URL Rainbond base URL, for example http://example.com:7070
190
+ --token JWT Use an existing Rainbond JWT, skip browser login
191
+ --no-cached-token Ignore RAINBOND_JWT inherited from the shell and re-login
192
+ --username NAME Legacy: Rainbond login username (self-hosted only)
193
+ --allow-insecure-http Allow plain HTTP for internal trial environments
194
+ -h, --help Show this help message
195
+
196
+ Environment:
197
+ RAINBOND_URL Same as --rainbond-url
198
+ RAINBOND_JWT Same as --token (preferred for CI)
199
+ RAINBOND_USERNAME Legacy: same as --username
200
+ RAINBOND_PASSWORD Legacy: Rainbond login password for non-interactive runs
201
+ RAINBOND_LOGIN_TIMEOUT Browser login timeout in seconds (default 300)
202
+ EOF
203
+ }
204
+
205
+ log() {
206
+ printf '%s\n' "$1"
207
+ }
208
+
209
+ warn() {
210
+ printf '警告:%s\n' "$1" >&2
211
+ }
212
+
213
+ new_rainskills_install_attempt_id() {
214
+ if command -v python3 >/dev/null 2>&1; then
215
+ python3 - <<'PY'
216
+ import uuid
217
+
218
+ print(uuid.uuid4().hex)
219
+ PY
220
+ return 0
221
+ fi
222
+ printf '%s-%s-%s\n' "$(date +%s)" "$$" "${RANDOM:-0}"
223
+ }
224
+
225
+ rainskills_install_client_for_target() {
226
+ case "$1" in
227
+ codex)
228
+ printf 'codex\n'
229
+ ;;
230
+ claude)
231
+ printf 'claude_code\n'
232
+ ;;
233
+ all)
234
+ printf 'both\n'
235
+ ;;
236
+ *)
237
+ printf 'unknown\n'
238
+ ;;
239
+ esac
240
+ }
241
+
242
+ report_rainskills_installation() {
243
+ local phase="$1"
244
+ local status="$2"
245
+ local failure_stage="${3:-}"
246
+ local failure_category="${4:-}"
247
+
248
+ [[ -n "$RAINSKILLS_INSTALL_ATTEMPT_ID" ]] || return 0
249
+ if [[ "$phase" == "authorized" || "$phase" == "configured" ]]; then
250
+ [[ -n "$RAINSKILLS_INSTALL_EID" ]] || return 0
251
+ fi
252
+ command -v curl >/dev/null 2>&1 || return 0
253
+ command -v python3 >/dev/null 2>&1 || return 0
254
+
255
+ local payload
256
+ payload="$(
257
+ python3 - \
258
+ "$RAINSKILLS_INSTALL_ATTEMPT_ID" \
259
+ "$RAINSKILLS_INSTALL_EID" \
260
+ "$RAINSKILLS_INSTALL_CLIENT" \
261
+ "$RAINSKILLS_INSTALL_ACTION" \
262
+ "$phase" \
263
+ "$status" \
264
+ "$failure_stage" \
265
+ "$failure_category" <<'PY'
266
+ import json
267
+ import sys
268
+
269
+ keys = (
270
+ "install_attempt_id",
271
+ "eid",
272
+ "install_client",
273
+ "action",
274
+ "phase",
275
+ "status",
276
+ "failure_stage",
277
+ "failure_category",
278
+ )
279
+ print(json.dumps(dict(zip(keys, sys.argv[1:])), separators=(",", ":")))
280
+ PY
281
+ )" || return 0
282
+
283
+ (
284
+ curl \
285
+ --silent \
286
+ --show-error \
287
+ --connect-timeout 2 \
288
+ --max-time 3 \
289
+ -X POST \
290
+ "$RAINSKILLS_INSTALL_REPORT_URL" \
291
+ -H 'Content-Type: application/json' \
292
+ --data-binary "$payload" \
293
+ >/dev/null 2>&1 || true
294
+ ) &
295
+ }
296
+
297
+ initialize_rainskills_installation_reporting() {
298
+ local arg target=""
299
+ RAINSKILLS_INSTALL_ATTEMPT_ID="$(new_rainskills_install_attempt_id)"
300
+ for arg in "$@"; do
301
+ case "$arg" in
302
+ refresh)
303
+ RAINSKILLS_INSTALL_ACTION="refresh"
304
+ ;;
305
+ codex|claude|all)
306
+ target="$arg"
307
+ ;;
308
+ esac
309
+ done
310
+ RAINSKILLS_INSTALL_CLIENT="$(rainskills_install_client_for_target "$target")"
311
+ report_rainskills_installation "started" "started"
312
+ }
313
+
314
+ resolve_rainskills_enterprise_id() {
315
+ local base_url="$1"
316
+ local token="$2"
317
+ local response_file http_code eid
318
+ response_file="$(mktemp)"
319
+ http_code="$({
320
+ curl \
321
+ --silent \
322
+ --show-error \
323
+ --connect-timeout 2 \
324
+ --max-time 5 \
325
+ --output "$response_file" \
326
+ --write-out '%{http_code}' \
327
+ "${base_url}/console/users/details" \
328
+ -H "Authorization: GRJWT ${token}"
329
+ } 2>/dev/null || true)"
330
+
331
+ if [[ ! "$http_code" =~ ^2 ]]; then
332
+ rm -f "$response_file"
333
+ return 1
334
+ fi
335
+
336
+ eid="$({
337
+ python3 - "$response_file" <<'PY'
338
+ import json
339
+ import sys
340
+
341
+ try:
342
+ with open(sys.argv[1], "r", encoding="utf-8") as fh:
343
+ payload = json.load(fh)
344
+ except Exception:
345
+ raise SystemExit(1)
346
+
347
+ eid = (((payload.get("data") or {}).get("bean") or {}).get("enterprise_id"))
348
+ if not isinstance(eid, str) or not eid.strip():
349
+ raise SystemExit(1)
350
+ print(eid.strip())
351
+ PY
352
+ } 2>/dev/null || true)"
353
+ rm -f "$response_file"
354
+ [[ -n "$eid" ]] || return 1
355
+ printf '%s\n' "$eid"
356
+ }
357
+
358
+ record_rainskills_authorization() {
359
+ local base_url="$1"
360
+ local token="$2"
361
+ local eid
362
+ eid="$(resolve_rainskills_enterprise_id "$base_url" "$token" || true)"
363
+ [[ -n "$eid" ]] || return 0
364
+ RAINSKILLS_INSTALL_EID="$eid"
365
+ report_rainskills_installation "authorized" "success"
366
+ }
367
+
368
+ set_rainskills_failure_context() {
369
+ RAINSKILLS_INSTALL_FAILURE_STAGE="$1"
370
+ RAINSKILLS_INSTALL_FAILURE_CATEGORY="$2"
371
+ }
372
+
373
+ report_unhandled_rainskills_installation_failure() {
374
+ local exit_status="$1"
375
+ if [[ "$exit_status" -ne 0 && \
376
+ "${BASH_SUBSHELL:-0}" -eq 0 && \
377
+ "$RAINSKILLS_INSTALL_TERMINAL_REPORTED" -eq 0 ]]; then
378
+ RAINSKILLS_INSTALL_TERMINAL_REPORTED=1
379
+ report_rainskills_installation \
380
+ "failed" \
381
+ "failure" \
382
+ "$RAINSKILLS_INSTALL_FAILURE_STAGE" \
383
+ "$RAINSKILLS_INSTALL_FAILURE_CATEGORY"
384
+ fi
385
+ }
386
+
387
+ cleanup_browser_login() {
388
+ local process_id
389
+
390
+ for process_id in \
391
+ "$RAINSKILLS_BROWSER_LOGIN_READER_PID" \
392
+ "$RAINSKILLS_BROWSER_LOGIN_SERVER_PID"; do
393
+ if [[ -n "$process_id" ]] && kill -0 "$process_id" 2>/dev/null; then
394
+ kill "$process_id" 2>/dev/null || true
395
+ fi
396
+ done
397
+
398
+ for process_id in \
399
+ "$RAINSKILLS_BROWSER_LOGIN_READER_PID" \
400
+ "$RAINSKILLS_BROWSER_LOGIN_SERVER_PID"; do
401
+ if [[ -n "$process_id" ]]; then
402
+ wait "$process_id" 2>/dev/null || true
403
+ fi
404
+ done
405
+
406
+ if [[ -n "$RAINSKILLS_BROWSER_LOGIN_RESULT_FILE" ]]; then
407
+ rm -f \
408
+ "$RAINSKILLS_BROWSER_LOGIN_RESULT_FILE" \
409
+ "${RAINSKILLS_BROWSER_LOGIN_RESULT_FILE}.port" \
410
+ "${RAINSKILLS_BROWSER_LOGIN_RESULT_FILE}.err"
411
+ fi
412
+
413
+ RAINSKILLS_BROWSER_LOGIN_READER_PID=""
414
+ RAINSKILLS_BROWSER_LOGIN_SERVER_PID=""
415
+ RAINSKILLS_BROWSER_LOGIN_RESULT_FILE=""
416
+ }
417
+
418
+ handle_installer_signal() {
419
+ local exit_code="$1"
420
+ trap - INT TERM
421
+ cleanup_browser_login
422
+ exit "$exit_code"
423
+ }
424
+
425
+ handle_installer_exit() {
426
+ local exit_code="$1"
427
+ cleanup_browser_login
428
+ report_unhandled_rainskills_installation_failure "$exit_code"
429
+ }
430
+
431
+ die() {
432
+ if [[ "${BASH_SUBSHELL:-0}" -eq 0 && "$RAINSKILLS_INSTALL_TERMINAL_REPORTED" -eq 0 ]]; then
433
+ RAINSKILLS_INSTALL_TERMINAL_REPORTED=1
434
+ report_rainskills_installation \
435
+ "failed" \
436
+ "failure" \
437
+ "$RAINSKILLS_INSTALL_FAILURE_STAGE" \
438
+ "$RAINSKILLS_INSTALL_FAILURE_CATEGORY"
439
+ fi
440
+ printf '错误:%s\n' "$1" >&2
441
+ exit 1
442
+ }
443
+
444
+ trim() {
445
+ printf '%s' "$1" | awk '{$1=$1; print}'
446
+ }
447
+
448
+ validate_skill_dir() {
449
+ local skill_dir="$1"
450
+ local skill_file="$skill_dir/SKILL.md"
451
+
452
+ if [[ ! -f "$skill_file" ]]; then
453
+ log "[invalid] $skill_dir 缺少 SKILL.md"
454
+ return 1
455
+ fi
456
+
457
+ if ! awk '
458
+ NR == 1 {
459
+ if ($0 != "---") {
460
+ exit 1
461
+ }
462
+ next
463
+ }
464
+ NR <= 20 {
465
+ if ($0 ~ /^name:[[:space:]]*[^[:space:]].*$/) {
466
+ has_name = 1
467
+ }
468
+ if ($0 ~ /^description:[[:space:]]*[^[:space:]].*$/) {
469
+ has_description = 1
470
+ }
471
+ if ($0 == "---") {
472
+ has_closing = 1
473
+ }
474
+ }
475
+ END {
476
+ if (!(has_name && has_description && has_closing)) {
477
+ exit 1
478
+ }
479
+ }
480
+ ' "$skill_file"; then
481
+ log "[invalid] $skill_file 必须包含带 name 和 description 的标准 YAML frontmatter"
482
+ return 1
483
+ fi
484
+ }
485
+
486
+ copy_skill() {
487
+ local src="$1"
488
+ local dest_root="$2"
489
+ local skill_name
490
+ skill_name="$(basename "$src")"
491
+ local dest="$dest_root/$skill_name"
492
+
493
+ mkdir -p "$dest_root"
494
+
495
+ if [[ ! -e "$dest" ]]; then
496
+ cp -R "$src" "$dest"
497
+ log "[install] 已安装到 $dest"
498
+ INSTALL_COUNT_NEW=$((INSTALL_COUNT_NEW + 1))
499
+ return 0
500
+ fi
501
+
502
+ if [[ "$FORCE" -eq 1 ]]; then
503
+ rm -rf "$dest"
504
+ cp -R "$src" "$dest"
505
+ log "[overwrite] 已强制覆盖 $dest"
506
+ INSTALL_COUNT_FORCED=$((INSTALL_COUNT_FORCED + 1))
507
+ return 0
508
+ fi
509
+
510
+ if diff -rq "$src" "$dest" >/dev/null 2>&1; then
511
+ log "[skip] $dest 已是最新"
512
+ INSTALL_COUNT_UNCHANGED=$((INSTALL_COUNT_UNCHANGED + 1))
513
+ return 0
514
+ fi
515
+
516
+ # diff -rq 在有差异时退出码非零;与 `set -e + pipefail` 共存需要 || true 兜底
517
+ local changed_files
518
+ changed_files="$( { diff -rq "$src" "$dest" 2>/dev/null || true; } | wc -l | tr -d ' ')"
519
+ rm -rf "$dest"
520
+ cp -R "$src" "$dest"
521
+ log "[update] 已更新 ${dest}(${changed_files} 项变化,本地修改已被覆盖)"
522
+ INSTALL_COUNT_UPDATED=$((INSTALL_COUNT_UPDATED + 1))
523
+ }
524
+
525
+ parse_args() {
526
+ while [[ $# -gt 0 ]]; do
527
+ case "$1" in
528
+ refresh)
529
+ ACTION="refresh"
530
+ shift
531
+ ;;
532
+ claude|codex|all)
533
+ TARGET="$1"
534
+ shift
535
+ ;;
536
+ --dest)
537
+ [[ $# -ge 2 ]] || die "--dest 需要一个路径值"
538
+ CUSTOM_DEST="$2"
539
+ shift 2
540
+ ;;
541
+ --force)
542
+ FORCE=1
543
+ shift
544
+ ;;
545
+ --skip-mcp)
546
+ SKIP_MCP=1
547
+ shift
548
+ ;;
549
+ --non-interactive)
550
+ NON_INTERACTIVE=1
551
+ shift
552
+ ;;
553
+ --rainbond-url)
554
+ [[ $# -ge 2 ]] || die "--rainbond-url 需要一个值"
555
+ RAINBOND_URL_INPUT="$2"
556
+ RAINBOND_URL_FROM_FLAG=1
557
+ shift 2
558
+ ;;
559
+ --no-cached-token)
560
+ RAINBOND_TOKEN_INPUT=""
561
+ RAINBOND_CACHED_URL=""
562
+ shift
563
+ ;;
564
+ --saas)
565
+ DEPLOYMENT_MODE_INPUT="saas"
566
+ shift
567
+ ;;
568
+ --self-hosted)
569
+ DEPLOYMENT_MODE_INPUT="self-hosted"
570
+ shift
571
+ ;;
572
+ --token)
573
+ [[ $# -ge 2 ]] || die "--token 需要一个值"
574
+ RAINBOND_TOKEN_INPUT="$2"
575
+ RAINBOND_TOKEN_FROM_FLAG=1
576
+ shift 2
577
+ ;;
578
+ --username)
579
+ [[ $# -ge 2 ]] || die "--username 需要一个值"
580
+ RAINBOND_USERNAME_INPUT="$2"
581
+ shift 2
582
+ ;;
583
+ --allow-insecure-http)
584
+ ALLOW_INSECURE_HTTP=1
585
+ shift
586
+ ;;
587
+ -h|--help)
588
+ usage
589
+ exit 0
590
+ ;;
591
+ *)
592
+ die "未知参数:$1"
593
+ ;;
594
+ esac
595
+ done
596
+ }
597
+
598
+ resolve_target() {
599
+ if [[ -n "$CUSTOM_DEST" ]]; then
600
+ return 0
601
+ fi
602
+
603
+ if [[ -n "$TARGET" ]]; then
604
+ return 0
605
+ fi
606
+
607
+ if [[ "$NON_INTERACTIVE" -eq 1 || ! -t 0 ]]; then
608
+ TARGET="$DEFAULT_TARGET"
609
+ return 0
610
+ fi
611
+
612
+ log "请选择要安装和配置的平台:"
613
+ log " 1) Codex"
614
+ log " 2) Claude Code"
615
+ log " 3) 两者都要"
616
+
617
+ while true; do
618
+ printf '请输入选项 [1-3]: '
619
+ read -r choice
620
+ case "$choice" in
621
+ 1)
622
+ TARGET="codex"
623
+ return 0
624
+ ;;
625
+ 2)
626
+ TARGET="claude"
627
+ return 0
628
+ ;;
629
+ 3|"")
630
+ TARGET="all"
631
+ return 0
632
+ ;;
633
+ *)
634
+ log "请输入 1、2 或 3。"
635
+ ;;
636
+ esac
637
+ done
638
+ }
639
+
640
+ collect_destinations() {
641
+ local destinations=()
642
+
643
+ if [[ -n "$CUSTOM_DEST" ]]; then
644
+ destinations+=("$CUSTOM_DEST")
645
+ else
646
+ case "$TARGET" in
647
+ claude)
648
+ destinations+=("$HOME/.claude/skills")
649
+ ;;
650
+ codex)
651
+ destinations+=("$HOME/.codex/skills")
652
+ ;;
653
+ all)
654
+ destinations+=("$HOME/.claude/skills")
655
+ destinations+=("$HOME/.codex/skills")
656
+ ;;
657
+ *)
658
+ die "未知安装目标:$TARGET"
659
+ ;;
660
+ esac
661
+ fi
662
+
663
+ printf '%s\n' "${destinations[@]}"
664
+ }
665
+
666
+ normalize_rainbond_url() {
667
+ local raw
668
+ raw="$(trim "$1")"
669
+ [[ -n "$raw" ]] || die "Rainbond 地址不能为空"
670
+
671
+ if [[ "$raw" != http://* && "$raw" != https://* ]]; then
672
+ raw="http://$raw"
673
+ fi
674
+
675
+ raw="${raw%/}"
676
+ raw="${raw%/console/mcp/rainskills/codex/query}"
677
+ raw="${raw%/console/mcp/rainskills/claude-code/query}"
678
+ raw="${raw%/console/mcp/query}"
679
+ raw="${raw%/console/users/login}"
680
+ raw="${raw%/console/}"
681
+ raw="${raw%/console}"
682
+ raw="${raw%/}"
683
+ printf '%s\n' "$raw"
684
+ }
685
+
686
+ prompt_for_value() {
687
+ local prompt_text="$1"
688
+ local current_value="$2"
689
+
690
+ if [[ -n "$current_value" ]]; then
691
+ printf '%s\n' "$current_value"
692
+ return 0
693
+ fi
694
+
695
+ if [[ "$NON_INTERACTIVE" -eq 1 || ! -t 0 ]]; then
696
+ die "非交互模式下必须提供${prompt_text}。"
697
+ fi
698
+
699
+ local value=""
700
+ while [[ -z "$value" ]]; do
701
+ printf '%s: ' "$prompt_text" >&2
702
+ read -r value
703
+ value="$(trim "$value")"
704
+ done
705
+ printf '%s\n' "$value"
706
+ }
707
+
708
+ # 始终向用户提问,但提供一个可回车沿用的默认值。
709
+ # 与 prompt_for_value 的区别:default_value 非空时不会静默返回,用户仍可改写。
710
+ prompt_with_default() {
711
+ local prompt_text="$1"
712
+ local default_value="$2"
713
+
714
+ if [[ "$NON_INTERACTIVE" -eq 1 || ! -t 0 ]]; then
715
+ if [[ -n "$default_value" ]]; then
716
+ printf '%s\n' "$default_value"
717
+ return 0
718
+ fi
719
+ die "非交互模式下必须提供${prompt_text}。"
720
+ fi
721
+
722
+ local value=""
723
+ while [[ -z "$value" ]]; do
724
+ if [[ -n "$default_value" ]]; then
725
+ printf '%s [回车沿用 %s]: ' "$prompt_text" "$default_value" >&2
726
+ else
727
+ printf '%s: ' "$prompt_text" >&2
728
+ fi
729
+ read -r value
730
+ value="$(trim "$value")"
731
+ if [[ -z "$value" && -n "$default_value" ]]; then
732
+ value="$default_value"
733
+ fi
734
+ done
735
+ printf '%s\n' "$value"
736
+ }
737
+
738
+ prompt_for_password() {
739
+ if [[ -n "$RAINBOND_PASSWORD_INPUT" ]]; then
740
+ printf '%s\n' "$RAINBOND_PASSWORD_INPUT"
741
+ return 0
742
+ fi
743
+
744
+ if [[ "$NON_INTERACTIVE" -eq 1 || ! -t 0 ]]; then
745
+ die "非交互模式下必须提供 Rainbond 密码,请设置 RAINBOND_PASSWORD。"
746
+ fi
747
+
748
+ local password=""
749
+ while [[ -z "$password" ]]; do
750
+ printf 'Rainbond 密码: ' >&2
751
+ read -r -s password
752
+ printf '\n' >&2
753
+ done
754
+ printf '%s\n' "$password"
755
+ }
756
+
757
+ confirm_insecure_http_if_needed() {
758
+ local base_url="$1"
759
+
760
+ if [[ "$base_url" != http://* ]]; then
761
+ return 0
762
+ fi
763
+
764
+ if [[ "$ALLOW_INSECURE_HTTP" -eq 1 ]]; then
765
+ warn "当前使用明文 HTTP 连接,凭证以明文传输。"
766
+ return 0
767
+ fi
768
+
769
+ if [[ "$NON_INTERACTIVE" -eq 1 || ! -t 0 ]]; then
770
+ die "默认禁用明文 HTTP。如需继续请加 --allow-insecure-http 重新执行。"
771
+ fi
772
+
773
+ warn "当前 Rainbond 地址使用明文 HTTP,账号密码和 JWT 传输不会加密。"
774
+ printf '是否继续使用明文 HTTP 连接?[y/N]: '
775
+ read -r answer
776
+ case "$answer" in
777
+ y|Y|yes|YES)
778
+ ALLOW_INSECURE_HTTP=1
779
+ ;;
780
+ *)
781
+ die "已取消安装。"
782
+ ;;
783
+ esac
784
+ }
785
+
786
+ ensure_python3() {
787
+ command -v python3 >/dev/null 2>&1 || die "需要 python3 来解析 JSON。"
788
+ }
789
+
790
+ show_self_hosted_install_hint() {
791
+ log ""
792
+ log "============================================================"
793
+ log "[重要] 私有化部署需要先准备一个可访问的 Rainbond 环境"
794
+ log ""
795
+ log "如果尚未安装 Rainbond,请在目标机器执行快速安装命令:"
796
+ log " curl -o install.sh https://get.rainbond.com && bash ./install.sh"
797
+ log ""
798
+ log "安装完成后,请使用安装脚本输出的 Console 地址继续下面的配置。"
799
+ log "安装文档:https://www.rainbond.com/docs/quick-start/quick-install/"
800
+ log "============================================================"
801
+ log ""
802
+ }
803
+
804
+ resolve_deployment_mode() {
805
+ if [[ -n "$DEPLOYMENT_MODE_INPUT" ]]; then
806
+ return 0
807
+ fi
808
+
809
+ if [[ "$RAINBOND_URL_FROM_FLAG" -eq 1 ]]; then
810
+ DEPLOYMENT_MODE_INPUT="self-hosted"
811
+ return 0
812
+ fi
813
+
814
+ if [[ "$NON_INTERACTIVE" -eq 1 || ! -t 0 ]]; then
815
+ if [[ -n "$RAINBOND_URL_INPUT" ]]; then
816
+ DEPLOYMENT_MODE_INPUT="self-hosted"
817
+ return 0
818
+ fi
819
+ die "非交互模式下必须指定 --saas 或 --self-hosted(搭配 --rainbond-url)。"
820
+ fi
821
+
822
+ log "请选择 Rainbond 部署形态:"
823
+ log " 1) Rainbond Cloud(SaaS:${SAAS_DEFAULT_URL})"
824
+ log " 2) 私有化部署(自填 Console 地址)"
825
+
826
+ while true; do
827
+ printf '请输入选项 [1-2,回车默认 1]: '
828
+ read -r choice
829
+ case "$choice" in
830
+ 1|"")
831
+ DEPLOYMENT_MODE_INPUT="saas"
832
+ return 0
833
+ ;;
834
+ 2)
835
+ DEPLOYMENT_MODE_INPUT="self-hosted"
836
+ show_self_hosted_install_hint
837
+ return 0
838
+ ;;
839
+ *)
840
+ log "请输入 1 或 2。"
841
+ ;;
842
+ esac
843
+ done
844
+ }
845
+
846
+ can_open_browser() {
847
+ if [[ "$(uname -s)" == "Darwin" ]] && command -v open >/dev/null 2>&1; then
848
+ return 0
849
+ fi
850
+ if command -v xdg-open >/dev/null 2>&1 && { [[ -n "${DISPLAY:-}" ]] || [[ -n "${WAYLAND_DISPLAY:-}" ]]; }; then
851
+ return 0
852
+ fi
853
+ return 1
854
+ }
855
+
856
+ open_browser() {
857
+ local url="$1"
858
+ if [[ "$(uname -s)" == "Darwin" ]] && command -v open >/dev/null 2>&1; then
859
+ open "$url" >/dev/null 2>&1 || true
860
+ return 0
861
+ fi
862
+ if command -v xdg-open >/dev/null 2>&1; then
863
+ xdg-open "$url" >/dev/null 2>&1 || true
864
+ return 0
865
+ fi
866
+ return 1
867
+ }
868
+
869
+ extract_token_from_paste() {
870
+ python3 - "$1" "$2" <<'PY'
871
+ import re
872
+ import sys
873
+ from urllib.parse import urlparse, parse_qs
874
+
875
+ raw = sys.argv[1].strip()
876
+ expected_state = sys.argv[2]
877
+
878
+ if raw.startswith("http://") or raw.startswith("https://"):
879
+ qs = parse_qs(urlparse(raw).query)
880
+ token = (qs.get("token") or [None])[0]
881
+ state = (qs.get("state") or [None])[0]
882
+ if not token:
883
+ print("回调 URL 中未找到 token 参数。", file=sys.stderr)
884
+ sys.exit(1)
885
+ if state and state != expected_state:
886
+ print("回调 URL 中的 state 与当前授权会话不一致;可能是过期或错混的链接,请重新点击「授权」。", file=sys.stderr)
887
+ sys.exit(1)
888
+ print(token)
889
+ sys.exit(0)
890
+
891
+ if not re.fullmatch(r"[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+", raw):
892
+ print("输入既不是合法 JWT,也不是回调 URL,请重试。", file=sys.stderr)
893
+ sys.exit(1)
894
+
895
+ print(raw)
896
+ PY
897
+ }
898
+
899
+ manual_paste_reader() {
900
+ local port="$1"
901
+ local state="$2"
902
+ local server_pid="$3"
903
+ local pasted token_out
904
+ while kill -0 "$server_pid" 2>/dev/null; do
905
+ printf '\n请粘贴回调 URL 或 JWT(按回车确认,Ctrl+C 取消): ' >&2
906
+ if ! IFS= read -r pasted </dev/tty; then
907
+ return 0
908
+ fi
909
+ pasted="$(trim "$pasted")"
910
+ [[ -n "$pasted" ]] || continue
911
+ if token_out="$(extract_token_from_paste "$pasted" "$state")"; then
912
+ curl -fsS --max-time 5 \
913
+ "http://127.0.0.1:${port}/cli-callback?token=${token_out}&state=${state}" \
914
+ >/dev/null 2>&1 || true
915
+ return 0
916
+ fi
917
+ done
918
+ }
919
+
920
+ browser_login_to_rainbond() {
921
+ local base_url="$1"
922
+ local result_file state port auth_url
923
+ result_file="$(mktemp "${TMPDIR:-/tmp}/rainskills-auth.XXXXXX")"
924
+ RAINSKILLS_BROWSER_LOGIN_RESULT_FILE="$result_file"
925
+
926
+ state="$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')"
927
+
928
+ python3 - "$result_file" "$state" "$LOGIN_TIMEOUT" >"${result_file}.port" 2>"${result_file}.err" <<'PY' &
929
+ import json
930
+ import sys
931
+ import time
932
+ from http.server import BaseHTTPRequestHandler, HTTPServer
933
+ from urllib.parse import urlparse, parse_qs
934
+
935
+ result_path = sys.argv[1]
936
+ expected_state = sys.argv[2]
937
+ timeout = int(sys.argv[3])
938
+
939
+ received = {"token": None, "error": None}
940
+
941
+ SUCCESS_HTML = (
942
+ "<!doctype html><meta charset=\"utf-8\">"
943
+ "<title>Rainbond CLI 授权完成</title>"
944
+ "<body style=\"font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
945
+ "max-width:480px;margin:120px auto;text-align:center;color:#1f2933;\">"
946
+ "<h2 style=\"color:#0a7d3a;\">授权完成</h2>"
947
+ "<p>Rainbond CLI 已收到凭证,请回到终端继续。可以关闭此页面。</p>"
948
+ "</body>"
949
+ )
950
+ ERROR_HTML = (
951
+ "<!doctype html><meta charset=\"utf-8\">"
952
+ "<title>Rainbond CLI 授权失败</title>"
953
+ "<body style=\"font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
954
+ "max-width:480px;margin:120px auto;text-align:center;color:#1f2933;\">"
955
+ "<h2 style=\"color:#b8312f;\">授权失败</h2>"
956
+ "<p>{detail}</p><p>请回到终端重新执行 install.sh。</p></body>"
957
+ )
958
+
959
+ class Handler(BaseHTTPRequestHandler):
960
+ def _cors(self):
961
+ origin = self.headers.get("Origin") or "*"
962
+ self.send_header("Access-Control-Allow-Origin", origin)
963
+ self.send_header("Vary", "Origin")
964
+ self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS, GET")
965
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
966
+ # Chrome Private Network Access (CORS-RFC1918): public-page → loopback
967
+ # requires this preflight ack header.
968
+ if self.headers.get("Access-Control-Request-Private-Network", "").lower() == "true":
969
+ self.send_header("Access-Control-Allow-Private-Network", "true")
970
+
971
+ def do_OPTIONS(self):
972
+ self.send_response(204)
973
+ self._cors()
974
+ self.end_headers()
975
+
976
+ def log_message(self, fmt, *args):
977
+ return
978
+
979
+ def _finish(self, status, msg):
980
+ self.send_response(status)
981
+ self._cors()
982
+ self.send_header("Content-Type", "text/html; charset=utf-8")
983
+ self.end_headers()
984
+ self.wfile.write(msg.encode("utf-8"))
985
+
986
+ def _handle_callback(self, params):
987
+ state = (params.get("state") or [None])[0]
988
+ token = (params.get("token") or [None])[0]
989
+ if state != expected_state:
990
+ received["error"] = "Rainbond 浏览器授权回调 state 不匹配,疑似 CSRF。"
991
+ self._finish(400, ERROR_HTML.format(detail="state 校验失败"))
992
+ return
993
+ if not token:
994
+ received["error"] = "Rainbond 浏览器授权回调缺少 token。"
995
+ self._finish(400, ERROR_HTML.format(detail="缺少 token"))
996
+ return
997
+ received["token"] = token
998
+ self._finish(200, SUCCESS_HTML)
999
+
1000
+ def do_GET(self):
1001
+ parsed = urlparse(self.path)
1002
+ if not parsed.path.startswith("/cli-callback"):
1003
+ self._finish(404, "not found")
1004
+ return
1005
+ self._handle_callback(parse_qs(parsed.query))
1006
+
1007
+ def do_POST(self):
1008
+ parsed = urlparse(self.path)
1009
+ if not parsed.path.startswith("/cli-callback"):
1010
+ self._finish(404, "not found")
1011
+ return
1012
+ length = int(self.headers.get("Content-Length") or 0)
1013
+ raw = self.rfile.read(length) if length > 0 else b"{}"
1014
+ try:
1015
+ payload = json.loads(raw.decode("utf-8"))
1016
+ except Exception as exc:
1017
+ received["error"] = "回调载荷不是合法 JSON:{}".format(exc)
1018
+ self._finish(400, ERROR_HTML.format(detail="无效请求"))
1019
+ return
1020
+ params = {k: [v] for k, v in payload.items() if v is not None}
1021
+ self._handle_callback(params)
1022
+
1023
+ server = HTTPServer(("127.0.0.1", 0), Handler)
1024
+ port = server.server_address[1]
1025
+ print(port, flush=True)
1026
+
1027
+ deadline = time.time() + timeout
1028
+ server.timeout = 1.0
1029
+ while time.time() < deadline and received["token"] is None and received["error"] is None:
1030
+ server.handle_request()
1031
+
1032
+ if received["error"]:
1033
+ print(received["error"], file=sys.stderr)
1034
+ sys.exit(2)
1035
+ if received["token"] is None:
1036
+ print("Rainbond 浏览器授权超时({} 秒)。".format(timeout), file=sys.stderr)
1037
+ sys.exit(3)
1038
+
1039
+ with open(result_path, "w", encoding="utf-8") as fh:
1040
+ fh.write(received["token"])
1041
+ PY
1042
+ local server_pid=$!
1043
+ RAINSKILLS_BROWSER_LOGIN_SERVER_PID="$server_pid"
1044
+
1045
+ # Server prints chosen port to stdout (file) on first line, then waits
1046
+ local waited=0
1047
+ while [[ ! -s "${result_file}.port" ]]; do
1048
+ sleep 0.1
1049
+ waited=$((waited + 1))
1050
+ if [[ "$waited" -gt 50 ]]; then
1051
+ cleanup_browser_login
1052
+ die "无法启动本地回调服务(端口准备超时)。"
1053
+ fi
1054
+ if ! kill -0 "$server_pid" 2>/dev/null; then
1055
+ cat "${result_file}.err" >&2 || true
1056
+ cleanup_browser_login
1057
+ die "本地回调服务启动失败。"
1058
+ fi
1059
+ done
1060
+
1061
+ port="$(head -n 1 "${result_file}.port")"
1062
+ auth_url="${base_url}/#/cli-auth?callback=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "http://127.0.0.1:${port}/cli-callback")&state=${state}"
1063
+
1064
+ printf '终端会自动等待授权结果(最长 %s 秒),无需在终端按回车;授权完成后会自动继续,Ctrl+C 可取消。\n' "$LOGIN_TIMEOUT" >&2
1065
+ if can_open_browser; then
1066
+ printf '正在浏览器中打开 Rainbond CLI 授权页面,请在浏览器中完成登录并点击「授权」按钮。\n' >&2
1067
+ printf '授权地址:%s\n' "$auth_url" >&2
1068
+ open_browser "$auth_url"
1069
+ else
1070
+ printf '\n' >&2
1071
+ printf '未检测到本机浏览器(典型场景:远程 SSH 到 Linux 服务器),进入手动授权模式:\n' >&2
1072
+ printf ' 1. 在你能打开浏览器的电脑上,访问下面这条授权链接:\n' >&2
1073
+ printf ' %s\n' "$auth_url" >&2
1074
+ printf ' 2. 登录后点击页面上的「授权」按钮。\n' >&2
1075
+ printf ' 3. 浏览器会跳到 http://127.0.0.1:%s/cli-callback?token=...&state=... 的地址。\n' "$port" >&2
1076
+ printf ' 远程 SSH 场景下页面会显示「无法访问」,属正常现象,只看地址栏即可。\n' >&2
1077
+ printf ' 4. 从浏览器地址栏复制整条 URL(或仅 token= 后那串 JWT),按下方提示粘贴回车。\n' >&2
1078
+ printf '\n' >&2
1079
+ printf '若浏览器与此终端在同一台机器,直接点击「授权」即可,无需手动粘贴。\n' >&2
1080
+
1081
+ if [[ -r /dev/tty ]]; then
1082
+ manual_paste_reader "$port" "$state" "$server_pid" &
1083
+ RAINSKILLS_BROWSER_LOGIN_READER_PID=$!
1084
+ else
1085
+ warn "/dev/tty 不可读,无法接收手动粘贴。请改用 --token <jwt> 重新执行 install.sh。"
1086
+ fi
1087
+ fi
1088
+
1089
+ if ! wait "$server_pid"; then
1090
+ local err
1091
+ err="$(cat "${result_file}.err" 2>/dev/null || true)"
1092
+ RAINSKILLS_BROWSER_LOGIN_SERVER_PID=""
1093
+ cleanup_browser_login
1094
+ if [[ -n "$err" ]]; then
1095
+ die "$err"
1096
+ fi
1097
+ die "Rainbond 浏览器授权失败。"
1098
+ fi
1099
+ RAINSKILLS_BROWSER_LOGIN_SERVER_PID=""
1100
+
1101
+ local token
1102
+ token="$(cat "$result_file")"
1103
+ cleanup_browser_login
1104
+ if [[ -z "$token" ]]; then
1105
+ die "Rainbond 浏览器授权未返回 token。"
1106
+ fi
1107
+ OBTAINED_RAINBOND_TOKEN="$token"
1108
+ }
1109
+
1110
+ login_to_rainbond() {
1111
+ local base_url="$1"
1112
+ local username="$2"
1113
+ local password="$3"
1114
+ local login_url="${base_url}/console/users/login"
1115
+ local response_file
1116
+ response_file="$(mktemp)"
1117
+
1118
+ local http_code
1119
+ http_code="$(
1120
+ curl \
1121
+ --silent \
1122
+ --show-error \
1123
+ --output "$response_file" \
1124
+ --write-out '%{http_code}' \
1125
+ -X POST \
1126
+ "$login_url" \
1127
+ -H 'Content-Type: application/x-www-form-urlencoded' \
1128
+ --data-urlencode "nick_name=$username" \
1129
+ --data-urlencode "password=$password"
1130
+ )"
1131
+
1132
+ if [[ ! "$http_code" =~ ^2 ]]; then
1133
+ rm -f "$response_file"
1134
+ die "Rainbond 登录失败,HTTP 状态码 ${http_code}"
1135
+ fi
1136
+
1137
+ local token
1138
+ if ! token="$(
1139
+ python3 - "$response_file" <<'PY'
1140
+ import json
1141
+ import sys
1142
+
1143
+ path = sys.argv[1]
1144
+ try:
1145
+ with open(path, "r", encoding="utf-8") as fh:
1146
+ payload = json.load(fh)
1147
+ except Exception:
1148
+ body = open(path, "r", encoding="utf-8", errors="replace").read()
1149
+ print("登录响应不是合法 JSON:{}".format(body[:200]), file=sys.stderr)
1150
+ sys.exit(1)
1151
+
1152
+ code = payload.get("code")
1153
+ if code != 200:
1154
+ print(payload.get("msg_show") or payload.get("msg") or "Rainbond 登录失败", file=sys.stderr)
1155
+ sys.exit(1)
1156
+
1157
+ token = (((payload.get("data") or {}).get("bean") or {}).get("token"))
1158
+ if not token:
1159
+ print("登录成功但没有返回 token", file=sys.stderr)
1160
+ sys.exit(1)
1161
+
1162
+ print(token)
1163
+ PY
1164
+ )"; then
1165
+ rm -f "$response_file"
1166
+ die "Rainbond 登录失败"
1167
+ fi
1168
+
1169
+ rm -f "$response_file"
1170
+ OBTAINED_RAINBOND_TOKEN="$token"
1171
+ }
1172
+
1173
+ shell_quote_single() {
1174
+ printf "%s" "$1" | sed "s/'/'\"'\"'/g"
1175
+ }
1176
+
1177
+ write_token_file() {
1178
+ local token="$1"
1179
+ local base_url="$2"
1180
+ local token_dir="$HOME/.rainbond"
1181
+ local token_file="$token_dir/mcp.env"
1182
+ mkdir -p "$token_dir"
1183
+ chmod 700 "$token_dir"
1184
+
1185
+ local escaped_token escaped_url
1186
+ escaped_token="$(shell_quote_single "$token")"
1187
+ escaped_url="$(shell_quote_single "$base_url")"
1188
+ umask 077
1189
+ cat > "$token_file" <<EOF
1190
+ export RAINBOND_JWT='$escaped_token'
1191
+ export RAINBOND_URL='$escaped_url'
1192
+ EOF
1193
+ chmod 600 "$token_file"
1194
+ log "[write] 已写入 $token_file"
1195
+ }
1196
+
1197
+ backup_file() {
1198
+ local file="$1"
1199
+ [[ -f "$file" ]] || return 0
1200
+ local backup="${file}.rainbond-skills.bak.$(date +%Y%m%d%H%M%S)"
1201
+ cp "$file" "$backup"
1202
+ log "[backup] 已备份 $backup"
1203
+ }
1204
+
1205
+ update_managed_block() {
1206
+ local file="$1"
1207
+ local begin_marker="$2"
1208
+ local end_marker="$3"
1209
+ local block_body="$4"
1210
+ local tmp
1211
+ tmp="$(mktemp)"
1212
+
1213
+ mkdir -p "$(dirname "$file")"
1214
+ touch "$file"
1215
+
1216
+ awk -v begin="$begin_marker" -v end="$end_marker" '
1217
+ $0 == begin {skip = 1; next}
1218
+ $0 == end {skip = 0; next}
1219
+ !skip {print}
1220
+ ' "$file" > "$tmp"
1221
+
1222
+ if [[ -s "$tmp" ]]; then
1223
+ printf '\n' >> "$tmp"
1224
+ fi
1225
+ printf '%s\n%s\n%s\n' "$begin_marker" "$block_body" "$end_marker" >> "$tmp"
1226
+ mv "$tmp" "$file"
1227
+ }
1228
+
1229
+ detect_shell_rc_file() {
1230
+ local shell_name
1231
+ shell_name="$(basename "${SHELL:-zsh}")"
1232
+
1233
+ case "$shell_name" in
1234
+ zsh)
1235
+ printf '%s\n' "$HOME/.zshrc"
1236
+ ;;
1237
+ bash)
1238
+ printf '%s\n' "$HOME/.bashrc"
1239
+ ;;
1240
+ *)
1241
+ printf '%s\n' "$HOME/.profile"
1242
+ ;;
1243
+ esac
1244
+ }
1245
+
1246
+ configure_shell_autoload() {
1247
+ local token_file="$HOME/.rainbond/mcp.env"
1248
+ local rc_file
1249
+ rc_file="$(detect_shell_rc_file)"
1250
+ local begin_marker="# >>> rainbond skills mcp >>>"
1251
+ local end_marker="# <<< rainbond skills mcp <<<"
1252
+ local block='[ -f "$HOME/.rainbond/mcp.env" ] && source "$HOME/.rainbond/mcp.env"'
1253
+
1254
+ backup_file "$rc_file"
1255
+ update_managed_block "$rc_file" "$begin_marker" "$end_marker" "$block"
1256
+ ACTIVE_SHELL_RC="$rc_file"
1257
+ log "[update] 已更新 $rc_file"
1258
+ }
1259
+
1260
+ codex_config_matches() {
1261
+ local mcp_url="$1"
1262
+ local config_file="$HOME/.codex/config.toml"
1263
+ [[ -f "$config_file" ]] || return 1
1264
+
1265
+ local current_url current_env
1266
+ current_url="$(
1267
+ awk '
1268
+ $0 == "[mcp_servers.rainbond]" {in_section = 1; next}
1269
+ in_section && /^\[/ {in_section = 0}
1270
+ in_section && $1 == "url" {
1271
+ sub(/^[^=]+=[[:space:]]*"/, "", $0)
1272
+ sub(/"$/, "", $0)
1273
+ print
1274
+ exit
1275
+ }
1276
+ ' "$config_file"
1277
+ )"
1278
+ current_env="$(
1279
+ awk '
1280
+ $0 == "[mcp_servers.rainbond]" {in_section = 1; next}
1281
+ in_section && /^\[/ {in_section = 0}
1282
+ in_section && $1 == "bearer_token_env_var" {
1283
+ sub(/^[^=]+=[[:space:]]*"/, "", $0)
1284
+ sub(/"$/, "", $0)
1285
+ print
1286
+ exit
1287
+ }
1288
+ ' "$config_file"
1289
+ )"
1290
+
1291
+ [[ "$current_url" == "$mcp_url" && "$current_env" == "RAINBOND_JWT" ]]
1292
+ }
1293
+
1294
+ claude_config_matches() {
1295
+ local mcp_url="$1"
1296
+ local config_file="$HOME/.claude.json"
1297
+ [[ -f "$config_file" ]] || return 1
1298
+
1299
+ python3 - "$config_file" "$mcp_url" <<'PY' >/dev/null
1300
+ import json
1301
+ import sys
1302
+
1303
+ path, expected_url = sys.argv[1], sys.argv[2]
1304
+
1305
+ with open(path, "r", encoding="utf-8") as fh:
1306
+ payload = json.load(fh)
1307
+
1308
+ server = ((payload.get("mcpServers") or {}).get("rainbond") or {})
1309
+ header = ((server.get("headers") or {}).get("Authorization"))
1310
+
1311
+ if server.get("url") == expected_url and header == "GRJWT ${RAINBOND_JWT}":
1312
+ sys.exit(0)
1313
+
1314
+ sys.exit(1)
1315
+ PY
1316
+ }
1317
+
1318
+ configure_codex_mcp() {
1319
+ local mcp_url="$1"
1320
+
1321
+ if ! command -v codex >/dev/null 2>&1; then
1322
+ warn "未找到 Codex CLI,跳过 Codex MCP 配置。"
1323
+ return 1
1324
+ fi
1325
+
1326
+ backup_file "$HOME/.codex/config.toml"
1327
+ codex mcp remove rainbond >/dev/null 2>&1 || true
1328
+ if ! codex mcp add rainbond --url "$mcp_url" --bearer-token-env-var RAINBOND_JWT >/dev/null; then
1329
+ return 1
1330
+ fi
1331
+ log "[configure] 已配置 Codex MCP"
1332
+ }
1333
+
1334
+ configure_claude_mcp() {
1335
+ local mcp_url="$1"
1336
+
1337
+ if ! command -v claude >/dev/null 2>&1; then
1338
+ warn "未找到 Claude CLI,跳过 Claude MCP 配置。"
1339
+ return 1
1340
+ fi
1341
+
1342
+ backup_file "$HOME/.claude.json"
1343
+ claude mcp remove --scope user rainbond >/dev/null 2>&1 || true
1344
+ if ! claude mcp add --scope user --transport http rainbond "$mcp_url" -H 'Authorization: GRJWT ${RAINBOND_JWT}' >/dev/null; then
1345
+ return 1
1346
+ fi
1347
+ log "[configure] 已配置 Claude MCP"
1348
+ }
1349
+
1350
+ validate_mcp_connectivity() {
1351
+ local mcp_url="$1"
1352
+ local token="$2"
1353
+ local response_file header_file
1354
+ response_file="$(mktemp)"
1355
+ header_file="$(mktemp)"
1356
+ VALIDATED_TOKEN="$token"
1357
+
1358
+ local http_code
1359
+ http_code="$(
1360
+ curl \
1361
+ --silent \
1362
+ --show-error \
1363
+ --output "$response_file" \
1364
+ --dump-header "$header_file" \
1365
+ --write-out '%{http_code}' \
1366
+ -X POST \
1367
+ "$mcp_url" \
1368
+ -H 'Accept: application/json' \
1369
+ -H 'Content-Type: application/json' \
1370
+ -H "Authorization: GRJWT ${token}" \
1371
+ -H 'MCP-Protocol-Version: 2025-03-26' \
1372
+ --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
1373
+ )"
1374
+
1375
+ if [[ ! "$http_code" =~ ^2 ]]; then
1376
+ rm -f "$response_file" "$header_file"
1377
+ if [[ "$http_code" == "404" ]]; then
1378
+ local mcp_path
1379
+ mcp_path="${mcp_url#*://}"
1380
+ mcp_path="/${mcp_path#*/}"
1381
+ die "Rainbond MCP 校验失败:${mcp_url} 未暴露 ${mcp_path}。登录已成功,说明这个环境可达,但当前部署的 Rainbond Console 可能未包含 RainSkills MCP 接口,或你连接到了错误的 Rainbond 主机。"
1382
+ fi
1383
+ die "Rainbond MCP 校验失败,HTTP 状态码 ${http_code}"
1384
+ fi
1385
+
1386
+ if ! python3 - "$response_file" <<'PY' >/dev/null; then
1387
+ import json
1388
+ import sys
1389
+
1390
+ path = sys.argv[1]
1391
+ with open(path, "r", encoding="utf-8") as fh:
1392
+ payload = json.load(fh)
1393
+
1394
+ name = ((((payload.get("result") or {}).get("serverInfo") or {}).get("name")))
1395
+ if name != "rainbond-console-mcp":
1396
+ raise SystemExit(1)
1397
+ PY
1398
+ rm -f "$response_file" "$header_file"
1399
+ die "Rainbond MCP 校验返回了无法识别的响应"
1400
+ fi
1401
+
1402
+ local renewed_token
1403
+ renewed_token="$(
1404
+ python3 - "$header_file" <<'PY'
1405
+ import sys
1406
+
1407
+ path = sys.argv[1]
1408
+ renewed = ""
1409
+ authorization = ""
1410
+
1411
+ try:
1412
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
1413
+ for line in fh:
1414
+ if ":" not in line:
1415
+ continue
1416
+ name, value = line.split(":", 1)
1417
+ name = name.strip().lower()
1418
+ value = value.strip()
1419
+ if name == "x-renewed-token" and value:
1420
+ renewed = value
1421
+ elif name == "authorization" and value.lower().startswith("grjwt "):
1422
+ authorization = value.split(None, 1)[1].strip()
1423
+ except FileNotFoundError:
1424
+ pass
1425
+
1426
+ print(renewed or authorization)
1427
+ PY
1428
+ )"
1429
+
1430
+ if [[ -n "$renewed_token" ]]; then
1431
+ if looks_like_jwt "$renewed_token"; then
1432
+ local renewed_status_line renewed_status
1433
+ renewed_status_line="$(jwt_time_status "$renewed_token" "$JWT_MIN_TTL_SECONDS")"
1434
+ renewed_status="${renewed_status_line%% *}"
1435
+ if [[ "$renewed_status" == "ok" ]]; then
1436
+ VALIDATED_TOKEN="$renewed_token"
1437
+ log "[renew] 已保存后端滑动续期返回的新 JWT"
1438
+ else
1439
+ warn "MCP 响应返回了续期 token,但其有效期状态为 ${renewed_status},已忽略。"
1440
+ fi
1441
+ else
1442
+ warn "MCP 响应返回了续期 token,但不是合法 JWT,已忽略。"
1443
+ fi
1444
+ fi
1445
+
1446
+ rm -f "$response_file" "$header_file"
1447
+ log "[verify] Rainbond MCP 可访问"
1448
+ }
1449
+
1450
+ migrate_codex_mcp_if_generic() {
1451
+ local generic_url="$1"
1452
+ local dedicated_url="$2"
1453
+ local config_file="$HOME/.codex/config.toml"
1454
+ codex_config_matches "$generic_url" || return 0
1455
+
1456
+ local backup="${config_file}.rainskills-backup"
1457
+ cp "$config_file" "$backup"
1458
+ log "[backup] 已备份 $backup"
1459
+ if ! configure_codex_mcp "$dedicated_url"; then
1460
+ cp "$backup" "$config_file"
1461
+ warn "Codex MCP 迁移失败,已恢复原配置。"
1462
+ return 1
1463
+ fi
1464
+ log "[migrate] Codex MCP 已切换到 RainSkills 专用地址"
1465
+ }
1466
+
1467
+ migrate_claude_mcp_if_generic() {
1468
+ local generic_url="$1"
1469
+ local dedicated_url="$2"
1470
+ local config_file="$HOME/.claude.json"
1471
+ claude_config_matches "$generic_url" || return 0
1472
+
1473
+ local backup="${config_file}.rainskills-backup"
1474
+ cp "$config_file" "$backup"
1475
+ log "[backup] 已备份 $backup"
1476
+ if ! configure_claude_mcp "$dedicated_url"; then
1477
+ cp "$backup" "$config_file"
1478
+ warn "Claude MCP 迁移失败,已恢复原配置。"
1479
+ return 1
1480
+ fi
1481
+ log "[migrate] Claude MCP 已切换到 RainSkills 专用地址"
1482
+ }
1483
+
1484
+ looks_like_jwt() {
1485
+ # JWT compact form: header.payload.signature, three base64url segments.
1486
+ [[ "$1" =~ ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ ]]
1487
+ }
1488
+
1489
+ jwt_time_status() {
1490
+ local token="$1"
1491
+ local min_ttl_seconds="$2"
1492
+ python3 - "$token" "$min_ttl_seconds" <<'PY'
1493
+ import base64
1494
+ import json
1495
+ import sys
1496
+ import time
1497
+
1498
+ token = sys.argv[1]
1499
+ min_ttl = int(sys.argv[2])
1500
+
1501
+ try:
1502
+ payload_segment = token.split(".")[1]
1503
+ payload_segment += "=" * (-len(payload_segment) % 4)
1504
+ payload = json.loads(base64.urlsafe_b64decode(payload_segment.encode("ascii")).decode("utf-8"))
1505
+ except Exception:
1506
+ print("invalid 0")
1507
+ sys.exit(0)
1508
+
1509
+ exp = payload.get("exp")
1510
+ try:
1511
+ exp = int(exp)
1512
+ except Exception:
1513
+ print("no-exp 0")
1514
+ sys.exit(0)
1515
+
1516
+ remaining = exp - int(time.time())
1517
+ if remaining <= 0:
1518
+ print("expired {}".format(remaining))
1519
+ elif remaining <= min_ttl:
1520
+ print("expiring {}".format(remaining))
1521
+ else:
1522
+ print("ok {}".format(remaining))
1523
+ PY
1524
+ }
1525
+
1526
+ reject_flag_token() {
1527
+ local status="$1"
1528
+ case "$status" in
1529
+ expired)
1530
+ die "--token 提供的 Rainbond JWT 已过期,请重新获取 token 或改用浏览器登录。"
1531
+ ;;
1532
+ expiring)
1533
+ die "--token 提供的 Rainbond JWT 即将过期,请重新获取 token 或改用浏览器登录。"
1534
+ ;;
1535
+ no-exp)
1536
+ die "--token 提供的 Rainbond JWT 不包含 exp,无法判断有效期,请重新获取 token。"
1537
+ ;;
1538
+ invalid)
1539
+ die "--token 提供的 Rainbond JWT 无法解析 exp,请确认 token 是否完整。"
1540
+ ;;
1541
+ esac
1542
+ }
1543
+
1544
+ check_reusable_token_or_clear() {
1545
+ local token_label="$1"
1546
+ local status_line status
1547
+ status_line="$(jwt_time_status "$RAINBOND_TOKEN_INPUT" "$JWT_MIN_TTL_SECONDS")"
1548
+ status="${status_line%% *}"
1549
+
1550
+ case "$status" in
1551
+ ok)
1552
+ return 0
1553
+ ;;
1554
+ expired)
1555
+ if [[ "$RAINBOND_TOKEN_FROM_FLAG" -eq 1 ]]; then
1556
+ reject_flag_token "$status"
1557
+ fi
1558
+ warn "${token_label} 已过期,将重新登录获取新 JWT。"
1559
+ ;;
1560
+ expiring)
1561
+ if [[ "$RAINBOND_TOKEN_FROM_FLAG" -eq 1 ]]; then
1562
+ reject_flag_token "$status"
1563
+ fi
1564
+ warn "${token_label} 即将过期,将重新登录获取新 JWT。"
1565
+ ;;
1566
+ no-exp|invalid)
1567
+ if [[ "$RAINBOND_TOKEN_FROM_FLAG" -eq 1 ]]; then
1568
+ reject_flag_token "$status"
1569
+ fi
1570
+ warn "${token_label} 无法解析有效期,将忽略旧 token 并重新登录。"
1571
+ ;;
1572
+ *)
1573
+ if [[ "$RAINBOND_TOKEN_FROM_FLAG" -eq 1 ]]; then
1574
+ die "--token 提供的 Rainbond JWT 状态未知,请重新获取 token。"
1575
+ fi
1576
+ warn "${token_label} 状态未知,将忽略旧 token 并重新登录。"
1577
+ ;;
1578
+ esac
1579
+
1580
+ RAINBOND_TOKEN_INPUT=""
1581
+ return 1
1582
+ }
1583
+
1584
+ obtain_rainbond_token() {
1585
+ local base_url="$1"
1586
+ local mode="$2"
1587
+ OBTAINED_RAINBOND_TOKEN=""
1588
+
1589
+ if [[ -n "$RAINBOND_TOKEN_INPUT" ]]; then
1590
+ if ! looks_like_jwt "$RAINBOND_TOKEN_INPUT"; then
1591
+ if [[ "$RAINBOND_TOKEN_FROM_FLAG" -eq 1 ]]; then
1592
+ die "--token 提供的值不是合法的 JWT(应形如 xxx.yyy.zzz)。"
1593
+ fi
1594
+ warn "RAINBOND_JWT 不是合法的 JWT(应形如 xxx.yyy.zzz);忽略并改走浏览器登录。"
1595
+ warn "如果你的当前 shell 还在加载旧的 ~/.rainbond/mcp.env,请先执行:unset RAINBOND_JWT"
1596
+ RAINBOND_TOKEN_INPUT=""
1597
+ elif [[ "$RAINBOND_TOKEN_FROM_FLAG" -eq 1 ]]; then
1598
+ check_reusable_token_or_clear "--token 提供的 Rainbond JWT" || true
1599
+ printf '使用 --token 提供的 Rainbond JWT,跳过登录。\n' >&2
1600
+ OBTAINED_RAINBOND_TOKEN="$RAINBOND_TOKEN_INPUT"
1601
+ return 0
1602
+ elif [[ -n "$RAINBOND_CACHED_URL" && "$RAINBOND_CACHED_URL" != "$base_url" ]]; then
1603
+ warn "检测到 shell 中已加载的 RAINBOND_JWT 来自 ${RAINBOND_CACHED_URL},与本次目标 ${base_url} 不一致;忽略旧 token,将重新登录。"
1604
+ RAINBOND_TOKEN_INPUT=""
1605
+ elif [[ "$NON_INTERACTIVE" -eq 1 || ! -t 0 ]]; then
1606
+ check_reusable_token_or_clear "shell 中已加载的 RAINBOND_JWT" || true
1607
+ if [[ -n "$RAINBOND_TOKEN_INPUT" ]]; then
1608
+ printf '复用 shell 中已加载的 RAINBOND_JWT。\n' >&2
1609
+ OBTAINED_RAINBOND_TOKEN="$RAINBOND_TOKEN_INPUT"
1610
+ return 0
1611
+ fi
1612
+ else
1613
+ check_reusable_token_or_clear "shell 中已加载的 RAINBOND_JWT" || true
1614
+ if [[ -n "$RAINBOND_TOKEN_INPUT" ]]; then
1615
+ local cached_label="${RAINBOND_CACHED_URL:-未知来源}"
1616
+ printf '检测到 shell 中已加载的 RAINBOND_JWT(来自 %s)。是否复用?[y/N]: ' "$cached_label" >&2
1617
+ local reuse_answer=""
1618
+ read -r reuse_answer
1619
+ case "$reuse_answer" in
1620
+ y|Y|yes|YES)
1621
+ OBTAINED_RAINBOND_TOKEN="$RAINBOND_TOKEN_INPUT"
1622
+ return 0
1623
+ ;;
1624
+ *)
1625
+ printf '将忽略旧 token 并重新登录。\n' >&2
1626
+ RAINBOND_TOKEN_INPUT=""
1627
+ ;;
1628
+ esac
1629
+ fi
1630
+ fi
1631
+ fi
1632
+
1633
+ # Legacy username/password path (self-hosted only) — kept for CI / non-USE_SAAS deployments.
1634
+ if [[ "$mode" == "self-hosted" ]] && [[ -n "$RAINBOND_USERNAME_INPUT" || -n "$RAINBOND_PASSWORD_INPUT" ]]; then
1635
+ local username password
1636
+ username="$(prompt_for_value "Rainbond 用户名" "$RAINBOND_USERNAME_INPUT")"
1637
+ password="$(prompt_for_password)"
1638
+ login_to_rainbond "$base_url" "$username" "$password"
1639
+ return 0
1640
+ fi
1641
+
1642
+ if [[ "$NON_INTERACTIVE" -eq 1 || ! -t 0 ]]; then
1643
+ die "非交互模式下浏览器登录不可用,请改用 --token <jwt> 或设置 RAINBOND_JWT。"
1644
+ fi
1645
+
1646
+ browser_login_to_rainbond "$base_url"
1647
+ }
1648
+
1649
+ read_cached_rainbond_url() {
1650
+ local mcp_env="$HOME/.rainbond/mcp.env"
1651
+ [[ -f "$mcp_env" ]] || return 1
1652
+ (
1653
+ set +u
1654
+ # shellcheck disable=SC1090
1655
+ . "$mcp_env"
1656
+ printf '%s\n' "${RAINBOND_URL:-}"
1657
+ )
1658
+ }
1659
+
1660
+ do_refresh() {
1661
+ set_rainskills_failure_context "authorization" "authorization_failed"
1662
+ ensure_python3
1663
+
1664
+ # Refresh exists because the cached JWT is broken; ignore inherited tokens
1665
+ # unless the operator explicitly supplied one via --token.
1666
+ if [[ "$RAINBOND_TOKEN_FROM_FLAG" -ne 1 ]]; then
1667
+ RAINBOND_TOKEN_INPUT=""
1668
+ RAINBOND_CACHED_URL=""
1669
+ fi
1670
+
1671
+ if [[ -z "$RAINBOND_URL_INPUT" ]]; then
1672
+ local cached_url
1673
+ cached_url="$(read_cached_rainbond_url 2>/dev/null || true)"
1674
+ if [[ -n "$cached_url" ]]; then
1675
+ RAINBOND_URL_INPUT="$cached_url"
1676
+ log "[refresh] 使用 ~/.rainbond/mcp.env 中已记录的地址:$cached_url"
1677
+ fi
1678
+ fi
1679
+
1680
+ if [[ -z "$DEPLOYMENT_MODE_INPUT" ]]; then
1681
+ if [[ -z "$RAINBOND_URL_INPUT" || "$RAINBOND_URL_INPUT" == "$SAAS_DEFAULT_URL" ]]; then
1682
+ DEPLOYMENT_MODE_INPUT="saas"
1683
+ else
1684
+ DEPLOYMENT_MODE_INPUT="self-hosted"
1685
+ fi
1686
+ fi
1687
+
1688
+ local base_url_input base_url
1689
+ case "$DEPLOYMENT_MODE_INPUT" in
1690
+ saas)
1691
+ base_url_input="${RAINBOND_URL_INPUT:-$SAAS_DEFAULT_URL}"
1692
+ ;;
1693
+ self-hosted)
1694
+ base_url_input="$(prompt_for_value "Rainbond Console 地址" "$RAINBOND_URL_INPUT")"
1695
+ ;;
1696
+ *)
1697
+ die "未知部署形态:$DEPLOYMENT_MODE_INPUT"
1698
+ ;;
1699
+ esac
1700
+ base_url="$(normalize_rainbond_url "$base_url_input")"
1701
+ confirm_insecure_http_if_needed "$base_url"
1702
+
1703
+ local token generic_mcp_url codex_mcp_url claude_mcp_url
1704
+ local migrate_codex=0 migrate_claude=0 validate_codex=0 validate_claude=0
1705
+ obtain_rainbond_token "$base_url" "$DEPLOYMENT_MODE_INPUT"
1706
+ token="$OBTAINED_RAINBOND_TOKEN"
1707
+ generic_mcp_url="${base_url}/console/mcp/query"
1708
+ codex_mcp_url="${base_url}/console/mcp/rainskills/codex/query"
1709
+ claude_mcp_url="${base_url}/console/mcp/rainskills/claude-code/query"
1710
+
1711
+ if codex_config_matches "$generic_mcp_url"; then
1712
+ migrate_codex=1
1713
+ validate_codex=1
1714
+ elif codex_config_matches "$codex_mcp_url"; then
1715
+ validate_codex=1
1716
+ fi
1717
+ if claude_config_matches "$generic_mcp_url"; then
1718
+ migrate_claude=1
1719
+ validate_claude=1
1720
+ elif claude_config_matches "$claude_mcp_url"; then
1721
+ validate_claude=1
1722
+ fi
1723
+ if (( validate_codex == 0 && validate_claude == 0 )); then
1724
+ validate_codex=1
1725
+ fi
1726
+
1727
+ if (( validate_codex == 1 && validate_claude == 1 )); then
1728
+ RAINSKILLS_INSTALL_CLIENT="both"
1729
+ elif (( validate_claude == 1 )); then
1730
+ RAINSKILLS_INSTALL_CLIENT="claude_code"
1731
+ else
1732
+ RAINSKILLS_INSTALL_CLIENT="codex"
1733
+ fi
1734
+ record_rainskills_authorization "$base_url" "$token"
1735
+
1736
+ set_rainskills_failure_context "verification" "mcp_verification_failed"
1737
+ if (( validate_codex == 1 )); then
1738
+ validate_mcp_connectivity "$codex_mcp_url" "$token"
1739
+ token="$VALIDATED_TOKEN"
1740
+ fi
1741
+ if (( validate_claude == 1 )); then
1742
+ validate_mcp_connectivity "$claude_mcp_url" "$token"
1743
+ token="$VALIDATED_TOKEN"
1744
+ fi
1745
+
1746
+ export RAINBOND_JWT="$token"
1747
+ write_token_file "$token" "$base_url"
1748
+ configure_shell_autoload
1749
+
1750
+ set_rainskills_failure_context "configuration" "mcp_configuration_failed"
1751
+ if (( migrate_codex == 1 )); then
1752
+ migrate_codex_mcp_if_generic "$generic_mcp_url" "$codex_mcp_url" \
1753
+ || die "Codex MCP 专用地址迁移失败"
1754
+ fi
1755
+ if (( migrate_claude == 1 )); then
1756
+ migrate_claude_mcp_if_generic "$generic_mcp_url" "$claude_mcp_url" \
1757
+ || die "Claude MCP 专用地址迁移失败"
1758
+ fi
1759
+
1760
+ log ""
1761
+ log "JWT 刷新完成。脚本管理的旧通用 MCP 地址已按需迁移到 RainSkills 专用地址。"
1762
+ log "请重启 Claude Code 或 Codex 让新 JWT 生效(它们在启动时一次性读取 RAINBOND_JWT)。"
1763
+ if [[ -n "$ACTIVE_SHELL_RC" ]]; then
1764
+ log "如果想立刻在当前终端使用,请执行:source ${ACTIVE_SHELL_RC}"
1765
+ fi
1766
+ RAINSKILLS_INSTALL_TERMINAL_REPORTED=1
1767
+ report_rainskills_installation "configured" "success"
1768
+ }
1769
+
1770
+ configure_mcp() {
1771
+ [[ "$SKIP_MCP" -eq 0 ]] || return 0
1772
+ [[ -z "$CUSTOM_DEST" ]] || return 0
1773
+
1774
+ if { [[ "$NON_INTERACTIVE" -eq 1 ]] || [[ ! -t 0 ]]; } && \
1775
+ [[ -z "$RAINBOND_URL_INPUT" && -z "$RAINBOND_TOKEN_INPUT" && -z "$DEPLOYMENT_MODE_INPUT" && -z "$RAINBOND_USERNAME_INPUT" && -z "$RAINBOND_PASSWORD_INPUT" ]]; then
1776
+ log "非交互模式下未提供 Rainbond 连接信息,已跳过 MCP 配置。"
1777
+ return 0
1778
+ fi
1779
+
1780
+ set_rainskills_failure_context "authorization" "authorization_failed"
1781
+ ensure_python3
1782
+ resolve_deployment_mode
1783
+
1784
+ local base_url_input base_url
1785
+ case "$DEPLOYMENT_MODE_INPUT" in
1786
+ saas)
1787
+ if [[ "$RAINBOND_URL_FROM_FLAG" -eq 1 && -n "$RAINBOND_URL_INPUT" ]]; then
1788
+ base_url_input="$RAINBOND_URL_INPUT"
1789
+ else
1790
+ base_url_input="$SAAS_DEFAULT_URL"
1791
+ fi
1792
+ ;;
1793
+ self-hosted)
1794
+ if [[ "$RAINBOND_URL_FROM_FLAG" -eq 1 && -n "$RAINBOND_URL_INPUT" ]]; then
1795
+ # 显式 --rainbond-url 传入,视为明确意图,直接采用。
1796
+ base_url_input="$RAINBOND_URL_INPUT"
1797
+ else
1798
+ # 用户在交互菜单中显式选了"私有化部署",意图就是自填 Console 地址。
1799
+ # 继承自 shell env 的 RAINBOND_URL(无论是 SaaS 默认值还是某个旧的私有化
1800
+ # 地址)只能作为可回车沿用的提示,绝不静默采用——否则会把用户想切换的
1801
+ # 新私有化部署导回旧地址。
1802
+ base_url_input="$(prompt_with_default "Rainbond Console 地址" "$RAINBOND_URL_INPUT")"
1803
+ fi
1804
+ ;;
1805
+ *)
1806
+ die "未知部署形态:$DEPLOYMENT_MODE_INPUT"
1807
+ ;;
1808
+ esac
1809
+ base_url="$(normalize_rainbond_url "$base_url_input")"
1810
+ confirm_insecure_http_if_needed "$base_url"
1811
+
1812
+ local token codex_mcp_url claude_mcp_url
1813
+ obtain_rainbond_token "$base_url" "$DEPLOYMENT_MODE_INPUT"
1814
+ token="$OBTAINED_RAINBOND_TOKEN"
1815
+ RAINSKILLS_INSTALL_CLIENT="$(rainskills_install_client_for_target "$TARGET")"
1816
+ record_rainskills_authorization "$base_url" "$token"
1817
+ codex_mcp_url="${base_url}/console/mcp/rainskills/codex/query"
1818
+ claude_mcp_url="${base_url}/console/mcp/rainskills/claude-code/query"
1819
+
1820
+ set_rainskills_failure_context "verification" "mcp_verification_failed"
1821
+ case "$TARGET" in
1822
+ codex)
1823
+ validate_mcp_connectivity "$codex_mcp_url" "$token"
1824
+ token="$VALIDATED_TOKEN"
1825
+ ;;
1826
+ claude)
1827
+ validate_mcp_connectivity "$claude_mcp_url" "$token"
1828
+ token="$VALIDATED_TOKEN"
1829
+ ;;
1830
+ all)
1831
+ validate_mcp_connectivity "$codex_mcp_url" "$token"
1832
+ token="$VALIDATED_TOKEN"
1833
+ validate_mcp_connectivity "$claude_mcp_url" "$token"
1834
+ token="$VALIDATED_TOKEN"
1835
+ ;;
1836
+ esac
1837
+
1838
+ # Refresh this process's env for any downstream CLI behavior that resolves it.
1839
+ export RAINBOND_JWT="$token"
1840
+ write_token_file "$token" "$base_url"
1841
+ configure_shell_autoload
1842
+
1843
+ set_rainskills_failure_context "configuration" "mcp_configuration_failed"
1844
+ local configured=0
1845
+ case "$TARGET" in
1846
+ codex)
1847
+ configure_codex_mcp "$codex_mcp_url" && configured=1 || true
1848
+ ;;
1849
+ claude)
1850
+ configure_claude_mcp "$claude_mcp_url" && configured=1 || true
1851
+ ;;
1852
+ all)
1853
+ configure_codex_mcp "$codex_mcp_url" && configured=$((configured + 1)) || true
1854
+ configure_claude_mcp "$claude_mcp_url" && configured=$((configured + 1)) || true
1855
+ ;;
1856
+ esac
1857
+
1858
+ (( configured > 0 )) || die "所选平台都未能完成 MCP 配置。"
1859
+ RAINSKILLS_INSTALL_TERMINAL_REPORTED=1
1860
+ report_rainskills_installation "configured" "success"
1861
+
1862
+ if [[ -n "$ACTIVE_SHELL_RC" ]]; then
1863
+ log "当前 shell 提示:新开的终端会自动从 ${ACTIVE_SHELL_RC} 加载 RAINBOND_JWT。"
1864
+ log "如果你想立刻在当前终端使用 Codex 或 Claude,请执行:source ${ACTIVE_SHELL_RC}"
1865
+ fi
1866
+ }
1867
+
1868
+ main() {
1869
+ parse_args "$@"
1870
+
1871
+ if [[ "$ACTION" == "refresh" ]]; then
1872
+ do_refresh
1873
+ return 0
1874
+ fi
1875
+
1876
+ resolve_target
1877
+ RAINSKILLS_INSTALL_CLIENT="$(rainskills_install_client_for_target "$TARGET")"
1878
+ set_rainskills_failure_context "skill_installation" "skill_installation_failed"
1879
+
1880
+ local skills=()
1881
+ local skill_dir
1882
+ while IFS= read -r skill_dir; do
1883
+ skills+=("$skill_dir")
1884
+ done < <(find "$SCRIPT_DIR" -maxdepth 1 -mindepth 1 -type d -name 'rainbond-*' | sort)
1885
+ [[ "${#skills[@]}" -gt 0 ]] || die "在 $SCRIPT_DIR 下没有找到 rainbond-* skill 目录。"
1886
+
1887
+ for skill_dir in "${skills[@]}"; do
1888
+ validate_skill_dir "$skill_dir"
1889
+ done
1890
+
1891
+ local destinations=()
1892
+ local dest
1893
+ while IFS= read -r dest; do
1894
+ destinations+=("$dest")
1895
+ done < <(collect_destinations)
1896
+
1897
+ for dest in "${destinations[@]}"; do
1898
+ log "安装到:$dest"
1899
+ for skill_dir in "${skills[@]}"; do
1900
+ copy_skill "$skill_dir" "$dest"
1901
+ done
1902
+ done
1903
+
1904
+ configure_mcp
1905
+
1906
+ log ""
1907
+ log "安装完成。本次:${INSTALL_COUNT_NEW} 项新装 / ${INSTALL_COUNT_UPDATED} 项已更新 / ${INSTALL_COUNT_UNCHANGED} 项已是最新 / ${INSTALL_COUNT_FORCED} 项强制覆盖"
1908
+ if [[ -n "$CUSTOM_DEST" || "$SKIP_MCP" -eq 1 ]]; then
1909
+ log "请重启 Claude Code 或 Codex 以加载新技能。"
1910
+ else
1911
+ log "请在重新加载 shell 环境后重启 Claude Code 或 Codex。"
1912
+ fi
1913
+ }
1914
+
1915
+ trap 'handle_installer_signal 130' INT
1916
+ trap 'handle_installer_signal 143' TERM
1917
+ trap 'handle_installer_exit "$?"' EXIT
1918
+ initialize_rainskills_installation_reporting "$@"
1919
+ main "$@"