feishu-user-plugin 1.3.6 → 1.3.8

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 (71) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +71 -0
  3. package/README.md +72 -41
  4. package/package.json +10 -3
  5. package/scripts/capture-feishu-protobuf.js +86 -0
  6. package/scripts/check-changelog.js +31 -0
  7. package/scripts/check-docs-sync.js +41 -0
  8. package/scripts/check-tool-count.js +40 -0
  9. package/scripts/check-version.js +40 -0
  10. package/scripts/decode-feishu-protobuf.js +115 -0
  11. package/scripts/smoke.js +224 -0
  12. package/scripts/sync-claude-md.sh +12 -0
  13. package/scripts/sync-server-json.js +71 -0
  14. package/scripts/sync-team-skills.sh +22 -0
  15. package/scripts/test-all-tools.js +158 -0
  16. package/scripts/test-wiki-attach-fallback.js +71 -0
  17. package/scripts/test-ws-events.js +84 -0
  18. package/skills/feishu-user-plugin/SKILL.md +5 -5
  19. package/skills/feishu-user-plugin/references/CLAUDE.md +248 -318
  20. package/skills/feishu-user-plugin/references/table.md +18 -9
  21. package/src/auth/cookie.js +30 -0
  22. package/src/auth/credentials.js +399 -0
  23. package/src/auth/profile-router.js +248 -0
  24. package/src/auth/uat.js +231 -0
  25. package/src/cli.js +45 -13
  26. package/src/clients/official/base.js +188 -0
  27. package/src/clients/official/bitable.js +269 -0
  28. package/src/clients/official/calendar.js +176 -0
  29. package/src/clients/official/contacts.js +54 -0
  30. package/src/clients/official/docs.js +301 -0
  31. package/src/clients/official/drive.js +77 -0
  32. package/src/clients/official/groups.js +68 -0
  33. package/src/clients/official/im.js +414 -0
  34. package/src/clients/official/index.js +30 -0
  35. package/src/clients/official/okr.js +127 -0
  36. package/src/clients/official/tasks.js +142 -0
  37. package/src/clients/official/uploads.js +260 -0
  38. package/src/clients/official/wiki.js +207 -0
  39. package/src/{client.js → clients/user.js} +25 -33
  40. package/src/config.js +13 -8
  41. package/src/events/event-buffer.js +100 -0
  42. package/src/events/index.js +5 -0
  43. package/src/events/ws-server.js +86 -0
  44. package/src/index.js +4 -1977
  45. package/src/logger.js +20 -0
  46. package/src/oauth.js +5 -1
  47. package/src/official.js +5 -1944
  48. package/src/prompts/_registry.js +69 -0
  49. package/src/prompts/index.js +54 -0
  50. package/src/server.js +305 -0
  51. package/src/setup.js +16 -1
  52. package/src/test-all.js +2 -2
  53. package/src/test-comprehensive.js +3 -3
  54. package/src/test-send.js +1 -1
  55. package/src/tools/_registry.js +31 -0
  56. package/src/tools/bitable.js +246 -0
  57. package/src/tools/calendar.js +207 -0
  58. package/src/tools/contacts.js +66 -0
  59. package/src/tools/diagnostics.js +172 -0
  60. package/src/tools/docs.js +158 -0
  61. package/src/tools/drive.js +111 -0
  62. package/src/tools/events.js +64 -0
  63. package/src/tools/groups.js +81 -0
  64. package/src/tools/im-read.js +259 -0
  65. package/src/tools/messaging-bot.js +151 -0
  66. package/src/tools/messaging-user.js +292 -0
  67. package/src/tools/okr.js +159 -0
  68. package/src/tools/profile.js +74 -0
  69. package/src/tools/tasks.js +168 -0
  70. package/src/tools/uploads.js +63 -0
  71. package/src/tools/wiki.js +191 -0
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Decode a captured Feishu protobuf payload against proto/lark.proto.
4
+ //
5
+ // Usage:
6
+ // node scripts/decode-feishu-protobuf.js Packet < /path/to/payload.bin
7
+ // echo "0a..." | node scripts/decode-feishu-protobuf.js Packet --hex
8
+ // node scripts/decode-feishu-protobuf.js Packet --b64 'CgRwYWNr...'
9
+ //
10
+ // Output:
11
+ // - Decoded JSON of the named message
12
+ // - "Unknown fields detected" section listing tag numbers + wire types we
13
+ // don't have in the proto (these are what we need to add).
14
+
15
+ const path = require('path');
16
+ const protobuf = require('protobufjs');
17
+
18
+ async function main() {
19
+ const args = process.argv.slice(2);
20
+ const messageName = args[0];
21
+ if (!messageName) {
22
+ console.error('Usage: node scripts/decode-feishu-protobuf.js <MessageName> [--hex | --b64 <data>]');
23
+ process.exit(2);
24
+ }
25
+ const flagIdx = args.indexOf('--hex');
26
+ const b64Idx = args.indexOf('--b64');
27
+
28
+ let buf;
29
+ if (b64Idx !== -1) {
30
+ buf = Buffer.from(args[b64Idx + 1], 'base64');
31
+ } else if (flagIdx !== -1) {
32
+ const hex = await readStdin();
33
+ buf = Buffer.from(hex.replace(/\s+/g, ''), 'hex');
34
+ } else {
35
+ buf = await readStdinBuffer();
36
+ }
37
+
38
+ const proto = await protobuf.load(path.join(__dirname, '..', 'proto', 'lark.proto'));
39
+ const T = proto.lookupType(messageName);
40
+ const decoded = T.decode(buf);
41
+ const obj = T.toObject(decoded, { defaults: false, bytes: String });
42
+ // Walk the buffer to find unknown field tags.
43
+ const unknown = scanUnknownFields(buf, T);
44
+ console.log(JSON.stringify(obj, _dumpBytes, 2));
45
+ if (unknown.length) {
46
+ console.log('\n--- Unknown fields detected ---');
47
+ for (const u of unknown) console.log(` field ${u.tag} (wire type ${u.wireType}, ${u.length} bytes): ${u.preview}`);
48
+ console.log('\nFor each unknown tag, add the field to proto/lark.proto and re-run to see the decoded shape.');
49
+ } else {
50
+ console.log('\n--- All fields known ---');
51
+ }
52
+ }
53
+
54
+ function _dumpBytes(_, v) {
55
+ if (Buffer.isBuffer(v)) return `<${v.length} bytes 0x${v.slice(0, 16).toString('hex')}${v.length > 16 ? '…' : ''}>`;
56
+ return v;
57
+ }
58
+
59
+ function readStdin() {
60
+ return new Promise((resolve) => {
61
+ const chunks = [];
62
+ process.stdin.on('data', (c) => chunks.push(c));
63
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
64
+ });
65
+ }
66
+
67
+ function readStdinBuffer() {
68
+ return new Promise((resolve) => {
69
+ const chunks = [];
70
+ process.stdin.on('data', (c) => chunks.push(c));
71
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks)));
72
+ });
73
+ }
74
+
75
+ // Walks raw protobuf bytes, decoding tag headers, and reports tags whose
76
+ // number+wireType is not present in the schema. Matches protobufjs's reader
77
+ // state machine but operates entry-point-only (no recursion into subtrees).
78
+ function scanUnknownFields(buf, type) {
79
+ const known = new Set(type.fieldsArray.map(f => f.id));
80
+ const reader = protobuf.Reader.create(buf);
81
+ const out = [];
82
+ while (reader.pos < reader.len) {
83
+ const tagInt = reader.uint32();
84
+ const tag = tagInt >>> 3;
85
+ const wireType = tagInt & 7;
86
+ const start = reader.pos;
87
+ let value;
88
+ try {
89
+ value = readValueByWireType(reader, wireType);
90
+ } catch (e) {
91
+ out.push({ tag, wireType, length: 0, preview: `decode error: ${e.message}` });
92
+ break;
93
+ }
94
+ if (!known.has(tag)) {
95
+ const len = reader.pos - start;
96
+ let preview;
97
+ if (Buffer.isBuffer(value)) preview = `0x${value.slice(0, 24).toString('hex')}${value.length > 24 ? '…' : ''}`;
98
+ else preview = String(value).slice(0, 80);
99
+ out.push({ tag, wireType, length: len, preview });
100
+ }
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function readValueByWireType(reader, wireType) {
106
+ switch (wireType) {
107
+ case 0: return reader.uint64(); // varint
108
+ case 1: return reader.fixed64(); // 64-bit
109
+ case 2: return Buffer.from(reader.bytes()); // length-delimited
110
+ case 5: return reader.fixed32(); // 32-bit
111
+ default: reader.skipType(wireType); return null;
112
+ }
113
+ }
114
+
115
+ main().catch(e => { console.error('Error:', e.message); process.exit(1); });
@@ -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,71 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Regenerates server.json so it never drifts from package.json + src/server.js.
4
+ // Reads:
5
+ // - package.json: version, description (truncated to ~220 chars for display)
6
+ // - src/server.js TOOLS: tool list (name + description from inputSchema.description)
7
+ // Preserves:
8
+ // - display_name, icon, repository, license, categories, tags,
9
+ // installations, environment_variables (these don't drift, edited by hand)
10
+ // CI gate (validate.yml) re-runs this and diffs — drift = build fail.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const ROOT = path.join(__dirname, '..');
16
+ const SERVER_JSON = path.join(ROOT, 'server.json');
17
+ const PKG = require(path.join(ROOT, 'package.json'));
18
+ const { TOOLS } = require(path.join(ROOT, 'src', 'server'));
19
+
20
+ function deriveToolEntry(t) {
21
+ // Strip the "[Plugin]"/"[Cookie]"/etc category prefix from descriptions for compactness.
22
+ const desc = (t.description || '').replace(/^\[[^\]]+\]\s*/, '');
23
+ return { name: t.name, description: desc.split('\n')[0].slice(0, 200) };
24
+ }
25
+
26
+ const existing = JSON.parse(fs.readFileSync(SERVER_JSON, 'utf8'));
27
+
28
+ // Truncate package.json description for the marketplace display field.
29
+ // The package.json one is intentionally long for npm searches; server.json
30
+ // trims it for cleaner cards.
31
+ const shortDesc = PKG.description.replace(/\s+/g, ' ').slice(0, 220);
32
+
33
+ const next = {
34
+ name: PKG.name,
35
+ display_name: existing.display_name || 'Feishu User Plugin for Claude Code',
36
+ description: shortDesc,
37
+ version: PKG.version,
38
+ icon: existing.icon || 'https://www.feishu.cn/favicon.ico',
39
+ repository: existing.repository || { type: 'git', url: PKG.repository?.url || '' },
40
+ license: existing.license || PKG.license || 'MIT',
41
+ categories: existing.categories || ['communication', 'messaging', 'productivity'],
42
+ tags: existing.tags || ['feishu', 'lark', 'im', 'messaging', 'docs', 'bitable', 'wiki', 'protobuf', 'plugin', 'claude-code'],
43
+ tools: TOOLS.map(deriveToolEntry),
44
+ installations: existing.installations || {
45
+ 'claude-code': { type: 'stdio', command: 'npx', args: ['-y', 'feishu-user-plugin'] },
46
+ },
47
+ environment_variables: existing.environment_variables || [
48
+ { name: 'LARK_COOKIE', description: 'Feishu web login cookie string (required for user identity messaging)', required: true },
49
+ { name: 'LARK_APP_ID', description: 'Feishu Open Platform App ID (required for official API)', required: true },
50
+ { name: 'LARK_APP_SECRET', description: 'Feishu Open Platform App Secret (required for official API)', required: true },
51
+ { name: 'LARK_USER_ACCESS_TOKEN', description: 'OAuth user_access_token for P2P chat reading (run: npx feishu-user-plugin oauth)', required: true },
52
+ { name: 'LARK_USER_REFRESH_TOKEN', description: 'OAuth refresh_token for automatic UAT renewal (obtained via OAuth flow)', required: true },
53
+ ],
54
+ };
55
+
56
+ const cmd = process.argv[2] || 'write';
57
+ const nextStr = JSON.stringify(next, null, 2) + '\n';
58
+
59
+ if (cmd === 'check') {
60
+ const cur = fs.readFileSync(SERVER_JSON, 'utf8');
61
+ if (cur !== nextStr) {
62
+ console.error('ERROR: server.json is out of sync with package.json + src/server.js TOOLS.');
63
+ console.error('Fix: node scripts/sync-server-json.js');
64
+ process.exit(1);
65
+ }
66
+ console.log(`OK: server.json in sync (${TOOLS.length} tools, v${PKG.version})`);
67
+ process.exit(0);
68
+ }
69
+
70
+ fs.writeFileSync(SERVER_JSON, nextStr);
71
+ console.log(`Regenerated server.json (${TOOLS.length} tools, v${PKG.version})`);
@@ -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"
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // scripts/test-all-tools.js — semi-automated tool regression.
3
+ //
4
+ // Spawns the MCP server (src/index.js) as a stdio child, sends `initialize` +
5
+ // `tools/list`, then calls a curated set of READ tools to verify each domain
6
+ // is wired up. Writes a per-tool pass/fail summary to stdout.
7
+ //
8
+ // Read-only by design: this script does NOT create / modify / delete any
9
+ // Feishu resources. For write-tool regression, see docs/TESTING-METHODOLOGY.md
10
+ // "Live regression checklist".
11
+ //
12
+ // Usage:
13
+ // node scripts/test-all-tools.js
14
+ // node scripts/test-all-tools.js --user-id <open_id> # to also test list_user_okrs
15
+ // node scripts/test-all-tools.js --json # machine-readable output
16
+ //
17
+ // Exit code: 0 if all calls succeed, 1 if any failed.
18
+
19
+ const { spawn } = require('child_process');
20
+ const path = require('path');
21
+ const { readCredentials } = require('../src/config');
22
+
23
+ const SERVER_PATH = path.join(__dirname, '..', 'src', 'index.js');
24
+
25
+ function jsonrpc(id, method, params) {
26
+ return JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
27
+ }
28
+
29
+ function waitFor(fn, timeoutMs) {
30
+ return new Promise((resolve, reject) => {
31
+ const start = Date.now();
32
+ const tick = () => {
33
+ if (fn()) return resolve();
34
+ if (Date.now() - start > timeoutMs) return reject(new Error(`timeout after ${timeoutMs}ms`));
35
+ setTimeout(tick, 50);
36
+ };
37
+ tick();
38
+ });
39
+ }
40
+
41
+ async function runRegression() {
42
+ const cliArgs = process.argv.slice(2);
43
+ const wantJson = cliArgs.includes('--json');
44
+ const userIdIdx = cliArgs.indexOf('--user-id');
45
+ const userId = userIdIdx >= 0 ? cliArgs[userIdIdx + 1] : null;
46
+
47
+ const creds = readCredentials() || {};
48
+ const childEnv = { ...process.env };
49
+ for (const k of ['LARK_COOKIE', 'LARK_APP_ID', 'LARK_APP_SECRET', 'LARK_USER_ACCESS_TOKEN', 'LARK_USER_REFRESH_TOKEN', 'LARK_PROFILES_JSON']) {
50
+ if (creds[k] && !childEnv[k]) childEnv[k] = creds[k];
51
+ }
52
+
53
+ const child = spawn('node', [SERVER_PATH], {
54
+ stdio: ['pipe', 'pipe', 'pipe'],
55
+ env: childEnv,
56
+ });
57
+ let buf = '';
58
+ const responses = new Map();
59
+ child.stdout.on('data', (d) => {
60
+ buf += d.toString();
61
+ const lines = buf.split('\n');
62
+ buf = lines.pop();
63
+ for (const line of lines) {
64
+ if (!line.trim()) continue;
65
+ try {
66
+ const msg = JSON.parse(line);
67
+ if (msg.id != null) responses.set(msg.id, msg);
68
+ } catch {}
69
+ }
70
+ });
71
+ child.stderr.on('data', () => {});
72
+
73
+ let nextId = 1;
74
+ function call(method, params, timeoutMs = 15000) {
75
+ const id = nextId++;
76
+ child.stdin.write(jsonrpc(id, method, params));
77
+ return waitFor(() => responses.has(id), timeoutMs).then(() => responses.get(id));
78
+ }
79
+
80
+ const init = await call('initialize', {
81
+ protocolVersion: '2024-11-05',
82
+ capabilities: {},
83
+ clientInfo: { name: 'test-all-tools', version: '0' },
84
+ });
85
+ if (init.error) throw new Error(`initialize failed: ${JSON.stringify(init.error)}`);
86
+
87
+ const toolsResp = await call('tools/list', {});
88
+ const allTools = (toolsResp.result?.tools || []).map((t) => t.name).sort();
89
+
90
+ // Curated read-only suite. Each entry: [name, args, optional notes].
91
+ const SUITE = [
92
+ ['get_login_status', {}],
93
+ ['list_profiles', {}],
94
+ ['list_chats', { page_size: 5 }],
95
+ ['search_contacts', { query: 'feishu' }],
96
+ ['list_calendars', { page_size: 50 }],
97
+ ['list_okr_periods', {}],
98
+ ['list_wiki_spaces', {}],
99
+ ['search_docs', { query: 'README' }],
100
+ ['list_files', {}],
101
+ ];
102
+ if (userId) SUITE.push(['list_user_okrs', { user_id: userId, limit: 1 }]);
103
+ // list_tasks (v1.3.7) is safe but only meaningful if Tasks scope is granted.
104
+ if (allTools.includes('list_tasks')) SUITE.push(['list_tasks', { page_size: 1 }]);
105
+
106
+ const results = [];
107
+ for (const [name, args] of SUITE) {
108
+ if (!allTools.includes(name)) {
109
+ results.push({ tool: name, ok: false, skipped: true, reason: 'tool not registered' });
110
+ continue;
111
+ }
112
+ const t0 = Date.now();
113
+ try {
114
+ const r = await call('tools/call', { name, arguments: args }, 30000);
115
+ const ms = Date.now() - t0;
116
+ if (r.error) {
117
+ results.push({ tool: name, ok: false, ms, error: r.error.message });
118
+ } else {
119
+ const isError = r.result?.isError === true;
120
+ results.push({ tool: name, ok: !isError, ms, summary: summarize(r.result) });
121
+ }
122
+ } catch (e) {
123
+ results.push({ tool: name, ok: false, ms: Date.now() - t0, error: e.message });
124
+ }
125
+ }
126
+
127
+ child.kill('SIGTERM');
128
+
129
+ if (wantJson) {
130
+ process.stdout.write(JSON.stringify({ allTools: allTools.length, results }, null, 2) + '\n');
131
+ } else {
132
+ const okCount = results.filter((r) => r.ok).length;
133
+ const failCount = results.filter((r) => !r.ok && !r.skipped).length;
134
+ const skipCount = results.filter((r) => r.skipped).length;
135
+ console.log(`Tool registry size: ${allTools.length}`);
136
+ console.log(`Suite: ${okCount} ok, ${failCount} fail, ${skipCount} skipped (out of ${results.length} planned)\n`);
137
+ for (const r of results) {
138
+ const status = r.skipped ? 'SKIP' : (r.ok ? ' OK ' : 'FAIL');
139
+ const ms = r.ms !== undefined ? ` ${r.ms}ms` : '';
140
+ const tail = r.error ? ` — ${r.error}` : (r.reason ? ` — ${r.reason}` : (r.summary ? ` — ${r.summary}` : ''));
141
+ console.log(` [${status}]${ms.padStart(8)} ${r.tool}${tail}`);
142
+ }
143
+ if (failCount > 0) process.exit(1);
144
+ }
145
+ }
146
+
147
+ function summarize(result) {
148
+ const txt = result?.content?.[0]?.text;
149
+ if (!txt) return '';
150
+ // Crop multi-line summaries to the first line + length.
151
+ const firstLine = txt.split('\n', 1)[0];
152
+ return firstLine.length > 80 ? firstLine.slice(0, 77) + '…' : firstLine;
153
+ }
154
+
155
+ runRegression().catch((e) => {
156
+ console.error('Regression failed:', e.message);
157
+ process.exit(2);
158
+ });
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Simulates wiki:wiki scope insufficient and verifies the upload fallback path
4
+ // surfaces a clear error rather than burying the wiki failure under a generic
5
+ // "uploaded to drive root, attach failed" silent miss.
6
+ //
7
+ // Approach: monkey-patch attachToWiki on the LarkOfficialClient prototype to
8
+ // throw a 91403 (the production wiki "no permission" code), then call
9
+ // upload_drive_file with wiki_space_id and check the response.
10
+
11
+ const { readCredentials } = require('../src/auth/credentials');
12
+ const creds = readCredentials();
13
+ if (!creds.LARK_APP_ID || !creds.LARK_APP_SECRET || !creds.LARK_USER_ACCESS_TOKEN) {
14
+ console.error('Skipped: needs LARK_APP_ID / LARK_APP_SECRET / UAT (skip on CI).');
15
+ process.exit(77); // POSIX skip code
16
+ }
17
+
18
+ (async () => {
19
+ const { LarkOfficialClient } = require('../src/clients/official');
20
+ const client = new LarkOfficialClient(creds.LARK_APP_ID, creds.LARK_APP_SECRET);
21
+ client.loadUAT();
22
+
23
+ const original = client.attachToWiki?.bind(client);
24
+ if (!original) { console.error('attachToWiki not present — wiki domain may not be loaded.'); process.exit(2); }
25
+
26
+ client.attachToWiki = async function(...args) {
27
+ const err = new Error('attachToWiki failed (HTTP 403, code=91403): wiki scope not granted');
28
+ err.code = 91403;
29
+ throw err;
30
+ };
31
+
32
+ const tmpFile = '/tmp/feishu-test-attach-fallback.txt';
33
+ require('fs').writeFileSync(tmpFile, 'attach-fallback-test ' + Date.now());
34
+
35
+ // Need a real folder_token: this script is opportunistic — pass via env
36
+ // FEISHU_TEST_FOLDER_TOKEN. Skip cleanly otherwise.
37
+ const folderToken = process.env.FEISHU_TEST_FOLDER_TOKEN;
38
+ if (!folderToken) {
39
+ console.error('Skipped: set FEISHU_TEST_FOLDER_TOKEN env (a real Drive folder token you can write to) to exercise this fallback.');
40
+ require('fs').unlinkSync(tmpFile);
41
+ process.exit(77);
42
+ }
43
+
44
+ try {
45
+ const res = await client.uploadDriveFile({
46
+ file_path: tmpFile,
47
+ file_name: 'attach-fallback-test.txt',
48
+ folder_token: folderToken,
49
+ parent_type: 'explorer',
50
+ wiki_space_id: '0000000000000000', // bogus; attachToWiki monkey-patch throws 91403 either way
51
+ });
52
+ console.log('Result:', JSON.stringify(res, null, 2));
53
+ if (res?._wikiAttachWarning || res?.error || /91403|wiki/i.test(JSON.stringify(res))) {
54
+ console.log('PASS: upload surfaces the wiki attach failure');
55
+ process.exit(0);
56
+ }
57
+ console.log('FAIL: upload did not surface the wiki attach failure');
58
+ process.exit(1);
59
+ } catch (e) {
60
+ // The monkey-patched attachToWiki throws — uploadDriveFile may rethrow.
61
+ // That's also acceptable as long as the message preserves the wiki failure.
62
+ if (/91403|wiki/i.test(e.message)) {
63
+ console.log('PASS: upload surfaces the wiki attach failure via thrown error:', e.message);
64
+ process.exit(0);
65
+ }
66
+ console.error('FAIL: upload threw, but message does not mention wiki/91403:', e.message);
67
+ process.exit(1);
68
+ } finally {
69
+ try { require('fs').unlinkSync(tmpFile); } catch {}
70
+ }
71
+ })().catch((e) => { console.error('Error:', e.message); process.exit(1); });