UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

147 lines (146 loc) 6.16 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.docs = void 0; const path_1 = __importDefault(require("path")); const crypto_1 = __importDefault(require("crypto")); const fs_extra_1 = require("fs-extra"); const request_1 = require("../../../api/request"); const get_tool_auth_headers_1 = require("../../../api/get-tool-auth-headers"); const local_plugin_config_1 = require("../../../local-plugin-config"); const workspace_1 = require("../../../v2/utils/workspace"); /** Path of the knowledge MCP endpoint on the Meego site. */ const KNOWLEDGE_PATH = '/mcp_server/knowledge'; /** Tool name exposed by the knowledge MCP server (confirmed via tools/list). */ const SEARCH_TOOL = 'search_meegle_plugin_docs'; /** * `lpm ai docs <query>` — search official Feishu Project / Meego plugin dev docs. * * Why native (not the host's `feishu-project-knowledge` MCP): the skill used to * call that MCP tool directly, which is a hard dependency on the AI host having * the server configured. This command moves the call into the CLI and reuses the * plugin project's existing `lpm login` token, so it works across agents/machines * with no host MCP config. userkey is resolved server-side from the token (facade * middleware → app_center CheckToken); the CLI never touches it. * * Result is written to `.lpm-cache/mcp/<slug>.md` (same cache convention the skill * already reads with `lpm ai peek`); stdout reports the cache path. * * Exit 0 on success, 1 on any error (missing query / siteDomain / auth / request / * JSON-RPC error). */ async function docs(query, opts) { var _a; const format = opts.format === 'json' ? 'json' : 'text'; const q = (query || '').trim(); if (!q) { emitFatal(format, 'query is required, e.g. lpm ai docs "如何订阅工作项字段变更"'); process.exit(1); } // getLocalPluginConfig throws (not just returns undefined) when plugin.config.json // exists but is invalid — funnel that through emitFatal so the user gets a clean // message instead of a raw stack trace. let siteDomain; try { siteDomain = (_a = (0, local_plugin_config_1.getLocalPluginConfig)()) === null || _a === void 0 ? void 0 : _a.siteDomain; } catch (e) { emitFatal(format, e.message); process.exit(1); } if (!siteDomain) { emitFatal(format, 'cannot resolve siteDomain from plugin.config.json (run inside a plugin project)'); process.exit(1); } // Reuses the same auth path as every other API command: developer token or // auto-refreshed OAuth token. Throws self-describing login guidance if absent. let headers; try { headers = await (0, get_tool_auth_headers_1.getToolAuthHeaders)(siteDomain); } catch (e) { emitFatal(format, e.message); process.exit(1); } const url = `${new URL(siteDomain).origin}${KNOWLEDGE_PATH}`; let resp; try { // Knowledge endpoint returns raw JSON-RPC (no `{code,data}` envelope), so // request() returns the body as-is; we parse result/error ourselves. resp = (await (0, request_1.request)(url, { method: 'POST', headers: Object.assign(Object.assign({}, headers), { 'Content-Type': 'application/json' }), data: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: SEARCH_TOOL, arguments: { query: q } }, }, })); } catch (e) { emitFatal(format, `knowledge request failed: ${e.message}`); process.exit(1); } if (resp === null || resp === void 0 ? void 0 : resp.error) { emitFatal(format, `knowledge search error: ${resp.error.message || JSON.stringify(resp.error)}`); process.exit(1); } const text = extractText(resp === null || resp === void 0 ? void 0 : resp.result); (0, workspace_1.ensureWorkspace)(); const paths = (0, workspace_1.workspacePaths)(); const slug = slugify(q); const file = paths.mcp(slug); const body = `# ${q}\n\n_source: lpm ai docs → ${SEARCH_TOOL} @ ${url}_\n\n${text}\n`; (0, fs_extra_1.writeFileSync)(file, body); const rel = path_1.default.relative(process.cwd(), file); if (format === 'json') { process.stdout.write(JSON.stringify({ query: q, cache: rel }) + '\n'); } else { process.stdout.write(`saved → ${rel}\n`); process.stdout.write(`read it with: lpm ai peek ${rel} "<章节>"\n`); } process.exit(0); } exports.docs = docs; /** * Pull human text out of an MCP `tools/call` result. Standard MCP shape is * `result.content[].text`; if the server returns something else, fall back to * pretty-printed JSON so nothing is silently dropped. */ function extractText(result) { if (result && typeof result === 'object' && Array.isArray(result.content)) { const parts = result.content .map(c => (typeof (c === null || c === void 0 ? void 0 : c.text) === 'string' ? c.text : '')) .filter(Boolean); if (parts.length) return parts.join('\n\n'); } return typeof result === 'string' ? result : JSON.stringify(result, null, 2); } /** * Derive a stable, filesystem-safe cache slug from the query: kebab the * letters/digits (CJK kept), truncate, and append a short content hash so the * same query always maps to the same file (re-runs overwrite, distinct queries * never collide). */ function slugify(q) { const hash = crypto_1.default.createHash('md5').update(q).digest('hex').slice(0, 6); const base = q .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, '-') .replace(/^-+|-+$/g, '') .slice(0, 40); return `${base || 'docs'}-${hash}`; } function emitFatal(format, message) { if (format === 'json') { process.stdout.write(JSON.stringify({ error: message }) + '\n'); } else { process.stderr.write(`${message}\n`); } }