feishu-user-plugin 1.3.5 → 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 (56) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +66 -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 +152 -96
  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/doc-blocks.js +20 -5
  31. package/src/index.js +4 -1744
  32. package/src/logger.js +20 -0
  33. package/src/oauth.js +8 -1
  34. package/src/official.js +5 -1734
  35. package/src/prompts/_registry.js +69 -0
  36. package/src/prompts/index.js +54 -0
  37. package/src/server.js +242 -0
  38. package/src/test-all.js +2 -2
  39. package/src/test-comprehensive.js +3 -3
  40. package/src/test-send.js +1 -1
  41. package/src/tools/_registry.js +30 -0
  42. package/src/tools/bitable.js +246 -0
  43. package/src/tools/calendar.js +207 -0
  44. package/src/tools/contacts.js +66 -0
  45. package/src/tools/diagnostics.js +172 -0
  46. package/src/tools/docs.js +158 -0
  47. package/src/tools/drive.js +111 -0
  48. package/src/tools/groups.js +81 -0
  49. package/src/tools/im-read.js +259 -0
  50. package/src/tools/messaging-bot.js +151 -0
  51. package/src/tools/messaging-user.js +292 -0
  52. package/src/tools/okr.js +159 -0
  53. package/src/tools/profile.js +43 -0
  54. package/src/tools/tasks.js +168 -0
  55. package/src/tools/uploads.js +63 -0
  56. package/src/tools/wiki.js +191 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.5",
4
- "description": "All-in-one Feishu plugin for Claude Code — send messages as yourself (incl. real @-mentions), read chats (with auto-expanded merge_forward), manage docs (with image read/write) / bitable / wiki (native + move_docs_to_wiki) / drive / OKR / calendar. 75 tools + 9 skills, 3 auth layers. v1.3.5: cross-process UAT refresh lock, bot-fallback warning, download_file.",
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-74-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 -- 75 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,9 +337,9 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
337
337
  }
338
338
  ```
339
339
 
340
- ## Tools (75 total)
340
+ ## Tools (80 total)
341
341
 
342
- ### User Identity -- Messaging (8 tools, cookie auth)
342
+ ### User Identity -- Messaging (10 tools, cookie auth)
343
343
 
344
344
  | Tool | Description |
345
345
  |------|-------------|
@@ -349,8 +349,8 @@ 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 |
352
+ | `batch_send` | Fan-out send to multiple targets in one call (text / image / file / post). v1.3.6 |
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. |
354
354
 
355
355
  ### User Identity -- Contacts & Info (5 tools, cookie auth)
356
356
 
@@ -390,8 +390,8 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
390
390
  | `add_members` | Add users to a group |
391
391
  | `remove_members` | Remove users from a group |
392
392
  | `upload_image` / `upload_file` | Upload image/file, returns key for sending |
393
- | `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. |
394
- | `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). |
395
395
 
396
396
  ### Wiki, OKR, and Calendar (v1.3.4)
397
397
 
@@ -407,7 +407,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
407
407
 
408
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.
409
409
 
410
- ### Official API -- Documents (7 tools)
410
+ ### Official API -- Documents (5 tools)
411
411
 
412
412
  | Tool | Description |
413
413
  |------|-------------|
@@ -415,58 +415,71 @@ All docx / bitable tools' `document_id` / `app_token` parameter also accepts a W
415
415
  | `read_doc` | Read raw text content |
416
416
  | `get_doc_blocks` | Get structured block tree |
417
417
  | `create_doc` | Create a new document |
418
- | `create_doc_block` | Insert content blocks into a document |
419
- | `update_doc_block` | Update a specific block |
420
- | `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. |
421
419
 
422
- ### Official API -- Bitable (17 tools)
420
+ ### Official API -- Bitable (6 tools, v1.3.7 consolidation)
423
421
 
