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.
Files changed (55) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +55 -40
  4. package/package.json +10 -3
  5. package/scripts/check-tool-count.js +15 -0
  6. package/scripts/check-version.js +40 -0
  7. package/scripts/smoke.js +224 -0
  8. package/scripts/sync-claude-md.sh +12 -0
  9. package/scripts/sync-team-skills.sh +22 -0
  10. package/scripts/test-all-tools.js +158 -0
  11. package/skills/feishu-user-plugin/SKILL.md +5 -5
  12. package/skills/feishu-user-plugin/references/CLAUDE.md +138 -99
  13. package/skills/feishu-user-plugin/references/table.md +18 -9
  14. package/src/auth/credentials.js +350 -0
  15. package/src/cli.js +42 -13
  16. package/src/clients/official/base.js +424 -0
  17. package/src/clients/official/bitable.js +269 -0
  18. package/src/clients/official/calendar.js +176 -0
  19. package/src/clients/official/contacts.js +54 -0
  20. package/src/clients/official/docs.js +301 -0
  21. package/src/clients/official/drive.js +77 -0
  22. package/src/clients/official/groups.js +68 -0
  23. package/src/clients/official/im.js +414 -0
  24. package/src/clients/official/index.js +30 -0
  25. package/src/clients/official/okr.js +127 -0
  26. package/src/clients/official/tasks.js +142 -0
  27. package/src/clients/official/uploads.js +260 -0
  28. package/src/clients/official/wiki.js +207 -0
  29. package/src/{client.js → clients/user.js} +23 -17
  30. package/src/index.js +4 -1977
  31. package/src/logger.js +20 -0
  32. package/src/oauth.js +5 -1
  33. package/src/official.js +5 -1944
  34. package/src/prompts/_registry.js +69 -0
  35. package/src/prompts/index.js +54 -0
  36. package/src/server.js +242 -0
  37. package/src/test-all.js +2 -2
  38. package/src/test-comprehensive.js +3 -3
  39. package/src/test-send.js +1 -1
  40. package/src/tools/_registry.js +30 -0
  41. package/src/tools/bitable.js +246 -0
  42. package/src/tools/calendar.js +207 -0
  43. package/src/tools/contacts.js +66 -0
  44. package/src/tools/diagnostics.js +172 -0
  45. package/src/tools/docs.js +158 -0
  46. package/src/tools/drive.js +111 -0
  47. package/src/tools/groups.js +81 -0
  48. package/src/tools/im-read.js +259 -0
  49. package/src/tools/messaging-bot.js +151 -0
  50. package/src/tools/messaging-user.js +292 -0
  51. package/src/tools/okr.js +159 -0
  52. package/src/tools/profile.js +43 -0
  53. package/src/tools/tasks.js +168 -0
  54. package/src/tools/uploads.js +63 -0
  55. package/src/tools/wiki.js +191 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.6",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. batch_send and real @-mentions), read chats (auto-expanded merge_forward), manage docs (image + file blocks) / bitable (with attachment upload) / wiki / drive (file upload + wiki attach) / OKR / calendar / multi-profile. 81 tools + 9 skills, 3 auth layers. v1.3.6: upload completeness, sheets:spreadsheet scope, batch_send, multi-profile, send_card_as_user (bot-default).",
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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
4
  [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org)
5
5
  [![MCP](https://img.shields.io/badge/MCP-Compatible-purple.svg)](https://modelcontextprotocol.io)
6
- [![Tools](https://img.shields.io/badge/Tools-81-orange.svg)](#tools)
6
+ [![Tools](https://img.shields.io/badge/Tools-80-orange.svg)](#tools)
7
7
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
8
8
 
9
- **All-in-one Feishu/Lark MCP Server -- 81 tools, 9 skills, 3 auth layers for messaging, docs, bitable, calendar, tasks, drive, OKR, and more.**
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 (81 total)
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
- | `download_image` | Download a chat-message image (message_id + image_key) OR a docx image (image_token + optional doc_token). Returned as MCP image content. For merge_forward children, use the child's `parentMessageId`, not the child id. |
396
- | `download_file` | 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. (v1.3.5) |
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 (7 tools)
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
- | `create_doc_block` | Insert content blocks: generic `children`, `image_path` / `image_token` (image block), `file_path` / `file_token` (file attachment block, v1.3.6) |
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 (18 tools)
420
+ ### Official API -- Bitable (6 tools, v1.3.7 consolidation)
425
421
 
426
- | Tool | Description |
427
- |------|-------------|
428
- | `create_bitable` | Create a new Bitable app |
429
- | `list_bitable_tables` / `create_bitable_table` / `delete_bitable_table` | Table management |
430
- | `list_bitable_fields` / `create_bitable_field` / `update_bitable_field` / `delete_bitable_field` | Field management |
431
- | `list_bitable_views` | List views |
432
- | `search_bitable_records` / `get_bitable_record` | Query records |
433
- | `create_bitable_record` / `update_bitable_record` / `delete_bitable_record` | Single record CRUD |
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 (5 tools)
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
- | `delete_calendar_event` | Delete an event |
445
- | `get_freebusy` | Check user availability |
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 (5 tools)
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
- | `create_task` | Create a task |
452
- | `get_task` | Get task details |
453
- | `list_tasks` | List tasks |
454
- | `update_task` | Update a task |
455
- | `complete_task` | Mark task as complete |
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 (6 tools)
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
- | `copy_file` | Copy a file |
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 & Contacts (4 tools)
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
- | `find_user` | Find user by email or mobile number |
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 (81 tools)
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.6",
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 (with attachment upload), wiki, drive (file upload + wiki attach), OKR, calendar, multi-profile. 81 tools + 9 skills, 3 auth layers. v1.3.6: upload completeness, sheets:spreadsheet scope, batch_send, multi-profile, send_card_as_user (bot-default).",
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
- "prepublishOnly": "node scripts/confirm-version.js"
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}`);
@@ -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"