Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e5a65a7
feat(skills): hash-based publish/update without user version
Jul 27, 2026
6c8ecf6
fix(skills): address hash publish/update review findings
Jul 27, 2026
70e53d9
refactor(skills): deepen hash sync (C1–C4 architecture)
Jul 28, 2026
c4af0e5
test(skills): mock SkillsFile.update for publishSkill create path
Jul 28, 2026
bf31f95
refactor(skills): use shared slug helpers in skills.ts
Jul 28, 2026
d34ce3d
style: format skills hash changes with Prettier
Jul 28, 2026
485af12
fix(skills): single lock write after update + fingerprint mock fidelity
Jul 28, 2026
1ad3dc7
fix(skills): clear ESLint errors after update command split
Jul 28, 2026
0b14d47
refactor(dt-skill): drop unused resolve CLI types
Jul 28, 2026
b52a53c
test(dt-skill): align update mock with single detail + disk fingerprint
Jul 28, 2026
5988053
fix(skills): explicitly preserve category on re-publish
Jul 28, 2026
e1e9d5a
refactor(skills): share skill category enum via contracts
Jul 28, 2026
122ffb3
style: prettier format re-publish category contract test
Jul 28, 2026
5ee63cd
feat(dt-skill): default registry to intranet deploy URL
Jul 28, 2026
8ab042d
fix(dt-skill): simplify default registry fallback after review
Jul 28, 2026
cb76008
test(dt-skill): cover registry overrides for local dev
Jul 28, 2026
f3d7fb3
fix(dt-skill): test real cli/env registry selection path
Jul 28, 2026
64dc134
docs(dt-skill): document default registry and dev override
Jul 28, 2026
5e9dc41
fix(dt-skill): align README Defaults with built-in registry
Jul 28, 2026
8379b85
style(dt-skill): prettier format cli help registry block
Jul 28, 2026
5bc214d
chore(dt-skill): release 0.18.4 metadata for DTStack/doraemon
Jul 29, 2026
68f76f8
chore(dt-skill): ignore npm pack tarballs
Jul 29, 2026
d43b2dc
fix(skills): do not mark UTF-8 skills binary at 4k boundary
Jul 29, 2026
7c29957
feat(skills): set contributor from git user.name on CLI publish
Jul 29, 2026
6f8d692
fix(skills): update contributor on content-unchanged publish
Jul 29, 2026
119c578
style(dt-skill): sort publish imports for eslint
Jul 29, 2026
107fb41
chore(dt-skill): bump version to 0.18.5
Jul 29, 2026
68522b9
refactor(dt-skill): import fileExists from skillHelpers
Jul 29, 2026
6ea10ea
fix(skills): read publish fingerprint inside transaction
Jul 29, 2026
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
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ test/ # 主项目测试文件(Node.js 内置 test runner)
- **构建**: `node ./scripts/build.mjs`,输出到 `dist/`。
- **测试**: Vitest,配置在 `vitest.config.ts`(测试 `src/**/*.test.ts`)。
- **Node 版本要求**: `>=20`(与主项目的 `>=18` 不同)。
- **默认 Registry**: 内网部署 `http://172.16.100.225:7001`(无 flag/env 时开箱即用)。
- **本地开发覆盖**:
```bash
export DT_SKILL_REGISTRY=http://127.0.0.1:7001
# 或
node bin/dt-skill.js --registry http://127.0.0.1:7001 search foo
```
优先级:`--registry` > `DT_SKILL_REGISTRY` > 本机缓存 > site 发现 > 内置默认。

## 测试

Expand Down Expand Up @@ -156,3 +164,17 @@ test/ # 主项目测试文件(Node.js 内置 test runner)
- `dev`: 主开发分支。
- `feat_版本号_xxx`: 新特性分支,从 `master` 切出,开发完 PR 到 `dev`。
- `hotfix_版本号_xxx`: Bug 修复分支,从 `master` 切出,修复完 PR 到 `dev`,验证后合并到 `master`。

## Agent skills

### Issue tracker

