Skip to content

Commit 0251321

Browse files
author
linyuan.yang
committed
skill
1 parent 9b3c5f5 commit 0251321

3 files changed

Lines changed: 30 additions & 59 deletions

File tree

packages/sbot/prompts/skills/system.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ vars:
1212
<step order="2">Call list_skill_files to inspect the skill directory structure</step>
1313
<step order="3">Call read_skill_file to read SKILL.md</step>
1414
<step order="4">Follow the instructions in SKILL.md strictly and completely</step>
15-
<step order="5">Use read_skill_file or execute_skill_script for any files or scripts referenced in SKILL.md</step>
15+
<step order="5">Use read_skill_file or execute_skill_script for any files or commands referenced in SKILL.md. For execute_skill_script, pass the command as written in SKILL.md (e.g. "python scripts/run.py --in data.json"); use workingDir if the script assumes its own directory as cwd.</step>
1616
</workflow>
1717
<tools>
1818
<tool name="read_skill_file">Read any file inside the skill directory</tool>
1919
<tool name="list_skill_files">List the full directory tree of a skill</tool>
20-
<tool name="execute_skill_script">Run a script file (.py, .sh, .js, .ts) inside the skill directory</tool>
20+
<tool name="execute_skill_script">Run a shell command (any executable on PATH) inside the skill directory; supports multi-line scripts and chained commands like "pip install -r requirements.txt &amp;&amp; python scripts/run.py"</tool>
2121
</tools>
2222
<constraints>
2323
<constraint>Always read SKILL.md before performing any skill-related action</constraint>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Execute a script in a skill directory (Python, Shell, Node.js, TypeScript). Runs with the skill directory as cwd. Use list_skill_files first to confirm the script path.
1+
Execute a shell command (or multi-line script) inside a skill directory. Default cwd is the skill root; pass workingDir (relative sub-path) for scripts that assume their own directory as cwd. Any executable on PATH works (python, node, bash, pwsh, cmd, npm, pip, ...). Use list_skill_files first to confirm paths referenced in the command.

packages/scorpio.ai/src/Skills/SkillService.ts

Lines changed: 27 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ import {
1313
createErrorResult,
1414
createSuccessResult,
1515
MCPToolResult,
16-
runProgram,
17-
isCommandAvailable,
16+
runShellCommand,
1817
} from "../Tools";
1918
import { UsageTracker, UsageState } from "../Utils/UsageTracker";
2019