424
- | Tool | Description |
425
- |------|-------------|
426
- | `create_bitable` | Create a new Bitable app |
427
- | `list_bitable_tables` / `create_bitable_table` / `delete_bitable_table` | Table management |
428
- | `list_bitable_fields` / `create_bitable_field` / `update_bitable_field` / `delete_bitable_field` | Field management |
429
- | `list_bitable_views` | List views |
430
- | `search_bitable_records` / `get_bitable_record` | Query records |
431
- | `create_bitable_record` / `update_bitable_record` / `delete_bitable_record` | Single record CRUD |
432
- | `batch_create_bitable_records` / `batch_update_bitable_records` / `batch_delete_bitable_records` | Batch operations (max 500/call) |
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 |
433
430
 
434
- ### Official API -- Calendar (5 tools)
431
+ ### Official API -- Calendar (8 tools, write tools v1.3.7)
435
432
 
436
433
  | Tool | Description |
437
434
  |------|-------------|
438
435
  | `list_calendars` | List accessible calendars |
439
- | `create_calendar_event` | Create a calendar event |
440
436
  | `list_calendar_events` | List events in a calendar |
441
- | `delete_calendar_event` | Delete an event |
442
- | `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) |
443
+
444
+ ### Official API -- Tasks v2 (7 tools, v1.3.7 new domain)
443
445
 
444
- ### Official API -- Tasks (5 tools)
446
+ Identifier is `task_guid` (not v1's numeric `task_id`). Requires `task:task` scope.
445
447
 
446
448
  | Tool | Description |
447
449
  |------|-------------|
448
- | `create_task` | Create a task |
449
- | `get_task` | Get task details |
450
- | `list_tasks` | List tasks |
451
- | `update_task` | Update a task |
452
- | `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"}]` |
453
457
 
454
- ### Official API -- Drive (5 tools)
458
+ ### Official API -- Drive (4 tools)
455
459
 
456
460
  | Tool | Description |
457
461
  |------|-------------|
458
462
  | `list_files` | List files in a folder |
459
463
  | `create_folder` | Create a new folder |
460
- | `copy_file` | Copy a file |
461
- | `move_file` | Move a file |
462
- | `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. |
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 |
466
+
467
+ ### Official API -- Wiki (8 tools)
468
+
469
+ | Tool | Description |
470
+ |------|-------------|
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) |
463
476
 
464
- ### Official API -- Wiki & Contacts (4 tools)
477
+ ### Plugin -- Profiles (2 tools, v1.3.6)
465
478
 
466
479
  | Tool | Description |
467
480
  |------|-------------|
468
- | `list_wiki_spaces` / `search_wiki` / `list_wiki_nodes` | Wiki spaces, search, browse |
469
- | `find_user` | Find user by email or mobile number |
481
+ | `list_profiles` | List available identity profiles (default + extras from `LARK_PROFILES_JSON`) and the active one |
482
+ | `switch_profile` | Hot-swap active profile; cached client instances rebuild against new credentials |
470
483
 
471
484
  ## Claude Code Slash Commands (9 skills)
472
485
 
@@ -526,7 +539,7 @@ feishu-user-plugin/
526
539
  │ ├── SKILL.md # Main skill definition (trigger, tools, auth)
527
540
  │ └── references/ # 8 skill reference docs + CLAUDE.md
528
541
  ├── src/
529
- │ ├── index.js # MCP server entry point (75 tools)
542
+ │ ├── index.js # MCP server entry point (78 tools)
530
543
  │ ├── client.js # User identity client (Protobuf gateway)
531
544
  │ ├── official.js # Official API client (REST, UAT)
532
545
  │ ├── utils.js # ID generators, cookie parser
@@ -555,6 +568,19 @@ Issues and PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for development s
555
568
 
556
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.
557
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
+
558
584
  ## License
559
585
 
560
586
  [MIT](LICENSE)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.5",
4
- "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (with merge_forward expansion), docs (with image read/write), bitable, wiki (native + move_docs_to_wiki), drive, OKR, calendar. 75 tools + 9 skills, 3 auth layers. v1.3.5: cross-process UAT refresh lock + bot-fallback warning + auto-expand merge_forward + download_file.",
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"