Issues / specs / tickets live as **local markdown** under `.scratch/<feature-slug>/`. See `docs/agents/issue-tracker.md`.

### Triage labels

Default vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix` (written as `Status:` on local ticket files). See `docs/agents/triage-labels.md`.

### Domain docs

**Single-context** layout: optional root `CONTEXT.md` + `docs/adr/`. See `docs/agents/domain.md`.
11 changes: 1 addition & 10 deletions app/service/skills.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,7 @@ const SKILLS_ROOT_DISCOVER_DEPTH_LIMIT = 8;
const DISCOVER_MAX_DIR_COUNT = 3000;
const SKILL_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

const SKILL_CATEGORY_OPTIONS = [
'通用',
'前端',
'后端',
'数据与AI',
'运维与系统',
'工程效率',
'安全',
'其他',
];
const { SKILL_CATEGORY_OPTIONS } = require('../../contracts/skill-categories');

const EXTENSION_LANGUAGE_MAP = {
'.md': 'markdown',
Expand Down
252 changes: 183 additions & 69 deletions app/service/skillsRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ const ignore = require('ignore');
const path = require('path');
const skillUtils = require('../utils/skill-utils');
const skillFingerprint = require('../../contracts/skill-fingerprint');
const {
SKILL_CATEGORY_OPTIONS,
isValidSkillCategory,
} = require('../../contracts/skill-categories');

const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
const SKILL_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/** Compatibility placeholder when client omits version; content hash is the change signal. */
const DEFAULT_PUBLISH_VERSION = '0.0.0';
/** Matches skills_items.contributor VARCHAR(50) and marketplace UI max length. */
const MAX_CONTRIBUTOR_LENGTH = 50;

class SkillsRegistryService extends Service {
// Well-Known Registry Metadata
Expand Down Expand Up @@ -156,6 +164,12 @@ class SkillsRegistryService extends Service {
const stats = { stars: skill.stars || 0, downloads: 0 };
const createdAt = skill.created_at ? new Date(skill.created_at).getTime() : 0;
const updatedAt = skill.updated_at ? new Date(skill.updated_at).getTime() : 0;
let fingerprint = null;
try {
fingerprint = await this.computeSkillFingerprint(skill.id);
} catch (err) {
this.ctx.logger.warn('[skillsRegistry] compute fingerprint failed:', err);
}

const detail = {
skill: {
Expand All @@ -169,7 +183,10 @@ class SkillsRegistryService extends Service {
updatedAt,
isPackage: skill.is_package === 1,
parentSlug: skill.parent_slug || null,
category: skill.category || '通用',
fingerprint,
},
// fingerprint lives only on skill (single-slot current content).
latestVersion: version
? {
version,
Expand Down Expand Up @@ -353,22 +370,40 @@ class SkillsRegistryService extends Service {
return SEMVER_PATTERN.test(String(version || '').trim());
}

// Publish or update a skill
async publishSkill(payload, files) {
const { SkillsItem, SkillsFile, SkillsSource } = this.app.model;
const { slug, displayName, version, tags } = payload;

if (!SKILL_SLUG_PATTERN.test(String(slug || ''))) {
this.ctx.throw(400, 'slug 格式无效');
// Compatibility placeholder when client omits version (hash is the real change signal).
resolvePublishVersion(version) {
const raw = String(version || '').trim();
if (!raw) return DEFAULT_PUBLISH_VERSION;
if (!this.validateSemVer(raw)) {
this.ctx.throw(400, 'version 必须是有效的 SemVer 格式');
}
return raw;
}

if (!this.validateSemVer(version)) {
this.ctx.throw(400, 'version 必须是有效的 SemVer 格式');
resolvePublishCategory(category) {
const raw = String(category || '').trim();
if (!raw) return null;
if (!isValidSkillCategory(raw)) {
this.ctx.throw(400, `category 无效,可选: ${SKILL_CATEGORY_OPTIONS.join(', ')}`);
}
return raw;
}

const parsedTags = Array.isArray(tags) ? tags : [];
// Fingerprint of an in-memory multipart/processed upload set (same contract as stored files).
computeIncomingFingerprint(processedFiles) {
const storedLike = processedFiles.map((file) => ({
file_path: file.relPath,
content: file.content,
is_binary: file.isBinary ? 1 : 0,
}));
const fingerprintIgnore = this.createFingerprintIgnore(storedLike);
return skillFingerprint.buildSkillFingerprintFromStoredFiles(storedLike, {
ignoreMatcher: fingerprintIgnore,
});
}

// file.content 直接给(内存形态,测试/部分调用方)优先;否则读磁盘临时文件(真实 multipart)。
/** Normalize multipart/in-memory uploads into stored-file shape. Requires SKILL.md. */
normalizePublishFiles(files) {
const processedFiles = [];
for (const file of files) {
const originalName = file.filename || path.basename(file.filepath || '');
Expand All @@ -395,7 +430,6 @@ class SkillsRegistryService extends Service {
this.ctx.throw(400, `读取上传文件 ${originalName} 失败`);
}
} else {
// I1: 既无 content 也无可读磁盘文件,必须报错而非静默存空
this.ctx.throw(400, `上传文件不存在: ${originalName}`);
}
processedFiles.push({
Expand All @@ -406,7 +440,6 @@ class SkillsRegistryService extends Service {
});
}

// Check for SKILL.md
const skillMdFile = processedFiles.find(
(f) => f.filename && f.filename.toLowerCase().endsWith('skill.md')
);
Expand All @@ -415,6 +448,76 @@ class SkillsRegistryService extends Service {
this.ctx.throw(400, `上传内容必须包含 SKILL.md。已上传: ${uploadedNames}`);
}

return { processedFiles, skillMdFile };
}

async tryPublishUnchanged(skill, incomingFingerprint, version, meta = {}, transaction) {
if (!skill || skill.is_delete !== 0) return null;
// Read files in the same transaction as publish so concurrent writers cannot
// 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.
if (meta.hasContributor) {
await skill.update(
{ contributor: meta.contributor || null },
transaction ? { transaction } : undefined
);
}
return {
ok: true,
skillId: String(skill.id),
versionId: `v${skill.version || version}`,
fingerprint: existingFingerprint,
unchanged: true,
};
}

async replaceSkillStoredFiles(skill, processedFiles, transaction) {
const { SkillsFile } = this.app.model;
await SkillsFile.update({ is_delete: 1 }, { where: { skill_id: skill.id }, transaction });
for (const file of processedFiles) {
await SkillsFile.create(
{
skill_id: skill.id,
file_path: file.relPath,
language: this.detectLanguage(file.filename),
size: Buffer.byteLength(file.content, file.isBinary ? 'base64' : 'utf8'),
is_binary: file.isBinary ? 1 : 0,
encoding: file.isBinary ? 'base64' : 'utf8',
content: file.content,
},
{ transaction }
);
}
await skill.update({ file_count: processedFiles.length }, { transaction });
}

validateContributor(value) {
const contributor = String(value || '').trim();
if (contributor.length > MAX_CONTRIBUTOR_LENGTH) {
this.ctx.throw(400, `贡献者不能超过 ${MAX_CONTRIBUTOR_LENGTH} 个字符`);
}
return contributor;
}

// Publish or update a skill (single-slot per slug; content hash is the change signal)
async publishSkill(payload, files) {
const { SkillsItem, SkillsSource } = this.app.model;
const { slug, displayName, tags } = payload;
const version = this.resolvePublishVersion(payload.version);
const category = this.resolvePublishCategory(payload.category);
const hasContributor = Object.prototype.hasOwnProperty.call(payload, 'contributor');
const contributor = hasContributor ? this.validateContributor(payload.contributor) : '';

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 incomingFingerprint = this.computeIncomingFingerprint(processedFiles);

return await this.app.model.transaction(async (t) => {
const [source] = await SkillsSource.findOrCreate({
where: { source_url: 'clawhub-publish' },
Expand All @@ -428,75 +531,79 @@ class SkillsRegistryService extends Service {

let skill = await SkillsItem.findOne({ where: { slug }, transaction: t });

const noop = await this.tryPublishUnchanged(
skill,
incomingFingerprint,
version,
{
hasContributor,
contributor,
},
t
);
if (noop) return noop;

if (skill) {
await skill.update(
{
name: displayName,
description: payload.description || '',
version,
tags: JSON.stringify(parsedTags),
skill_md: skillMdFile.content || '',
is_delete: 0,
source_id: source.id,
},
{ transaction: t }
);
// Delete old files
await SkillsFile.update(
{ is_delete: 1 },
{ where: { skill_id: skill.id }, transaction: t }
);
const updatePayload = {
name: displayName,
description: payload.description || '',
version,
tags: JSON.stringify(parsedTags),
skill_md: skillMdFile.content || '',
is_delete: 0,
source_id: source.id,
};
// Explicit preserve: do not rely on partial-update omitting the field.
if (category) {
updatePayload.category = category;
} else if (skill.category) {
updatePayload.category = skill.category;
}
if (hasContributor) {
updatePayload.contributor = contributor || null;
}
await skill.update(updatePayload, { transaction: t });
} else {
skill = await SkillsItem.create(
{
source_id: source.id,
slug,
name: displayName,
description: payload.description || '',
version,
tags: JSON.stringify(parsedTags),
skill_md: skillMdFile.content || '',
category: '通用',
file_count: files.length,
},
{ transaction: t }
);
}

// Save files
for (const file of processedFiles) {
await SkillsFile.create(
{
skill_id: skill.id,
file_path: file.relPath,
language: this.detectLanguage(file.filename),
size: Buffer.byteLength(file.content, file.isBinary ? 'base64' : 'utf8'),
is_binary: file.isBinary ? 1 : 0,
encoding: file.isBinary ? 'base64' : 'utf8',
content: file.content,
},
{ transaction: t }
);
const createPayload = {
source_id: source.id,
slug,
name: displayName,
description: payload.description || '',
version,
tags: JSON.stringify(parsedTags),
skill_md: skillMdFile.content || '',
category: category || '通用',
file_count: processedFiles.length,
};
if (hasContributor) {
createPayload.contributor = contributor || null;
}
skill = await SkillsItem.create(createPayload, { transaction: t });
}

// Update file count
await skill.update({ file_count: files.length }, { transaction: t });
await this.replaceSkillStoredFiles(skill, processedFiles, t);

return {
ok: true,
skillId: String(skill.id),
versionId: `v${version}`,
fingerprint: incomingFingerprint,
unchanged: false,
};
});
}

// Compute SHA256 fingerprint for a skill
async computeSkillFingerprint(skillId) {
async computeSkillFingerprint(skillId, transaction) {
const { SkillsFile } = this.app.model;
const files = await SkillsFile.findAll({
const query = {
where: { skill_id: skillId, is_delete: 0 },
order: [['file_path', 'ASC']],
});
};
if (transaction) {
query.transaction = transaction;
}
const files = await SkillsFile.findAll(query);
const fingerprintIgnore = this.createFingerprintIgnore(files);

return skillFingerprint.buildSkillFingerprintFromStoredFiles(files, {
Expand All @@ -518,9 +625,16 @@ class SkillsRegistryService extends Service {
};
}

const skillFingerprint = await this.computeSkillFingerprint(skill.id);
const match = skillFingerprint === hash ? { version: skill.version || '' } : null;
const latestVersion = skill.version ? { version: skill.version } : null;
const currentFingerprint = await this.computeSkillFingerprint(skill.id);
const version = skill.version || '0.0.0';
const match =
hash && currentFingerprint === hash
? { version, fingerprint: currentFingerprint }
: null;
const latestVersion = {
version,
fingerprint: currentFingerprint,
};

return {
match,
Expand Down Expand Up @@ -612,7 +726,7 @@ class SkillsRegistryService extends Service {
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
if (sample.includes(0)) return true;
try {
new TextDecoder('utf-8', { fatal: true }).decode(sample);
new TextDecoder('utf-8', { fatal: true }).decode(buffer);
return false;
} catch {
return true;
Expand Down
Loading
Loading