@chenglu.she/sandy 1.0.1 → 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/README.md +23 -21
- package/bin/feishu-cursor-bot.js +2 -2
- package/config.yaml.example +13 -0
- package/package.json +6 -6
- package/src/cli.ts +12 -0
- package/src/config-io.ts +109 -0
- package/src/config-schema.ts +34 -0
- package/src/config.ts +5 -82
- package/src/index.ts +1 -0
- package/src/init-guides.ts +132 -0
- package/src/init.ts +136 -0
- package/src/prompt.ts +105 -0
- package/.env.example +0 -24
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
<p align="center"><b>Sandy</b> — 飞书长连接机器人。</p>
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
通用脚手架:在工作目录放一份 `config.yaml` +(可选)`.cursor/rules`,装好依赖后启动即可。
|
|
12
12
|
|
|
13
13
|
## 需要什么
|
|
14
14
|
|
|
@@ -22,38 +22,40 @@
|
|
|
22
22
|
|
|
23
23
|
`npm install -g @chenglu.she/sandy`
|
|
24
24
|
|
|
25
|
-
### 2.
|
|
25
|
+
### 2. 初始化配置
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
在你要运行 bot 的目录执行(例如 `~/treedome`):
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
mkdir -p ~/treedome && cd ~/treedome
|
|
31
|
-
|
|
31
|
+
sandy init
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
会交互式询问飞书 / Cursor / Agent 等项,并写入当前目录的 `config.yaml`。也可参考 [config.yaml.example](./config.yaml.example) 手改。
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
必填项:
|
|
37
|
+
|
|
38
|
+
| 配置项 | 说明 |
|
|
37
39
|
|------|------|
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
40
|
+
| `feishu.appId` | 飞书应用 App ID |
|
|
41
|
+
| `feishu.appSecret` | 飞书应用 Secret |
|
|
42
|
+
| `cursor.apiKey` | Cursor API Key |
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
常用可选项:
|
|
43
45
|
|
|
44
|
-
|
|
|
46
|
+
| 配置项 | 默认 | 说明 |
|
|
45
47
|
|------|------|------|
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
48
|
+
| `agent.name` | `Sandy` | Agent 显示名 |
|
|
49
|
+
| `agent.cwd` | 工作目录 | Agent 工作区(会加载这里的 `.cursor/rules`) |
|
|
50
|
+
| `agent.dirs` | `[]` | 额外可访问目录 |
|
|
51
|
+
| `agent.dirLinks` | `[]` | `agent.cwd` 下要一并放行的 symlink 名 |
|
|
52
|
+
| `agent.sandbox` | `false` | `true` 时开 Cursor 本地沙箱 |
|
|
53
|
+
| `cursor.model` | `auto` | 模型 id |
|
|
54
|
+
| `feishuDocsFolder` | (空) | 创建飞书文档时的默认 Drive `folder_token` |
|
|
53
55
|
|
|
54
56
|
### 3. (推荐)放项目规则
|
|
55
57
|
|
|
56
|
-
在 `
|
|
58
|
+
在 `agent.cwd`(默认就是运行目录)下:
|
|
57
59
|
|
|
58
60
|
```text
|
|
59
61
|
.cursor/rules/your-persona.mdc
|
|
@@ -68,7 +70,7 @@ Agent 通过 `settingSources: ["project"]` 加载这些规则。人设、业务
|
|
|
68
70
|
2. **权限**(开通后**发布新版本**才生效),至少:
|
|
69
71
|
- 消息:获取与发送单聊/群消息、上传图片、上传文件、下载文件(如 `im:message`、`im:resource`)
|
|
70
72
|
- 文档:读正文、创建文档、编辑文档(如 `docx:document:readonly`、`docx:document` / write)
|
|
71
|
-
- 若要把新建文档放到指定文件夹:Drive 文件夹相关权限,并填 `
|
|
73
|
+
- 若要把新建文档放到指定文件夹:Drive 文件夹相关权限,并填 `feishuDocsFolder`
|
|
72
74
|
3. **版本管理**:发布;可用范围包含你自己
|
|
73
75
|
4. **事件与回调** → 订阅方式选 **长连接**(先让 bot 进程在线再保存)
|
|
74
76
|
5. 事件:`im.message.receive_v1`
|
|
@@ -114,7 +116,7 @@ bash scripts/sandy-ctl.sh logs
|
|
|
114
116
|
|
|
115
117
|
```bash
|
|
116
118
|
npm run dev # tsx watch
|
|
117
|
-
npm start # 等同
|
|
119
|
+
npm start # 等同 sandy(读 cwd config.yaml)
|
|
118
120
|
npm run typecheck
|
|
119
121
|
```
|
|
120
122
|
|
package/bin/feishu-cursor-bot.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* CLI entry: run from any directory; loads
|
|
3
|
+
* CLI entry: run from any directory; loads `config.yaml` from cwd (see src/config-io.ts).
|
|
4
4
|
*
|
|
5
5
|
* npx feishu-cursor-bot
|
|
6
6
|
* npm i -g . && feishu-cursor-bot
|
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
|
|
13
13
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
-
const entry = path.join(packageRoot, "src", "
|
|
14
|
+
const entry = path.join(packageRoot, "src", "cli.ts");
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
16
|
|
|
17
17
|
let tsxCli;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chenglu.she/sandy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Feishu long-connection bot that drives Cursor agents via @cursor/sdk",
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
"assets",
|
|
27
27
|
"README.md",
|
|
28
28
|
"LICENSE",
|
|
29
|
-
".
|
|
29
|
+
"config.yaml.example"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"dev": "tsx watch src/
|
|
33
|
-
"start": "tsx src/
|
|
32
|
+
"dev": "tsx watch src/cli.ts",
|
|
33
|
+
"start": "tsx src/cli.ts",
|
|
34
34
|
"typecheck": "tsc --noEmit"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@cursor/sdk": "^1.0.0",
|
|
41
41
|
"@larksuiteoapi/node-sdk": "^1.55.0",
|
|
42
|
-
"
|
|
43
|
-
"
|
|
42
|
+
"tsx": "^4.20.3",
|
|
43
|
+
"yaml": "^2.8.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^22.17.0",
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const subcommand = process.argv[2];
|
|
2
|
+
|
|
3
|
+
if (subcommand === "init") {
|
|
4
|
+
const { runInit } = await import("./init.js");
|
|
5
|
+
await runInit(process.argv.slice(3));
|
|
6
|
+
} else if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
|
7
|
+
console.log(`Usage:
|
|
8
|
+
sandy Start the Feishu bot (reads ./config.yaml)
|
|
9
|
+
sandy init Interactive setup — writes config.yaml`);
|
|
10
|
+
} else {
|
|
11
|
+
await import("./index.js");
|
|
12
|
+
}
|
package/src/config-io.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
6
|
+
import type { SandyConfigFile } from "./config-schema.js";
|
|
7
|
+
|
|
8
|
+
export const packageRoot = path.resolve(
|
|
9
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
10
|
+
"..",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/** Workspace that owns `config.yaml` — usually where you run the CLI. */
|
|
14
|
+
export const workDir = (() => {
|
|
15
|
+
const override = process.env.SANDY_CWD?.trim() || process.env.FEISHU_CURSOR_CWD?.trim();
|
|
16
|
+
if (!override) return process.cwd();
|
|
17
|
+
return expandPath(override, process.cwd());
|
|
18
|
+
})();
|
|
19
|
+
|
|
20
|
+
export function configFilePath(base = workDir): string {
|
|
21
|
+
const override = process.env.SANDY_CONFIG?.trim();
|
|
22
|
+
if (override) return expandPath(override, base);
|
|
23
|
+
return path.join(base, "config.yaml");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function expandPath(raw: string, base: string = process.cwd()): string {
|
|
27
|
+
const trimmed = raw.trim();
|
|
28
|
+
if (trimmed === "~") return os.homedir();
|
|
29
|
+
if (trimmed.startsWith("~/")) {
|
|
30
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
31
|
+
}
|
|
32
|
+
return path.isAbsolute(trimmed) ? trimmed : path.resolve(base, trimmed);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readConfigFile(filePath: string): SandyConfigFile {
|
|
36
|
+
if (!fs.existsSync(filePath)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Config not found: ${filePath}\nRun \`sandy init\` in ${workDir} to create config.yaml.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
42
|
+
const parsed = parseYaml(raw) as SandyConfigFile | null;
|
|
43
|
+
if (!parsed || typeof parsed !== "object") {
|
|
44
|
+
throw new Error(`Invalid config.yaml: ${filePath}`);
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeConfigFile(filePath: string, data: SandyConfigFile): void {
|
|
50
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
51
|
+
const body = stringifyYaml(data, {
|
|
52
|
+
lineWidth: 0,
|
|
53
|
+
defaultStringType: "QUOTE_DOUBLE",
|
|
54
|
+
});
|
|
55
|
+
fs.writeFileSync(filePath, body.endsWith("\n") ? body : `${body}\n`, "utf8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseBool(raw: unknown, defaultValue: boolean): boolean {
|
|
59
|
+
if (raw === undefined || raw === null || raw === "") return defaultValue;
|
|
60
|
+
if (typeof raw === "boolean") return raw;
|
|
61
|
+
const v = String(raw).trim().toLowerCase();
|
|
62
|
+
if (["1", "true", "yes", "on"].includes(v)) return true;
|
|
63
|
+
if (["0", "false", "no", "off"].includes(v)) return false;
|
|
64
|
+
return defaultValue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requiredField(label: string, value: string | undefined, filePath: string): string {
|
|
68
|
+
const trimmed = value?.trim();
|
|
69
|
+
if (!trimmed) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Missing required config: ${label} (set it in ${filePath}, or run \`sandy init\`)`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return trimmed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function loadRuntimeConfig() {
|
|
78
|
+
const filePath = configFilePath();
|
|
79
|
+
const fileConfig = readConfigFile(filePath);
|
|
80
|
+
const agentSection = fileConfig.agent ?? {};
|
|
81
|
+
const agentCwdRaw = agentSection.cwd?.trim() || workDir;
|
|
82
|
+
const agentDirs = Array.isArray(agentSection.dirs)
|
|
83
|
+
? [...new Set(agentSection.dirs.map((p) => expandPath(String(p), workDir)).filter(Boolean))]
|
|
84
|
+
: [];
|
|
85
|
+
const agentDirLinks = Array.isArray(agentSection.dirLinks)
|
|
86
|
+
? [...new Set(agentSection.dirLinks.map((s) => String(s).trim()).filter(Boolean))]
|
|
87
|
+
: [];
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
packageRoot,
|
|
91
|
+
workDir,
|
|
92
|
+
configPath: filePath,
|
|
93
|
+
feishuAppId: requiredField("feishu.appId", fileConfig.feishu?.appId, filePath),
|
|
94
|
+
feishuAppSecret: requiredField("feishu.appSecret", fileConfig.feishu?.appSecret, filePath),
|
|
95
|
+
cursorApiKey: requiredField("cursor.apiKey", fileConfig.cursor?.apiKey, filePath),
|
|
96
|
+
cursorModel: fileConfig.cursor?.model?.trim() || "auto",
|
|
97
|
+
agentName: agentSection.name?.trim() || "Sandy",
|
|
98
|
+
agentCwd: expandPath(String(agentCwdRaw), workDir),
|
|
99
|
+
agentDirs,
|
|
100
|
+
agentDirLinks,
|
|
101
|
+
agentSandbox: parseBool(agentSection.sandbox, false),
|
|
102
|
+
sessionStorePath: path.resolve(workDir, ".data", "sessions.json"),
|
|
103
|
+
pendingStorePath: path.resolve(workDir, ".data", "pending-questions.json"),
|
|
104
|
+
feishuDocsFolder: fileConfig.feishuDocsFolder?.trim() || "",
|
|
105
|
+
inboxDir: path.join(expandPath(String(agentCwdRaw), workDir), ".data", "feishu-inbox"),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type RuntimeConfig = ReturnType<typeof loadRuntimeConfig>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** On-disk config.yaml shape. */
|
|
2
|
+
export type SandyConfigFile = {
|
|
3
|
+
feishu: {
|
|
4
|
+
appId: string;
|
|
5
|
+
appSecret: string;
|
|
6
|
+
};
|
|
7
|
+
cursor: {
|
|
8
|
+
apiKey: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
};
|
|
11
|
+
agent?: {
|
|
12
|
+
name?: string;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
dirs?: string[];
|
|
15
|
+
dirLinks?: string[];
|
|
16
|
+
sandbox?: boolean;
|
|
17
|
+
};
|
|
18
|
+
feishuDocsFolder?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function defaultConfigFile(workDir: string): SandyConfigFile {
|
|
22
|
+
return {
|
|
23
|
+
feishu: { appId: "", appSecret: "" },
|
|
24
|
+
cursor: { apiKey: "", model: "auto" },
|
|
25
|
+
agent: {
|
|
26
|
+
name: "Sandy",
|
|
27
|
+
cwd: workDir,
|
|
28
|
+
dirs: [],
|
|
29
|
+
dirLinks: [],
|
|
30
|
+
sandbox: false,
|
|
31
|
+
},
|
|
32
|
+
feishuDocsFolder: "",
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,93 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import dotenv from "dotenv";
|
|
1
|
+
import type { RuntimeConfig } from "./config-io.js";
|
|
2
|
+
import { loadRuntimeConfig } from "./config-io.js";
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"..",
|
|
9
|
-
);
|
|
4
|
+
export { expandPath, workDir } from "./config-io.js";
|
|
5
|
+
export type { RuntimeConfig };
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
export const workDir = (() => {
|
|
13
|
-
const override = process.env.FEISHU_CURSOR_CWD?.trim();
|
|
14
|
-
if (!override) return process.cwd();
|
|
15
|
-
return expandPath(override, process.cwd());
|
|
16
|
-
})();
|
|
17
|
-
|
|
18
|
-
// Prefer cwd `.env`, then package-local `.env` as fallback (no override).
|
|
19
|
-
dotenv.config({ path: path.join(workDir, ".env") });
|
|
20
|
-
dotenv.config({ path: path.join(packageRoot, ".env") });
|
|
21
|
-
|
|
22
|
-
function required(name: string): string {
|
|
23
|
-
const value = process.env[name]?.trim();
|
|
24
|
-
if (!value) {
|
|
25
|
-
throw new Error(
|
|
26
|
-
`Missing required env: ${name} (put it in ${path.join(workDir, ".env")})`,
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
return value;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function expandPath(raw: string, base: string = process.cwd()): string {
|
|
33
|
-
const trimmed = raw.trim();
|
|
34
|
-
if (trimmed === "~") return os.homedir();
|
|
35
|
-
if (trimmed.startsWith("~/")) {
|
|
36
|
-
return path.join(os.homedir(), trimmed.slice(2));
|
|
37
|
-
}
|
|
38
|
-
return path.isAbsolute(trimmed) ? trimmed : path.resolve(base, trimmed);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function parseBool(raw: string | undefined, defaultValue: boolean): boolean {
|
|
42
|
-
if (raw === undefined || raw.trim() === "") return defaultValue;
|
|
43
|
-
const v = raw.trim().toLowerCase();
|
|
44
|
-
if (["1", "true", "yes", "on"].includes(v)) return true;
|
|
45
|
-
if (["0", "false", "no", "off"].includes(v)) return false;
|
|
46
|
-
return defaultValue;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const agentCwdRaw = process.env.AGENT_CWD?.trim() || workDir;
|
|
50
|
-
const agentDirsRaw = process.env.AGENT_DIRS?.trim() || "";
|
|
51
|
-
const agentDirs = agentDirsRaw
|
|
52
|
-
? [...new Set(agentDirsRaw.split(",").map((p) => expandPath(p)).filter(Boolean))]
|
|
53
|
-
: [];
|
|
54
|
-
|
|
55
|
-
/** Optional symlink names under AGENT_CWD to also allow (comma-separated). */
|
|
56
|
-
const agentDirLinksRaw = process.env.AGENT_DIR_LINKS?.trim() || "";
|
|
57
|
-
export const agentDirLinks = agentDirLinksRaw
|
|
58
|
-
? [...new Set(agentDirLinksRaw.split(",").map((s) => s.trim()).filter(Boolean))]
|
|
59
|
-
: [];
|
|
60
|
-
|
|
61
|
-
export const config = {
|
|
62
|
-
packageRoot,
|
|
63
|
-
workDir,
|
|
64
|
-
feishuAppId: required("FEISHU_APP_ID"),
|
|
65
|
-
feishuAppSecret: required("FEISHU_APP_SECRET"),
|
|
66
|
-
cursorApiKey: required("CURSOR_API_KEY"),
|
|
67
|
-
cursorModel: process.env.CURSOR_MODEL?.trim() || "auto",
|
|
68
|
-
/** Display name for the Cursor agent / bot persona. */
|
|
69
|
-
agentName: process.env.AGENT_NAME?.trim() || "Sandy",
|
|
70
|
-
/** Primary workspace root for the agent. Defaults to cwd. */
|
|
71
|
-
agentCwd: expandPath(String(agentCwdRaw)),
|
|
72
|
-
/** Optional extra workspace roots (multi-root). */
|
|
73
|
-
agentDirs,
|
|
74
|
-
agentDirLinks,
|
|
75
|
-
/** Enable Cursor local sandbox (FS + shell + default deny network for shell). */
|
|
76
|
-
agentSandbox: parseBool(process.env.AGENT_SANDBOX, false),
|
|
77
|
-
sessionStorePath: path.resolve(workDir, ".data", "sessions.json"),
|
|
78
|
-
pendingStorePath: path.resolve(workDir, ".data", "pending-questions.json"),
|
|
79
|
-
/** Optional Drive folder_token for feishu_doc_create default placement. */
|
|
80
|
-
feishuDocsFolder: process.env.FEISHU_DOCS_FOLDER?.trim() || "",
|
|
81
|
-
/** Where incoming Feishu files are saved (under AGENT_CWD so the agent can read them). */
|
|
82
|
-
inboxDir: path.join(expandPath(String(agentCwdRaw)), ".data", "feishu-inbox"),
|
|
83
|
-
};
|
|
7
|
+
export const config: RuntimeConfig = loadRuntimeConfig();
|
|
84
8
|
|
|
85
9
|
export function localAgentOptions() {
|
|
86
10
|
return {
|
|
87
11
|
cwd: config.agentCwd,
|
|
88
12
|
...(config.agentDirs.length > 0 ? { dirs: config.agentDirs } : {}),
|
|
89
13
|
...(config.agentSandbox ? { sandboxOptions: { enabled: true as const } } : {}),
|
|
90
|
-
// Load AGENTS.md / .cursor/rules from AGENT_CWD.
|
|
91
14
|
settingSources: ["project" as const],
|
|
92
15
|
};
|
|
93
16
|
}
|
package/src/index.ts
CHANGED
|
@@ -298,6 +298,7 @@ async function handleCardAction(data: unknown) {
|
|
|
298
298
|
|
|
299
299
|
async function main() {
|
|
300
300
|
console.log("[boot] feishu-cursor-bot starting");
|
|
301
|
+
console.log(`[boot] config=${config.configPath}`);
|
|
301
302
|
console.log(`[boot] agent cwd=${config.agentCwd}`);
|
|
302
303
|
console.log(`[boot] agent dirs=${config.agentDirs.join(", ") || "(none)"}`);
|
|
303
304
|
console.log(`[boot] sandbox=${config.agentSandbox}`);
|
|
@@ -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
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
configFilePath,
|
|
4
|
+
expandPath,
|
|
5
|
+
workDir,
|
|
6
|
+
writeConfigFile,
|
|
7
|
+
} from "./config-io.js";
|
|
8
|
+
import { defaultConfigFile, type SandyConfigFile } from "./config-schema.js";
|
|
9
|
+
import { INIT_FIELD_GUIDES } from "./init-guides.js";
|
|
10
|
+
import { createPrompter } from "./prompt.js";
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv: string[]): { targetDir: string } {
|
|
13
|
+
let targetDir = workDir;
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const arg = argv[i];
|
|
16
|
+
if (arg === "--dir" || arg === "-C") {
|
|
17
|
+
const next = argv[i + 1];
|
|
18
|
+
if (!next) throw new Error(`${arg} requires a directory path`);
|
|
19
|
+
targetDir = expandPath(next, process.cwd());
|
|
20
|
+
i++;
|
|
21
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
22
|
+
console.log(`Usage: sandy init [--dir <path>]
|
|
23
|
+
|
|
24
|
+
Create config.yaml interactively in the target directory (default: current cwd).`);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
} else {
|
|
27
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { targetDir };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function splitList(raw: string): string[] {
|
|
34
|
+
return raw
|
|
35
|
+
.split(",")
|
|
36
|
+
.map((s) => s.trim())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runInit(argv: string[] = []): Promise<void> {
|
|
41
|
+
const { targetDir } = parseArgs(argv);
|
|
42
|
+
const outPath = configFilePath(targetDir);
|
|
43
|
+
const prompt = createPrompter();
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
console.log("Sandy 初始化");
|
|
47
|
+
console.log("会逐步说明每项如何获取,并写入 config.yaml。\n");
|
|
48
|
+
console.log(`目标目录: ${targetDir}`);
|
|
49
|
+
console.log(`配置文件: ${outPath}`);
|
|
50
|
+
|
|
51
|
+
if (fs.existsSync(outPath)) {
|
|
52
|
+
const overwrite = await prompt.askYesNo("\n已存在 config.yaml,是否覆盖?", false);
|
|
53
|
+
if (!overwrite) {
|
|
54
|
+
console.log("已取消。");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const cfg: SandyConfigFile = defaultConfigFile(targetDir);
|
|
60
|
+
|
|
61
|
+
prompt.section("一、飞书应用");
|
|
62
|
+
prompt.guide(INIT_FIELD_GUIDES.feishuAppHome);
|
|
63
|
+
|
|
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
|
+
);
|
|
95
|
+
|
|
96
|
+
const dirsRaw = await prompt.askWithGuide(
|
|
97
|
+
INIT_FIELD_GUIDES.agentDirs,
|
|
98
|
+
"额外目录(逗号分隔,可留空)",
|
|
99
|
+
{ defaultValue: "" },
|
|
100
|
+
);
|
|
101
|
+
cfg.agent!.dirs = dirsRaw ? splitList(dirsRaw) : [];
|
|
102
|
+
|
|
103
|
+
const linksRaw = await prompt.askWithGuide(
|
|
104
|
+
INIT_FIELD_GUIDES.agentDirLinks,
|
|
105
|
+
"symlink 名(逗号分隔,可留空)",
|
|
106
|
+
{ defaultValue: "" },
|
|
107
|
+
);
|
|
108
|
+
cfg.agent!.dirLinks = linksRaw ? splitList(linksRaw) : [];
|
|
109
|
+
|
|
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
|
+
);
|
|
125
|
+
|
|
126
|
+
writeConfigFile(outPath, cfg);
|
|
127
|
+
|
|
128
|
+
console.log("\n✓ 已写入 " + outPath);
|
|
129
|
+
console.log("\n下一步:");
|
|
130
|
+
console.log(` cd ${targetDir}`);
|
|
131
|
+
console.log(" sandy");
|
|
132
|
+
console.log("\n飞书后台若尚未配置长连接与权限,请按上文「飞书后台还需完成」逐项检查。");
|
|
133
|
+
} finally {
|
|
134
|
+
prompt.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
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
|
+
|
|
15
|
+
export type Prompter = {
|
|
16
|
+
section: (title: string) => void;
|
|
17
|
+
guide: (guide: FieldGuide) => void;
|
|
18
|
+
ask: (label: string, defaultValue?: string) => Promise<string>;
|
|
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>;
|
|
22
|
+
askYesNo: (label: string, defaultValue?: boolean) => Promise<boolean>;
|
|
23
|
+
close: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function createPrompter(): Prompter {
|
|
27
|
+
const rl = readline.createInterface({ input, output });
|
|
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
|
+
|
|
46
|
+
async function ask(label: string, defaultValue?: string): Promise<string> {
|
|
47
|
+
const hint =
|
|
48
|
+
defaultValue !== undefined && defaultValue !== ""
|
|
49
|
+
? ` [默认: ${defaultValue}]`
|
|
50
|
+
: "";
|
|
51
|
+
const answer = (await rl.question(`${label}${hint}: `)).trim();
|
|
52
|
+
if (!answer && defaultValue !== undefined) return defaultValue;
|
|
53
|
+
return answer;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function askRequired(label: string): Promise<string> {
|
|
57
|
+
while (true) {
|
|
58
|
+
const answer = (await rl.question(`${label}: `)).trim();
|
|
59
|
+
if (answer) return answer;
|
|
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);
|
|
72
|
+
}
|
|
73
|
+
return ask(label, options?.defaultValue);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function askYesNo(label: string, defaultValue = false): Promise<boolean> {
|
|
77
|
+
const hint = defaultValue ? "Y/n" : "y/N";
|
|
78
|
+
const answer = (await rl.question(`${label} [${hint}]: `)).trim().toLowerCase();
|
|
79
|
+
if (!answer) return defaultValue;
|
|
80
|
+
if (["y", "yes", "是", "1", "true"].includes(answer)) return true;
|
|
81
|
+
if (["n", "no", "否", "0", "false"].includes(answer)) return false;
|
|
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);
|
|
92
|
+
return askYesNo(label, defaultValue);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
section,
|
|
97
|
+
guide,
|
|
98
|
+
ask,
|
|
99
|
+
askRequired,
|
|
100
|
+
askWithGuide,
|
|
101
|
+
askYesNoWithGuide,
|
|
102
|
+
askYesNo,
|
|
103
|
+
close: () => rl.close(),
|
|
104
|
+
};
|
|
105
|
+
}
|
package/.env.example
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
FEISHU_APP_ID=cli_xxx
|
|
2
|
-
FEISHU_APP_SECRET=xxx
|
|
3
|
-
CURSOR_API_KEY=crsr_xxx
|
|
4
|
-
|
|
5
|
-
# Agent display name (shown in Cursor / logs)
|
|
6
|
-
AGENT_NAME=Sandy
|
|
7
|
-
|
|
8
|
-
# Workspace the agent works in (defaults to current working directory)
|
|
9
|
-
# AGENT_CWD=.
|
|
10
|
-
|
|
11
|
-
# Extra roots the agent may use, comma-separated
|
|
12
|
-
# AGENT_DIRS=~/code/api,~/code/web
|
|
13
|
-
|
|
14
|
-
# Symlink names under AGENT_CWD whose targets should also be allowlisted
|
|
15
|
-
# AGENT_DIR_LINKS=api,web
|
|
16
|
-
|
|
17
|
-
# false ≈ full local shell (SSH / git / deploy). true = Cursor sandbox.
|
|
18
|
-
AGENT_SANDBOX=false
|
|
19
|
-
|
|
20
|
-
# Model id for @cursor/sdk
|
|
21
|
-
CURSOR_MODEL=auto
|
|
22
|
-
|
|
23
|
-
# Optional Drive folder_token for feishu_doc_create default placement
|
|
24
|
-
# FEISHU_DOCS_FOLDER=
|