feishu-user-plugin 1.3.9 → 1.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "feishu-user-plugin",
3
- "version": "1.3.9",
4
- "description": "All-in-one Feishu plugin for Claude Code & Codex — messaging (incl. user-identity image send v1.3.9 + batch_send), docs (markdown read v1.3.9, image/file blocks), bitable, wiki, drive, OKR, calendar, Tasks v2, multi-profile (cross-process sync v1.3.9), machine-level shared WS events (v1.3.9). 84 tools + 9 prompts, 3 auth layers.",
3
+ "mcpName": "io.github.EthanQC/feishu-user-plugin",
4
+ "version": "1.3.11",
5
+ "description": "All-in-one Feishu MCP server for Claude Code & Codex — 84 tools across 3 auth layers (cookie / app / OAuth). Send as you, read groups, manage docs / bitable / wiki / drive / calendar / tasks / OKR.",
5
6
  "main": "src/index.js",
6
7
  "bin": {
7
8
  "feishu-user-plugin": "src/cli.js"
@@ -44,11 +45,15 @@
44
45
  "proto/",
45
46
  "scripts/",
46
47
  ".claude-plugin/",
48
+ ".cursor-plugin/",
49
+ ".mcpb/",
47
50
  "skills/",
48
51
  ".mcp.json.example",
49
52
  ".env.example",
50
53
  "CHANGELOG.md",
51
54
  "README.md",
55
+ "README.en.md",
56
+ "PRIVACY.md",
52
57
  "LICENSE"
53
58
  ],
