@starlens-app/cli 0.1.1 → 0.1.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starlens-app/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Starlens CLI — manage your GitHub starred repositories from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "src/index.mjs",
11
+ "skills/",
11
12
  "README.md"
12
13
  ],
13
14
  "engines": {
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: starlens
3
+ description: Use when an agent runtime such as Hermes, OpenClaw, custom HTTP agents, or coding assistants needs to search, inspect, organize, tag, sync, or ask questions over a user's GitHub starred repositories stored in StarLens. Prefer this skill for agent-side StarLens integration through HTTP APIs with STARLENS_API_BASE_URL and STARLENS_TOKEN.
4
+ ---
5
+
6
+ # StarLens
7
+
8
+ ## Purpose
9
+
10
+ Use StarLens as the user's searchable memory of GitHub starred repositories. This skill tells an agent when and how to call StarLens over HTTP.
11
+
12
+ Prefer HTTP API access for Hermes, OpenClaw, server-side agents, remote workers, and containerized runtimes. Use MCP only for IDE or terminal clients that natively support MCP.
13
+
14
+ ## Required Configuration
15
+
16
+ Read these values from the agent runtime environment, secret store, or project config:
17
+
18
+ ```bash
19
+ STARLENS_API_BASE_URL="https://starlens.example.com"
20
+ STARLENS_TOKEN="stl_xxx"
21
+ ```
22
+
23
+ Send every API request with:
24
+
25
+ ```http
26
+ Authorization: Bearer ${STARLENS_TOKEN}
27
+ Accept: application/json
28
+ ```
29
+
30
+ For JSON request bodies, also send:
31
+
32
+ ```http
33
+ Content-Type: application/json
34
+ ```
35
+
36
+ Never print, log, summarize, or store `STARLENS_TOKEN` in model-visible output.
37
+
38
+ ## Workflow
39
+
40
+ 1. Normalize the user's intent into one of these operations: search, inspect, sync, favorite, note, tag, or ask.
41
+ 2. Use `GET /api/search` first when the user gives a repository topic, keyword, language, tag, owner, or partial repository name.
42
+ 3. Use `GET /api/repos/{idOrFullName}` when the user gives a concrete repository id or `owner/repo`.
43
+ 4. Use write endpoints only when the user clearly asks to modify StarLens state, such as adding a note, tagging a repo, or marking a favorite.
44
+ 5. Use `POST /api/ai/ask` when the user asks for synthesis across starred repositories.
45
+ 6. Return concise answers with repository names, URLs when available, and the reason each result is relevant.
46
+
47
+ Read `references/http-api.md` when you need exact endpoint parameters, request bodies, or response handling.
48
+
49
+ ## Behavior Rules
50
+
51
+ - Treat StarLens as private user data. Do not expose results beyond the current task.
52
+ - Prefer specific queries over broad scans. Ask a follow-up only when the request cannot be mapped to a safe query.
53
+ - If a repository lookup by id or `owner/repo` returns 404, search by that same text before reporting failure.
54
+ - If the API returns 401, tell the user the StarLens token is missing, expired, or revoked.
55
+ - If the API returns 429 or 5xx, retry at most once with a short delay, then report the service issue.
56
+ - Do not create API tokens. Token management is browser-session only.
57
+ - Do not use MCP for Hermes/OpenClaw-style runtimes unless the user explicitly says that runtime supports MCP and wants it.
58
+
59
+ ## Common Examples
60
+
61
+ Search vector database stars:
62
+
63
+ ```bash
64
+ curl "$STARLENS_API_BASE_URL/api/search?q=vector%20database&page=1&pageSize=10&sort=relevance" \
65
+ -H "Authorization: Bearer $STARLENS_TOKEN" \
66
+ -H "Accept: application/json"
67
+ ```
68
+
69
+ Ask across starred repositories:
70
+
71
+ ```bash
72
+ curl -X POST "$STARLENS_API_BASE_URL/api/ai/ask" \
73
+ -H "Authorization: Bearer $STARLENS_TOKEN" \
74
+ -H "Accept: application/json" \
75
+ -H "Content-Type: application/json" \
76
+ -d '{"question":"哪些 starred repos 适合做本地 RAG 原型?"}'
77
+ ```
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "StarLens Agent"
3
+ short_description: "Use StarLens HTTP APIs from agent runtimes."
4
+ default_prompt: "Use the StarLens skill to search, inspect, organize, and ask questions over GitHub starred repositories through the configured HTTP API."
@@ -0,0 +1,100 @@
1
+ # StarLens HTTP API
2
+
3
+ All endpoints are relative to `STARLENS_API_BASE_URL`.
4
+
5
+ ## Response Envelope
6
+
7
+ Successful responses use:
8
+
9
+ ```json
10
+ { "ok": true, "data": {} }
11
+ ```
12
+
13
+ Failed responses use:
14
+
15
+ ```json
16
+ { "ok": false, "error": { "code": "string", "message": "string" } }
17
+ ```
18
+
19
+ If `ok` is not `true`, treat the request as failed even when the HTTP status is unexpected.
20
+
21
+ ## Authentication
22
+
23
+ Use a personal StarLens API token:
24
+
25
+ ```http
26
+ Authorization: Bearer stl_xxx
27
+ Accept: application/json
28
+ ```
29
+
30
+ ## Search Stars
31
+
32
+ `GET /api/search`
33
+
34
+ Query parameters:
35
+
36
+ | Name | Type | Notes |
37
+ | --- | --- | --- |
38
+ | `q` | string | Keyword, topic, repo name, owner, or natural query. |
39
+ | `page` | integer | Defaults to `1`. |
40
+ | `pageSize` | integer | Use `10` to `20` for agent answers. |
41
+ | `sort` | string | `relevance`, `recent`, `stars`, or `updated`. |
42
+ | `language` | string | Filter by repository language. |
43
+ | `owner` | string | Filter by GitHub owner. |
44
+ | `tag` | string | Filter by StarLens tag. |
45
+ | `favorite` | boolean | Filter favorites. |
46
+
47
+ Use this endpoint before detail lookup when the user provides a topic, partial name, or ambiguous repository reference.
48
+
49
+ ## Repository Detail
50
+
51
+ `GET /api/repos/{idOrFullName}`
52
+
53
+ `idOrFullName` can be a StarLens repository id or `owner/repo`. If it returns 404, search the same text with `/api/search` before giving up.
54
+
55
+ ## Sync Stars
56
+
57
+ `POST /api/sync`
58
+
59
+ Trigger GitHub Stars sync for the authenticated StarLens user. Use only when the user asks to refresh or sync.
60
+
61
+ ## Update Repository State
62
+
63
+ `PATCH /api/repos/{idOrFullName}`
64
+
65
+ Body fields:
66
+
67
+ ```json
68
+ {
69
+ "isFavorite": true,
70
+ "note": "Short user note"
71
+ }
72
+ ```
73
+
74
+ Send only fields that should change. Use an empty `note` string only when the user asks to clear a note.
75
+
76
+ ## Tags
77
+
78
+ Add a tag:
79
+
80
+ `POST /api/repos/{idOrFullName}/tags`
81
+
82
+ ```json
83
+ { "tag": "rag" }
84
+ ```
85
+
86
+ Remove a tag:
87
+
88
+ `DELETE /api/repos/{idOrFullName}/tags/{tag}`
89
+
90
+ ## AI Ask
91
+
92
+ `POST /api/ai/ask`
93
+
94
+ ```json
95
+ { "question": "哪些 starred repos 适合做本地 RAG 原型?" }
96
+ ```
97
+
98
+ Use this endpoint for synthesis, comparison, recommendations, and natural-language questions over the user's starred repositories.
99
+
100
+ The server chooses the user's default AI Provider first and falls back to the system default AI configuration when no user default is available.
package/src/index.mjs CHANGED
@@ -31,7 +31,7 @@ const helpText = [
31
31
  " stars note <repo-id|owner/repo> (--set <text>|--clear) [--api-base-url <url>] [--token-path <path>] [--format table|json]",
32
32
  " stars tag add <repo-id|owner/repo> <tag> [--api-base-url <url>] [--token-path <path>] [--format table|json]",
33
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]",
34
+ " stars install-skill [--api-base-url <url>] [--token <token>] [--client claude,cursor,...] (多客户端逗号分隔)",
35
35
  " stars version",
36
36
  "",
37
37
  "Configuration:",
@@ -715,6 +715,97 @@ function detectProjectRoot() {
715
715
  return new URL("../../..", import.meta.url).pathname.replace(/\/$/, "");
716
716
  }
717
717
 
718
+ // Skill 源目录(npm 包内的 skills/ 目录)
719
+ function getSkillSourceDir() {
720
+ return new URL("../../skills/starlens", import.meta.url).pathname;
721
+ }
722
+
723
+ // 各客户端全局 skill 目标路径
724
+ const SKILL_TARGETS = {
725
+ claude: { path: join(homedir(), ".claude", "skills", "starlens"), label: "Claude Code" },
726
+ opencode: { path: join(homedir(), ".opencode", "skills", "starlens"), label: "OpenCode" },
727
+ codex: { path: join(homedir(), ".codex", "skills", "starlens"), label: "Codex CLI" },
728
+ openclaw: { path: join(homedir(), ".openclaw", "skills", "starlens"), label: "OpenClaw" },
729
+ hermes: { path: join(homedir(), ".hermes", "skills", "starlens"), label: "Hermes" },
730
+ };
731
+
732
+ async function copyDir(src, dest) {
733
+ const { readdir, copyFile } = await import("node:fs/promises");
734
+ await mkdir(dest, { recursive: true });
735
+ const entries = await readdir(src, { withFileTypes: true });
736
+ for (const entry of entries) {
737
+ const srcPath = join(src, entry.name);
738
+ const destPath = join(dest, entry.name);
739
+ if (entry.isDirectory()) {
740
+ await copyDir(srcPath, destPath);
741
+ } else {
742
+ await copyFile(srcPath, destPath);
743
+ }
744
+ }
745
+ }
746
+
747
+ async function installSkillFiles(client, projectPath) {
748
+ const skillSrc = getSkillSourceDir();
749
+
750
+ // 检查 skill 源是否存在(全局安装时应该存在)
751
+ try {
752
+ await access(skillSrc);
753
+ } catch {
754
+ return { ok: false, reason: "skill 文件未找到(可能是旧版本,请更新:npm i -g @starlens-app/cli)" };
755
+ }
756
+
757
+ const results = [];
758
+
759
+ // 1. 全局路径(对应当前客户端)
760
+ const globalTarget = SKILL_TARGETS[client];
761
+ if (globalTarget) {
762
+ try {
763
+ await copyDir(skillSrc, globalTarget.path);
764
+ results.push({ path: globalTarget.path, ok: true });
765
+ } catch (e) {
766
+ results.push({ path: globalTarget.path, ok: false, reason: e.message });
767
+ }
768
+ }
769
+
770
+ // 2. Cursor 项目级:.cursor/rules/starlens.mdc
771
+ if (client === "cursor" && projectPath) {
772
+ const cursorRulesDir = join(projectPath, ".cursor", "rules");
773
+ const cursorTarget = join(cursorRulesDir, "starlens.mdc");
774
+ try {
775
+ await mkdir(cursorRulesDir, { recursive: true });
776
+ const skillContent = await readFile(join(skillSrc, "SKILL.md"), "utf8");
777
+ // 转换 SKILL.md → .mdc(保持内容不变,Cursor 兼容 markdown frontmatter)
778
+ await writeFile(cursorTarget, skillContent);
779
+ results.push({ path: cursorTarget, ok: true });
780
+ } catch (e) {
781
+ results.push({ path: cursorTarget, ok: false, reason: e.message });
782
+ }
783
+ }
784
+
785
+ // 3. VS Code 项目级:.github/copilot-instructions.md(追加)
786
+ if (client === "vscode" && projectPath) {
787
+ const githubDir = join(projectPath, ".github");
788
+ const vscodeTarget = join(githubDir, "copilot-instructions.md");
789
+ try {
790
+ await mkdir(githubDir, { recursive: true });
791
+ const skillContent = await readFile(join(skillSrc, "SKILL.md"), "utf8");
792
+ // 去掉 frontmatter,只保留正文
793
+ const body = skillContent.replace(/^---[\s\S]*?---\n/, "").trim();
794
+ let existing = "";
795
+ try { existing = await readFile(vscodeTarget, "utf8"); } catch { /* 不存在则新建 */ }
796
+ const marker = "<!-- starlens-skill -->";
797
+ if (!existing.includes(marker)) {
798
+ await writeFile(vscodeTarget, existing + (existing ? "\n\n" : "") + marker + "\n" + body + "\n" + marker);
799
+ }
800
+ results.push({ path: vscodeTarget, ok: true });
801
+ } catch (e) {
802
+ results.push({ path: vscodeTarget, ok: false, reason: e.message });
803
+ }
804
+ }
805
+
806
+ return { ok: results.some(r => r.ok), results };
807
+ }
808
+
718
809
  function createReadlineInterface() {
719
810
  return createInterface({ input: process.stdin, output: process.stdout, terminal: false });
720
811
  }
@@ -770,6 +861,109 @@ async function wizardPromptSecret(question) {
770
861
  });
