@bluevs/ttcli 0.0.1 → 0.0.2

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/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "name": "@bluevs/ttcli",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "TikTok CLI - designed for AI Agent and developers",
5
5
  "bin": {
6
6
  "ttcli": "scripts/run.js"
7
7
  },
8
- "scripts": {},
8
+ "scripts": {
9
+ "postinstall": "node -e \"console.log('\\n📦 ttcli installed. To install the openclaw skill, run:\\n bash $(npm root -g)/@bluevs/ttcli/skills/ttcli/scripts/install-skill.sh\\n')\""
10
+ },
9
11
  "optionalDependencies": {
10
- "@bluevs/ttcli-darwin-arm64": "0.0.1",
11
- "@bluevs/ttcli-darwin-x64": "0.0.1",
12
- "@bluevs/ttcli-linux-arm64": "0.0.1",
13
- "@bluevs/ttcli-linux-x64": "0.0.1",
14
- "@bluevs/ttcli-win32-arm64": "0.0.1",
15
- "@bluevs/ttcli-win32-x64": "0.0.1"
12
+ "@bluevs/ttcli-darwin-arm64": "0.0.2",
13
+ "@bluevs/ttcli-darwin-x64": "0.0.2",
14
+ "@bluevs/ttcli-linux-arm64": "0.0.2",
15
+ "@bluevs/ttcli-linux-x64": "0.0.2",
16
+ "@bluevs/ttcli-win32-arm64": "0.0.2",
17
+ "@bluevs/ttcli-win32-x64": "0.0.2"
16
18
  },
17
19
  "engines": {
18
20
  "node": ">=16"
@@ -23,6 +25,7 @@
23
25
  },
24
26
  "license": "MIT",
25
27
  "files": [
26
- "scripts/run.js"
28
+ "scripts/run.js",
29
+ "skills/"
27
30
  ]
28
31
  }
