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
6 changes: 6 additions & 0 deletions app/model/skills_item.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ module.exports = (app) => {
defaultValue: '',
comment: '技能版本号',
},
publish_revision: {
type: INTEGER,
allowNull: false,
defaultValue: 1,
comment: '发布自增版本号',
},
tags: {
type: TEXT('long'),
comment: 'JSON字符串数组',
Expand Down
48 changes: 46 additions & 2 deletions app/service/skills.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
const {
normalizeRelativePath: normalizeRelativeFilePath,
extractSkillMdDescription,
resolveSkillDisplayVersion,
resolveMarketCardDescription,
} = require('../utils/skill-utils');
const {
Expand Down Expand Up @@ -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();
Expand All @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }
);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions app/service/skillsRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions app/utils/skill-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion app/web/pages/skills/detail/SkillDetailContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,8 @@ const SkillDetailContent: React.FC<SkillDetailContentProps> = ({
<div className={`skill-detail-shell ${isPackage ? 'is-package' : ''}`}>
{!isPackage ? (
<SkillFileExplorer
version={detail.version}
version={detail.displayVersion}
versionSource={detail.versionSource}
parentSlug={detail.parentSlug}
routeParentSlug={routeParentSlug}
history={history}
Expand Down
19 changes: 16 additions & 3 deletions app/web/pages/skills/detail/components/SkillFileExplorer.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import React from 'react';
import { ArrowLeftOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { Button, Empty, Tree } from 'antd';
import { Button, Empty, Tooltip, Tree } from 'antd';

import fileDocIcon from '@/asset/images/skills-detail-figma/file-doc.svg';
import folderOpenBlueIcon from '@/asset/images/skills-detail-figma/folder-open-blue.svg';
import { copyToClipboard } from '@/utils/copyUtils';
import type { SkillVersionSource } from '../../types';
import type { SkillDetailHistory, SkillTreeNode } from '../utils/skillDetailUtils';
import { DetailIcon } from './DetailIcon';

const VERSION_SOURCE_LABELS: Record<SkillVersionSource, string> = {
'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;
Expand All @@ -25,6 +35,7 @@ interface SkillFileExplorerProps {

export const SkillFileExplorer: React.FC<SkillFileExplorerProps> = ({
version,
versionSource,
parentSlug,
routeParentSlug,
history,
Expand Down Expand Up @@ -56,7 +67,9 @@ export const SkillFileExplorer: React.FC<SkillFileExplorerProps> = ({
/>
<div className="sidebar-title-group">
<span>技能目录</span>
<strong>{version || '-'}</strong>
<Tooltip title={VERSION_SOURCE_LABELS[versionSource]}>
<strong>{formatVersion(version)}</strong>
</Tooltip>
</div>
</div>

Expand Down
4 changes: 4 additions & 0 deletions app/web/pages/skills/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand Down
32 changes: 31 additions & 1 deletion dt-skill/src/cli/installer.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(() => {});
}
});
});
9 changes: 1 addition & 8 deletions dt-skill/src/cli/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,21 +121,14 @@ export async function createSymlink(target: string, linkPath: string): Promise<b
}
}

const EXCLUDE_FILES = new Set(['metadata.json']);
const EXCLUDE_DIRS = new Set(['.git', '__pycache__', '__pypackages__']);

const isExcluded = (name: string, isDirectory = false) => {
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<void> {
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);
Expand Down
1 change: 1 addition & 0 deletions sql/doraemon.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 '贡献者',
Expand Down
59 changes: 59 additions & 0 deletions test/skill-display-version.test.js
Original file line number Diff line number Diff line change
@@ -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' }
);
});
Loading
Loading