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.
- package/LICENSE +201 -0
- package/README.md +374 -0
- package/bin/rainskills.js +71 -0
- package/install.sh +1919 -0
- package/package.json +55 -0
- package/rainbond-app-assistant/SKILL.md +1319 -0
- package/rainbond-app-assistant/evals/01-linked-topology-missing.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/01-linked-topology-missing.response.md +53 -0
- package/rainbond-app-assistant/evals/02-linked-topology-building.expected.yaml +19 -0
- package/rainbond-app-assistant/evals/02-linked-topology-building.response.md +53 -0
- package/rainbond-app-assistant/evals/03-runtime-healthy-delivery-unknown.expected.yaml +19 -0
- package/rainbond-app-assistant/evals/03-runtime-healthy-delivery-unknown.response.md +53 -0
- package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.response.md +58 -0
- package/rainbond-app-assistant/evals/05-delivered-explicit-dev-to-test.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/05-delivered-explicit-dev-to-test.response.md +80 -0
- package/rainbond-app-assistant/evals/06-manual-validation-stop.expected.yaml +16 -0
- package/rainbond-app-assistant/evals/06-manual-validation-stop.response.md +58 -0
- package/rainbond-app-assistant/evals/07-cluster-capacity-blocked.expected.yaml +15 -0
- package/rainbond-app-assistant/evals/07-cluster-capacity-blocked.response.md +53 -0
- package/rainbond-app-assistant/evals/08-code-build-handoff-stop.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/08-code-build-handoff-stop.response.md +56 -0
- package/rainbond-app-assistant/evals/09-source-backed-failure-no-fallback.expected.yaml +22 -0
- package/rainbond-app-assistant/evals/09-source-backed-failure-no-fallback.response.md +56 -0
- package/rainbond-app-assistant/evals/10-identity-ambiguous-stop.expected.yaml +21 -0
- package/rainbond-app-assistant/evals/10-identity-ambiguous-stop.response.md +45 -0
- package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.expected.yaml +28 -0
- package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.response.md +64 -0
- package/rainbond-app-assistant/evals/12-complex-suite-needs-descriptor-stop.expected.yaml +24 -0
- package/rainbond-app-assistant/evals/12-complex-suite-needs-descriptor-stop.response.md +45 -0
- package/rainbond-app-assistant/evals/13-explicit-git-url-locks-source.expected.yaml +28 -0
- package/rainbond-app-assistant/evals/13-explicit-git-url-locks-source.response.md +53 -0
- package/rainbond-app-assistant/evals/14-strategy-switch-cleans-residue.expected.yaml +22 -0
- package/rainbond-app-assistant/evals/14-strategy-switch-cleans-residue.response.md +53 -0
- package/rainbond-app-assistant/evals/15-turn-discipline-reuse-alias.expected.yaml +23 -0
- package/rainbond-app-assistant/evals/15-turn-discipline-reuse-alias.response.md +53 -0
- package/rainbond-app-assistant/schemas/app-assistant-result.schema.yaml +279 -0
- package/rainbond-app-assistant/scripts/run_app_assistant_evals.py +80 -0
- package/rainbond-app-assistant/scripts/validate_app_assistant_output.py +935 -0
- package/rainbond-app-version-assistant/SKILL.md +444 -0
- package/rainbond-delivery-verifier/SKILL.md +372 -0
- package/rainbond-delivery-verifier/evals/01-delivered-verified.expected.yaml +19 -0
- package/rainbond-delivery-verifier/evals/01-delivered-verified.response.md +31 -0
- package/rainbond-delivery-verifier/evals/02-delivered-manual-validation.expected.yaml +19 -0
- package/rainbond-delivery-verifier/evals/02-delivered-manual-validation.response.md +31 -0
- package/rainbond-delivery-verifier/evals/03-partially-delivered-building.expected.yaml +20 -0
- package/rainbond-delivery-verifier/evals/03-partially-delivered-building.response.md +31 -0
- package/rainbond-delivery-verifier/evals/04-blocked-capacity.expected.yaml +18 -0
- package/rainbond-delivery-verifier/evals/04-blocked-capacity.response.md +31 -0
- package/rainbond-delivery-verifier/evals/05-blocked-no-usable-url.expected.yaml +20 -0
- package/rainbond-delivery-verifier/evals/05-blocked-no-usable-url.response.md +31 -0
- package/rainbond-delivery-verifier/evals/06-blocked-runtime-unhealthy.expected.yaml +19 -0
- package/rainbond-delivery-verifier/evals/06-blocked-runtime-unhealthy.response.md +31 -0
- package/rainbond-delivery-verifier/schemas/delivery-verification-result.schema.yaml +84 -0
- package/rainbond-delivery-verifier/scripts/run_delivery_verifier_evals.py +78 -0
- package/rainbond-delivery-verifier/scripts/validate_delivery_verifier_output.py +728 -0
- package/rainbond-env-sync/SKILL.md +496 -0
- package/rainbond-fullstack-bootstrap/SKILL.md +262 -0
- package/rainbond-fullstack-bootstrap/evals/01-reuse-only.expected.yaml +27 -0
- package/rainbond-fullstack-bootstrap/evals/01-reuse-only.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/01-reuse-only.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.expected.yaml +27 -0
- package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.response.md +44 -0
- package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.expected.yaml +26 -0
- package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/04-template-skip.expected.yaml +31 -0
- package/rainbond-fullstack-bootstrap/evals/04-template-skip.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/04-template-skip.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.expected.yaml +33 -0
- package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/06-external-artifact-unreachable.expected.yaml +23 -0
- package/rainbond-fullstack-bootstrap/evals/06-external-artifact-unreachable.response.md +39 -0
- package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.expected.yaml +29 -0
- package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.expected.yaml +30 -0
- package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.expected.yaml +32 -0
- package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.response.md +41 -0
- package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.expected.yaml +34 -0
- package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.response.md +43 -0
- package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.expected.yaml +46 -0
- package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.response.md +48 -0
- package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.expected.yaml +43 -0
- package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.response.md +63 -0
- package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.expected.yaml +47 -0
- package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.response.md +60 -0
- package/rainbond-fullstack-bootstrap/modules/10-context-loading.md +157 -0
- package/rainbond-fullstack-bootstrap/modules/20-scope-and-boundaries.md +105 -0
- package/rainbond-fullstack-bootstrap/modules/30-creation-rules.md +337 -0
- package/rainbond-fullstack-bootstrap/modules/40-source-and-package-rules.md +245 -0
- package/rainbond-fullstack-bootstrap/modules/50-workflow-and-convergence.md +198 -0
- package/rainbond-fullstack-bootstrap/modules/60-verification-and-handoffs.md +143 -0
- package/rainbond-fullstack-bootstrap/modules/70-output-contract.md +152 -0
- package/rainbond-fullstack-bootstrap/references/manifest-v1-reference.md +150 -0
- package/rainbond-fullstack-bootstrap/references/quick-reference.md +101 -0
- package/rainbond-fullstack-bootstrap/references/source-build-parameter-guide.md +264 -0
- package/rainbond-fullstack-bootstrap/references/sql-init-recipe.md +83 -0
- package/rainbond-fullstack-bootstrap/schemas/bootstrap-result.schema.yaml +217 -0
- package/rainbond-fullstack-bootstrap/scripts/run_bootstrap_evals.py +74 -0
- package/rainbond-fullstack-bootstrap/scripts/validate_bootstrap_output.py +615 -0
- package/rainbond-fullstack-troubleshooter/SKILL.md +870 -0
- package/rainbond-fullstack-troubleshooter/evals/01-source-build-failed.expected.yaml +29 -0
- package/rainbond-fullstack-troubleshooter/evals/01-source-build-failed.response.md +55 -0
- package/rainbond-fullstack-troubleshooter/evals/02-source-build-still-running.expected.yaml +20 -0
- package/rainbond-fullstack-troubleshooter/evals/02-source-build-still-running.response.md +53 -0
- package/rainbond-fullstack-troubleshooter/evals/03-dependency-missing.expected.yaml +21 -0
- package/rainbond-fullstack-troubleshooter/evals/03-dependency-missing.response.md +59 -0
- package/rainbond-fullstack-troubleshooter/evals/04-wrong-connection-values.expected.yaml +19 -0
- package/rainbond-fullstack-troubleshooter/evals/04-wrong-connection-values.response.md +58 -0
- package/rainbond-fullstack-troubleshooter/evals/05-env-naming-incompatibility.expected.yaml +18 -0
- package/rainbond-fullstack-troubleshooter/evals/05-env-naming-incompatibility.response.md +59 -0
- package/rainbond-fullstack-troubleshooter/evals/06-frontend-access-path-issue.expected.yaml +21 -0
- package/rainbond-fullstack-troubleshooter/evals/06-frontend-access-path-issue.response.md +56 -0
- package/rainbond-fullstack-troubleshooter/evals/07-cluster-capacity-blocked.expected.yaml +20 -0
- package/rainbond-fullstack-troubleshooter/evals/07-cluster-capacity-blocked.response.md +57 -0
- package/rainbond-fullstack-troubleshooter/evals/08-code-or-build-handoff-needed.expected.yaml +21 -0
- package/rainbond-fullstack-troubleshooter/evals/08-code-or-build-handoff-needed.response.md +55 -0
- package/rainbond-fullstack-troubleshooter/evals/09-external-artifact-unreachable.expected.yaml +28 -0
- package/rainbond-fullstack-troubleshooter/evals/09-external-artifact-unreachable.response.md +53 -0
- package/rainbond-fullstack-troubleshooter/schemas/troubleshoot-result.schema.yaml +217 -0
- package/rainbond-fullstack-troubleshooter/scripts/run_troubleshooter_evals.py +80 -0
- package/rainbond-fullstack-troubleshooter/scripts/validate_troubleshoot_output.py +789 -0
- package/rainbond-project-init/SKILL.md +1001 -0
- 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 "$@"
|