Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .release-notes/fix-issue-534-bundled-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 修复 V2 内置 Skill 资源一致性

<!-- release-target: v2 -->

## Bug 修复

- 修复 V2 内置 Skill 在自动化模板、主 Skill 库与安装包之间可能内容不一致的问题,嵌套资源现在会随 Skill 完整交付。
2 changes: 1 addition & 1 deletion apps/main-2.0/assets/bundled-skills/brainstorming/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,4 @@ A browser-based companion for showing mockups, diagrams, and visual options duri
A question about a UI topic is not automatically a visual question. "What does personality mean in this context?" is a conceptual question — use the terminal. "Which wizard layout works better?" is a visual question — use the browser.

If they agree to the companion, read the detailed guide before proceeding:
`skills/brainstorming/visual-companion.md`
`references/visual-companion.md`
1 change: 0 additions & 1 deletion apps/main-2.0/scripts/check-source-entrypoints.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ function collectSourceFiles(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (entryPath === path.join(sourceRoot, "automation", "engine", "shared", "bundled-skills")) continue;
files.push(...collectSourceFiles(entryPath));
continue;
}
Expand Down
67 changes: 67 additions & 0 deletions apps/main-2.0/scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { build as viteBuild } from "vite";
import { packReleaseArchive } from "./pack-release.mjs";