@@ -141,62 +140,36 @@ export class SkillService implements ISkillService {
141140
description: this.toolExecDesc!,
142141
schema: z.object({
143142
skillName: z.string().describe("Skill name (kebab-case)"),
144-
scriptPath: z.string().describe('Relative path to the script, e.g. "scripts/process.py". Confirm via list_skill_files first.'),
145-
args: z.array(z.string()).optional().describe("Arguments to pass to the script"),
146-
stdin: z.any().optional().describe('Data to pipe into the script via stdin. Prefer a string; objects/arrays are auto-serialized to JSON.'),
143+
command: z.string().min(1).describe('Shell command or multi-line script to run inside the skill, e.g. "python scripts/process.py --in data.json", "npm install && npm run build". Relative paths resolve against workingDir.'),
144+
workingDir: z.string().optional().describe('Optional sub-directory inside the skill to use as cwd, relative to the skill root (e.g. "scripts"). Defaults to the skill root. Use this for scripts that assume their own directory as cwd.'),
145+
stdin: z.any().optional().describe('Data to pipe into the command via stdin. Prefer a string; objects/arrays are auto-serialized to JSON.'),
147146
timeout: z.number().optional().default(60000).describe('Timeout in milliseconds, default 60000 (60 s)'),
148147
}) as any,
149-
func: async ({ skillName, scriptPath, args = [], stdin, timeout = 60000 }: any): Promise<MCPToolResult> => {
148+
func: async ({ skillName, command, workingDir, stdin, timeout = 60000 }: any): Promise<MCPToolResult> => {
150149
// schema 用 z.any() 是为了同时满足两点:(1) Zod v4 的 toJSONSchema 不接受 transform/preprocess;
151150
// (2) 模型偶尔不遵守 string 约束、直接塞 object/array。这里在 func 入口统一序列化兜底。
152151
if (stdin != null && typeof stdin !== 'string') stdin = JSON.stringify(stdin);
153152
try {
154153
const skill = this.getAllSkills().find(s => s.name === skillName);
155154
if (!skill) return createErrorResult(`Skill "${skillName}" not found`);
156155

157-
const fullPath = path.join(skill.path, scriptPath);
158-
if (!this.isPathSafe(fullPath, skill.path)) return createErrorResult("Security error: executing scripts outside the skill directory is not allowed");
159-
if (!fs.existsSync(fullPath)) return createErrorResult(`Script not found: ${scriptPath}`);
160-
161-
const ext = path.extname(scriptPath).toLowerCase();
162-
// 一律走 runProgram + 解释器调用:免 shell 转义,且不要求脚本本身有 +x。
163-
// python 解释器名按平台双探测:现代 Linux 通常只有 python3,Windows 多为 python。
164-
let interpreter: string;
165-
let interpreterArgs: string[] = [];
166-
switch (ext) {
167-
case ".py":
168-
interpreter = isCommandAvailable("python") ? "python" : "python3";
169-
break;
170-
case ".js": interpreter = "node"; break;
171-
case ".ts": interpreter = "ts-node"; break;
172-
case ".sh": interpreter = "bash"; break;
173-
case ".ps1":
174-
// pwsh 是 PowerShell 7+ 的跨平台名,优先用;回退到 Windows 自带的 powershell。
175-
// -NoProfile 避免加载用户 profile,-ExecutionPolicy Bypass 绕开默认 Restricted 策略。
176-
interpreter = isCommandAvailable("pwsh") ? "pwsh" : "powershell";
177-
interpreterArgs = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"];
178-
break;
179-
case ".cmd":
180-
// .cmd 不是可执行文件,必须由 cmd.exe 解释;只在 Windows 上有意义。
181-
if (process.platform !== "win32") return createErrorResult(`${ext} scripts are only supported on Windows`);
182-
interpreter = "cmd";
183-
interpreterArgs = ["/c"];
184-
break;
185-
default:
186-
return createErrorResult(`Unsupported script type: ${ext}. Supported: .py, .sh, .js, .ts, .ps1, .cmd`);
156+
// 默认 cwd = skill 根;workingDir 用 path.resolve 处理,绝对路径会覆盖 base,
157+
// 再用 isPathSafe 兜底拦截 ".."/绝对路径越权,保持 skill 边界。
158+
const cwd = workingDir ? path.resolve(skill.path, workingDir) : skill.path;
159+
if (!this.isPathSafe(cwd, skill.path)) {
160+
return createErrorResult("Security error: workingDir must be inside the skill directory");
187161
}
188-
if (!isCommandAvailable(interpreter)) {
189-
return createErrorResult(`Interpreter "${interpreter}" not found in PATH`);
162+
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
163+
return createErrorResult(`Working directory not found: ${cwd}`);
190164
}
191165

192166
new UsageTracker(skill.path).recordUse();
193-
const cwd = path.dirname(fullPath);
194-
const label = `skill ${skillName}/${scriptPath}`;
195-
this.logger?.info(`执行 skill 脚本 ${skillName}/${scriptPath} cwd=${cwd}`);
167+
const label = `skill ${skillName}`;
168+
this.logger?.info(`执行 skill ${skillName} (cwd=${cwd}): ${command}`);
196169

197-
return await runProgram(interpreter, [...interpreterArgs, fullPath, ...args], cwd, timeout, label, stdin);
170+
return await runShellCommand(command, cwd, timeout, label, stdin);
198171
} catch (error: any) {
199-
this.logger?.error(`Error executing skill script ${skillName}/${scriptPath}: ${error.message}`);
172+
this.logger?.error(`Error executing skill ${skillName}: ${error.message}`);
200173
return createErrorResult(`Error: ${error.message}`);
201174
}
202175
}
@@ -236,23 +209,21 @@ export class SkillService implements ISkillService {
236209
return path.normalize(fullPath).startsWith(path.normalize(baseDir));
237210
}
238211

239-
private getDirectoryStructure(dirPath: string, prefix = ""): string[] {
212+
// 扁平化输出相对路径,例如 "SKILL.md" / "scripts/run.sh"。统一用 "/" 作分隔符,跨平台一致;
213+
// 空目录不输出(对 LLM 决策不构成行为面);按 localeCompare 稳定排序,便于 diff 复现。
214+
private getDirectoryStructure(dirPath: string, relPrefix = ""): string[] {
240215
const items: string[] = [];
241-
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
242-
entries.forEach((entry, index) => {
243-
if (entry.name === '.usage.json') return;
244-
const isLast = index === entries.length - 1;
245-
const marker = isLast ? "└─" : "├─";
246-
const nextPrefix = prefix + (isLast ? " " : "│ ");
216+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
217+
.filter(e => e.name !== '.usage.json')
218+
.sort((a, b) => a.name.localeCompare(b.name));
219+
for (const entry of entries) {
220+
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
247221
if (entry.isDirectory()) {
248-
items.push(`${prefix}${marker} ${entry.name}/`);
249-
items.push(...this.getDirectoryStructure(path.join(dirPath, entry.name), nextPrefix));
222+
items.push(...this.getDirectoryStructure(path.join(dirPath, entry.name), rel));
250223
} else {
251-
const stat = fs.statSync(path.join(dirPath, entry.name));
252-
const sizeStr = stat.size > 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
253-
items.push(`${prefix}${marker} ${entry.name} (${sizeStr})`);
224+
items.push(rel);
254225
}
255-
});
226+
}
256227
return items;
257228
}
258229
}

0 commit comments

Comments
 (0)