771
862
  }
772
863
 
864
+ function maskToken(token) {
865
+ if (!token || token.length < 8) return "***";
866
+ return token.slice(0, 4) + "..." + token.slice(-3);
867
+ }
868
+
869
+ async function readExistingToken() {
870
+ const agentEnvPath = join(homedir(), ".starlens", "agent.env");
871
+ try {
872
+ const content = await readFile(agentEnvPath, "utf8");
873
+ const match = content.match(/^export STARLENS_TOKEN="([^"]+)"/m);
874
+ return match ? match[1] : null;
875
+ } catch {
876
+ return null;
877
+ }
878
+ }
879
+
880
+ async function wizardCheckbox(items) {
881
+ const isTTY = typeof process.stdin.setRawMode === "function";
882
+
883
+ if (!isTTY) {
884
+ // Non-TTY fallback: comma-separated input
885
+ const labels = items.map((it, i) => ` ${i + 1}) ${it.label}${it.skillOnly ? " [仅 Skill]" : ""}`).join("\n");
886
+ console.log(labels);
887
+ const rl = createReadlineInterface();
888
+ return new Promise((resolve) => {
889
+ rl.question("输入序号(逗号分隔,如 1,2): ", (answer) => {
890
+ rl.close();
891
+ const selected = answer.trim().split(",").map(s => {
892
+ const n = parseInt(s.trim(), 10);
893
+ return items[n - 1]?.value ?? null;
894
+ }).filter(Boolean);
895
+ resolve(selected.length > 0 ? selected : [items[0].value]);
896
+ });
897
+ });
898
+ }
899
+
900
+ return new Promise((resolve) => {
901
+ let cursor = 0;
902
+ const selected = new Set([items[0].value]); // 默认选中第一项
903
+
904
+ const RESET = "\x1b[0m";
905
+ const BOLD = "\x1b[1m";
906
+ const CYAN = "\x1b[36m";
907
+ const DIM = "\x1b[2m";
908
+
909
+ function render() {
910
+ // 清除之前的输出行
911
+ process.stdout.write("\x1b[" + items.length + "A\x1b[0J");
912
+ for (let i = 0; i < items.length; i++) {
913
+ const item = items[i];
914
+ const isActive = i === cursor;
915
+ const isSelected = selected.has(item.value);
916
+ const icon = isSelected ? "◉" : "◯";
917
+ const label = item.label + (item.skillOnly ? ` ${DIM}[仅 Skill]${RESET}` : "");
918
+ const line = isActive
919
+ ? `${BOLD}${CYAN}> ${icon} ${label}${RESET}`
920
+ : ` ${icon} ${label}`;
921
+ process.stdout.write(line + "\n");
922
+ }
923
+ }
924
+
925
+ // 初次渲染
926
+ console.log(`请选择 AI 客户端(${BOLD}↑↓${RESET} 移动,${BOLD}空格${RESET} 选中/取消,${BOLD}回车${RESET} 确认):\n`);
927
+ for (let i = 0; i < items.length; i++) {
928
+ process.stdout.write("\n");
929
+ }
930
+ render();
931
+
932
+ const stdin = process.stdin;
933
+ stdin.setRawMode(true);
934
+ stdin.resume();
935
+ stdin.setEncoding("utf8");
936
+
937
+ const onData = (chunk) => {
938
+ if (chunk === "\x1b[A") { // 上箭头
939
+ cursor = (cursor - 1 + items.length) % items.length;
940
+ render();
941
+ } else if (chunk === "\x1b[B") { // 下箭头
942
+ cursor = (cursor + 1) % items.length;
943
+ render();
944
+ } else if (chunk === " ") { // 空格
945
+ const val = items[cursor].value;
946
+ if (selected.has(val)) selected.delete(val);
947
+ else selected.add(val);
948
+ render();
949
+ } else if (chunk === "\r" || chunk === "\n") { // 回车
950
+ stdin.setRawMode(false);
951
+ stdin.removeListener("data", onData);
952
+ stdin.pause();
953
+ process.stdout.write("\n");
954
+ const result = items.map(it => it.value).filter(v => selected.has(v));
955
+ resolve(result.length > 0 ? result : [items[0].value]);
956
+ } else if (chunk === "\x03") { // Ctrl+C
957
+ stdin.setRawMode(false);
958
+ process.stdout.write("\n");
959
+ process.exit(1);
960
+ }
961
+ };
962
+
963
+ stdin.on("data", onData);
964
+ });
965
+ }
966
+
773
967
  function buildMcpArgs(projectRoot) {
774
968
  return ["-lc", `source "$HOME/.starlens/agent.env" && cd "${projectRoot}" && corepack pnpm mcp:start`];
775
969
  }
