@starlens-app/cli 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/package.json +31 -0
- package/src/index.mjs +1159 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@starlens-app/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Starlens CLI — manage your GitHub starred repositories from the terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"stars": "src/index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/index.mjs",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.11.0"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"github",
|
|
18
|
+
"stars",
|
|
19
|
+
"cli",
|
|
20
|
+
"starlens"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"registry": "https://registry.npmjs.org/"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"start": "node src/index.mjs",
|
|
29
|
+
"test": "node --test src/test/*.test.mjs"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { access, chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_API_BASE_URL = "http://localhost:3000";
|
|
11
|
+
// AI 问答链路可能比搜索更慢,但默认仍保持在 30 秒,避免 CLI 长时间无响应。
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30 * 1000;
|
|
13
|
+
const DEFAULT_RETRIES = 1;
|
|
14
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
15
|
+
let cachedCliVersion;
|
|
16
|
+
|
|
17
|
+
const helpText = [
|
|
18
|
+
"Starlens CLI",
|
|
19
|
+
"",
|
|
20
|
+
"Usage:",
|
|
21
|
+
" stars login (--token <token>|--token-stdin) [--token-path <path>] [--format table|json]",
|
|
22
|
+
" stars logout [--token-path <path>] [--format table|json]",
|
|
23
|
+
" stars status [--api-base-url <url>] [--token-path <path>] [--format table|json]",
|
|
24
|
+
" stars sync [--api-base-url <url>] [--token-path <path>] [--timeout-ms <ms>] [--retries <n>] [--format table|json]",
|
|
25
|
+
" stars search <query> [--api-base-url <url>] [--token-path <path>] [--page <n>] [--page-size <n>] [--sort relevance|recent|stars|updated] [--language <value>] [--owner <value>] [--tag <value>] [--favorite true|false] [--format table|json]",
|
|
26
|
+
" stars show <repo-id|owner/repo> [--api-base-url <url>] [--token-path <path>] [--format table|json]",
|
|
27
|
+
" stars open <repo-id|owner/repo> [--api-base-url <url>] [--token-path <path>] [--print]",
|
|
28
|
+
" stars ask <question> [--api-base-url <url>] [--token-path <path>] [--format table|json]",
|
|
29
|
+
" stars favorite <repo-id|owner/repo> [--api-base-url <url>] [--token-path <path>] [--format table|json]",
|
|
30
|
+
" stars unfavorite <repo-id|owner/repo> [--api-base-url <url>] [--token-path <path>] [--format table|json]",
|
|
31
|
+
" stars note <repo-id|owner/repo> (--set <text>|--clear) [--api-base-url <url>] [--token-path <path>] [--format table|json]",
|
|
32
|
+
" stars tag add <repo-id|owner/repo> <tag> [--api-base-url <url>] [--token-path <path>] [--format table|json]",
|
|
33
|
+
" stars tag remove <repo-id|owner/repo> <tag> [--api-base-url <url>] [--token-path <path>] [--format table|json]",
|
|
34
|
+
" stars install-skill [--api-base-url <url>] [--token <token>] [--client claude|cursor|codex|opencode|other]",
|
|
35
|
+
" stars version",
|
|
36
|
+
"",
|
|
37
|
+
"Configuration:",
|
|
38
|
+
" --api-base-url, STARLENS_API_BASE_URL API base URL (default: http://localhost:3000)",
|
|
39
|
+
" --token-path, STARLENS_TOKEN_PATH Bearer token storage path (default: ~/.config/starlens/token)",
|
|
40
|
+
" --timeout-ms, STARLENS_TIMEOUT_MS API request timeout in milliseconds (default: 30000)",
|
|
41
|
+
" --retries, STARLENS_RETRIES Retry count for transient API failures (default: 1)",
|
|
42
|
+
" --format, STARLENS_FORMAT Output format: table or json (default: table)",
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
class CliError extends Error {
|
|
46
|
+
constructor(message, exitCode = 1, details = {}) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "CliError";
|
|
49
|
+
this.exitCode = exitCode;
|
|
50
|
+
Object.assign(this, details);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function defaultTokenPath() {
|
|
55
|
+
return join(homedir(), ".config", "starlens", "token");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseNumber(value, fallback, name, { min = 0 } = {}) {
|
|
59
|
+
if (value === undefined || value === "") return fallback;
|
|
60
|
+
const number = Number(value);
|
|
61
|
+
if (!Number.isFinite(number) || !Number.isInteger(number) || number < min) {
|
|
62
|
+
throw new CliError(`${name} must be an integer greater than or equal to ${min}.`);
|
|
63
|
+
}
|
|
64
|
+
return number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readOption(args, name) {
|
|
68
|
+
const values = [];
|
|
69
|
+
const rest = [];
|
|
70
|
+
|
|
71
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
72
|
+
const arg = args[index];
|
|
73
|
+
if (arg === name) {
|
|
74
|
+
const value = args[index + 1];
|
|
75
|
+
if (!value || value.startsWith("--")) {
|
|
76
|
+
throw new CliError(`${name} requires a value.`);
|
|
77
|
+
}
|
|
78
|
+
values.push(value);
|
|
79
|
+
index += 1;
|
|
80
|
+
} else if (arg.startsWith(`${name}=`)) {
|
|
81
|
+
values.push(arg.slice(name.length + 1));
|
|
82
|
+
} else {
|
|
83
|
+
rest.push(arg);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { value: values.at(-1), rest };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readFlag(args, name) {
|
|
91
|
+
let found = false;
|
|
92
|
+
const rest = [];
|
|
93
|
+
|
|
94
|
+
for (const arg of args) {
|
|
95
|
+
if (arg === name) {
|
|
96
|
+
found = true;
|
|
97
|
+
} else if (arg.startsWith(`${name}=`)) {
|
|
98
|
+
throw new CliError(`${name} does not take a value.`);
|
|
99
|
+
} else {
|
|
100
|
+
rest.push(arg);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { found, rest };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseGlobalOptions(args, env = process.env) {
|
|
108
|
+
let rest = [...args];
|
|
109
|
+
const option = (name) => {
|
|
110
|
+
const parsed = readOption(rest, name);
|
|
111
|
+
rest = parsed.rest;
|
|
112
|
+
return parsed.value;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const format = option("--format") ?? env.STARLENS_FORMAT ?? "table";
|
|
116
|
+
if (!["table", "json"].includes(format)) {
|
|
117
|
+
throw new CliError("--format must be either table or json.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const apiBaseUrl = (option("--api-base-url") ?? env.STARLENS_API_BASE_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
121
|
+
const tokenPath = option("--token-path") ?? env.STARLENS_TOKEN_PATH ?? defaultTokenPath();
|
|
122
|
+
const timeoutMs = parseNumber(option("--timeout-ms") ?? env.STARLENS_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, "--timeout-ms", { min: 1 });
|
|
123
|
+
const retries = parseNumber(option("--retries") ?? env.STARLENS_RETRIES, DEFAULT_RETRIES, "--retries", { min: 0 });
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
args: rest,
|
|
127
|
+
config: {
|
|
128
|
+
apiBaseUrl,
|
|
129
|
+
tokenPath,
|
|
130
|
+
timeoutMs,
|
|
131
|
+
retries,
|
|
132
|
+
format,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function readToken(tokenPath) {
|
|
138
|
+
try {
|
|
139
|
+
const token = (await readFile(tokenPath, "utf8")).trim();
|
|
140
|
+
if (!token) throw new CliError(`No token found at ${tokenPath}. Run: stars login --token <token>`);
|
|
141
|
+
return token;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof CliError) throw error;
|
|
144
|
+
if (error?.code === "ENOENT") {
|
|
145
|
+
throw new CliError(`No token found at ${tokenPath}. Run: stars login --token <token>`);
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function hasToken(tokenPath) {
|
|
152
|
+
try {
|
|
153
|
+
const token = (await readFile(tokenPath, "utf8")).trim();
|
|
154
|
+
return Boolean(token);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error?.code === "ENOENT") return false;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function saveToken(tokenPath, token) {
|
|
162
|
+
await mkdir(dirname(tokenPath), { recursive: true });
|
|
163
|
+
await writeFile(tokenPath, `${token.trim()}\n`, { mode: 0o600 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function deleteToken(tokenPath) {
|
|
167
|
+
await rm(tokenPath, { force: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readStdin() {
|
|
171
|
+
let input = "";
|
|
172
|
+
process.stdin.setEncoding("utf8");
|
|
173
|
+
for await (const chunk of process.stdin) {
|
|
174
|
+
input += chunk;
|
|
175
|
+
}
|
|
176
|
+
return input.trim();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function getCliVersion() {
|
|
180
|
+
if (cachedCliVersion) {
|
|
181
|
+
return cachedCliVersion;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const packageJsonPath = new URL("../package.json", import.meta.url);
|
|
185
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
186
|
+
cachedCliVersion = packageJson.version ?? "0.0.0";
|
|
187
|
+
return cachedCliVersion;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseApiPayload(payload) {
|
|
191
|
+
if (!payload || typeof payload !== "object") {
|
|
192
|
+
throw new CliError("API returned an invalid JSON response.");
|
|
193
|
+
}
|
|
194
|
+
if (payload.ok === true) return payload.data;
|
|
195
|
+
const message = payload.error?.message ?? "API request failed.";
|
|
196
|
+
throw new CliError(message);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function authErrorMessage(status, payload) {
|
|
200
|
+
const apiMessage = payload?.error?.message;
|
|
201
|
+
if (status === 401) {
|
|
202
|
+
return `${apiMessage ?? "Authentication is required."} Run 'stars login --token <token>' with a valid token.`;
|
|
203
|
+
}
|
|
204
|
+
if (status === 403) {
|
|
205
|
+
return `${apiMessage ?? "Access forbidden."} Check that your token has permission for this operation.`;
|
|
206
|
+
}
|
|
207
|
+
if (status === 429) {
|
|
208
|
+
return `${apiMessage ?? "Rate limit exceeded."} Please wait and retry later, or reduce request frequency.`;
|
|
209
|
+
}
|
|
210
|
+
return apiMessage ?? `API request failed with status ${status}.`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
219
|
+
} finally {
|
|
220
|
+
clearTimeout(timeout);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function apiRequest(path, { method = "GET", query, body, config }) {
|
|
225
|
+
const token = await readToken(config.tokenPath);
|
|
226
|
+
const url = new URL(path, `${config.apiBaseUrl}/`);
|
|
227
|
+
if (query) {
|
|
228
|
+
for (const [key, value] of Object.entries(query)) {
|
|
229
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
230
|
+
url.searchParams.set(key, String(value));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let lastError;
|
|
236
|
+
for (let attempt = 0; attempt <= config.retries; attempt += 1) {
|
|
237
|
+
try {
|
|
238
|
+
const hasBody = body !== undefined || method === "POST" || method === "PATCH";
|
|
239
|
+
const response = await fetchWithTimeout(
|
|
240
|
+
url,
|
|
241
|
+
{
|
|
242
|
+
method,
|
|
243
|
+
headers: {
|
|
244
|
+
Accept: "application/json",
|
|
245
|
+
Authorization: `Bearer ${token}`,
|
|
246
|
+
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
|
247
|
+
},
|
|
248
|
+
...(hasBody ? { body: JSON.stringify(body ?? {}) } : {}),
|
|
249
|
+
},
|
|
250
|
+
config.timeoutMs,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const text = await response.text();
|
|
254
|
+
let payload;
|
|
255
|
+
try {
|
|
256
|
+
payload = text ? JSON.parse(text) : undefined;
|
|
257
|
+
} catch {
|
|
258
|
+
throw new CliError("API returned an invalid JSON response.");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (response.status === 401 || response.status === 403 || response.status === 429) {
|
|
262
|
+
throw new CliError(authErrorMessage(response.status, payload), 1, { status: response.status });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!response.ok) {
|
|
266
|
+
throw new CliError(authErrorMessage(response.status, payload), 1, {
|
|
267
|
+
status: response.status,
|
|
268
|
+
apiCode: payload?.error?.code,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return parseApiPayload(payload);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
lastError = error;
|
|
275
|
+
const canRetry = !(error instanceof CliError) || /^API request failed with status 5\d\d\./.test(error.message);
|
|
276
|
+
if (!canRetry || attempt === config.retries) break;
|
|
277
|
+
await delay(Math.min(250 * 2 ** attempt, 2_000));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (lastError?.name === "AbortError") {
|
|
282
|
+
throw new CliError(`API request timed out after ${config.timeoutMs}ms.`);
|
|
283
|
+
}
|
|
284
|
+
throw lastError;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function outputJson(value) {
|
|
288
|
+
console.log(JSON.stringify(value, null, 2));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function truncate(value, width) {
|
|
292
|
+
const text = String(value ?? "");
|
|
293
|
+
return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function printTable(rows, columns) {
|
|
297
|
+
if (rows.length === 0) {
|
|
298
|
+
console.log("No results.");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const widths = columns.map((column) =>
|
|
303
|
+
Math.min(
|
|
304
|
+
column.maxWidth ?? 32,
|
|
305
|
+
Math.max(column.label.length, ...rows.map((row) => String(row[column.key] ?? "").length)),
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
const line = (row) =>
|
|
309
|
+
columns
|
|
310
|
+
.map((column, index) => truncate(row[column.key] ?? "", widths[index]).padEnd(widths[index]))
|
|
311
|
+
.join(" ")
|
|
312
|
+
.trimEnd();
|
|
313
|
+
|
|
314
|
+
console.log(line(Object.fromEntries(columns.map((column) => [column.key, column.label]))));
|
|
315
|
+
console.log(widths.map((width) => "-".repeat(width)).join(" "));
|
|
316
|
+
for (const row of rows) console.log(line(row));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function renderLogin({ tokenPath }, format) {
|
|
320
|
+
const data = { status: "logged_in", tokenPath };
|
|
321
|
+
if (format === "json") return outputJson(data);
|
|
322
|
+
console.log(`Logged in. Token saved to ${tokenPath}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function renderLogout({ tokenPath }, format) {
|
|
326
|
+
const data = { status: "logged_out", tokenPath };
|
|
327
|
+
if (format === "json") return outputJson(data);
|
|
328
|
+
console.log(`Logged out. Token removed from ${tokenPath}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function renderStatus(data, format) {
|
|
332
|
+
if (format === "json") return outputJson(data);
|
|
333
|
+
printTable(
|
|
334
|
+
[
|
|
335
|
+
{ field: "API base URL", value: data.apiBaseUrl },
|
|
336
|
+
{ field: "Token path", value: data.tokenPath },
|
|
337
|
+
{ field: "Token configured", value: data.tokenConfigured ? "yes" : "no" },
|
|
338
|
+
],
|
|
339
|
+
[
|
|
340
|
+
{ key: "field", label: "Field", maxWidth: 24 },
|
|
341
|
+
{ key: "value", label: "Value", maxWidth: 96 },
|
|
342
|
+
],
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function renderSync(data, format) {
|
|
347
|
+
if (format === "json") return outputJson(data);
|
|
348
|
+
printTable(
|
|
349
|
+
[
|
|
350
|
+
{
|
|
351
|
+
status: data.status ?? "started",
|
|
352
|
+
startedAt: data.startedAt ?? "",
|
|
353
|
+
finishedAt: data.finishedAt ?? "",
|
|
354
|
+
fetched: data.counts?.fetched ?? "",
|
|
355
|
+
insertedOrUpdated: data.counts?.insertedOrUpdated ?? "",
|
|
356
|
+
unstarred: data.counts?.unstarred ?? "",
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
[
|
|
360
|
+
{ key: "status", label: "Status" },
|
|
361
|
+
{ key: "startedAt", label: "Started", maxWidth: 28 },
|
|
362
|
+
{ key: "finishedAt", label: "Finished", maxWidth: 28 },
|
|
363
|
+
{ key: "fetched", label: "Fetched" },
|
|
364
|
+
{ key: "insertedOrUpdated", label: "Upserted" },
|
|
365
|
+
{ key: "unstarred", label: "Unstarred" },
|
|
366
|
+
],
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function renderSearch(data, format) {
|
|
371
|
+
if (format === "json") return outputJson(data);
|
|
372
|
+
printTable(
|
|
373
|
+
(data.items ?? []).map((repo) => ({
|
|
374
|
+
fullName: repo.fullName,
|
|
375
|
+
language: repo.language ?? "",
|
|
376
|
+
stars: repo.stargazersCount ?? 0,
|
|
377
|
+
favorite: repo.isFavorite ? "yes" : "no",
|
|
378
|
+
tags: (repo.tags ?? []).join(","),
|
|
379
|
+
summary: repo.repoSummary || repo.description || "",
|
|
380
|
+
})),
|
|
381
|
+
[
|
|
382
|
+
{ key: "fullName", label: "Repository", maxWidth: 32 },
|
|
383
|
+
{ key: "language", label: "Language", maxWidth: 16 },
|
|
384
|
+
{ key: "stars", label: "Stars", maxWidth: 10 },
|
|
385
|
+
{ key: "favorite", label: "Favorite", maxWidth: 10 },
|
|
386
|
+
{ key: "tags", label: "Tags", maxWidth: 20 },
|
|
387
|
+
{ key: "summary", label: "Summary", maxWidth: 56 },
|
|
388
|
+
],
|
|
389
|
+
);
|
|
390
|
+
console.log(`\nPage ${data.page ?? 1} · ${data.total ?? 0} total · hasMore=${Boolean(data.hasMore)}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function renderRepo(repo, format) {
|
|
394
|
+
if (format === "json") return outputJson(repo);
|
|
395
|
+
printTable(
|
|
396
|
+
[
|
|
397
|
+
{ field: "Repository", value: repo.fullName ?? "" },
|
|
398
|
+
{ field: "Language", value: repo.language ?? "" },
|
|
399
|
+
{ field: "Stars", value: repo.stargazersCount ?? 0 },
|
|
400
|
+
{ field: "Favorite", value: repo.isFavorite ? "yes" : "no" },
|
|
401
|
+
{ field: "Tags", value: (repo.tags ?? []).join(", ") },
|
|
402
|
+
{ field: "Summary", value: repo.repoSummary || repo.description || "" },
|
|
403
|
+
{ field: "Note", value: repo.note ?? "" },
|
|
404
|
+
{ field: "URL", value: repo.htmlUrl ?? "" },
|
|
405
|
+
],
|
|
406
|
+
[
|
|
407
|
+
{ key: "field", label: "Field", maxWidth: 16 },
|
|
408
|
+
{ key: "value", label: "Value", maxWidth: 96 },
|
|
409
|
+
],
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function renderAsk(data, format) {
|
|
414
|
+
if (format === "json") return outputJson(data);
|
|
415
|
+
console.log(data.answer ?? "No answer.");
|
|
416
|
+
const candidates = data.candidates ?? data.matches ?? [];
|
|
417
|
+
if (candidates.length > 0) {
|
|
418
|
+
console.log("");
|
|
419
|
+
const hasReason = candidates.some((item) => typeof item.reason === "string" && item.reason.trim() !== "");
|
|
420
|
+
const rows = candidates.map((item) => ({
|
|
421
|
+
repo: item.fullName ?? item.repoId ?? item.id ?? "",
|
|
422
|
+
reason: item.reason ?? "",
|
|
423
|
+
}));
|
|
424
|
+
const columns = hasReason
|
|
425
|
+
? [
|
|
426
|
+
{ key: "repo", label: "Repository", maxWidth: 48 },
|
|
427
|
+
{ key: "reason", label: "Reason", maxWidth: 72 },
|
|
428
|
+
]
|
|
429
|
+
: [{ key: "repo", label: "Repository", maxWidth: 48 }];
|
|
430
|
+
printTable(rows, columns);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function renderTags(data, format) {
|
|
435
|
+
if (format === "json") return outputJson(data);
|
|
436
|
+
printTable(
|
|
437
|
+
(data.tags ?? []).map((tag) => ({ tag })),
|
|
438
|
+
[{ key: "tag", label: "Tag", maxWidth: 48 }],
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function searchOptions(args) {
|
|
443
|
+
let rest = [...args];
|
|
444
|
+
const option = (name) => {
|
|
445
|
+
const parsed = readOption(rest, name);
|
|
446
|
+
rest = parsed.rest;
|
|
447
|
+
return parsed.value;
|
|
448
|
+
};
|
|
449
|
+
return {
|
|
450
|
+
args: rest,
|
|
451
|
+
query: {
|
|
452
|
+
page: option("--page"),
|
|
453
|
+
pageSize: option("--page-size") ?? DEFAULT_PAGE_SIZE,
|
|
454
|
+
sort: option("--sort"),
|
|
455
|
+
language: option("--language"),
|
|
456
|
+
owner: option("--owner"),
|
|
457
|
+
tag: option("--tag"),
|
|
458
|
+
favorite: option("--favorite"),
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function resolveRepo(repoOrId, config) {
|
|
464
|
+
try {
|
|
465
|
+
return await apiRequest(`/api/repos/${encodeURIComponent(repoOrId)}`, { config });
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (!(error instanceof CliError) || error.status !== 404) {
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const result = await apiRequest("/api/search", {
|
|
473
|
+
config,
|
|
474
|
+
query: { q: repoOrId, page: 1, pageSize: 10, sort: "relevance" },
|
|
475
|
+
});
|
|
476
|
+
const normalized = repoOrId.toLowerCase();
|
|
477
|
+
const exact = (result.items ?? []).find((repo) => repo.fullName?.toLowerCase() === normalized || repo.id === repoOrId);
|
|
478
|
+
const fallback = exact ?? (result.items?.length === 1 ? result.items[0] : null);
|
|
479
|
+
|
|
480
|
+
if (!fallback) {
|
|
481
|
+
throw new CliError(`Repository was not found: ${repoOrId}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return fallback;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function patchRepoCuration(repoOrId, updates, config) {
|
|
488
|
+
try {
|
|
489
|
+
return await apiRequest(`/api/repos/${encodeURIComponent(repoOrId)}`, {
|
|
490
|
+
method: "PATCH",
|
|
491
|
+
body: updates,
|
|
492
|
+
config,
|
|
493
|
+
});
|
|
494
|
+
} catch (error) {
|
|
495
|
+
if (!(error instanceof CliError) || error.status !== 404) {
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const repo = await resolveRepo(repoOrId, config);
|
|
501
|
+
return apiRequest(`/api/repos/${encodeURIComponent(repo.id)}`, {
|
|
502
|
+
method: "PATCH",
|
|
503
|
+
body: updates,
|
|
504
|
+
config,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function addTag(repoOrId, tag, config) {
|
|
509
|
+
try {
|
|
510
|
+
return await apiRequest(`/api/repos/${encodeURIComponent(repoOrId)}/tags`, {
|
|
511
|
+
method: "POST",
|
|
512
|
+
body: { tag },
|
|
513
|
+
config,
|
|
514
|
+
});
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (!(error instanceof CliError) || error.status !== 404) {
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const repo = await resolveRepo(repoOrId, config);
|
|
522
|
+
return apiRequest(`/api/repos/${encodeURIComponent(repo.id)}/tags`, {
|
|
523
|
+
method: "POST",
|
|
524
|
+
body: { tag },
|
|
525
|
+
config,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function removeTag(repoOrId, tag, config) {
|
|
530
|
+
try {
|
|
531
|
+
return await apiRequest(`/api/repos/${encodeURIComponent(repoOrId)}/tags/${encodeURIComponent(tag)}`, {
|
|
532
|
+
method: "DELETE",
|
|
533
|
+
config,
|
|
534
|
+
});
|
|
535
|
+
} catch (error) {
|
|
536
|
+
if (!(error instanceof CliError) || error.status !== 404) {
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const repo = await resolveRepo(repoOrId, config);
|
|
542
|
+
return apiRequest(`/api/repos/${encodeURIComponent(repo.id)}/tags/${encodeURIComponent(tag)}`, {
|
|
543
|
+
method: "DELETE",
|
|
544
|
+
config,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function openUrl(url) {
|
|
549
|
+
const commands = {
|
|
550
|
+
darwin: ["open", [url]],
|
|
551
|
+
win32: ["cmd", ["/c", "start", "", url]],
|
|
552
|
+
linux: ["xdg-open", [url]],
|
|
553
|
+
};
|
|
554
|
+
const [command, args] = commands[process.platform] ?? commands.linux;
|
|
555
|
+
|
|
556
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
557
|
+
const child = spawn(command, args, { stdio: "ignore" });
|
|
558
|
+
child.on("error", () => rejectOpen(new CliError(`Could not open URL automatically. Open it manually: ${url}`)));
|
|
559
|
+
child.on("close", (code) => {
|
|
560
|
+
if (code && code !== 0) {
|
|
561
|
+
rejectOpen(new CliError(`Could not open URL automatically. Open it manually: ${url}`));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
resolveOpen();
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
570
|
+
const { args, config } = parseGlobalOptions(argv, env);
|
|
571
|
+
const command = args[0];
|
|
572
|
+
|
|
573
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
574
|
+
console.log(helpText);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
579
|
+
console.log(await getCliVersion());
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (command === "login") {
|
|
584
|
+
const tokenOption = readOption(args.slice(1), "--token");
|
|
585
|
+
const stdinOption = readFlag(tokenOption.rest, "--token-stdin");
|
|
586
|
+
if (tokenOption.value && stdinOption.found) {
|
|
587
|
+
throw new CliError("login accepts either --token <token> or --token-stdin, not both.");
|
|
588
|
+
}
|
|
589
|
+
const token = tokenOption.value ?? (stdinOption.found ? await readStdin() : "");
|
|
590
|
+
if (!token) throw new CliError("login requires --token <token> or --token-stdin.");
|
|
591
|
+
if (stdinOption.rest.length > 0) throw new CliError(`Unknown login arguments: ${stdinOption.rest.join(" ")}`);
|
|
592
|
+
await saveToken(config.tokenPath, token);
|
|
593
|
+
renderLogin({ tokenPath: config.tokenPath }, config.format);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (command === "logout") {
|
|
598
|
+
if (args.length > 1) throw new CliError(`Unknown logout arguments: ${args.slice(1).join(" ")}`);
|
|
599
|
+
await deleteToken(config.tokenPath);
|
|
600
|
+
renderLogout({ tokenPath: config.tokenPath }, config.format);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (command === "status") {
|
|
605
|
+
if (args.length > 1) throw new CliError(`Unknown status arguments: ${args.slice(1).join(" ")}`);
|
|
606
|
+
renderStatus({
|
|
607
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
608
|
+
tokenPath: config.tokenPath,
|
|
609
|
+
tokenConfigured: await hasToken(config.tokenPath),
|
|
610
|
+
}, config.format);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (command === "sync") {
|
|
615
|
+
if (args.length > 1) throw new CliError(`Unknown sync arguments: ${args.slice(1).join(" ")}`);
|
|
616
|
+
renderSync(await apiRequest("/api/sync", { method: "POST", config }), config.format);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (command === "search") {
|
|
621
|
+
const parsed = searchOptions(args.slice(1));
|
|
622
|
+
const queryText = parsed.args.join(" ").trim();
|
|
623
|
+
if (!queryText) throw new CliError("search requires a query.");
|
|
624
|
+
renderSearch(await apiRequest("/api/search", { config, query: { ...parsed.query, q: queryText } }), config.format);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (command === "show") {
|
|
629
|
+
const repoOrId = args.slice(1).join(" ").trim();
|
|
630
|
+
if (!repoOrId) throw new CliError("show requires a repository id or owner/repo.");
|
|
631
|
+
renderRepo(await resolveRepo(repoOrId, config), config.format);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (command === "open") {
|
|
636
|
+
const { found: printOnly, rest } = readFlag(args.slice(1), "--print");
|
|
637
|
+
const repoOrId = rest.join(" ").trim();
|
|
638
|
+
if (!repoOrId) throw new CliError("open requires a repository id or owner/repo.");
|
|
639
|
+
const repo = await resolveRepo(repoOrId, config);
|
|
640
|
+
if (!repo.htmlUrl) throw new CliError(`Repository has no URL: ${repo.fullName ?? repoOrId}`);
|
|
641
|
+
console.log(repo.htmlUrl);
|
|
642
|
+
if (!printOnly) {
|
|
643
|
+
await openUrl(repo.htmlUrl);
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (command === "ask") {
|
|
649
|
+
const question = args.slice(1).join(" ").trim();
|
|
650
|
+
if (!question) throw new CliError("ask requires a question.");
|
|
651
|
+
renderAsk(await apiRequest("/api/ai/ask", { method: "POST", config, body: { question } }), config.format);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (command === "favorite" || command === "unfavorite") {
|
|
656
|
+
const repoOrId = args.slice(1).join(" ").trim();
|
|
657
|
+
if (!repoOrId) throw new CliError(`${command} requires a repository id or owner/repo.`);
|
|
658
|
+
renderRepo(
|
|
659
|
+
await patchRepoCuration(repoOrId, { isFavorite: command === "favorite" }, config),
|
|
660
|
+
config.format,
|
|
661
|
+
);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (command === "note") {
|
|
666
|
+
let rest = args.slice(1);
|
|
667
|
+
const setOption = readOption(rest, "--set");
|
|
668
|
+
rest = setOption.rest;
|
|
669
|
+
const clearOption = readFlag(rest, "--clear");
|
|
670
|
+
rest = clearOption.rest;
|
|
671
|
+
if (setOption.value !== undefined && clearOption.found) {
|
|
672
|
+
throw new CliError("note accepts either --set <text> or --clear, not both.");
|
|
673
|
+
}
|
|
674
|
+
if (setOption.value === undefined && !clearOption.found) {
|
|
675
|
+
throw new CliError("note requires --set <text> or --clear.");
|
|
676
|
+
}
|
|
677
|
+
const repoOrId = rest.join(" ").trim();
|
|
678
|
+
if (!repoOrId) throw new CliError("note requires a repository id or owner/repo.");
|
|
679
|
+
renderRepo(
|
|
680
|
+
await patchRepoCuration(repoOrId, { note: clearOption.found ? "" : setOption.value }, config),
|
|
681
|
+
config.format,
|
|
682
|
+
);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (command === "tag") {
|
|
687
|
+
const action = args[1];
|
|
688
|
+
if (!["add", "remove"].includes(action)) {
|
|
689
|
+
throw new CliError("tag requires add or remove.");
|
|
690
|
+
}
|
|
691
|
+
const tag = args.at(-1)?.trim();
|
|
692
|
+
const repoOrId = args.slice(2, -1).join(" ").trim();
|
|
693
|
+
if (!repoOrId || !tag) {
|
|
694
|
+
throw new CliError(`tag ${action} requires a repository id or owner/repo and a tag.`);
|
|
695
|
+
}
|
|
696
|
+
const data = action === "add"
|
|
697
|
+
? await addTag(repoOrId, tag, config)
|
|
698
|
+
: await removeTag(repoOrId, tag, config);
|
|
699
|
+
renderTags(data, config.format);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (command === "install-skill" || command === "setup") {
|
|
704
|
+
await runInstallSkillWizard(args.slice(1), env);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
throw new CliError(`Unknown command: ${command}\n\n${helpText}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── install-skill wizard ──────────────────────────────────────────────────────
|
|
712
|
+
|
|
713
|
+
function detectProjectRoot() {
|
|
714
|
+
// apps/cli/src/index.mjs → up 3 levels = project root
|
|
715
|
+
return new URL("../../..", import.meta.url).pathname.replace(/\/$/, "");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function createReadlineInterface() {
|
|
719
|
+
return createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function wizardPrompt(rl, question, defaultValue) {
|
|
723
|
+
const hint = defaultValue ? ` [${defaultValue}]` : "";
|
|
724
|
+
return new Promise((resolve) => {
|
|
725
|
+
rl.question(`${question}${hint}: `, (answer) => {
|
|
726
|
+
const trimmed = answer.trim();
|
|
727
|
+
resolve(trimmed || defaultValue || "");
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function wizardPromptSecret(question) {
|
|
733
|
+
return new Promise((resolve) => {
|
|
734
|
+
process.stdout.write(`${question}: `);
|
|
735
|
+
const stdin = process.stdin;
|
|
736
|
+
const wasRaw = stdin.isRaw;
|
|
737
|
+
let input = "";
|
|
738
|
+
|
|
739
|
+
if (typeof stdin.setRawMode === "function") {
|
|
740
|
+
stdin.setRawMode(true);
|
|
741
|
+
stdin.resume();
|
|
742
|
+
stdin.setEncoding("utf8");
|
|
743
|
+
|
|
744
|
+
const onData = (char) => {
|
|
745
|
+
if (char === "\r" || char === "\n") {
|
|
746
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
747
|
+
stdin.removeListener("data", onData);
|
|
748
|
+
process.stdout.write("\n");
|
|
749
|
+
resolve(input.trim());
|
|
750
|
+
} else if (char === "") {
|
|
751
|
+
process.stdout.write("\n");
|
|
752
|
+
process.exit(1);
|
|
753
|
+
} else if (char === "" || char === "\b") {
|
|
754
|
+
if (input.length > 0) input = input.slice(0, -1);
|
|
755
|
+
} else {
|
|
756
|
+
input += char;
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
stdin.on("data", onData);
|
|
761
|
+
} else {
|
|
762
|
+
// Non-TTY fallback: readline without echo hiding
|
|
763
|
+
const rl = createReadlineInterface();
|
|
764
|
+
rl.question("", (answer) => {
|
|
765
|
+
rl.close();
|
|
766
|
+
process.stdout.write("\n");
|
|
767
|
+
resolve(answer.trim());
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function buildMcpArgs(projectRoot) {
|
|
774
|
+
return ["-lc", `source "$HOME/.starlens/agent.env" && cd "${projectRoot}" && corepack pnpm mcp:start`];
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function renderClaudeCodeSnippet(projectRoot) {
|
|
778
|
+
const mcpJson = JSON.stringify(
|
|
779
|
+
{
|
|
780
|
+
type: "stdio",
|
|
781
|
+
command: "zsh",
|
|
782
|
+
args: buildMcpArgs(projectRoot),
|
|
783
|
+
},
|
|
784
|
+
null,
|
|
785
|
+
2,
|
|
786
|
+
);
|
|
787
|
+
return `claude mcp add-json starlens '${mcpJson}'`;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function renderCursorSnippet(projectRoot) {
|
|
791
|
+
return JSON.stringify(
|
|
792
|
+
{
|
|
793
|
+
mcpServers: {
|
|
794
|
+
starlens: {
|
|
795
|
+
command: "corepack",
|
|
796
|
+
args: ["pnpm", "mcp:start"],
|
|
797
|
+
cwd: projectRoot,
|
|
798
|
+
env: {
|
|
799
|
+
STARLENS_TOKEN: "(从 ~/.starlens/agent.env 读取)",
|
|
800
|
+
STARLENS_API_BASE_URL: "(从 ~/.starlens/agent.env 读取)",
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
null,
|
|
806
|
+
2,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function renderCodexSnippet(projectRoot) {
|
|
811
|
+
return `[mcp_servers.starlens]
|
|
812
|
+
type = "stdio"
|
|
813
|
+
command = "zsh"
|
|
814
|
+
args = ["-lc", "source \\"$HOME/.starlens/agent.env\\" && cd \\"${projectRoot}\\" && corepack pnpm mcp:start"]
|
|
815
|
+
startup_timeout_sec = 30
|
|
816
|
+
default_tools_approval_mode = "approve"`;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function renderOpencodeSnippet(projectRoot) {
|
|
820
|
+
return JSON.stringify(
|
|
821
|
+
{
|
|
822
|
+
mcp: {
|
|
823
|
+
starlens: {
|
|
824
|
+
type: "local",
|
|
825
|
+
command: ["zsh", "-lc", `source "$HOME/.starlens/agent.env" && cd "${projectRoot}" && corepack pnpm mcp:start`],
|
|
826
|
+
enabled: true,
|
|
827
|
+
timeout: 10000,
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
null,
|
|
832
|
+
2,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function spawnCommand(command, args) {
|
|
837
|
+
return new Promise((resolve) => {
|
|
838
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
839
|
+
child.on("error", () => resolve(false));
|
|
840
|
+
child.on("close", (code) => resolve(code === 0));
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const HOSTED_MCP_BASE_URL = "https://starlens.520ai.xin";
|
|
845
|
+
|
|
846
|
+
function isHostedUrl(url) {
|
|
847
|
+
try {
|
|
848
|
+
const { hostname } = new URL(url);
|
|
849
|
+
return hostname !== "localhost" && hostname !== "127.0.0.1" && !hostname.startsWith("192.168.");
|
|
850
|
+
} catch {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function renderHostedClaudeSnippet(apiBaseUrl, token) {
|
|
856
|
+
return `claude mcp add-json starlens '${JSON.stringify({
|
|
857
|
+
type: "http",
|
|
858
|
+
url: `${apiBaseUrl}/mcp`,
|
|
859
|
+
headers: { Authorization: `Bearer ${token || "stl_xxx"}` },
|
|
860
|
+
}, null, 2)}'`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function renderHostedCursorSnippet(apiBaseUrl, token) {
|
|
864
|
+
return JSON.stringify(
|
|
865
|
+
{
|
|
866
|
+
mcpServers: {
|
|
867
|
+
starlens: {
|
|
868
|
+
url: `${apiBaseUrl}/mcp`,
|
|
869
|
+
headers: { Authorization: `Bearer ${token || "stl_xxx"}` },
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
null,
|
|
874
|
+
2,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function renderHostedCodexSnippet(apiBaseUrl, token) {
|
|
879
|
+
return `[mcp_servers.starlens]
|
|
880
|
+
type = "http"
|
|
881
|
+
url = "${apiBaseUrl}/mcp"
|
|
882
|
+
|
|
883
|
+
[mcp_servers.starlens.headers]
|
|
884
|
+
Authorization = "Bearer ${token || "stl_xxx"}"
|
|
885
|
+
startup_timeout_sec = 30
|
|
886
|
+
default_tools_approval_mode = "approve"`;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function renderHostedOpencodeSnippet(apiBaseUrl, token) {
|
|
890
|
+
return JSON.stringify(
|
|
891
|
+
{
|
|
892
|
+
mcp: {
|
|
893
|
+
starlens: {
|
|
894
|
+
type: "http",
|
|
895
|
+
url: `${apiBaseUrl}/mcp`,
|
|
896
|
+
headers: { Authorization: `Bearer ${token || "stl_xxx"}` },
|
|
897
|
+
enabled: true,
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
null,
|
|
902
|
+
2,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function runInstallSkillWizard(args, env) {
|
|
907
|
+
let rest = [...args];
|
|
908
|
+
|
|
909
|
+
const apiBaseUrlArg = readOption(rest, "--api-base-url");
|
|
910
|
+
rest = apiBaseUrlArg.rest;
|
|
911
|
+
const tokenArg = readOption(rest, "--token");
|
|
912
|
+
rest = tokenArg.rest;
|
|
913
|
+
const clientArg = readOption(rest, "--client");
|
|
914
|
+
rest = clientArg.rest;
|
|
915
|
+
|
|
916
|
+
console.log("");
|
|
917
|
+
console.log("Starlens MCP 安装向导");
|
|
918
|
+
console.log("═".repeat(40));
|
|
919
|
+
console.log("本向导将引导你完成 MCP Server 接入配置。");
|
|
920
|
+
console.log("");
|
|
921
|
+
|
|
922
|
+
// Step 0: check global install
|
|
923
|
+
const isGlobalInstall = !process.argv[1]?.includes("apps/cli");
|
|
924
|
+
if (!isGlobalInstall) {
|
|
925
|
+
console.log("提示:你正在从源码运行。如需让其他工具通过 `stars` 命令使用,");
|
|
926
|
+
console.log(" 请先全局安装:npm install -g starlens");
|
|
927
|
+
console.log("");
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const rl = createReadlineInterface();
|
|
931
|
+
|
|
932
|
+
try {
|
|
933
|
+
// Step 1: select deployment mode
|
|
934
|
+
const defaultUrl = apiBaseUrlArg.value ?? env.STARLENS_API_BASE_URL ?? HOSTED_MCP_BASE_URL;
|
|
935
|
+
console.log("部署模式:");
|
|
936
|
+
console.log(" 1) 托管服务(推荐)— 使用 starlens.520ai.xin,无需本地启动服务");
|
|
937
|
+
console.log(" 2) 自部署 — 使用你自己的服务器或本地开发环境");
|
|
938
|
+
const modeChoice = await wizardPrompt(rl, "选择模式", "1");
|
|
939
|
+
const isSelfHosted = modeChoice.trim() === "2";
|
|
940
|
+
|
|
941
|
+
let apiBaseUrl;
|
|
942
|
+
let projectRoot;
|
|
943
|
+
|
|
944
|
+
if (isSelfHosted) {
|
|
945
|
+
console.log("");
|
|
946
|
+
apiBaseUrl = (await wizardPrompt(rl, "Starlens API base URL", defaultUrl === HOSTED_MCP_BASE_URL ? DEFAULT_API_BASE_URL : defaultUrl)).replace(/\/+$/, "");
|
|
947
|
+
// only ask for project root in self-hosted stdio mode
|
|
948
|
+
if (!isHostedUrl(apiBaseUrl)) {
|
|
949
|
+
const detectedRoot = detectProjectRoot();
|
|
950
|
+
console.log(`检测到项目根目录:${detectedRoot}`);
|
|
951
|
+
projectRoot = (await wizardPrompt(rl, "项目路径(回车确认)", detectedRoot)).replace(/\/$/, "");
|
|
952
|
+
}
|
|
953
|
+
} else {
|
|
954
|
+
apiBaseUrl = HOSTED_MCP_BASE_URL;
|
|
955
|
+
console.log(`✓ 使用托管服务:${HOSTED_MCP_BASE_URL}`);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const hosted = isHostedUrl(apiBaseUrl);
|
|
959
|
+
|
|
960
|
+
// Step 2: select client
|
|
961
|
+
const clientMap = {
|
|
962
|
+
"1": "claude", "2": "cursor", "3": "codex", "4": "opencode", "5": "other",
|
|
963
|
+
"claude": "claude", "cursor": "cursor", "codex": "codex", "opencode": "opencode", "other": "other",
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
let client = clientArg.value?.toLowerCase();
|
|
967
|
+
if (!clientMap[client]) {
|
|
968
|
+
console.log("");
|
|
969
|
+
console.log("请选择你的 AI 客户端:");
|
|
970
|
+
console.log(" 1) Claude Code");
|
|
971
|
+
console.log(" 2) Cursor");
|
|
972
|
+
console.log(" 3) Codex");
|
|
973
|
+
console.log(" 4) opencode");
|
|
974
|
+
console.log(" 5) 其他(仅输出配置片段)");
|
|
975
|
+
const clientChoice = await wizardPrompt(rl, "输入序号或名称", "1");
|
|
976
|
+
client = clientMap[clientChoice.toLowerCase()] ?? "other";
|
|
977
|
+
} else {
|
|
978
|
+
client = clientMap[client];
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const clientLabels = { claude: "Claude Code", cursor: "Cursor", codex: "Codex", opencode: "opencode", other: "其他" };
|
|
982
|
+
console.log(`已选择客户端:${clientLabels[client]}`);
|
|
983
|
+
|
|
984
|
+
// Step 3: token
|
|
985
|
+
console.log("");
|
|
986
|
+
console.log("在 Starlens 设置页创建 API Token(stl_xxx),然后粘贴到这里。");
|
|
987
|
+
let token = tokenArg.value ?? "";
|
|
988
|
+
if (!token) {
|
|
989
|
+
token = await wizardPromptSecret("API Token(输入不可见)");
|
|
990
|
+
}
|
|
991
|
+
if (!token) {
|
|
992
|
+
console.log("⚠ 未输入 Token,配置片段中将显示占位符 stl_xxx,请事后手动替换。");
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Step 4: for self-hosted + non-hosted URL, write ~/.starlens/agent.env
|
|
996
|
+
if (!hosted && token) {
|
|
997
|
+
const agentEnvDir = join(homedir(), ".starlens");
|
|
998
|
+
const agentEnvPath = join(agentEnvDir, "agent.env");
|
|
999
|
+
let skipEnvWrite = false;
|
|
1000
|
+
|
|
1001
|
+
let envExists = false;
|
|
1002
|
+
try {
|
|
1003
|
+
await access(agentEnvPath);
|
|
1004
|
+
envExists = true;
|
|
1005
|
+
} catch {
|
|
1006
|
+
// doesn't exist
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (envExists) {
|
|
1010
|
+
console.log("");
|
|
1011
|
+
const overwrite = await wizardPrompt(rl, "~/.starlens/agent.env 已存在,是否覆盖?(y/N)", "N");
|
|
1012
|
+
skipEnvWrite = !/^y$/i.test(overwrite);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (!skipEnvWrite) {
|
|
1016
|
+
await mkdir(agentEnvDir, { recursive: true });
|
|
1017
|
+
await chmod(agentEnvDir, 0o700);
|
|
1018
|
+
const envContent = [
|
|
1019
|
+
`export STARLENS_TOKEN="${token}"`,
|
|
1020
|
+
`export STARLENS_API_BASE_URL="${apiBaseUrl}"`,
|
|
1021
|
+
"",
|
|
1022
|
+
].join("\n");
|
|
1023
|
+
await writeFile(agentEnvPath, envContent, { mode: 0o600 });
|
|
1024
|
+
console.log(`✓ 已写入 ${agentEnvPath}`);
|
|
1025
|
+
} else {
|
|
1026
|
+
console.log("跳过写入 agent.env。");
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Step 5: output config snippet
|
|
1031
|
+
console.log("");
|
|
1032
|
+
console.log("─".repeat(40));
|
|
1033
|
+
|
|
1034
|
+
if (hosted) {
|
|
1035
|
+
// ── Hosted mode: HTTP MCP ──
|
|
1036
|
+
if (client === "claude") {
|
|
1037
|
+
const snippet = renderHostedClaudeSnippet(apiBaseUrl, token);
|
|
1038
|
+
console.log("Claude Code 配置命令:");
|
|
1039
|
+
console.log("");
|
|
1040
|
+
console.log(snippet);
|
|
1041
|
+
console.log("");
|
|
1042
|
+
const autoRun = await wizardPrompt(rl, "是否立即执行上述命令?(y/N)", "N");
|
|
1043
|
+
if (/^y$/i.test(autoRun)) {
|
|
1044
|
+
const mcpJson = JSON.stringify({
|
|
1045
|
+
type: "http",
|
|
1046
|
+
url: `${apiBaseUrl}/mcp`,
|
|
1047
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1048
|
+
});
|
|
1049
|
+
console.log("正在注册 MCP server...");
|
|
1050
|
+
const ok = await spawnCommand("claude", ["mcp", "add-json", "starlens", mcpJson]);
|
|
1051
|
+
if (ok) {
|
|
1052
|
+
console.log("✓ MCP server 已注册到 Claude Code。");
|
|
1053
|
+
} else {
|
|
1054
|
+
console.log("✗ 注册失败,请手动执行上方命令。");
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
} else if (client === "cursor") {
|
|
1058
|
+
console.log("将以下内容写入 .cursor/mcp.json(合并到 mcpServers 节点):");
|
|
1059
|
+
console.log("");
|
|
1060
|
+
console.log(renderHostedCursorSnippet(apiBaseUrl, token));
|
|
1061
|
+
} else if (client === "codex") {
|
|
1062
|
+
console.log("将以下内容追加到 ~/.codex/config.toml:");
|
|
1063
|
+
console.log("");
|
|
1064
|
+
console.log(renderHostedCodexSnippet(apiBaseUrl, token));
|
|
1065
|
+
} else if (client === "opencode") {
|
|
1066
|
+
console.log("将以下内容合并到 ~/.config/opencode/opencode.json:");
|
|
1067
|
+
console.log("");
|
|
1068
|
+
console.log(renderHostedOpencodeSnippet(apiBaseUrl, token));
|
|
1069
|
+
} else {
|
|
1070
|
+
console.log("HTTP MCP 端点信息:");
|
|
1071
|
+
console.log("");
|
|
1072
|
+
console.log(` URL: ${apiBaseUrl}/mcp`);
|
|
1073
|
+
console.log(` Authorization: Bearer ${token || "stl_xxx"}`);
|
|
1074
|
+
}
|
|
1075
|
+
} else {
|
|
1076
|
+
// ── Self-hosted mode: stdio MCP ──
|
|
1077
|
+
if (client === "claude") {
|
|
1078
|
+
const snippet = renderClaudeCodeSnippet(projectRoot);
|
|
1079
|
+
console.log("Claude Code 配置命令:");
|
|
1080
|
+
console.log("");
|
|
1081
|
+
console.log(snippet);
|
|
1082
|
+
console.log("");
|
|
1083
|
+
const autoRun = await wizardPrompt(rl, "是否立即执行上述命令?(y/N)", "N");
|
|
1084
|
+
if (/^y$/i.test(autoRun)) {
|
|
1085
|
+
const mcpJson = JSON.stringify({ type: "stdio", command: "zsh", args: buildMcpArgs(projectRoot) });
|
|
1086
|
+
console.log("正在注册 MCP server...");
|
|
1087
|
+
const ok = await spawnCommand("claude", ["mcp", "add-json", "starlens", mcpJson]);
|
|
1088
|
+
if (ok) {
|
|
1089
|
+
console.log("✓ MCP server 已注册到 Claude Code。");
|
|
1090
|
+
} else {
|
|
1091
|
+
console.log("✗ 注册失败,请手动执行上方命令。");
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
} else if (client === "cursor") {
|
|
1095
|
+
console.log("将以下内容写入 .cursor/mcp.json(合并到 mcpServers 节点):");
|
|
1096
|
+
console.log("");
|
|
1097
|
+
console.log(renderCursorSnippet(projectRoot));
|
|
1098
|
+
} else if (client === "codex") {
|
|
1099
|
+
console.log("将以下内容追加到 ~/.codex/config.toml:");
|
|
1100
|
+
console.log("");
|
|
1101
|
+
console.log(renderCodexSnippet(projectRoot));
|
|
1102
|
+
} else if (client === "opencode") {
|
|
1103
|
+
console.log("将以下内容合并到 ~/.config/opencode/opencode.json:");
|
|
1104
|
+
console.log("");
|
|
1105
|
+
console.log(renderOpencodeSnippet(projectRoot));
|
|
1106
|
+
} else {
|
|
1107
|
+
console.log("通用 Agent Skill 环境变量配置:");
|
|
1108
|
+
console.log("");
|
|
1109
|
+
console.log(` STARLENS_TOKEN="${token || "stl_xxx"}"`);
|
|
1110
|
+
console.log(` STARLENS_API_BASE_URL="${apiBaseUrl}"`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Step 6: verify token (optional)
|
|
1115
|
+
if (token) {
|
|
1116
|
+
console.log("");
|
|
1117
|
+
const doVerify = await wizardPrompt(rl, "是否验证 Token 可用性?(y/N)", "N");
|
|
1118
|
+
if (/^y$/i.test(doVerify)) {
|
|
1119
|
+
console.log("验证中...");
|
|
1120
|
+
try {
|
|
1121
|
+
const res = await fetchWithTimeout(
|
|
1122
|
+
`${apiBaseUrl}/api/search?q=test&pageSize=1`,
|
|
1123
|
+
{ headers: { Accept: "application/json", Authorization: `Bearer ${token}` } },
|
|
1124
|
+
8_000,
|
|
1125
|
+
);
|
|
1126
|
+
if (res.ok) {
|
|
1127
|
+
console.log("✓ Token 验证成功,API 连接正常。");
|
|
1128
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
1129
|
+
console.log(`✗ Token 无效(HTTP ${res.status})。请检查 Token 是否正确。`);
|
|
1130
|
+
} else {
|
|
1131
|
+
console.log(`⚠ 服务器返回 HTTP ${res.status},请检查 API base URL 是否正确。`);
|
|
1132
|
+
}
|
|
1133
|
+
} catch {
|
|
1134
|
+
console.log(`✗ 无法连接到 ${apiBaseUrl},请检查服务是否启动。`);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Step 7: done
|
|
1140
|
+
console.log("");
|
|
1141
|
+
console.log("─".repeat(40));
|
|
1142
|
+
console.log("✓ 配置完成!");
|
|
1143
|
+
console.log("");
|
|
1144
|
+
console.log("下一步:");
|
|
1145
|
+
console.log(" 1. 重启你的 AI 客户端,使 MCP server 生效。");
|
|
1146
|
+
console.log(" 2. 在客户端中输入「搜索我收藏的关于 React 的仓库」测试工具是否可用。");
|
|
1147
|
+
console.log(` 3. 完整文档:${HOSTED_MCP_BASE_URL}/docs/integrations`);
|
|
1148
|
+
console.log("");
|
|
1149
|
+
} finally {
|
|
1150
|
+
rl.close();
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1155
|
+
main().catch((error) => {
|
|
1156
|
+
console.error(error instanceof CliError ? error.message : error.stack || error.message);
|
|
1157
|
+
process.exitCode = error.exitCode ?? 1;
|
|
1158
|
+
});
|
|
1159
|
+
}
|