feishu-user-plugin 1.3.6 → 1.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +22 -0
- package/README.md +55 -40
- package/package.json +10 -3
- package/scripts/check-tool-count.js +15 -0
- package/scripts/check-version.js +40 -0
- package/scripts/smoke.js +224 -0
- package/scripts/sync-claude-md.sh +12 -0
- package/scripts/sync-team-skills.sh +22 -0
- package/scripts/test-all-tools.js +158 -0
- package/skills/feishu-user-plugin/SKILL.md +5 -5
- package/skills/feishu-user-plugin/references/CLAUDE.md +138 -99
- package/skills/feishu-user-plugin/references/table.md +18 -9
- package/src/auth/credentials.js +350 -0
- package/src/cli.js +42 -13
- package/src/clients/official/base.js +424 -0
- package/src/clients/official/bitable.js +269 -0
- package/src/clients/official/calendar.js +176 -0
- package/src/clients/official/contacts.js +54 -0
- package/src/clients/official/docs.js +301 -0
- package/src/clients/official/drive.js +77 -0
- package/src/clients/official/groups.js +68 -0
- package/src/clients/official/im.js +414 -0
- package/src/clients/official/index.js +30 -0
- package/src/clients/official/okr.js +127 -0
- package/src/clients/official/tasks.js +142 -0
- package/src/clients/official/uploads.js +260 -0
- package/src/clients/official/wiki.js +207 -0
- package/src/{client.js → clients/user.js} +23 -17
- package/src/index.js +4 -1977
- package/src/logger.js +20 -0
- package/src/oauth.js +5 -1
- package/src/official.js +5 -1944
- package/src/prompts/_registry.js +69 -0
- package/src/prompts/index.js +54 -0
- package/src/server.js +242 -0
- package/src/test-all.js +2 -2
- package/src/test-comprehensive.js +3 -3
- package/src/test-send.js +1 -1
- package/src/tools/_registry.js +30 -0
- package/src/tools/bitable.js +246 -0
- package/src/tools/calendar.js +207 -0
- package/src/tools/contacts.js +66 -0
- package/src/tools/diagnostics.js +172 -0
- package/src/tools/docs.js +158 -0
- package/src/tools/drive.js +111 -0
- package/src/tools/groups.js +81 -0
- package/src/tools/im-read.js +259 -0
- package/src/tools/messaging-bot.js +151 -0
- package/src/tools/messaging-user.js +292 -0
- package/src/tools/okr.js +159 -0
- package/src/tools/profile.js +43 -0
- package/src/tools/tasks.js +168 -0
- package/src/tools/uploads.js +63 -0
- package/src/tools/wiki.js +191 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself
|
|
3
|
+
"version": "1.3.7",
|
|
4
|
+
"description": "All-in-one Feishu plugin for Claude Code — send messages as yourself, read chats (auto-expanded merge_forward), manage docs / bitable / wiki (full CRUD) / drive / OKR (with progress writes) / calendar (read+write) / Tasks v2 / multi-profile. 80 tools + 9 prompts, 3 auth layers.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "EthanQC"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.3.6] - 2026-05-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Upload completeness**: `uploadDocMedia` → `uploadMedia` accepting 8 `parent_type`s (docx / sheet / bitable × image / file + legacy doc_*). New `create_doc_block` modes for files (`file_path` / `file_token`, block_type 23, auto view-wrap). `update_doc_block` accepts `file_token` to swap existing file blocks. New `upload_drive_file` (`drive/v1/files/upload_all`; optional `wiki_space_id` auto-attaches via `move_docs_to_wiki`). New `upload_bitable_attachment` (`parent_type=bitable_image|bitable_file`).
|
|
11
|
+
- **`batch_send` tool**: fan-out the same or different content to multiple targets in one call. Each target dispatches sequentially with anti-rate-limit throttling and reports per-target `ok` / `error`. Identity is the cookie user unless `target.via=bot`.
|
|
12
|
+
- **Multi-profile support**: `list_profiles` / `switch_profile` tools + `LARK_PROFILES_JSON` env. Hot-swap credentials without restarting the MCP server; cached client instances rebuild against the new profile.
|
|
13
|
+
- **`send_card_as_user` (bot-routed default)**: send Feishu interactive cards. v1.3.6 routes through the bot identity; the `as_user` suffix is reserved for v1.3.7's reverse-engineered cookie path. `via="user"` returns an explicit not-yet-implemented error.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- OAuth scopes added: `drive:file:upload` (narrower scope for `drive/v1/files/upload_all`), `sheets:spreadsheet` (sheet image / file uploads). Existing users must re-run `npx feishu-user-plugin oauth` to pick them up.
|
|
17
|
+
|
|
18
|
+
## [1.3.5] - 2026-04-24
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **Cross-process UAT refresh lock**: file lock at `~/.claude/feishu-uat-refresh.lock` (`O_CREAT|O_EXCL`, 30s stale detection) serializes UAT refresh across concurrent MCP processes. Inside the critical section, the lock holder re-reads `~/.claude.json` to see whether a peer already rotated the token; if so it adopts the fresh one. Closes the "Codex spawned 6 MCP servers, all raced to refresh" failure mode that was burning refresh tokens on 2026-04-23.
|
|
22
|
+
- **`get_login_status` UAT health check**: now actually exercises the UAT (calls `listChatsAsUser({pageSize:1})`) instead of just checking presence. Surfaces "configured but 401" cases that previously stayed silent until the next real tool call.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- **Bot-fallback ⚠️ warning**: every write tool that silently fell back from UAT to bot identity (`create_doc` / `create_bitable` / `create_folder` / `create_doc_block` / etc.) now appends a `fallbackWarning` to the response so users see the ownership change immediately. Before, callers only learned days later when a teammate could read their "private" resource.
|
|
26
|
+
- **Auto-expand `merge_forward`**: `read_messages` / `read_p2p_messages` walk a `merge_forward` placeholder into its child messages by default (`expand_merge_forward=false` to opt out). Children carry `parentMessageId` (use that, NOT the child id, when downloading their media). Text children get `urls[]` + `feishuDocs[]` extracted so agents can feed them straight into `read_doc` / WebFetch.
|
|
27
|
+
- **`download_file` tool**: download a file attachment (`msg_type=file`). Returns base64 + mimeType + byte count; optional `save_path` writes to disk. Same parent-id rule for `merge_forward` children as `download_image`.
|
|
28
|
+
|
|
7
29
|
## [1.3.4] - 2026-04-22
|
|
8
30
|
|
|
9
31
|
### Added
|
package/README.md
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
[](https://nodejs.org)
|
|
5
5
|
[](https://modelcontextprotocol.io)
|
|
6
|
-
[](#tools)
|
|
7
7
|
[](CONTRIBUTING.md)
|
|
8
8
|
|
|
9
|
-
**All-in-one Feishu/Lark MCP Server --
|
|
9
|
+
**All-in-one Feishu/Lark MCP Server -- 80 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, OKR, and more.**
|
|
10
10
|
|
|
11
11
|
The only MCP server that lets you send messages as your **personal identity** (not a bot), while also integrating the full official Feishu API. Works with Claude Code, Cursor, Windsurf, OpenClaw, and any MCP-compatible client.
|
|
12
12
|
|
|
@@ -337,7 +337,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
|
337
337
|
}
|
|
338
338
|
```
|
|
339
339
|
|
|
340
|
-
## Tools (
|
|
340
|
+
## Tools (80 total)
|
|
341
341
|
|
|
342
342
|
### User Identity -- Messaging (10 tools, cookie auth)
|
|
343
343
|
|
|
@@ -349,8 +349,6 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
|
349
349
|
| `send_image_as_user` | Send image (requires `image_key` from `upload_image`) |
|
|
350
350
|
| `send_file_as_user` | Send file (requires `file_key` from `upload_file`) |
|
|
351
351
|
| `send_post_as_user` | Send rich text with title + formatted paragraphs |
|
|
352
|
-
| `send_sticker_as_user` | Send sticker/emoji |
|
|
353
|
-
| `send_audio_as_user` | Send audio message |
|
|
354
352
|
| `batch_send` | Fan-out send to multiple targets in one call (text / image / file / post). v1.3.6 |
|
|
355
353
|
| `send_card_as_user` | Send a Feishu interactive card. v1.3.6 default routes through bot identity; user-identity is reserved for v1.3.7. |
|
|
356
354
|
|
|
@@ -392,8 +390,8 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
|
392
390
|
| `add_members` | Add users to a group |
|
|
393
391
|
| `remove_members` | Remove users from a group |
|
|
394
392
|
| `upload_image` / `upload_file` | Upload image/file, returns key for sending |
|
|
395
|
-
| `
|
|
396
|
-
| `
|
|
393
|
+
| `download_message_resource` | v1.3.7 (C2.4): download a message-attached image or file. Args: `message_id`, `key`, `kind=image|file`, `save_path?`. **Required save_path when bytes > 2 MiB** (Anthropic 5 MB inline cap). Replaces v1.3.6 download_image (message mode) + download_file. |
|
|
394
|
+
| `download_doc_image` | v1.3.7 (C2.4): download an image embedded in a docx (image_token + optional doc_token). Same 2 MiB cap. Replaces v1.3.6 download_image (docx mode). |
|
|
397
395
|
|
|
398
396
|
### Wiki, OKR, and Calendar (v1.3.4)
|
|
399
397
|
|
|
@@ -409,7 +407,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
|
409
407
|
|
|
410
408
|
All docx / bitable tools' `document_id` / `app_token` parameter also accepts a Wiki node token or a full Feishu URL — the plugin resolves it transparently.
|
|
411
409
|
|
|
412
|
-
### Official API -- Documents (
|
|
410
|
+
### Official API -- Documents (5 tools)
|
|
413
411
|
|
|
414
412
|
| Tool | Description |
|
|
415
413
|
|------|-------------|
|
|
@@ -417,60 +415,64 @@ All docx / bitable tools' `document_id` / `app_token` parameter also accepts a W
|
|
|
417
415
|
| `read_doc` | Read raw text content |
|
|
418
416
|
| `get_doc_blocks` | Get structured block tree |
|
|
419
417
|
| `create_doc` | Create a new document |
|
|
420
|
-
| `
|
|
421
|
-
| `update_doc_block` | Update a specific block: generic `update_body`, `image_token`, or `file_token` (v1.3.6) |
|
|
422
|
-
| `delete_doc_blocks` | Delete a range of blocks |
|
|
418
|
+
| `manage_doc_block` | Insert / update / delete blocks (`action=create|update|delete`). Supports generic `children`, image (`image_path`/`image_token`), and file (`file_path`/`file_token`) shortcuts. v1.3.7 consolidates the v1.3.6 trio create_doc_block / update_doc_block / delete_doc_blocks. |
|
|
423
419
|
|
|
424
|
-
### Official API -- Bitable (
|
|
420
|
+
### Official API -- Bitable (6 tools, v1.3.7 consolidation)
|
|
425
421
|
|
|
426
|
-
| Tool | Description |
|
|
427
|
-
|
|
428
|
-
| `
|
|
429
|
-
| `
|
|
430
|
-
| `
|
|
431
|
-
| `
|
|
432
|
-
| `
|
|
433
|
-
| `
|
|
434
|
-
| `batch_create_bitable_records` / `batch_update_bitable_records` / `batch_delete_bitable_records` | Batch operations (max 500/call) |
|
|
435
|
-
| `upload_bitable_attachment` | Upload a file into a Bitable Attachment-type field. Returns `file_token` to write into the field as `[{file_token}]`. v1.3.6 |
|
|
422
|
+
| Tool | Actions | Description |
|
|
423
|
+
|------|---------|-------------|
|
|
424
|
+
| `manage_bitable_app` | create / copy / get_meta | App-level operations (v1.3.7 consolidates create_bitable / copy_bitable / get_bitable_meta) |
|
|
425
|
+
| `manage_bitable_table` | list / create / update / delete | Table CRUD (rename via update) |
|
|
426
|
+
| `manage_bitable_field` | list / create / update / delete | Field (column) management. `type` required for both create AND update. |
|
|
427
|
+
| `manage_bitable_view` | list / create / delete | Views (grid, kanban, gallery, form, gantt, calendar) |
|
|
428
|
+
| `manage_bitable_record` | search / get / create / update / delete | Record CRUD. create/update/delete accept arrays — single record or up to 500/call. |
|
|
429
|
+
| `upload_bitable_attachment` | — | Upload a file into a Bitable Attachment-type field. Returns `file_token` to write into the field as `[{file_token}]`. v1.3.6 |
|
|
436
430
|
|
|
437
|
-
### Official API -- Calendar (
|
|
431
|
+
### Official API -- Calendar (8 tools, write tools v1.3.7)
|
|
438
432
|
|
|
439
433
|
| Tool | Description |
|
|
440
434
|
|------|-------------|
|
|
441
435
|
| `list_calendars` | List accessible calendars |
|
|
442
|
-
| `create_calendar_event` | Create a calendar event |
|
|
443
436
|
| `list_calendar_events` | List events in a calendar |
|
|
444
|
-
| `
|
|
445
|
-
| `
|
|
437
|
+
| `get_calendar_event` | Full event details |
|
|
438
|
+
| `create_calendar_event` | Create an event (v1.3.7). Requires `calendar:calendar.event:write`. |
|
|
439
|
+
| `update_calendar_event` | Patch event fields (v1.3.7) |
|
|
440
|
+
| `delete_calendar_event` | Delete an event, optionally dissolve its meeting chat (v1.3.7) |
|
|
441
|
+
| `respond_calendar_event` | RSVP as accept / decline / tentative (v1.3.7) |
|
|
442
|
+
| `get_freebusy` | Freebusy lookup for `user_ids` in a time range (v1.3.7) |
|
|
446
443
|
|
|
447
|
-
### Official API -- Tasks (
|
|
444
|
+
### Official API -- Tasks v2 (7 tools, v1.3.7 new domain)
|
|
445
|
+
|
|
446
|
+
Identifier is `task_guid` (not v1's numeric `task_id`). Requires `task:task` scope.
|
|
448
447
|
|
|
449
448
|
| Tool | Description |
|
|
450
449
|
|------|-------------|
|
|
451
|
-
| `
|
|
452
|
-
| `get_task` |
|
|
453
|
-
| `
|
|
454
|
-
| `update_task` |
|
|
455
|
-
| `complete_task` | Mark
|
|
450
|
+
| `list_tasks` | List the current user's tasks (filter by completed / type) |
|
|
451
|
+
| `get_task` | Full task detail |
|
|
452
|
+
| `create_task` | Create a task (summary required; due/members optional) |
|
|
453
|
+
| `update_task` | Patch fields. **`update_fields` is required** — Feishu only updates the listed keys. |
|
|
454
|
+
| `complete_task` | Mark complete (or uncomplete with `completed=false`) |
|
|
455
|
+
| `delete_task` | Permanent delete |
|
|
456
|
+
| `manage_task_members` | `action=add|remove`, members `[{id, role:"assignee"|"follower"}]` |
|
|
456
457
|
|
|
457
|
-
### Official API -- Drive (
|
|
458
|
+
### Official API -- Drive (4 tools)
|
|
458
459
|
|
|
459
460
|
| Tool | Description |
|
|
460
461
|
|------|-------------|
|
|
461
462
|
| `list_files` | List files in a folder |
|
|
462
463
|
| `create_folder` | Create a new folder |
|
|
463
|
-
| `
|
|
464
|
-
| `move_file` | Move a file |
|
|
465
|
-
| `delete_file` | Delete a file/folder |
|
|
464
|
+
| `manage_drive_file` | Copy / move / delete a Drive file (`action=copy|move|delete`, `type` required). v1.3.7 consolidates v1.3.6 copy_file / move_file / delete_file. |
|
|
466
465
|
| `upload_drive_file` | Upload a local file into a Drive folder (`drive/v1/files/upload_all`). Optional `wiki_space_id` attaches the upload as a Wiki node atomically. v1.3.6 |
|
|
467
466
|
|
|
468
|
-
### Official API -- Wiki
|
|
467
|
+
### Official API -- Wiki (8 tools)
|
|
469
468
|
|
|
470
469
|
| Tool | Description |
|
|
471
470
|
|------|-------------|
|
|
472
|
-
| `list_wiki_spaces` / `search_wiki` / `list_wiki_nodes` | Wiki spaces, search, browse |
|
|
473
|
-
| `
|
|
471
|
+
| `list_wiki_spaces` / `search_wiki` / `list_wiki_nodes` / `get_wiki_node` | Wiki spaces, search, browse + resolve a wiki node to underlying obj_token |
|
|
472
|
+
| `create_wiki_node` | Create a new wiki node (doc/sheet/bitable/mindnote/file/docx/slides) inside a space |
|
|
473
|
+
| `update_wiki_node` | Rename a wiki node (title only — content edits via docx/bitable tools) |
|
|
474
|
+
| `move_wiki_node` | Move a wiki node to a different parent or different space |
|
|
475
|
+
| `copy_wiki_node` | Deep-copy a wiki node to a different location (optionally to a different space) |
|
|
474
476
|
|
|
475
477
|
### Plugin -- Profiles (2 tools, v1.3.6)
|
|
476
478
|
|
|
@@ -537,7 +539,7 @@ feishu-user-plugin/
|
|
|
537
539
|
│ ├── SKILL.md # Main skill definition (trigger, tools, auth)
|
|
538
540
|
│ └── references/ # 8 skill reference docs + CLAUDE.md
|
|
539
541
|
├── src/
|
|
540
|
-
│ ├── index.js # MCP server entry point (
|
|
542
|
+
│ ├── index.js # MCP server entry point (78 tools)
|
|
541
543
|
│ ├── client.js # User identity client (Protobuf gateway)
|
|
542
544
|
│ ├── official.js # Official API client (REST, UAT)
|
|
543
545
|
│ ├── utils.js # ID generators, cookie parser
|
|
@@ -566,6 +568,19 @@ Issues and PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for development s
|
|
|
566
568
|
|
|
567
569
|
If Feishu updates their protocol and something breaks, please [open an issue](https://github.com/EthanQC/feishu-user-plugin/issues/new?template=bug_report.md) with the error details.
|
|
568
570
|
|
|
571
|
+
### Automated sync hooks
|
|
572
|
+
|
|
573
|
+
This repo uses husky to enforce several invariants on every commit:
|
|
574
|
+
|
|
575
|
+
- **CLAUDE.md sync** — staging `CLAUDE.md` automatically regenerates `AGENTS.md` (identical body, different first line) and `skills/feishu-user-plugin/references/CLAUDE.md` (verbatim copy). Both are re-staged in the same commit.
|
|
576
|
+
- **Version triangle** — if `package.json`, `.claude-plugin/plugin.json`, or `skills/feishu-user-plugin/SKILL.md` are staged, all three `version` fields must agree or the commit is rejected.
|
|
577
|
+
- **Tool-count badge** — if `src/server.js` or any file under `src/tools/` is staged, the `N tools` badge in `README.md` must match the actual `TOOLS.length` exported by `src/server.js`.
|
|
578
|
+
- **Smoke test** — any change under `src/` triggers `npm run smoke` to catch schema regressions before commit.
|
|
579
|
+
|
|
580
|
+
CI (`.github/workflows/validate.yml`) runs the same checks on every PR to `main`, so bypassing the local hook still gets caught.
|
|
581
|
+
|
|
582
|
+
On the maintainer's machine, a post-merge hook (`scripts/sync-team-skills.sh`) auto-opens a sync PR in the `~/team-skills` repo after every merge to main. The hook silently skips if `~/team-skills` is absent.
|
|
583
|
+
|
|
569
584
|
## License
|
|
570
585
|
|
|
571
586
|
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (with merge_forward expansion + batch_send), docs (image + file blocks read/write), bitable (
|
|
3
|
+
"version": "1.3.7",
|
|
4
|
+
"description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (with merge_forward expansion + batch_send), docs (image + file blocks read/write), bitable, wiki (full CRUD), drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile. 80 tools + 9 prompts, 3 auth layers.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"feishu-user-plugin": "src/cli.js"
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
"test": "node src/test-all.js",
|
|
12
12
|
"test:quick": "node src/test-send.js",
|
|
13
13
|
"oauth": "node src/oauth.js",
|
|
14
|
-
"
|
|
14
|
+
"smoke": "node scripts/smoke.js diff",
|
|
15
|
+
"smoke:baseline": "node scripts/smoke.js write-baseline",
|
|
16
|
+
"test:tools": "node scripts/test-all-tools.js",
|
|
17
|
+
"prepublishOnly": "node scripts/check-version.js && node scripts/check-tool-count.js && node scripts/confirm-version.js",
|
|
18
|
+
"prepare": "husky"
|
|
15
19
|
},
|
|
16
20
|
"keywords": [
|
|
17
21
|
"feishu",
|
|
@@ -55,5 +59,8 @@
|
|
|
55
59
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
56
60
|
"dotenv": "^16.4.7",
|
|
57
61
|
"protobufjs": "^7.4.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"husky": "^9.1.7"
|
|
58
65
|
}
|
|
59
66
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { TOOLS } = require(path.join(__dirname, '..', 'src', 'server'));
|
|
6
|
+
const readme = fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8');
|
|
7
|
+
// Find the canonical "N tools" reference. Pick the highest-confidence match.
|
|
8
|
+
const m = readme.match(/(\d+)\s+tools/);
|
|
9
|
+
if (!m) { console.error('No "N tools" badge in README.md'); process.exit(1); }
|
|
10
|
+
const claimed = parseInt(m[1], 10);
|
|
11
|
+
if (claimed !== TOOLS.length) {
|
|
12
|
+
console.error(`README claims ${claimed} tools, src/server.js has ${TOOLS.length}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
console.log(`OK: ${TOOLS.length} tools`);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const ROOT = path.join(__dirname, '..');
|
|
7
|
+
|
|
8
|
+
// Source 1: package.json
|
|
9
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
10
|
+
const pkgVersion = pkg.version;
|
|
11
|
+
|
|
12
|
+
// Source 2: .claude-plugin/plugin.json
|
|
13
|
+
const plugin = JSON.parse(fs.readFileSync(path.join(ROOT, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
14
|
+
const pluginVersion = plugin.version;
|
|
15
|
+
|
|
16
|
+
// Source 3: skills/feishu-user-plugin/SKILL.md frontmatter version line
|
|
17
|
+
const skillMd = fs.readFileSync(path.join(ROOT, 'skills', 'feishu-user-plugin', 'SKILL.md'), 'utf8');
|
|
18
|
+
const skillMatch = skillMd.match(/^version:\s*["']?([^"'\s]+)["']?/m);
|
|
19
|
+
if (!skillMatch) {
|
|
20
|
+
console.error('ERROR: Could not find version in skills/feishu-user-plugin/SKILL.md frontmatter');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const skillVersion = skillMatch[1];
|
|
24
|
+
|
|
25
|
+
const sources = [
|
|
26
|
+
{ label: 'package.json', version: pkgVersion, path: 'package.json' },
|
|
27
|
+
{ label: '.claude-plugin/plugin.json', version: pluginVersion, path: '.claude-plugin/plugin.json' },
|
|
28
|
+
{ label: 'skills/feishu-user-plugin/SKILL.md', version: skillVersion, path: 'skills/feishu-user-plugin/SKILL.md' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const versions = sources.map((s) => s.version);
|
|
32
|
+
const allEqual = versions.every((v) => v === versions[0]);
|
|
33
|
+
|
|
34
|
+
if (!allEqual) {
|
|
35
|
+
console.error('ERROR: Version triangle mismatch!');
|
|
36
|
+
sources.forEach((s) => console.error(` ${s.path}: ${s.version}`));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log(`OK: version ${pkgVersion}`);
|
package/scripts/smoke.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/smoke.js — MCP smoke test for refactor before/after diff.
|
|
3
|
+
// Spawns src/index.js as a child via stdio, sends:
|
|
4
|
+
// 1. tools/list → dumps sorted (name, description, inputSchema) to stdout
|
|
5
|
+
// 2. tools/call get_login_status → dumps recursive Object.keys (not values) to stdout
|
|
6
|
+
// 3. prompts/list → dumps sorted (name, description, arguments) to stdout
|
|
7
|
+
// Exits 0 on success, 1 on protocol error. Diff output against tests/baseline/*.json.
|
|
8
|
+
//
|
|
9
|
+
// Cred sourcing: src/index.js only reads process.env.LARK_*; this script injects
|
|
10
|
+
// creds from ~/.claude.json via readCredentials() so the spawned MCP server has
|
|
11
|
+
// the same auth it would have when launched by Claude Code. Without this the
|
|
12
|
+
// baseline captures the not-configured login_status shape.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node scripts/smoke.js dump # print normalized current snapshot to stdout
|
|
16
|
+
// node scripts/smoke.js diff # compare against tests/baseline/* and exit non-zero on mismatch
|
|
17
|
+
// node scripts/smoke.js write-baseline # overwrite tests/baseline/*.json with current snapshot
|
|
18
|
+
|
|
19
|
+
const { spawn } = require('child_process');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const { readCredentials } = require('../src/config');
|
|
23
|
+
|
|
24
|
+
const SERVER_PATH = path.join(__dirname, '..', 'src', 'index.js');
|
|
25
|
+
const BASELINE_DIR = path.join(__dirname, '..', 'tests', 'baseline');
|
|
26
|
+
const TOOLS_BASELINE = path.join(BASELINE_DIR, 'tools-list.json');
|
|
27
|
+
const LOGIN_BASELINE = path.join(BASELINE_DIR, 'login-status-shape.json');
|
|
28
|
+
const PROMPTS_BASELINE = path.join(BASELINE_DIR, 'prompts-list.json');
|
|
29
|
+
|
|
30
|
+
function jsonrpc(id, method, params) {
|
|
31
|
+
return JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeSchema(s) {
|
|
35
|
+
if (!s || typeof s !== 'object') return s;
|
|
36
|
+
if (Array.isArray(s)) return s.map(normalizeSchema);
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const k of Object.keys(s).sort()) {
|
|
39
|
+
out[k] = k === 'required' && Array.isArray(s[k]) ? [...s[k]].sort() : normalizeSchema(s[k]);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function shapeOnly(v) {
|
|
45
|
+
if (v === null || v === undefined) return v === null ? 'null' : 'undefined';
|
|
46
|
+
if (Array.isArray(v)) return v.length === 0 ? '[]' : ['<' + (typeof v[0] === 'object' ? 'object' : typeof v[0]) + '>'];
|
|
47
|
+
if (typeof v === 'object') {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const k of Object.keys(v).sort()) out[k] = shapeOnly(v[k]);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
return typeof v;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function waitFor(fn, timeoutMs) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const start = Date.now();
|
|
58
|
+
const tick = () => {
|
|
59
|
+
if (fn()) return resolve();
|
|
60
|
+
if (Date.now() - start > timeoutMs) return reject(new Error(`timeout after ${timeoutMs}ms`));
|
|
61
|
+
setTimeout(tick, 50);
|
|
62
|
+
};
|
|
63
|
+
tick();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function runSmoke() {
|
|
68
|
+
const creds = readCredentials() || {};
|
|
69
|
+
const childEnv = { ...process.env };
|
|
70
|
+
for (const k of ['LARK_COOKIE', 'LARK_APP_ID', 'LARK_APP_SECRET', 'LARK_USER_ACCESS_TOKEN', 'LARK_USER_REFRESH_TOKEN', 'LARK_PROFILES_JSON']) {
|
|
71
|
+
if (creds[k] && !childEnv[k]) childEnv[k] = creds[k];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const child = spawn('node', [SERVER_PATH], {
|
|
75
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
76
|
+
env: childEnv,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let buf = '';
|
|
80
|
+
const responses = new Map();
|
|
81
|
+
child.stdout.on('data', (d) => {
|
|
82
|
+
buf += d.toString();
|
|
83
|
+
const lines = buf.split('\n');
|
|
84
|
+
buf = lines.pop();
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
if (!line.trim()) continue;
|
|
87
|
+
try {
|
|
88
|
+
const msg = JSON.parse(line);
|
|
89
|
+
if (msg.id != null) responses.set(msg.id, msg);
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
child.stderr.on('data', () => {}); // discard
|
|
94
|
+
|
|
95
|
+
let exitErr = null;
|
|
96
|
+
child.on('exit', (code) => { if (code !== 0 && code !== null) exitErr = new Error(`server exited with code ${code}`); });
|
|
97
|
+
|
|
98
|
+
child.stdin.write(jsonrpc(1, 'initialize', {
|
|
99
|
+
protocolVersion: '2024-11-05',
|
|
100
|
+
capabilities: {},
|
|
101
|
+
clientInfo: { name: 'smoke', version: '0' },
|
|
102
|
+
}));
|
|
103
|
+
await waitFor(() => responses.has(1) || exitErr, 8000);
|
|
104
|
+
if (exitErr) throw exitErr;
|
|
105
|
+
|
|
106
|
+
child.stdin.write(jsonrpc(2, 'tools/list', {}));
|
|
107
|
+
await waitFor(() => responses.has(2) || exitErr, 8000);
|
|
108
|
+
if (exitErr) throw exitErr;
|
|
109
|
+
|
|
110
|
+
child.stdin.write(jsonrpc(3, 'tools/call', { name: 'get_login_status', arguments: {} }));
|
|
111
|
+
await waitFor(() => responses.has(3) || exitErr, 15000);
|
|
112
|
+
if (exitErr) throw exitErr;
|
|
113
|
+
|
|
114
|
+
child.stdin.write(jsonrpc(4, 'prompts/list', {}));
|
|
115
|
+
await waitFor(() => responses.has(4) || exitErr, 8000);
|
|
116
|
+
if (exitErr) throw exitErr;
|
|
117
|
+
|
|
118
|
+
child.kill('SIGTERM');
|
|
119
|
+
|
|
120
|
+
const tools = (responses.get(2)?.result?.tools || []).map((t) => ({
|
|
121
|
+
name: t.name,
|
|
122
|
+
description: t.description,
|
|
123
|
+
inputSchema: normalizeSchema(t.inputSchema),
|
|
124
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
125
|
+
|
|
126
|
+
let loginShape = null;
|
|
127
|
+
const txt = responses.get(3)?.result?.content?.[0]?.text;
|
|
128
|
+
if (typeof txt === 'string') {
|
|
129
|
+
try {
|
|
130
|
+
loginShape = shapeOnly(JSON.parse(txt));
|
|
131
|
+
} catch {
|
|
132
|
+
// Not JSON — capture as a plain text shape (length stays unstable so just record it's a string).
|
|
133
|
+
loginShape = { _format: 'text', _length_bucket: txt.length < 100 ? '<100' : txt.length < 1000 ? '<1000' : '>=1000' };
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
loginShape = { _error: 'no response', _raw: shapeOnly(responses.get(3)?.result) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const prompts = (responses.get(4)?.result?.prompts || []).map((p) => ({
|
|
140
|
+
name: p.name,
|
|
141
|
+
description: p.description,
|
|
142
|
+
...(p.arguments && p.arguments.length > 0 ? { arguments: p.arguments } : {}),
|
|
143
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
144
|
+
|
|
145
|
+
return { tools, loginShape, prompts };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
(async () => {
|
|
149
|
+
const cmd = process.argv[2] || 'dump';
|
|
150
|
+
let snap;
|
|
151
|
+
try {
|
|
152
|
+
snap = await runSmoke();
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error('SMOKE FAIL:', err.message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (cmd === 'dump') {
|
|
159
|
+
process.stdout.write(JSON.stringify(snap, null, 2) + '\n');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (cmd === 'write-baseline') {
|
|
163
|
+
fs.mkdirSync(BASELINE_DIR, { recursive: true });
|
|
164
|
+
fs.writeFileSync(TOOLS_BASELINE, JSON.stringify(snap.tools, null, 2) + '\n');
|
|
165
|
+
fs.writeFileSync(LOGIN_BASELINE, JSON.stringify(snap.loginShape, null, 2) + '\n');
|
|
166
|
+
fs.writeFileSync(PROMPTS_BASELINE, JSON.stringify(snap.prompts, null, 2) + '\n');
|
|
167
|
+
console.error(`Baseline written: ${snap.tools.length} tools, ${snap.prompts.length} prompts, login_status shape captured`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (cmd === 'diff') {
|
|
171
|
+
if (!fs.existsSync(TOOLS_BASELINE) || !fs.existsSync(LOGIN_BASELINE)) {
|
|
172
|
+
console.error('No baseline found. Run: node scripts/smoke.js write-baseline');
|
|
173
|
+
process.exit(2);
|
|
174
|
+
}
|
|
175
|
+
const expectedTools = JSON.parse(fs.readFileSync(TOOLS_BASELINE, 'utf8'));
|
|
176
|
+
const expectedLogin = JSON.parse(fs.readFileSync(LOGIN_BASELINE, 'utf8'));
|
|
177
|
+
const expectedPrompts = fs.existsSync(PROMPTS_BASELINE)
|
|
178
|
+
? JSON.parse(fs.readFileSync(PROMPTS_BASELINE, 'utf8'))
|
|
179
|
+
: null;
|
|
180
|
+
const actualToolsStr = JSON.stringify(snap.tools, null, 2);
|
|
181
|
+
const expectedToolsStr = JSON.stringify(expectedTools, null, 2);
|
|
182
|
+
let ok = true;
|
|
183
|
+
if (actualToolsStr !== expectedToolsStr) {
|
|
184
|
+
ok = false;
|
|
185
|
+
const expectedNames = new Set(expectedTools.map(t => t.name));
|
|
186
|
+
const actualNames = new Set(snap.tools.map(t => t.name));
|
|
187
|
+
const added = [...actualNames].filter(n => !expectedNames.has(n));
|
|
188
|
+
const removed = [...expectedNames].filter(n => !actualNames.has(n));
|
|
189
|
+
console.error('TOOLS MISMATCH');
|
|
190
|
+
console.error(`expected ${expectedTools.length} tools, got ${snap.tools.length}`);
|
|
191
|
+
if (added.length) console.error(` added: ${added.join(', ')}`);
|
|
192
|
+
if (removed.length) console.error(` removed: ${removed.join(', ')}`);
|
|
193
|
+
// Find tools whose schema/description changed
|
|
194
|
+
const expByName = Object.fromEntries(expectedTools.map(t => [t.name, t]));
|
|
195
|
+
const changed = snap.tools.filter(t => expByName[t.name] && JSON.stringify(t) !== JSON.stringify(expByName[t.name])).map(t => t.name);
|
|
196
|
+
if (changed.length) console.error(` changed: ${changed.join(', ')}`);
|
|
197
|
+
}
|
|
198
|
+
if (JSON.stringify(snap.loginShape) !== JSON.stringify(expectedLogin)) {
|
|
199
|
+
ok = false;
|
|
200
|
+
console.error('LOGIN STATUS SHAPE MISMATCH');
|
|
201
|
+
console.error('expected:', JSON.stringify(expectedLogin, null, 2));
|
|
202
|
+
console.error('actual: ', JSON.stringify(snap.loginShape, null, 2));
|
|
203
|
+
}
|
|
204
|
+
if (expectedPrompts !== null && JSON.stringify(snap.prompts, null, 2) !== JSON.stringify(expectedPrompts, null, 2)) {
|
|
205
|
+
ok = false;
|
|
206
|
+
const expectedNames = new Set(expectedPrompts.map(p => p.name));
|
|
207
|
+
const actualNames = new Set(snap.prompts.map(p => p.name));
|
|
208
|
+
const added = [...actualNames].filter(n => !expectedNames.has(n));
|
|
209
|
+
const removed = [...expectedNames].filter(n => !actualNames.has(n));
|
|
210
|
+
console.error('PROMPTS MISMATCH');
|
|
211
|
+
console.error(`expected ${expectedPrompts.length} prompts, got ${snap.prompts.length}`);
|
|
212
|
+
if (added.length) console.error(` added: ${added.join(', ')}`);
|
|
213
|
+
if (removed.length) console.error(` removed: ${removed.join(', ')}`);
|
|
214
|
+
const expByName = Object.fromEntries(expectedPrompts.map(p => [p.name, p]));
|
|
215
|
+
const changed = snap.prompts.filter(p => expByName[p.name] && JSON.stringify(p) !== JSON.stringify(expByName[p.name])).map(p => p.name);
|
|
216
|
+
if (changed.length) console.error(` changed: ${changed.join(', ')}`);
|
|
217
|
+
}
|
|
218
|
+
if (!ok) process.exit(1);
|
|
219
|
+
console.error(`OK: ${snap.tools.length} tools, ${snap.prompts.length} prompts, login_status shape matches`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
console.error('usage: smoke.js [dump|diff|write-baseline]');
|
|
223
|
+
process.exit(2);
|
|
224
|
+
})();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
ROOT="$(git rev-parse --show-toplevel)"
|
|
4
|
+
cd "$ROOT"
|
|
5
|
+
if git diff --cached --name-only | grep -qx "CLAUDE.md"; then
|
|
6
|
+
tail -n +2 CLAUDE.md > /tmp/feishu-claude-body.$$
|
|
7
|
+
{ echo "# feishu-user-plugin — Codex Instructions"; cat /tmp/feishu-claude-body.$$; } > AGENTS.md
|
|
8
|
+
rm -f /tmp/feishu-claude-body.$$
|
|
9
|
+
cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md
|
|
10
|
+
git add AGENTS.md skills/feishu-user-plugin/references/CLAUDE.md
|
|
11
|
+
echo "[hook] CLAUDE.md → AGENTS.md + skill reference synced"
|
|
12
|
+
fi
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
TEAM_SKILLS="/Users/abble/team-skills/plugins/feishu-user-plugin"
|
|
4
|
+
if [ ! -d "$TEAM_SKILLS" ]; then echo "[hook] team-skills not present, skip"; exit 0; fi
|
|
5
|
+
ROOT="$(git rev-parse --show-toplevel)"
|
|
6
|
+
cd "$ROOT"
|
|
7
|
+
cp -r skills/. "$TEAM_SKILLS/skills/"
|
|
8
|
+
cp .claude-plugin/plugin.json "$TEAM_SKILLS/.claude-plugin/"
|
|
9
|
+
cd "$TEAM_SKILLS/.."
|
|
10
|
+
VERSION=$(node -e "console.log(require('$TEAM_SKILLS/.claude-plugin/plugin.json').version)")
|
|
11
|
+
BRANCH="sync/feishu-v$VERSION"
|
|
12
|
+
if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then
|
|
13
|
+
echo "[hook] branch $BRANCH already exists, skipping"; exit 0
|
|
14
|
+
fi
|
|
15
|
+
git checkout -b "$BRANCH"
|
|
16
|
+
git add "plugins/feishu-user-plugin/"
|
|
17
|
+
git commit -m "chore: sync feishu-user-plugin v$VERSION skills + plugin.json" || { echo "[hook] nothing to sync"; exit 0; }
|
|
18
|
+
git push -u origin "$BRANCH"
|
|
19
|
+
gh pr create --title "Sync feishu-user-plugin v$VERSION" --body "Auto-sync from feishu-user-plugin main."
|
|
20
|
+
PR_NUM=$(gh pr view --json number --jq .number)
|
|
21
|
+
gh pr merge "$PR_NUM" --auto --merge
|
|
22
|
+
echo "[hook] team-skills sync PR #$PR_NUM created with auto-merge"
|