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
17 changes: 17 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,20 @@ _Avoid_: 将空间负责人直接称为所有技能的作者。

**历史作者**:某次历史内容提交的实际作者。历史作者与技能当前所有者可能是不同用户。
_Avoid_: 把所有权转移描述为改写历史作者。

## 技能使用

**任务草稿**:用户尚未提交给 Agent、可以继续编辑的任务内容。准备任务草稿不表示任务已经执行。
_Avoid_:将“填入输入框”称为“执行案例”或“任务已开始”。

**技能任务模板**:技能作者为某项技能提供的可复用任务描述,供用户选择后形成可编辑的任务草稿。同一技能可以有多条模板,不同技能拥有各自的模板集合。
_Avoid_:将统一引导文案称为技能专属案例,或将任务模板混同完整成果案例。

**案例内容更新**:对技能任务模板内容的调整;首期作为技能版本内容随包发布,通过对应技能版本更新交付。
_Avoid_:将尚未支持的独立刷新描述为已具备能力。

**技能案例文件**:技能包内固定位置的任务模板声明,上传、下载和安装后保持同一相对路径;符合协议时作为案例识别,否则按普通文件保留。
_Avoid_:将案例文件当成安装器内部记录、独立缓存或技能可调用的证明。

**技能占位符**:任务模板 prompt 中的精确标记 `{{skill}}`,在使用核验通过后替换为当前技能的真实原生调用,不表示任意模板变量或执行能力。
_Avoid_:用市场 slug 猜测技能调用,或把占位符替换称为执行模板代码。
37 changes: 37 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,40 @@ skillhub doctor
Apache-2.0

Copyright 2026 iFlytek Co., Ltd.

## Packaged task examples

Authors can include `.skillhub/examples.json` in a skill package. The file stays at the same
path after publishing and installation. It is versioned skill content, including when its
JSON does not conform to the examples protocol; local edits are protected by existing
upgrade/sync integrity checks. No separate Web configuration is required.

```json
{
"schemaVersion": 1,
"defaultLocale": "zh-CN",
"examples": {
"zh-CN": [{ "id": "summary", "title": "整理纪要", "prompt": "请用 {{skill}} 整理会议记录。" }],
"en": [{ "id": "summary", "title": "Summarize notes", "prompt": "Use {{skill}} to summarize meeting notes." }]
}
}
```

The native DSH plugin recognizes this bounded UTF-8 declaration and offers editable task
prompts. Unsupported declarations remain ordinary package files. See the
[protocol and limits](../openspec/changes/add-dsh-native-plugin/skill-examples-design.md).
Only `.skillhub/metadata.json` is an installer-owned record: directory publishing and sync
push omit it, and installation rejects a package that supplies it (including path aliases).
Other authored `.skillhub` files are retained and fingerprinted.

Older consumers that ignore the whole `.skillhub` directory do not support packages with
these files. Publish/install them only with lifecycle-capable CLI/plugin builds; old package
bytes and published fingerprints must remain unchanged. This is not transparent backward
compatibility for existing binaries.

Directory and ZIP publishing (`publish`, including `--dry-run`, and `sync push`) inspect the
actual outgoing archive for task examples. Human output reports the recognition summary;
`--json` keeps it in `examplesStatus`, `examplesCount` and `warnings` fields. Counts include
all declared languages. An unsupported declaration generates a nonblocking warning and is
still uploaded unchanged; an unreadable archive produces an inspection warning and remains
subject to the server's package validation.
16 changes: 12 additions & 4 deletions cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { resolveRegistry, resolveToken } from '../services/registry-service'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { createZip, isZipFile } from '../platform/archive'
import { inspectPackagedExamples, examplesPreflightLine } from '../services/skill-examples-preflight'

export interface PublishCommandOptions {
namespace?: string
Expand Down Expand Up @@ -50,17 +51,20 @@ export async function publishCommand(path: string, options: PublishCommandOption
}
} else if (pathStat.isDirectory()) {
archiveBlob = await createZip(path, {
exclude: relativePath => relativePath === '.skillhub' || relativePath.startsWith('.skillhub/')
exclude: relativePath => relativePath.toLowerCase() === '.skillhub/metadata.json'
})
archiveName = `${basename(path)}.zip`
} else {
throw new CliError(`path must be a file or directory: ${path}`, EXIT.filesystem, { path })
}

const examples = await inspectPackagedExamples(archiveBlob)
const summary = examplesPreflightLine(examples)
const client = new SkillHubClient(registry, token)

