@lark-project/cli
Version:
飞书项目插件开发工具
147 lines (146 loc) • 6.16 kB
JavaScript
;
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`);
}
}