@@ -0,0 +1,356 @@
1
+ ---
2
+ name: ttcli
3
+ description: 直接调用 TikTok Marketing API(access token 鉴权,不经 VH 平台/项目层)。当用户提供原生 TikTok access_token + advertiser_id、要直连 TikTok 创建或查询 Smart+ campaign/adgroup/ad、或查 identity/app/region 等元数据时使用。注意:用户走 VH 项目流程(po_xxxx + account_id)的 TikTok 创编应改走 vhcli tiktok。
4
+ ---
5
+
6
+ # TikTok CLI (`ttcli`)
7
+
8
+ `ttcli` wraps TikTok Marketing API (`business-api.tiktok.com`) as a CLI optimized for AI agents. JSON output by default; the `--human` flag exists for project-owner debugging only — agents should never pass it.
9
+
10
+ This skill is intentionally short. Always treat `ttcli -h` and `ttcli <subcommand> -h` as the primary reference for flags.
11
+
12
+ ## ttcli vs vhcli tiktok — 选哪一个
13
+
14
+ openclaw 同时装了两个 TikTok 广告工具,触发条件不同:
15
+
16
+ | 用户提供的上下文 | 用 |
17
+ |---|---|
18
+ | 原生 `access_token` + `advertiser_id`(用户从 TikTok Marketing 后台直接复制的)| **ttcli** |
19
+ | `po_xxxx` 项目 ID、提到 VH 平台、`customer_project_id` / `account_id` | **vhcli tiktok** |
20
+ | 想直连 TikTok 调试原生 API 行为 / 看真实错误码 | **ttcli** |
21
+ | 想要 VH 的项目级审计、审批、异步任务 | **vhcli tiktok** |
22
+
23
+ **鉴权材料是关键判断点**——access_token 直接给的走 ttcli,从 VH project 拿的走 vhcli。如果用户没说清楚,**先问**他要给你哪种 token,不要猜。
24
+
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ # 1) 装 CLI
30
+ npm install -g @bluevs/ttcli
31
+
32
+ # 2) 把 skill 同步到 openclaw workspace(一次即可;自动检测版本变化)
33
+ bash $(npm root -g)/@bluevs/ttcli/skills/ttcli/scripts/install-skill.sh
34
+ ```
35
+
36
+ `install-skill.sh` 会:
37
+ - 检测 `ttcli` 是否在 PATH,没有就 `npm install -g @bluevs/ttcli`
38
+ - 把 `SKILL.md` 拷到 `${OPENCLAW_WORKSPACE:-$HOME/.openclaw/workspace}/skills/ttcli/SKILL.md`
39
+ - 用 `.installed_version` 文件追踪版本,相同则跳过
40
+ - 若本机同时存在 `~/.claude/skills/`(开发机),顺手同步一份
41
+
42
+ 升级 ttcli:重跑 `npm install -g @bluevs/ttcli` 然后再跑一次 `install-skill.sh`,会自动检测到版本变化并替换 SKILL.md。
43
+
44
+ ## Trigger
45
+
46
+ Use when the user wants to:
47
+ - Create / list TikTok Smart+ campaigns or adgroups
48
+ - Look up `app_id`, `location_id`, or other targeting metadata
49
+ - Set or inspect TikTok API credentials
50
+
51
+ ## Network: requires HTTPS_PROXY in mainland China
52
+
53
+ `business-api.tiktok.com` is unreachable from mainland China without a proxy. Go's default HTTP transport honors `HTTPS_PROXY`, so:
54
+
55
+ ```
56
+ HTTPS_PROXY=http://127.0.0.1:7897 ttcli ...
57
+ ```
58
+
59
+ If you see `context deadline exceeded` or `dial: connection refused`, check this first.
60
+
61
+ ## Output: JSON by default
62
+
63
+ `ttcli` writes pretty-printed JSON to stdout. Pipe to `jq` / `python -m json.tool` to extract fields. There is no `--json` flag — JSON is the default. stderr carries logs and prompts (`auto-generated request_id: ...`, `saved credentials to ...`); never mix it into stdout consumers.
64
+
65
+ ## Discovery before write
66
+
67
+ Before creating a campaign or adgroup, gather the IDs you'll reference:
68
+
69
+ ```
70
+ ttcli auth show # confirm credentials loaded + their source
71
+ ttcli app list # → app_id
72
+ ttcli smart-plus campaign list # → existing campaign_id (or you'll create one)
73
+ ttcli tool region \
74
+ --placements=PLACEMENT_TIKTOK \
75
+ --objective-type=APP_PROMOTION \
76
+ --level-range=TO_COUNTRY # → location_id for targeting_spec.location_ids
77
+ ```
78
+
79
+ `tool region` accepts `--language=zh-CN` (or any of 78 supported codes) for localized region names — useful when surfacing options to the user.
80
+
81
+ ## Write safety: ALWAYS dry-run first
82
+
83
+ `smart-plus campaign create` and `smart-plus adgroup create` create real entities in the TikTok account that persist until deleted. Run with `--dry-run` first, inspect the merged body, only then re-run without it.
84
+
85
+ Default `operation_status` per command:
86
+ - `campaign create` → TikTok's default `ENABLE`. Pass `--operation-status=DISABLE` for safe testing.
87
+ - `adgroup create` → ttcli forces `DISABLE` (overriding TikTok's `ENABLE`). Do NOT set `--operation-status=ENABLE` unless the user explicitly asks.
88
+
89
+ A `DISABLE` campaign or adgroup creates the entity but does not run / spend.
90
+
91
+ ## Two write patterns
92
+
93
+ These two endpoints differ in scale; they use different CLI styles.
94
+
95
+ ### `smart-plus campaign create` — flag-driven
96
+
97
+ Roughly 25 fields, all exposed as flags:
98
+
99
+ ```
100
+ ttcli smart-plus campaign create \
101
+ --objective-type=APP_PROMOTION \
102
+ --app-promotion-type=APP_INSTALL \
103
+ --campaign-name="my_test" \
104
+ --budget=20 \
105
+ --operation-status=DISABLE \
106
+ --dry-run
107
+ ```
108
+
109
+ See `ttcli smart-plus campaign create --help` for the full flag list (objective_type / sales_destination / catalog / budget_mode / RTA etc.).
110
+
111
+ ### `smart-plus adgroup create` — JSON body + essential flag overrides
112
+
113
+ 80+ fields with nested `targeting_spec`, multiple nested arrays. Body is the source of truth; flags override only 5 essential keys.
114
+
115
+ ```
116
+ ttcli smart-plus adgroup create \
117
+ --body-from-file=adgroup.json \ # OR --body=- to read from stdin / pipe
118
+ --campaign-id=<id> \
119
+ --dry-run
120
+ ```
121
+
122
+ Body sources (mutually exclusive):
123
+ - `--body='{...}'` — inline JSON literal
124
+ - `--body=-` — read from stdin
125
+ - `--body-from-file=PATH` — read from file
126
+
127
+ The 5 essential flags that override matching body keys:
128
+ - `--advertiser-id` (global), `--campaign-id`, `--request-id`, `--operation-status`, `--dry-run`
129
+
130
+ Do NOT try to flag fields like `adgroup_name`, `bid_price`, `targeting_spec` — they belong in the body. The CLI will not expose flags for them.
131
+
132
+ Minimal Android APP_INSTALL adgroup body (use as a template):
133
+
134
+ ```json
135
+ {
136
+ "adgroup_name": "...",
137
+ "app_id": "...",
138
+ "promotion_type": "APP_ANDROID",
139
+ "optimization_goal": "INSTALL",
140
+ "bid_type": "BID_TYPE_NO_BID",
141
+ "billing_event": "OCPM",
142
+ "schedule_type": "SCHEDULE_START_END",
143
+ "schedule_start_time": "YYYY-MM-DD HH:MM:SS",
144
+ "schedule_end_time": "YYYY-MM-DD HH:MM:SS",
145
+ "targeting_optimization_mode": "AUTOMATIC",
146
+ "targeting_spec": {
147
+ "location_ids": ["..."],
148
+ "spc_audience_age": "OVER_EIGHTEEN",
149
+ "operating_systems": ["ANDROID"]
150
+ }
151
+ }
152
+ ```
153
+
154
+ 11 unconditionally-required fields are validated client-side: `advertiser_id`, `request_id`, `campaign_id`, `adgroup_name`, `promotion_type`, `optimization_goal`, `bid_type`, `billing_event`, `schedule_type`, `schedule_start_time`, `targeting_spec`. The first three are usually injected from flags.
155
+
156
+ ## MINI_GAME (TikTok Minis) — verified configuration
157
+
158
+ Creating MINI_GAME adgroups has many undocumented or scattered constraints. The combination below was end-to-end verified. Use as the starting template; do not relax fields without a specific reason.
159
+
160
+ **Campaign must be CBO=off + INFINITE**:
161
+
162
+ ```
163
+ ttcli smart-plus campaign create \
164
+ --objective-type=APP_PROMOTION \
165
+ --app-promotion-type=MINIS \
166
+ --campaign-name="..." \
167
+ --budget-optimize-on=false \
168
+ --budget-mode=BUDGET_MODE_INFINITE \
169
+ --operation-status=DISABLE
170
+ ```
171
+
172
+ **Adgroup body** (MINI_GAME with VALUE/ROAS optimization):
173
+
174
+ ```json
175
+ {
176
+ "adgroup_name": "...",
177
+ "minis_id": "<minis-id>",
178
+ "promotion_type": "MINI_GAME",
179
+ "placement_type": "PLACEMENT_TYPE_NORMAL",
180
+ "placements": ["PLACEMENT_TIKTOK"],
181
+ "budget_mode": "BUDGET_MODE_DYNAMIC_DAILY_BUDGET",
182
+ "budget": 50,
183
+ "schedule_type": "SCHEDULE_FROM_NOW",
184
+ "schedule_start_time": "YYYY-MM-DD HH:MM:SS",
185
+ "schedule_end_time": "YYYY-MM-DD HH:MM:SS",
186
+ "optimization_goal": "VALUE",
187
+ "optimization_event": "AD_REVENUE_VALUE",
188
+ "billing_event": "OCPM",
189
+ "bid_type": "BID_TYPE_NO_BID",
190
+ "deep_bid_type": "VO_MIN_ROAS",
191
+ "roas_bid": 1,
192
+ "vbo_window": "ZERO_DAY",
193
+ "click_attribution_window": "THIRTY_DAYS",
194
+ "view_attribution_window": "OFF",
195
+ "engaged_view_attribution_window": "OFF",
196
+ "targeting_optimization_mode": "AUTOMATIC",
197
+ "targeting_spec": {
198
+ "age_groups": ["AGE_18_24","AGE_25_34","AGE_35_44","AGE_45_54","AGE_55_100"],
199
+ "gender": "GENDER_UNLIMITED",
200
+ "location_ids": ["6252001"],
201
+ "spc_audience_age": "OVER_EIGHTEEN"
202
+ }
203
+ }
204
+ ```
205
+
206
+ **MINI_GAME-specific constraints discovered through trial**:
207
+
208
+ | Constraint | Symptom if violated |
209
+ |---|---|
210
+ | `optimization_goal` MUST be `VALUE` (not INSTALL/CLICK/IN_APP_EVENT) | `40002 Invalid optimization goal. Please change your goal or promotion type.` |
211
+ | `optimization_event` for VALUE goal: `AD_REVENUE_VALUE` (TikTok normalizes to `IMPRESSION_LEVEL_AD_REVENUE`) | `40002 Invalid event` |
212
+ | `deep_bid_type` MUST be `VO_MIN_ROAS` for VALUE | implicit failure |
213
+ | `roas_bid` REQUIRED when `VO_MIN_ROAS` (range 0.01–1000) | implicit failure |
214
+ | `vbo_window` REQUIRED for VO_MIN_ROAS, `ZERO_DAY` or `SEVEN_DAYS` | implicit failure |
215
+ | `placement_type` MUST be `PLACEMENT_TYPE_NORMAL` (not AUTOMATIC) | `40002 placement_type only supports 'PLACEMENT_TYPE_NORMAL'.` |
216
+ | `placements` MUST contain `PLACEMENT_TIKTOK` (Minis only run on TikTok) | will fail validation |
217
+ | `click_attribution_window` MUST be `THIRTY_DAYS` (only valid value for MINI_GAME) | implicit failure |
218
+ | `budget_mode` MUST be `BUDGET_MODE_DYNAMIC_DAILY_BUDGET` (not INFINITE) when using `VO_MIN_ROAS` | `40002 This ad does not support infinite budget mode.` |
219
+ | Campaign-level CBO must be off if you set adgroup-level `budget_mode` | `40002 Something went wrong. Contact support.` (opaque; pure trial-and-error) |
220
+ | `app_id` MUST NOT be set; use `minis_id` instead | `40002 The app(app_id: ...) info does not exist` |
221
+
222
+ **Failure mode: opaque "Allowlist not enabled"**
223
+
224
+ Some MINI_GAME features (VO_MIN_ROAS, vbo_window=ZERO_DAY, BID_TYPE_NO_BID for max delivery) are whitelist-gated for new advertisers. The error message just says `Allowlist not enabled` without specifying which field. If you hit this, ask the user to:
225
+ 1. Confirm in TikTok Ads Manager that an existing MINI_GAME adgroup with similar settings runs successfully on this advertiser.
226
+ 2. If not: contact TikTok sales rep to enable the relevant whitelist.
227
+
228
+ **MINI_APP (short drama) variant**
229
+
230
+ If `promotion_type=MINI_APP` (short drama series, not games), some fields differ. Doc gives `THIRTY_TWO_DAYS` and `ONE_HUNDRED_EIGHTY_DAYS` `click_attribution_window` for MINI_APP+`ACTIVE_PAY` — these constraints are MINI_APP-specific. Reuse the MINI_GAME template above and adjust if user wants short drama promotion.
231
+
232
+ ## Ad creation (`smart-plus ad create`)
233
+
234
+ Creates one or more ads under an adgroup. The endpoint has no `request_id` field — no idempotency design at this level.
235
+
236
+ **Body sources** (same pattern as adgroup): `--body=...` / `--body=-` / `--body-from-file=PATH`.
237
+
238
+ **Essential flag overrides**:
239
+ - `--advertiser-id` (global), `--adgroup-id`, `--operation-status` (defaults to DISABLE), `--dry-run`
240
+
241
+ **Required body fields**: `creative_list` (non-empty array). `advertiser_id` and `adgroup_id` typically injected from flags.
242
+
243
+ **TikTok placement → MUST use Spark Ads**
244
+
245
+ Per the doc: "对于现有广告账号,当广告组的投放广告位为自动版位或手动广告位且包含 TikTok 时,已不再支持使用自定义身份创建非 Spark Ads。" Translation: non-Spark Ads no longer allowed on TikTok placement. Since MINI_GAME / MINI_APP / most adgroups use `PLACEMENT_TIKTOK`, you MUST use Spark Ads:
246
+
247
+ ```json
248
+ {
249
+ "creative_list": [
250
+ {
251
+ "creative_info": {
252
+ "ad_format": "SINGLE_VIDEO",
253
+ "tiktok_item_id": "<existing TikTok video post ID>",
254
+ "identity_type": "TT_USER",
255
+ "identity_id": "<advertiser identity>"
256
+ }
257
+ }
258
+ ],
259
+ "landing_page_url_list": [{"landing_page_url": "..."}]
260
+ }
261
+ ```
262
+
263
+ **Helper APIs needed (NOT yet wrapped by ttcli — block real ad creation)**:
264
+
265
+ | Need | Endpoint | ttcli command |
266
+ |---|---|---|
267
+ | List identities under advertiser | `/identity/get/` | `ttcli identity list` (filter by `--identity-type`) |
268
+ | `tiktok_item_id` (Spark Ads) | `/tt_video/info/` | `ttcli tt-video info --auth-code=...` |
269
+ | List posts under an identity | `/identity/video/get/` | `ttcli identity video list --identity-id=... --identity-type=...` |
270
+ | Verify `identity_id` | `/identity/info/` | `ttcli identity info --identity-id=... --identity-type=...` |
271
+ | Upload video → `video_id` | `/file/video/ad/upload/` | ⏳ not yet wrapped (rare; non-Spark only) |
272
+ | Upload image → `web_uri` | `/file/image/ad/upload/` | ⏳ not yet wrapped |
273
+ | Music → `music_id` | (separate music API) | ⏳ not yet wrapped |
274
+
275
+ Without the upload helpers you cannot do non-Spark ads, but for any TikTok-placement ad (which is everything MINI_GAME / MINI_APP / most APP_PROMOTION) Spark Ads is mandatory anyway, and the Spark Ads pipeline is fully discoverable via `tt-video info` or `identity video list`.
276
+
277
+ ## Spark Ads discovery flow
278
+
279
+ For MINI_GAME or any TikTok-placement ad creation:
280
+
281
+ 1. **List identities under the advertiser** (no auth_code needed if you already have managed identities):
282
+ ```
283
+ ttcli --human identity list --identity-type=BC_AUTH_TT
284
+ ```
285
+ Pick one with `available_status=AVAILABLE`. The `identity_id` and `identity_type` go into the ad body.
286
+ 2. **Or, get an auth_code from a creator**: TikTok app → 帖子 → 三点菜单 → 广告设置 → 开启"广告授权" → 选择授权时长 → 复制授权码
287
+ 3. **Resolve auth_code to `item_id` + `identity_id`**:
288
+ ```
289
+ ttcli tt-video info --auth-code='AUTH_CODE_FROM_STEP_2'
290
+ ```
291
+ Returns `item_info.item_id` (→ `creative_info.tiktok_item_id`) and `user_info.identity_id` + `user_info.identity_type`.
292
+ 4. **List videos under a known identity** (skip step 2-3 if the identity already has authorized posts):
293
+ ```
294
+ ttcli identity video list --identity-id=... --identity-type=TT_USER
295
+ ```
296
+ Returned `item_id` values used directly as `tiktok_item_id`.
297
+ 5. **Optionally verify identity health** before submitting:
298
+ ```
299
+ ttcli identity info --identity-id=... --identity-type=...
300
+ ```
301
+ Confirm `available_status=AVAILABLE` and `is_gpppa=false` (GPPPA accounts can't run Spark Ads).
302
+
303
+ Note: TikTok identity-list pagination requires `identity_type` to be set — without a type filter, `page_info` is null in the response.
304
+
305
+ ## Idempotency: reuse request_id on retry
306
+
307
+ Without `--request-id`, ttcli generates one from `time.Now().UnixNano()` and logs it to stderr:
308
+
309
+ ```
310
+ auto-generated request_id: 1780447951510460000 (use --request-id=... to retry idempotently)
311
+ ```
312
+
313
+ If a write fails (network, timeout) and you want to retry without creating a duplicate, capture and pass that same `request_id`. TikTok dedupes on this field — a second submission with the same ID returns the original result, not a new entity.
314
+
315
+ ## Errors
316
+
317
+ ttcli errors carry TikTok's `code`, `message`, and `request_id` (the API request ID, distinct from the idempotency one):
318
+
319
+ ```
320
+ Error: tiktok api error: code=40105 message="Access token is incorrect or has been revoked." request_id=20260602185506...
321
+ ```
322
+
323
+ Common codes:
324
+ - `40105` → access token invalid or revoked. Tell the user to refresh.
325
+ - `40001` → token has no permission for the advertiser_id in use. Likely `TIKTOK_ADVERTISER_ID` doesn't match what the token was authorized for.
326
+
327
+ The CLI does NOT validate enum values, type correctness, or conditional-required fields. It only checks that unconditionally required fields are present. TikTok is the source of truth for everything else — surface its error messages directly.
328
+
329
+ ## Credentials
330
+
331
+ Priority: `--flag` > env var > config file (`~/.config/ttcli/credentials.json`, mode 0600).
332
+
333
+ | Field | Flag | Env |
334
+ |---|---|---|
335
+ | Access token | `--token` | `TIKTOK_ACCESS_TOKEN` |
336
+ | Advertiser ID | `--advertiser-id` | `TIKTOK_ADVERTISER_ID` |
337
+ | Base URL | `--base-url` | `TIKTOK_BASE_URL` |
338
+
339
+ Persist for the local user with:
340
+
341
+ ```
342
+ ttcli auth set --token=... --advertiser-id=...
343
+ ```
344
+
345
+ `auth show` reports each value and where it came from (`flag` / `env:VAR` / `file` / `default` / `unset`); useful when a write fails with `40001` and you suspect the wrong advertiser is being used.
346
+
347
+ ## Standard end-to-end flow
348
+
349
+ 1. `ttcli auth show` — confirm credentials
350
+ 2. `ttcli app list` → pick `app_id`
351
+ 3. `ttcli tool region --placements=... --objective-type=...` → pick `location_id`
352
+ 4. `ttcli smart-plus campaign create --dry-run ...` → review body
353
+ 5. `ttcli smart-plus campaign create ...` → capture `campaign_id` from JSON response
354
+ 6. Build adgroup body JSON (heredoc / temp file)
355
+ 7. `ttcli smart-plus adgroup create --dry-run --body-from-file=... --campaign-id=...` → review merged body
356
+ 8. Same command without `--dry-run` → adgroup created in `DISABLE` state by default
@@ -0,0 +1,120 @@
1
+ #!/bin/bash
2
+ # install-skill.sh — ttcli + skill 双向依赖检测与安装
3
+ #
4
+ # 入口场景:
5
+ # A) 已装 skill,确保 ttcli CLI 可用(缺则自动 npm install)
6
+ # B) 已装 ttcli CLI,确保 skill 同步到 openclaw workspace
7
+ # 开发期奖励:若本机存在 ~/.claude/skills/,也同步一份方便 Claude Code 调试
8
+ #
9
+ # 用法:
10
+ # bash <path>/install-skill.sh
11
+ #
12
+ # 调用点:
13
+ # - 开发期 release.command 末尾(从仓库源同步)
14
+ # - 服务器 npm install 后手动跑(从 npm pkg 同步)
15
+ # bash $(npm root -g)/@bluevs/ttcli/skills/ttcli/scripts/install-skill.sh
16
+
17
+ set -euo pipefail
18
+
19
+ # 源目录:脚本所在目录的父目录就是 skill 目录(含 SKILL.md)
20
+ SOURCE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
21
+ SKILL_FILE="$SOURCE_DIR/SKILL.md"
22
+
23
+ if [ ! -f "$SKILL_FILE" ]; then
24
+ echo "❌ SKILL.md not found at $SKILL_FILE"
25
+ exit 1
26
+ fi
27
+
28
+ # Target:openclaw workspace
29
+ WORKSPACE="${OPENCLAW_WORKSPACE:-$HOME/.openclaw/workspace}"
30
+ WORKSPACE_SKILL_DIR="$WORKSPACE/skills/ttcli"
31
+
32
+ # npm 包路径(用于 CLI 缺失时反查)
33
+ NPM_ROOT="$(npm root -g 2>/dev/null || echo '')"
34
+ PKG_DIR="${NPM_ROOT:+$NPM_ROOT/@bluevs/ttcli}"
35
+
36
+ # 版本来源:尽量从 npm 包 package.json 读;本地 release 时退化为 SKILL.md mtime
37
+ get_version() {
38
+ if [ -n "$PKG_DIR" ] && [ -f "$PKG_DIR/package.json" ]; then
39
+ node -p "require('$PKG_DIR/package.json').version" 2>/dev/null && return
40
+ fi
41
+ # 本地 release 模式:用 SKILL.md 的 mtime 当版本戳
42
+ date -r "$SKILL_FILE" "+local-%Y%m%d-%H%M%S" 2>/dev/null || echo "local-unknown"
43
+ }
44
+
45
+ # ============================================================
46
+ # 方向 A:确保 ttcli CLI 可用
47
+ # ============================================================
48
+ check_cli() {
49
+ if command -v ttcli &>/dev/null; then
50
+ echo "✅ ttcli CLI ready ($(ttcli --help 2>&1 | head -1 || echo 'ok'))"
51
+ return 0
52
+ fi
53
+ echo "⚠️ ttcli CLI 未安装,尝试 npm install -g @bluevs/ttcli ..."
54
+ if npm install -g @bluevs/ttcli 2>&1 | tail -3; then
55
+ if command -v ttcli &>/dev/null; then
56
+ echo "✅ ttcli CLI 安装成功"
57
+ NPM_ROOT="$(npm root -g 2>/dev/null || echo '')"
58
+ PKG_DIR="${NPM_ROOT:+$NPM_ROOT/@bluevs/ttcli}"
59
+ return 0
60
+ fi
61
+ fi
62
+ echo "❌ npm install 失败。请手动: npm install -g @bluevs/ttcli"
63
+ return 1
64
+ }
65
+
66
+ # ============================================================
67
+ # 方向 B:把 skill 同步到 target
68
+ # ============================================================
69
+ sync_to() {
70
+ local target_dir="$1"
71
+ local label="$2"
72
+ local version_file="$target_dir/.installed_version"
73
+
74
+ local source_version="$(get_version)"
75
+
76
+ if [ -f "$version_file" ]; then
77
+ local installed_version="$(cat "$version_file")"
78
+ if [ "$installed_version" = "$source_version" ]; then
79
+ echo "✅ $label skill 已是 ${source_version},跳过"
80
+ return 0
81
+ fi
82
+ echo "🔄 $label skill: $installed_version → $source_version"
83
+ else
84
+ echo "📦 $label skill 首次安装 ($source_version)"
85
+ fi
86
+
87
+ mkdir -p "$target_dir"
88
+ cp -f "$SKILL_FILE" "$target_dir/SKILL.md"
89
+
90
+ # 未来 docs/ 目录加进来时自动 sync(当前可能无)
91
+ if [ -d "$SOURCE_DIR/docs" ]; then
92
+ mkdir -p "$target_dir/docs"
93
+ cp -f "$SOURCE_DIR/docs/"*.md "$target_dir/docs/" 2>/dev/null || true
94
+ fi
95
+
96
+ echo "$source_version" > "$version_file"
97
+ echo "✅ $label → $target_dir/SKILL.md"
98
+ }
99
+
100
+ # ============================================================
101
+ # 主流程
102
+ # ============================================================
103
+ echo "=== ttcli skill 安装 ==="
104
+ echo " source: $SOURCE_DIR"
105
+ echo ""
106
+
107
+ check_cli || exit 1
108
+ echo ""
109
+
110
+ # 主 target: openclaw workspace(永远 sync)
111
+ sync_to "$WORKSPACE_SKILL_DIR" "openclaw"
112
+
113
+ # 副 target: 本机 Claude Code(开发期才有)
114
+ if [ -d "$HOME/.claude/skills" ]; then
115
+ echo ""
116
+ sync_to "$HOME/.claude/skills/ttcli" "claude-code (dev)"
117
+ fi
118
+
119
+ echo ""
120
+ echo "=== 完成 ==="