@chenglu.she/sandy 1.0.4 → 1.0.6
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 +9 -3
- package/package.json +1 -1
- package/src/ask-question.ts +91 -38
- package/src/ask-waiters.ts +44 -0
- package/src/authorize.ts +185 -0
- package/src/cli.ts +13 -2
- package/src/cursor-agent.ts +30 -39
- package/src/feishu-tools.ts +72 -0
- package/src/feishu.ts +25 -10
- package/src/index.ts +47 -7
- package/src/init-guides.ts +0 -20
- package/src/init.ts +13 -12
- package/templates/sandy.mdc +1 -1
package/README.md
CHANGED
|
@@ -31,7 +31,13 @@ mkdir -p ~/treedome && cd ~/treedome
|
|
|
31
31
|
sandy init
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
会交互式询问飞书 / Cursor / Agent
|
|
34
|
+
会交互式询问飞书 / Cursor / Agent 等项,写入 `config.yaml`;在 macOS 上接着触发磁盘授权弹窗(桌面 / 文稿 / 下载等)。人设模板见 [templates/sandy.mdc](./templates/sandy.mdc),可复制到 `.cursor/rules/`。
|
|
35
|
+
|
|
36
|
+
新机器务必在**电脑屏幕前**跑 init(不要 SSH):弹出的「node 想访问某某文件夹」全部点「允许」,并把提示里的 Node 路径加到「完全磁盘访问权限」。以后单独补授权:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
sandy authorize # 别名:sandy diskauth
|
|
40
|
+
```
|
|
35
41
|
|
|
36
42
|
必填项:
|
|
37
43
|
|
|
@@ -86,7 +92,7 @@ cd ~/treedome
|
|
|
86
92
|
sandy
|
|
87
93
|
```
|
|
88
94
|
|
|
89
|
-
看到 `ws client ready`
|
|
95
|
+
看到 `ws client ready` 后,飞书里私聊机器人即可。远程任务卡住、回家才看到 node 访问目录的授权框,再跑一次 `sandy authorize`。
|
|
90
96
|
|
|
91
97
|
#### macOS 常驻(可选)
|
|
92
98
|
|
|
@@ -109,7 +115,7 @@ bash scripts/sandy-ctl.sh logs
|
|
|
109
115
|
| 同会话 | `Agent.resume` 多轮;映射在 cwd 的 `.data/sessions.json` |
|
|
110
116
|
| `/new` `/reset` `重置` `新对话` | 清空会话,下次新建 Agent |
|
|
111
117
|
| 连续消息 | 按会话排队;排队 `OneSecond`,处理中 `OnIt` |
|
|
112
|
-
| `askQuestion` |
|
|
118
|
+
| `askQuestion` | 走 `feishu_ask_question`:飞书先发题目正文,再发可点选卡片;点选或回编号后续跑 |
|
|
113
119
|
| 用户发文件/图 | 下载到 `AGENT_CWD/.data/feishu-inbox/…`,路径写入 prompt |
|
|
114
120
|
| Agent 发回文件 | 工具 `feishu_send_file`(本地路径 → 飞书回复) |
|
|
115
121
|
| 飞书文档 | `feishu_doc_read` / `feishu_doc_create` / `feishu_doc_append` |
|
package/package.json
CHANGED
package/src/ask-question.ts
CHANGED
|
@@ -23,28 +23,41 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function parseOptions(raw: unknown): AskOption[] {
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
26
|
+
if (Array.isArray(raw)) {
|
|
27
|
+
return raw
|
|
28
|
+
.map((item, index) => {
|
|
29
|
+
if (typeof item === "string" && item.trim()) {
|
|
30
|
+
return { id: `opt_${index + 1}`, label: item.trim() };
|
|
31
|
+
}
|
|
32
|
+
const obj = asRecord(item);
|
|
33
|
+
if (!obj) return undefined;
|
|
34
|
+
const id =
|
|
35
|
+
typeof obj.id === "string"
|
|
36
|
+
? obj.id
|
|
37
|
+
: typeof obj.value === "string"
|
|
38
|
+
? obj.value
|
|
39
|
+
: `opt_${index + 1}`;
|
|
40
|
+
const label =
|
|
41
|
+
typeof obj.label === "string"
|
|
42
|
+
? obj.label
|
|
43
|
+
: typeof obj.text === "string"
|
|
44
|
+
? obj.text
|
|
45
|
+
: typeof obj.title === "string"
|
|
46
|
+
? obj.title
|
|
47
|
+
: typeof obj.description === "string"
|
|
48
|
+
? obj.description
|
|
49
|
+
: String(id);
|
|
50
|
+
return { id, label };
|
|
51
|
+
})
|
|
52
|
+
.filter((x): x is AskOption => Boolean(x));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const obj = asRecord(raw);
|
|
56
|
+
if (!obj) return [];
|
|
57
|
+
return Object.entries(obj).map(([id, label], index) => ({
|
|
58
|
+
id: id || `opt_${index + 1}`,
|
|
59
|
+
label: typeof label === "string" && label.trim() ? label : String(id),
|
|
60
|
+
}));
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
function parseQuestions(raw: unknown): AskQuestion[] {
|
|
@@ -66,9 +79,10 @@ function parseQuestions(raw: unknown): AskQuestion[] {
|
|
|
66
79
|
? obj.question
|
|
67
80
|
: typeof obj.text === "string"
|
|
68
81
|
? obj.text
|
|
69
|
-
:
|
|
82
|
+
: typeof obj.header === "string"
|
|
83
|
+
? obj.header
|
|
84
|
+
: `问题 ${index + 1}`;
|
|
70
85
|
const options = parseOptions(obj.options ?? obj.choices);
|
|
71
|
-
if (options.length === 0) return undefined;
|
|
72
86
|
return {
|
|
73
87
|
id,
|
|
74
88
|
prompt,
|
|
@@ -81,7 +95,7 @@ function parseQuestions(raw: unknown): AskQuestion[] {
|
|
|
81
95
|
|
|
82
96
|
/** Defensive parse of askQuestion tool args / tool_use input. */
|
|
83
97
|
export function parseAskQuestionArgs(args: unknown): ParsedAskQuestion | undefined {
|
|
84
|
-
const root = asRecord(args);
|
|
98
|
+
const root = asRecord(coerceJson(args));
|
|
85
99
|
if (!root) return undefined;
|
|
86
100
|
|
|
87
101
|
const nested =
|
|
@@ -91,22 +105,22 @@ export function parseAskQuestionArgs(args: unknown): ParsedAskQuestion | undefin
|
|
|
91
105
|
root;
|
|
92
106
|
|
|
93
107
|
const questions = parseQuestions(
|
|
94
|
-
nested.questions ?? nested.question_list ?? nested.items,
|
|
108
|
+
coerceJson(nested.questions ?? nested.question_list ?? nested.items),
|
|
95
109
|
);
|
|
96
110
|
if (questions.length === 0) {
|
|
97
|
-
// Single-question flattened shape
|
|
98
111
|
const options = parseOptions(nested.options ?? nested.choices);
|
|
99
|
-
|
|
112
|
+
const prompt =
|
|
113
|
+
typeof nested.prompt === "string"
|
|
114
|
+
? nested.prompt
|
|
115
|
+
: typeof nested.question === "string"
|
|
116
|
+
? nested.question
|
|
117
|
+
: typeof nested.title === "string"
|
|
118
|
+
? nested.title
|
|
119
|
+
: "";
|
|
120
|
+
if (options.length > 0 || prompt) {
|
|
100
121
|
questions.push({
|
|
101
122
|
id: typeof nested.id === "string" ? nested.id : "q1",
|
|
102
|
-
prompt:
|
|
103
|
-
typeof nested.prompt === "string"
|
|
104
|
-
? nested.prompt
|
|
105
|
-
: typeof nested.question === "string"
|
|
106
|
-
? nested.question
|
|
107
|
-
: typeof nested.title === "string"
|
|
108
|
-
? nested.title
|
|
109
|
-
: "请选择",
|
|
123
|
+
prompt: prompt || "请选择",
|
|
110
124
|
options,
|
|
111
125
|
allowMultiple: Boolean(nested.allowMultiple ?? nested.allow_multiple),
|
|
112
126
|
});
|
|
@@ -121,10 +135,49 @@ export function parseAskQuestionArgs(args: unknown): ParsedAskQuestion | undefin
|
|
|
121
135
|
};
|
|
122
136
|
}
|
|
123
137
|
|
|
124
|
-
|
|
138
|
+
function coerceJson(raw: unknown): unknown {
|
|
139
|
+
if (typeof raw !== "string") return raw;
|
|
140
|
+
const trimmed = raw.trim();
|
|
141
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return raw;
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(trimmed);
|
|
144
|
+
} catch {
|
|
145
|
+
return raw;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Built-in Cursor AskQuestion only — not our Feishu custom tool. */
|
|
150
|
+
export function isBuiltinAskQuestionToolName(name: string | undefined): boolean {
|
|
125
151
|
if (!name) return false;
|
|
126
152
|
const n = name.replace(/[_-]/g, "").toLowerCase();
|
|
127
|
-
return n === "askquestion"
|
|
153
|
+
return n === "askquestion";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function isAskQuestionToolName(name: string | undefined): boolean {
|
|
157
|
+
return isBuiltinAskQuestionToolName(name);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Last-resort ask payload when the tool fired but args did not parse. */
|
|
161
|
+
export function fallbackAskFromRaw(args: unknown): ParsedAskQuestion {
|
|
162
|
+
const parsed = parseAskQuestionArgs(args);
|
|
163
|
+
if (parsed) return parsed;
|
|
164
|
+
const dump =
|
|
165
|
+
typeof args === "string"
|
|
166
|
+
? args
|
|
167
|
+
: args == null
|
|
168
|
+
? ""
|
|
169
|
+
: JSON.stringify(args, null, 2);
|
|
170
|
+
return {
|
|
171
|
+
title: "需要你的回复",
|
|
172
|
+
questions: [
|
|
173
|
+
{
|
|
174
|
+
id: "q1",
|
|
175
|
+
prompt: dump.trim() || "Agent 发起了询问,但题目解析失败。请直接回复你的选择或补充说明。",
|
|
176
|
+
options: [],
|
|
177
|
+
allowMultiple: false,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
};
|
|
128
181
|
}
|
|
129
182
|
|
|
130
183
|
/** Format selected answers as a follow-up user message for Agent.send. */
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type AskAnswer = {
|
|
2
|
+
questionId: string;
|
|
3
|
+
selectedOptionIds: string[];
|
|
4
|
+
freeformText?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type Waiter = {
|
|
8
|
+
resolve: (answers: AskAnswer[]) => void;
|
|
9
|
+
reject: (err: Error) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const waiters = new Map<string, Waiter>();
|
|
13
|
+
|
|
14
|
+
export function hasAskWaiter(sessionKey: string): boolean {
|
|
15
|
+
return waiters.has(sessionKey);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function waitForAskAnswers(sessionKey: string): Promise<AskAnswer[]> {
|
|
19
|
+
const existing = waiters.get(sessionKey);
|
|
20
|
+
if (existing) {
|
|
21
|
+
existing.reject(new Error("replaced by a new question round"));
|
|
22
|
+
waiters.delete(sessionKey);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
waiters.set(sessionKey, { resolve, reject });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveAskWaiter(sessionKey: string, answers: AskAnswer[]): boolean {
|
|
31
|
+
const waiter = waiters.get(sessionKey);
|
|
32
|
+
if (!waiter) return false;
|
|
33
|
+
waiters.delete(sessionKey);
|
|
34
|
+
waiter.resolve(answers);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cancelAskWaiter(sessionKey: string, reason: string): boolean {
|
|
39
|
+
const waiter = waiters.get(sessionKey);
|
|
40
|
+
if (!waiter) return false;
|
|
41
|
+
waiters.delete(sessionKey);
|
|
42
|
+
waiter.reject(new Error(reason));
|
|
43
|
+
return true;
|
|
44
|
+
}
|
package/src/authorize.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
configFilePath,
|
|
7
|
+
expandPath,
|
|
8
|
+
readConfigFile,
|
|
9
|
+
workDir,
|
|
10
|
+
} from "./config-io.js";
|
|
11
|
+
|
|
12
|
+
export type AuthorizeOptions = {
|
|
13
|
+
extraDirs?: string[];
|
|
14
|
+
/** Open System Settings → Full Disk Access after probing. Default true. */
|
|
15
|
+
openSettings?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ProbeResult = {
|
|
19
|
+
dir: string;
|
|
20
|
+
status: "ok" | "missing" | "denied";
|
|
21
|
+
detail?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function nodeBinary(): string {
|
|
25
|
+
try {
|
|
26
|
+
return fs.realpathSync(process.execPath);
|
|
27
|
+
} catch {
|
|
28
|
+
return process.execPath;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isRemoteSession(): boolean {
|
|
33
|
+
return Boolean(process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extraDirsFromConfig(): string[] {
|
|
37
|
+
try {
|
|
38
|
+
const filePath = configFilePath();
|
|
39
|
+
if (!fs.existsSync(filePath)) return [workDir];
|
|
40
|
+
const file = readConfigFile(filePath);
|
|
41
|
+
const cwdRaw = file.agent?.cwd?.trim() || workDir;
|
|
42
|
+
const dirs = Array.isArray(file.agent?.dirs) ? file.agent.dirs : [];
|
|
43
|
+
return [
|
|
44
|
+
expandPath(String(cwdRaw), workDir),
|
|
45
|
+
...dirs.map((d) => expandPath(String(d), workDir)),
|
|
46
|
+
];
|
|
47
|
+
} catch {
|
|
48
|
+
return [workDir];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function listSubdirs(root: string): string[] {
|
|
53
|
+
try {
|
|
54
|
+
return fs
|
|
55
|
+
.readdirSync(root, { withFileTypes: true })
|
|
56
|
+
.filter((d) => d.isDirectory() || d.isSymbolicLink())
|
|
57
|
+
.map((d) => path.resolve(root, d.name));
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function standardTargets(home: string): string[] {
|
|
64
|
+
return [
|
|
65
|
+
home,
|
|
66
|
+
path.join(home, "Desktop"),
|
|
67
|
+
path.join(home, "Documents"),
|
|
68
|
+
path.join(home, "Downloads"),
|
|
69
|
+
path.join(home, "Pictures"),
|
|
70
|
+
path.join(home, "Movies"),
|
|
71
|
+
path.join(home, "Music"),
|
|
72
|
+
path.join(home, "Library"),
|
|
73
|
+
path.join(home, "Library", "CloudStorage"),
|
|
74
|
+
path.join(home, "Library", "Mobile Documents"),
|
|
75
|
+
path.join(home, "Library", "Mobile Documents", "com~apple~CloudDocs"),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function uniqueExistingOrder(dirs: string[]): string[] {
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
const out: string[] = [];
|
|
82
|
+
for (const raw of dirs) {
|
|
83
|
+
const resolved = path.resolve(raw);
|
|
84
|
+
if (seen.has(resolved)) continue;
|
|
85
|
+
seen.add(resolved);
|
|
86
|
+
out.push(resolved);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function probe(dir: string): ProbeResult {
|
|
92
|
+
try {
|
|
93
|
+
fs.accessSync(dir, fs.constants.R_OK);
|
|
94
|
+
const st = fs.statSync(dir);
|
|
95
|
+
if (st.isDirectory()) {
|
|
96
|
+
const dh = fs.opendirSync(dir);
|
|
97
|
+
dh.closeSync();
|
|
98
|
+
}
|
|
99
|
+
return { dir, status: "ok" };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
const err = e as NodeJS.ErrnoException;
|
|
102
|
+
if (err.code === "ENOENT") return { dir, status: "missing" };
|
|
103
|
+
return { dir, status: "denied", detail: err.code || err.message };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function displayPath(dir: string, home: string): string {
|
|
108
|
+
if (dir === home) return "~";
|
|
109
|
+
if (dir.startsWith(home + path.sep)) return "~" + dir.slice(home.length);
|
|
110
|
+
return dir;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function openFullDiskAccessSettings(): void {
|
|
114
|
+
spawn(
|
|
115
|
+
"open",
|
|
116
|
+
[
|
|
117
|
+
"x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
|
|
118
|
+
],
|
|
119
|
+
{ detached: true, stdio: "ignore" },
|
|
120
|
+
).unref();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Touch macOS TCC-protected folders so this Node binary gets Files and Folders
|
|
125
|
+
* prompts. Must be run at the Mac (GUI session); SSH will hang on unread dialogs.
|
|
126
|
+
*/
|
|
127
|
+
export function runAuthorize(options: AuthorizeOptions = {}): void {
|
|
128
|
+
if (process.platform !== "darwin") {
|
|
129
|
+
console.log("非 macOS,无需磁盘授权。");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const home = os.homedir();
|
|
134
|
+
const nodePath = nodeBinary();
|
|
135
|
+
const extra = options.extraDirs?.length ? options.extraDirs : extraDirsFromConfig();
|
|
136
|
+
const cloudRoot = path.resolve(path.join(home, "Library", "CloudStorage"));
|
|
137
|
+
const targets = uniqueExistingOrder([...standardTargets(home), ...extra]);
|
|
138
|
+
const openSettings = options.openSettings !== false;
|
|
139
|
+
|
|
140
|
+
console.log("macOS 磁盘授权");
|
|
141
|
+
console.log("会逐个访问受保护目录;弹出「node 想访问…」请全部点「允许」。");
|
|
142
|
+
console.log(`当前 Node: ${nodePath}`);
|
|
143
|
+
if (isRemoteSession()) {
|
|
144
|
+
console.log("检测到 SSH:弹窗会出现在这台 Mac 的屏幕上,没人点就会卡住。请在电脑前执行。");
|
|
145
|
+
}
|
|
146
|
+
console.log("");
|
|
147
|
+
|
|
148
|
+
const results: ProbeResult[] = [];
|
|
149
|
+
const seen = new Set(targets);
|
|
150
|
+
for (let i = 0; i < targets.length; i++) {
|
|
151
|
+
const dir = targets[i]!;
|
|
152
|
+
const label = displayPath(dir, home);
|
|
153
|
+
process.stdout.write(` ${label} … `);
|
|
154
|
+
const result = probe(dir);
|
|
155
|
+
results.push(result);
|
|
156
|
+
if (result.status === "ok") console.log("ok");
|
|
157
|
+
else if (result.status === "missing") console.log("(目录不存在,跳过)");
|
|
158
|
+
else console.log(`拒绝 (${result.detail})`);
|
|
159
|
+
|
|
160
|
+
if (result.status === "ok" && dir === cloudRoot) {
|
|
161
|
+
for (const child of listSubdirs(dir)) {
|
|
162
|
+
if (seen.has(child)) continue;
|
|
163
|
+
seen.add(child);
|
|
164
|
+
targets.push(child);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const denied = results.filter((r) => r.status === "denied");
|
|
170
|
+
const ok = results.filter((r) => r.status === "ok");
|
|
171
|
+
console.log("");
|
|
172
|
+
console.log(`完成:允许 ${ok.length},拒绝 ${denied.length},其余目录不存在。`);
|
|
173
|
+
if (denied.length) {
|
|
174
|
+
console.log("被拒绝的目录远程访问仍会卡住。再跑一次 sandy authorize,或到「文件和文件夹」里打开开关。");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log("");
|
|
178
|
+
console.log("建议再把这份 Node 加到「完全磁盘访问权限」(升级 Node 后要重新加):");
|
|
179
|
+
console.log(` ${nodePath}`);
|
|
180
|
+
|
|
181
|
+
if (openSettings) {
|
|
182
|
+
openFullDiskAccessSettings();
|
|
183
|
+
console.log("已打开系统设置 → 隐私与安全性 → 完全磁盘访问权限。");
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -3,10 +3,21 @@ const subcommand = process.argv[2];
|
|
|
3
3
|
if (subcommand === "init") {
|
|
4
4
|
const { runInit } = await import("./init.js");
|
|
5
5
|
await runInit(process.argv.slice(3));
|
|
6
|
+
} else if (subcommand === "authorize" || subcommand === "diskauth") {
|
|
7
|
+
const { runAuthorize } = await import("./authorize.js");
|
|
8
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
9
|
+
console.log(`Usage: sandy authorize
|
|
10
|
+
|
|
11
|
+
Touch macOS-protected folders so this Node binary gets Files and Folders prompts.
|
|
12
|
+
Run at the Mac (not over SSH). Alias: sandy diskauth`);
|
|
13
|
+
} else {
|
|
14
|
+
runAuthorize({ openSettings: true });
|
|
15
|
+
}
|
|
6
16
|
} else if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
|
|
7
17
|
console.log(`Usage:
|
|
8
|
-
sandy
|
|
9
|
-
sandy init
|
|
18
|
+
sandy Start the Feishu bot (reads ./config.yaml)
|
|
19
|
+
sandy init Interactive setup — writes config.yaml, then macOS disk auth
|
|
20
|
+
sandy authorize Trigger macOS folder-access prompts (alias: diskauth)`);
|
|
10
21
|
} else {
|
|
11
22
|
await import("./index.js");
|
|
12
23
|
}
|
package/src/cursor-agent.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Agent, CursorAgentError, type SDKCustomTool } from "@cursor/sdk";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
fallbackAskFromRaw,
|
|
4
|
+
isBuiltinAskQuestionToolName,
|
|
4
5
|
parseAskQuestionArgs,
|
|
5
6
|
type ParsedAskQuestion,
|
|
6
7
|
} from "./ask-question.js";
|
|
@@ -45,29 +46,24 @@ export async function runCursorAgent(
|
|
|
45
46
|
const existing = sessionStore.get(sessionKey);
|
|
46
47
|
let agent;
|
|
47
48
|
const customTools = options?.customTools;
|
|
49
|
+
const agentOptions = {
|
|
50
|
+
apiKey: config.cursorApiKey,
|
|
51
|
+
model: { id: config.cursorModel },
|
|
52
|
+
name: config.agentName,
|
|
53
|
+
// Built-in AskQuestion is auto-declined in the SDK and never reaches Feishu.
|
|
54
|
+
disallowedTools: ["askQuestion"],
|
|
55
|
+
local: {
|
|
56
|
+
...localAgentOptions(),
|
|
57
|
+
...(customTools ? { customTools } : {}),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
48
60
|
|
|
49
61
|
try {
|
|
50
62
|
if (existing?.agentId) {
|
|
51
|
-
agent = await Agent.resume(existing.agentId,
|
|
52
|
-
apiKey: config.cursorApiKey,
|
|
53
|
-
model: { id: config.cursorModel },
|
|
54
|
-
name: config.agentName,
|
|
55
|
-
local: {
|
|
56
|
-
...localAgentOptions(),
|
|
57
|
-
...(customTools ? { customTools } : {}),
|
|
58
|
-
},
|
|
59
|
-
});
|
|
63
|
+
agent = await Agent.resume(existing.agentId, agentOptions);
|
|
60
64
|
console.log(`[cursor] resumed agent=${existing.agentId} session=${sessionKey}`);
|
|
61
65
|
} else {
|
|
62
|
-
agent = await Agent.create(
|
|
63
|
-
apiKey: config.cursorApiKey,
|
|
64
|
-
model: { id: config.cursorModel },
|
|
65
|
-
name: config.agentName,
|
|
66
|
-
local: {
|
|
67
|
-
...localAgentOptions(),
|
|
68
|
-
...(customTools ? { customTools } : {}),
|
|
69
|
-
},
|
|
70
|
-
});
|
|
66
|
+
agent = await Agent.create(agentOptions);
|
|
71
67
|
sessionStore.set(sessionKey, agent.agentId);
|
|
72
68
|
console.log(`[cursor] created agent=${agent.agentId} session=${sessionKey}`);
|
|
73
69
|
}
|
|
@@ -88,31 +84,26 @@ export async function runCursorAgent(
|
|
|
88
84
|
if (block.type === "text" && block.text) {
|
|
89
85
|
partialText += block.text;
|
|
90
86
|
}
|
|
91
|
-
if (block.type === "tool_use" &&
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
`[cursor] askQuestion via tool_use questions=${parsed.questions.length}`,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
87
|
+
if (block.type === "tool_use" && isBuiltinAskQuestionToolName(block.name)) {
|
|
88
|
+
pendingAsk = parseAskQuestionArgs(block.input) ?? fallbackAskFromRaw(block.input);
|
|
89
|
+
sawAskQuestion = true;
|
|
90
|
+
console.log(
|
|
91
|
+
`[cursor] askQuestion via tool_use questions=${pendingAsk.questions.length}`,
|
|
92
|
+
);
|
|
100
93
|
}
|
|
101
94
|
}
|
|
102
95
|
}
|
|
103
96
|
|
|
104
|
-
if (event.type === "tool_call" &&
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
);
|
|
112
|
-
} else {
|
|
97
|
+
if (event.type === "tool_call" && isBuiltinAskQuestionToolName(event.name)) {
|
|
98
|
+
pendingAsk = parseAskQuestionArgs(event.args) ?? fallbackAskFromRaw(event.args);
|
|
99
|
+
sawAskQuestion = true;
|
|
100
|
+
console.log(
|
|
101
|
+
`[cursor] askQuestion tool_call status=${event.status} questions=${pendingAsk.questions.length}`,
|
|
102
|
+
);
|
|
103
|
+
if (!parseAskQuestionArgs(event.args)) {
|
|
113
104
|
console.warn(
|
|
114
|
-
"[cursor] askQuestion
|
|
115
|
-
JSON.stringify(event.args)?.slice(0,
|
|
105
|
+
"[cursor] askQuestion args used fallback:",
|
|
106
|
+
JSON.stringify(event.args)?.slice(0, 800),
|
|
116
107
|
);
|
|
117
108
|
}
|
|
118
109
|
|
package/src/feishu-tools.ts
CHANGED
|
@@ -3,6 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
4
4
|
import type { SDKCustomTool, SDKJsonValue } from "@cursor/sdk";
|
|
5
5
|
import { config } from "./config.js";
|
|
6
|
+
import {
|
|
7
|
+
fallbackAskFromRaw,
|
|
8
|
+
parseAskQuestionArgs,
|
|
9
|
+
type ParsedAskQuestion,
|
|
10
|
+
} from "./ask-question.js";
|
|
11
|
+
import type { AskAnswer } from "./ask-waiters.js";
|
|
6
12
|
import {
|
|
7
13
|
appendMarkdownToDocument,
|
|
8
14
|
createDocument,
|
|
@@ -35,6 +41,7 @@ export type FeishuToolContext = {
|
|
|
35
41
|
client: Lark.Client;
|
|
36
42
|
replyToMessageId: string;
|
|
37
43
|
chatId: string;
|
|
44
|
+
onAskQuestion?: (ask: ParsedAskQuestion) => Promise<AskAnswer[]>;
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
/** In-process tools exposed to the Cursor agent as custom-user-tools. */
|
|
@@ -42,6 +49,71 @@ export function buildFeishuCustomTools(
|
|
|
42
49
|
ctx: FeishuToolContext,
|
|
43
50
|
): Record<string, SDKCustomTool> {
|
|
44
51
|
return {
|
|
52
|
+
feishu_ask_question: {
|
|
53
|
+
description:
|
|
54
|
+
"Ask the Feishu user one or more questions and wait until they reply. " +
|
|
55
|
+
"Always use this when you need a decision (auth, image, plan, env, etc.). " +
|
|
56
|
+
"The user cannot see Cursor's AskQuestion UI — never write “等你回上面的题” " +
|
|
57
|
+
"without calling this tool. Include the full prompt and options on every question.",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
title: { type: "string", description: "Optional card title" },
|
|
62
|
+
questions: {
|
|
63
|
+
type: "array",
|
|
64
|
+
description: "Questions to show in Feishu",
|
|
65
|
+
items: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
id: { type: "string" },
|
|
69
|
+
prompt: { type: "string", description: "Question text shown to the user" },
|
|
70
|
+
options: {
|
|
71
|
+
type: "array",
|
|
72
|
+
items: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
id: { type: "string" },
|
|
76
|
+
label: { type: "string" },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
allowMultiple: { type: "boolean" },
|
|
81
|
+
},
|
|
82
|
+
required: ["prompt"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
required: ["questions"],
|
|
87
|
+
},
|
|
88
|
+
async execute(args) {
|
|
89
|
+
try {
|
|
90
|
+
if (!ctx.onAskQuestion) {
|
|
91
|
+
throw new Error("onAskQuestion is not configured");
|
|
92
|
+
}
|
|
93
|
+
const parsed =
|
|
94
|
+
parseAskQuestionArgs(args) ?? fallbackAskFromRaw(args);
|
|
95
|
+
const answers = await ctx.onAskQuestion(parsed);
|
|
96
|
+
return {
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
type: "text",
|
|
100
|
+
text: JSON.stringify({ ok: true, answers }),
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: "text",
|
|
109
|
+
text: `feishu_ask_question cancelled: ${err instanceof Error ? err.message : String(err)}`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
isError: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
},
|
|
45
117
|
feishu_send_file: {
|
|
46
118
|
description:
|
|
47
119
|
"Send a local file or image to the current Feishu chat as a reply. " +
|
package/src/feishu.ts
CHANGED
|
@@ -287,7 +287,7 @@ export function buildAskQuestionCard(
|
|
|
287
287
|
} satisfies CardActionValue,
|
|
288
288
|
})),
|
|
289
289
|
});
|
|
290
|
-
} else {
|
|
290
|
+
} else if (q.options.length > 0) {
|
|
291
291
|
const lines = q.options.map((opt, oi) => `${oi + 1}. ${opt.label}`);
|
|
292
292
|
elements.push({
|
|
293
293
|
tag: "markdown",
|
|
@@ -297,12 +297,14 @@ export function buildAskQuestionCard(
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
if (!useButtons) {
|
|
300
|
+
const hasOptions = ask.questions.some((q) => q.options.length > 0);
|
|
300
301
|
elements.push({
|
|
301
302
|
tag: "markdown",
|
|
302
|
-
content:
|
|
303
|
-
ask.questions.length === 1
|
|
303
|
+
content: hasOptions
|
|
304
|
+
? ask.questions.length === 1
|
|
304
305
|
? "请直接回复选项编号(多选如 `1,3`),或回复选项原文。"
|
|
305
|
-
: "请按题号回复,例如:`1:2; 2:1
|
|
306
|
+
: "请按题号回复,例如:`1:2; 2:1`(题号:选项编号)。也可直接用文字说明。"
|
|
307
|
+
: "请直接回复本题答案。",
|
|
306
308
|
});
|
|
307
309
|
}
|
|
308
310
|
|
|
@@ -318,19 +320,26 @@ export function buildAskQuestionCard(
|
|
|
318
320
|
}
|
|
319
321
|
|
|
320
322
|
export function formatAskQuestionFallbackText(ask: ParsedAskQuestion): string {
|
|
321
|
-
const lines = [
|
|
323
|
+
const lines = [`【需要你选择】${ask.title?.trim() || ""}`.trim()];
|
|
322
324
|
for (const [qi, q] of ask.questions.entries()) {
|
|
323
325
|
lines.push("");
|
|
324
326
|
lines.push(`${qi + 1}. ${q.prompt}${q.allowMultiple ? "(可多选)" : ""}`);
|
|
327
|
+
if (q.options.length === 0) {
|
|
328
|
+
lines.push(" (请直接回复)");
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
325
331
|
for (const [oi, opt] of q.options.entries()) {
|
|
326
332
|
lines.push(` ${oi + 1}) ${opt.label}`);
|
|
327
333
|
}
|
|
328
334
|
}
|
|
329
335
|
lines.push("");
|
|
336
|
+
const hasOptions = ask.questions.some((q) => q.options.length > 0);
|
|
330
337
|
lines.push(
|
|
331
|
-
|
|
332
|
-
?
|
|
333
|
-
|
|
338
|
+
hasOptions
|
|
339
|
+
? ask.questions.length === 1
|
|
340
|
+
? "回复编号继续(多选如 1,3),也可点卡片按钮。"
|
|
341
|
+
: "回复格式如 1:2; 2:1,或直接用文字说明。"
|
|
342
|
+
: "请直接回复答案。",
|
|
334
343
|
);
|
|
335
344
|
return lines.join("\n");
|
|
336
345
|
}
|
|
@@ -341,6 +350,13 @@ export async function replyAskQuestionCard(
|
|
|
341
350
|
sessionKey: string,
|
|
342
351
|
ask: ParsedAskQuestion,
|
|
343
352
|
): Promise<void> {
|
|
353
|
+
// Always send readable text first. Cursor's built-in AskQuestion UI never
|
|
354
|
+
// reaches Feishu; if the interactive card fails, the user still sees the
|
|
355
|
+
// questions and how to reply.
|
|
356
|
+
await replyText(client, messageId, formatAskQuestionFallbackText(ask), {
|
|
357
|
+
preferMarkdown: true,
|
|
358
|
+
});
|
|
359
|
+
|
|
344
360
|
const card = buildAskQuestionCard(sessionKey, ask);
|
|
345
361
|
try {
|
|
346
362
|
await client.im.v1.message.reply({
|
|
@@ -351,8 +367,7 @@ export async function replyAskQuestionCard(
|
|
|
351
367
|
},
|
|
352
368
|
});
|
|
353
369
|
} catch (err) {
|
|
354
|
-
console.warn("[feishu] interactive card failed
|
|
355
|
-
await replyText(client, messageId, formatAskQuestionFallbackText(ask));
|
|
370
|
+
console.warn("[feishu] interactive ask card failed (text already sent):", err);
|
|
356
371
|
}
|
|
357
372
|
}
|
|
358
373
|
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,15 @@ import path from "node:path";
|
|
|
2
2
|
import {
|
|
3
3
|
formatAnswerPrompt,
|
|
4
4
|
parseTextAnswer,
|
|
5
|
+
type AskQuestion,
|
|
5
6
|
} from "./ask-question.js";
|
|
7
|
+
import {
|
|
8
|
+
cancelAskWaiter,
|
|
9
|
+
hasAskWaiter,
|
|
10
|
+
resolveAskWaiter,
|
|
11
|
+
waitForAskAnswers,
|
|
12
|
+
type AskAnswer,
|
|
13
|
+
} from "./ask-waiters.js";
|
|
6
14
|
import {
|
|
7
15
|
isResetCommand,
|
|
8
16
|
resetSession,
|
|
@@ -138,10 +146,6 @@ async function deliverOutcome(
|
|
|
138
146
|
createdAt: new Date().toISOString(),
|
|
139
147
|
});
|
|
140
148
|
|
|
141
|
-
if (outcome.partialText) {
|
|
142
|
-
await replyAgentText(client, replyToMessageId, outcome.partialText);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
149
|
await replyAskQuestionCard(client, replyToMessageId, sessionKey, outcome.ask);
|
|
146
150
|
console.log(
|
|
147
151
|
`[ask] waiting for selection session=${sessionKey} questions=${outcome.ask.questions.length}`,
|
|
@@ -160,12 +164,32 @@ const sessionQueue = createSessionQueueManager(client, async (job: QueueJob) =>
|
|
|
160
164
|
client,
|
|
161
165
|
replyToMessageId: job.messageId,
|
|
162
166
|
chatId: job.chatId,
|
|
167
|
+
onAskQuestion: async (ask) => {
|
|
168
|
+
pendingStore.set(sessionKey, {
|
|
169
|
+
agentId: sessionStore.get(sessionKey)?.agentId ?? "",
|
|
170
|
+
chatId: job.chatId,
|
|
171
|
+
replyToMessageId: job.messageId,
|
|
172
|
+
title: ask.title,
|
|
173
|
+
questions: ask.questions,
|
|
174
|
+
createdAt: new Date().toISOString(),
|
|
175
|
+
});
|
|
176
|
+
await replyAskQuestionCard(client, job.messageId, sessionKey, ask);
|
|
177
|
+
console.log(
|
|
178
|
+
`[ask] waiting (feishu_ask_question) session=${sessionKey} questions=${ask.questions.length}`,
|
|
179
|
+
);
|
|
180
|
+
try {
|
|
181
|
+
return await waitForAskAnswers(sessionKey);
|
|
182
|
+
} finally {
|
|
183
|
+
pendingStore.delete(sessionKey);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
163
186
|
});
|
|
164
187
|
const outcome = await runCursorAgent(sessionStore, sessionKey, job.prompt, {
|
|
165
188
|
customTools,
|
|
166
189
|
});
|
|
167
190
|
await deliverOutcome(sessionKey, job.messageId, job.chatId, outcome);
|
|
168
191
|
} catch (err) {
|
|
192
|
+
cancelAskWaiter(sessionKey, "agent failed");
|
|
169
193
|
const message = err instanceof Error ? err.message : String(err);
|
|
170
194
|
console.error("[handle] agent failed:", err);
|
|
171
195
|
await replyText(client, job.messageId, `处理失败:${message}`);
|
|
@@ -185,11 +209,24 @@ function enqueuePrompt(
|
|
|
185
209
|
});
|
|
186
210
|
}
|
|
187
211
|
|
|
212
|
+
function freeformAnswers(questions: AskQuestion[], text: string): AskAnswer[] {
|
|
213
|
+
return questions.map((q) => ({
|
|
214
|
+
questionId: q.id,
|
|
215
|
+
selectedOptionIds: [],
|
|
216
|
+
freeformText: text,
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
188
220
|
async function continueWithAnswers(
|
|
189
221
|
sessionKey: string,
|
|
190
222
|
replyToMessageId: string,
|
|
191
|
-
answers:
|
|
223
|
+
answers: AskAnswer[],
|
|
192
224
|
): Promise<void> {
|
|
225
|
+
if (resolveAskWaiter(sessionKey, answers)) {
|
|
226
|
+
console.log(`[ask] resolved in-run waiter session=${sessionKey}`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
193
230
|
const pending = pendingStore.get(sessionKey);
|
|
194
231
|
if (!pending) {
|
|
195
232
|
await replyText(client, replyToMessageId, "没有待回答的选择题,直接发消息即可。");
|
|
@@ -251,6 +288,7 @@ async function handleMessage(raw: Parameters<typeof parseIncomingMessage>[0]) {
|
|
|
251
288
|
}
|
|
252
289
|
|
|
253
290
|
if (isResetCommand(text)) {
|
|
291
|
+
cancelAskWaiter(sessionKey, "user reset");
|
|
254
292
|
resetSession(sessionStore, sessionKey);
|
|
255
293
|
pendingStore.delete(sessionKey);
|
|
256
294
|
await sessionQueue.clear(sessionKey);
|
|
@@ -260,12 +298,13 @@ async function handleMessage(raw: Parameters<typeof parseIncomingMessage>[0]) {
|
|
|
260
298
|
|
|
261
299
|
const pending = pendingStore.get(sessionKey);
|
|
262
300
|
if (pending && text && !attachmentPrompt) {
|
|
263
|
-
const answers =
|
|
301
|
+
const answers =
|
|
302
|
+
parseTextAnswer(pending.questions, text) ??
|
|
303
|
+
(hasAskWaiter(sessionKey) ? freeformAnswers(pending.questions, text) : undefined);
|
|
264
304
|
if (answers) {
|
|
265
305
|
await continueWithAnswers(sessionKey, msg.messageId, answers);
|
|
266
306
|
return;
|
|
267
307
|
}
|
|
268
|
-
// Not a valid answer — treat as normal new prompt, drop pending.
|
|
269
308
|
console.log(`[ask] clearing pending; treating as new prompt session=${sessionKey}`);
|
|
270
309
|
pendingStore.delete(sessionKey);
|
|
271
310
|
}
|
|
@@ -291,6 +330,7 @@ async function handleCardAction(data: unknown) {
|
|
|
291
330
|
const q = pending.questions.find((qq) => qq.id === value.qid);
|
|
292
331
|
if (!q || !q.options.some((o) => o.id === value.oid)) {
|
|
293
332
|
await replyText(client, replyTo, "选项无效或已过期,请重新提问。");
|
|
333
|
+
cancelAskWaiter(sessionKey, "invalid option");
|
|
294
334
|
pendingStore.delete(sessionKey);
|
|
295
335
|
return;
|
|
296
336
|
}
|
package/src/init-guides.ts
CHANGED
|
@@ -91,14 +91,6 @@ export const INIT_FIELD_GUIDES = {
|
|
|
91
91
|
],
|
|
92
92
|
} satisfies FieldGuide,
|
|
93
93
|
|
|
94
|
-
agentDirLinks: {
|
|
95
|
-
title: "工作目录下的 symlink(可选)",
|
|
96
|
-
lines: [
|
|
97
|
-
"若 agent.cwd 里有 symlink(如 api → ~/code/api),填链接名以便一并放行。",
|
|
98
|
-
"多个名称用英文逗号分隔;留空可跳过。",
|
|
99
|
-
],
|
|
100
|
-
} satisfies FieldGuide,
|
|
101
|
-
|
|
102
94
|
agentSandbox: {
|
|
103
95
|
title: "Cursor 本地沙箱",
|
|
104
96
|
lines: [
|
|
@@ -121,17 +113,5 @@ export const INIT_FIELD_GUIDES = {
|
|
|
121
113
|
links: [{ label: "Cursor 模型与 SDK", url: "https://cursor.com/docs/sdk/typescript" }],
|
|
122
114
|
} satisfies FieldGuide,
|
|
123
115
|
|
|
124
|
-
feishuDocsFolder: {
|
|
125
|
-
title: "飞书文档默认文件夹(可选)",
|
|
126
|
-
lines: [
|
|
127
|
-
"feishu_doc_create 创建文档时默认放到哪个云空间文件夹。",
|
|
128
|
-
"在飞书云文档打开目标文件夹,从 URL 或文件夹属性里复制 folder_token。",
|
|
129
|
-
"不需要自动归档文档可留空。",
|
|
130
|
-
],
|
|
131
|
-
links: [
|
|
132
|
-
{ label: "创建文档 API", url: "https://open.feishu.cn/document/server-docs/docs/docs/docx-v1/document/create" },
|
|
133
|
-
],
|
|
134
|
-
} satisfies FieldGuide,
|
|
135
|
-
|
|
136
116
|
feishuAppHome: FEISHU_APP_HOME,
|
|
137
117
|
} as const;
|
package/src/init.ts
CHANGED
|
@@ -100,13 +100,6 @@ export async function runInit(argv: string[] = []): Promise<void> {
|
|
|
100
100
|
);
|
|
101
101
|
cfg.agent!.dirs = dirsRaw ? splitList(dirsRaw) : [];
|
|
102
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
103
|
cfg.agent!.sandbox = await prompt.askYesNoWithGuide(
|
|
111
104
|
INIT_FIELD_GUIDES.agentSandbox,
|
|
112
105
|
"开启本地沙箱?",
|
|
@@ -117,15 +110,23 @@ export async function runInit(argv: string[] = []): Promise<void> {
|
|
|
117
110
|
"模型 id",
|
|
118
111
|
{ defaultValue: "auto" },
|
|
119
112
|
);
|
|
120
|
-
cfg.feishuDocsFolder = await prompt.askWithGuide(
|
|
121
|
-
INIT_FIELD_GUIDES.feishuDocsFolder,
|
|
122
|
-
"folder_token(可留空)",
|
|
123
|
-
{ defaultValue: "" },
|
|
124
|
-
);
|
|
125
113
|
|
|
126
114
|
writeConfigFile(outPath, cfg);
|
|
127
115
|
|
|
128
116
|
console.log("\n✓ 已写入 " + outPath);
|
|
117
|
+
|
|
118
|
+
if (process.platform === "darwin") {
|
|
119
|
+
const { runAuthorize } = await import("./authorize.js");
|
|
120
|
+
prompt.section("四、macOS 磁盘授权");
|
|
121
|
+
console.log(" 弹出「node 想访问…」请全部点「允许」。远程 SSH 时弹窗在本机屏幕上。\n");
|
|
122
|
+
const extraDirs = [
|
|
123
|
+
expandPath(cfg.agent!.cwd || targetDir, targetDir),
|
|
124
|
+
...(cfg.agent!.dirs ?? []).map((d) => expandPath(d, targetDir)),
|
|
125
|
+
targetDir,
|
|
126
|
+
];
|
|
127
|
+
runAuthorize({ extraDirs, openSettings: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
129
130
|
console.log("\n下一步:");
|
|
130
131
|
console.log(` cd ${targetDir}`);
|
|
131
132
|
console.log(" sandy");
|
package/templates/sandy.mdc
CHANGED
|
@@ -10,7 +10,7 @@ alwaysApply: true
|
|
|
10
10
|
## 职责
|
|
11
11
|
|
|
12
12
|
- 协助写代码、排障、运维与部署
|
|
13
|
-
-
|
|
13
|
+
- 在飞书里回答简洁、可执行;需要对方做选择时,必须调用 `feishu_ask_question`(题目和选项会发到飞书)。不要只写「等你回上面的题」——飞书用户看不到 Cursor 里的询问框。
|
|
14
14
|
- 可在 `AGENT_CWD/.cursor/rules/` 下创建或更新规则,沉淀领域约定与协作习惯
|
|
15
15
|
- 文件修改范围以 `config.yaml` 里的 `agent.cwd` / `agent.dirs` 白名单为准
|
|
16
16
|
- 可使用本机 shell(SSH / git / 项目脚本),除非 `agent.sandbox=true`
|