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/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +27 -0
- package/.mcpb/manifest.json +91 -0
- package/CHANGELOG.md +68 -0
- package/PRIVACY.md +105 -0
- package/README.en.md +610 -0
- package/README.md +309 -529
- package/package.json +7 -2
- package/scripts/build-mcpb.js +119 -0
- package/scripts/check-mcp-registry-version.js +43 -0
- package/scripts/check-mcpb-version.js +33 -0
- package/scripts/check-version.js +5 -0
- package/scripts/sync-team-skills.sh +72 -57
- package/skills/feishu-user-plugin/SKILL.md +1 -1
- package/skills/feishu-user-plugin/references/CLAUDE.md +1 -0
- package/src/auth/credentials.js +49 -0
- package/src/auth/lark-desktop.js +135 -0
- package/src/server.js +42 -0
- package/src/setup.js +44 -0
- package/src/test-lark-desktop.js +300 -0
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feishu-user-plugin",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
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}`);
|
package/scripts/check-version.js
CHANGED
|
@@ -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
|
-
#
|
|
5
|
-
#
|
|
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
|
-
#
|
|
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
|
-
# -
|
|
22
|
-
# -
|
|
23
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
65
|
-
ROOT_README="$
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
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.
|
|
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.
|
package/src/auth/credentials.js
CHANGED
|
@@ -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
|
+
};
|