const execFileAsync = promisify(execFile);
Expand All @@ -15,6 +16,7 @@ const packDir = path.join(tempRoot, "pack");
const prefix = path.join(tempRoot, "prefix");
const stageRoot = path.join(tempRoot, "stage");
const home = path.join(tempRoot, "home");
const skillVerifierRoot = path.join(tempRoot, "skill-verifier");
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const environment = {
...process.env,
Expand Down Expand Up @@ -145,6 +147,71 @@ try {
if (diagramSamples.filter((entry) => entry.endsWith(".svg")).length !== 66) {
throw new Error("Packaged diagram Skill must include all 66 SVG samples.");
}

// Keep smoke-only entry points out of out/main. The installed package is the
// Vite root so absolute asset globs read the exact files in the archive.
await viteBuild({
root: installedRoot,
configFile: false,
publicDir: false,
logLevel: "warn",
build: {
ssr: true,
target: "node22",
outDir: skillVerifierRoot,
emptyOutDir: true,
rollupOptions: {
input: {
"bundled-skill-library": path.join(root, "src", "automation", "engine", "shared", "bundled-skill-library.ts"),
"managed-skill-library": path.join(root, "src", "core", "managed-skill-library.ts"),
},
output: {
entryFileNames: "[name].mjs",
},
},
},
});

const bundledSkillLibrary = await import(pathToFileURL(path.join(skillVerifierRoot, "bundled-skill-library.mjs")).href);
const loadBundledSkillTemplates = bundledSkillLibrary.loadBundledSkillTemplates ?? bundledSkillLibrary.default?.loadBundledSkillTemplates;
const bundledSkillAssetsFor = bundledSkillLibrary.bundledSkillAssetsFor ?? bundledSkillLibrary.default?.bundledSkillAssetsFor;
if (typeof loadBundledSkillTemplates !== "function" || typeof bundledSkillAssetsFor !== "function") {
throw new Error("Packaged bundled Skill loader did not expose its template and asset APIs.");
}
const bundledTemplates = loadBundledSkillTemplates();
for (const templateId of ["rewrite-technical-tutorial", "feishu-tech-diagram"]) {
if (!bundledTemplates.some((template) => template.id === templateId && template.sourceType === "official")) {
throw new Error(`Packaged Automation templates did not discover ${templateId}.`);
}
}
const packagedDiagramAssets = bundledSkillAssetsFor("feishu-tech-diagram");
if (packagedDiagramAssets.filter((asset) => asset.relativePath.startsWith("assets/samples/") && asset.relativePath.endsWith(".svg")).length !== 66) {
throw new Error("Packaged Automation loader must embed all 66 diagram SVG samples.");
}

const managedSkillLibraryModule = await import(pathToFileURL(path.join(skillVerifierRoot, "managed-skill-library.mjs")).href);
const ManagedSkillLibrary = managedSkillLibraryModule.ManagedSkillLibrary ?? managedSkillLibraryModule.default?.ManagedSkillLibrary;
if (typeof ManagedSkillLibrary !== "function") throw new Error("Packaged managed Skill library was not exported.");
const managedLibrary = new ManagedSkillLibrary({
libraryRoot: path.join(tempRoot, "managed-skills"),
homeDir: path.join(tempRoot, "managed-home"),
});
managedLibrary.ensureBuiltinSkills(path.join(installedRoot, "assets", "bundled-skills"));
for (const skillId of ["rewrite-technical-tutorial", "feishu-tech-diagram"]) {
if (!managedLibrary.list().skills.some((skill) => skill.managedId === skillId)) {
throw new Error(`Packaged managed Skill library did not import ${skillId}.`);
}
}
const managedDiagram = managedLibrary.list().skills.find((skill) => skill.managedId === "feishu-tech-diagram");
if (!managedDiagram) throw new Error("Packaged managed Skill library did not import feishu-tech-diagram.");
const managedDiagramSpecs = JSON.parse(await readFile(path.join(managedDiagram.directoryPath, "references", "template-specs.json")));
if (!Array.isArray(managedDiagramSpecs) || managedDiagramSpecs.length !== 66) {
throw new Error("Managed Skill import did not retain all 66 diagram template specifications.");
}
const managedDiagramSamples = await readdir(path.join(managedDiagram.directoryPath, "assets", "samples"));
if (managedDiagramSamples.filter((entry) => entry.endsWith(".svg")).length !== 66) {
throw new Error("Managed Skill import did not retain all 66 diagram SVG samples.");
}
const installedRequire = createRequire(path.join(installedRoot, "package.json"));
const {
restoreEmbeddedPostgresNativeLinks,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ describe("MCP bridge", () => {
const skillTemplates = (await (await bridgeRequest("/mcp/skill-templates/list", bridge.token, {})).json()) as any;
expect(skillTemplates).toMatchObject({
ok: true,
templates: expect.arrayContaining([expect.objectContaining({ id: "brainstorming", sourcePath: "src/shared/bundled-skills/brainstorming/SKILL.md" })]),
templates: expect.arrayContaining([expect.objectContaining({ id: "brainstorming", sourcePath: "assets/bundled-skills/brainstorming/SKILL.md" })]),
});

const skillSearch = (await (await bridgeRequest("/mcp/skills/search-online", bridge.token, { query: "frontend design anthropic" })).json()) as any;
Expand Down
30 changes: 4 additions & 26 deletions apps/main-2.0/src/automation/engine/main/skills/skill-installer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { existsSync } from "node:fs";
import { cp, lstat, mkdir, readdir, readFile, readlink, rm, symlink, unlink, writeFile } from "node:fs/promises";
import { lstat, mkdir, readdir, readFile, readlink, rm, symlink, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { bundledSkillAssetsFor } from "../../shared/bundled-skill-library";
import { parseSkillMarkdown } from "../../shared/online-skills";
import { SKILL_TEMPLATES } from "../../shared/skill-templates";
Expand Down Expand Up @@ -70,27 +68,12 @@ interface ImportedSkillMetadata {
importedFromId?: string;
}

function bundledSkillSourceDir(template: SkillTemplate): string | undefined {
if (!template.sourcePath?.startsWith("src/shared/bundled-skills/")) return undefined;
const relativeDir = path.dirname(template.sourcePath);
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.resolve(process.cwd(), relativeDir),
path.resolve(moduleDir, "..", "..", relativeDir),
path.resolve(moduleDir, "..", "shared", "bundled-skills", template.id),
];
return candidates.find((candidate) => pathExistsSync(candidate));
}

function pathExistsSync(filePath: string): boolean {
return existsSync(filePath);
}

async function writeEmbeddedSkillSource(template: SkillTemplate, sourceDir: string): Promise<void> {
const assets = bundledSkillAssetsFor(template.id);
await mkdir(sourceDir, { recursive: true });
const skillAsset = assets.find((asset) => asset.relativePath === "SKILL.md");
await writeFile(path.join(sourceDir, "SKILL.md"), skillAsset?.contents ?? `${template.prompt.trim()}\n`, "utf8");
if (!skillAsset) throw new Error(`Bundled Skill ${template.id} is missing SKILL.md.`);
await writeFile(path.join(sourceDir, "SKILL.md"), skillAsset.contents, "utf8");
for (const asset of assets) {
if (asset.relativePath === "SKILL.md") continue;
const targetPath = path.join(sourceDir, asset.relativePath);
Expand Down Expand Up @@ -194,12 +177,7 @@ export async function installBundledSkill(request: InstallSkillRequest, homeDir:
const existed = await pathExists(linkPath);
if (!imported) {
await rm(sourceDir, { recursive: true, force: true });
const bundledSourceDir = bundledSkillSourceDir(template);
if (bundledSourceDir) {
await cp(bundledSourceDir, sourceDir, { recursive: true });
} else {
await writeEmbeddedSkillSource(template, sourceDir);
}
await writeEmbeddedSkillSource(template, sourceDir);
}
await mkdir(path.dirname(linkPath), { recursive: true });
if (existed) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { bundledSkillAssetsFor, loadBundledSkillTemplates } from "./bundled-skill-library";

const canonicalSkillRoot = fileURLToPath(new URL("../../../../assets/bundled-skills", import.meta.url));
const legacySkillRoot = fileURLToPath(new URL("../../../../src/automation/engine/shared/bundled-skills", import.meta.url));

describe("bundled Skill assets", () => {
it("keeps bundled Skill content in the packaged assets directory only", () => {
expect(existsSync(canonicalSkillRoot)).toBe(true);
expect(existsSync(legacySkillRoot)).toBe(false);
});

it("lists the technical writing and diagram Skills as official writing templates", () => {
const templates = loadBundledSkillTemplates();

expect(templates.map((template) => template.id)).toEqual([
"brainstorming",
"frontend-design",
"feishu-tech-diagram",
"handoff",
"skill-creator",
"systematic-debugging",
"personal-finance-planning",
"resume-optimization",
"paper-writing",
"rewrite-technical-tutorial",
"refactor-review-knowledge",
"code-review-and-quality",
]);
expect(templates.every((template) => template.sourcePath?.startsWith("assets/bundled-skills/") ?? false)).toBe(true);

expect(templates.find((template) => template.id === "rewrite-technical-tutorial"))
.toMatchObject({
name: "rewrite-technical-tutorial",
Expand Down
37 changes: 19 additions & 18 deletions apps/main-2.0/src/automation/engine/shared/bundled-skill-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,29 @@ const BUNDLED_SKILL_ORDER = [
"code-review-and-quality",
];

const skillMarkdownFiles = import.meta.glob<string>("./bundled-skills/*/SKILL.md", {
const BUNDLED_SKILL_ROOT = "/assets/bundled-skills";

const skillMarkdownFiles = import.meta.glob<string>("/assets/bundled-skills/*/SKILL.md", {
eager: true,
import: "default",
query: "?raw",
});
const skillTranslationFiles = import.meta.glob<string>("./bundled-skills/*/SKILL.zh.md", {
const skillTranslationFiles = import.meta.glob<string>("/assets/bundled-skills/*/SKILL.zh.md", {
eager: true,
import: "default",
query: "?raw",
});
const skillMetadataFiles = import.meta.glob<string>("./bundled-skills/*/metadata.json", {
const skillMetadataFiles = import.meta.glob<string>("/assets/bundled-skills/*/metadata.json", {
eager: true,
import: "default",
query: "?raw",
});
const directSkillAssetFiles = import.meta.glob<string>("./bundled-skills/*/*", {
const directSkillAssetFiles = import.meta.glob<string>("/assets/bundled-skills/*/*", {
eager: true,
import: "default",
query: "?raw",
});
const nestedSkillAssetFiles = import.meta.glob<string>("./bundled-skills/*/**/*", {
const nestedSkillAssetFiles = import.meta.glob<string>("/assets/bundled-skills/*/**/*", {
eager: true,
import: "default",
query: "?raw",
Expand All @@ -54,17 +56,17 @@ export interface BundledSkillAsset {
}

function skillIdFromPath(filePath: string): string {
const match = filePath.match(/\.\/bundled-skills\/([^/]+)\/[^/]+$/);
const match = filePath.match(/\/assets\/bundled-skills\/([^/]+)\/[^/]+$/);
if (!match?.[1]) throw new Error(`Invalid bundled skill path: ${filePath}`);
return match[1];
}

function sourcePathFor(filePath: string): string {
return `src/shared/${filePath.replace(/^\.\//, "")}`;
return filePath.replace(/^\//, "");
}

export function bundledSkillAssetsFor(skillId: string): BundledSkillAsset[] {
const prefix = `./bundled-skills/${skillId}/`;
const prefix = `${BUNDLED_SKILL_ROOT}/${skillId}/`;
const assets = new Map<string, string>();
for (const [filePath, contents] of Object.entries({ ...directSkillAssetFiles, ...nestedSkillAssetFiles })) {
if (!filePath.startsWith(prefix)) continue;
Expand Down Expand Up @@ -103,7 +105,7 @@ function stripYamlScalar(value: string): string {
}

function metadataFor(skillId: string): BundledSkillMetadata {
const raw = skillMetadataFiles[`./bundled-skills/${skillId}/metadata.json`];
const raw = skillMetadataFiles[`${BUNDLED_SKILL_ROOT}/${skillId}/metadata.json`];
if (!raw) return {};
const parsed = JSON.parse(raw) as Partial<BundledSkillMetadata>;
const metadata: BundledSkillMetadata = {};
Expand All @@ -115,14 +117,13 @@ function metadataFor(skillId: string): BundledSkillMetadata {
}

function orderedSkillEntries(): Array<[string, string]> {
const order = new Map(BUNDLED_SKILL_ORDER.map((id, index) => [id, index]));
return Object.entries(skillMarkdownFiles).sort(([leftPath], [rightPath]) => {
const leftId = skillIdFromPath(leftPath);
const rightId = skillIdFromPath(rightPath);
const leftOrder = order.get(leftId) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = order.get(rightId) ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return leftId.localeCompare(rightId);
return BUNDLED_SKILL_ORDER.map((skillId) => {
const filePath = `${BUNDLED_SKILL_ROOT}/${skillId}/SKILL.md`;
const prompt = skillMarkdownFiles[filePath];
if (prompt === undefined) {
throw new Error(`Bundled Automation Skill is missing its canonical asset: ${filePath}`);
}
return [filePath, prompt];
});
}

Expand All @@ -145,7 +146,7 @@ export function loadBundledSkillTemplates(): SkillTemplate[] {
};
const sourceUrl = metadata.sourceUrl;
if (sourceUrl) template.sourceUrl = sourceUrl;
const translationZh = skillTranslationFiles[`./bundled-skills/${id}/SKILL.zh.md`];
const translationZh = skillTranslationFiles[`${BUNDLED_SKILL_ROOT}/${id}/SKILL.zh.md`];
if (translationZh) template.translationZh = normalizeNewlines(translationZh);
return template;
});
Expand Down
Loading