Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ test/ # 主项目测试文件(Node.js 内置 test runner)
- **入口**: `bin/dt-skill.js`。
- **构建**: `node ./scripts/build.mjs`,输出到 `dist/`。
- **测试**: Vitest,配置在 `vitest.config.ts`(测试 `src/**/*.test.ts`)。
- **Node 版本要求**: `>=20`(与主项目的 `>=18` 不同)。
- **Node 版本要求**: `>=18.17`(与主项目 Node 18 对齐;`npx dt-skill` / `test:src` 均可在 18 上运行)。
- **默认 Registry**: 内网部署 `http://172.16.100.225:7001`(无 flag/env 时开箱即用)。
- **本地开发覆盖**:
```bash
Expand Down
74 changes: 45 additions & 29 deletions app/service/skills.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ const {
resolveSkillIdentifier,
createUniqueSkillNames,
} = require('../utils/skill-install-key');
const { normalizeRelativePath: normalizeRelativeFilePath } = require('../utils/skill-utils');
const {
normalizeRelativePath: normalizeRelativeFilePath,
extractSkillMdDescription,
resolveMarketCardDescription,
} = require('../utils/skill-utils');
const GitHubStarsClient = require('../utils/github-stars');
const CommandRunner = require('../utils/command-runner');

Expand Down Expand Up @@ -820,14 +824,6 @@ class SkillsService extends Service {
return '';
}

extractDescription(content) {
const stripped = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#') && !line.startsWith('---'));
return stripped[0] || '';
}

parseFrontmatter(content) {
const result = {};
const text = String(content || '');
Expand Down Expand Up @@ -1435,13 +1431,11 @@ class SkillsService extends Service {
const content = fs.readFileSync(skillFilePath, 'utf8');
const stat = fs.statSync(skillFilePath);
const frontmatter = this.parseFrontmatter(content);
const body = frontmatter.__body || content;

const name =
String(frontmatter.name || path.basename(skillDir)).trim() || path.basename(skillDir);
const description =
String(frontmatter.description || this.extractDescription(body)).trim() ||
this.extractDescription(content);
// Same helper as registry publish / CLI default card summary.
const description = extractSkillMdDescription(content);
const version = String(frontmatter.version || '').trim();
const allowedTools = this.parseArrayLike(
frontmatter['allowed-tools'] || frontmatter.allowedTools || frontmatter.allowed_tools
Expand Down Expand Up @@ -1917,6 +1911,8 @@ class SkillsService extends Service {
transaction,
});

// Same sticky card rules as registry publish (CLI): explicit wins; else keep / backfill.
const hasDescription = Object.prototype.hasOwnProperty.call(params, 'description');
const payload = {
name,
category,
Expand All @@ -1926,6 +1922,14 @@ class SkillsService extends Service {
if (hasContributor) {
payload.contributor = contributor || null;
}
if (hasDescription) {
payload.description = resolveMarketCardDescription({
hasDescription: true,
description: params.description,
currentDescription: itemRow.description,
fromSkillMd: '',
});
}

if (!hasZipUpload) {
await itemRow.update(payload, { transaction });
Expand Down Expand Up @@ -1967,7 +1971,12 @@ class SkillsService extends Service {
await itemRow.update(
{
...payload,
description: nextRecord.description,
description: resolveMarketCardDescription({
hasDescription,
description: params.description,
currentDescription: itemRow.description,
fromSkillMd: nextRecord.description,
}),
allowed_tools: JSON.stringify(nextRecord.allowedTools || []),
updated_at_remote: nextRecord.updatedAt,
source_repo: nextRecord.sourceRepo,
Expand Down Expand Up @@ -2164,11 +2173,32 @@ class SkillsService extends Service {
record.name,
usedSlugs
);
const globalExisting = await SkillsItem.findOne({
where: { slug },
transaction,
});

if (
globalExisting &&
globalExisting.is_delete === 0 &&
globalExisting.name !== record.name
) {
this.ctx.throw(400, 'slug 已存在');
}

const targetRow = globalExisting || oldRowMap.get(slug);
// Web zip re-import: sticky market card (same as CLI registry re-publish).
const description = resolveMarketCardDescription({
hasDescription: false,
description: '',
currentDescription: targetRow ? targetRow.description : '',
fromSkillMd: record.description,
});
const payload = {
source_id: sourceId,
slug,
name: record.name,
description: record.description,
description,
category: record.category,
version: record.version || '',
tags: JSON.stringify(record.tags || []),
Expand All @@ -2185,20 +2215,6 @@ class SkillsService extends Service {
is_package: 0,
parent_slug: parentSlug || null,
};
const globalExisting = await SkillsItem.findOne({
where: { slug },
transaction,
});

if (
globalExisting &&
globalExisting.is_delete === 0 &&
globalExisting.name !== record.name
) {
this.ctx.throw(400, 'slug 已存在');
}

const targetRow = globalExisting || oldRowMap.get(slug);
let itemRow;
if (targetRow) {
itemRow = targetRow;
Expand Down
104 changes: 87 additions & 17 deletions app/service/skillsRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -402,11 +402,37 @@ class SkillsRegistryService extends Service {
});
}

/** Normalize multipart/in-memory uploads into stored-file shape. Requires SKILL.md. */
normalizePublishFiles(files) {
/**
* Normalize multipart/in-memory uploads into stored-file shape. Requires SKILL.md.
* @param {Array} files multipart or in-memory file objects
* @param {{ filePaths?: string[] }} [options]
* filePaths: optional parallel list of skill-relative paths (same order as files).
* Multipart Content-Disposition filenames often strip directories (RFC 7578),
* so clients should send explicit paths when nesting folders (e.g. agents/openai.yaml).
*/
normalizePublishFiles(files, options = {}) {
// Present but wrong type → hard fail (do not silently flatten nested paths).
if (options.filePaths != null && !Array.isArray(options.filePaths)) {
this.ctx.throw(400, 'filePaths 必须是字符串数组');
}
const filePaths = Array.isArray(options.filePaths) ? options.filePaths : null;
if (filePaths && filePaths.length > 0 && filePaths.length !== files.length) {
this.ctx.throw(
400,
`filePaths 数量 (${filePaths.length}) 与上传文件数量 (${files.length}) 不一致`
);
}

const processedFiles = [];
for (const file of files) {
const originalName = file.filename || path.basename(file.filepath || '');
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
// Prefer explicit path map; then multipart filename; then basename of temp filepath.
const declaredPath =
filePaths && filePaths[i] != null && String(filePaths[i]).trim()
? String(filePaths[i]).trim()
: null;
const originalName =
declaredPath || file.filename || path.basename(file.filepath || '');
const relPath = skillUtils.normalizeRelativePath(originalName);
if (!relPath) {
this.ctx.throw(400, `非法文件路径: ${originalName}`);
Expand All @@ -433,18 +459,19 @@ class SkillsRegistryService extends Service {
this.ctx.throw(400, `上传文件不存在: ${originalName}`);
}
processedFiles.push({
filename: originalName,
filename: path.basename(relPath),
relPath,
content,
isBinary,
});
}

const skillMdFile = processedFiles.find(
(f) => f.filename && f.filename.toLowerCase().endsWith('skill.md')
);
const skillMdFile = processedFiles.find((f) => {
const p = String(f.relPath || f.filename || '').toLowerCase();
return p === 'skill.md' || p.endsWith('/skill.md');
});
if (!skillMdFile) {
const uploadedNames = processedFiles.map((f) => f.filename).join(', ');
const uploadedNames = processedFiles.map((f) => f.relPath || f.filename).join(', ');
this.ctx.throw(400, `上传内容必须包含 SKILL.md。已上传: ${uploadedNames}`);
}

Expand All @@ -457,12 +484,26 @@ class SkillsRegistryService extends Service {
// make the no-op decision against a non-transactional snapshot.
const existingFingerprint = await this.computeSkillFingerprint(skill.id, transaction);
if (!existingFingerprint || existingFingerprint !== incomingFingerprint) return null;
// Content unchanged: still apply optional metadata (e.g. contributor) without re-storing files.
// Content unchanged: optional metadata only (no file rewrite).
const patch = {};
if (meta.hasContributor) {
await skill.update(
{ contributor: meta.contributor || null },
transaction ? { transaction } : undefined
);
patch.contributor = meta.contributor || null;
}
const nextDescription = skillUtils.resolveMarketCardDescription({
hasDescription: Boolean(meta.hasDescription),
description: meta.description,
currentDescription: skill.description,
fromSkillMd: meta.fromSkillMd,
});
const currentDesc = String(skill.description || '').trim();
// Always apply explicit override (incl. clear to ""); else only when card changes (e.g. empty backfill).
if (meta.hasDescription || nextDescription !== currentDesc) {
if (meta.hasDescription || nextDescription) {
patch.description = nextDescription;
}
}
if (Object.keys(patch).length > 0) {
await skill.update(patch, transaction ? { transaction } : undefined);
}
return {
ok: true,
Expand Down Expand Up @@ -509,13 +550,18 @@ class SkillsRegistryService extends Service {
const category = this.resolvePublishCategory(payload.category);
const hasContributor = Object.prototype.hasOwnProperty.call(payload, 'contributor');
const contributor = hasContributor ? this.validateContributor(payload.contributor) : '';
// description: present (incl. "") = market override; omit = SKILL.md default / keep card.
const hasDescription = Object.prototype.hasOwnProperty.call(payload, 'description');

if (!SKILL_SLUG_PATTERN.test(String(slug || ''))) {
this.ctx.throw(400, 'slug 格式无效');
}

const parsedTags = Array.isArray(tags) ? tags : [];
const { processedFiles, skillMdFile } = this.normalizePublishFiles(files);
const { processedFiles, skillMdFile } = this.normalizePublishFiles(files, {
filePaths: payload.filePaths,
});
const fromSkillMd = skillUtils.extractSkillMdDescription(skillMdFile.content || '');
const incomingFingerprint = this.computeIncomingFingerprint(processedFiles);

return await this.app.model.transaction(async (t) => {
Expand All @@ -529,7 +575,18 @@ class SkillsRegistryService extends Service {
transaction: t,
});

// Exact slug first; if missing, resolve installKey / alias so overwrite
// does not create a second skill with a different primary slug.
let skill = await SkillsItem.findOne({ where: { slug }, transaction: t });
if (!skill) {
const aliased = await this._resolveSlug(slug);
if (aliased && aliased.slug && aliased.slug !== slug) {
skill = await SkillsItem.findOne({
where: { slug: aliased.slug },
transaction: t,
});
}
}

const noop = await this.tryPublishUnchanged(
skill,
Expand All @@ -538,6 +595,9 @@ class SkillsRegistryService extends Service {
{
hasContributor,
contributor,
hasDescription,
description: payload.description,
fromSkillMd,
},
t
);
Expand All @@ -546,12 +606,17 @@ class SkillsRegistryService extends Service {
if (skill) {
const updatePayload = {
name: displayName,
description: payload.description || '',
version,
tags: JSON.stringify(parsedTags),
skill_md: skillMdFile.content || '',
is_delete: 0,
source_id: source.id,
description: skillUtils.resolveMarketCardDescription({
hasDescription,
description: payload.description,
currentDescription: skill.description,
fromSkillMd,
}),
};
// Explicit preserve: do not rely on partial-update omitting the field.
if (category) {
Expand All @@ -568,7 +633,12 @@ class SkillsRegistryService extends Service {
source_id: source.id,
slug,
name: displayName,
description: payload.description || '',
description: skillUtils.resolveMarketCardDescription({
hasDescription,
description: payload.description,
currentDescription: '',
fromSkillMd,
}),
version,
tags: JSON.stringify(parsedTags),
skill_md: skillMdFile.content || '',
Expand Down
Loading
Loading