@uzqw/mcp 0.1.0
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/dist/chunk-DEIK42FS.js +83 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +375 -0
- package/dist/install-VFWABBBV.js +611 -0
- package/package.json +35 -0
- package/skills/recolx-digest/SKILL.md +51 -0
- package/skills/recolx-export/SKILL.md +50 -0
- package/skills/recolx-followup/SKILL.md +53 -0
- package/skills/recolx-shared/SKILL.md +68 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/skills.ts
|
|
2
|
+
import { cp, mkdir, readFile, readdir, rm, writeFile } from "fs/promises";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { dirname, join, resolve } from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
function parseFrontmatter(raw) {
|
|
7
|
+
if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
|
|
8
|
+
const end = raw.indexOf("\n---\n", 4);
|
|
9
|
+
if (end === -1) return { meta: {}, body: raw };
|
|
10
|
+
const yaml = raw.slice(4, end);
|
|
11
|
+
const body = raw.slice(end + 5);
|
|
12
|
+
const meta = {};
|
|
13
|
+
for (const line of yaml.split("\n")) {
|
|
14
|
+
const match = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
|
|
15
|
+
if (match && !line.startsWith(" ")) {
|
|
16
|
+
const [, k, v] = match;
|
|
17
|
+
if (v !== "") meta[k] = v.replace(/^["']|["']$/g, "");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return { meta, body };
|
|
21
|
+
}
|
|
22
|
+
function findSkillsDir() {
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
return resolve(here, "..", "skills");
|
|
25
|
+
}
|
|
26
|
+
async function loadSkills() {
|
|
27
|
+
const dir = findSkillsDir();
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = await readdir(dir);
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const skills = [];
|
|
35
|
+
for (const name of entries) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = await readFile(join(dir, name, "SKILL.md"), "utf-8");
|
|
38
|
+
const { meta } = parseFrontmatter(raw);
|
|
39
|
+
skills.push({
|
|
40
|
+
name: meta.name ?? name,
|
|
41
|
+
dir: name,
|
|
42
|
+
version: meta.version ?? "0.0.0",
|
|
43
|
+
description: meta.description ?? "",
|
|
44
|
+
content: raw.trim()
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return skills.sort((a, b) => a.name === "recolx-shared" ? -1 : b.name === "recolx-shared" ? 1 : a.name.localeCompare(b.name));
|
|
50
|
+
}
|
|
51
|
+
function installHome() {
|
|
52
|
+
return process.env.RECOLX_INSTALL_HOME ?? homedir();
|
|
53
|
+
}
|
|
54
|
+
async function installSkillsToClaudeCode() {
|
|
55
|
+
const skills = await loadSkills();
|
|
56
|
+
const destRoot = join(installHome(), ".claude", "skills");
|
|
57
|
+
await mkdir(destRoot, { recursive: true });
|
|
58
|
+
const installed = [];
|
|
59
|
+
for (const skill of skills) {
|
|
60
|
+
await cp(join(findSkillsDir(), skill.dir), join(destRoot, skill.dir), { recursive: true, force: true });
|
|
61
|
+
installed.push(skill.name);
|
|
62
|
+
}
|
|
63
|
+
return installed;
|
|
64
|
+
}
|
|
65
|
+
async function removeSkillsFromClaudeCode() {
|
|
66
|
+
const skills = await loadSkills();
|
|
67
|
+
const destRoot = join(installHome(), ".claude", "skills");
|
|
68
|
+
for (const skill of skills) {
|
|
69
|
+
await rm(join(destRoot, skill.dir), { recursive: true, force: true }).catch(() => {
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function skillsInstalledMessage(installed) {
|
|
74
|
+
if (installed.length === 0) return "skills \u76EE\u5F55\u4E3A\u7A7A\uFF0C\u672A\u5B89\u88C5";
|
|
75
|
+
return `\u5DF2\u5B89\u88C5 ${installed.length} \u4E2A skill \u5230 ~/.claude/skills/\uFF08${installed.join(", ")}\uFF09`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export {
|
|
79
|
+
loadSkills,
|
|
80
|
+
installSkillsToClaudeCode,
|
|
81
|
+
removeSkillsFromClaudeCode,
|
|
82
|
+
skillsInstalledMessage
|
|
83
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
loadSkills
|
|
4
|
+
} from "./chunk-DEIK42FS.js";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
import { writeFileSync } from "fs";
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import open from "open";
|
|
11
|
+
import { RecolxClient, runOAuthCallback, LoginInput, LogoutInput } from "@uzqw/shared";
|
|
12
|
+
|
|
13
|
+
// src/tools.ts
|
|
14
|
+
import {
|
|
15
|
+
GetCurrentUserInput,
|
|
16
|
+
ListFilesInput,
|
|
17
|
+
GetFileInput,
|
|
18
|
+
GetNoteInput,
|
|
19
|
+
GetTranscriptInput,
|
|
20
|
+
localDayStart,
|
|
21
|
+
localDayEnd
|
|
22
|
+
} from "@uzqw/shared";
|
|
23
|
+
var MAX_FILTER_PAGES = 5;
|
|
24
|
+
var FILTER_PAGE_SIZE = 100;
|
|
25
|
+
var TRANSCRIPT_PAGE_SIZE = 50;
|
|
26
|
+
var READ_ONLY = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
|
|
27
|
+
function encodeTranscriptCursor(offset) {
|
|
28
|
+
return Buffer.from(JSON.stringify({ o: offset }), "utf8").toString("base64url");
|
|
29
|
+
}
|
|
30
|
+
function decodeTranscriptCursor(cursor) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
33
|
+
if (typeof parsed.o === "number" && Number.isInteger(parsed.o) && parsed.o >= 0) return parsed.o;
|
|
34
|
+
return null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function errText(err) {
|
|
40
|
+
return err instanceof Error ? err.message : String(err);
|
|
41
|
+
}
|
|
42
|
+
function registerTools(server2, client2) {
|
|
43
|
+
server2.registerTool(
|
|
44
|
+
"get_current_user",
|
|
45
|
+
{
|
|
46
|
+
description: "\u83B7\u53D6\u5F53\u524D\u767B\u5F55\u7528\u6237\u7684\u8D26\u53F7\u4FE1\u606F\u4E0E\u8BA2\u9605\u6863\uFF08Pro \u989D\u5EA6/\u4F59\u91CF\uFF09\uFF0C\u7528\u4E8E\u5224\u65AD\u767B\u5F55\u6001\u4E0E\u989D\u5EA6\u95F8\u95E8",
|
|
47
|
+
annotations: { title: "\u83B7\u53D6\u5F53\u524D\u7528\u6237", ...READ_ONLY },
|
|
48
|
+
inputSchema: GetCurrentUserInput
|
|
49
|
+
},
|
|
50
|
+
async () => {
|
|
51
|
+
try {
|
|
52
|
+
const me = await client2.getCurrentUser();
|
|
53
|
+
return { content: [{ type: "text", text: JSON.stringify(me, null, 2) }] };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { content: [{ type: "text", text: `\u83B7\u53D6\u7528\u6237\u4FE1\u606F\u5931\u8D25\uFF1A${errText(err)}` }], isError: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
server2.registerTool(
|
|
60
|
+
"list_files",
|
|
61
|
+
{
|
|
62
|
+
description: "\u5217\u51FA\u5F55\u97F3\u6587\u4EF6\u3002\u652F\u6301\u5BA2\u6237\u7AEF\u8FC7\u6EE4\uFF1A`query`\uFF08\u6807\u9898\u5927\u5C0F\u5199\u4E0D\u654F\u611F\u5B50\u4E32\uFF09\u3001`date_from`/`date_to`\uFF08YYYY-MM-DD\uFF0C\u542B\u8FB9\u754C\uFF09\u3002\u8BBE\u8FC7\u6EE4\u65F6\u6700\u591A\u626B 5 \u9875 \xD7 100 \u6761\u5E76\u8FD4\u56DE\u5168\u90E8\u5339\u914D",
|
|
63
|
+
annotations: { title: "\u5217\u51FA\u5F55\u97F3", ...READ_ONLY },
|
|
64
|
+
inputSchema: ListFilesInput
|
|
65
|
+
},
|
|
66
|
+
async ({ page, page_size, query, date_from, date_to }) => {
|
|
67
|
+
try {
|
|
68
|
+
const hasFilter = Boolean(query || date_from || date_to);
|
|
69
|
+
if (!hasFilter) {
|
|
70
|
+
const result = await client2.listFiles(page, page_size);
|
|
71
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
72
|
+
}
|
|
73
|
+
const q = query?.toLowerCase();
|
|
74
|
+
const from = localDayStart(date_from);
|
|
75
|
+
const to = localDayEnd(date_to);
|
|
76
|
+
const files = [];
|
|
77
|
+
let scanned = 0;
|
|
78
|
+
let truncated = false;
|
|
79
|
+
for (let p = 1; p <= MAX_FILTER_PAGES; p++) {
|
|
80
|
+
const pageResult = await client2.listFiles(p, FILTER_PAGE_SIZE);
|
|
81
|
+
const items = pageResult.files ?? [];
|
|
82
|
+
scanned += items.length;
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
const title = String(item.title ?? "");
|
|
85
|
+
if (q && !title.toLowerCase().includes(q)) continue;
|
|
86
|
+
if (from !== null || to !== null) {
|
|
87
|
+
const recordedMs = Number(item.recorded_at) * 1e3;
|
|
88
|
+
if (Number.isNaN(recordedMs)) continue;
|
|
89
|
+
if (from !== null && recordedMs < from) continue;
|
|
90
|
+
if (to !== null && recordedMs > to) continue;
|
|
91
|
+
}
|
|
92
|
+
files.push(item);
|
|
93
|
+
}
|
|
94
|
+
if (items.length < FILTER_PAGE_SIZE) break;
|
|
95
|
+
if (p === MAX_FILTER_PAGES) truncated = true;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
content: [
|
|
99
|
+
{
|
|
100
|
+
type: "text",
|
|
101
|
+
text: JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
files,
|
|
104
|
+
scanned,
|
|
105
|
+
matched: files.length,
|
|
106
|
+
truncated,
|
|
107
|
+
note: truncated ? `\u53EA\u626B\u4E86\u524D ${MAX_FILTER_PAGES * FILTER_PAGE_SIZE} \u6761\u5F55\u97F3\uFF1B\u8BF7\u7F29\u5C0F\u8FC7\u6EE4\u6761\u4EF6\u4EE5\u83B7\u5F97\u5B8C\u6574\u7ED3\u679C\u3002` : void 0
|
|
108
|
+
},
|
|
109
|
+
null,
|
|
110
|
+
2
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
};
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { content: [{ type: "text", text: `\u5217\u51FA\u5F55\u97F3\u5931\u8D25\uFF1A${errText(err)}` }], isError: true };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
server2.registerTool(
|
|
121
|
+
"get_file",
|
|
122
|
+
{
|
|
123
|
+
description: "\u83B7\u53D6\u5355\u4E2A\u5F55\u97F3\u7684\u8BE6\u60C5\uFF1A\u6807\u9898\u3001\u65F6\u957F\u3001\u8BBE\u5907\uFF08serial + \u578B\u53F7\uFF09\u300124h \u97F3\u9891 presign \u4E0B\u8F7D\u5730\u5740",
|
|
124
|
+
annotations: { title: "\u83B7\u53D6\u5F55\u97F3\u8BE6\u60C5", ...READ_ONLY },
|
|
125
|
+
inputSchema: GetFileInput
|
|
126
|
+
},
|
|
127
|
+
async ({ file_id }) => {
|
|
128
|
+
try {
|
|
129
|
+
const file = await client2.getFile(file_id);
|
|
130
|
+
let text = JSON.stringify(file, null, 2);
|
|
131
|
+
if (!file.presigned_url) {
|
|
132
|
+
text += "\n\n\u63D0\u793A\uFF1Apresigned_url\uFF0824h \u97F3\u9891\u4E0B\u8F7D\u5730\u5740\uFF09\u4E3A\u7A7A\u2014\u2014\u97F3\u9891\u53EF\u80FD\u5C1A\u672A\u5C31\u7EEA\uFF0C\u7A0D\u540E\u91CD\u8BD5 get_file \u83B7\u53D6\u3002";
|
|
133
|
+
}
|
|
134
|
+
return { content: [{ type: "text", text }] };
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return { content: [{ type: "text", text: `\u83B7\u53D6\u6587\u4EF6\u8BE6\u60C5\u5931\u8D25\uFF1A${errText(err)}` }], isError: true };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
server2.registerTool(
|
|
141
|
+
"get_note",
|
|
142
|
+
{
|
|
143
|
+
description: "\u83B7\u53D6\u5F55\u97F3\u7684\u7B14\u8BB0/\u6458\u8981\uFF0C\u6309\u6A21\u677F tab \u8FD4\u56DE\uFF1Asummary \u6570\u7EC4\u6BCF\u9879\u542B prompt_id/prompt_name\u3002\u4F20 `template`\uFF08\u6A21\u677F\u540D\u6216\u6A21\u677F id\uFF09\u65F6\u53EA\u8FD4\u56DE\u5339\u914D\u6A21\u677F\uFF1B\u7F3A\u7701\u8FD4\u56DE\u5168\u90E8",
|
|
144
|
+
annotations: { title: "\u83B7\u53D6\u5F55\u97F3\u7B14\u8BB0", ...READ_ONLY },
|
|
145
|
+
inputSchema: GetNoteInput
|
|
146
|
+
},
|
|
147
|
+
async ({ file_id, template }) => {
|
|
148
|
+
try {
|
|
149
|
+
const file = await client2.getFile(file_id, { includeTranscript: true });
|
|
150
|
+
const summaries = file.transcript?.summary ?? [];
|
|
151
|
+
if (summaries.length === 0) {
|
|
152
|
+
return { content: [{ type: "text", text: "\u8BE5\u5F55\u97F3\u6682\u65E0\u7B14\u8BB0/\u6458\u8981\uFF08\u53EF\u80FD\u5C1A\u672A\u751F\u6210\uFF09\u3002" }] };
|
|
153
|
+
}
|
|
154
|
+
const matched = template ? summaries.filter(
|
|
155
|
+
(s) => s.prompt_id === template || (s.prompt_name ?? "").toLowerCase().includes(template.toLowerCase())
|
|
156
|
+
) : summaries;
|
|
157
|
+
if (matched.length === 0) {
|
|
158
|
+
const available = summaries.map((s) => `\`${s.prompt_name}\`\uFF08${s.prompt_id}\uFF09`).join("\u3001");
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: "text", text: `\u672A\u627E\u5230\u6A21\u677F\u300C${template}\u300D\u3002\u53EF\u7528\u6A21\u677F\uFF1A${available}` }]
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { content: [{ type: "text", text: JSON.stringify(matched, null, 2) }] };
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return { content: [{ type: "text", text: `\u83B7\u53D6\u7B14\u8BB0\u5931\u8D25\uFF1A${errText(err)}` }], isError: true };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
server2.registerTool(
|
|
170
|
+
"get_transcript",
|
|
171
|
+
{
|
|
172
|
+
description: "\u83B7\u53D6\u5F55\u97F3\u7684\u8F6C\u5199\uFF08\u4FDD\u7559 language/speaker\uFF09\u3002\u9ED8\u8BA4 raw \u539F\u6587\uFF1B\u540E\u7AEF\u6682\u672A\u63D0\u4F9B polish \u6DA6\u8272\u3002\u652F\u6301 language/speaker \u8FC7\u6EE4\u4E0E cursor \u5206\u9875\uFF08\u6BCF\u6B21 50 \u6761\uFF09",
|
|
173
|
+
annotations: { title: "\u83B7\u53D6\u5F55\u97F3\u8F6C\u5199", ...READ_ONLY },
|
|
174
|
+
inputSchema: GetTranscriptInput
|
|
175
|
+
},
|
|
176
|
+
async ({ file_id, cursor, language, speaker, mode }) => {
|
|
177
|
+
try {
|
|
178
|
+
if (mode === "polish") {
|
|
179
|
+
return {
|
|
180
|
+
content: [
|
|
181
|
+
{ type: "text", text: "\u540E\u7AEF\u6682\u672A\u63D0\u4F9B polish \u6DA6\u8272\u8F6C\u5199\uFF0C\u5F53\u524D\u53EA\u6709 raw \u539F\u6587\uFF1B\u7701\u7565 mode \u6216\u4F20 raw \u83B7\u53D6\u3002" }
|
|
182
|
+
]
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const file = await client2.getFile(file_id, { includeTranscript: true });
|
|
186
|
+
const transcript = file.transcript;
|
|
187
|
+
const all = transcript?.segments ?? [];
|
|
188
|
+
if (all.length === 0) {
|
|
189
|
+
return { content: [{ type: "text", text: "\u8BE5\u5F55\u97F3\u6682\u65E0\u8F6C\u5199\uFF08\u53EF\u80FD\u4ECD\u5728\u5904\u7406\u4E2D\uFF09\u3002" }] };
|
|
190
|
+
}
|
|
191
|
+
const filtered = all.filter((s) => {
|
|
192
|
+
if (language && (s.language ?? "") !== language) return false;
|
|
193
|
+
if (speaker && !(s.speaker ?? "").includes(speaker)) return false;
|
|
194
|
+
return true;
|
|
195
|
+
});
|
|
196
|
+
let offset = 0;
|
|
197
|
+
if (cursor !== void 0) {
|
|
198
|
+
const decoded = decodeTranscriptCursor(cursor);
|
|
199
|
+
if (decoded === null) {
|
|
200
|
+
return { content: [{ type: "text", text: "\u6E38\u6807\u65E0\u6548\u3002\u7701\u7565 cursor \u4ECE\u5934\u5F00\u59CB\u3002" }], isError: true };
|
|
201
|
+
}
|
|
202
|
+
offset = decoded;
|
|
203
|
+
}
|
|
204
|
+
const page = filtered.slice(offset, offset + TRANSCRIPT_PAGE_SIZE);
|
|
205
|
+
const next = offset + page.length;
|
|
206
|
+
return {
|
|
207
|
+
content: [
|
|
208
|
+
{
|
|
209
|
+
type: "text",
|
|
210
|
+
text: JSON.stringify(
|
|
211
|
+
{
|
|
212
|
+
file_id,
|
|
213
|
+
language: transcript?.language ?? null,
|
|
214
|
+
mode: "raw",
|
|
215
|
+
total: filtered.length,
|
|
216
|
+
offset,
|
|
217
|
+
limit: TRANSCRIPT_PAGE_SIZE,
|
|
218
|
+
returned: page.length,
|
|
219
|
+
next_cursor: next < filtered.length ? encodeTranscriptCursor(next) : null,
|
|
220
|
+
segments: page
|
|
221
|
+
},
|
|
222
|
+
null,
|
|
223
|
+
2
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
]
|
|
227
|
+
};
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return { content: [{ type: "text", text: `\u83B7\u53D6\u8F6C\u5199\u5931\u8D25\uFF1A${errText(err)}` }], isError: true };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/index.ts
|
|
236
|
+
var CALLBACK_PORT = 8199;
|
|
237
|
+
var LOGIN_TIMEOUT_MS = 12e4;
|
|
238
|
+
var sub = process.argv[2];
|
|
239
|
+
if (sub === "install" || sub === "uninstall") {
|
|
240
|
+
const { runInstall, runUninstall } = await import("./install-VFWABBBV.js");
|
|
241
|
+
const args = process.argv.slice(3);
|
|
242
|
+
const yes = args.includes("--yes") || args.includes("-y");
|
|
243
|
+
const noLogin = args.includes("--no-login");
|
|
244
|
+
if (sub === "install") await runInstall({ yes, noLogin });
|
|
245
|
+
else await runUninstall({ yes });
|
|
246
|
+
process.exit(0);
|
|
247
|
+
}
|
|
248
|
+
if (sub === "--help" || sub === "-h") {
|
|
249
|
+
console.log(`recolx-mcp \u2014 Recolx MCP server\uFF08\u53EA\u8BFB\uFF0C7 \u4E2A tool\uFF09
|
|
250
|
+
|
|
251
|
+
\u7528\u6CD5\uFF1A
|
|
252
|
+
recolx-mcp \u4EE5 stdio \u542F\u52A8 MCP server\uFF08Claude/Codex/Cursor/Zed \u7B49\u5BA2\u6237\u7AEF\u9ED8\u8BA4\u8DEF\u5F84\uFF09
|
|
253
|
+
recolx-mcp install \u68C0\u6D4B\u5E76\u5199\u5165\u672C\u5730\u5BA2\u6237\u7AEF\u914D\u7F6E\uFF08Claude Desktop / Codex / Claude Code / Cursor / Windsurf / VS Code / Zed\uFF09
|
|
254
|
+
recolx-mcp install --yes \u5168\u81EA\u52A8\uFF1A\u4E0D\u8BE2\u95EE\uFF0C\u76F4\u63A5\u914D\u7F6E\u6240\u6709\u68C0\u6D4B\u5230\u7684\u5BA2\u6237\u7AEF
|
|
255
|
+
recolx-mcp install --no-login \u8DF3\u8FC7 OAuth \u767B\u5F55\uFF08CI / \u8DF3\u677F\u673A\uFF09
|
|
256
|
+
recolx-mcp uninstall \u79FB\u9664\u5404\u5BA2\u6237\u7AEF\u91CC\u7684 recolx MCP \u914D\u7F6E
|
|
257
|
+
`);
|
|
258
|
+
process.exit(0);
|
|
259
|
+
}
|
|
260
|
+
var server = new McpServer({ name: "recolx", version: "0.1.0" });
|
|
261
|
+
var client = new RecolxClient({});
|
|
262
|
+
var READ_ONLY2 = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
|
|
263
|
+
server.registerTool(
|
|
264
|
+
"login",
|
|
265
|
+
{
|
|
266
|
+
description: "\u767B\u5F55 Recolx\uFF08OAuth PKCE\uFF09\uFF1A\u6253\u5F00\u6D4F\u89C8\u5668\u8DF3\u8F6C\u6388\u6743\u9875\uFF0C\u540C\u610F\u540E\u5728\u672C\u5730 8199 \u56DE\u8C03\u6536\u53D6 token \u5E76\u5199\u5165 ~/.recolx/tokens-mcp.json\uFF1B\u5DF2\u767B\u5F55\u65F6\u76F4\u63A5\u8FD4\u56DE",
|
|
267
|
+
annotations: { title: "\u767B\u5F55 Recolx", ...READ_ONLY2 },
|
|
268
|
+
inputSchema: LoginInput
|
|
269
|
+
},
|
|
270
|
+
async () => {
|
|
271
|
+
const existingToken = await client.auth.getAccessToken();
|
|
272
|
+
if (existingToken) {
|
|
273
|
+
try {
|
|
274
|
+
await client.getCurrentUser();
|
|
275
|
+
return { content: [{ type: "text", text: "\u5DF2\u767B\u5F55\u3002" }] };
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
278
|
+
if (msg.includes("401") || msg.includes("\u672A\u767B\u5F55")) {
|
|
279
|
+
await client.auth.logout();
|
|
280
|
+
} else {
|
|
281
|
+
return { content: [{ type: "text", text: `\u767B\u5F55\u6001\u6821\u9A8C\u5931\u8D25\uFF1A${msg}` }], isError: true };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const devUserId = process.env.RECOLX_DEV_USER_ID;
|
|
286
|
+
const authReq = client.auth.createAuthorizationRequest(devUserId ? { user_id: devUserId } : {});
|
|
287
|
+
const authUrlFile = process.env.RECOLX_DEV_AUTH_URL_FILE;
|
|
288
|
+
const result = await runOAuthCallback({
|
|
289
|
+
port: CALLBACK_PORT,
|
|
290
|
+
expectedState: authReq.state,
|
|
291
|
+
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
292
|
+
exchangeCode: (code) => client.auth.exchangeCode(code, authReq.codeVerifier),
|
|
293
|
+
onListening: () => {
|
|
294
|
+
if (authUrlFile) {
|
|
295
|
+
writeFileSync(authUrlFile, `${authReq.url}
|
|
296
|
+
`, "utf8");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
open(authReq.url).catch(() => {
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
switch (result.status) {
|
|
304
|
+
case "success": {
|
|
305
|
+
let nickname = "";
|
|
306
|
+
try {
|
|
307
|
+
const me = await client.getCurrentUser();
|
|
308
|
+
if (me?.nickname) nickname = `\uFF08${me.nickname}\uFF09`;
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
return { content: [{ type: "text", text: `\u5DF2\u767B\u5F55 Recolx${nickname}\u3002` }] };
|
|
312
|
+
}
|
|
313
|
+
case "timeout":
|
|
314
|
+
return {
|
|
315
|
+
content: [
|
|
316
|
+
{
|
|
317
|
+
type: "text",
|
|
318
|
+
text: `\u6388\u6743\u8D85\u65F6\uFF082 \u5206\u949F\uFF09\u3002\u82E5\u6D4F\u89C8\u5668\u672A\u81EA\u52A8\u6253\u5F00\uFF0C\u8BF7\u624B\u52A8\u8BBF\u95EE\uFF1A
|
|
319
|
+
${authReq.url}
|
|
320
|
+
|
|
321
|
+
\u8FDC\u7A0B/\u65E0\u5934\u673A\u5668\u9700\u5148\u8F6C\u53D1\u672C\u5730\u7AEF\u53E3 ${CALLBACK_PORT}\uFF1Assh -L ${CALLBACK_PORT}:localhost:${CALLBACK_PORT}`
|
|
322
|
+
}
|
|
323
|
+
],
|
|
324
|
+
isError: true
|
|
325
|
+
};
|
|
326
|
+
case "denied":
|
|
327
|
+
return {
|
|
328
|
+
content: [{ type: "text", text: `\u6388\u6743\u88AB\u62D2\u7EDD\uFF1A${result.error?.message ?? "\u7528\u6237\u53D6\u6D88\u6388\u6743"}` }],
|
|
329
|
+
isError: true
|
|
330
|
+
};
|
|
331
|
+
case "exchange-failed":
|
|
332
|
+
return {
|
|
333
|
+
content: [{ type: "text", text: `\u6388\u6743\u5931\u8D25\uFF1A${result.error?.message ?? "code \u6362 token \u5931\u8D25"}` }],
|
|
334
|
+
isError: true
|
|
335
|
+
};
|
|
336
|
+
case "listen-failed":
|
|
337
|
+
return {
|
|
338
|
+
content: [{ type: "text", text: `\u56DE\u8C03\u670D\u52A1\u542F\u52A8\u5931\u8D25\uFF1A${result.error?.message ?? "\u672A\u77E5\u9519\u8BEF"}` }],
|
|
339
|
+
isError: true
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
);
|
|
344
|
+
server.registerTool(
|
|
345
|
+
"logout",
|
|
346
|
+
{
|
|
347
|
+
description: "\u767B\u51FA\u5E76\u64A4\u9500 Recolx \u6388\u6743\uFF1A\u8C03\u670D\u52A1\u7AEF revoke\uFF08\u5F53\u524D access + \u5168\u90E8 refresh \u5931\u6548\uFF09\uFF0C\u5220\u9664\u672C\u5730 token \u6587\u4EF6\uFF1B\u4E0D\u5F71\u54CD App \u767B\u5F55",
|
|
348
|
+
annotations: { title: "\u767B\u51FA Recolx", ...READ_ONLY2 },
|
|
349
|
+
inputSchema: LogoutInput
|
|
350
|
+
},
|
|
351
|
+
async () => {
|
|
352
|
+
const existingToken = await client.auth.getAccessToken();
|
|
353
|
+
if (!existingToken) {
|
|
354
|
+
return { content: [{ type: "text", text: "\u5C1A\u672A\u767B\u5F55\u3002" }] };
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
await client.revokeCurrentUser();
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
await client.auth.logout();
|
|
361
|
+
return { content: [{ type: "text", text: "\u5DF2\u767B\u51FA\u5E76\u64A4\u9500\u6388\u6743\u3002" }] };
|
|
362
|
+
}
|
|
363
|
+
);
|
|
364
|
+
registerTools(server, client);
|
|
365
|
+
try {
|
|
366
|
+
const skills = await loadSkills();
|
|
367
|
+
for (const skill of skills) {
|
|
368
|
+
server.registerPrompt(skill.name, { description: skill.description }, () => ({
|
|
369
|
+
messages: [{ role: "user", content: { type: "text", text: skill.content } }]
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
}
|
|
374
|
+
var transport = new StdioServerTransport();
|
|
375
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import {
|
|
2
|
+
installSkillsToClaudeCode,
|
|
3
|
+
removeSkillsFromClaudeCode,
|
|
4
|
+
skillsInstalledMessage
|
|
5
|
+
} from "./chunk-DEIK42FS.js";
|
|
6
|
+
|
|
7
|
+
// src/install.ts
|
|
8
|
+
import { existsSync, writeFileSync } from "fs";
|
|
9
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
10
|
+
import { homedir, platform } from "os";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
|
+
import { spawnSync } from "child_process";
|
|
13
|
+
import { createInterface } from "readline/promises";
|
|
14
|
+
import open from "open";
|
|
15
|
+
import { RecolxClient, runOAuthCallback } from "@uzqw/shared";
|
|
16
|
+
var SERVER_NAME = "recolx";
|
|
17
|
+
var CALLBACK_PORT = 8199;
|
|
18
|
+
var LOGIN_TIMEOUT_MS = 12e4;
|
|
19
|
+
function findNodeBinCommand(command) {
|
|
20
|
+
const commandName = platform() === "win32" ? `${command}.cmd` : command;
|
|
21
|
+
const sibling = join(dirname(process.execPath), commandName);
|
|
22
|
+
if (existsSync(sibling)) return sibling;
|
|
23
|
+
const found = spawnSync(platform() === "win32" ? "where" : "which", [commandName], { encoding: "utf-8" });
|
|
24
|
+
const firstMatch = found.status === 0 ? found.stdout.split(/\r?\n/).find((l) => l.trim())?.trim() : void 0;
|
|
25
|
+
if (firstMatch) return firstMatch;
|
|
26
|
+
return commandName;
|
|
27
|
+
}
|
|
28
|
+
function isLocalPackageSpec(spec) {
|
|
29
|
+
return spec.endsWith(".tgz") || spec.startsWith(".") || spec.startsWith("/") || /^[A-Za-z]:[\\/]/.test(spec);
|
|
30
|
+
}
|
|
31
|
+
function getMcpEntry() {
|
|
32
|
+
const packageSpec = process.env.RECOLX_MCP_PACKAGE_SPEC?.trim() || "@uzqw/mcp@latest";
|
|
33
|
+
if (isLocalPackageSpec(packageSpec)) {
|
|
34
|
+
return { command: findNodeBinCommand("npm"), args: ["exec", "--yes", "--package", packageSpec, "--", "recolx-mcp"] };
|
|
35
|
+
}
|
|
36
|
+
return { command: findNodeBinCommand("npx"), args: ["-y", packageSpec] };
|
|
37
|
+
}
|
|
38
|
+
function commandPathIsStale(command) {
|
|
39
|
+
if (!command) return false;
|
|
40
|
+
if (command.includes("fnm_multishells")) return true;
|
|
41
|
+
const isAbsolute = command.startsWith("/") || /^[A-Za-z]:[\\/]/.test(command);
|
|
42
|
+
return isAbsolute && !existsSync(command);
|
|
43
|
+
}
|
|
44
|
+
function normalizeWindowsPath(p, plat = platform()) {
|
|
45
|
+
return plat === "win32" ? p.replace(/\\/g, "/") : p;
|
|
46
|
+
}
|
|
47
|
+
function claudeDesktopConfigPath(plat, home, appData) {
|
|
48
|
+
if (plat === "darwin") return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
49
|
+
if (plat === "win32") return `${appData}\\Claude\\claude_desktop_config.json`;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function installHome() {
|
|
53
|
+
return process.env.RECOLX_INSTALL_HOME ?? homedir();
|
|
54
|
+
}
|
|
55
|
+
function configPaths() {
|
|
56
|
+
const h = installHome();
|
|
57
|
+
return {
|
|
58
|
+
claudeDesktop: claudeDesktopConfigPath(platform(), h, process.env.APPDATA ?? ""),
|
|
59
|
+
codex: join(h, ".codex", "config.toml"),
|
|
60
|
+
cursor: join(h, ".cursor", "mcp.json"),
|
|
61
|
+
windsurf: join(h, ".codeium", "windsurf", "mcp_config.json"),
|
|
62
|
+
vsCode: join(process.cwd(), ".vscode", "mcp.json"),
|
|
63
|
+
zed: join(h, ".config", "zed", "settings.json"),
|
|
64
|
+
claudeCodeDir: join(h, ".claude")
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function isJsonObject(v) {
|
|
68
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
69
|
+
}
|
|
70
|
+
function isEnoent(err) {
|
|
71
|
+
return isJsonObject(err) && err.code === "ENOENT";
|
|
72
|
+
}
|
|
73
|
+
function entryAutoUpdates(entry) {
|
|
74
|
+
if (!isJsonObject(entry)) return false;
|
|
75
|
+
const args = Array.isArray(entry.args) ? entry.args : [];
|
|
76
|
+
return args.some((a) => typeof a === "string" && a.includes("@uzqw/mcp@latest"));
|
|
77
|
+
}
|
|
78
|
+
function jsonAdapter(spec) {
|
|
79
|
+
const rootKey = spec.rootKey ?? "mcpServers";
|
|
80
|
+
const toEntry = spec.toEntry ?? ((e) => ({ command: e.command, args: e.args }));
|
|
81
|
+
async function load() {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(await readFile(spec.configPath, "utf8"));
|
|
84
|
+
if (!isJsonObject(parsed)) return { error: `failed: ${spec.configPath} \u4E0D\u662F JSON \u5BF9\u8C61` };
|
|
85
|
+
return { config: parsed, exists: true };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (!isEnoent(err)) return { error: `failed: ${spec.configPath} \u542B\u65E0\u6548 JSON\uFF0C\u4FEE\u590D\u540E\u91CD\u8BD5` };
|
|
88
|
+
return { config: {}, exists: false };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
id: spec.id,
|
|
93
|
+
label: spec.label,
|
|
94
|
+
detect() {
|
|
95
|
+
if (!spec.configPath) return { detected: false, configPath: "(unsupported on this OS)" };
|
|
96
|
+
return {
|
|
97
|
+
detected: existsSync(spec.configPath) || existsSync(dirname(spec.configPath)),
|
|
98
|
+
configPath: spec.configPath
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
async install(ctx) {
|
|
102
|
+
if (!spec.configPath) return { status: "skipped", message: "\u6B64\u64CD\u4F5C\u7CFB\u7EDF\u4E0D\u652F\u6301" };
|
|
103
|
+
const loaded = await load();
|
|
104
|
+
if ("error" in loaded) return { status: "failed", message: loaded.error };
|
|
105
|
+
const servers = isJsonObject(loaded.config[rootKey]) ? loaded.config[rootKey] : {};
|
|
106
|
+
const existing = servers[SERVER_NAME];
|
|
107
|
+
if (existing && entryAutoUpdates(existing)) {
|
|
108
|
+
const cmd = isJsonObject(existing) && typeof existing.command === "string" ? existing.command : void 0;
|
|
109
|
+
if (!commandPathIsStale(cmd)) {
|
|
110
|
+
return { status: "already-configured", message: "\u5DF2\u914D\u7F6E\uFF08npx @latest \u81EA\u52A8\u66F4\u65B0\uFF09", restartHint: spec.restartHint };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
loaded.config[rootKey] = { ...servers, [SERVER_NAME]: toEntry(ctx.entry) };
|
|
114
|
+
await mkdir(dirname(spec.configPath), { recursive: true });
|
|
115
|
+
await writeFile(spec.configPath, JSON.stringify(loaded.config, null, 2) + "\n", "utf8");
|
|
116
|
+
return { status: "configured", message: spec.configuredMessage, restartHint: spec.restartHint };
|
|
117
|
+
},
|
|
118
|
+
async uninstall() {
|
|
119
|
+
if (!spec.configPath) return { status: "skipped", message: "\u6B64\u64CD\u4F5C\u7CFB\u7EDF\u4E0D\u652F\u6301" };
|
|
120
|
+
const loaded = await load();
|
|
121
|
+
if ("error" in loaded) return { status: "failed", message: loaded.error };
|
|
122
|
+
if (!loaded.exists) return { status: "skipped", message: "\u672A\u627E\u5230\u914D\u7F6E\u6587\u4EF6\uFF0C\u8DF3\u8FC7" };
|
|
123
|
+
const servers = isJsonObject(loaded.config[rootKey]) ? loaded.config[rootKey] : {};
|
|
124
|
+
if (!(SERVER_NAME in servers)) return { status: "skipped", message: "\u672A\u914D\u7F6E recolx\uFF0C\u8DF3\u8FC7" };
|
|
125
|
+
const next = { ...servers };
|
|
126
|
+
delete next[SERVER_NAME];
|
|
127
|
+
if (Object.keys(next).length > 0) loaded.config[rootKey] = next;
|
|
128
|
+
else delete loaded.config[rootKey];
|
|
129
|
+
await writeFile(spec.configPath, JSON.stringify(loaded.config, null, 2) + "\n", "utf8");
|
|
130
|
+
return { status: "configured", message: "\u5DF2\u79FB\u9664", restartHint: spec.restartHint };
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
var CODEX_HEADER = "[mcp_servers.recolx]";
|
|
135
|
+
function codexBlockText(entry) {
|
|
136
|
+
const win = platform() === "win32";
|
|
137
|
+
const command = win ? normalizeWindowsPath(entry.command, "win32") : entry.command;
|
|
138
|
+
const args = (win ? entry.args.map((a) => normalizeWindowsPath(a, "win32")) : entry.args).map((a) => JSON.stringify(a)).join(", ");
|
|
139
|
+
return `${CODEX_HEADER}
|
|
140
|
+
command = ${JSON.stringify(command)}
|
|
141
|
+
args = [${args}]`;
|
|
142
|
+
}
|
|
143
|
+
function codexBlockRange(lines) {
|
|
144
|
+
const start = lines.findIndex((l) => l.trim() === CODEX_HEADER);
|
|
145
|
+
if (start === -1) return null;
|
|
146
|
+
let end = lines.length;
|
|
147
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
148
|
+
if (/^\s*\[/.test(lines[i])) {
|
|
149
|
+
end = i;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { start, end };
|
|
154
|
+
}
|
|
155
|
+
function extractTomlCommand(lines) {
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
const m = line.match(/^\s*command\s*=\s*("(?:[^"\\]|\\.)*")\s*$/);
|
|
158
|
+
if (m) {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(m[1]);
|
|
161
|
+
} catch {
|
|
162
|
+
return void 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return void 0;
|
|
167
|
+
}
|
|
168
|
+
function codexAdapter(configPath) {
|
|
169
|
+
return {
|
|
170
|
+
id: "codex",
|
|
171
|
+
label: "Codex",
|
|
172
|
+
detect() {
|
|
173
|
+
return { detected: existsSync(configPath) || existsSync(dirname(configPath)), configPath };
|
|
174
|
+
},
|
|
175
|
+
async install(ctx) {
|
|
176
|
+
let content = "";
|
|
177
|
+
try {
|
|
178
|
+
content = await readFile(configPath, "utf8");
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (!isEnoent(err)) return { status: "failed", message: `failed: ${configPath} \u8BFB\u53D6\u5931\u8D25` };
|
|
181
|
+
}
|
|
182
|
+
const lines = content.split("\n");
|
|
183
|
+
const range = codexBlockRange(lines);
|
|
184
|
+
const block = codexBlockText(ctx.entry);
|
|
185
|
+
if (range) {
|
|
186
|
+
const existingCommand = extractTomlCommand(lines.slice(range.start, range.end));
|
|
187
|
+
if (!commandPathIsStale(existingCommand)) {
|
|
188
|
+
return { status: "already-configured", message: "\u5DF2\u914D\u7F6E\uFF08npx @latest \u81EA\u52A8\u66F4\u65B0\uFF09" };
|
|
189
|
+
}
|
|
190
|
+
const rebuilt = [...lines.slice(0, range.start), ...block.split("\n"), ...lines.slice(range.end)].join("\n");
|
|
191
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
192
|
+
await writeFile(configPath, rebuilt, "utf8");
|
|
193
|
+
return { status: "configured", message: "\u5DF2\u4FEE\u590D\u5931\u6548\u542F\u52A8\u8DEF\u5F84\uFF08npx @latest\uFF09" };
|
|
194
|
+
}
|
|
195
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
196
|
+
await writeFile(configPath, `${content}${content && !content.endsWith("\n") ? "\n" : ""}
|
|
197
|
+
${block}
|
|
198
|
+
`, "utf8");
|
|
199
|
+
return { status: "configured", message: "\u5DF2\u5199\u5165 [mcp_servers.recolx]" };
|
|
200
|
+
},
|
|
201
|
+
async uninstall() {
|
|
202
|
+
let content = "";
|
|
203
|
+
try {
|
|
204
|
+
content = await readFile(configPath, "utf8");
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (isEnoent(err)) return { status: "skipped", message: "\u672A\u627E\u5230\u914D\u7F6E\u6587\u4EF6\uFF0C\u8DF3\u8FC7" };
|
|
207
|
+
return { status: "failed", message: `${configPath} \u8BFB\u53D6\u5931\u8D25` };
|
|
208
|
+
}
|
|
209
|
+
const lines = content.split("\n");
|
|
210
|
+
const range = codexBlockRange(lines);
|
|
211
|
+
if (!range) return { status: "skipped", message: "\u672A\u914D\u7F6E recolx\uFF0C\u7701\u7565" };
|
|
212
|
+
const rebuilt = [...lines.slice(0, range.start), ...lines.slice(range.end)].join("\n").replace(/\n{3,}/g, "\n\n");
|
|
213
|
+
await writeFile(configPath, rebuilt, "utf8");
|
|
214
|
+
return { status: "configured", message: "\u5DF2\u79FB\u9664" };
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
var CLAUDE_CODE_RESTART = "\u9000\u51FA\u5F53\u524D Claude Code \u4F1A\u8BDD\uFF0C\u65B0\u5F00 `claude` \u4F1A\u8BDD\u4EE5\u52A0\u8F7D MCP";
|
|
219
|
+
function commandExists(command) {
|
|
220
|
+
const lookup = platform() === "win32" ? "where" : "which";
|
|
221
|
+
return spawnSync(lookup, [command], { encoding: "utf-8" }).status === 0;
|
|
222
|
+
}
|
|
223
|
+
function claudeCodeAdapter(claudeCodeDir) {
|
|
224
|
+
return {
|
|
225
|
+
id: "claude-code",
|
|
226
|
+
label: "Claude Code",
|
|
227
|
+
detect() {
|
|
228
|
+
return { detected: existsSync(claudeCodeDir), configPath: claudeCodeDir };
|
|
229
|
+
},
|
|
230
|
+
async install({ entry }) {
|
|
231
|
+
const serverCommand = [entry.command, ...entry.args].join(" ");
|
|
232
|
+
const manualCmd = `claude mcp add --scope user recolx -- ${serverCommand}`;
|
|
233
|
+
if (!commandExists("claude")) {
|
|
234
|
+
return {
|
|
235
|
+
status: "failed",
|
|
236
|
+
message: `failed: PATH \u4E0A\u627E\u4E0D\u5230 claude CLI\u3002\u53EF\u624B\u52A8\u6267\u884C\uFF1A
|
|
237
|
+
${manualCmd}`
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
spawnSync("claude", ["mcp", "remove", "recolx", "--scope", "user"], { encoding: "utf-8" });
|
|
241
|
+
const register = spawnSync(
|
|
242
|
+
"claude",
|
|
243
|
+
["mcp", "add", "--scope", "user", "recolx", "--", entry.command, ...entry.args],
|
|
244
|
+
{ encoding: "utf-8" }
|
|
245
|
+
);
|
|
246
|
+
if (register.status !== 0) {
|
|
247
|
+
const err = (register.stderr || register.stdout || "").trim();
|
|
248
|
+
return {
|
|
249
|
+
status: "failed",
|
|
250
|
+
message: `failed: claude mcp add \u5931\u8D25\uFF08${err || "\u672A\u77E5\u9519\u8BEF"}\uFF09\u3002\u624B\u52A8\u6267\u884C\uFF1A
|
|
251
|
+
${manualCmd}`
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
let skillsMsg = "";
|
|
255
|
+
try {
|
|
256
|
+
const installed = await installSkillsToClaudeCode();
|
|
257
|
+
skillsMsg = `\uFF1B${skillsInstalledMessage(installed)}`;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
skillsMsg = `\uFF1Bskills \u62F7\u8D1D\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}\uFF08\u53EF\u7A0D\u540E\u91CD\u8DD1 install\uFF09`;
|
|
260
|
+
}
|
|
261
|
+
return { status: "configured", message: `\u5DF2\u6CE8\u518C\u5230 user scope${skillsMsg}`, restartHint: CLAUDE_CODE_RESTART };
|
|
262
|
+
},
|
|
263
|
+
async uninstall() {
|
|
264
|
+
if (!commandExists("claude")) {
|
|
265
|
+
return { status: "failed", message: "failed: PATH \u4E0A\u627E\u4E0D\u5230 claude CLI\u3002\u624B\u52A8\u6267\u884C claude mcp remove recolx --scope user" };
|
|
266
|
+
}
|
|
267
|
+
const removed = spawnSync("claude", ["mcp", "remove", "recolx", "--scope", "user"], { encoding: "utf-8" });
|
|
268
|
+
if (removed.status !== 0) return { status: "failed", message: "failed: claude mcp remove \u5931\u8D25" };
|
|
269
|
+
let skillsMsg = "";
|
|
270
|
+
try {
|
|
271
|
+
await removeSkillsFromClaudeCode();
|
|
272
|
+
skillsMsg = "\uFF1B\u5DF2\u79FB\u9664 ~/.claude/skills/ \u4E2D\u7684 recolx skills";
|
|
273
|
+
} catch (err) {
|
|
274
|
+
skillsMsg = `\uFF1Bskills \u79FB\u9664\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}`;
|
|
275
|
+
}
|
|
276
|
+
return { status: "configured", message: `\u5DF2\u79FB\u9664${skillsMsg}`, restartHint: CLAUDE_CODE_RESTART };
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function zedAdapter(configPath) {
|
|
281
|
+
return {
|
|
282
|
+
id: "zed",
|
|
283
|
+
label: "Zed",
|
|
284
|
+
detect() {
|
|
285
|
+
return { detected: existsSync(configPath) || existsSync(dirname(configPath)), configPath };
|
|
286
|
+
},
|
|
287
|
+
async install(ctx) {
|
|
288
|
+
let config = {};
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
|
291
|
+
if (!isJsonObject(parsed)) return { status: "failed", message: `failed: ${configPath} \u4E0D\u662F JSON \u5BF9\u8C61` };
|
|
292
|
+
config = parsed;
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if (!isEnoent(err)) return { status: "failed", message: `failed: ${configPath} \u542B\u65E0\u6548 JSON\uFF0C\u4FEE\u590D\u540E\u91CD\u8BD5` };
|
|
295
|
+
}
|
|
296
|
+
const servers = isJsonObject(config.context_servers) ? config.context_servers : {};
|
|
297
|
+
const existing = servers[SERVER_NAME];
|
|
298
|
+
if (existing && entryAutoUpdates(existing)) {
|
|
299
|
+
const path = isJsonObject(existing) && isJsonObject(existing.command) && typeof existing.command.path === "string" ? existing.command.path : void 0;
|
|
300
|
+
if (!commandPathIsStale(path)) {
|
|
301
|
+
return { status: "already-configured", message: "\u5DF2\u914D\u7F6E\uFF08npx @latest \u81EA\u52A8\u66F4\u65B0\uFF09" };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
config.context_servers = {
|
|
305
|
+
...servers,
|
|
306
|
+
[SERVER_NAME]: { command: { path: ctx.entry.command, args: ctx.entry.args } }
|
|
307
|
+
};
|
|
308
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
309
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
310
|
+
return { status: "configured", message: "\u5DF2\u5199\u5165 context_servers" };
|
|
311
|
+
},
|
|
312
|
+
async uninstall() {
|
|
313
|
+
let config = {};
|
|
314
|
+
try {
|
|
315
|
+
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
|
316
|
+
if (!isJsonObject(parsed)) return { status: "failed", message: `failed: ${configPath} \u4E0D\u662F JSON \u5BF9\u8C61` };
|
|
317
|
+
config = parsed;
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (isEnoent(err)) return { status: "skipped", message: "\u672A\u627E\u5230\u914D\u7F6E\u6587\u4EF6\uFF0C\u8DF3\u8FC7" };
|
|
320
|
+
return { status: "failed", message: `${configPath} \u542B\u65E0\u6548 JSON` };
|
|
321
|
+
}
|
|
322
|
+
const servers = isJsonObject(config.context_servers) ? config.context_servers : {};
|
|
323
|
+
if (!(SERVER_NAME in servers)) return { status: "skipped", message: "\u672A\u914D\u7F6E recolx\uFF0C\u7701\u7565" };
|
|
324
|
+
const next = { ...servers };
|
|
325
|
+
delete next[SERVER_NAME];
|
|
326
|
+
if (Object.keys(next).length > 0) config.context_servers = next;
|
|
327
|
+
else delete config.context_servers;
|
|
328
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
329
|
+
return { status: "configured", message: "\u5DF2\u79FB\u9664" };
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
var RESTART_HINTS = {
|
|
334
|
+
"claude-desktop": "\u5B8C\u5168\u9000\u51FA Claude Desktop\uFF08macOS \u2318Q / Windows Alt+F4\uFF09\u540E\u91CD\u65B0\u6253\u5F00\u2014\u2014\u53EA\u5173\u7A97\u53E3\u4E0D\u591F",
|
|
335
|
+
codex: "\u9000\u51FA Codex \u540E\u91CD\u65B0\u6253\u5F00",
|
|
336
|
+
"claude-code": CLAUDE_CODE_RESTART,
|
|
337
|
+
cursor: "\u91CD\u542F Cursor \u4EE5\u52A0\u8F7D MCP",
|
|
338
|
+
windsurf: "\u91CD\u542F Windsurf \u4EE5\u52A0\u8F7D MCP",
|
|
339
|
+
vscode: "\u91CD\u8F7D VS Code \u7A97\u53E3\uFF08Command Palette \u2192 Reload Window\uFF09",
|
|
340
|
+
zed: "\u91CD\u542F Zed \u4EE5\u52A0\u8F7D MCP"
|
|
341
|
+
};
|
|
342
|
+
function detectClients() {
|
|
343
|
+
const p = configPaths();
|
|
344
|
+
const adapters = [
|
|
345
|
+
jsonAdapter({
|
|
346
|
+
id: "claude-desktop",
|
|
347
|
+
label: "Claude Desktop",
|
|
348
|
+
configPath: p.claudeDesktop ?? "",
|
|
349
|
+
configuredMessage: "\u5DF2\u5199\u5165 mcpServers",
|
|
350
|
+
restartHint: RESTART_HINTS["claude-desktop"]
|
|
351
|
+
}),
|
|
352
|
+
codexAdapter(p.codex),
|
|
353
|
+
claudeCodeAdapter(p.claudeCodeDir),
|
|
354
|
+
jsonAdapter({
|
|
355
|
+
id: "cursor",
|
|
356
|
+
label: "Cursor",
|
|
357
|
+
configPath: p.cursor,
|
|
358
|
+
configuredMessage: "\u5DF2\u5199\u5165 mcp.json",
|
|
359
|
+
restartHint: RESTART_HINTS.cursor
|
|
360
|
+
}),
|
|
361
|
+
jsonAdapter({
|
|
362
|
+
id: "windsurf",
|
|
363
|
+
label: "Windsurf",
|
|
364
|
+
configPath: p.windsurf,
|
|
365
|
+
configuredMessage: "\u5DF2\u5199\u5165 mcp_config.json",
|
|
366
|
+
restartHint: RESTART_HINTS.windsurf
|
|
367
|
+
}),
|
|
368
|
+
jsonAdapter({
|
|
369
|
+
id: "vscode",
|
|
370
|
+
label: "VS Code",
|
|
371
|
+
configPath: p.vsCode,
|
|
372
|
+
rootKey: "servers",
|
|
373
|
+
toEntry: (e) => ({ type: "stdio", command: e.command, args: e.args }),
|
|
374
|
+
configuredMessage: "\u5DF2\u5199\u5165 .vscode/mcp.json",
|
|
375
|
+
restartHint: RESTART_HINTS.vscode
|
|
376
|
+
}),
|
|
377
|
+
zedAdapter(p.zed)
|
|
378
|
+
];
|
|
379
|
+
return adapters.map((a) => ({ adapter: a, id: a.id, label: a.label, ...a.detect() }));
|
|
380
|
+
}
|
|
381
|
+
var promptInterface = null;
|
|
382
|
+
var pipedAnswers = null;
|
|
383
|
+
var pipedAnswersPromise = null;
|
|
384
|
+
async function readPipedAnswers() {
|
|
385
|
+
let input = "";
|
|
386
|
+
for await (const chunk of process.stdin) {
|
|
387
|
+
input += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
388
|
+
}
|
|
389
|
+
return input.split(/\r?\n/);
|
|
390
|
+
}
|
|
391
|
+
async function prompt(question, defaultYes = true) {
|
|
392
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
393
|
+
let answer;
|
|
394
|
+
if (!process.stdin.isTTY) {
|
|
395
|
+
process.stdout.write(`${question} [${hint}] `);
|
|
396
|
+
pipedAnswers ??= await (pipedAnswersPromise ??= readPipedAnswers());
|
|
397
|
+
answer = (pipedAnswers.shift() ?? "").trim().toLowerCase();
|
|
398
|
+
} else {
|
|
399
|
+
promptInterface ??= createInterface({ input: process.stdin, output: process.stdout });
|
|
400
|
+
answer = (await promptInterface.question(`${question} [${hint}] `)).trim().toLowerCase();
|
|
401
|
+
}
|
|
402
|
+
if (answer === "") return defaultYes;
|
|
403
|
+
return answer.startsWith("y");
|
|
404
|
+
}
|
|
405
|
+
function pickIdentity(user) {
|
|
406
|
+
if (!isJsonObject(user)) return void 0;
|
|
407
|
+
for (const k of ["nickname", "user_id", "id"]) {
|
|
408
|
+
const v = user[k];
|
|
409
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
410
|
+
if (typeof v === "number") return String(v);
|
|
411
|
+
}
|
|
412
|
+
return void 0;
|
|
413
|
+
}
|
|
414
|
+
async function runLogin() {
|
|
415
|
+
const client = new RecolxClient({});
|
|
416
|
+
const devUserId = process.env.RECOLX_DEV_USER_ID;
|
|
417
|
+
const authReq = client.auth.createAuthorizationRequest(devUserId ? { user_id: devUserId } : {});
|
|
418
|
+
const authUrlFile = process.env.RECOLX_DEV_AUTH_URL_FILE;
|
|
419
|
+
const result = await runOAuthCallback({
|
|
420
|
+
port: CALLBACK_PORT,
|
|
421
|
+
expectedState: authReq.state,
|
|
422
|
+
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
423
|
+
exchangeCode: (code) => client.auth.exchangeCode(code, authReq.codeVerifier),
|
|
424
|
+
onListening: () => {
|
|
425
|
+
if (authUrlFile) {
|
|
426
|
+
writeFileSync(authUrlFile, `${authReq.url}
|
|
427
|
+
`, "utf8");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
console.log(` \u82E5\u6D4F\u89C8\u5668\u672A\u81EA\u52A8\u6253\u5F00\uFF0C\u8BF7\u624B\u52A8\u8BBF\u95EE\uFF1A
|
|
431
|
+
${authReq.url}`);
|
|
432
|
+
open(authReq.url).catch(() => {
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
switch (result.status) {
|
|
437
|
+
case "success": {
|
|
438
|
+
let who;
|
|
439
|
+
try {
|
|
440
|
+
who = pickIdentity(await client.getCurrentUser());
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
return { status: "success", who };
|
|
444
|
+
}
|
|
445
|
+
case "timeout":
|
|
446
|
+
return { status: "timeout", message: `\u6388\u6743\u8D85\u65F6\uFF082 \u5206\u949F\uFF09\u3002\u53EF\u91CD\u8BD5 recolx-mcp install --yes\uFF0C\u6216\u5728\u5BA2\u6237\u7AEF\u91CC\u8FD0\u884C login tool` };
|
|
447
|
+
case "denied":
|
|
448
|
+
return { status: "failed", message: result.error?.message ?? "\u6388\u6743\u88AB\u62D2\u7EDD" };
|
|
449
|
+
case "exchange-failed":
|
|
450
|
+
return { status: "failed", message: result.error?.message ?? "code \u6362 token \u5931\u8D25" };
|
|
451
|
+
case "listen-failed":
|
|
452
|
+
return { status: "failed", message: result.error?.message ?? "\u56DE\u8C03\u670D\u52A1\u542F\u52A8\u5931\u8D25\uFF08\u7AEF\u53E3 8199 \u88AB\u5360\u7528\uFF1F\uFF09" };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async function loginStep(nonInteractive) {
|
|
456
|
+
console.log();
|
|
457
|
+
console.log("\u2192 \u767B\u5F55 Recolx\u2026");
|
|
458
|
+
const client = new RecolxClient({});
|
|
459
|
+
const existing = await client.auth.getAccessToken();
|
|
460
|
+
if (existing) {
|
|
461
|
+
try {
|
|
462
|
+
const me = await client.getCurrentUser();
|
|
463
|
+
const who = pickIdentity(me);
|
|
464
|
+
console.log(` \u5DF2\u767B\u5F55${who ? `\uFF08${who}\uFF09` : ""}\uFF0C\u8DF3\u8FC7 OAuth\u3002`);
|
|
465
|
+
return { status: "already-authed", who };
|
|
466
|
+
} catch (err) {
|
|
467
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
468
|
+
if (msg.includes("401") || msg.includes("\u672A\u767B\u5F55")) {
|
|
469
|
+
console.log(" \u672C\u5730 token \u5DF2\u5931\u6548\u2014\u2014\u6E05\u6389\u91CD\u65B0\u8D70\u6388\u6743\u3002");
|
|
470
|
+
await client.auth.logout().catch(() => {
|
|
471
|
+
});
|
|
472
|
+
} else {
|
|
473
|
+
console.log(" token \u5B58\u5728\u4F46\u6821\u9A8C\u5931\u8D25\uFF0C\u5C06\u91CD\u65B0\u6388\u6743\u3002");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (!nonInteractive) {
|
|
478
|
+
const yes = await prompt("\u73B0\u5728\u767B\u5F55 Recolx\uFF1F\uFF08\u6253\u5F00\u6D4F\u89C8\u5668\uFF09", true);
|
|
479
|
+
if (!yes) {
|
|
480
|
+
console.log(" \u8DF3\u8FC7 \u2014\u2014 \u91CD\u542F\u5BA2\u6237\u7AEF\u540E\u9996\u6B21\u8C03\u7528 tool \u65F6\u518D\u767B\u5F55\u3002");
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
console.log(" \u6253\u5F00\u6D4F\u89C8\u5668 \u2014\u2014 \u70B9\u51FB\u300C\u6388\u6743\u300D\u5B8C\u6210\u767B\u5F55\u3002");
|
|
485
|
+
const outcome = await runLogin();
|
|
486
|
+
switch (outcome.status) {
|
|
487
|
+
case "success":
|
|
488
|
+
console.log(` \u2713 \u5DF2\u8BA4\u8BC1${outcome.who ? `\uFF08${outcome.who}\uFF09` : ""}\u3002`);
|
|
489
|
+
return { status: "success", who: outcome.who };
|
|
490
|
+
case "timeout":
|
|
491
|
+
console.log(` \u2717 ${outcome.message}`);
|
|
492
|
+
return { status: "timeout" };
|
|
493
|
+
case "failed":
|
|
494
|
+
console.log(` \u2717 \u767B\u5F55\u5931\u8D25\uFF1A${outcome.message}`);
|
|
495
|
+
return { status: "failed" };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
var BAR = "\u2500".repeat(60);
|
|
499
|
+
function printNextSteps(clients, login) {
|
|
500
|
+
if (clients.length === 0) return;
|
|
501
|
+
const authed = login?.status === "success" || login?.status === "already-authed";
|
|
502
|
+
console.log();
|
|
503
|
+
console.log(BAR);
|
|
504
|
+
console.log(`\u2713 Recolx MCP \u5DF2\u914D\u7F6E\uFF1A${clients.map((c) => c.label).join(", ")}`);
|
|
505
|
+
if (authed && login?.who) console.log(`\u2713 \u5DF2\u8BA4\u8BC1\uFF08${login.who}\uFF09`);
|
|
506
|
+
console.log(BAR);
|
|
507
|
+
console.log();
|
|
508
|
+
if (clients.length === 1) {
|
|
509
|
+
console.log(`\u4E0B\u4E00\u6B65\uFF1A${clients[0].restartHint ?? "\u91CD\u542F\u5BA2\u6237\u7AEF"}`);
|
|
510
|
+
} else {
|
|
511
|
+
console.log("\u4E0B\u4E00\u6B65\uFF1A\u91CD\u542F\u5404\u5BA2\u6237\u7AEF");
|
|
512
|
+
for (const c of clients) console.log(` \u2022 ${c.label}\uFF1A${c.restartHint ?? "\u91CD\u542F"}`);
|
|
513
|
+
}
|
|
514
|
+
if (!authed) {
|
|
515
|
+
console.log();
|
|
516
|
+
console.log(" \u7136\u540E\u4EFB\u9009\u4E00\u4E2A\u5BA2\u6237\u7AEF\u91CC\u8F93\u5165\u300CList my Recolx recordings\u300D\uFF0C\u4F1A\u89E6\u53D1\u4E00\u6B21 OAuth \u767B\u5F55\u3002");
|
|
517
|
+
}
|
|
518
|
+
console.log();
|
|
519
|
+
console.log(" \u8FDC\u7A0B\u673A\u5668\uFF08\u65E0\u6D4F\u89C8\u5668 / \u8DF3\u677F\u673A\uFF09\uFF1A\u5148\u8F6C\u53D1 OAuth \u56DE\u8C03\u7AEF\u53E3\u518D\u767B\u5F55\uFF1A");
|
|
520
|
+
console.log(` ssh -L ${CALLBACK_PORT}:localhost:${CALLBACK_PORT} <host>`);
|
|
521
|
+
console.log();
|
|
522
|
+
console.log(BAR);
|
|
523
|
+
}
|
|
524
|
+
async function runInstall(opts = {}) {
|
|
525
|
+
console.log("Recolx MCP installer\n");
|
|
526
|
+
const clients = detectClients();
|
|
527
|
+
console.log("\u68C0\u6D4B\u5230\u7684 AI \u5BA2\u6237\u7AEF\uFF1A");
|
|
528
|
+
for (const c of clients) {
|
|
529
|
+
const mark = c.detected ? "\u2713" : "\xB7";
|
|
530
|
+
console.log(` ${mark} ${c.label.padEnd(16)} ${c.detected ? c.configPath : "(\u672A\u68C0\u6D4B\u5230)"}`);
|
|
531
|
+
}
|
|
532
|
+
console.log();
|
|
533
|
+
const selected = [];
|
|
534
|
+
if (opts.yes) {
|
|
535
|
+
for (const c of clients) if (c.detected) selected.push(c);
|
|
536
|
+
if (selected.length > 0) {
|
|
537
|
+
console.log(`--yes\uFF1A\u81EA\u52A8\u914D\u7F6E ${selected.map((c) => c.label).join(", ")}\uFF0C\u4E0D\u518D\u8BE2\u95EE\u3002
|
|
538
|
+
`);
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
for (const c of clients) {
|
|
542
|
+
if (!c.detected) continue;
|
|
543
|
+
if (await prompt(`\u914D\u7F6E ${c.label}\uFF1F`, true)) selected.push(c);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (selected.length === 0) {
|
|
547
|
+
console.log("\n\u6CA1\u6709\u53EF\u914D\u7F6E\u7684\u5BA2\u6237\u7AEF\uFF0C\u9000\u51FA\u3002");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const entry = getMcpEntry();
|
|
551
|
+
const succeeded = [];
|
|
552
|
+
for (const c of selected) {
|
|
553
|
+
process.stdout.write(`\u2192 ${c.label}... `);
|
|
554
|
+
try {
|
|
555
|
+
const result = await c.adapter.install({ entry });
|
|
556
|
+
console.log(result.message);
|
|
557
|
+
if (result.status === "configured" || result.status === "already-configured") {
|
|
558
|
+
succeeded.push({ id: c.id, label: c.label, restartHint: result.restartHint });
|
|
559
|
+
}
|
|
560
|
+
} catch (err) {
|
|
561
|
+
console.log(`failed\uFF1A${err instanceof Error ? err.message : String(err)}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const login = opts.noLogin ? null : await loginStep(Boolean(opts.yes));
|
|
565
|
+
if (opts.noLogin) {
|
|
566
|
+
console.log();
|
|
567
|
+
console.log("--no-login\uFF1A\u8DF3\u8FC7 OAuth\u3002\u4E4B\u540E\u5728\u4EFB\u4E00\u5BA2\u6237\u7AEF\u91CC\u89E6\u53D1 login\uFF08\u6216\u8FD0\u884C recolx-mcp login\uFF09\u3002");
|
|
568
|
+
}
|
|
569
|
+
printNextSteps(succeeded, login);
|
|
570
|
+
}
|
|
571
|
+
async function runUninstall(opts = {}) {
|
|
572
|
+
console.log("Recolx MCP uninstaller\n");
|
|
573
|
+
const clients = detectClients();
|
|
574
|
+
const selected = [];
|
|
575
|
+
if (opts.yes) {
|
|
576
|
+
for (const c of clients) if (c.detected) selected.push(c);
|
|
577
|
+
} else {
|
|
578
|
+
for (const c of clients) {
|
|
579
|
+
if (!c.detected) continue;
|
|
580
|
+
if (await prompt(`\u79FB\u9664 ${c.label} \u91CC\u7684 recolx \u914D\u7F6E\uFF1F`, true)) selected.push(c);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (selected.length === 0) {
|
|
584
|
+
console.log("\n\u6CA1\u6709\u9700\u8981\u6E05\u7406\u7684\u5BA2\u6237\u7AEF\uFF0C\u9000\u51FA\u3002");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
for (const c of selected) {
|
|
588
|
+
process.stdout.write(`\u2192 ${c.label}... `);
|
|
589
|
+
try {
|
|
590
|
+
const result = await c.adapter.uninstall();
|
|
591
|
+
console.log(result.message);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
console.log(`failed\uFF1A${err instanceof Error ? err.message : String(err)}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
console.log();
|
|
597
|
+
console.log("\u5DF2\u6E05\u7406\u5BA2\u6237\u7AEF\u914D\u7F6E\u3002token \u6587\u4EF6 ~/.recolx/tokens-mcp.json \u4FDD\u7559\uFF08\u5982\u9700\u5F7B\u5E95\u79FB\u9664\u8BF7\u5220\u9664\u8BE5\u6587\u4EF6\uFF09\u3002");
|
|
598
|
+
}
|
|
599
|
+
export {
|
|
600
|
+
CALLBACK_PORT,
|
|
601
|
+
LOGIN_TIMEOUT_MS,
|
|
602
|
+
SERVER_NAME,
|
|
603
|
+
claudeDesktopConfigPath,
|
|
604
|
+
codexBlockRange,
|
|
605
|
+
codexBlockText,
|
|
606
|
+
commandPathIsStale,
|
|
607
|
+
getMcpEntry,
|
|
608
|
+
normalizeWindowsPath,
|
|
609
|
+
runInstall,
|
|
610
|
+
runUninstall
|
|
611
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uzqw/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Recolx read-only MCP server (stdio) — 7 read-only tools, readOnlyHint: true",
|
|
5
|
+
"mcpName": "io.github.uzqw/recolx-mcp",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": {
|
|
11
|
+
"recolx-mcp": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": ["dist", "skills"],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
24
|
+
"test": "tsx --test test/*.test.ts"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
28
|
+
"@uzqw/shared": "*",
|
|
29
|
+
"open": "^10.1.0",
|
|
30
|
+
"zod": "^3.23.8"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: recolx-digest
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "Summarize multiple Recolx recordings into a digest. Use when the user says 'weekly report', '周报', 'digest of this month', 'what meetings did I have this week', 'recap of last quarter', or asks to roll up multiple recordings into one overview. This is the Loop Leverage phase: turn a window of recordings into a reusable summary."
|
|
5
|
+
metadata:
|
|
6
|
+
requires:
|
|
7
|
+
bins: []
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# recolx-digest
|
|
11
|
+
|
|
12
|
+
**Read [`recolx-shared`](../recolx-shared/SKILL.md) first.**
|
|
13
|
+
|
|
14
|
+
## When to use
|
|
15
|
+
|
|
16
|
+
- User asks for a roll-up across multiple recordings.
|
|
17
|
+
- Time window is explicit ("this week", "上个月") or implicit ("recap of recent meetings").
|
|
18
|
+
- Scope is "what happened", not "find one specific meeting".
|
|
19
|
+
|
|
20
|
+
## Steps
|
|
21
|
+
|
|
22
|
+
1. **Resolve the window.** Interpret relative phrases against the **current date** (from conversation context, not training cutoff):
|
|
23
|
+
|
|
24
|
+
| User phrase | date_from / date_to |
|
|
25
|
+
|---|---|
|
|
26
|
+
| "today" | today → today |
|
|
27
|
+
| "yesterday" | yesterday → yesterday |
|
|
28
|
+
| "this week" | Monday of this week → today |
|
|
29
|
+
| "last week" | Monday → Sunday of last week |
|
|
30
|
+
| "this month" | 1st of this month → today |
|
|
31
|
+
| "last month" | 1st → last day of previous month |
|
|
32
|
+
|
|
33
|
+
2. **List the corpus.** `list_files` with `date_from` / `date_to`. Cap at 50 recordings — if the window returns more, ask the user to narrow it.
|
|
34
|
+
3. **Fetch notes in batch.** For each recording, call `get_note`. Do **not** call `get_transcript` unless a specific recording merits a deeper pull.
|
|
35
|
+
4. **Synthesize.** Produce a structured digest:
|
|
36
|
+
- **Headline** — one-line theme of the window.
|
|
37
|
+
- **By recording** — one bullet per recording: `• name (date, duration) — one-sentence takeaway`.
|
|
38
|
+
- **Recurring themes** — topics that appeared in ≥ 2 recordings.
|
|
39
|
+
- **Open action items** — aggregated across recordings, deduplicated.
|
|
40
|
+
5. **Cite sources.** Every non-trivial claim must reference the recording it came from, using the file title (not the raw ID unless the user asked).
|
|
41
|
+
|
|
42
|
+
## Budget
|
|
43
|
+
|
|
44
|
+
- Hard cap: 50 `get_note` calls per digest. If the window has more recordings, ask the user to narrow.
|
|
45
|
+
- Skip recordings where `get_note` returns empty — mention them at the end under "未生成摘要" (unsummarized).
|
|
46
|
+
|
|
47
|
+
## Anti-patterns
|
|
48
|
+
|
|
49
|
+
- Do not load transcripts just to pad the digest.
|
|
50
|
+
- Do not synthesize across windows the user didn't ask for ("while we're at it, here's last month too").
|
|
51
|
+
- Do not invent action items that aren't in the notes — only aggregate what's there.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: recolx-export
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "Push Recolx content or a generated artifact to Notion, Slack, Gmail, or a custom webhook. Use when the user says 'save to Notion', '存到 Notion', 'post to Slack', 'send to webhook', or asks to deliver recording content to an external system. This skill calls other MCPs the user has already connected — it never writes its own connectors."
|
|
5
|
+
metadata:
|
|
6
|
+
requires:
|
|
7
|
+
bins: []
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# recolx-export
|
|
11
|
+
|
|
12
|
+
**Read [`recolx-shared`](../recolx-shared/SKILL.md) first.**
|
|
13
|
+
|
|
14
|
+
## When to use
|
|
15
|
+
|
|
16
|
+
- User has a ready artifact (from `recolx-followup`) or recording content and wants to **deliver** it somewhere.
|
|
17
|
+
- Destination is an external system (not the chat).
|
|
18
|
+
|
|
19
|
+
## Out of scope
|
|
20
|
+
|
|
21
|
+
- Generating the artifact — that's `recolx-followup`.
|
|
22
|
+
- Reading recording content — that's `get_note` / `get_transcript`.
|
|
23
|
+
|
|
24
|
+
This skill is the final leg: take content that already exists and send it.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
Recolx itself exposes no `push` tool and stores no destination credentials. Delivery uses an **MCP tool that is already available in the session** — the Notion MCP, Slack MCP, a webhook tool, Gmail send, etc. — whichever the user has connected. We call that tool; we don't build connectors.
|
|
29
|
+
|
|
30
|
+
## Steps
|
|
31
|
+
|
|
32
|
+
1. **Confirm the payload.** Recording summary (raw `get_note` content)? Generated artifact (email, brief — already drafted)? Raw transcript excerpt?
|
|
33
|
+
2. **Confirm the destination + identifiers.** Ask for the exact target. Recolx does not store destination credentials.
|
|
34
|
+
3. **Deliver using the MCP tool or integration available in the user's environment.** If none is available in the session, say so and list what the user would need to connect — do not improvise a delivery channel.
|
|
35
|
+
4. **Report the delivery URL** (Notion page URL, Slack message permalink, webhook HTTP status) back to the user.
|
|
36
|
+
|
|
37
|
+
## Destination identifier cheat-sheet
|
|
38
|
+
|
|
39
|
+
| Destination | Required identifier | Typical ask |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Notion | page ID or database ID | "Which Notion page should this go under?" |
|
|
42
|
+
| Slack | channel name or ID | "Which channel? (e.g., `#sales` or `C0123`)" |
|
|
43
|
+
| Gmail | recipient email(s) | "Who should this email go to?" |
|
|
44
|
+
| Webhook | full URL | "Paste the webhook URL" |
|
|
45
|
+
|
|
46
|
+
## Anti-patterns
|
|
47
|
+
|
|
48
|
+
- Never persist destination credentials in the conversation or in files. Assume the MCP host provides them.
|
|
49
|
+
- Never send to a default destination ("I'll put it in `#general`") — always confirm.
|
|
50
|
+
- Never alter the artifact content during delivery. If Slack needs mrkdwn, convert format without changing meaning.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: recolx-followup
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "Turn a Recolx recording into a follow-up email, thank-you note, action-item list, or meeting brief. Use when the user says 'draft follow-up', 'what were the action items', 'send thank-you email', 'write the recap', '起草跟进邮件', or names an artifact to generate from a recording."
|
|
5
|
+
metadata:
|
|
6
|
+
requires:
|
|
7
|
+
bins: []
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# recolx-followup
|
|
11
|
+
|
|
12
|
+
**Read [`recolx-shared`](../recolx-shared/SKILL.md) first.**
|
|
13
|
+
|
|
14
|
+
## When to use
|
|
15
|
+
|
|
16
|
+
- User wants a **generated document** grounded in one recording.
|
|
17
|
+
- Target format is explicit (email, brief, action-item list) or implicit ("write the follow-up").
|
|
18
|
+
- If the user wants to *send* the output to Notion / Slack / a webhook, chain into `recolx-export` after drafting.
|
|
19
|
+
|
|
20
|
+
## Steps
|
|
21
|
+
|
|
22
|
+
1. **Identify the recording.** If the user didn't name one, ask them to pick from a `list_files` result.
|
|
23
|
+
2. **Fetch source content.**
|
|
24
|
+
- `get_note` first — usually enough for summaries and action items.
|
|
25
|
+
- `get_transcript` only if the artifact needs verbatim quotes or speaker attribution.
|
|
26
|
+
3. **Generate the artifact** in the requested format. Ground every claim in the source; do not invent attendees, dates, decisions, or numbers.
|
|
27
|
+
4. **Present to the user** in the chat, then ask if they want to refine or export.
|
|
28
|
+
|
|
29
|
+
## Artifact templates
|
|
30
|
+
|
|
31
|
+
### Follow-up email
|
|
32
|
+
- To: attendees (from notes if listed).
|
|
33
|
+
- Subject: "Follow-up — {recording title}, {date}".
|
|
34
|
+
- Opening line: thanks + one-line meeting summary.
|
|
35
|
+
- Body: 3–5 bullets of key points.
|
|
36
|
+
- Action items: numbered list with owner and due date if mentioned.
|
|
37
|
+
- Closing: "Let me know if I missed anything."
|
|
38
|
+
|
|
39
|
+
### Thank-you email
|
|
40
|
+
- Short. One paragraph. One concrete thing you learned or appreciated from the call.
|
|
41
|
+
|
|
42
|
+
### Action-item list
|
|
43
|
+
- Plain markdown: `- [ ] {owner}: {item} (due {date})`.
|
|
44
|
+
- Mark owner as `?` if unclear from notes — do not guess.
|
|
45
|
+
|
|
46
|
+
### Meeting brief
|
|
47
|
+
- Attendees, date, duration, decisions, risks, next steps.
|
|
48
|
+
|
|
49
|
+
## Anti-patterns
|
|
50
|
+
|
|
51
|
+
- Never invent email recipients. If attendees weren't captured, ask the user.
|
|
52
|
+
- Never invent due dates. Mark as `due: TBD` if not stated.
|
|
53
|
+
- Do not send the email — this skill drafts. Hand off to `recolx-export` for delivery.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: recolx-shared
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "First read before any Recolx operation. Auth flow, tool inventory, error semantics, output conventions, token refresh. Use when the user mentions Recolx for the first time in a session, or when any other recolx-* skill is invoked."
|
|
5
|
+
metadata:
|
|
6
|
+
requires:
|
|
7
|
+
bins: []
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# recolx-shared
|
|
11
|
+
|
|
12
|
+
**CRITICAL — read this before calling any Recolx tool.** Applies to every other `recolx-*` skill.
|
|
13
|
+
|
|
14
|
+
## Authentication
|
|
15
|
+
|
|
16
|
+
- Recolx uses developer OAuth (PKCE S256). MCP tokens are stored in `~/.recolx/tokens-mcp.json` and refreshed automatically. The terminal CLI (`recolx`) keeps a separate `~/.recolx/tokens-cli.json` — the two never share tokens.
|
|
17
|
+
- If any tool returns an auth error (message includes `401` or `Not authenticated`), call the `login` tool and wait for the browser callback. Do **not** retry the original tool until login returns success.
|
|
18
|
+
- Never ask the user to paste tokens. The `login` tool handles the whole flow (authorize page → local `:8199` callback → token stored).
|
|
19
|
+
- Remote/headless machines need the callback port forwarded first: `ssh -L 8199:localhost:8199 <host>`.
|
|
20
|
+
|
|
21
|
+
## Tool inventory
|
|
22
|
+
|
|
23
|
+
| Tool | Purpose |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `login` | Open browser for OAuth; blocks until callback or 2-min timeout |
|
|
26
|
+
| `logout` | Revoke all tokens server-side and clear local token file |
|
|
27
|
+
| `get_current_user` | Verify who is signed in (nickname, subscription tier / Pro quota) |
|
|
28
|
+
| `list_files` | List recordings; supports `query`, `date_from`, `date_to` (client-side filter) |
|
|
29
|
+
| `get_file` | Single recording detail incl. 24h `presigned_url` audio link + device |
|
|
30
|
+
| `get_note` | AI summary / notes for one recording (by template tab) |
|
|
31
|
+
| `get_transcript` | Timestamped transcript with speaker/language labels, cursor-paginated |
|
|
32
|
+
|
|
33
|
+
All tools are read-only (`readOnlyHint: true`). Some hosts namespace tool names with a connector prefix (e.g. `recolx_list_files`) — resolve tools from the list you were actually given, matching on the canonical suffix (`…list_files`, `…get_note`). Never assume a bare name, never hard-code a prefix.
|
|
34
|
+
|
|
35
|
+
## Error semantics
|
|
36
|
+
|
|
37
|
+
| Pattern in error message | Meaning | What to do |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `401` / `Not authenticated` / 未登录 | Token missing or expired | Call `login`, then retry |
|
|
40
|
+
| `404` | File ID does not exist | Tell the user the ID is wrong; do not retry |
|
|
41
|
+
| `500` | Backend error | Retry once; if still failing treat as not-found |
|
|
42
|
+
| `fetch failed` / `ECONNREFUSED` | Local backend unreachable | Abort; ask user to check the Recolx service |
|
|
43
|
+
|
|
44
|
+
## Output conventions
|
|
45
|
+
|
|
46
|
+
When presenting recordings:
|
|
47
|
+
|
|
48
|
+
- Always show name, date, duration, and file ID — users need the ID to ask follow-ups.
|
|
49
|
+
- Durations are **seconds** in the API: render human-readable `23s`, `5m23s`, `1h05m`. Raw seconds are for logs only.
|
|
50
|
+
- Dates as `YYYY-MM-DD` in local time (`recorded_at` is a Unix timestamp in seconds).
|
|
51
|
+
- Transcripts: preserve `[MM:SS - MM:SS] Speaker: content`. Do not reformat timestamps.
|
|
52
|
+
- Notes (`get_note` returns `summary[]` with `prompt_id` / `prompt_name`): render the `text` as Markdown.
|
|
53
|
+
|
|
54
|
+
## Data model quick reference
|
|
55
|
+
|
|
56
|
+
- `file_id` — string. `title`, `recorded_at` (Unix seconds), `duration` (seconds), `file_size` (bytes), `device: {name, serial, model}`.
|
|
57
|
+
- `get_note` → `[{task_id, text, prompt_id, prompt_name}]`; empty when the note is not generated yet.
|
|
58
|
+
- `get_transcript` → `{language, segments: [{start, end, text, speaker, language}], next_cursor}` — follow `next_cursor` for long recordings; `start`/`end` are seconds.
|
|
59
|
+
- `presigned_url` — audio download link, expires in 24h; re-fetch with `get_file` if stale.
|
|
60
|
+
|
|
61
|
+
## When to load which sibling skill
|
|
62
|
+
|
|
63
|
+
| User intent | Skill to follow |
|
|
64
|
+
|---|---|
|
|
65
|
+
| "List / show my recordings" | `recolx-digest` (roll-up) or plain `list_files` browsing |
|
|
66
|
+
| "Weekly report / 周报 / digest of this month" | `recolx-digest` |
|
|
67
|
+
| "Draft follow-up / action items / thank-you email" | `recolx-followup` |
|
|
68
|
+
| "Save to Notion / Slack / webhook / 存到" | `recolx-export` |
|