if (options.dryRun) {
const result = await client.validatePublish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
const validation = await client.validatePublish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
const result = { ...validation, ...examples, warnings: [...new Set([...validation.warnings, ...examples.warnings])] }

if (options.json) {
if (!result.valid) {
Expand All @@ -82,6 +86,7 @@ export async function publishCommand(path: string, options: PublishCommandOption
if (result.resolvedVersion) {
lines.push(` Version: ${result.resolvedVersion}`)
}
if (summary) lines.push(summary)
if (result.errors.length > 0) {
lines.push('Errors:')
for (const error of result.errors) {
Expand Down Expand Up @@ -114,14 +119,17 @@ export async function publishCommand(path: string, options: PublishCommandOption
version: result.version,
visibility: result.visibility.toLowerCase(),
status: result.status,
detailUrl
detailUrl,
...examples
})
}
return [
`Submitted successfully: ${result.namespace}/${result.slug}@${result.version}`,
`Status: ${result.status}`,
`Detail: ${detailUrl}`,
'Check the Web page for final publish or review status.'
'Check the Web page for final publish or review status.',
...(summary ? [summary] : []),
...examples.warnings.map(warning => `Warning: ${warning}`)
].join('\n')
}

Expand Down
6 changes: 5 additions & 1 deletion cli/src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { examplesPreflightLine } from '../services/skill-examples-preflight'
import { join, resolve } from 'node:path'
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
Expand Down Expand Up @@ -266,7 +267,10 @@ function renderPushResults(namespace: string, results: PushResultItem[], json: b
const action = item.action === 'uploaded' || item.action === 'submitted-review' ? 'submitted' : item.action
const status = item.status ? ` status=${item.status}` : ''
const reviewStatus = item.reviewStatus ? ` reviewStatus=${item.reviewStatus}` : ''
return `${action.padEnd(16)} ${coordinate}${status}${reviewStatus}${detail}`
const summary = examplesPreflightLine({ ...item, warnings: item.warnings ?? [] })
return [`${action.padEnd(16)} ${coordinate}${status}${reviewStatus}${detail}`,
...(summary ? [` ${summary}`] : []),
...(item.warnings ?? []).map(warning => ` Warning: ${warning}`)].join('\n')
})
if (!dryRun && results.some(item => item.action === 'uploaded' || item.action === 'submitted-review')) {
lines.push('Check the Web page for final publish or review status.')
Expand Down
3 changes: 3 additions & 0 deletions cli/src/integrations/local-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ export type { UpdateAssessment, UpdateIdentity } from './update-assessment'
export { inspectUpgradeBaseline } from './upgrade-baseline'
export type { UpgradeBaseline } from './upgrade-baseline'
export { inspectRemovalBaseline } from './removal-baseline'

export { parseSkillExamples, readSkillExamples } from '../services/skill-examples'
export type { SkillExample, SkillExamples } from '../services/skill-examples'
9 changes: 9 additions & 0 deletions cli/src/integrations/native-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,16 @@ export interface UpdateAssessment {
candidateVersion: string
}

export interface SkillExample { id: string; title: string; prompt: string }
export type SkillExamples =
| { state: 'ready'; digest: string; defaultLocale: string; items: Record<string, SkillExample[]> }
| { state: 'none' }
| { state: 'invalid' }
| { state: 'unavailable'; retryable: boolean }

export interface NativeRuntime {
/** Host-only fixed-path read after installation verification; never a Remote path argument. */
readSkillExamples(directory: string): Promise<SkillExamples>
/** Host-only, read-only evidence; the callback must verify the actual scoped DSH content and resource directory. */
observeInstallation(operationId: string, ownerId: string, trustedRoots: readonly string[],
observe: (paths: readonly string[]) => Promise<boolean>): Promise<boolean>
Expand Down
2 changes: 2 additions & 0 deletions cli/src/integrations/native-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readSkillExamples } from '../services/skill-examples'
import { observeInstallationDiscovery } from './install-discovery'
import { readInstalledProjection } from './installed-projection'
import { assessSkillUpdate } from './update-assessment'
Expand All @@ -20,6 +21,7 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven
const inventory = new InventoryStore(options.inventoryHome)
return {
hostInstanceId: operations.hostInstanceId,
readSkillExamples,
observeInstallation: (operationId, ownerId, trustedRoots, observe) => observeInstallationDiscovery({
operations, inventory, operationId, ownerId, trustedRoots, observe
}),
Expand Down
26 changes: 19 additions & 7 deletions cli/src/integrations/recovery-files.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { lstat, readdir, realpath } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { OperationRecord } from './operation-store'
import type { InstallMove } from '../services/install-transaction'
import { readInstalledSkillMetadata } from '../services/installed-skill-metadata'

export async function verifiedBackupPath(record: OperationRecord, move: InstallMove): Promise<string> {
const root = dirname(move.canonicalSkillDir)
Expand All @@ -17,18 +18,29 @@ export async function verifiedBackupPath(record: OperationRecord, move: InstallM
}

export async function assertOwnedRegularTree(path: string): Promise<void> {
await inspectTree(path, path)
const authoredMetadataPaths: string[] = []
await inspectTree(path, path, authoredMetadataPaths)
if (!authoredMetadataPaths.length) return
const metadata = await readInstalledSkillMetadata(path)
const files = metadata.status === 'valid' ? metadata.metadata.files : undefined
if (authoredMetadataPaths.some(entry => !files || !(entry in files))) {
throw new Error('Untracked recovery metadata content')
}
}

async function inspectTree(path: string, root: string): Promise<void> {
async function inspectTree(path: string, root: string, authoredMetadataPaths: string[]): Promise<void> {
const info = await lstat(path)
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())
|| (process.getuid && info.uid !== process.getuid())) throw new Error('Unsafe recovery path')
if (!info.isDirectory()) return
const local = relative(root, path).split('\\').join('/')
if (!info.isDirectory()) {
// Authored files are permitted only when the installed baseline records them.
if (local.split('/').includes('.skillhub') && local !== '.skillhub/metadata.json') authoredMetadataPaths.push(local)
return
}
const names = await readdir(path)
// The fingerprint deliberately excludes .skillhub; do not silently delete untracked content there.
if (basename(path) === '.skillhub' && (path !== join(root, '.skillhub') || names.length !== 1 || names[0] !== 'metadata.json')) {
if (!names.length && local.split('/').includes('.skillhub') && local !== '.skillhub') {
throw new Error('Untracked recovery metadata content')
}
for (const name of names) await inspectTree(join(path, name), root)
for (const name of names) await inspectTree(join(path, name), root, authoredMetadataPaths)
}
32 changes: 27 additions & 5 deletions cli/src/platform/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ const ZIP64_MARKER_32 = 0xffffffff
* Extract a zip archive buffer into the target directory.
* Pure JS implementation using fflate — no system commands needed.
*/
export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise<void> {
export async function extractZip(buffer: ArrayBuffer, targetDir: string, options: { rejectInternalMetadata?: boolean } = {}): Promise<void> {
await mkdir(targetDir, { recursive: true })
const archive = new Uint8Array(buffer)
validateZipCentralDirectory(archive)
validateZipCentralDirectory(archive, options.rejectInternalMetadata ?? false)
const files = unzipSync(archive)
const entries = Object.entries(files).map(([name, data]) => ({
name,
data,
filePath: safeJoin(targetDir, name),
}))
for (const { name, data, filePath } of entries) {
if (name.endsWith('/')) {
if (isZipDirectory(name)) {
await mkdir(filePath, { recursive: true })
continue
}
Expand All @@ -34,7 +34,11 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis
}
}

function validateZipCentralDirectory(archive: Uint8Array): void {
function isZipDirectory(name: string): boolean {
return name.endsWith('/') || name.endsWith('\\')
}

function validateZipCentralDirectory(archive: Uint8Array, rejectInternalMetadata: boolean): void {
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
const eocdOffset = findEndOfCentralDirectory(view)
if (eocdOffset < 0) {
Expand Down Expand Up @@ -66,6 +70,7 @@ function validateZipCentralDirectory(archive: Uint8Array): void {
throw new Error('invalid zip central directory')
}

const destinations = new Map<string, boolean>()
let offset = centralDirectoryOffset
let totalUncompressedSize = 0
const decoder = new TextDecoder()
Expand All @@ -85,6 +90,23 @@ function validateZipCentralDirectory(archive: Uint8Array): void {
}

const entryName = decoder.decode(archive.subarray(nameStart, nameEnd))
const normalized = entryName.replaceAll('\\', '/').split('/').filter(part => part !== '' && part !== '.')
if (normalized.some(part => part === '..' || (part.includes(':') || [...part].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) || /[ .]$/.test(part))) {
throw new Error('unsafe zip entry path')
}
const destination = normalized.join('/').toLowerCase()
const directory = isZipDirectory(entryName)
if (rejectInternalMetadata && destination === '.skillhub/examples.json' && normalized.join('/') !== destination) {
throw new Error('Ambiguous skill examples path casing')
}
if (rejectInternalMetadata && ((!directory && destination === '.skillhub') || destination === '.skillhub/metadata.json' || destination.startsWith('.skillhub/metadata.json/'))) {
throw new Error('Installer metadata cannot be supplied by a skill package')
}
if (destinations.has(destination) || [...destinations].some(([path, isDirectory]) =>
(!isDirectory && destination.startsWith(path + '/')) || (!directory && path.startsWith(destination + '/')))) {
throw new Error('conflicting zip entry destinations')
}
destinations.set(destination, directory)
if (!entryName.endsWith('/') && uncompressedSize > MAX_SINGLE_FILE_BYTES) {
throw new Error('zip entry size exceeds limit')
}
Expand Down Expand Up @@ -160,7 +182,7 @@ function safeJoin(targetDir: string, entryName: string): string {
}

const root = resolve(targetDir)
const target = resolve(root, entryName)
const target = resolve(root, entryName.replaceAll('\\', '/'))
const rel = relative(root, target)
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(`unsafe zip entry path: ${entryName}`)
Expand Down
2 changes: 1 addition & 1 deletion cli/src/services/install-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export async function installSkill(options: InstallOptions): Promise<InstallResu
await mkdir(tempDir, { mode: 0o700 })
try {
await options.authorizeTargetMutation?.([tempDir], 'staging')
await extractZip(buffer, tempDir)
await extractZip(buffer, tempDir, { rejectInternalMetadata: true })

const installedAt = new Date().toISOString()
const snapshot = await snapshotSkillDirectory(tempDir)
Expand Down
65 changes: 65 additions & 0 deletions cli/src/services/skill-examples-preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { unzipSync } from 'fflate'
import { MAX_EXAMPLES_BYTES, parseSkillExamples } from './skill-examples'

export interface ExamplesPreflight {
examplesStatus?: 'ready' | 'none' | 'invalid'
examplesCount?: number
warnings: string[]
}

/** Advisory inspection of the exact uploaded bytes; server package validation remains authoritative. */
export async function inspectPackagedExamples(archive: Blob): Promise<ExamplesPreflight> {
try {
const bytes = new Uint8Array(await archive.arrayBuffer())
const entries: Array<{ name: string; path: string; size: number; storedSize?: number }> = []
let entryCount = 0
// Collect names without inflating arbitrary resources or an oversized examples declaration.
unzipSync(bytes, { filter: file => {
if (++entryCount > 500) throw new Error('Package entry limit')
const path = file.name.replaceAll('\\', '/')
const basename = path.split('/').at(-1) ?? ''
if (!path.endsWith('/') && !path.startsWith('__MACOSX/') && path !== '__MACOSX'
&& basename !== '.DS_Store' && !basename.startsWith('._')) {
const parts = path.split('/').filter(part => part !== '.' && part !== '')
if (basename.toLowerCase() === 'skill.md') parts[parts.length - 1] = 'SKILL.md'
entries.push({ name: file.name, path: parts.join('/'), size: file.originalSize,
...(file.compression === 0 ? { storedSize: file.size } : {}) })
}
return false
} })
// Match the existing server extractor's single-root stripping and single-SKILL promotion.
if (entries.length && entries.every(entry => entry.path.includes('/'))
&& new Set(entries.map(entry => entry.path.split('/')[0])).size === 1) {
for (const entry of entries) entry.path = entry.path.slice(entry.path.indexOf('/') + 1)
}
if (!entries.some(entry => entry.path === 'SKILL.md')) {
const roots = entries.filter(entry => /^[^/]+\/SKILL\.md$/.test(entry.path)).map(entry => entry.path.slice(0, -'SKILL.md'.length))
if (roots.length === 1) for (const entry of entries) {
entry.path = entry.path.startsWith(roots[0]!) ? entry.path.slice(roots[0]!.length) : ''
}
}
const candidates = entries.filter(entry => entry.path === '.skillhub/examples.json')
const entry = candidates[0]
if (!entry) return { examplesStatus: 'none', examplesCount: 0, warnings: [] }
if (candidates.length !== 1 || entry.size > MAX_EXAMPLES_BYTES
|| (entry.storedSize !== undefined && (entry.storedSize > MAX_EXAMPLES_BYTES || entry.storedSize !== entry.size))) return invalid()
const selected = unzipSync(bytes, { filter: file => file.name === entry.name && file.originalSize <= MAX_EXAMPLES_BYTES
&& (file.compression !== 0 || (file.size <= MAX_EXAMPLES_BYTES && file.size === file.originalSize)) })[entry.name]
const parsed = selected ? parseSkillExamples(selected) : { state: 'invalid' as const }
if (parsed.state !== 'ready') return invalid()
const count = Object.values(parsed.items).reduce((total, items) => total + items.length, 0)
return { examplesStatus: count ? 'ready' : 'none', examplesCount: count, warnings: [] }
} catch {
return { warnings: ['Task examples could not be inspected locally; server package validation will determine the result.'] }
}
}

function invalid(): ExamplesPreflight {
return { examplesStatus: 'invalid', warnings: ['.skillhub/examples.json is not a supported task-examples declaration; it remains ordinary package content.'] }
}

export function examplesPreflightLine(result: ExamplesPreflight): string | undefined {
if (result.examplesStatus === 'ready') return `Task examples: ${result.examplesCount} entries across all declared languages.`
if (result.examplesStatus === 'none') return 'Task examples: none.'
return undefined
}
Loading
Loading