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,248 @@
1
+ // src/auth/profile-router.js — multi-profile auto-switch middleware (v1.3.8).
2
+ //
3
+ // Wraps the MCP CallToolRequestSchema dispatcher. For READ-ONLY tools that
4
+ // fail with a permission-denied / forbidden error, we transparently retry the
5
+ // call with another profile (if more than one is configured) and remember the
6
+ // winner via credentials.profileHints.
7
+ //
8
+ // Intentionally DOES NOT touch write paths. A user wanting auto-switch on a
9
+ // write must opt in explicitly: pass `via_profile: "auto"` in the tool args.
10
+ //
11
+ // What this owns:
12
+ // - READ_ONLY_TOOLS whitelist (name prefix + manage_bitable_* read-action override)
13
+ // - SWITCH_TRIGGERING_PATTERNS (error code / message regex set)
14
+ // - extractResourceKey(name, args) — resourceKey for hinting
15
+ // - profileOrder(name, args, hints, ctx) — ordered list of profiles to try
16
+ // - withProfileRouting(ctx, name, args, callHandler) — the main wrapper
17
+ //
18
+ // What it does NOT own:
19
+ // - The actual setActiveProfile / cache-invalidate (delegated to ctx).
20
+ // - Persistence of hints (delegates to auth/credentials).
21
+
22
+ const credentials = require('./credentials');
23
+
24
+ // --- Whitelist ---
25
+
26
+ const READ_ONLY_PREFIXES = ['read_', 'list_', 'get_', 'search_', 'download_'];
27
+
28
+ // Explicit allowlist for manage_*/check_* tools whose action arg is read-only.
29
+ // Each entry: tool name → predicate(args) returns true if THIS call is read-only.
30
+ const READ_ONLY_OVERRIDES = {
31
+ manage_bitable_app: (a) => a?.action === 'get_meta',
32
+ manage_bitable_table: (a) => a?.action === 'list',
33
+ manage_bitable_field: (a) => a?.action === 'list',
34
+ manage_bitable_view: (a) => a?.action === 'list',
35
+ manage_bitable_record: (a) => a?.action === 'search',
36
+ };
37
+
38
+ function isReadOnlyCall(name, args) {
39
+ if (READ_ONLY_PREFIXES.some(p => name.startsWith(p))) return true;
40
+ const override = READ_ONLY_OVERRIDES[name];
41
+ if (override && override(args)) return true;
42
+ return false;
43
+ }
44
+
45
+ // --- Error classification ---
46
+
47
+ const SWITCH_CODES = new Set([
48
+ 91403, // wiki / docx no permission
49
+ 1254301, // bitable no permission
50
+ 1254000, // bitable access denied
51
+ 99991672, // OAuth scope not granted (user-side)
52
+ 403, // generic HTTP forbidden
53
+ ]);
54
+
55
+ const SWITCH_PATTERNS = [
56
+ /access[_ ]?denied/i,
57
+ /permission[_ ]?denied/i,
58
+ /docx_no_permission/i,
59
+ /no permission/i,
60
+ /not authorized/i,
61
+ /forbidden/i,
62
+ /HTTP 403/i,
63
+ ];
64
+
65
+ function shouldSwitchOnError(err) {
66
+ const msg = (err?.message || String(err) || '').toLowerCase();
67
+ // Code-form: "(91403)", "code=1254301", "(HTTP 403, ...)"
68
+ const codeMatch = msg.match(/\b(?:code[=(]|http\s+)(\d+)/i) || msg.match(/\((\d{3,7})(?:,|\))/);
69
+ if (codeMatch) {
70
+ const code = parseInt(codeMatch[1], 10);
71
+ if (SWITCH_CODES.has(code)) return { yes: true, reason: `code=${code}` };
72
+ }
73
+ for (const re of SWITCH_PATTERNS) {
74
+ if (re.test(msg)) return { yes: true, reason: re.source };
75
+ }
76
+ return { yes: false, reason: null };
77
+ }
78
+
79
+ // --- Resource key extraction ---
80
+
81
+ // Mapping: arg field name → resourceKey kind. Order matters — first match wins.
82
+ const RESOURCE_FIELDS = [
83
+ { field: 'document_id', kind: 'doc' },
84
+ { field: 'doc_token', kind: 'doc' },
85
+ { field: 'app_token', kind: 'app' },
86
+ { field: 'space_id', kind: 'wiki' },
87
+ { field: 'node_token', kind: 'wiki' },
88
+ { field: 'wiki_token', kind: 'wiki' },
89
+ { field: 'chat_id', kind: 'chat' },
90
+ { field: 'user_id', kind: 'user' },
91
+ { field: 'open_id', kind: 'user' },
92
+ { field: 'file_token', kind: 'file' },
93
+ { field: 'image_token', kind: 'file' },
94
+ { field: 'message_id', kind: 'msg' },
95
+ ];
96
+
97
+ function extractResourceKey(args) {
98
+ if (!args || typeof args !== 'object') return null;
99
+ for (const { field, kind } of RESOURCE_FIELDS) {
100
+ const v = args[field];
101
+ if (typeof v === 'string' && v) return `${kind}:${v}`;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // --- Profile order ---
107
+
108
+ function profileOrder(name, args, ctx) {
109
+ const all = ctx.listProfiles();
110
+ if (all.length <= 1) return all;
111
+ const active = ctx.getActiveProfile();
112
+ const resourceKey = extractResourceKey(args);
113
+ const hints = credentials.getProfileHints();
114
+ const hinted = resourceKey ? hints[resourceKey] : null;
115
+
116
+ // Order: [hinted (if !active && exists), active, ...rest]
117
+ // We always try active first when there's no hint or the hint matches active —
118
+ // this preserves "your current profile is the default attempt" UX. When the
119
+ // hint differs from active, we skip active for the FIRST attempt because the
120
+ // hint is empirically known to work (or used to).
121
+ const order = [];
122
+ const seen = new Set();
123
+ const push = (p) => { if (p && all.includes(p) && !seen.has(p)) { order.push(p); seen.add(p); } };
124
+
125
+ if (hinted && hinted !== active) push(hinted);
126
+ push(active);
127
+ for (const p of [...all].sort()) push(p);
128
+ return order;
129
+ }
130
+
131
+ // --- Main wrapper ---
132
+
133
+ async function withProfileRouting(ctx, name, args, callHandler) {
134
+ // Bail out early when there's nothing to switch to or this is a write.
135
+ const all = ctx.listProfiles();
136
+ const isMultiProfile = all.length > 1;
137
+
138
+ // Explicit override: via_profile arg pins (or unlocks auto-switch on writes).
139
+ let viaProfileArg = null;
140
+ if (args && typeof args === 'object' && typeof args.via_profile === 'string') {
141
+ viaProfileArg = args.via_profile;
142
+ }
143
+
144
+ const allowAuto = isMultiProfile && (
145
+ isReadOnlyCall(name, args) || viaProfileArg === 'auto'
146
+ );
147
+
148
+ if (!allowAuto) {
149
+ // Single-profile or write: just call once.
150
+ if (viaProfileArg && viaProfileArg !== 'auto') {
151
+ // Explicit pin
152
+ if (!all.includes(viaProfileArg)) {
153
+ return { content: [{ type: 'text', text: `via_profile: "${viaProfileArg}" not found. Available: ${all.join(', ')}.` }], isError: true };
154
+ }
155
+ const wasActive = ctx.getActiveProfile();
156
+ if (viaProfileArg !== wasActive) ctx.setActiveProfile(viaProfileArg);
157
+ try {
158
+ return await callHandler();
159
+ } finally {
160
+ // Restore active for subsequent calls in same session — explicit pin
161
+ // is per-call, not a session toggle.
162
+ if (viaProfileArg !== wasActive) ctx.setActiveProfile(wasActive);
163
+ }
164
+ }
165
+ return callHandler();
166
+ }
167
+
168
+ // Auto-switch loop.
169
+ const order = profileOrder(name, args, ctx);
170
+ const wasActive = ctx.getActiveProfile();
171
+ const failures = [];
172
+ let switchedFrom = null;
173
+ let switchedReason = null;
174
+
175
+ for (let i = 0; i < order.length; i++) {
176
+ const profile = order[i];
177
+ if (i > 0) {
178
+ // Switching away from previous profile.
179
+ if (!switchedFrom) switchedFrom = wasActive;
180
+ ctx.setActiveProfile(profile);
181
+ console.error(`[feishu-user-plugin] profile-router: ${order[i - 1]} → ${profile} on ${name} (${switchedReason || 'first attempt failed'})`);
182
+ } else if (profile !== wasActive) {
183
+ // Hinted profile differs from active — switch on first attempt too.
184
+ switchedFrom = wasActive;
185
+ ctx.setActiveProfile(profile);
186
+ console.error(`[feishu-user-plugin] profile-router: ${wasActive} → ${profile} on ${name} (hint match)`);
187
+ }
188
+
189
+ try {
190
+ const res = await callHandler();
191
+ // Detect handler-level isError responses (not all errors throw).
192
+ if (res?.isError && res.content?.[0]?.text) {
193
+ const decision = shouldSwitchOnError({ message: res.content[0].text });
194
+ if (decision.yes && i + 1 < order.length) {
195
+ failures.push({ profile, error: res.content[0].text.slice(0, 200) });
196
+ switchedReason = decision.reason;
197
+ continue;
198
+ }
199
+ }
200
+ // Success — cache hint if we switched, then return.
201
+ if (switchedFrom && profile !== switchedFrom) {
202
+ const rk = extractResourceKey(args);
203
+ if (rk) {
204
+ try { credentials.setProfileHint(rk, profile); }
205
+ catch (e) { console.error(`[feishu-user-plugin] profile-router: hint persist failed (${e.message})`); }
206
+ }
207
+ // Annotate response.
208
+ if (res?.content?.[0]?.type === 'text' && typeof res.content[0].text === 'string') {
209
+ res.content[0].text = `[autoSwitched: ${switchedFrom} → ${profile} on ${rk || 'no-key'}]\n` + res.content[0].text;
210
+ }
211
+ }
212
+ // Restore active to whatever the user had — auto-switch is per-call.
213
+ if (switchedFrom) ctx.setActiveProfile(wasActive);
214
+ return res;
215
+ } catch (err) {
216
+ const decision = shouldSwitchOnError(err);
217
+ failures.push({ profile, error: err.message });
218
+ if (!decision.yes || i + 1 >= order.length) {
219
+ if (switchedFrom) ctx.setActiveProfile(wasActive);
220
+ // Compose a comprehensive error if all profiles failed.
221
+ if (failures.length > 1) {
222
+ const lines = failures.map(f => ` ${f.profile}: ${f.error}`).join('\n');
223
+ throw new Error(`All ${failures.length} profiles failed on ${name}:\n${lines}`);
224
+ }
225
+ throw err;
226
+ }
227
+ switchedReason = decision.reason;
228
+ // Loop to next profile.
229
+ }
230
+ }
231
+
232
+ if (switchedFrom) ctx.setActiveProfile(wasActive);
233
+ // Should not reach: loop returns on success or throws.
234
+ throw new Error(`profile-router: exhausted ${order.length} profiles on ${name}`);
235
+ }
236
+
237
+ module.exports = {
238
+ withProfileRouting,
239
+ isReadOnlyCall,
240
+ shouldSwitchOnError,
241
+ extractResourceKey,
242
+ profileOrder,
243
+ // Constants exposed for tests.
244
+ READ_ONLY_PREFIXES,
245
+ READ_ONLY_OVERRIDES,
246
+ SWITCH_CODES,
247
+ SWITCH_PATTERNS,
248
+ };
@@ -0,0 +1,231 @@
1
+ // src/auth/uat.js — UAT lifecycle: refresh, cross-process file lock, persist.
2
+ //
3
+ // State lives on the LarkOfficialClient instance (this._uat, this._uatRefresh,
4
+ // this._uatExpires). These functions take `client` as first arg and mutate its
5
+ // fields. Lifted out of clients/official/base.js for clarity; called only from
6
+ // there.
7
+ //
8
+ // What this owns:
9
+ // - decodeTokenExpiry(token) — JWT exp parsing
10
+ // - getValidUAT(client) — returns current UAT, refreshes if expiring
11
+ // - refreshUAT(client) — full refresh dance with file lock + persist
12
+ // - withUAT(client, fn) — wrapper that retries fn once on auth-error codes
13
+ // - uatREST(client, method, path, opts) — generic UAT REST helper
14
+ // - asUserOrApp(client, opts) — UAT-first, bot-fallback wrapper
15
+ // - persistUAT(client) — writes through auth/credentials
16
+ // - adoptPersistedUATIfNewer(client) — peer-rotation adoption
17
+ // - acquireRefreshLock / releaseRefreshLock — cross-process advisory lock
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const { fetchWithTimeout } = require('../utils');
23
+
24
+ function decodeTokenExpiry(token) {
25
+ try {
26
+ const payload = token?.split('.')?.[1];
27
+ if (!payload) return 0;
28
+ const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
29
+ return typeof data.exp === 'number' ? data.exp : 0;
30
+ } catch (_) {
31
+ return 0;
32
+ }
33
+ }
34
+
35
+ async function getValidUAT(client) {
36
+ if (!client._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
37
+ const now = Math.floor(Date.now() / 1000);
38
+ if (!client._uatExpires) client._uatExpires = decodeTokenExpiry(client._uat);
39
+ if (client._uatExpires > 0 && client._uatExpires <= now + 300) {
40
+ return refreshUAT(client);
41
+ }
42
+ return client._uat;
43
+ }
44
+
45
+ function adoptPersistedUATIfNewer(client) {
46
+ try {
47
+ const { readCredentials } = require('./credentials');
48
+ const creds = readCredentials();
49
+ const token = creds.LARK_USER_ACCESS_TOKEN;
50
+ const refresh = creds.LARK_USER_REFRESH_TOKEN;
51
+ if (!token && !refresh) return false;
52
+ const expires = parseInt(creds.LARK_UAT_EXPIRES || '0') || decodeTokenExpiry(token);
53
+ const changed = (token && token !== client._uat)
54
+ || (refresh && refresh !== client._uatRefresh)
55
+ || (expires && expires !== client._uatExpires);
56
+ if (!changed) return false;
57
+ if (token) client._uat = token;
58
+ if (refresh) client._uatRefresh = refresh;
59
+ client._uatExpires = expires || 0;
60
+ console.error('[feishu-user-plugin] UAT adopted latest persisted token before refresh');
61
+ return true;
62
+ } catch (e) {
63
+ console.error(`[feishu-user-plugin] UAT persisted-token check failed: ${e.message}`);
64
+ return false;
65
+ }
66
+ }
67
+
68
+ function uatLockPath() {
69
+ return path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock');
70
+ }
71
+
72
+ async function acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
73
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
74
+ const start = Date.now();
75
+ while (Date.now() - start < timeoutMs) {
76
+ try {
77
+ const fd = fs.openSync(lockPath, 'wx');
78
+ fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
79
+ fs.closeSync(fd);
80
+ return true;
81
+ } catch (e) {
82
+ if (e.code !== 'EEXIST') throw e;
83
+ try {
84
+ const stat = fs.statSync(lockPath);
85
+ if (Date.now() - stat.mtimeMs > staleMs) {
86
+ try { fs.unlinkSync(lockPath); } catch (_) {}
87
+ continue;
88
+ }
89
+ } catch (_) { /* lock vanished — retry */ }
90
+ await new Promise(r => setTimeout(r, pollMs));
91
+ }
92
+ }
93
+ return false;
94
+ }
95
+
96
+ function releaseRefreshLock(lockPath) {
97
+ try { fs.unlinkSync(lockPath); } catch (_) {}
98
+ }
99
+
100
+ async function refreshUAT(client) {
101
+ const lockPath = uatLockPath();
102
+ const acquired = await acquireRefreshLock(lockPath);
103
+ if (!acquired) {
104
+ console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion');
105
+ }
106
+ try {
107
+ const now = Math.floor(Date.now() / 1000);
108
+ if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
109
+ return client._uat;
110
+ }
111
+ if (!client._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
112
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
113
+ method: 'POST',
114
+ headers: { 'content-type': 'application/json' },
115
+ body: JSON.stringify({
116
+ grant_type: 'refresh_token',
117
+ client_id: client.appId,
118
+ client_secret: client.appSecret,
119
+ refresh_token: client._uatRefresh,
120
+ }),
121
+ });
122
+ const data = await res.json();
123
+ const tokenData = data.access_token ? data : data.data;
124
+ if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
125
+ client._uat = tokenData.access_token;
126
+ client._uatRefresh = tokenData.refresh_token || client._uatRefresh;
127
+ const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
128
+ client._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
129
+ persistUAT(client);
130
+ console.error('[feishu-user-plugin] UAT refreshed successfully');
131
+ return client._uat;
132
+ } finally {
133
+ if (acquired) releaseRefreshLock(lockPath);
134
+ }
135
+ }
136
+
137
+ function persistUAT(client) {
138
+ const { persistToConfig } = require('./credentials');
139
+ persistToConfig({
140
+ LARK_USER_ACCESS_TOKEN: client._uat,
141
+ LARK_USER_REFRESH_TOKEN: client._uatRefresh,
142
+ LARK_UAT_EXPIRES: String(client._uatExpires),
143
+ });
144
+ }
145
+
146
+ async function withUAT(client, fn) {
147
+ let uat = await getValidUAT(client);
148
+ const data = await fn(uat);
149
+ if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
150
+ if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
151
+ return data;
152
+ }
153
+ uat = await refreshUAT(client);
154
+ return fn(uat);
155
+ }
156
+ return data;
157
+ }
158
+
159
+ async function uatREST(client, method, urlPath, { body, query } = {}) {
160
+ let qs = '';
161
+ if (query) {
162
+ const sp = new URLSearchParams();
163
+ for (const [k, v] of Object.entries(query)) {
164
+ if (v === undefined || v === null) continue;
165
+ if (Array.isArray(v)) { for (const item of v) sp.append(k, String(item)); }
166
+ else sp.append(k, String(v));
167
+ }
168
+ const str = sp.toString();
169
+ if (str) qs = '?' + str;
170
+ }
171
+ const url = 'https://open.feishu.cn' + urlPath + qs;
172
+ return withUAT(client, async (uat) => {
173
+ const headers = { 'Authorization': `Bearer ${uat}` };
174
+ const init = { method, headers };
175
+ if (body !== undefined) {
176
+ headers['content-type'] = 'application/json';
177
+ init.body = JSON.stringify(body);
178
+ }
179
+ const res = await fetchWithTimeout(url, init);
180
+ return res.json();
181
+ });
182
+ }
183
+
184
+ async function asUserOrApp(client, { uatPath, method = 'GET', body, query, sdkFn, label }) {
185
+ let uatSummary = null;
186
+ if (client.hasUAT) {
187
+ try {
188
+ const data = await uatREST(client, method, uatPath, { body, query });
189
+ if (data.code === 0) {
190
+ data._viaUser = true;
191
+ return data;
192
+ }
193
+ uatSummary = `as user: code=${data.code} msg=${data.msg}`;
194
+ console.error(`[feishu-user-plugin] ${label} ${uatSummary}, retrying as app`);
195
+ } catch (err) {
196
+ uatSummary = `as user: ${err.message}`;
197
+ console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
198
+ }
199
+ }
200
+ try {
201
+ const appData = await client._safeSDKCall(sdkFn, label);
202
+ if (appData && typeof appData === 'object') {
203
+ appData._viaUser = false;
204
+ if (uatSummary) {
205
+ appData._fallbackWarning = `⚠️ UAT 不可用 (${uatSummary}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
206
+ } else if (!client.hasUAT) {
207
+ appData._fallbackWarning = `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
208
+ }
209
+ }
210
+ return appData;
211
+ } catch (appErr) {
212
+ if (uatSummary) {
213
+ const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${appErr.message}`);
214
+ err.uatSummary = uatSummary;
215
+ err.appError = appErr;
216
+ throw err;
217
+ }
218
+ throw appErr;
219
+ }
220
+ }
221
+
222
+ module.exports = {
223
+ decodeTokenExpiry,
224
+ getValidUAT,
225
+ refreshUAT,
226
+ withUAT,
227
+ uatREST,
228
+ asUserOrApp,
229
+ persistUAT,
230
+ adoptPersistedUATIfNewer,
231
+ };
package/src/cli.js CHANGED
@@ -25,6 +25,17 @@ switch (cmd) {
25
25
  case 'keepalive':
26
26
  keepalive();
27
27
  break;
28
+ case 'list-prompts': {
29
+ const { listPrompts } = require('./prompts');
30
+ for (const p of listPrompts()) {
31
+ console.log(`/${p.name} — ${p.description}`);
32
+ for (const a of (p.arguments || [])) console.log(` - ${a.name}${a.required ? ' (required)' : ''}: ${a.description}`);
33
+ }
34
+ break;
35
+ }
36
+ case 'migrate':
37
+ migrate();
38
+ break;
28
39
  case 'help':
29
40
  case '--help':
30
41
  case '-h':
@@ -46,6 +57,9 @@ Commands:
46
57
  oauth Run OAuth flow to obtain user_access_token
47
58
  status Check authentication status
48
59
  keepalive Refresh cookie + UAT to prevent expiration (for cron jobs)
60
+ migrate One-time consolidation: copy creds from harness configs into
61
+ ~/.feishu-user-plugin/credentials.json (single source of truth).
62
+ Dry-run by default. Add --confirm to actually write.
49
63
  help Show this help
50
64
 
51
65
  Setup options:
@@ -53,6 +67,9 @@ Setup options:
53
67
  --app-secret <s> App Secret (non-interactive mode)
54
68
  --cookie <c> Cookie string (optional)
55
69
  --client <target> Config target: claude (default), codex, or both
70
+ --pointer-only Write only FEISHU_PLUGIN_PROFILE=default to harness env.
71
+ Real creds live in ~/.feishu-user-plugin/credentials.json
72
+ (run "migrate --confirm" first if not yet migrated).
56
73
 
57
74
  Quick Start (Claude Code):
58
75
  1. npx feishu-user-plugin setup
@@ -70,17 +87,23 @@ Auto-renewal (optional):
70
87
  `);
71
88
  }
72
89
 
90
+ function migrate() {
91
+ const { migrate: runMigrate } = require('./auth/credentials');
92
+ const confirm = process.argv.includes('--confirm');
93
+ const result = runMigrate({ dryRun: !confirm });
94
+ process.exit(result.ok ? 0 : 1);
95
+ }
96
+
73
97
  async function keepalive() {
74
- const { LarkUserClient } = require('./client');
75
- const { LarkOfficialClient } = require('./official');
76
- const { findMcpConfig, persistToConfig } = require('./config');
98
+ const { LarkUserClient } = require('./clients/user');
99
+ const { LarkOfficialClient } = require('./clients/official');
100
+ const { readCredentials, persistToConfig } = require('./auth/credentials');
77
101
 
78
- const found = findMcpConfig();
79
- if (!found) {
80
- console.error('[keepalive] No config found. Run: npx feishu-user-plugin setup');
102
+ const creds = readCredentials();
103
+ if (!creds.LARK_COOKIE && !creds.LARK_APP_ID) {
104
+ console.error('[keepalive] No credentials found. Run: npx feishu-user-plugin setup');
81
105
  process.exit(1);
82
106
  }
83
- const creds = found.serverEnv;
84
107
  let ok = true;
85
108
 
86
109
  // 1. Refresh Cookie
@@ -124,18 +147,27 @@ async function keepalive() {
124
147
  }
125
148
 
126
149
  async function checkStatus() {
127
- const { LarkUserClient } = require('./client');
128
- const { LarkOfficialClient } = require('./official');
150
+ const { LarkUserClient } = require('./clients/user');
151
+ const { LarkOfficialClient } = require('./clients/official');
129
152
  const { findMcpConfig } = require('./config');
153
+ const { readCanonical, getActiveProfileName, listProfileNames, readCredentials } = require('./auth/credentials');
130
154
 
155
+ const canonical = readCanonical();
131
156
  const found = findMcpConfig();
132
- const creds = found ? found.serverEnv : {};
157
+ const creds = readCredentials();
133
158
 
134
159
  console.log('=== feishu-user-plugin Auth Status ===\n');
135
- if (found) {
136
- console.log(`Config: ${found.configPath}${found.projectPath ? ` (project: ${found.projectPath})` : ''}`);
160
+ if (canonical) {
161
+ const path = require('path');
162
+ const os = require('os');
163
+ console.log(`Source: ${path.join(os.homedir(), '.feishu-user-plugin', 'credentials.json')} (canonical)`);
164
+ console.log(`Active profile: ${getActiveProfileName()}`);
165
+ console.log(`Available profiles: ${listProfileNames().join(', ')}`);
166
+ } else if (found) {
167
+ console.log(`Source: ${found.configPath}${found.projectPath ? ` (project: ${found.projectPath})` : ''} (legacy)`);
168
+ console.log('Tip: run `npx feishu-user-plugin migrate --confirm` to consolidate creds into ~/.feishu-user-plugin/credentials.json.');
137
169
  } else {
138
- console.log('Config: NOT FOUND (run: npx feishu-user-plugin setup)');
170
+ console.log('Source: NOT FOUND (run: npx feishu-user-plugin setup)');
139
171
  }
140
172
  console.log('');
141
173