54
59
  "engines": {
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/build-mcpb.js
5
+ //
6
+ // Packages the runtime files + .mcpb/manifest.json into
7
+ // dist/feishu-user-plugin-<version>.mcpb (a ZIP with manifest.json at the root).
8
+ //
9
+ // The .mcpb format is a plain ZIP archive consumed by Claude Desktop / Anthropic
10
+ // Connectors Directory. Required layout:
11
+ // manifest.json (at root, copied from .mcpb/manifest.json)
12
+ // src/... (server runtime)
13
+ // proto/... (protobuf descriptors)
14
+ // skills/... (MCP prompts source)
15
+ // .claude-plugin/... (plugin metadata)
16
+ // package.json (so `node src/index.js` resolves deps after `npm ci`)
17
+ // package-lock.json
18
+ // PRIVACY.md, README.md, LICENSE
19
+ //
20
+ // node_modules/ is NOT bundled; the connector host runs `npm ci --omit=dev`
21
+ // against the bundled package.json once installed (Anthropic convention).
22
+ //
23
+ // Re-runnable: overwrites dist/feishu-user-plugin-<version>.mcpb on each run.
24
+ //
25
+ // Usage:
26
+ // node scripts/build-mcpb.js
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const { execFileSync } = require('child_process');
31
+
32
+ const ROOT = path.join(__dirname, '..');
33
+ const DIST = path.join(ROOT, 'dist');
34
+ const MANIFEST_SRC = path.join(ROOT, '.mcpb', 'manifest.json');
35
+
36
+ function fail(msg) {
37
+ console.error(`build-mcpb: ${msg}`);
38
+ process.exit(1);
39
+ }
40
+
41
+ if (!fs.existsSync(MANIFEST_SRC)) fail('.mcpb/manifest.json not found');
42
+
43
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
44
+ const manifest = JSON.parse(fs.readFileSync(MANIFEST_SRC, 'utf8'));
45
+
46
+ if (!manifest.version) fail('.mcpb/manifest.json missing `version`');
47
+ if (manifest.version !== pkg.version) {
48
+ fail(
49
+ `version mismatch — package.json=${pkg.version} but .mcpb/manifest.json=${manifest.version}. ` +
50
+ `Run: node scripts/check-mcpb-version.js`
51
+ );
52
+ }
53
+
54
+ const VERSION = pkg.version;
55
+ const OUT = path.join(DIST, `feishu-user-plugin-${VERSION}.mcpb`);
56
+
57
+ if (!fs.existsSync(DIST)) fs.mkdirSync(DIST, { recursive: true });
58
+ if (fs.existsSync(OUT)) fs.unlinkSync(OUT);
59
+
60
+ // Files & dirs to bundle. Order matters only for predictable zip output.
61
+ // Mirrors package.json::files plus PRIVACY.md and the bundled manifest.json.
62
+ const ENTRIES = [
63
+ 'manifest.json', // synthesized at staging root from .mcpb/manifest.json
64
+ 'src',
65
+ 'proto',
66
+ 'scripts',
67
+ '.claude-plugin',
68
+ 'skills',
69
+ 'package.json',
70
+ 'package-lock.json',
71
+ 'PRIVACY.md',
72
+ 'README.md',
73
+ 'LICENSE',
74
+ ];
75
+
76
+ // Stage in a tmp dir so the zip has manifest.json at the archive root rather
77
+ // than .mcpb/manifest.json. Using a tmp dir keeps the source tree clean.
78
+ const STAGE = fs.mkdtempSync(path.join(require('os').tmpdir(), 'mcpb-build-'));
79
+ try {
80
+ // Copy manifest.json to staging root
81
+ fs.copyFileSync(MANIFEST_SRC, path.join(STAGE, 'manifest.json'));
82
+
83
+ // Copy each remaining entry from repo root → staging root
84
+ for (const entry of ENTRIES.slice(1)) {
85
+ const src = path.join(ROOT, entry);
86
+ if (!fs.existsSync(src)) {
87
+ console.warn(`build-mcpb: skipping missing entry: ${entry}`);
88
+ continue;
89
+ }
90
+ const dest = path.join(STAGE, entry);
91
+ copyRecursive(src, dest);
92
+ }
93
+
94
+ // Create the ZIP via system `zip` (present on macOS + ubuntu-latest CI).
95
+ // -r recursive, -X strip extra OS attrs for reproducibility, -q quiet.
96
+ // We pass `.` so paths inside the zip are relative to the staging root.
97
+ execFileSync('zip', ['-rqX', OUT, '.'], { cwd: STAGE, stdio: 'inherit' });
98
+ } finally {
99
+ fs.rmSync(STAGE, { recursive: true, force: true });
100
+ }
101
+
102
+ const stats = fs.statSync(OUT);
103
+ console.log(`OK: built ${path.relative(ROOT, OUT)} (${(stats.size / 1024).toFixed(1)} KB)`);
104
+
105
+ // --- helpers ------------------------------------------------------------
106
+
107
+ function copyRecursive(src, dest) {
108
+ const stat = fs.statSync(src);
109
+ if (stat.isDirectory()) {
110
+ fs.mkdirSync(dest, { recursive: true });
111
+ for (const child of fs.readdirSync(src)) {
112
+ // Skip OS noise + already-built artifacts inside copied dirs
113
+ if (child === '.DS_Store' || child === 'node_modules' || child === 'dist') continue;
114
+ copyRecursive(path.join(src, child), path.join(dest, child));
115
+ }
116
+ } else if (stat.isFile()) {
117
+ fs.copyFileSync(src, dest);
118
+ }
119
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Verifies mcp-registry.json::version + packages[0].version == package.json::version.
4
+ // Wired into:
5
+ // - .github/workflows/publish.yml — pre-publish gate so CI never publishes to the
6
+ // official MCP Registry with a stale version string.
7
+ // - .github/workflows/validate.yml — PR-time gate so any version bump on
8
+ // package.json without a matching bump on mcp-registry.json fails before merge.
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const ROOT = path.join(__dirname, '..');
13
+
14
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
15
+ const pkgVersion = pkg.version;
16
+
17
+ const registryPath = path.join(ROOT, 'mcp-registry.json');
18
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
19
+ const registryVersion = registry.version;
20
+
21
+ if (!Array.isArray(registry.packages) || registry.packages.length === 0) {
22
+ console.error('ERROR: mcp-registry.json has no packages[] entries');
23
+ process.exit(1);
24
+ }
25
+ const pkgEntryVersion = registry.packages[0].version;
26
+
27
+ const sources = [
28
+ { label: 'package.json', version: pkgVersion, path: 'package.json' },
29
+ { label: 'mcp-registry.json::version', version: registryVersion, path: 'mcp-registry.json' },
30
+ { label: 'mcp-registry.json::packages[0].version', version: pkgEntryVersion, path: 'mcp-registry.json' },
31
+ ];
32
+
33
+ const allEqual = sources.every((s) => s.version === sources[0].version);
34
+
35
+ if (!allEqual) {
36
+ console.error('ERROR: mcp-registry.json version mismatch with package.json!');
37
+ sources.forEach((s) => console.error(` ${s.label}: ${s.version}`));
38
+ console.error('Fix: bump mcp-registry.json::version AND mcp-registry.json::packages[0].version');
39
+ console.error(` to match package.json (${pkgVersion}).`);
40
+ process.exit(1);
41
+ }
42
+
43
+ console.log(`OK: mcp-registry.json version ${pkgVersion}`);
@@ -0,0 +1,33 @@
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: .mcpb/manifest.json
13
+ const manifestPath = path.join(ROOT, '.mcpb', 'manifest.json');
14
+ if (!fs.existsSync(manifestPath)) {
15
+ console.error('ERROR: .mcpb/manifest.json not found');
16
+ process.exit(1);
17
+ }
18
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
19
+ const manifestVersion = manifest.version;
20
+
21
+ if (!manifestVersion) {
22
+ console.error('ERROR: .mcpb/manifest.json is missing the `version` field');
23
+ process.exit(1);
24
+ }
25
+
26
+ if (pkgVersion !== manifestVersion) {
27
+ console.error('ERROR: .mcpb manifest version mismatch!');
28
+ console.error(` package.json: ${pkgVersion}`);
29
+ console.error(` .mcpb/manifest.json: ${manifestVersion}`);
30
+ process.exit(1);
31
+ }
32
+
33
+ console.log(`OK: .mcpb manifest version ${pkgVersion}`);
@@ -22,10 +22,15 @@ if (!skillMatch) {
22
22
  }
23
23
  const skillVersion = skillMatch[1];
24
24
 
25
+ // Source 4: .cursor-plugin/plugin.json
26
+ const cursorPlugin = JSON.parse(fs.readFileSync(path.join(ROOT, '.cursor-plugin', 'plugin.json'), 'utf8'));
27
+ const cursorVersion = cursorPlugin.version;
28
+
25
29
  const sources = [
26
30
  { label: 'package.json', version: pkgVersion, path: 'package.json' },
27
31
  { label: '.claude-plugin/plugin.json', version: pluginVersion, path: '.claude-plugin/plugin.json' },
28
32
  { label: 'skills/feishu-user-plugin/SKILL.md', version: skillVersion, path: 'skills/feishu-user-plugin/SKILL.md' },
33
+ { label: '.cursor-plugin/plugin.json', version: cursorVersion, path: '.cursor-plugin/plugin.json' },
29
34
  ];
30
35
 
31
36
  const versions = sources.map((s) => s.version);
@@ -1,28 +1,30 @@
1
1
  #!/usr/bin/env bash
2
2
  # scripts/sync-team-skills.sh — post-merge hook on main.
3
3
  #
4
- # What this does (zero manual steps, no degradation):
5
- # 1. Copy skills/ + .claude-plugin/plugin.json into team-skills repo
6
- # 2. Run team-skills' generate-catalog.py (forced manual-yaml path for byte
7
- # parity with CI)
8
- # 3. Run our scripts/generate-release-artifacts.js to produce
9
- # changelog + readme-row from CHANGELOG.md
10
- # 4. Inject the changelog block into team-skills child README before the
11
- # previous version's heading
12
- # 5. Replace the team-skills root README catalog row matching feishu-user-plugin
13
- # 6. Commit + push branch + open PR
14
- # 7. Auto-merge: --admin --squash (we have admin on team-skills repo;
15
- # org-level setting blocks repo PATCH for allow_auto_merge so we use
16
- # --admin to bypass review wait. CI is non-blocking via "Check catalog"
17
- # drift never happening since step 2 produced byte-identical output.)
4
+ # Idempotent + conflict-resilient sync from feishu-user-plugin's main into
5
+ # zhuzhen-team/team-skills. Designed so retries always converge:
18
6
  #
19
- # Failure modes are now narrow:
7
+ # Flow:
8
+ # 1. Generate release artifacts in feishu repo (changelog block + readme row).
9
+ # 2. cd team-skills repo, fetch origin main.
10
+ # 3. Close any stale OPEN sync PRs whose branch is for an older version
11
+ # (so v1.3.10 sync doesn't pile up behind a never-merged v1.3.9 sync).
12
+ # 4. Delete any local stale sync/feishu-v$VERSION branch and recreate from
13
+ # origin/main — always starts fresh, never carries leftover commits.
14
+ # 5. Copy plugin tree + inject changelog + replace catalog row + regen catalog.
15
+ # 6. If nothing changed → exit 0 (already in sync for v$VERSION).
16
+ # 7. Commit + push --force-with-lease (safe: only this script writes to sync/* branches).
17
+ # 8. Open PR if not exists; merge --admin --squash.
18
+ #
19
+ # Failure modes:
20
20
  # - team-skills repo not cloned at expected path → clean skip
21
- # - branch already exists from previous attempt clean skip
22
- # - generate-release-artifacts.js fails exit non-zero (visible to user
23
- # via post-merge stderr; user fixes CHANGELOG and re-pushes)
21
+ # - generate-release-artifacts.js fails exit non-zero (visible in stderr)
22
+ # - PR merge fails (rare; should be impossible after force-recreate from origin/main)
23
+ # exit non-zero, post-merge wrapper labels as "non-fatal" but user sees stderr
24
24
  set -e
25
- TEAM_SKILLS="/Users/abble/team-skills/plugins/feishu-user-plugin"
25
+
26
+ TEAM_SKILLS_REPO="/Users/abble/team-skills"
27
+ TEAM_SKILLS="$TEAM_SKILLS_REPO/plugins/feishu-user-plugin"
26
28
  if [ ! -d "$TEAM_SKILLS" ]; then echo "[hook] team-skills not present, skip"; exit 0; fi
27
29
 
28
30
  ROOT="$(git rev-parse --show-toplevel)"
@@ -30,25 +32,43 @@ cd "$ROOT"
30
32
 
31
33
  VERSION=$(node -e "console.log(require('./package.json').version)")
32
34
  ARTIFACTS="/tmp/feishu-release/v${VERSION}"
35
+ BRANCH="sync/feishu-v$VERSION"
33
36
 
34
- # Generate release artifacts FIRST so we can inject them into team-skills.
35
- # This reads CHANGELOG.md for the v$VERSION section and emits team-skills
36
- # changelog markdown + root readme row + announcement card JSON.
37
+ # 1. Generate release artifacts FIRST so we can inject them into team-skills.
37
38
  node scripts/generate-release-artifacts.js "$VERSION" >/dev/null
38
39
 
39
- # Copy plugin tree.
40
- cp -r skills/. "$TEAM_SKILLS/skills/"
41
- cp .claude-plugin/plugin.json "$TEAM_SKILLS/.claude-plugin/"
40
+ # 2. cd team-skills, fetch origin main.
41
+ cd "$TEAM_SKILLS_REPO"
42
+ git fetch origin main --quiet
43
+
44
+ # 3. Close any stale OPEN sync PRs (different version branch). Idempotent —
45
+ # any matching PR for this same $VERSION is preserved (we'll force-update
46
+ # its branch in step 7 instead).
47
+ STALE_PRS=$(gh pr list --state open --search "Sync feishu-user-plugin in:title" \
48
+ --json number,headRefName --jq ".[] | select(.headRefName != \"$BRANCH\") | .number")
49
+ if [ -n "$STALE_PRS" ]; then
50
+ for stale_num in $STALE_PRS; do
51
+ gh pr close "$stale_num" \
52
+ --comment "Superseded by sync/feishu-v$VERSION (auto-closed by sync-team-skills.sh)" \
53
+ --delete-branch 2>&1 | tail -1 || true
54
+ echo "[hook] closed stale sync PR #$stale_num"
55
+ done
56
+ fi
57
+
58
+ # 4. Delete any local stale sync branch + recreate from origin/main.
59
+ # `git checkout -B` is "create or reset". We always start from latest main
60
+ # so there are no inherited commits from older sync attempts.
61
+ git checkout -B "$BRANCH" origin/main
62
+
63
+ # 5. Copy plugin tree from feishu repo, inject changelog, regen catalog.
64
+ cp -r "$ROOT/skills/." "$TEAM_SKILLS/skills/"
65
+ cp "$ROOT/.claude-plugin/plugin.json" "$TEAM_SKILLS/.claude-plugin/"
42
66
 
43
- # Inject changelog block into team-skills/plugins/feishu-user-plugin/README.md.
44
- # Insert just before the existing first "### vX.Y.Z" heading, OR after
45
- # "## 更新日志" if no prior version exists.
67
+ # 5a. Inject changelog block into team-skills child README (idempotent).
46
68
  README="$TEAM_SKILLS/README.md"
47
69
  if grep -q "^### v${VERSION} " "$README"; then
48
70
  echo "[hook] team-skills child README already has v${VERSION} section, skipping inject"
49
71
  else
50
- # awk: print everything; when we hit the FIRST `### vX.Y.Z (date)` heading,
51
- # insert the new block before it.
52
72
  awk -v block_file="$ARTIFACTS/team-skills-changelog.md" '
53
73
  BEGIN { inserted = 0 }
54
74
  /^### v[0-9]+\.[0-9]+\.[0-9]+ \(/ && !inserted {
@@ -61,14 +81,12 @@ else
61
81
  echo "[hook] injected v${VERSION} changelog block into child README"
62
82
  fi
63
83
 
64
- # Replace the team-skills root README catalog row matching feishu-user-plugin.
65
- ROOT_README="$TEAM_SKILLS/../../README.md"
84
+ # 5b. Replace root README catalog row matching feishu-user-plugin.
85
+ ROOT_README="$TEAM_SKILLS_REPO/README.md"
66
86
  NEW_ROW=$(cat "$ARTIFACTS/team-skills-readme-row.md")
67
87
  if grep -q "^| \\*\\*feishu-user-plugin\\*\\* |" "$ROOT_README"; then
68
- # Replace the line in-place. Use Python (sed regex with table chars + |
69
- # quotes is brittle across BSD/GNU).
70
88
  python3 -c "
71
- import sys, re
89
+ import re
72
90
  p = '$ROOT_README'
73
91
  new_row = '''$NEW_ROW'''.strip()
74
92
  text = open(p, 'r', encoding='utf-8').read()
@@ -78,47 +96,44 @@ open(p, 'w', encoding='utf-8').write(text)
78
96
  echo "[hook] updated root README catalog row to v${VERSION}"
79
97
  fi
80
98
 
81
- # Switch into team-skills repo root (two parents up from $TEAM_SKILLS).
82
- cd "$TEAM_SKILLS/../.."
83
-
84
- BRANCH="sync/feishu-v$VERSION"
85
- if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then
86
- echo "[hook] branch $BRANCH already exists locally, skipping"; exit 0
87
- fi
88
- git checkout -b "$BRANCH"
89
-
90
- # team-skills CI runs generate-catalog.py without PyYAML; force the same path
91
- # locally for byte-identical output. Verified in PR #36.
99
+ # 5c. Regenerate catalog.yaml (force PyYAML-less path for byte parity with CI).
92
100
  if [ -f "scripts/generate-catalog.py" ]; then
93
101
  python3 -c "import sys, runpy; sys.modules['yaml']=None; runpy.run_path('scripts/generate-catalog.py', run_name='__main__')" >/dev/null 2>&1
94
102
  fi
95
103
 
96
- # Stage every file the hook might have touched. Each `git add` is idempotent
97
- # on already-clean files, so unchanged ones stage as no-op. Files that don't
98
- # exist (e.g., catalog.yaml when team-skills repo doesn't have the generator)
99
- # would fail under `set -e`, so guard explicitly.
104
+ # 6. Stage everything the hook touched.
100
105
  git add "plugins/feishu-user-plugin/"
101
106
  [ -f "README.md" ] && git add README.md
102
107
  [ -f "catalog.yaml" ] && git add catalog.yaml
103
108
 
104
- # If nothing actually changed, exit 0 — the v$VERSION sync was already done.
105
109
  if git diff --cached --quiet; then
106
110
  echo "[hook] nothing to sync (working tree clean for v$VERSION)"; exit 0
107
111
  fi
108
112
 
109
113
  git commit -m "chore: sync feishu-user-plugin v$VERSION (skills + plugin.json + README changelog + catalog)"
110
- git push -u origin "$BRANCH"
111
114
 
112
- gh pr create --title "Sync feishu-user-plugin v$VERSION" --body "Auto-sync from feishu-user-plugin main. Includes:
115
+ # 7. Push (force-with-lease since this branch is exclusively written by this
116
+ # script — safe even if a previous run pushed something we just rebuilt).
117
+ git push --force-with-lease -u origin "$BRANCH"
118
+
119
+ # 8. Open PR if not exists, then merge.
120
+ PR_NUM=$(gh pr list --head "$BRANCH" --state open --json number --jq ".[0].number // empty")
121
+ if [ -z "$PR_NUM" ]; then
122
+ gh pr create --title "Sync feishu-user-plugin v$VERSION" --body "Auto-sync from feishu-user-plugin main. Includes:
113
123
  - plugins/feishu-user-plugin/.claude-plugin/plugin.json bumped to v$VERSION
114
124
  - plugins/feishu-user-plugin/skills/ regenerated
115
125
  - plugins/feishu-user-plugin/README.md: v$VERSION changelog section auto-generated from feishu-user-plugin's CHANGELOG.md
116
126
  - README.md (root): catalog row updated
117
127
  - catalog.yaml regenerated"
128
+ PR_NUM=$(gh pr view "$BRANCH" --json number --jq .number)
129
+ echo "[hook] opened sync PR #$PR_NUM"
130
+ else
131
+ echo "[hook] reusing existing sync PR #$PR_NUM (branch force-updated)"
132
+ fi
118
133
 
119
- PR_NUM=$(gh pr view --json number --jq .number)
120
- # Use --admin --squash: we have admin permissions on team-skills (verified) and
121
- # the team-skills org has auto-merge disabled at org level. --admin merges
122
- # without waiting for required reviews. CI is informational only here.
134
+ # Use --admin --squash: we have admin permissions on team-skills (verified).
135
+ # After step 4's force-recreate from origin/main, this PR is always cleanly
136
+ # mergeable (no carried conflicts). --admin bypasses required reviews; CI
137
+ # is informational since step 5c produced byte-identical catalog output.
123
138
  gh pr merge "$PR_NUM" --admin --squash
124
139
  echo "[hook] team-skills sync PR #$PR_NUM merged"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: feishu-user-plugin
3
- version: "1.3.9"
3
+ version: "1.3.11"
4
4
  description: "All-in-one Feishu plugin — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.8: multi-profile auto-switch on read errors (B), WebSocket realtime im.message events via get_new_events (C), credential pointer-only mode (E), CI gates (F), auth/uat.js + auth/cookie.js extracts (D)."
5
5
  allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, manage_profile_hints, read_p2p_messages, list_user_chats, list_chats, read_messages, send_message_as_bot, reply_message, forward_message, delete_message, update_message, add_reaction, delete_reaction, pin_message, create_group, update_group, list_members, manage_members, search_docs, read_doc, get_doc_blocks, create_doc, manage_doc_block, read_doc_markdown, manage_bitable_app, manage_bitable_table, manage_bitable_field, manage_bitable_view, manage_bitable_record, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, create_wiki_node, update_wiki_node, move_wiki_node, copy_wiki_node, delete_wiki_node, list_files, create_folder, upload_drive_file, manage_drive_file, upload_image, upload_file, download_message_resource, download_doc_image, list_user_okrs, get_okrs, list_okr_periods, create_okr_progress_record, list_okr_progress_records, delete_okr_progress_record, list_calendars, list_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event, respond_calendar_event, get_freebusy, list_tasks, get_task, create_task, update_task, complete_task, delete_task, manage_task_members, get_new_events, manage_ws_status
6
6
  user_invocable: true
@@ -197,6 +197,7 @@ For more profiles beyond the default, set `LARK_PROFILES_JSON` in the MCP env (o
197
197
  - `~/.feishu-user-plugin/ws-owner.lock`: lock file owned by the one MCP process driving the WS connection (O_CREAT|O_EXCL, 30 s stale).
198
198
  - `~/.feishu-user-plugin/events.jsonl`: append-only event log written by the WS owner; 10 MB soft / 20 MB hard cap then rotated to `events.jsonl.old`.
199
199
  - `~/.feishu-user-plugin/events.cursor.json`: global drain cursor shared across all MCP processes — advancing it marks events as consumed for all harnesses on the machine.
200
+ - **Lark Desktop multi-account auto-switch (v1.3.11)**: when `credentials.json::profiles[*].larkHash` is bound, the owner heartbeat (15 s) watches `~/Library/.../sdk_storage/<hash>/cookie_store.db` mtime; switching the active Lark Desktop account auto-flips `credentials.json::active` to the bound profile. macOS-only. Bind via `setup --bind-hash <hash>` or auto-detect on `setup` (single account binds silently; multiple prompts in interactive mode / picks most-recent in non-interactive mode). Cookies stay per-profile in `LARK_COOKIE`; the encrypted `cookie_store.db` is never read.
200
201
 
201
202
  ### Credentials store (v1.3.7+)
202
203
  Single source of truth at `~/.feishu-user-plugin/credentials.json` (mode 0600). Schema documented at `docs/CREDENTIALS-FORMAT.md`. The MCP server reads from this file when present; cookie heartbeat and UAT refresh persist back to it atomically. Multiple harnesses (Claude Code, Codex) sharing the same file see token rotations consistently — no more "Codex still has the old UAT" drift after a refresh in Claude Code.
@@ -360,6 +360,51 @@ function setProfileEvents(name, eventList) {
360
360
  return true;
361
361
  }
362
362
 
363
+ // --- Lark Desktop hash bindings (v1.3.11 §A) ---
364
+ //
365
+ // Each profile may carry an optional `larkHash` 32-char-hex field binding it
366
+ // to one of `~/Library/Containers/com.bytedance.macos.feishu/.../sdk_storage/<hash>/`.
367
+ // The owner heartbeat reactor uses this binding to decide which profile to
368
+ // switch to when the user changes account in Lark Desktop. See
369
+ // src/auth/lark-desktop.js for the detection / switch logic; this module
370
+ // just owns the persisted binding.
371
+
372
+ const _LARK_HASH_RE = /^[a-f0-9]{32}$/;
373
+
374
+ function getProfileLarkHash(name) {
375
+ const f = _readFile();
376
+ const target = name || (f ? f.active : 'default');
377
+ if (f && f.profiles[target] && typeof f.profiles[target].larkHash === 'string') {
378
+ return f.profiles[target].larkHash;
379
+ }
380
+ return null;
381
+ }
382
+
383
+ function setProfileLarkHash(name, hash) {
384
+ if (hash !== null && (typeof hash !== 'string' || !_LARK_HASH_RE.test(hash))) {
385
+ throw new Error('setProfileLarkHash: hash must be 32-char hex (a-f, 0-9) or null');
386
+ }
387
+ const f = _readFile();
388
+ if (!f) throw new Error('No credentials.json — cannot set profile larkHash. Run `npx feishu-user-plugin migrate --confirm` first.');
389
+ if (!f.profiles[name]) {
390
+ throw new Error(`setProfileLarkHash: profile "${name}" not found. Available: ${Object.keys(f.profiles).join(', ')}`);
391
+ }
392
+ if (hash === null) delete f.profiles[name].larkHash;
393
+ else f.profiles[name].larkHash = hash;
394
+ _atomicWriteJson(_credentialsPath(), f);
395
+ return true;
396
+ }
397
+
398
+ function findProfileByHash(hash) {
399
+ if (typeof hash !== 'string' || !_LARK_HASH_RE.test(hash)) return null;
400
+ const f = _readFile();
401
+ if (!f) return null;
402
+ for (const [name, profile] of Object.entries(f.profiles)) {
403
+ if (profile.larkHash === hash) return name;
404
+ }
405
+ return null;
406
+ }
407
+
363
408
  // --- Profile hints (v1.3.8) ---
364
409
  //
365
410
  // profileHints maps resourceKey → profileName, persisted in credentials.json.
@@ -419,6 +464,10 @@ module.exports = {
419
464
  // per-profile events list (v1.3.9 A.4)
420
465
  getProfileEvents,
421
466
  setProfileEvents,
467
+ // Lark Desktop hash bindings (v1.3.11 §A)
468
+ getProfileLarkHash,
469
+ setProfileLarkHash,
470
+ findProfileByHash,
422
471
  // profile hints (v1.3.8)
423
472
  getProfileHints,
424
473
  setProfileHint,
@@ -0,0 +1,135 @@
1
+ // src/auth/lark-desktop.js — Lark Desktop sdk_storage detection (v1.3.11 §A).
2
+ //
3
+ // macOS-only: Linux/Windows return null from getSdkStorageDir() and all
4
+ // callers no-op gracefully. We never read the encrypted cookie_store.db —
5
+ // only stat its mtime to detect account switches. Profile↔hash bindings
6
+ // live in credentials.json::profiles[*].larkHash.
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const HASH_RE = /^[a-f0-9]{32}$/;
15
+
16
+ // Debounce + freshness windows for the heartbeat reactor.
17
+ const SWITCH_DEBOUNCE_MS = 5_000;
18
+ const UNBOUND_FRESH_WINDOW_MS = 60_000;
19
+
20
+ function _macSdkStorageDir() {
21
+ return path.join(
22
+ os.homedir(),
23
+ 'Library/Containers/com.bytedance.macos.feishu/Data/Library/Application Support/LarkShell/sdk_storage'
24
+ );
25
+ }
26
+
27
+ function getSdkStorageDir() {
28
+ if (process.platform !== 'darwin') return null;
29
+ const dir = _macSdkStorageDir();
30
+ try {
31
+ return fs.statSync(dir).isDirectory() ? dir : null;
32
+ } catch (_) {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // List Lark account hash directories under sdk_storage, sorted by
38
+ // cookie_store.db mtime descending. Hash dirs without a cookie_store.db
39
+ // are filtered (account never logged in / cleared).
40
+ //
41
+ // Returns: [{ hash, mtimeMs, dir }]
42
+ function listAccountHashes({ dir } = {}) {
43
+ const root = dir || getSdkStorageDir();
44
+ if (!root) return [];
45
+ let entries;
46
+ try { entries = fs.readdirSync(root); } catch (_) { return []; }
47
+ const out = [];
48
+ for (const name of entries) {
49
+ if (!HASH_RE.test(name)) continue;
50
+ const accountDir = path.join(root, name);
51
+ const dbPath = path.join(accountDir, 'cookie_store.db');
52
+ let mtimeMs;
53
+ try { mtimeMs = fs.statSync(dbPath).mtimeMs; } catch (_) { continue; }
54
+ out.push({ hash: name, mtimeMs, dir: accountDir });
55
+ }
56
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
57
+ return out;
58
+ }
59
+
60
+ function mostRecentHash(opts = {}) {
61
+ const list = listAccountHashes(opts);
62
+ return list.length > 0 ? list[0] : null;
63
+ }
64
+
65
+ // Pure-ish reactor logic, dependency-injected for unit tests.
66
+ //
67
+ // Inputs:
68
+ // prevSnapshot: { [hash]: mtimeMs } from the previous heartbeat
69
+ // lastSwitchAt: ms timestamp of the last auto-switch (debounce key)
70
+ // seenUnboundHashes: Set<hash> — emit hint once per hash per session
71
+ // credsApi: { getActiveProfileName, getProfileLarkHash, findProfileByHash }
72
+ // listFn: () => [...] — defaults to listAccountHashes() with auto-detected dir
73
+ // now: ms — defaults to Date.now()
74
+ // log: (msg) => void — defaults to console.error (the unbound-hash hint goes here)
75
+ //
76
+ // Returns:
77
+ // { switchTo: { hash, profile } | null, isUnbound: boolean, hash?: string }
78
+ //
79
+ // Mutates seenUnboundHashes (adds the hash when it emits a hint).
80
+ function detectSwitch({
81
+ prevSnapshot,
82
+ lastSwitchAt,
83
+ seenUnboundHashes,
84
+ credsApi,
85
+ listFn,
86
+ now,
87
+ log,
88
+ } = {}) {
89
+ if (!credsApi) credsApi = require('./credentials');
90
+ if (!listFn) listFn = () => listAccountHashes();
91
+ if (typeof now !== 'number') now = Date.now();
92
+ if (typeof log !== 'function') log = console.error;
93
+
94
+ if (now - lastSwitchAt < SWITCH_DEBOUNCE_MS) {
95
+ return { switchTo: null, isUnbound: false };
96
+ }
97
+
98
+ const list = listFn();
99
+ if (list.length === 0) return { switchTo: null, isUnbound: false };
100
+
101
+ const top = list[0];
102
+ const activeProfile = credsApi.getActiveProfileName();
103
+ const activeHash = credsApi.getProfileLarkHash(activeProfile);
104
+ if (top.hash === activeHash) return { switchTo: null, isUnbound: false };
105
+
106
+ // Only act on a true mtime advance — this prevents repeatedly switching
107
+ // when the snapshot baseline shows a stable older delta.
108
+ const prev = prevSnapshot[top.hash] || 0;
109
+ if (top.mtimeMs <= prev) return { switchTo: null, isUnbound: false };
110
+
111
+ const targetProfile = credsApi.findProfileByHash(top.hash);
112
+ if (!targetProfile) {
113
+ const isFresh = (now - top.mtimeMs) < UNBOUND_FRESH_WINDOW_MS;
114
+ if (isFresh && seenUnboundHashes && !seenUnboundHashes.has(top.hash)) {
115
+ seenUnboundHashes.add(top.hash);
116
+ log(
117
+ `[feishu-user-plugin] Lark Desktop active account hash ${top.hash} is not bound to any MCP profile. ` +
118
+ `Run: npx feishu-user-plugin setup --profile <name> --bind-hash ${top.hash}`
119
+ );
120
+ }
121
+ return { switchTo: null, isUnbound: true, hash: top.hash };
122
+ }
123
+
124
+ return { switchTo: { hash: top.hash, profile: targetProfile }, isUnbound: false };
125
+ }
126
+
127
+ module.exports = {
128
+ HASH_RE,
129
+ SWITCH_DEBOUNCE_MS,
130
+ UNBOUND_FRESH_WINDOW_MS,
131
+ getSdkStorageDir,
132
+ listAccountHashes,
133
+ mostRecentHash,
134
+ detectSwitch,
135
+ };