chatccc 0.2.220 → 0.2.222
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/__tests__/builtin-chat-session.test.ts +40 -0
- package/src/__tests__/builtin-file-tools.test.ts +31 -0
- package/src/__tests__/builtin-skills.test.ts +141 -0
- package/src/__tests__/cards.test.ts +20 -0
- package/src/builtin/file-tools.ts +14 -4
- package/src/builtin/index.ts +12 -0
- package/src/builtin/skills.ts +108 -0
- package/src/cards.ts +20 -1
package/package.json
CHANGED
|
@@ -53,6 +53,46 @@ afterEach(() => {
|
|
|
53
53
|
config.rawStreamLogs = structuredClone(originalRawStreamLogs);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
describe("ChatSession codex-style skills", () => {
|
|
57
|
+
it("injects skill index into system prompt from skillsDirs", async () => {
|
|
58
|
+
const { ChatSession } = await import("../builtin/index.ts");
|
|
59
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-session-skills-"));
|
|
60
|
+
const skillsDir = join(dir, ".codex", "skills");
|
|
61
|
+
await mkdir(join(skillsDir, "demo-skill"), { recursive: true });
|
|
62
|
+
await writeFile(
|
|
63
|
+
join(skillsDir, "demo-skill", "SKILL.md"),
|
|
64
|
+
"---\nname: demo-skill\ndescription: 演示技能\n---\n\n# 演示",
|
|
65
|
+
"utf-8",
|
|
66
|
+
);
|
|
67
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream() });
|
|
68
|
+
|
|
69
|
+
const session = new ChatSession(
|
|
70
|
+
{ apiKey: "sk-test" },
|
|
71
|
+
{ cwd: dir, sessionId: "skill-index", skillsDirs: [skillsDir] },
|
|
72
|
+
);
|
|
73
|
+
await collect(session.chat("hi"));
|
|
74
|
+
|
|
75
|
+
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
76
|
+
expect(system).toContain("## Available Skills");
|
|
77
|
+
expect(system).toContain("demo-skill");
|
|
78
|
+
expect(system).toContain("演示技能");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("skips skill injection when no skills are found", async () => {
|
|
82
|
+
const { ChatSession } = await import("../builtin/index.ts");
|
|
83
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-session-no-skills-"));
|
|
84
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream() });
|
|
85
|
+
|
|
86
|
+
const session = new ChatSession(
|
|
87
|
+
{ apiKey: "sk-test" },
|
|
88
|
+
{ cwd: dir, sessionId: "no-skills", skillsDirs: [join(dir, "missing")] },
|
|
89
|
+
);
|
|
90
|
+
await collect(session.chat("hi"));
|
|
91
|
+
|
|
92
|
+
const system = streamTextMock.mock.calls.at(-1)?.[0].system as string;
|
|
93
|
+
expect(system).not.toContain("## Available Skills");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
56
96
|
describe("ChatSession context management", () => {
|
|
57
97
|
it("injects cwd project instruction files before runtime workspace details", async () => {
|
|
58
98
|
const { ChatSession } = await import("../builtin/index.ts");
|
|
@@ -158,6 +158,37 @@ describe("builtin file tools", () => {
|
|
|
158
158
|
await expect(readFile(file, "utf8")).resolves.toBe("alpha\nBETA\ngamma\n");
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
it("edits a CRLF file with LF oldText by normalizing line endings", async () => {
|
|
162
|
+
const dir = await makeTempDir();
|
|
163
|
+
const file = join(dir, "crlf.txt");
|
|
164
|
+
const crlfContent = "alpha\r\nbeta\r\ngamma\r\n";
|
|
165
|
+
await writeFile(file, crlfContent, "utf8");
|
|
166
|
+
|
|
167
|
+
const result = await editFileForTool(dir, {
|
|
168
|
+
path: "crlf.txt",
|
|
169
|
+
expectedSha256: sha256(crlfContent),
|
|
170
|
+
edits: [{ oldText: "alpha\nbeta", newText: "ALPHA\nBETA" }],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(result).toEqual(expect.objectContaining({
|
|
174
|
+
changed: true,
|
|
175
|
+
editsApplied: 1,
|
|
176
|
+
beforeSha256: sha256(crlfContent),
|
|
177
|
+
afterSha256: sha256("ALPHA\r\nBETA\r\ngamma\r\n"),
|
|
178
|
+
}));
|
|
179
|
+
await expect(readFile(file, "utf8")).resolves.toBe("ALPHA\r\nBETA\r\ngamma\r\n");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("rejects a multi-line oldText that still does not match after EOL normalization", async () => {
|
|
183
|
+
const dir = await makeTempDir();
|
|
184
|
+
await writeFile(join(dir, "crlf.txt"), "alpha\r\nbeta\r\n", "utf8");
|
|
185
|
+
|
|
186
|
+
await expect(editFileForTool(dir, {
|
|
187
|
+
path: "crlf.txt",
|
|
188
|
+
edits: [{ oldText: "alpha\ngamma", newText: "x" }],
|
|
189
|
+
})).rejects.toThrow("oldText was not found");
|
|
190
|
+
});
|
|
191
|
+
|
|
161
192
|
it("rejects edits when the SHA-256 precondition does not match", async () => {
|
|
162
193
|
const dir = await makeTempDir();
|
|
163
194
|
await writeFile(join(dir, "edit.txt"), "current\n", "utf8");
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
buildSkillsIndexPrompt,
|
|
9
|
+
parseSkillFrontmatter,
|
|
10
|
+
scanSkillsDirs,
|
|
11
|
+
} from "../builtin/skills.ts";
|
|
12
|
+
|
|
13
|
+
const tempDirs: string[] = [];
|
|
14
|
+
|
|
15
|
+
async function makeTempDir(): Promise<string> {
|
|
16
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-skills-"));
|
|
17
|
+
tempDirs.push(dir);
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("parseSkillFrontmatter", () => {
|
|
26
|
+
it("parses name and description from frontmatter", () => {
|
|
27
|
+
const content = [
|
|
28
|
+
"---",
|
|
29
|
+
"name: feishu-doc-download-md",
|
|
30
|
+
"description: 下载飞书文档为 Markdown",
|
|
31
|
+
"---",
|
|
32
|
+
"",
|
|
33
|
+
"# 正文",
|
|
34
|
+
].join("\n");
|
|
35
|
+
expect(parseSkillFrontmatter(content)).toEqual({
|
|
36
|
+
name: "feishu-doc-download-md",
|
|
37
|
+
description: "下载飞书文档为 Markdown",
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns null when there is no frontmatter", () => {
|
|
42
|
+
expect(parseSkillFrontmatter("# just a heading")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("tolerates missing description", () => {
|
|
46
|
+
expect(parseSkillFrontmatter("---\nname: minimal-skill\n---\n\nbody")).toEqual({
|
|
47
|
+
name: "minimal-skill",
|
|
48
|
+
description: "",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("handles CRLF line endings", () => {
|
|
53
|
+
const content = "---\r\nname: crlf-skill\r\ndescription: CRLF 描述\r\n---\r\n\r\nbody";
|
|
54
|
+
expect(parseSkillFrontmatter(content)).toEqual({
|
|
55
|
+
name: "crlf-skill",
|
|
56
|
+
description: "CRLF 描述",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("scanSkillsDirs", () => {
|
|
62
|
+
it("scans multiple dirs, skips hidden/system dirs, dedupes by name (later dir wins)", async () => {
|
|
63
|
+
const dir = await makeTempDir();
|
|
64
|
+
const userA = join(dir, "codex-skills");
|
|
65
|
+
const userB = join(dir, "agents-skills");
|
|
66
|
+
const project = join(dir, "project-codex");
|
|
67
|
+
await mkdir(join(userA, "feishu-doc"), { recursive: true });
|
|
68
|
+
await mkdir(join(userA, ".system"), { recursive: true });
|
|
69
|
+
await mkdir(join(userA, "no-skill-dir"));
|
|
70
|
+
await mkdir(join(userB, "feishu-doc"), { recursive: true });
|
|
71
|
+
await mkdir(join(userB, "another"), { recursive: true });
|
|
72
|
+
await mkdir(join(project, "feishu-doc"), { recursive: true });
|
|
73
|
+
|
|
74
|
+
await writeFile(
|
|
75
|
+
join(userA, "feishu-doc", "SKILL.md"),
|
|
76
|
+
"---\nname: feishu-doc\ndescription: 用户级 A\n---\n",
|
|
77
|
+
"utf8",
|
|
78
|
+
);
|
|
79
|
+
await writeFile(
|
|
80
|
+
join(userA, ".system", "SKILL.md"),
|
|
81
|
+
"---\nname: system-skill\ndescription: 内置\n---\n",
|
|
82
|
+
"utf8",
|
|
83
|
+
);
|
|
84
|
+
await writeFile(
|
|
85
|
+
join(userB, "feishu-doc", "SKILL.md"),
|
|
86
|
+
"---\nname: feishu-doc\ndescription: 用户级 B\n---\n",
|
|
87
|
+
"utf8",
|
|
88
|
+
);
|
|
89
|
+
await writeFile(
|
|
90
|
+
join(userB, "another", "SKILL.md"),
|
|
91
|
+
"---\nname: another\ndescription: 另一个\n---\n",
|
|
92
|
+
"utf8",
|
|
93
|
+
);
|
|
94
|
+
await writeFile(
|
|
95
|
+
join(project, "feishu-doc", "SKILL.md"),
|
|
96
|
+
"---\nname: feishu-doc\ndescription: 项目级覆盖\n---\n",
|
|
97
|
+
"utf8",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const skills = scanSkillsDirs([userA, userB, project]);
|
|
101
|
+
|
|
102
|
+
// 去重:同名保留一个,后面的目录(项目级)覆盖前面的(用户级)
|
|
103
|
+
expect(skills).toHaveLength(2);
|
|
104
|
+
const byName = new Map(skills.map((s) => [s.name, s]));
|
|
105
|
+
expect(byName.get("feishu-doc")?.description).toBe("项目级覆盖");
|
|
106
|
+
expect(byName.get("feishu-doc")?.skillPath).toBe(join(project, "feishu-doc", "SKILL.md"));
|
|
107
|
+
expect(byName.get("another")?.description).toBe("另一个");
|
|
108
|
+
expect(byName.has("system-skill")).toBe(false); // 隐藏目录被排除
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("skips missing directories and dirs without SKILL.md", async () => {
|
|
112
|
+
const dir = await makeTempDir();
|
|
113
|
+
await mkdir(join(dir, "empty-dir"));
|
|
114
|
+
|
|
115
|
+
const skills = scanSkillsDirs([join(dir, "missing-dir"), join(dir, "empty-dir")]);
|
|
116
|
+
|
|
117
|
+
expect(skills).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("buildSkillsIndexPrompt", () => {
|
|
122
|
+
it("renders a skill index with names, paths and descriptions", () => {
|
|
123
|
+
const prompt = buildSkillsIndexPrompt([
|
|
124
|
+
{
|
|
125
|
+
name: "feishu-doc",
|
|
126
|
+
description: "下载飞书文档",
|
|
127
|
+
skillPath: "C:\\users\\x\\skills\\feishu-doc\\SKILL.md",
|
|
128
|
+
},
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
expect(prompt).toContain("## Available Skills");
|
|
132
|
+
expect(prompt).toContain("**feishu-doc**");
|
|
133
|
+
expect(prompt).toContain("下载飞书文档");
|
|
134
|
+
expect(prompt).toContain("C:\\users\\x\\skills\\feishu-doc\\SKILL.md");
|
|
135
|
+
expect(prompt).toContain("read_file");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns empty string for no skills", () => {
|
|
139
|
+
expect(buildSkillsIndexPrompt([])).toBe("");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
buildButtons,
|
|
14
14
|
truncateContent,
|
|
15
15
|
getToolEmoji,
|
|
16
|
+
normalizeToolName,
|
|
16
17
|
} from "../cards.ts";
|
|
17
18
|
import { ABD_HELP_LINE } from "../shared-prefix.ts";
|
|
18
19
|
|
|
@@ -83,6 +84,25 @@ describe("getToolEmoji", () => {
|
|
|
83
84
|
expect(getToolEmoji("AskUserQuestion")).toBe("\u{2753}");// ❓
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
it("returns correct emoji for CCC builtin tool names (snake_case)", () => {
|
|
88
|
+
expect(getToolEmoji("read_file")).toBe("\u{1F4D6}");
|
|
89
|
+
expect(getToolEmoji("list_dir")).toBe("\u{1F4C2}");
|
|
90
|
+
expect(getToolEmoji("search_code")).toBe("\u{1F50E}");
|
|
91
|
+
expect(getToolEmoji("run_command")).toBe("\u{1F5A5}\u{FE0F}");
|
|
92
|
+
expect(getToolEmoji("edit_file")).toBe("\u{270F}\u{FE0F}");
|
|
93
|
+
expect(getToolEmoji("create_file")).toBe("\u{270D}\u{FE0F}");
|
|
94
|
+
expect(getToolEmoji("delete_file")).toBe("\u{1F5D1}\u{FE0F}");
|
|
95
|
+
expect(getToolEmoji("move_file")).toBe("\u{1F4E6}");
|
|
96
|
+
expect(getToolEmoji("apply_patch")).toBe("\u{1F4CB}");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("normalizeToolName converts snake_case to PascalCase", () => {
|
|
100
|
+
expect(normalizeToolName("read_file")).toBe("ReadFile");
|
|
101
|
+
expect(normalizeToolName("run_command")).toBe("RunCommand");
|
|
102
|
+
expect(normalizeToolName("Read")).toBe("Read");
|
|
103
|
+
expect(normalizeToolName("")).toBe("");
|
|
104
|
+
});
|
|
105
|
+
|
|
86
106
|
it("returns wrench for unknown tool names", () => {
|
|
87
107
|
expect(getToolEmoji("UnknownTool")).toBe("\u{1F527}");
|
|
88
108
|
expect(getToolEmoji("cat")).toBe("\u{1F527}");
|
|
@@ -979,13 +979,19 @@ export async function editFileForTool(cwd: string, input: EditFileInput): Promis
|
|
|
979
979
|
const before = await readEditableTextFile(filePath);
|
|
980
980
|
assertExpectedSha256(filePath, before.sha, input.expectedSha256);
|
|
981
981
|
|
|
982
|
-
|
|
982
|
+
// Normalize line endings before matching so that LF-based oldText/newText
|
|
983
|
+
// (which is what models typically emit) works against CRLF files checked
|
|
984
|
+
// out on Windows. The file's dominant EOL style is restored on write.
|
|
985
|
+
const eol = detectEol(before.text);
|
|
986
|
+
let text = eol === "\r\n" ? before.text.replace(/\r\n/g, "\n") : before.text;
|
|
983
987
|
let editsApplied = 0;
|
|
984
988
|
for (const [index, edit] of input.edits.entries()) {
|
|
985
989
|
if (!edit.oldText) {
|
|
986
990
|
throw new Error(`edit ${index + 1} oldText must not be empty`);
|
|
987
991
|
}
|
|
988
|
-
const
|
|
992
|
+
const oldText = edit.oldText.replace(/\r\n/g, "\n");
|
|
993
|
+
const newText = edit.newText.replace(/\r\n/g, "\n");
|
|
994
|
+
const count = countOccurrences(text, oldText);
|
|
989
995
|
if (count === 0) {
|
|
990
996
|
throw new Error(`edit ${index + 1} oldText was not found in ${filePath}`);
|
|
991
997
|
}
|
|
@@ -993,10 +999,14 @@ export async function editFileForTool(cwd: string, input: EditFileInput): Promis
|
|
|
993
999
|
throw new Error(`edit ${index + 1} oldText matched ${count} times in ${filePath}; set replaceAll=true or provide more context`);
|
|
994
1000
|
}
|
|
995
1001
|
text = edit.replaceAll
|
|
996
|
-
? replaceAllLiteral(text,
|
|
997
|
-
: text.replace(
|
|
1002
|
+
? replaceAllLiteral(text, oldText, newText)
|
|
1003
|
+
: text.replace(oldText, newText);
|
|
998
1004
|
editsApplied += edit.replaceAll ? count : 1;
|
|
999
1005
|
}
|
|
1006
|
+
if (eol === "\r\n") {
|
|
1007
|
+
// After normalization above the buffer contains only \n, so this is safe.
|
|
1008
|
+
text = text.replace(/\n/g, "\r\n");
|
|
1009
|
+
}
|
|
1000
1010
|
|
|
1001
1011
|
assertTextSize(filePath, text, MAX_EDIT_BYTES);
|
|
1002
1012
|
const afterSha = sha256(text);
|
package/src/builtin/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
defaultBuiltinSessionId,
|
|
21
21
|
} from "./context.ts";
|
|
22
22
|
import { createBuiltinFileTools } from "./file-tools.ts";
|
|
23
|
+
import { buildDefaultSkillDirs, buildSkillsIndexPrompt, scanSkillsDirs } from "./skills.ts";
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// 系统提示词 — 编译期冻结常量
|
|
@@ -118,6 +119,11 @@ export interface ChatSessionOptions {
|
|
|
118
119
|
keepRecentMessages?: number;
|
|
119
120
|
/** Optional tool-step limit. Leave unset for no step limit. */
|
|
120
121
|
maxSteps?: number;
|
|
122
|
+
/**
|
|
123
|
+
* Codex-style skill 扫描目录(<dir>/<name>/SKILL.md)。
|
|
124
|
+
* 缺省扫描 ~/.codex/skills、~/.agents/skills、<cwd>/.codex/skills。
|
|
125
|
+
*/
|
|
126
|
+
skillsDirs?: string[];
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
/**
|
|
@@ -180,6 +186,12 @@ export class ChatSession {
|
|
|
180
186
|
if (projectInstructions) {
|
|
181
187
|
systemContent.push("", projectInstructions);
|
|
182
188
|
}
|
|
189
|
+
// Codex-style skills 索引注入(name + description + 路径,模型按需 read_file 全文)
|
|
190
|
+
const skills = scanSkillsDirs(options.skillsDirs ?? buildDefaultSkillDirs(this.cwd));
|
|
191
|
+
const skillsPrompt = buildSkillsIndexPrompt(skills);
|
|
192
|
+
if (skillsPrompt) {
|
|
193
|
+
systemContent.push("", skillsPrompt);
|
|
194
|
+
}
|
|
183
195
|
if (options.systemPrompt) {
|
|
184
196
|
systemContent.push("", options.systemPrompt);
|
|
185
197
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* builtin/skills.ts — Codex-style skills 支持
|
|
3
|
+
*
|
|
4
|
+
* 从用户级(~/.codex/skills、~/.agents/skills)和项目级(<cwd>/.codex/skills)
|
|
5
|
+
* 扫描 Codex 目录式 skill(<name>/SKILL.md),解析 frontmatter 中的
|
|
6
|
+
* name + description,生成索引注入 system prompt。模型按需用 read_file
|
|
7
|
+
* 读取 SKILL.md 全文并执行(索引注入省 token,触发靠 description + 指令)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface BuiltinSkill {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
/** SKILL.md 的绝对路径 */
|
|
18
|
+
skillPath: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 解析 SKILL.md frontmatter(兼容 CRLF),返回 name + description;无 frontmatter 返回 null */
|
|
22
|
+
export function parseSkillFrontmatter(
|
|
23
|
+
content: string,
|
|
24
|
+
): { name: string; description: string } | null {
|
|
25
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(content);
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
const fm = match[1];
|
|
28
|
+
const nameMatch = /^name:\s*(.+?)\s*$/m.exec(fm);
|
|
29
|
+
if (!nameMatch) return null;
|
|
30
|
+
const descMatch = /^description:\s*(.+?)\s*$/m.exec(fm);
|
|
31
|
+
return {
|
|
32
|
+
name: nameMatch[1].trim(),
|
|
33
|
+
description: descMatch?.[1].trim() ?? "",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 扫描多个 skill 目录,返回去重后的 skill 列表。
|
|
39
|
+
* 同名 skill 后面的目录覆盖前面的(调用方应把项目级目录放最后)。
|
|
40
|
+
* 隐藏目录(.system 等)和无 SKILL.md 的目录会被跳过。
|
|
41
|
+
*/
|
|
42
|
+
export function scanSkillsDirs(dirs: string[]): BuiltinSkill[] {
|
|
43
|
+
const byName = new Map<string, BuiltinSkill>();
|
|
44
|
+
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
continue; // 目录不存在或不可读:跳过
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!entry.isDirectory()) continue;
|
|
55
|
+
if (entry.name.startsWith(".")) continue; // 排除 .system 等隐藏/内置目录
|
|
56
|
+
|
|
57
|
+
const skillPath = join(dir, entry.name, "SKILL.md");
|
|
58
|
+
let content: string;
|
|
59
|
+
try {
|
|
60
|
+
content = readFileSync(skillPath, "utf-8");
|
|
61
|
+
} catch {
|
|
62
|
+
continue; // 没有 SKILL.md 的目录不是 skill
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const parsed = parseSkillFrontmatter(content);
|
|
66
|
+
if (!parsed) continue;
|
|
67
|
+
|
|
68
|
+
byName.set(parsed.name, {
|
|
69
|
+
name: parsed.name,
|
|
70
|
+
description: parsed.description,
|
|
71
|
+
skillPath,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [...byName.values()];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 默认 skill 扫描目录:
|
|
81
|
+
* 1. ~/.codex/skills(Codex CLI 旧路径,用户实际在用的地方)
|
|
82
|
+
* 2. ~/.agents/skills(Codex 标准全局目录)
|
|
83
|
+
* 3. <cwd>/.codex/skills(项目级,优先级最高,放最后)
|
|
84
|
+
*/
|
|
85
|
+
export function buildDefaultSkillDirs(cwd: string): string[] {
|
|
86
|
+
return [
|
|
87
|
+
join(homedir(), ".codex", "skills"),
|
|
88
|
+
join(homedir(), ".agents", "skills"),
|
|
89
|
+
join(cwd, ".codex", "skills"),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 生成 skill 索引提示词(注入 system prompt)。
|
|
95
|
+
* 索引只含 name + description + 路径,并指示模型在任务匹配时
|
|
96
|
+
* 先用 read_file 读取 SKILL.md 全文再执行——这是触发率的关键。
|
|
97
|
+
*/
|
|
98
|
+
export function buildSkillsIndexPrompt(skills: BuiltinSkill[]): string {
|
|
99
|
+
if (skills.length === 0) return "";
|
|
100
|
+
|
|
101
|
+
const lines = [
|
|
102
|
+
"## Available Skills (Codex-style)",
|
|
103
|
+
"The following Codex-style skills are available on this machine. When a user request matches a skill's description, first read its full SKILL.md with read_file, then follow the instructions in it exactly.",
|
|
104
|
+
"",
|
|
105
|
+
...skills.map((s) => `- **${s.name}** (\`${s.skillPath}\`): ${s.description || "(no description)"}`),
|
|
106
|
+
];
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
package/src/cards.ts
CHANGED
|
@@ -70,10 +70,29 @@ const TOOL_EMOJI_MAP: Record<string, string> = {
|
|
|
70
70
|
Agent: "\u{1F916}", // 🤖
|
|
71
71
|
NotebookEdit: "\u{1F4D3}", // 📓
|
|
72
72
|
AskUserQuestion: "\u{2753}",// ❓
|
|
73
|
+
// CCC 内置 Agent 下划线命名(getToolEmoji 会先把下划线转驼峰再查表,这里保留蛇形条目以便直接命中)
|
|
74
|
+
read_file: "\u{1F4D6}", // 📖
|
|
75
|
+
list_dir: "\u{1F4C2}", // 📂
|
|
76
|
+
search_code: "\u{1F50E}", // 🔎
|
|
77
|
+
run_command: "\u{1F5A5}\u{FE0F}", // 🖥️
|
|
78
|
+
edit_file: "\u{270F}\u{FE0F}", // ✏️
|
|
79
|
+
create_file: "\u{270D}\u{FE0F}", // ✍️
|
|
80
|
+
delete_file: "\u{1F5D1}\u{FE0F}", // 🗑️
|
|
81
|
+
move_file: "\u{1F4E6}", // 📦
|
|
82
|
+
apply_patch: "\u{1F4CB}", // 📋
|
|
73
83
|
};
|
|
74
84
|
|
|
75
85
|
export function getToolEmoji(name: string): string {
|
|
76
|
-
return TOOL_EMOJI_MAP[name] ?? "\u{1F527}"; // 🔧
|
|
86
|
+
return TOOL_EMOJI_MAP[name] ?? TOOL_EMOJI_MAP[normalizeToolName(name)] ?? "\u{1F527}"; // 🔧
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 把下划线命名转成驼峰(read_file → ReadFile),用于兼容两种命名风格 */
|
|
90
|
+
export function normalizeToolName(name: string): string {
|
|
91
|
+
return name
|
|
92
|
+
.split("_")
|
|
93
|
+
.filter((part) => part.length > 0)
|
|
94
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
95
|
+
.join("");
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
// ---------------------------------------------------------------------------
|