@@ -833,6 +1027,70 @@ function renderOpencodeSnippet(projectRoot) {
833
1027
  );
834
1028
  }
835
1029
 
1030
+ async function mergeJson(filePath, mergeFn) {
1031
+ let existing = {};
1032
+ try {
1033
+ const raw = await readFile(filePath, "utf8");
1034
+ existing = JSON.parse(raw);
1035
+ } catch { /* 文件不存在或格式错误则从空对象开始 */ }
1036
+ const merged = mergeFn(existing);
1037
+ await mkdir(filePath.replace(/\/[^/]+$/, ""), { recursive: true });
1038
+ await writeFile(filePath, JSON.stringify(merged, null, 2) + "\n");
1039
+ }
1040
+
1041
+ async function appendTomlSection(filePath, sectionKey, content) {
1042
+ let existing = "";
1043
+ try { existing = await readFile(filePath, "utf8"); } catch { /* 新建 */ }
1044
+ if (existing.includes(`[${sectionKey}]`)) {
1045
+ return { ok: false, reason: `[${sectionKey}] 节点已存在,跳过写入` };
1046
+ }
1047
+ await mkdir(filePath.replace(/\/[^/]+$/, ""), { recursive: true });
1048
+ await writeFile(filePath, existing + (existing && !existing.endsWith("\n") ? "\n" : "") + "\n" + content + "\n");
1049
+ return { ok: true };
1050
+ }
1051
+
1052
+ async function writeMcpConfig(client, { apiBaseUrl, token, projectRoot, hosted }) {
1053
+ const home = homedir();
1054
+ try {
1055
+ if (client === "cursor") {
1056
+ const cursorMcpPath = join(home, ".cursor", "mcp.json");
1057
+ const starlensEntry = hosted
1058
+ ? { url: `${apiBaseUrl}/mcp`, headers: { Authorization: `Bearer ${token || "stl_xxx"}` } }
1059
+ : { command: "corepack", args: ["pnpm", "mcp:start"], cwd: projectRoot, env: { STARLENS_TOKEN: "(从 ~/.starlens/agent.env 读取)", STARLENS_API_BASE_URL: "(从 ~/.starlens/agent.env 读取)" } };
1060
+ await mergeJson(cursorMcpPath, (obj) => ({
1061
+ ...obj,
1062
+ mcpServers: { ...(obj.mcpServers ?? {}), starlens: starlensEntry },
1063
+ }));
1064
+ return { ok: true, path: cursorMcpPath };
1065
+ }
1066
+
1067
+ if (client === "opencode") {
1068
+ const opencodePath = join(home, ".config", "opencode", "opencode.json");
1069
+ const starlensEntry = hosted
1070
+ ? { type: "http", url: `${apiBaseUrl}/mcp`, headers: { Authorization: `Bearer ${token || "stl_xxx"}` }, enabled: true }
1071
+ : { type: "local", command: ["zsh", "-lc", `source "$HOME/.starlens/agent.env" && cd "${projectRoot}" && corepack pnpm mcp:start`], enabled: true, timeout: 10000 };
1072
+ await mergeJson(opencodePath, (obj) => ({
1073
+ ...obj,
1074
+ mcp: { ...(obj.mcp ?? {}), starlens: starlensEntry },
1075
+ }));
1076
+ return { ok: true, path: opencodePath };
1077
+ }
1078
+
1079
+ if (client === "codex") {
1080
+ const codexPath = join(home, ".codex", "config.toml");
1081
+ const content = hosted
1082
+ ? `[mcp_servers.starlens]\ntype = "http"\nurl = "${apiBaseUrl}/mcp"\n\n[mcp_servers.starlens.headers]\nAuthorization = "Bearer ${token || "stl_xxx"}"\nstartup_timeout_sec = 30\ndefault_tools_approval_mode = "approve"`
1083
+ : `[mcp_servers.starlens]\ntype = "stdio"\ncommand = "zsh"\nargs = ["-lc", "source \\"$HOME/.starlens/agent.env\\" && cd \\"${projectRoot}\\" && corepack pnpm mcp:start"]\nstartup_timeout_sec = 30\ndefault_tools_approval_mode = "approve"`;
1084
+ const result = await appendTomlSection(codexPath, "mcp_servers.starlens", content);
1085
+ return result.ok ? { ok: true, path: codexPath } : { ok: false, reason: result.reason };
1086
+ }
1087
+
1088
+ return { ok: false, reason: `${client} 不支持自动写入,请手动配置` };
1089
+ } catch (e) {
1090
+ return { ok: false, reason: e.message };
1091
+ }
1092
+ }
1093
+
836
1094
  async function spawnCommand(command, args) {
837
1095
  return new Promise((resolve) => {
838
1096
  const child = spawn(command, args, { stdio: "inherit" });
@@ -913,237 +1171,221 @@ async function runInstallSkillWizard(args, env) {
913
1171
  const clientArg = readOption(rest, "--client");
914
1172
  rest = clientArg.rest;
915
1173
 
1174
+ const clientLabels = {
1175
+ claude: "Claude Code", cursor: "Cursor", vscode: "VS Code (Copilot)",
1176
+ codex: "Codex CLI", opencode: "OpenCode", openclaw: "OpenClaw", hermes: "Hermes", other: "其他",
1177
+ };
1178
+ const MCP_SUPPORTED = new Set(["claude", "cursor", "codex", "opencode"]);
1179
+
1180
+ const CLIENT_ITEMS = [
1181
+ { value: "claude", label: "Claude Code" },
1182
+ { value: "cursor", label: "Cursor" },
1183
+ { value: "vscode", label: "VS Code (Copilot)", skillOnly: true },
1184
+ { value: "codex", label: "Codex CLI" },
1185
+ { value: "opencode", label: "OpenCode" },
1186
+ { value: "openclaw", label: "OpenClaw", skillOnly: true },
1187
+ { value: "hermes", label: "Hermes", skillOnly: true },
1188
+ { value: "other", label: "其他(仅输出配置片段)", skillOnly: true },
1189
+ ];
1190
+
916
1191
  console.log("");
917
- console.log("Starlens MCP 安装向导");
1192
+ console.log("Starlens 安装向导");
918
1193
  console.log("═".repeat(40));
919
- console.log("本向导将引导你完成 MCP Server 接入配置。");
1194
+ console.log("本向导将引导你完成 Skill 安装及可选的 MCP Server 配置。");
920
1195
  console.log("");
921
1196
 
922
1197
  // Step 0: check global install
923
1198
  const isGlobalInstall = !process.argv[1]?.includes("apps/cli");
924
1199
  if (!isGlobalInstall) {
925
1200
  console.log("提示:你正在从源码运行。如需让其他工具通过 `stars` 命令使用,");
926
- console.log(" 请先全局安装:npm install -g starlens");
1201
+ console.log(" 请先全局安装:npm install -g @starlens-app/cli");
927
1202
  console.log("");
928
1203
  }
929
1204
 
930
1205
  const rl = createReadlineInterface();
931
1206
 
932
1207
  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
- }
1208
+ // Step 1: 多选客户端
1209
+ let clients;
1210
+ const clientArgRaw = clientArg.value?.toLowerCase();
1211
+ if (clientArgRaw) {
1212
+ // --client 参数:逗号分隔
1213
+ const nameMap = Object.fromEntries(CLIENT_ITEMS.map(it => [it.value, it.value]));
1214
+ clients = clientArgRaw.split(",").map(s => nameMap[s.trim()]).filter(Boolean);
1215
+ if (clients.length === 0) clients = ["claude"];
1216
+ console.log(`已选择客户端:${clients.map(c => clientLabels[c]).join("、")}`);
953
1217
  } else {
954
- apiBaseUrl = HOSTED_MCP_BASE_URL;
955
- console.log(`✓ 使用托管服务:${HOSTED_MCP_BASE_URL}`);
1218
+ console.log("");
1219
+ clients = await wizardCheckbox(CLIENT_ITEMS);
1220
+ console.log(`已选择:${clients.map(c => clientLabels[c]).join("、")}`);
956
1221
  }
957
1222
 
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
- };
1223
+ const cwd = process.cwd();
965
1224
 
966
- let client = clientArg.value?.toLowerCase();
967
- if (!clientMap[client]) {
1225
+ // Step 2: 安装 Skill(默认是)
1226
+ console.log("");
1227
+ console.log("─".repeat(40));
1228
+ const installSkill = await wizardPrompt(rl, "是否安装 Starlens Skill 文件?(Y/n)", "Y");
1229
+ if (!/^n$/i.test(installSkill)) {
968
1230
  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";
1231
+ console.log("安装 Starlens Agent Skill...");
1232
+ for (const client of clients) {
1233
+ const skillResult = await installSkillFiles(client, cwd);
1234
+ if (skillResult.results) {
1235
+ for (const r of skillResult.results) {
1236
+ if (r.ok) {
1237
+ console.log(`✓ Skill 已安装:${r.path}`);
1238
+ } else {
1239
+ console.log(`⚠ Skill 安装失败:${r.path}(${r.reason})`);
1240
+ }
1241
+ }
1242
+ } else if (!skillResult.ok) {
1243
+ console.log(`⚠ ${clientLabels[client]}:${skillResult.reason}`);
1244
+ }
1245
+ }
977
1246
  } else {
978
- client = clientMap[client];
1247
+ console.log("跳过 Skill 安装。");
979
1248
  }
980
1249
 
981
- const clientLabels = { claude: "Claude Code", cursor: "Cursor", codex: "Codex", opencode: "opencode", other: "其他" };
982
- console.log(`已选择客户端:${clientLabels[client]}`);
983
-
984
- // Step 3: token
1250
+ // Step 3: 配置 MCP(可选,默认否)
985
1251
  console.log("");
986
- console.log("在 Starlens 设置页创建 API Token(stl_xxx),然后粘贴到这里。");
1252
+ console.log("".repeat(40));
1253
+ const mcpClients = clients.filter(c => MCP_SUPPORTED.has(c));
987
1254
  let token = tokenArg.value ?? "";
988
- if (!token) {
989
- token = await wizardPromptSecret("API Token(输入不可见)");
990
- }
991
- if (!token) {
992
- console.log("⚠ 未输入 Token,配置片段中将显示占位符 stl_xxx,请事后手动替换。");
993
- }
1255
+ let apiBaseUrl = "";
994
1256
 
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;
1257
+ if (mcpClients.length === 0) {
1258
+ console.log("所选客户端均不支持 MCP,跳过 MCP 配置。");
1259
+ } else {
1260
+ const doMcp = await wizardPrompt(rl, `是否配置 MCP Server?(支持:${mcpClients.map(c => clientLabels[c]).join("、")})(y/N)`, "N");
1261
+ if (/^y$/i.test(doMcp)) {
1262
+ // 部署模式
1263
+ console.log("");
1264
+ const defaultUrl = apiBaseUrlArg.value ?? env.STARLENS_API_BASE_URL ?? HOSTED_MCP_BASE_URL;
1265
+ console.log("部署模式:");
1266
+ console.log(" 1) 托管服务(推荐)— 使用 starlens.520ai.xin,无需本地启动服务");
1267
+ console.log(" 2) 自部署 — 使用你自己的服务器或本地开发环境");
1268
+ const modeChoice = await wizardPrompt(rl, "选择模式", "1");
1269
+ const isSelfHosted = modeChoice.trim() === "2";
1270
+
1271
+ let projectRoot;
1272
+ if (isSelfHosted) {
1273
+ console.log("");
1274
+ apiBaseUrl = (await wizardPrompt(rl, "Starlens API base URL", defaultUrl === HOSTED_MCP_BASE_URL ? DEFAULT_API_BASE_URL : defaultUrl)).replace(/\/+$/, "");
1275
+ if (!isHostedUrl(apiBaseUrl)) {
1276
+ const detectedRoot = detectProjectRoot();
1277
+ console.log(`检测到项目根目录:${detectedRoot}`);
1278
+ projectRoot = (await wizardPrompt(rl, "项目路径(回车确认)", detectedRoot)).replace(/\/$/, "");
1279
+ }
1280
+ } else {
1281
+ apiBaseUrl = HOSTED_MCP_BASE_URL;
1282
+ console.log(`✓ 使用托管服务:${HOSTED_MCP_BASE_URL}`);
1283
+ }
1000
1284
 
1001
- let envExists = false;
1002
- try {
1003
- await access(agentEnvPath);
1004
- envExists = true;
1005
- } catch {
1006
- // doesn't exist
1007
- }
1285
+ const hosted = isHostedUrl(apiBaseUrl);
1008
1286
 
1009
- if (envExists) {
1287
+ // Token(支持历史复用,脱敏展示)
1010
1288
  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
- }
1289
+ console.log("在 Starlens 设置页创建 API Token(stl_xxx),然后粘贴到这里。");
1290
+ if (!token) {
1291
+ const existingToken = await readExistingToken();
1292
+ const tokenHint = existingToken ? `回车复用已有 token: ${maskToken(existingToken)},或输入新值` : "输入不可见";
1293
+ const inputToken = await wizardPromptSecret(`API Token(${tokenHint})`);
1294
+ token = inputToken || existingToken || "";
1295
+ }
1296
+ if (!token) {
1297
+ console.log("⚠ 未输入 Token,配置片段中将显示占位符 stl_xxx,请事后手动替换。");
1298
+ }
1029
1299
 
1030
- // Step 5: output config snippet
1031
- console.log("");
1032
- console.log("".repeat(40));
1300
+ // 写入 agent.env(自部署非托管模式)
1301
+ if (!hosted && token) {
1302
+ const agentEnvDir = join(homedir(), ".starlens");
1303
+ const agentEnvPath = join(agentEnvDir, "agent.env");
1304
+ let envExists = false;
1305
+ try { await access(agentEnvPath); envExists = true; } catch { /* 不存在 */ }
1306
+
1307
+ let skipEnvWrite = false;
1308
+ if (envExists) {
1309
+ console.log("");
1310
+ const overwrite = await wizardPrompt(rl, "~/.starlens/agent.env 已存在,是否覆盖?(y/N)", "N");
1311
+ skipEnvWrite = !/^y$/i.test(overwrite);
1312
+ }
1033
1313
 
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。");
1314
+ if (!skipEnvWrite) {
1315
+ await mkdir(agentEnvDir, { recursive: true });
1316
+ await chmod(agentEnvDir, 0o700);
1317
+ const envContent = [`export STARLENS_TOKEN="${token}"`, `export STARLENS_API_BASE_URL="${apiBaseUrl}"`, ""].join("\n");
1318
+ await writeFile(agentEnvPath, envContent, { mode: 0o600 });
1319
+ console.log(`✓ 已写入 ${agentEnvPath}`);
1053
1320
  } else {
1054
- console.log(" 注册失败,请手动执行上方命令。");
1321
+ console.log("跳过写入 agent.env。");
1055
1322
  }
1056
1323
  }
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);
1324
+
1325
+ // 对每个支持 MCP 的客户端自动写入配置
1082
1326
  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。");
1327
+ console.log("─".repeat(40));
1328
+ console.log("配置 MCP Server...");
1329
+ for (const client of mcpClients) {
1330
+ if (client === "claude") {
1331
+ const mcpJson = hosted
1332
+ ? JSON.stringify({ type: "http", url: `${apiBaseUrl}/mcp`, headers: { Authorization: `Bearer ${token || "stl_xxx"}` } })
1333
+ : JSON.stringify({ type: "stdio", command: "zsh", args: buildMcpArgs(projectRoot) });
1334
+ console.log(`\n ${clientLabels.claude} 配置命令:`);
1335
+ console.log(` claude mcp add-json starlens '${mcpJson}'`);
1336
+ console.log("");
1337
+ const autoRun = await wizardPrompt(rl, " 是否立即执行?(y/N)", "N");
1338
+ if (/^y$/i.test(autoRun)) {
1339
+ const ok = await spawnCommand("claude", ["mcp", "add-json", "starlens", mcpJson]);
1340
+ console.log(ok ? " ✓ MCP server 已注册到 Claude Code。" : " ✗ 注册失败,请手动执行上方命令。");
1341
+ }
1090
1342
  } else {
1091
- console.log("✗ 注册失败,请手动执行上方命令。");
1343
+ const result = await writeMcpConfig(client, { apiBaseUrl, token, projectRoot, hosted });
1344
+ if (result.ok) {
1345
+ console.log(`✓ MCP 配置已写入:${result.path}`);
1346
+ } else {
1347
+ console.log(`⚠ ${clientLabels[client]}:${result.reason}`);
1348
+ }
1092
1349
  }
1093
1350
  }
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
1351
 
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 是否正确。`);
1352
+ // 验证 Token(可选)
1353
+ if (token) {
1354
+ console.log("");
1355
+ const doVerify = await wizardPrompt(rl, "是否验证 Token 可用性?(y/N)", "N");
1356
+ if (/^y$/i.test(doVerify)) {
1357
+ console.log("验证中...");
1358
+ try {
1359
+ const res = await fetchWithTimeout(
1360
+ `${apiBaseUrl}/api/search?q=test&pageSize=1`,
1361
+ { headers: { Accept: "application/json", Authorization: `Bearer ${token}` } },
1362
+ 8_000,
1363
+ );
1364
+ if (res.ok) {
1365
+ console.log("✓ Token 验证成功,API 连接正常。");
1366
+ } else if (res.status === 401 || res.status === 403) {
1367
+ console.log(`✗ Token 无效(HTTP ${res.status})。请检查 Token 是否正确。`);
1368
+ } else {
1369
+ console.log(`⚠ 服务器返回 HTTP ${res.status},请检查 API base URL 是否正确。`);
1370
+ }
1371
+ } catch {
1372
+ console.log(`✗ 无法连接到 ${apiBaseUrl},请检查服务是否启动。`);
1373
+ }
1132
1374
  }
1133
- } catch {
1134
- console.log(`✗ 无法连接到 ${apiBaseUrl},请检查服务是否启动。`);
1135
1375
  }
1376
+ } else {
1377
+ console.log("跳过 MCP 配置。");
1136
1378
  }
1137
1379
  }
1138
1380
 
1139
- // Step 7: done
1381
+ // 完成
1140
1382
  console.log("");
1141
1383
  console.log("─".repeat(40));
1142
1384
  console.log("✓ 配置完成!");
1143
1385
  console.log("");
1144
1386
  console.log("下一步:");
1145
- console.log(" 1. 重启你的 AI 客户端,使 MCP server 生效。");
1146
- console.log(" 2. 在客户端中输入「搜索我收藏的关于 React 的仓库」测试工具是否可用。");
1387
+ console.log(" 1. 重启你的 AI 客户端,使配置生效。");
1388
+ console.log(" 2. 在客户端中输入「搜索我收藏的关于 React 的仓库」测试是否可用。");
1147
1389
  console.log(` 3. 完整文档:${HOSTED_MCP_BASE_URL}/docs/integrations`);
1148
1390
  console.log("");
1149
1391
  } finally {