diff --git a/app/model/skills_item.js b/app/model/skills_item.js index a9893c0..2a381e1 100644 --- a/app/model/skills_item.js +++ b/app/model/skills_item.js @@ -38,6 +38,12 @@ module.exports = (app) => { defaultValue: '', comment: '技能版本号', }, + publish_revision: { + type: INTEGER, + allowNull: false, + defaultValue: 1, + comment: '发布自增版本号', + }, tags: { type: TEXT('long'), comment: 'JSON字符串数组', diff --git a/app/service/skills.js b/app/service/skills.js index 8e1c253..b1d609b 100644 --- a/app/service/skills.js +++ b/app/service/skills.js @@ -12,6 +12,7 @@ const { const { normalizeRelativePath: normalizeRelativeFilePath, extractSkillMdDescription, + resolveSkillDisplayVersion, resolveMarketCardDescription, } = require('../utils/skill-utils'); const { @@ -126,6 +127,7 @@ class SkillsService extends Service { await SkillsItem.sync(); await SkillsFile.sync(); await this.ensureSkillsItemVersionColumn(); + await this.ensureSkillsItemPublishRevisionColumn(); await this.ensureSkillsItemPackageColumns(); await this.ensureSkillsItemContributorColumn(); await this.ensureSkillsItemDownloadsColumn(); @@ -152,6 +154,19 @@ class SkillsService extends Service { }); } + async ensureSkillsItemPublishRevisionColumn() { + const queryInterface = this.app.model.getQueryInterface(); + const table = await queryInterface.describeTable('skills_items'); + if (table.publish_revision) return; + + await queryInterface.addColumn('skills_items', 'publish_revision', { + type: this.app.Sequelize.INTEGER, + allowNull: false, + defaultValue: 1, + comment: '发布自增版本号', + }); + } + async ensureSkillsItemPackageColumns() { const queryInterface = this.app.model.getQueryInterface(); const table = await queryInterface.describeTable('skills_items'); @@ -241,6 +256,10 @@ class SkillsService extends Service { } } + nextPublishRevision() { + return this.app.Sequelize.literal('publish_revision + 1'); + } + parseJsonArray(value) { if (!value) return []; if (Array.isArray(value)) return value; @@ -286,6 +305,7 @@ class SkillsService extends Service { description: row.description || '', category: row.category || '通用', version: row.version || '', + publishRevision: Number(row.publish_revision), tags: this.parseJsonArray(row.tags), allowedTools: this.parseJsonArray(row.allowed_tools), stars: coerceCount(row.stars), @@ -449,10 +469,27 @@ class SkillsService extends Service { limit: MAX_FILE_LIST_COUNT, }); + const metadataRow = await SkillsFile.findOne({ + where: { + skill_id: skill.id, + file_path: 'metadata.json', + is_delete: 0, + is_binary: 0, + }, + attributes: ['content'], + }); + const displayVersion = resolveSkillDisplayVersion({ + metadataJson: metadataRow ? metadataRow.content : '', + skillMd: skill.skillMd, + publishRevision: skill.publishRevision, + }); + const detail = { ...this.toPublicSkill(skill), skillMd: skill.skillMd, fileList: rows.map((row) => row.file_path), + displayVersion: displayVersion.version, + versionSource: displayVersion.source, }; if (skill.isPackage === 1) { @@ -2057,6 +2094,7 @@ class SkillsService extends Service { skill_md: nextRecord.skillMd, install_command: nextRecord.installCommand, file_count: nextRecord.files.length, + publish_revision: this.nextPublishRevision(), }, { transaction } ); @@ -2225,7 +2263,10 @@ class SkillsService extends Service { }); if (globalExistingParent) { - await globalExistingParent.update(parentPayload, { transaction }); + await globalExistingParent.update( + { ...parentPayload, publish_revision: this.nextPublishRevision() }, + { transaction } + ); oldRowMap.delete(parentSlug); } else { await SkillsItem.create(parentPayload, { transaction }); @@ -2291,7 +2332,10 @@ class SkillsService extends Service { let itemRow; if (targetRow) { itemRow = targetRow; - await targetRow.update(payload, { transaction }); + await targetRow.update( + { ...payload, publish_revision: this.nextPublishRevision() }, + { transaction } + ); oldRowMap.delete(slug); } else { itemRow = await SkillsItem.create(payload, { transaction }); diff --git a/app/service/skillsRegistry.js b/app/service/skillsRegistry.js index 47f68eb..5b386d1 100644 --- a/app/service/skillsRegistry.js +++ b/app/service/skillsRegistry.js @@ -626,6 +626,7 @@ class SkillsRegistryService extends Service { const updatePayload = { name: displayName, version, + publish_revision: this.app.Sequelize.literal('publish_revision + 1'), tags: JSON.stringify(parsedTags), skill_md: skillMdFile.content || '', is_delete: 0, diff --git a/app/utils/skill-utils.js b/app/utils/skill-utils.js index 92d5c8f..9b952a5 100644 --- a/app/utils/skill-utils.js +++ b/app/utils/skill-utils.js @@ -132,8 +132,59 @@ function resolveMarketCardDescription(opts = {}) { return String(opts.fromSkillMd || '').trim(); } +function extractFrontmatterLines(content) { + const normalized = String(content || '').replace(/\r\n/g, '\n'); + if (!normalized.startsWith('---\n')) return []; + const endMarkerIndex = normalized.indexOf('\n---\n', 4); + if (endMarkerIndex === -1) return []; + return normalized.slice(4, endMarkerIndex).split('\n'); +} + +function extractSkillMdVersion(skillMdContent) { + const lines = extractFrontmatterLines(skillMdContent); + let topLevelVersion = ''; + for (let i = 0; i < lines.length; i += 1) { + const versionMatch = lines[i].match(/^version:\s*(.*)$/); + if (versionMatch) { + topLevelVersion = unquoteYamlScalar(versionMatch[1]); + continue; + } + if (!/^metadata:\s*$/.test(lines[i])) continue; + for (let j = i + 1; j < lines.length && /^\s+\S/.test(lines[j]); j += 1) { + const nestedMatch = lines[j].match(/^\s+version:\s*(.*)$/); + if (nestedMatch && unquoteYamlScalar(nestedMatch[1])) { + return unquoteYamlScalar(nestedMatch[1]); + } + } + } + return topLevelVersion; +} + +function extractMetadataJsonVersion(metadataJsonContent) { + try { + const parsed = JSON.parse(String(metadataJsonContent || '')); + const version = parsed && parsed.version; + return typeof version === 'string' || typeof version === 'number' + ? String(version).trim() + : ''; + } catch (error) { + return ''; + } +} + +function resolveSkillDisplayVersion(opts) { + const fromMetadataJson = extractMetadataJsonVersion(opts.metadataJson); + if (fromMetadataJson) return { version: fromMetadataJson, source: 'metadata.json' }; + const fromSkillMd = extractSkillMdVersion(opts.skillMd); + if (fromSkillMd) return { version: fromSkillMd, source: 'frontmatter' }; + return { version: String(opts.publishRevision), source: 'revision' }; +} + module.exports = { normalizeRelativePath, + extractSkillMdVersion, + extractMetadataJsonVersion, + resolveSkillDisplayVersion, extractSkillMdDescription, extractSkillMdName, extractBodySummary, diff --git a/app/web/pages/skills/detail/SkillDetailContent.tsx b/app/web/pages/skills/detail/SkillDetailContent.tsx index eb74233..12cba72 100644 --- a/app/web/pages/skills/detail/SkillDetailContent.tsx +++ b/app/web/pages/skills/detail/SkillDetailContent.tsx @@ -268,7 +268,8 @@ const SkillDetailContent: React.FC = ({
{!isPackage ? ( = { + 'metadata.json': '版本来自 metadata.json', + frontmatter: '版本来自 SKILL.md frontmatter', + revision: '未声明版本,按发布次数自增', +}; + +const formatVersion = (version: string) => (/^v/i.test(version) ? version : `v${version}`); + interface SkillFileExplorerProps { - version?: string; + version: string; + versionSource: SkillVersionSource; parentSlug?: string | null; routeParentSlug?: string; history: SkillDetailHistory; @@ -25,6 +35,7 @@ interface SkillFileExplorerProps { export const SkillFileExplorer: React.FC = ({ version, + versionSource, parentSlug, routeParentSlug, history, @@ -56,7 +67,9 @@ export const SkillFileExplorer: React.FC = ({ />
技能目录 - {version || '-'} + + {formatVersion(version)} +
diff --git a/app/web/pages/skills/types.ts b/app/web/pages/skills/types.ts index 0f94799..20671b1 100644 --- a/app/web/pages/skills/types.ts +++ b/app/web/pages/skills/types.ts @@ -25,9 +25,13 @@ export interface SkillListResponse { categories: string[]; } +export type SkillVersionSource = 'metadata.json' | 'frontmatter' | 'revision'; + export interface SkillDetail extends SkillItem { fileList: string[]; skillMd: string; + displayVersion: string; + versionSource: SkillVersionSource; children?: SkillItem[]; } diff --git a/dt-skill/src/cli/installer.test.ts b/dt-skill/src/cli/installer.test.ts index 4418248..b24a6ff 100644 --- a/dt-skill/src/cli/installer.test.ts +++ b/dt-skill/src/cli/installer.test.ts @@ -1,5 +1,5 @@ /* @vitest-environment node */ -import { mkdir, mkdtemp, readlink, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, readlink, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { afterAll, afterEach, describe, expect, it } from 'vitest'; @@ -73,3 +73,33 @@ describe('linkOrCopyToAgent (global codex symlink)', () => { } }); }); + +describe('linkOrCopyToAgent (copy mode)', () => { + it('keeps metadata.json and skips .git in the copied skill', async () => { + const workdir = await mkdtemp(join(tmpdir(), 'dt-skill-copy-wd-')); + try { + const canonicalDir = join(workdir, '.agents', 'skills', 'demo'); + await mkdir(join(canonicalDir, '.git'), { recursive: true }); + await writeFile(join(canonicalDir, 'SKILL.md'), '# demo\n', 'utf8'); + await writeFile(join(canonicalDir, 'metadata.json'), '{"version":"2.0.1"}', 'utf8'); + await writeFile(join(canonicalDir, '.git', 'HEAD'), 'ref: refs/heads/main\n', 'utf8'); + + const result = await linkOrCopyToAgent('demo', canonicalDir, 'claude-code', { + global: false, + cwd: workdir, + mode: 'copy', + }); + + const agentDir = join(workdir, '.claude', 'skills', 'demo'); + expect(result.success).toBe(true); + expect(result.mode).toBe('copy'); + expect(result.path).toBe(agentDir); + expect((await readdir(agentDir)).sort()).toEqual(['SKILL.md', 'metadata.json']); + expect(await readFile(join(agentDir, 'metadata.json'), 'utf8')).toBe( + '{"version":"2.0.1"}' + ); + } finally { + await rm(workdir, { recursive: true, force: true }).catch(() => {}); + } + }); +}); diff --git a/dt-skill/src/cli/installer.ts b/dt-skill/src/cli/installer.ts index 497c608..6a25eb9 100644 --- a/dt-skill/src/cli/installer.ts +++ b/dt-skill/src/cli/installer.ts @@ -121,21 +121,14 @@ export async function createSymlink(target: string, linkPath: string): Promise { - if (EXCLUDE_FILES.has(name)) return true; - if (isDirectory && EXCLUDE_DIRS.has(name)) return true; - return false; -}; - export async function copyDirectory(src: string, dest: string): Promise { await mkdir(dest, { recursive: true }); const entries = await readdir(src, { withFileTypes: true }); await Promise.all( entries - .filter((entry) => !isExcluded(entry.name, entry.isDirectory())) + .filter((entry) => !(entry.isDirectory() && EXCLUDE_DIRS.has(entry.name))) .map(async (entry) => { const srcPath = join(src, entry.name); const destPath = join(dest, entry.name); diff --git a/sql/doraemon.sql b/sql/doraemon.sql index 2b406a2..e401503 100644 --- a/sql/doraemon.sql +++ b/sql/doraemon.sql @@ -333,6 +333,7 @@ CREATE TABLE `skills_items` ( `description` text, `category` varchar(64) NOT NULL DEFAULT '通用', `version` varchar(128) NOT NULL DEFAULT '' COMMENT '技能版本号', + `publish_revision` int NOT NULL DEFAULT '1' COMMENT '发布自增版本号', `tags` longtext COMMENT 'JSON字符串数组', `allowed_tools` longtext COMMENT 'JSON字符串数组', `contributor` varchar(50) DEFAULT NULL COMMENT '贡献者', diff --git a/test/skill-display-version.test.js b/test/skill-display-version.test.js new file mode 100644 index 0000000..eb189f9 --- /dev/null +++ b/test/skill-display-version.test.js @@ -0,0 +1,59 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + extractSkillMdVersion, + extractMetadataJsonVersion, + resolveSkillDisplayVersion, +} = require('../app/utils/skill-utils'); + +test('extractSkillMdVersion prefers metadata.version over top-level version', () => { + const skillMd = [ + '---', + 'name: demo', + 'version: 0.1.0', + 'metadata:', + ' author: shane', + ' version: "1.2.0"', + '---', + 'body', + ].join('\n'); + assert.equal(extractSkillMdVersion(skillMd), '1.2.0'); +}); + +test('extractSkillMdVersion reads top-level version', () => { + assert.equal(extractSkillMdVersion('---\nname: demo\nversion: 0.3.0\n---\nbody'), '0.3.0'); + assert.equal(extractSkillMdVersion('---\nname: demo\n---\nbody'), ''); + assert.equal(extractSkillMdVersion('no frontmatter'), ''); +}); + +test('extractMetadataJsonVersion reads version and ignores invalid json', () => { + assert.equal(extractMetadataJsonVersion('{"version":"2.0.1"}'), '2.0.1'); + assert.equal(extractMetadataJsonVersion('{"version":3}'), '3'); + assert.equal(extractMetadataJsonVersion('{"name":"x"}'), ''); + assert.equal(extractMetadataJsonVersion('not json'), ''); + assert.equal(extractMetadataJsonVersion(''), ''); +}); + +test('resolveSkillDisplayVersion follows metadata.json > frontmatter > revision', () => { + const skillMd = '---\nname: demo\nversion: 0.3.0\n---\nbody'; + assert.deepEqual( + resolveSkillDisplayVersion({ + metadataJson: '{"version":"2.0.1"}', + skillMd, + publishRevision: 4, + }), + { version: '2.0.1', source: 'metadata.json' } + ); + assert.deepEqual( + resolveSkillDisplayVersion({ metadataJson: '', skillMd, publishRevision: 4 }), + { + version: '0.3.0', + source: 'frontmatter', + } + ); + assert.deepEqual( + resolveSkillDisplayVersion({ metadataJson: '', skillMd: 'body', publishRevision: 4 }), + { version: '4', source: 'revision' } + ); +}); diff --git a/test/skills-install-key.test.js b/test/skills-install-key.test.js index 6f1dbc9..7df8cff 100644 --- a/test/skills-install-key.test.js +++ b/test/skills-install-key.test.js @@ -249,6 +249,7 @@ test('persistSkillsForSource - TDD scenarios for web upload', async () => { }, }, Sequelize: { + literal: (sql) => ({ sql }), Op: { in: 'in', ne: 'ne', @@ -921,6 +922,7 @@ test('getSkillDetail returns children for package, none for regular skill (T018) }, SkillsFile: { findAll: async () => [], + findOne: async () => null, }, }, }; @@ -1014,6 +1016,7 @@ test('getSkillArchive - nested ZIP structure for package with multiple children }, SkillsFile: { findAll: async (opts) => childFilesBySkillId[opts.where.skill_id] || [], + findOne: async () => null, }, }, }; diff --git a/test/skills-package-stars.test.js b/test/skills-package-stars.test.js index 120dc59..2694146 100644 --- a/test/skills-package-stars.test.js +++ b/test/skills-package-stars.test.js @@ -215,6 +215,7 @@ test('getSkillDetail aggregates package stars from children (T039 variant)', asy model: { SkillsFile: { findAll: async () => [], + findOne: async () => null, }, SkillsItem: { findAll: async (opts) => {