@chenglu.she/sandy 1.0.2 → 1.0.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 +1 -1
- package/src/init-guides.ts +132 -0
- package/src/init.ts +67 -20
- package/src/prompt.ts +60 -3
package/package.json
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { FieldGuide } from "./prompt.js";
|
|
2
|
+
|
|
3
|
+
const FEISHU_APP_HOME: FieldGuide = {
|
|
4
|
+
title: "飞书开放平台",
|
|
5
|
+
lines: ["企业自建应用的入口,下面几项都在同一个应用里配置。"],
|
|
6
|
+
links: [{ label: "打开飞书开发者后台", url: "https://open.feishu.cn/app" }],
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const INIT_FIELD_GUIDES = {
|
|
10
|
+
feishuAppId: {
|
|
11
|
+
title: "飞书 App ID",
|
|
12
|
+
lines: [
|
|
13
|
+
"在飞书开发者后台创建「企业自建应用」后获取。",
|
|
14
|
+
"路径:你的应用 → 凭证与基础信息 → App ID",
|
|
15
|
+
"格式通常以 cli_ 开头。",
|
|
16
|
+
],
|
|
17
|
+
links: [
|
|
18
|
+
{ label: "飞书开发者后台", url: "https://open.feishu.cn/app" },
|
|
19
|
+
{
|
|
20
|
+
label: "创建应用说明",
|
|
21
|
+
url: "https://open.feishu.cn/document/home/introduction-to-custom-app-development/self-built-application-development-process",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
} satisfies FieldGuide,
|
|
25
|
+
|
|
26
|
+
feishuAppSecret: {
|
|
27
|
+
title: "飞书 App Secret",
|
|
28
|
+
lines: [
|
|
29
|
+
"与 App ID 在同一页:凭证与基础信息 → App Secret",
|
|
30
|
+
"点击「显示」或「重置」后复制;勿泄露或提交到 git。",
|
|
31
|
+
],
|
|
32
|
+
links: [{ label: "飞书开发者后台", url: "https://open.feishu.cn/app" }],
|
|
33
|
+
} satisfies FieldGuide,
|
|
34
|
+
|
|
35
|
+
feishuSetupHint: {
|
|
36
|
+
title: "飞书后台还需完成(init 不会替你操作)",
|
|
37
|
+
lines: [
|
|
38
|
+
"1. 应用能力 → 开通「机器人」",
|
|
39
|
+
"2. 权限管理 → 开通消息 / 文件 / 文档等权限,并发布新版本",
|
|
40
|
+
"3. 事件与回调 → 订阅方式选「长连接」",
|
|
41
|
+
" 事件:im.message.receive_v1;回调:card.action.trigger",
|
|
42
|
+
"4. 先在本机运行 sandy,看到 ws client ready 后再在后台保存长连接配置",
|
|
43
|
+
],
|
|
44
|
+
links: [
|
|
45
|
+
{ label: "事件订阅(长连接)", url: "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/long-connection-mode" },
|
|
46
|
+
{ label: "机器人能力说明", url: "https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-enable-bot-ability" },
|
|
47
|
+
],
|
|
48
|
+
} satisfies FieldGuide,
|
|
49
|
+
|
|
50
|
+
cursorApiKey: {
|
|
51
|
+
title: "Cursor API Key",
|
|
52
|
+
lines: [
|
|
53
|
+
"在 Cursor 账号设置里创建,用于 @cursor/sdk 调用本地 Agent。",
|
|
54
|
+
"路径:Cursor Settings → Integrations / API Keys(或 Dashboard 中的 API Keys)",
|
|
55
|
+
"格式通常以 crsr_ 开头。",
|
|
56
|
+
],
|
|
57
|
+
links: [
|
|
58
|
+
{ label: "Cursor 设置", url: "https://cursor.com/settings" },
|
|
59
|
+
{ label: "Cursor SDK 文档", url: "https://cursor.com/docs/sdk/typescript" },
|
|
60
|
+
],
|
|
61
|
+
} satisfies FieldGuide,
|
|
62
|
+
|
|
63
|
+
agentName: {
|
|
64
|
+
title: "Agent 显示名",
|
|
65
|
+
lines: [
|
|
66
|
+
"仅影响日志与 Cursor 侧 Agent 标题,飞书里仍显示机器人在后台设置的名称。",
|
|
67
|
+
"直接回车使用默认 Sandy 即可。",
|
|
68
|
+
],
|
|
69
|
+
} satisfies FieldGuide,
|
|
70
|
+
|
|
71
|
+
agentCwd: {
|
|
72
|
+
title: "Agent 工作目录",
|
|
73
|
+
lines: [
|
|
74
|
+
"Agent 读写代码、加载 .cursor/rules 的根目录。",
|
|
75
|
+
"建议设为你要放 config.yaml 的目录(例如 ~/treedome)。",
|
|
76
|
+
"支持 ~ 与绝对路径;相对路径相对于运行 sandy 时的 cwd。",
|
|
77
|
+
],
|
|
78
|
+
} satisfies FieldGuide,
|
|
79
|
+
|
|
80
|
+
agentDirs: {
|
|
81
|
+
title: "额外可访问目录(可选)",
|
|
82
|
+
lines: [
|
|
83
|
+
"除工作目录外,Agent 还可以访问哪些路径(多仓库 / 多项目时用)。",
|
|
84
|
+
"多个目录用英文逗号分隔,例如:~/code/api,~/code/web",
|
|
85
|
+
"留空表示仅使用上面的工作目录。",
|
|
86
|
+
],
|
|
87
|
+
} satisfies FieldGuide,
|
|
88
|
+
|
|
89
|
+
agentDirLinks: {
|
|
90
|
+
title: "工作目录下的 symlink(可选)",
|
|
91
|
+
lines: [
|
|
92
|
+
"若 agent.cwd 里有 symlink(如 api → ~/code/api),填链接名以便一并放行。",
|
|
93
|
+
"多个名称用英文逗号分隔;留空可跳过。",
|
|
94
|
+
],
|
|
95
|
+
} satisfies FieldGuide,
|
|
96
|
+
|
|
97
|
+
agentSandbox: {
|
|
98
|
+
title: "Cursor 本地沙箱",
|
|
99
|
+
lines: [
|
|
100
|
+
"开启后 Agent 的 shell/文件操作走 Cursor 沙箱,默认禁止随意访问网络。",
|
|
101
|
+
"需要 SSH、git push、deploy 等本机能力时选「否」。",
|
|
102
|
+
"一般个人机器上建议关闭(默认)。",
|
|
103
|
+
],
|
|
104
|
+
links: [
|
|
105
|
+
{ label: "SDK 沙箱说明", url: "https://cursor.com/docs/sdk/typescript#sandbox-options" },
|
|
106
|
+
],
|
|
107
|
+
} satisfies FieldGuide,
|
|
108
|
+
|
|
109
|
+
cursorModel: {
|
|
110
|
+
title: "Cursor 模型",
|
|
111
|
+
lines: [
|
|
112
|
+
"传给 @cursor/sdk 的 model id。",
|
|
113
|
+
"auto 表示由 Cursor 自动选择;也可填具体模型名。",
|
|
114
|
+
"直接回车使用 auto。",
|
|
115
|
+
],
|
|
116
|
+
links: [{ label: "Cursor 模型与 SDK", url: "https://cursor.com/docs/sdk/typescript" }],
|
|
117
|
+
} satisfies FieldGuide,
|
|
118
|
+
|
|
119
|
+
feishuDocsFolder: {
|
|
120
|
+
title: "飞书文档默认文件夹(可选)",
|
|
121
|
+
lines: [
|
|
122
|
+
"feishu_doc_create 创建文档时默认放到哪个云空间文件夹。",
|
|
123
|
+
"在飞书云文档打开目标文件夹,从 URL 或文件夹属性里复制 folder_token。",
|
|
124
|
+
"不需要自动归档文档可留空。",
|
|
125
|
+
],
|
|
126
|
+
links: [
|
|
127
|
+
{ label: "创建文档 API", url: "https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/create" },
|
|
128
|
+
],
|
|
129
|
+
} satisfies FieldGuide,
|
|
130
|
+
|
|
131
|
+
feishuAppHome: FEISHU_APP_HOME,
|
|
132
|
+
} as const;
|
package/src/init.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
2
|
import {
|
|
4
3
|
configFilePath,
|
|
5
4
|
expandPath,
|
|
@@ -7,6 +6,7 @@ import {
|
|
|
7
6
|
writeConfigFile,
|
|
8
7
|
} from "./config-io.js";
|
|
9
8
|
import { defaultConfigFile, type SandyConfigFile } from "./config-schema.js";
|
|
9
|
+
import { INIT_FIELD_GUIDES } from "./init-guides.js";
|
|
10
10
|
import { createPrompter } from "./prompt.js";
|
|
11
11
|
|
|
12
12
|
function parseArgs(argv: string[]): { targetDir: string } {
|
|
@@ -43,12 +43,13 @@ export async function runInit(argv: string[] = []): Promise<void> {
|
|
|
43
43
|
const prompt = createPrompter();
|
|
44
44
|
|
|
45
45
|
try {
|
|
46
|
-
console.log("Sandy 初始化
|
|
46
|
+
console.log("Sandy 初始化");
|
|
47
|
+
console.log("会逐步说明每项如何获取,并写入 config.yaml。\n");
|
|
47
48
|
console.log(`目标目录: ${targetDir}`);
|
|
48
|
-
console.log(`配置文件: ${outPath}
|
|
49
|
+
console.log(`配置文件: ${outPath}`);
|
|
49
50
|
|
|
50
51
|
if (fs.existsSync(outPath)) {
|
|
51
|
-
const overwrite = await prompt.askYesNo("已存在 config.yaml,是否覆盖?", false);
|
|
52
|
+
const overwrite = await prompt.askYesNo("\n已存在 config.yaml,是否覆盖?", false);
|
|
52
53
|
if (!overwrite) {
|
|
53
54
|
console.log("已取消。");
|
|
54
55
|
return;
|
|
@@ -57,32 +58,78 @@ export async function runInit(argv: string[] = []): Promise<void> {
|
|
|
57
58
|
|
|
58
59
|
const cfg: SandyConfigFile = defaultConfigFile(targetDir);
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
cfg.feishu.appSecret = await prompt.askRequired("飞书 App Secret");
|
|
63
|
-
cfg.cursor.apiKey = await prompt.askRequired("Cursor API Key");
|
|
61
|
+
prompt.section("一、飞书应用");
|
|
62
|
+
prompt.guide(INIT_FIELD_GUIDES.feishuAppHome);
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
cfg.feishu.appId = await prompt.askWithGuide(
|
|
65
|
+
INIT_FIELD_GUIDES.feishuAppId,
|
|
66
|
+
"App ID",
|
|
67
|
+
{ required: true },
|
|
68
|
+
);
|
|
69
|
+
cfg.feishu.appSecret = await prompt.askWithGuide(
|
|
70
|
+
INIT_FIELD_GUIDES.feishuAppSecret,
|
|
71
|
+
"App Secret",
|
|
72
|
+
{ required: true },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
prompt.guide(INIT_FIELD_GUIDES.feishuSetupHint);
|
|
76
|
+
|
|
77
|
+
prompt.section("二、Cursor");
|
|
78
|
+
cfg.cursor.apiKey = await prompt.askWithGuide(
|
|
79
|
+
INIT_FIELD_GUIDES.cursorApiKey,
|
|
80
|
+
"API Key",
|
|
81
|
+
{ required: true },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
prompt.section("三、Agent 与工作区(可一路回车用默认)");
|
|
85
|
+
cfg.agent!.name = await prompt.askWithGuide(
|
|
86
|
+
INIT_FIELD_GUIDES.agentName,
|
|
87
|
+
"显示名",
|
|
88
|
+
{ defaultValue: "Sandy" },
|
|
89
|
+
);
|
|
90
|
+
cfg.agent!.cwd = await prompt.askWithGuide(
|
|
91
|
+
INIT_FIELD_GUIDES.agentCwd,
|
|
92
|
+
"工作目录",
|
|
93
|
+
{ defaultValue: targetDir },
|
|
94
|
+
);
|
|
68
95
|
|
|
69
|
-
const dirsRaw = await prompt.
|
|
96
|
+
const dirsRaw = await prompt.askWithGuide(
|
|
97
|
+
INIT_FIELD_GUIDES.agentDirs,
|
|
98
|
+
"额外目录(逗号分隔,可留空)",
|
|
99
|
+
{ defaultValue: "" },
|
|
100
|
+
);
|
|
70
101
|
cfg.agent!.dirs = dirsRaw ? splitList(dirsRaw) : [];
|
|
71
102
|
|
|
72
|
-
const linksRaw = await prompt.
|
|
73
|
-
|
|
74
|
-
"",
|
|
103
|
+
const linksRaw = await prompt.askWithGuide(
|
|
104
|
+
INIT_FIELD_GUIDES.agentDirLinks,
|
|
105
|
+
"symlink 名(逗号分隔,可留空)",
|
|
106
|
+
{ defaultValue: "" },
|
|
75
107
|
);
|
|
76
108
|
cfg.agent!.dirLinks = linksRaw ? splitList(linksRaw) : [];
|
|
77
109
|
|
|
78
|
-
cfg.agent!.sandbox = await prompt.
|
|
79
|
-
|
|
80
|
-
|
|
110
|
+
cfg.agent!.sandbox = await prompt.askYesNoWithGuide(
|
|
111
|
+
INIT_FIELD_GUIDES.agentSandbox,
|
|
112
|
+
"开启本地沙箱?",
|
|
113
|
+
false,
|
|
114
|
+
);
|
|
115
|
+
cfg.cursor.model = await prompt.askWithGuide(
|
|
116
|
+
INIT_FIELD_GUIDES.cursorModel,
|
|
117
|
+
"模型 id",
|
|
118
|
+
{ defaultValue: "auto" },
|
|
119
|
+
);
|
|
120
|
+
cfg.feishuDocsFolder = await prompt.askWithGuide(
|
|
121
|
+
INIT_FIELD_GUIDES.feishuDocsFolder,
|
|
122
|
+
"folder_token(可留空)",
|
|
123
|
+
{ defaultValue: "" },
|
|
124
|
+
);
|
|
81
125
|
|
|
82
126
|
writeConfigFile(outPath, cfg);
|
|
83
127
|
|
|
84
|
-
console.log(
|
|
85
|
-
console.log(
|
|
128
|
+
console.log("\n✓ 已写入 " + outPath);
|
|
129
|
+
console.log("\n下一步:");
|
|
130
|
+
console.log(` cd ${targetDir}`);
|
|
131
|
+
console.log(" sandy");
|
|
132
|
+
console.log("\n飞书后台若尚未配置长连接与权限,请按上文「飞书后台还需完成」逐项检查。");
|
|
86
133
|
} finally {
|
|
87
134
|
prompt.close();
|
|
88
135
|
}
|
package/src/prompt.ts
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import readline from "node:readline/promises";
|
|
2
2
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
3
|
|
|
4
|
+
export type GuideLink = {
|
|
5
|
+
label: string;
|
|
6
|
+
url: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type FieldGuide = {
|
|
10
|
+
title: string;
|
|
11
|
+
lines: string[];
|
|
12
|
+
links?: GuideLink[];
|
|
13
|
+
};
|
|
14
|
+
|
|
4
15
|
export type Prompter = {
|
|
16
|
+
section: (title: string) => void;
|
|
17
|
+
guide: (guide: FieldGuide) => void;
|
|
5
18
|
ask: (label: string, defaultValue?: string) => Promise<string>;
|
|
6
19
|
askRequired: (label: string) => Promise<string>;
|
|
20
|
+
askWithGuide: (guide: FieldGuide, label: string, options?: { required?: boolean; defaultValue?: string }) => Promise<string>;
|
|
21
|
+
askYesNoWithGuide: (guide: FieldGuide, label: string, defaultValue?: boolean) => Promise<boolean>;
|
|
7
22
|
askYesNo: (label: string, defaultValue?: boolean) => Promise<boolean>;
|
|
8
23
|
close: () => void;
|
|
9
24
|
};
|
|
@@ -11,10 +26,27 @@ export type Prompter = {
|
|
|
11
26
|
export function createPrompter(): Prompter {
|
|
12
27
|
const rl = readline.createInterface({ input, output });
|
|
13
28
|
|
|
29
|
+
function section(title: string): void {
|
|
30
|
+
console.log(`\n${title}`);
|
|
31
|
+
console.log("─".repeat(Math.min(title.length + 4, 60)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function guide(g: FieldGuide): void {
|
|
35
|
+
console.log(`\n▸ ${g.title}`);
|
|
36
|
+
for (const line of g.lines) {
|
|
37
|
+
console.log(` ${line}`);
|
|
38
|
+
}
|
|
39
|
+
if (g.links?.length) {
|
|
40
|
+
for (const link of g.links) {
|
|
41
|
+
console.log(` → ${link.label}: ${link.url}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
14
46
|
async function ask(label: string, defaultValue?: string): Promise<string> {
|
|
15
47
|
const hint =
|
|
16
48
|
defaultValue !== undefined && defaultValue !== ""
|
|
17
|
-
? `
|
|
49
|
+
? ` [默认: ${defaultValue}]`
|
|
18
50
|
: "";
|
|
19
51
|
const answer = (await rl.question(`${label}${hint}: `)).trim();
|
|
20
52
|
if (!answer && defaultValue !== undefined) return defaultValue;
|
|
@@ -25,8 +57,20 @@ export function createPrompter(): Prompter {
|
|
|
25
57
|
while (true) {
|
|
26
58
|
const answer = (await rl.question(`${label}: `)).trim();
|
|
27
59
|
if (answer) return answer;
|
|
28
|
-
console.log("此项必填,请重新输入。");
|
|
60
|
+
console.log(" 此项必填,请重新输入。");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function askWithGuide(
|
|
65
|
+
g: FieldGuide,
|
|
66
|
+
label: string,
|
|
67
|
+
options?: { required?: boolean; defaultValue?: string },
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
guide(g);
|
|
70
|
+
if (options?.required) {
|
|
71
|
+
return askRequired(label);
|
|
29
72
|
}
|
|
73
|
+
return ask(label, options?.defaultValue);
|
|
30
74
|
}
|
|
31
75
|
|
|
32
76
|
async function askYesNo(label: string, defaultValue = false): Promise<boolean> {
|
|
@@ -35,13 +79,26 @@ export function createPrompter(): Prompter {
|
|
|
35
79
|
if (!answer) return defaultValue;
|
|
36
80
|
if (["y", "yes", "是", "1", "true"].includes(answer)) return true;
|
|
37
81
|
if (["n", "no", "否", "0", "false"].includes(answer)) return false;
|
|
38
|
-
console.log("请输入 y 或 n。");
|
|
82
|
+
console.log(" 请输入 y 或 n。");
|
|
83
|
+
return askYesNo(label, defaultValue);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function askYesNoWithGuide(
|
|
87
|
+
g: FieldGuide,
|
|
88
|
+
label: string,
|
|
89
|
+
defaultValue = false,
|
|
90
|
+
): Promise<boolean> {
|
|
91
|
+
guide(g);
|
|
39
92
|
return askYesNo(label, defaultValue);
|
|
40
93
|
}
|
|
41
94
|
|
|
42
95
|
return {
|
|
96
|
+
section,
|
|
97
|
+
guide,
|
|
43
98
|
ask,
|
|
44
99
|
askRequired,
|
|
100
|
+
askWithGuide,
|
|
101
|
+
askYesNoWithGuide,
|
|
45
102
|
askYesNo,
|
|
46
103
|
close: () => rl.close(),
|
|
47
104
|
};
|