From 3402c07dbb6623cc628fd89b58c48ed4f3180483 Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Thu, 22 Oct 2026 17:50:03 +0800
Subject: [PATCH 1/2] feat(dsh): add versioned skill examples and native task
handoff
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
CONTEXT.md | 17 +
cli/README.md | 37 ++
cli/src/commands/publish.ts | 16 +-
cli/src/commands/sync.ts | 6 +-
cli/src/integrations/local-skills.ts | 3 +
cli/src/integrations/native-contract.d.ts | 9 +
cli/src/integrations/native-runtime.ts | 2 +
cli/src/integrations/recovery-files.ts | 26 +-
cli/src/platform/archive.ts | 32 +-
cli/src/services/install-service.ts | 2 +-
cli/src/services/skill-examples-preflight.ts | 65 +++
cli/src/services/skill-examples.ts | 87 ++++
cli/src/services/skill-fingerprint.ts | 2 +-
cli/src/services/sync-service.ts | 16 +-
cli/test/helpers/fake-registry.ts | 4 +-
cli/test/integration/publish-command.test.ts | 55 ++-
cli/test/integration/sync-command.test.ts | 42 ++
.../integrations/install-recovery.test.ts | 7 +-
.../unit/integrations/native-install.test.ts | 8 +-
.../unit/integrations/native-upgrade.test.ts | 10 +-
.../integrations/upgrade-baseline.test.ts | 11 +-
cli/test/unit/platform/archive.test.ts | 55 ++-
.../unit/services/install-service.test.ts | 27 +-
.../services/skill-examples-preflight.test.ts | 67 +++
cli/test/unit/services/skill-examples.test.ts | 84 ++++
cli/test/unit/services/suite-service.test.ts | 16 +-
.../changes/add-dsh-native-plugin/README.md | 8 +-
.../add-dsh-native-plugin/acceptance.md | 36 ++
.../contracts/skill-examples.empty.json | 7 +
.../contracts/skill-examples.schema.json | 61 +++
.../contracts/skill-examples.valid.json | 30 ++
.../dsh-integration-design.md | 16 +
.../implementation-design.md | 5 +
.../add-dsh-native-plugin/interface-design.md | 24 ++
.../add-dsh-native-plugin/lifecycle-design.md | 5 +
.../add-dsh-native-plugin/product-design.md | 7 +
.../saas-agent-prompt.md | 2 +
.../skill-examples-design.md | 202 +++++++++
.../skill-examples-execution-plan.md | 110 +++++
.../skill-examples-saas-agent-prompt.md | 77 ++++
.../specs/dsh-native-experience/spec.md | 132 ++++++
.../changes/add-dsh-native-plugin/tasks.md | 41 ++
.../add-dsh-native-plugin/use-skill-design.md | 190 +++++++++
.../add-dsh-native-plugin/verification.md | 104 +++++
plugins/dsh/README.en.md | 7 +
plugins/dsh/README.md | 7 +
plugins/dsh/bun.lock | 15 +
plugins/dsh/package.json | 8 +-
.../dsh/src/client/conversation-detail.tsx | 6 +-
plugins/dsh/src/client/index.tsx | 54 ++-
plugins/dsh/src/client/install-control.tsx | 3 +
plugins/dsh/src/client/install-dialog.tsx | 2 +-
plugins/dsh/src/client/install-tool-view.tsx | 9 +-
.../dsh/src/client/installation-progress.tsx | 9 +-
plugins/dsh/src/client/installed-skills.tsx | 4 +
.../dsh/src/client/native-control-styles.tsx | 11 +
plugins/dsh/src/client/use-skill-context.ts | 36 ++
plugins/dsh/src/client/use-skill-drafts.ts | 51 +++
.../dsh/src/client/use-skill-navigation.ts | 63 +++
plugins/dsh/src/client/use-skill.tsx | 334 +++++++++++++++
plugins/dsh/src/host/index.ts | 81 +++-
plugins/dsh/src/host/local-runtime.ts | 4 +
plugins/dsh/src/host/runtime-compatibility.ts | 3 +
plugins/dsh/src/host/skill-discovery.ts | 60 ++-
plugins/dsh/src/host/types.ts | 9 +
.../dsh/test/client-entry-lifecycle.test.ts | 6 +-
.../dsh/test/install-control-state.test.tsx | 9 +-
plugins/dsh/test/package.test.ts | 34 +-
plugins/dsh/test/prepare-skill-use.test.ts | 245 +++++++++++
plugins/dsh/test/runtime-execution.test.ts | 1 +
plugins/dsh/test/use-skill-context.test.ts | 60 +++
plugins/dsh/test/use-skill-drafts.test.ts | 82 ++++
plugins/dsh/test/use-skill-navigation.test.ts | 71 ++++
plugins/dsh/test/use-skill-render.test.tsx | 55 +++
plugins/dsh/test/use-skill.test.ts | 386 ++++++++++++++++++
.../dto/SkillPackagePreviewResponse.java | 11 +-
.../service/SkillPackagePreviewService.java | 5 +-
...PackagePreviewSecurityIntegrationTest.java | 15 +
.../SkillPackageArchiveExtractorTest.java | 16 +
.../dto/RegistryOpenApiSchemaTest.java | 9 +
.../SkillPackagePreviewServiceTest.java | 30 ++
.../validation/SkillExamplesSummary.java | 101 +++++
.../skill/validation/SkillPackagePolicy.java | 19 +
.../validation/SkillPackageValidator.java | 23 ++
.../validation/SkillExamplesSummaryTest.java | 61 +++
.../validation/SkillPackageValidatorTest.java | 38 ++
web/src/api/generated/schema.d.ts | 4 +
web/src/features/publish/folder-zip.test.ts | 15 +
web/src/features/publish/folder-zip.ts | 4 +
web/src/i18n/locales/en.json | 3 +
web/src/i18n/locales/ru.json | 3 +
web/src/i18n/locales/zh.json | 3 +
.../dashboard/publish-display-name.test.tsx | 19 +
web/src/pages/dashboard/publish.tsx | 3 +
94 files changed, 3778 insertions(+), 92 deletions(-)
create mode 100644 cli/src/services/skill-examples-preflight.ts
create mode 100644 cli/src/services/skill-examples.ts
create mode 100644 cli/test/unit/services/skill-examples-preflight.test.ts
create mode 100644 cli/test/unit/services/skill-examples.test.ts
create mode 100644 openspec/changes/add-dsh-native-plugin/contracts/skill-examples.empty.json
create mode 100644 openspec/changes/add-dsh-native-plugin/contracts/skill-examples.schema.json
create mode 100644 openspec/changes/add-dsh-native-plugin/contracts/skill-examples.valid.json
create mode 100644 openspec/changes/add-dsh-native-plugin/skill-examples-design.md
create mode 100644 openspec/changes/add-dsh-native-plugin/skill-examples-execution-plan.md
create mode 100644 openspec/changes/add-dsh-native-plugin/skill-examples-saas-agent-prompt.md
create mode 100644 openspec/changes/add-dsh-native-plugin/use-skill-design.md
create mode 100644 plugins/dsh/src/client/use-skill-context.ts
create mode 100644 plugins/dsh/src/client/use-skill-drafts.ts
create mode 100644 plugins/dsh/src/client/use-skill-navigation.ts
create mode 100644 plugins/dsh/src/client/use-skill.tsx
create mode 100644 plugins/dsh/test/prepare-skill-use.test.ts
create mode 100644 plugins/dsh/test/use-skill-context.test.ts
create mode 100644 plugins/dsh/test/use-skill-drafts.test.ts
create mode 100644 plugins/dsh/test/use-skill-navigation.test.ts
create mode 100644 plugins/dsh/test/use-skill-render.test.tsx
create mode 100644 plugins/dsh/test/use-skill.test.ts
create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillExamplesSummary.java
create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillExamplesSummaryTest.java
diff --git a/CONTEXT.md b/CONTEXT.md
index b48ada16f..7932b318d 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -26,3 +26,20 @@ _Avoid_: 将空间负责人直接称为所有技能的作者。
**历史作者**:某次历史内容提交的实际作者。历史作者与技能当前所有者可能是不同用户。
_Avoid_: 把所有权转移描述为改写历史作者。
+
+## 技能使用
+
+**任务草稿**:用户尚未提交给 Agent、可以继续编辑的任务内容。准备任务草稿不表示任务已经执行。
+_Avoid_:将“填入输入框”称为“执行案例”或“任务已开始”。
+
+**技能任务模板**:技能作者为某项技能提供的可复用任务描述,供用户选择后形成可编辑的任务草稿。同一技能可以有多条模板,不同技能拥有各自的模板集合。
+_Avoid_:将统一引导文案称为技能专属案例,或将任务模板混同完整成果案例。
+
+**案例内容更新**:对技能任务模板内容的调整;首期作为技能版本内容随包发布,通过对应技能版本更新交付。
+_Avoid_:将尚未支持的独立刷新描述为已具备能力。
+
+**技能案例文件**:技能包内固定位置的任务模板声明,上传、下载和安装后保持同一相对路径;符合协议时作为案例识别,否则按普通文件保留。
+_Avoid_:将案例文件当成安装器内部记录、独立缓存或技能可调用的证明。
+
+**技能占位符**:任务模板 prompt 中的精确标记 `{{skill}}`,在使用核验通过后替换为当前技能的真实原生调用,不表示任意模板变量或执行能力。
+_Avoid_:用市场 slug 猜测技能调用,或把占位符替换称为执行模板代码。
diff --git a/cli/README.md b/cli/README.md
index f4a2e1f52..1657d4c71 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -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.
diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts
index e21bc4ac4..d842fe7ff 100644
--- a/cli/src/commands/publish.ts
+++ b/cli/src/commands/publish.ts
@@ -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
@@ -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) {
@@ -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) {
@@ -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')
}
diff --git a/cli/src/commands/sync.ts b/cli/src/commands/sync.ts
index 263693a9b..ca1536f73 100644
--- a/cli/src/commands/sync.ts
+++ b/cli/src/commands/sync.ts
@@ -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'
@@ -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.')
diff --git a/cli/src/integrations/local-skills.ts b/cli/src/integrations/local-skills.ts
index 44cdab6b4..d2092efbc 100644
--- a/cli/src/integrations/local-skills.ts
+++ b/cli/src/integrations/local-skills.ts
@@ -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'
diff --git a/cli/src/integrations/native-contract.d.ts b/cli/src/integrations/native-contract.d.ts
index e6abdbcbe..e73e6c532 100644
--- a/cli/src/integrations/native-contract.d.ts
+++ b/cli/src/integrations/native-contract.d.ts
@@ -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 }
+ | { 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
/** 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): Promise
diff --git a/cli/src/integrations/native-runtime.ts b/cli/src/integrations/native-runtime.ts
index 8df4dee86..17e9da44c 100644
--- a/cli/src/integrations/native-runtime.ts
+++ b/cli/src/integrations/native-runtime.ts
@@ -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'
@@ -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
}),
diff --git a/cli/src/integrations/recovery-files.ts b/cli/src/integrations/recovery-files.ts
index 2fd3fd4e5..da517ce9c 100644
--- a/cli/src/integrations/recovery-files.ts
+++ b/cli/src/integrations/recovery-files.ts
@@ -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 {
const root = dirname(move.canonicalSkillDir)
@@ -17,18 +18,29 @@ export async function verifiedBackupPath(record: OperationRecord, move: InstallM
}
export async function assertOwnedRegularTree(path: string): Promise {
- 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 {
+async function inspectTree(path: string, root: string, authoredMetadataPaths: string[]): Promise {
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)
}
diff --git a/cli/src/platform/archive.ts b/cli/src/platform/archive.ts
index 2c2cc8bf0..0ea65d5b3 100644
--- a/cli/src/platform/archive.ts
+++ b/cli/src/platform/archive.ts
@@ -14,10 +14,10 @@ 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 {
+export async function extractZip(buffer: ArrayBuffer, targetDir: string, options: { rejectInternalMetadata?: boolean } = {}): Promise {
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,
@@ -25,7 +25,7 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis
filePath: safeJoin(targetDir, name),
}))
for (const { name, data, filePath } of entries) {
- if (name.endsWith('/')) {
+ if (isZipDirectory(name)) {
await mkdir(filePath, { recursive: true })
continue
}
@@ -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) {
@@ -66,6 +70,7 @@ function validateZipCentralDirectory(archive: Uint8Array): void {
throw new Error('invalid zip central directory')
}
+ const destinations = new Map()
let offset = centralDirectoryOffset
let totalUncompressedSize = 0
const decoder = new TextDecoder()
@@ -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')
}
@@ -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}`)
diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts
index 93c2acbb3..30ee649f8 100644
--- a/cli/src/services/install-service.ts
+++ b/cli/src/services/install-service.ts
@@ -145,7 +145,7 @@ export async function installSkill(options: InstallOptions): Promise {
+ 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
+}
diff --git a/cli/src/services/skill-examples.ts b/cli/src/services/skill-examples.ts
new file mode 100644
index 000000000..d845d872f
--- /dev/null
+++ b/cli/src/services/skill-examples.ts
@@ -0,0 +1,87 @@
+import { createHash } from 'node:crypto'
+import { constants } from 'node:fs'
+import { lstat, open } from 'node:fs/promises'
+import { join } from 'node:path'
+
+export interface SkillExample { id: string; title: string; prompt: string }
+export type SkillExamples =
+ | { state: 'ready'; digest: string; defaultLocale: string; items: Record }
+ | { state: 'none' }
+ | { state: 'invalid' }
+ | { state: 'unavailable'; retryable: boolean }
+
+export const MAX_EXAMPLES_BYTES = 65_536
+const localePattern = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/
+// Explicit protocol whitespace code points, including ASCII controls.
+// eslint-disable-next-line no-control-regex
+const nonWhitespace = /[^\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]/u
+
+/** Parse author bytes without rewriting the immutable packaged file. */
+export function parseSkillExamples(bytes: Uint8Array): SkillExamples {
+ if (bytes.byteLength > MAX_EXAMPLES_BYTES) return { state: 'invalid' }
+ try {
+ const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes).replace(/^\uFEFF/, '')
+ const value: unknown = JSON.parse(text)
+ if (!record(value) || !exactKeys(value, ['schemaVersion', 'defaultLocale', 'examples'])
+ || value.schemaVersion !== 1 || typeof value.defaultLocale !== 'string' || !localePattern.test(value.defaultLocale)
+ || !record(value.examples)) return { state: 'invalid' }
+ const languages = Object.keys(value.examples)
+ if (languages.length < 1 || languages.length > 10) return { state: 'invalid' }
+ const normalized = new Set()
+ const items: Record = Object.create(null)
+ for (const locale of languages) {
+ const entries = value.examples[locale]
+ if (!localePattern.test(locale) || normalized.has(locale.toLowerCase())
+ || !Array.isArray(entries) || entries.length > 20) return { state: 'invalid' }
+ normalized.add(locale.toLowerCase())
+ const ids = new Set()
+ const cases: SkillExample[] = []
+ for (const entry of entries) {
+ if (!record(entry) || !exactKeys(entry, ['id', 'title', 'prompt']) || typeof entry.id !== 'string'
+ || entry.id.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id) || ids.has(entry.id)
+ || !textWithin(entry.title, 80) || !textWithin(entry.prompt, 4000)) return { state: 'invalid' }
+ ids.add(entry.id)
+ cases.push({ id: entry.id, title: entry.title, prompt: entry.prompt })
+ }
+ items[locale] = cases
+ }
+ if (!normalized.has(value.defaultLocale.toLowerCase())) return { state: 'invalid' }
+ return { state: 'ready', digest: createHash('sha256').update(bytes).digest('hex'), defaultLocale: value.defaultLocale, items }
+ } catch { return { state: 'invalid' } }
+}
+
+/** Host-only fixed-path read; installation and winner verification remain the caller's responsibility. */
+export async function readSkillExamples(directory: string): Promise {
+ try {
+ const parent = await lstat(join(directory, '.skillhub'))
+ if (!parent.isDirectory() || parent.isSymbolicLink()) return { state: 'unavailable', retryable: false }
+ const path = join(directory, '.skillhub', 'examples.json')
+ const info = await lstat(path)
+ if (!info.isFile() || info.isSymbolicLink()) return { state: 'unavailable', retryable: false }
+ const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
+ try {
+ if (!(await file.stat()).isFile()) return { state: 'unavailable', retryable: false }
+ const bytes = new Uint8Array(MAX_EXAMPLES_BYTES + 1)
+ let size = 0
+ while (size < bytes.length) {
+ const { bytesRead } = await file.read(bytes, size, bytes.length - size, size)
+ if (!bytesRead) break
+ size += bytesRead
+ }
+ return parseSkillExamples(bytes.subarray(0, size))
+ } finally { await file.close() }
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === 'ENOENT'
+ ? { state: 'none' } : { state: 'unavailable', retryable: true }
+ }
+}
+
+function record(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+function exactKeys(value: Record, keys: string[]): boolean {
+ return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))
+}
+function textWithin(value: unknown, maximum: number): value is string {
+ return typeof value === 'string' && [...value].length <= maximum && nonWhitespace.test(value)
+}
diff --git a/cli/src/services/skill-fingerprint.ts b/cli/src/services/skill-fingerprint.ts
index d73bfaa61..a439d1dbb 100644
--- a/cli/src/services/skill-fingerprint.ts
+++ b/cli/src/services/skill-fingerprint.ts
@@ -39,8 +39,8 @@ async function listSkillFiles(root: string): Promise {
async function walk(current: string): Promise {
const entries = await readdir(current, { withFileTypes: true })
for (const entry of entries) {
- if (entry.name === '.skillhub') continue
const absolute = join(current, entry.name)
+ if (relative(root, absolute).split('\\').join('/') === '.skillhub/metadata.json') continue
if (entry.isDirectory()) {
await walk(absolute)
} else if (entry.isFile()) {
diff --git a/cli/src/services/sync-service.ts b/cli/src/services/sync-service.ts
index 0ed460fe1..cdeeef684 100644
--- a/cli/src/services/sync-service.ts
+++ b/cli/src/services/sync-service.ts
@@ -10,6 +10,7 @@ import { pathExists } from '../platform/paths'
import { compareSkillVersions } from './skill-version-order'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
+import { inspectPackagedExamples, type ExamplesPreflight } from './skill-examples-preflight'
export type SyncStatus = 'up-to-date' | 'update-available' | 'local-changed' | 'blocked' | 'orphaned' | 'not-installed'
@@ -42,7 +43,7 @@ export interface PullResult {
warnings: Array<{ slug: string; message: string }>
}
-export interface PushResultItem {
+export interface PushResultItem extends Omit {
path: string
slug?: string
version?: string
@@ -288,6 +289,7 @@ export async function pushSkills(options: {
for (const path of options.paths) {
try {
const archive = await prepareArchive(path)
+ const examples = await inspectPackagedExamples(archive.blob)
const validation = await options.client.validatePublish(
options.namespace, archive.blob, options.visibility, archive.fileName, true)
if (!validation.valid) {
@@ -295,7 +297,8 @@ export async function pushSkills(options: {
path,
action: 'failed',
errors: validation.errors,
- warnings: validation.warnings
+ ...examples,
+ warnings: [...new Set([...validation.warnings, ...examples.warnings])]
}
if (validation.resolvedSlug) failed.slug = validation.resolvedSlug
if (validation.resolvedVersion) failed.version = validation.resolvedVersion
@@ -306,7 +309,8 @@ export async function pushSkills(options: {
const validated: PushResultItem = {
path,
action: 'validated',
- warnings: validation.warnings
+ ...examples,
+ warnings: [...new Set([...validation.warnings, ...examples.warnings])]
}
if (validation.resolvedSlug) validated.slug = validation.resolvedSlug
if (validation.resolvedVersion) validated.version = validation.resolvedVersion
@@ -340,7 +344,9 @@ export async function pushSkills(options: {
version: published.version,
status,
action,
- ...(reviewStatus ? { reviewStatus } : {})
+ ...(reviewStatus ? { reviewStatus } : {}),
+ ...examples,
+ warnings: [...new Set([...validation.warnings, ...examples.warnings])]
})
} catch (error) {
results.push({ path, action: 'failed', errors: [error instanceof Error ? error.message : 'push failed'] })
@@ -365,7 +371,7 @@ async function prepareArchive(path: string): Promise<{ blob: Blob; fileName: str
const pathStat = await stat(path)
if (pathStat.isDirectory()) {
return {
- blob: await createZip(path, { exclude: relativePath => relativePath === '.skillhub' || relativePath.startsWith('.skillhub/') }),
+ blob: await createZip(path, { exclude: relativePath => relativePath.toLowerCase() === '.skillhub/metadata.json' }),
fileName: `${basename(path)}.zip`
}
}
diff --git a/cli/test/helpers/fake-registry.ts b/cli/test/helpers/fake-registry.ts
index 1ebdbf7a9..81979ed57 100644
--- a/cli/test/helpers/fake-registry.ts
+++ b/cli/test/helpers/fake-registry.ts
@@ -85,8 +85,8 @@ function resolveSkillFingerprint(skill: FakeSkill): string {
const entries = unzipSync(skill.zipBytes ?? MINIMAL_ZIP)
const aggregate = createHash('sha256')
for (const path of Object.keys(entries)
- .filter(path => !path.endsWith('/') && path !== '.skillhub' && !path.startsWith('.skillhub/'))
- .sort((left, right) => left.localeCompare(right))) {
+ .filter(path => !path.endsWith('/') && path !== '.skillhub/metadata.json')
+ .sort()) {
const fileHash = createHash('sha256').update(entries[path]!).digest('hex')
aggregate.update(`${path}:${fileHash}\n`, 'utf8')
}
diff --git a/cli/test/integration/publish-command.test.ts b/cli/test/integration/publish-command.test.ts
index 5a1140d9e..ce1cb79fc 100644
--- a/cli/test/integration/publish-command.test.ts
+++ b/cli/test/integration/publish-command.test.ts
@@ -125,6 +125,7 @@ describe('publish command — P0', () => {
const dir = await makeTempDir(['SKILL.md', '# Demo'])
await mkdir(join(dir, '.skillhub'), { recursive: true })
await writeFile(join(dir, '.skillhub', 'metadata.json'), '{"registry":"private"}')
+ await writeFile(join(dir, '.skillhub', 'examples.json'), '{invalid authored content}')
const result = await runCli(['publish', dir, '--registry', registry.url], {
HOME: env.home,
@@ -133,7 +134,8 @@ describe('publish command — P0', () => {
expect(result.exitCode).toBe(0)
expect(registry.received.publish!.archiveEntries).toContain('SKILL.md')
- expect(registry.received.publish!.archiveEntries.some(path => path.startsWith('.skillhub'))).toBe(false)
+ expect(registry.received.publish!.archiveEntries).not.toContain('.skillhub/metadata.json')
+ expect(registry.received.publish!.archiveEntries).toContain('.skillhub/examples.json')
})
test('zip file happy path: exit 0, fileName matches passed file', async () => {
@@ -616,3 +618,54 @@ describe('publish command — content shape', () => {
}
})
})
+
+const examplesContent = JSON.stringify({ schemaVersion: 1, defaultLocale: 'zh-CN', examples: {
+ 'zh-CN': [{ id: 'summary', title: '摘要', prompt: '请使用 {{skill}} 总结。' }],
+ en: [{ id: 'summary', title: 'Summary', prompt: 'Use {{skill}}.' }]
+} })
+
+for (const format of ['directory', 'zip'] as const) {
+ for (const state of ['ready', 'invalid', 'none'] as const) {
+ test.each([true, false])(`publish ${format} provides nonblocking ${state} examples feedback (json=%s)`, async json => {
+ const env = await createTempHome()
+ registry = await startFakeRegistry({ token: 'sk_ok' })
+ const directory = await makeTempDir(['SKILL.md', '# Demo'])
+ const content = state === 'ready' ? examplesContent : '{malformed author content}'
+ if (state !== 'none') {
+ await mkdir(join(directory, '.skillhub'), { recursive: true })
+ await writeFile(join(directory, '.skillhub/examples.json'), content)
+ }
+ let path = directory
+ if (format === 'zip') {
+ path = join(directory, 'package.zip')
+ await writeFile(path, zipSync({ 'SKILL.md': new TextEncoder().encode('# Demo'),
+ ...(state !== 'none' ? { '.skillhub/examples.json': new TextEncoder().encode(content) } : {}) }))
+ }
+ const result = await runCli(['publish', path, '--registry', registry.url, '--token', 'sk_ok', ...(json ? ['--json'] : [])], { HOME: env.home })
+ expect(result.exitCode).toBe(0)
+ expect(registry.received.publish).not.toBeNull()
+ if (json) {
+ const output = JSON.parse(result.stdout)
+ expect(output.examplesStatus).toBe(state)
+ expect(output.examplesCount).toBe(state === 'ready' ? 2 : state === 'none' ? 0 : undefined)
+ expect(output.warnings).toHaveLength(state === 'invalid' ? 1 : 0)
+ } else {
+ expect(result.stdout).toContain(state === 'invalid' ? 'remains ordinary package content' : state === 'ready' ? 'Task examples: 2' : 'Task examples: none')
+ }
+ if (state !== 'none') expect(registry.received.publish!.archiveEntries).toContain('.skillhub/examples.json')
+ })
+ }
+}
+
+test.each([true, false])('publish dry-run reports malformed ZIP examples without submitting (json=%s)', async json => {
+ const env = await createTempHome()
+ registry = await startFakeRegistry({ token: 'sk_ok' })
+ const directory = await makeTempDir()
+ const path = join(directory, 'skill.zip')
+ await writeFile(path, zipSync({ 'SKILL.md': new TextEncoder().encode('# Demo'), '.skillhub/examples.json': new TextEncoder().encode('{broken') }))
+ const result = await runCli(['publish', path, '--dry-run', '--registry', registry.url, '--token', 'sk_ok', ...(json ? ['--json'] : [])], { HOME: env.home })
+ expect(result.exitCode).toBe(0)
+ expect(registry.received.publish).toBeNull()
+ if (json) expect(JSON.parse(result.stdout)).toMatchObject({ valid: true, examplesStatus: 'invalid', warnings: [expect.stringContaining('ordinary package content')] })
+ else expect(result.stdout).toContain('remains ordinary package content')
+})
diff --git a/cli/test/integration/sync-command.test.ts b/cli/test/integration/sync-command.test.ts
index faaa5731f..738af3afc 100644
--- a/cli/test/integration/sync-command.test.ts
+++ b/cli/test/integration/sync-command.test.ts
@@ -647,6 +647,7 @@ describe('sync command', () => {
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"must":"not be uploaded"}')
+ await writeFile(join(skillDir, '.skillhub', 'examples.json'), '{invalid authored content}')
const registry = await startFakeRegistry({ token: 'token', publishStatus: 'UPLOADED' })
try {
@@ -663,6 +664,8 @@ describe('sync command', () => {
})
expect(registry.received.publish?.visibility).toBe('NAMESPACE_ONLY')
expect(registry.received.publish?.rejectExistingVersion).toBe(true)
+ expect(registry.received.publish?.archiveEntries).toContain('.skillhub/examples.json')
+ expect(registry.received.publish?.archiveEntries).not.toContain('.skillhub/metadata.json')
expect(registry.received.review).toMatchObject({
namespace: 'team-a', slug: 'demo', version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY'
})
@@ -805,3 +808,42 @@ describe('sync command', () => {
}
})
})
+
+
+test.each([true, false])('sync push keeps malformed examples and reports nonblocking warnings (dryRun=%s)', async dryRun => {
+ const env = await createTempHome()
+ const directory = await makeLocalSkill(env.cwd)
+ await mkdir(join(directory, '.skillhub'), { recursive: true })
+ await writeFile(join(directory, '.skillhub/examples.json'), '{invalid but publishable}')
+ const registry = await startFakeRegistry({ token: 'token' })
+ try {
+ for (const json of [true, false]) {
+ const result = await runCli(['sync', 'push', directory, '--namespace', 'team-a', '--registry', registry.url, '--token', 'token',
+ ...(dryRun ? ['--dry-run'] : []), ...(json ? ['--json'] : [])], { HOME: env.home }, { cwd: env.cwd })
+ expect(result.exitCode).toBe(0)
+ if (json) {
+ const item = JSON.parse(result.stdout).items[0]
+ expect(item.examplesStatus).toBe('invalid')
+ expect(item.warnings).toHaveLength(1)
+ expect(item.action).toBe(dryRun ? 'validated' : 'uploaded')
+ } else expect(result.stdout).toContain('remains ordinary package content')
+ }
+ if (!dryRun) expect(registry.received.publish!.archiveEntries).toContain('.skillhub/examples.json')
+ } finally { registry.stop() }
+})
+
+test.each(['ready', 'none'] as const)('sync push reports %s examples for raw ZIP in JSON and human output', async state => {
+ const env = await createTempHome()
+ const path = join(env.cwd, 'skill.zip')
+ const content = JSON.stringify({ schemaVersion: 1, defaultLocale: 'en', examples: { en: [{ id: 'one', title: 'One', prompt: 'Use {{skill}}' }] } })
+ await writeFile(path, zipSync({ 'SKILL.md': strToU8('# Demo'), ...(state === 'ready' ? { '.skillhub/examples.json': strToU8(content) } : {}) }))
+ const registry = await startFakeRegistry({ token: 'token' })
+ try {
+ for (const json of [true, false]) {
+ const result = await runCli(['sync', 'push', path, '--namespace', 'team-a', '--registry', registry.url, '--token', 'token', ...(json ? ['--json'] : [])], { HOME: env.home }, { cwd: env.cwd })
+ expect(result.exitCode).toBe(0)
+ if (json) expect(JSON.parse(result.stdout).items[0]).toMatchObject({ examplesStatus: state, examplesCount: state === 'ready' ? 1 : 0, warnings: [] })
+ else expect(result.stdout).toContain(state === 'ready' ? 'Task examples: 1' : 'Task examples: none')
+ }
+ } finally { registry.stop() }
+})
diff --git a/cli/test/unit/integrations/install-recovery.test.ts b/cli/test/unit/integrations/install-recovery.test.ts
index 1992c6da0..ca8089812 100644
--- a/cli/test/unit/integrations/install-recovery.test.ts
+++ b/cli/test/unit/integrations/install-recovery.test.ts
@@ -14,11 +14,14 @@ import { acquireSkillTargetLock } from '../../../src/services/skill-target-lock'
import { InventoryCommitUnknownError, InventoryStore, type Inventory } from '../../../src/stores/inventory-store'
const content = '# Recovery fixture'
-const fingerprint = 'sha256:' + createHash('sha256').update('SKILL.md:' + createHash('sha256').update(content).digest('hex') + '\n').digest('hex')
+const examples = '{invalid but preserved versioned examples}'
+const fingerprint = 'sha256:' + createHash('sha256')
+ .update('.skillhub/examples.json:' + createHash('sha256').update(examples).digest('hex') + '\n')
+ .update('SKILL.md:' + createHash('sha256').update(content).digest('hex') + '\n').digest('hex')
const identity = { registry: 'https://registry.example', namespace: 'team', slug: 'writer' }
class LocalClient extends SkillHubClient {
async download(): Promise {
- return new Response(new Uint8Array(zipSync({ 'SKILL.md': new TextEncoder().encode(content) })).buffer)
+ return new Response(new Uint8Array(zipSync({ 'SKILL.md': new TextEncoder().encode(content), '.skillhub/examples.json': new TextEncoder().encode(examples) })).buffer)
}
}
diff --git a/cli/test/unit/integrations/native-install.test.ts b/cli/test/unit/integrations/native-install.test.ts
index 00c207a37..f400adbcc 100644
--- a/cli/test/unit/integrations/native-install.test.ts
+++ b/cli/test/unit/integrations/native-install.test.ts
@@ -12,7 +12,10 @@ import type { NativeInstallExecution } from '../../../src/integrations/native-co
async function fixture(downloadFailure = false) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-native-execution-')))
const content = '# Native fixture'
- const fingerprint = 'sha256:' + createHash('sha256').update('SKILL.md:' + createHash('sha256').update(content).digest('hex') + '\n').digest('hex')
+ const examples = '{invalid but preserved versioned examples}'
+ const fingerprint = 'sha256:' + createHash('sha256')
+ .update('.skillhub/examples.json:' + createHash('sha256').update(examples).digest('hex') + '\n')
+ .update('SKILL.md:' + createHash('sha256').update(content).digest('hex') + '\n').digest('hex')
let downloads = 0
let releaseChanged = false
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
@@ -22,7 +25,7 @@ async function fixture(downloadFailure = false) {
} })
downloads++
if (downloadFailure) return new Response('missing', { status: 404 })
- return new Response(new Uint8Array(zipSync({ 'SKILL.md': new TextEncoder().encode(content) })).buffer)
+ return new Response(new Uint8Array(zipSync({ 'SKILL.md': new TextEncoder().encode(content), '.skillhub/examples.json': new TextEncoder().encode(examples) })).buffer)
} })
const registry = server.url.origin
const stateDirectory = join(root, 'operations')
@@ -52,6 +55,7 @@ test('a new request for the same complete installation returns already-installed
expect(await f.runtime.executeInstall(second)).toMatchObject({ outcome: 'committed', reason: 'already-installed' })
expect((await f.runtime.getOperation(second.operationId, second.ownerId))?.files).toBe('already-installed')
expect(f.downloads()).toBe(1)
+ expect(await readFile(join(f.root, 'skills/writer/.skillhub/examples.json'), 'utf8')).toBe('{invalid but preserved versioned examples}')
} finally { await f.close() }
})
diff --git a/cli/test/unit/integrations/native-upgrade.test.ts b/cli/test/unit/integrations/native-upgrade.test.ts
index 7a8872c48..fb326cb65 100644
--- a/cli/test/unit/integrations/native-upgrade.test.ts
+++ b/cli/test/unit/integrations/native-upgrade.test.ts
@@ -13,8 +13,11 @@ async function fixture(downloadFailure = false) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-native-upgrade-')))
const versions = ['1.0.0', '2.0.0'].map((version, index) => {
const content = `# Version ${version}`
- const fingerprint = 'sha256:' + createHash('sha256').update('SKILL.md:' + createHash('sha256').update(content).digest('hex') + '\n').digest('hex')
- return { version, versionId: index + 1, content, fingerprint }
+ const examples = JSON.stringify({ schemaVersion: 1, defaultLocale: 'en', examples: { en: [{ id: 'one', title: version, prompt: 'Use {{skill}} ' + version }] } })
+ const fingerprint = 'sha256:' + createHash('sha256')
+ .update('.skillhub/examples.json:' + createHash('sha256').update(examples).digest('hex') + '\n')
+ .update('SKILL.md:' + createHash('sha256').update(content).digest('hex') + '\n').digest('hex')
+ return { version, versionId: index + 1, content, examples, fingerprint }
})
let downloads = 0
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
@@ -26,7 +29,7 @@ async function fixture(downloadFailure = false) {
} })
downloads++
if (downloadFailure && version.version === '2.0.0') return new Response('missing', { status: 404 })
- return new Response(new Uint8Array(zipSync({ 'SKILL.md': new TextEncoder().encode(version.content) })).buffer)
+ return new Response(new Uint8Array(zipSync({ 'SKILL.md': new TextEncoder().encode(version.content), '.skillhub/examples.json': new TextEncoder().encode(version.examples) })).buffer)
} })
const registry = server.url.origin
const inventory = new InventoryStore(join(root, 'home'))
@@ -52,6 +55,7 @@ test('native upgrade replaces the entire confirmed target set and persists its o
try {
expect(await f.runtime.executeUpgrade(f.request)).toMatchObject({ outcome: 'committed' })
for (const target of f.roots) expect(await readFile(join(target.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 2.0.0')
+ expect(await f.runtime.readSkillExamples(join(f.roots[0]!.root, 'writer'))).toMatchObject({ state: 'ready', items: { en: [{ title: '2.0.0' }] } })
expect((await f.inventory.read()).items[0]!.version).toBe('2.0.0')
expect((await f.runtime.getOperation(f.request.operationId, 'session'))?.files).toBe('upgraded')
expect(await f.runtime.listPending()).toEqual([])
diff --git a/cli/test/unit/integrations/upgrade-baseline.test.ts b/cli/test/unit/integrations/upgrade-baseline.test.ts
index 183fd6de0..f168ae691 100644
--- a/cli/test/unit/integrations/upgrade-baseline.test.ts
+++ b/cli/test/unit/integrations/upgrade-baseline.test.ts
@@ -15,6 +15,7 @@ test('upgrade baseline includes both approved targets and blocks drift, sharing,
for (const target of item.targets) {
await mkdir(join(target.installDir, '.skillhub'), { recursive: true })
await writeFile(join(target.installDir, 'SKILL.md'), 'original')
+ await writeFile(join(target.installDir, '.skillhub/examples.json'), '{invalid but fingerprinted}')
const snapshot = await snapshotSkillDirectory(target.installDir)
item.fingerprint = snapshot.fingerprint
await writeFile(join(target.installDir, '.skillhub/metadata.json'), JSON.stringify({ ...item, ...snapshot,
@@ -29,6 +30,13 @@ test('upgrade baseline includes both approved targets and blocks drift, sharing,
expect(ready.targets.map(target => target.scope)).toEqual(['user', 'project'])
expect(ready.inventoryItem.targets).toHaveLength(2)
+ await writeFile(join(item.targets[0]!.installDir, '.skillhub/examples.json'), '{edited}')
+ expect(await check()).toEqual({ status: 'blocked', reason: 'local-changes' })
+ await rm(join(item.targets[0]!.installDir, '.skillhub/examples.json'))
+ expect(await check()).toEqual({ status: 'blocked', reason: 'local-changes' })
+ await writeFile(join(item.targets[0]!.installDir, '.skillhub/examples.json'), '{invalid but fingerprinted}')
+ expect((await check()).status).toBe('ready')
+
inventory.items[0]!.targets[1]!.agent = 'other-agent'
expect(await check()).toEqual({ status: 'blocked', reason: 'target-outside-context' })
inventory = { items: [structuredClone(item)] }
@@ -50,7 +58,8 @@ test('upgrade baseline includes both approved targets and blocks drift, sharing,
const snapshot = await snapshotSkillDirectory(item.targets[0]!.installDir)
const metadata = { ...item, ...snapshot, agent: 'dsh', installedAt: item.targets[0]!.installedAt }
await writeFile(metadataPath, JSON.stringify({ ...metadata, files: undefined }))
- expect(await check()).toEqual({ status: 'blocked', reason: 'metadata-invalid' })
+ // Authored metadata files cannot be considered owned without a recorded file baseline.
+ expect(await check()).toEqual({ status: 'blocked', reason: 'target-unavailable' })
await writeFile(metadataPath, JSON.stringify(metadata))
let reads = 0
expect(await inspectUpgradeBaseline({ read: async () => {
diff --git a/cli/test/unit/platform/archive.test.ts b/cli/test/unit/platform/archive.test.ts
index b175e279a..73f508698 100644
--- a/cli/test/unit/platform/archive.test.ts
+++ b/cli/test/unit/platform/archive.test.ts
@@ -1,4 +1,4 @@
-import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
@@ -17,6 +17,59 @@ describe('archive helpers', () => {
expect(await readFile(join(target, 'SKILL.md'), 'utf-8')).toBe('# Demo')
})
+ test.each(['/', '\\'])('extracts explicit directories and files with %s separators', async separator => {
+ const root = await mkdtemp(join(tmpdir(), 'skillhub-archive-directories-'))
+ try {
+ const source = join(root, 'source')
+ const target = join(root, 'target')
+ const examples = new TextEncoder().encode('{"schemaVersion":1,"examples":{"zh-CN":[]}}\n')
+ const archive = zipSync({
+ 'SKILL.md': new TextEncoder().encode('# Demo'),
+ [`.skillhub${separator}`]: new Uint8Array(0),
+ [`.skillhub${separator}examples.json`]: examples,
+ [`resources${separator}`]: new Uint8Array(0),
+ [`resources${separator}empty${separator}`]: new Uint8Array(0),
+ [`resources${separator}data.bin`]: new Uint8Array([0, 255, 42]),
+ })
+
+ await extractZip(archive.buffer as ArrayBuffer, source, { rejectInternalMetadata: true })
+ const repacked = await createZip(source)
+ await extractZip(await repacked.arrayBuffer(), target, { rejectInternalMetadata: true })
+
+ for (const directory of [source, target]) {
+ expect(await readFile(join(directory, 'SKILL.md'), 'utf-8')).toBe('# Demo')
+ expect(new Uint8Array(await readFile(join(directory, '.skillhub', 'examples.json')))).toEqual(examples)
+ expect(new Uint8Array(await readFile(join(directory, 'resources', 'data.bin')))).toEqual(new Uint8Array([0, 255, 42]))
+ expect((await stat(join(directory, '.skillhub'))).isDirectory()).toBe(true)
+ expect((await stat(join(directory, 'resources', 'empty'))).isDirectory()).toBe(true)
+ }
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+ })
+
+ test.each(['.skillhub/metadata.json', '.skillhub/./metadata.json', '.SKILLHUB/METADATA.JSON', '.skillhub\\metadata.json', '.skillhub/metadata.json/child'])('install rejects reserved metadata alias %s before writing', async path => {
+ const target = await mkdtemp(join(tmpdir(), 'skillhub-reserved-'))
+ const archive = zipSync({ 'SKILL.md': new TextEncoder().encode('safe'), [path]: new TextEncoder().encode('untrusted') })
+ await expect(extractZip(archive.buffer as ArrayBuffer, target, { rejectInternalMetadata: true })).rejects.toThrow('Installer metadata')
+ await expect(readFile(join(target, 'SKILL.md'))).rejects.toThrow()
+ })
+
+ test.each(['.SKILLHUB/examples.json', '.skillhub/Examples.JSON'])('install rejects ambiguous cross-platform examples path %s', async path => {
+ const target = await mkdtemp(join(tmpdir(), 'skillhub-case-alias-'))
+ const archive = zipSync({ 'SKILL.md': new TextEncoder().encode('safe'), [path]: new TextEncoder().encode('{}') })
+ await expect(extractZip(archive.buffer as ArrayBuffer, target, { rejectInternalMetadata: true })).rejects.toThrow('Ambiguous skill examples')
+ await expect(readFile(join(target, 'SKILL.md'))).rejects.toThrow()
+ })
+
+ test('rejects canonical duplicate and file/directory conflicts', async () => {
+ for (const entries of [{ 'a.json': 'one', './a.json': 'two' }, { a: 'file', 'a/b': 'child' }]) {
+ const target = await mkdtemp(join(tmpdir(), 'skillhub-duplicate-'))
+ const archive = zipSync(Object.fromEntries(Object.entries(entries).map(([path, content]) => [path, new TextEncoder().encode(content)])))
+ await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('conflicting zip')
+ }
+ })
+
test('detects zip files by magic bytes', async () => {
const dir = await mkdtemp(join(tmpdir(), 'skillhub-archive-detect-'))
const zipPath = join(dir, 'skill.zip')
diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts
index 64f2ea237..ce9d3c539 100644
--- a/cli/test/unit/services/install-service.test.ts
+++ b/cli/test/unit/services/install-service.test.ts
@@ -26,7 +26,7 @@ async function exists(path: string): Promise {
function skillFingerprint(zipEntries: Record): string {
const aggregate = createHash('sha256')
- for (const [name, content] of Object.entries(zipEntries).sort(([left], [right]) => left.localeCompare(right))) {
+ for (const [name, content] of Object.entries(zipEntries).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
const fileHash = createHash('sha256').update(content).digest('hex')
aggregate.update(`${name}:${fileHash}\n`, 'utf8')
}
@@ -89,6 +89,31 @@ describe('installSkill', () => {
globalThis.fetch = originalFetch
})
+ test.each(['{"schemaVersion":1,"defaultLocale":"en","examples":{"en":[{"id":"one","title":"One","prompt":"Use {{skill}}"}]}}', '{invalid'])('installs authored examples unchanged and records their complete baseline: %s', async content => {
+ const root = await mkdtemp(join(tmpdir(), 'skillhub-packaged-examples-'))
+ try {
+ globalThis.fetch = installFetch({ 'SKILL.md': '# Demo', '.skillhub/examples.json': content })
+ await installSkill({ registry: 'http://registry.test', namespace: 'global', slug: 'demo',
+ targets: [{ agent: 'dsh', rootDir: join(root, 'skills'), scope: 'project', source: 'explicit' }], force: false, home: join(root, 'home') })
+ const target = join(root, 'skills/demo')
+ expect(await readFile(join(target, '.skillhub/examples.json'), 'utf8')).toBe(content)
+ const metadata = JSON.parse(await readFile(join(target, '.skillhub/metadata.json'), 'utf8'))
+ expect(metadata.files['.skillhub/examples.json']).toBe(createHash('sha256').update(content).digest('hex'))
+ expect(metadata.files['.skillhub/metadata.json']).toBeUndefined()
+ } finally { await rm(root, { recursive: true, force: true }) }
+ })
+
+ test('rejects authored installation metadata without replacing target or inventory', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'skillhub-forged-metadata-'))
+ try {
+ globalThis.fetch = installFetch({ 'SKILL.md': '# Demo', '.skillhub/metadata.json': '{"registry":"evil"}' })
+ await expect(installSkill({ registry: 'http://registry.test', namespace: 'global', slug: 'demo',
+ targets: [{ agent: 'dsh', rootDir: join(root, 'skills'), scope: 'project', source: 'explicit' }], force: false, home: join(root, 'home') })).rejects.toThrow('Installer metadata')
+ expect(await exists(join(root, 'skills/demo'))).toBe(false)
+ expect((await new InventoryStore(join(root, 'home')).read()).items).toEqual([])
+ } finally { await rm(root, { recursive: true, force: true }) }
+ })
+
test.each(['journal-failure', 'directory-collision'] as const)('staging intent precedes directory creation: %s', async failure => {
const root = await mkdtemp(join(tmpdir(), 'skillhub-staging-intent-'))
const rootDir = join(root, 'skills')
diff --git a/cli/test/unit/services/skill-examples-preflight.test.ts b/cli/test/unit/services/skill-examples-preflight.test.ts
new file mode 100644
index 000000000..bf425b585
--- /dev/null
+++ b/cli/test/unit/services/skill-examples-preflight.test.ts
@@ -0,0 +1,67 @@
+import { expect, spyOn, test } from 'bun:test'
+import { zipSync } from 'fflate'
+import { inspectPackagedExamples } from '../../../src/services/skill-examples-preflight'
+
+const valid = JSON.stringify({ schemaVersion: 1, defaultLocale: 'zh-CN', examples: {
+ 'zh-CN': [], en: [{ id: 'one', title: 'Summary', prompt: 'Use {{skill}}.' }]
+} })
+function archive(files: Record): Blob {
+ return new Blob([new Uint8Array(zipSync(Object.fromEntries(Object.entries(files).map(([path, text]) => [path, new TextEncoder().encode(text)])))).buffer])
+}
+
+test('preflight matches server root stripping/promotion and counts all language entries', async () => {
+ for (const files of [
+ { 'SKILL.md': '# skill', '.skillhub/examples.json': valid },
+ { 'package/SKILL.md': '# skill', 'package/.skillhub/examples.json': valid },
+ { 'package/SKILL.md': '# skill', 'package/.skillhub/examples.json': valid, 'README.md': 'outside' },
+ { 'package/skill.md': '# skill', 'package/.skillhub/examples.json': valid, 'README.md': 'outside' },
+ { 'package/Skill.MD': '# skill', 'package/.skillhub/examples.json': valid, 'README.md': 'outside' },
+ { 'package/SKILL.md': '# skill', 'package/.skillhub/examples.json': valid, '__MACOSX/._file': 'ignored' }
+ ]) expect(await inspectPackagedExamples(archive(files))).toEqual({ examplesStatus: 'ready', examplesCount: 1, warnings: [] })
+})
+
+test('preflight bounds inflation, preserves invalid declaration semantics and ignores unrelated files', async () => {
+ for (const content of ['{broken', 'x'.repeat(65537)]) {
+ const result = await inspectPackagedExamples(archive({ 'SKILL.md': '# skill', '.skillhub/examples.json': content }))
+ expect(result.examplesStatus).toBe('invalid')
+ expect(result.examplesCount).toBeUndefined()
+ expect(result.warnings).toHaveLength(1)
+ }
+ expect(await inspectPackagedExamples(archive({ 'SKILL.md': '# skill', 'docs/.skillhub/examples.json': valid })))
+ .toEqual({ examplesStatus: 'none', examplesCount: 0, warnings: [] })
+ const unreadable = await inspectPackagedExamples(new Blob(['not a zip']))
+ expect(unreadable.examplesStatus).toBeUndefined()
+ expect(unreadable.warnings).toHaveLength(1)
+})
+
+
+test('preflight rejects forged stored-file sizes without copying the oversized entry body', async () => {
+ const content = new Uint8Array(65537).fill(120)
+ const bytes = zipSync({ 'SKILL.md': new TextEncoder().encode('# skill'), '.skillhub/examples.json': content }, { level: 0 })
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+ let forged = false
+ for (let offset = 0; offset <= bytes.byteLength - 46; offset++) {
+ if (view.getUint32(offset, true) !== 0x02014b50) continue
+ const nameLength = view.getUint16(offset + 28, true)
+ const name = new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength))
+ if (name === '.skillhub/examples.json') {
+ expect(view.getUint16(offset + 10, true)).toBe(0)
+ expect(view.getUint32(offset + 20, true)).toBe(65537)
+ view.setUint32(offset + 24, 1, true)
+ forged = true
+ break
+ }
+ }
+ expect(forged).toBe(true)
+ const subarray = Uint8Array.prototype.subarray
+ let oversizedReads = 0
+ const spy = spyOn(Uint8Array.prototype, 'subarray').mockImplementation(function (this: Uint8Array, begin, end) {
+ if ((end ?? this.length) - (begin ?? 0) > 65536) oversizedReads++
+ return subarray.call(this, begin, end)
+ })
+ try {
+ expect(await inspectPackagedExamples(new Blob([new Uint8Array(bytes).buffer])))
+ .toMatchObject({ examplesStatus: 'invalid', warnings: [expect.stringContaining('ordinary package content')] })
+ expect(oversizedReads).toBe(0)
+ } finally { spy.mockRestore() }
+})
diff --git a/cli/test/unit/services/skill-examples.test.ts b/cli/test/unit/services/skill-examples.test.ts
new file mode 100644
index 000000000..ac78348cc
--- /dev/null
+++ b/cli/test/unit/services/skill-examples.test.ts
@@ -0,0 +1,84 @@
+import { expect, test } from 'bun:test'
+import { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { createTempHome } from '../../helpers/temp-env'
+import { parseSkillExamples, readSkillExamples } from '../../../src/services/skill-examples'
+import { snapshotSkillDirectory, diffSkillFiles } from '../../../src/services/skill-fingerprint'
+import { assertOwnedRegularTree } from '../../../src/integrations/recovery-files'
+
+const example = { id: 'summary', title: '会议摘要', prompt: '请用 {{skill}} 总结会议。' }
+const valid = { schemaVersion: 1, defaultLocale: 'zh-CN', examples: { 'zh-CN': [example], en: [{ ...example, title: 'Summary', prompt: 'Use {{skill}}.' }] } }
+const bytes = (value: unknown) => new TextEncoder().encode(JSON.stringify(value))
+
+test('examples recognizes bounded multilingual object protocol without changing authored bytes', () => {
+ const parsed = parseSkillExamples(bytes(valid))
+ expect(parsed.state).toBe('ready')
+ if (parsed.state !== 'ready') throw new Error('expected ready')
+ expect(parsed.items['zh-CN']).toEqual([example])
+ expect(parsed.digest).toHaveLength(64)
+ expect(parseSkillExamples(new Uint8Array([0xef, 0xbb, 0xbf, ...bytes(valid)]))).toMatchObject({ state: 'ready' })
+ expect(parseSkillExamples(bytes({ ...valid, examples: { en: [] }, defaultLocale: 'en' }))).toMatchObject({ state: 'ready', items: { en: [] } })
+})
+
+test('examples rejects unsupported or ambiguous declarations, Unicode lengths and encoding', () => {
+ const invalid = [
+ {}, { ...valid, schemaVersion: 2 }, { ...valid, extra: true }, { ...valid, defaultLocale: 'ja' },
+ { ...valid, examples: { 'zh-CN': [example], 'zh-cn': [] } },
+ { ...valid, examples: { 'zh-CN': [example, example] } },
+ { ...valid, examples: { 'zh-CN': ['old string format'] } },
+ ...[{ ...example, id: 'Bad ID' }, { ...example, title: '\u0085\uFEFF' }, { ...example, prompt: '' },
+ { ...example, title: '😀'.repeat(81) }, { ...example, prompt: '😀'.repeat(4001) }, { ...example, extra: 1 }]
+ .map(item => ({ ...valid, examples: { 'zh-CN': [item] } }))
+ ]
+ for (const value of invalid) expect(parseSkillExamples(bytes(value))).toEqual({ state: 'invalid' })
+ expect(parseSkillExamples(new Uint8Array([0xff]))).toEqual({ state: 'invalid' })
+ expect(parseSkillExamples(new Uint8Array(65537))).toEqual({ state: 'invalid' })
+ expect(parseSkillExamples(bytes({ ...valid, examples: { 'zh-CN': [{ ...example, title: '😀'.repeat(80) }] } }))).toMatchObject({ state: 'ready' })
+})
+
+test('packaged examples participate in fingerprint and recovery ownership even when malformed', async () => {
+ const env = await createTempHome()
+ const root = join(env.cwd, 'skill')
+ await mkdir(join(root, '.skillhub'), { recursive: true })
+ await writeFile(join(root, 'SKILL.md'), '# skill')
+ const path = join(root, '.skillhub/examples.json')
+ await writeFile(path, bytes(valid))
+ const snapshot = await snapshotSkillDirectory(root)
+ await writeFile(join(root, '.skillhub/metadata.json'), JSON.stringify({ registry: 'https://example.com', namespace: 'a', slug: 'b', version: '1', ...snapshot }))
+ await expect(assertOwnedRegularTree(root)).resolves.toBeUndefined()
+ expect(await readSkillExamples(root)).toMatchObject({ state: 'ready' })
+ expect((await snapshotSkillDirectory(root)).fingerprint).toBe(snapshot.fingerprint)
+ await writeFile(path, '{broken')
+ expect(await readSkillExamples(root)).toEqual({ state: 'invalid' })
+ expect(diffSkillFiles(snapshot.files, (await snapshotSkillDirectory(root)).files)).toEqual(['.skillhub/examples.json'])
+ expect(await readFile(path, 'utf8')).toBe('{broken')
+ await writeFile(join(root, '.skillhub/untracked.txt'), 'preserve')
+ await expect(assertOwnedRegularTree(root)).rejects.toThrow('Untracked recovery metadata content')
+ await rm(join(root, '.skillhub/untracked.txt'))
+ await rm(path)
+ expect(await readSkillExamples(root)).toEqual({ state: 'none' })
+ await symlink(join(root, 'SKILL.md'), path)
+ await expect(assertOwnedRegularTree(root)).rejects.toThrow('Unsafe recovery path')
+})
+
+
+test('recognizes the normative protocol examples shared with server and schema checks', async () => {
+ for (const name of ['skill-examples.valid.json', 'skill-examples.empty.json']) {
+ const content = await readFile(new URL('../../../../openspec/changes/add-dsh-native-plugin/contracts/' + name, import.meta.url))
+ expect(parseSkillExamples(content)).toMatchObject({ state: 'ready' })
+ }
+})
+
+
+test('examples honors inclusive raw-byte, locale and per-language collection bounds', () => {
+ const encoded = bytes(valid)
+ const padded = new Uint8Array(65536).fill(32)
+ padded.set(encoded)
+ expect(parseSkillExamples(padded)).toMatchObject({ state: 'ready' })
+ const entries = Array.from({ length: 20 }, (_, index) => ({ ...example, id: `case-${index}` }))
+ expect(parseSkillExamples(bytes({ ...valid, examples: { 'zh-CN': entries } }))).toMatchObject({ state: 'ready' })
+ expect(parseSkillExamples(bytes({ ...valid, examples: { 'zh-CN': [...entries, { ...example, id: 'extra' }] } }))).toEqual({ state: 'invalid' })
+ const languages = Object.fromEntries(['en', 'zh', 'ja', 'ko', 'fr', 'de', 'es', 'it', 'pt', 'ru'].map(locale => [locale, []]))
+ expect(parseSkillExamples(bytes({ ...valid, defaultLocale: 'en', examples: languages }))).toMatchObject({ state: 'ready' })
+ expect(parseSkillExamples(bytes({ ...valid, defaultLocale: 'en', examples: { ...languages, nl: [] } }))).toEqual({ state: 'invalid' })
+})
diff --git a/cli/test/unit/services/suite-service.test.ts b/cli/test/unit/services/suite-service.test.ts
index 6a962c018..c42b43e0c 100644
--- a/cli/test/unit/services/suite-service.test.ts
+++ b/cli/test/unit/services/suite-service.test.ts
@@ -16,9 +16,11 @@ import {
const registry = 'http://registry.test'
-function archive(content: string): { bytes: Uint8Array; fingerprint: string } {
- const bytes = zipSync({ 'SKILL.md': new TextEncoder().encode(content) })
+function archive(content: string, includeExamples = true): { bytes: Uint8Array; fingerprint: string } {
+ const examples = '{invalid but versioned suite examples}'
+ const bytes = zipSync({ 'SKILL.md': new TextEncoder().encode(content), ...(includeExamples ? { '.skillhub/examples.json': new TextEncoder().encode(examples) } : {}) })
const aggregate = createHash('sha256')
+ if (includeExamples) aggregate.update('.skillhub/examples.json:' + createHash('sha256').update(examples).digest('hex') + '\n')
const fileHash = createHash('sha256').update(content).digest('hex')
aggregate.update(`SKILL.md:${fileHash}\n`, 'utf8')
return { bytes, fingerprint: `sha256:${aggregate.digest('hex')}` }
@@ -75,9 +77,9 @@ function clientWithPlanFailure(status: number, message: string): SkillHubClient
return new SkillHubClient(registry, undefined, fetchImpl)
}
-function makePlan(): { plan: SuiteInstallPlan; downloads: Record } {
- const alpha = archive('# Alpha')
- const beta = archive('# Beta')
+function makePlan(includeExamples = true): { plan: SuiteInstallPlan; downloads: Record } {
+ const alpha = archive('# Alpha', includeExamples)
+ const beta = archive('# Beta', includeExamples)
return {
plan: {
operationId: 'operation-1',
@@ -732,7 +734,7 @@ describe('Suite local lifecycle', () => {
test('reuses a matching legacy direct install and preserves it when Suite is removed', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
- const { plan, downloads } = makePlan()
+ const { plan, downloads } = makePlan(false)
plan.members = [plan.members[0]!]
const skillDir = join(rootDir, 'alpha')
await mkdir(skillDir, { recursive: true })
@@ -801,7 +803,7 @@ describe('Suite local lifecycle', () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const directRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-direct-root-'))
const suiteOnlyRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-only-root-'))
- const { plan, downloads } = makePlan()
+ const { plan, downloads } = makePlan(false)
const alpha = plan.members[0]!
await mkdir(join(directRoot, alpha.slug), { recursive: true })
await writeFile(join(directRoot, alpha.slug, 'SKILL.md'), '# Alpha')
diff --git a/openspec/changes/add-dsh-native-plugin/README.md b/openspec/changes/add-dsh-native-plugin/README.md
index c6a3e0dab..325228ea2 100644
--- a/openspec/changes/add-dsh-native-plugin/README.md
+++ b/openspec/changes/add-dsh-native-plugin/README.md
@@ -8,8 +8,8 @@
| 入口 | 本期能力 | 详情容器 |
| --- | --- | --- |
-| 技能广场 | 浏览、搜索、安装、已安装、检查更新、升级、卸载 | 主内容区子页面,左上返回 |
-| 原 DSH 对话 | 搜索卡片、详情、显式安装、自定义结果卡片 | 抽屉,窄屏内容区浮层 |
+| 技能广场 | 浏览、搜索、安装、已安装、检查更新、升级、卸载、使用已安装技能 | 主内容区子页面,左上返回 |
+| 原 DSH 对话 | 搜索卡片、详情、显式安装、自定义结果卡片、使用已安装技能 | 抽屉,窄屏内容区浮层 |
| 宿主插件配置 | 单一 Registry 地址,版本/审核/评论 auto/show/hide | 原生插件配置区域 |
不做登录、Token、私有访问、权限产品、DSH 插件市场、自动更新、降级、强制覆盖、发表评论或自动执行技能。对话升级/卸载工具不在本期,管理操作从广场进入。
@@ -20,6 +20,10 @@
| --- | --- |
| [proposal.md](proposal.md) | 范围与决策摘要 |
| [product-design.md](product-design.md) | 页面、功能 ID、视觉和产品行为 |
+| [use-skill-design.md](use-skill-design.md) | 使用此技能:本地核验、原生输入框衔接、固定案例文件与生命周期兼容约定 |
+| [skill-examples-design.md](skill-examples-design.md) | 固定案例文件的 JSON 协议、版本分发、跨端生命周期与兼容策略 |
+| [skill-examples-execution-plan.md](skill-examples-execution-plan.md) | 本轮开源范围、实施阶段、验收及后续SaaS同步边界 |
+| [skill-examples-saas-agent-prompt.md](skill-examples-saas-agent-prompt.md) | 可交给SaaS Agent的案例文件增量适配任务 |
| [conversation-design.md](conversation-design.md) | 对话卡片/抽屉、工具参数、选择引用、历史结果 |
| [engineering-blueprint.md](engineering-blueprint.md) | 详细工程方案:装配、工具/UI、核心复用、构建和实施切片 |
| [design.md](design.md) | 总体架构、模块边界、复用依据 |
diff --git a/openspec/changes/add-dsh-native-plugin/acceptance.md b/openspec/changes/add-dsh-native-plugin/acceptance.md
index 5fd0a7348..fd53095bf 100644
--- a/openspec/changes/add-dsh-native-plugin/acceptance.md
+++ b/openspec/changes/add-dsh-native-plugin/acceptance.md
@@ -142,3 +142,39 @@ flowchart LR
`pnpm add` 不能单独证明严格 peer 校验有效。本轮追加 `dsh plugin --profile install --strict-peer-dependencies --ignore-scripts` 与独立 npm `--strict-peer-deps` 安装,并验证冲突时正常失败。
交付跟踪见 [PR #988](https://github.com/iflytek/skillhub/pull/988)。本轮未发布 npm、创建 tag、调用模型或修改生产环境;公开插件 `0.3.0` 不包含此候选兼容修复。
+
+
+## 使用此技能:候选验收门禁
+
+| 编号 | 输入与动作 | 应观察结果 | 当前状态 |
+| --- | --- | --- | --- |
+| USE-01 | 已安装卡片、详情、安装成功分别点击使用 | 三处共用本地核验与原生草稿衔接;不自动发送 | 通过:五处原生入口及统一服务 |
+| USE-02 | 市场 slug 与 SKILL.md name 不同 | 调用真实 name;不取标题或 slug 猜测 | 通过:双 RC 原生技能真名 |
+| USE-03 | 草稿已有文字、引用与附件;包含非折叠选区 | 既有内容保留,在原生捕获位置末端插入;仍可编辑和手动发送 | 通过:选区行为测试;原生引用和图片保留 |
+| USE-04 | 核验期间编辑草稿、切会话/来源/工作区、卸载组件 | 比较或上下文检查失败,无跨会话污染、无自动重试覆盖 | 通过:行为测试及原生会话/草稿竞态 |
+| USE-05 | 本地资源改变、同名 Provider 覆盖、禁止用户调用、记录删除 | 明确阻止,草稿和技能文件不变 | 通过:行为测试;原生资源改动及同名覆盖 |
+| USE-06 | 从已升级或已卸载的历史安装回执使用 | 不静默替换版本;要求选择当前安装 | 通过:精确版本/范围行为测试;当前回执实机 |
+| USE-07 | 两个支持 RC 独立 Profile 加载同一冻结候选 | 实际 Host RPC/原生编辑器交互通过,无脚本异常、模型请求或消息提交 | 通过:双 RC 各 12 项原生浏览器检查 |
+
+设计见 [use-skill-design.md](use-skill-design.md)。详细结果见 verification.md。“通过”指各项规定层级的组合验收,不表示所有异常分支都已逐一人工浏览器操作;本功能尚未发布到 npm。此处不扩大到真实模型技能执行、SaaS、桌面或原生 Windows/macOS 验收。
+
+
+## 固定技能案例文件:开源验收
+
+已确认 `.skillhub/examples.json` 为上传、下载和安装后保持一致的唯一案例文件,随技能版本更新。协议合法则自动识别,不合法则在通过通用安全检查后按普通文件保留;不新增 Web 编辑器或独立缓存。详细行为与验收由 [使用设计](use-skill-design.md#技能专属使用案例固定文件约定) 和 [正式场景](specs/dsh-native-experience/spec.md#requirement-versioned-skill-examples-at-one-fixed-package-path) 定义。
+
+协议 schema、接口投影、内部 metadata 上传策略和旧消费者发布策略已在 skill-examples-design.md 定义;开源跨端功能、真实新旧消费者与双RC案例选择已通过本地验收,具体范围和平台缺口见 verification.md 的固定案例开源实现记录。SaaS 后续独立执行,见 tasks.md §40.9。
+
+
+## 输入框内联案例交互:本轮门禁
+
+| 编号 | 动作 | 预期 |
+| --- | --- | --- |
+| USE-N01 | 技能中心的卡片、详情及安装成功点击使用 | 同一工作区仅创建一个新对话,展开内联案例;旧会话内容、引用、附件不变 |
+| USE-N02 | 对话工具回执点击使用 | 留在原会话,展开同一内联案例区,无新对话 |
+| USE-N03 | 取消、选择后确认、自定义与无案例 | 展开不写草稿;取消不写不删;确认后可编辑,无案例不显示空选项 |
+| USE-N04 | 快速重复、创建失败、切会话/工作区/来源/语言、确认期间编辑 | 明确失败或撤销,旧结果不抢回导航或污染其他草稿 |
+| USE-N06 | 满8份非空草稿、回访、清空、会话移除、创建失败、插件卸载 | 新建前限额拒绝,不驱逐;官方retain按生命周期准确释放;不承诺跨刷新持久化 |
+| USE-N05 | 双支持RC安装同一候选并实际浏览器操作 | 官方API与slot可用,核验精确原安装范围/版本、草稿附件保留,零消息发送和模型调用 |
+
+本轮组合门禁已通过,结果见 verification.md 的“原生新对话与输入框案例交互”。USE-N01–N03、N05 的正常交互与 USE-N04 的重复点击、切会话、确认期间编辑,均在两版真实浏览器验证;创建失败、工作区/来源/语言失效、无案例和 USE-N06 的容量/清理分支由行为测试与独立评审覆盖。不是所有边界都逐一执行了浏览器操作,历史弹窗结果不替代本轮。
diff --git a/openspec/changes/add-dsh-native-plugin/contracts/skill-examples.empty.json b/openspec/changes/add-dsh-native-plugin/contracts/skill-examples.empty.json
new file mode 100644
index 000000000..05ec96f85
--- /dev/null
+++ b/openspec/changes/add-dsh-native-plugin/contracts/skill-examples.empty.json
@@ -0,0 +1,7 @@
+{
+ "schemaVersion": 1,
+ "defaultLocale": "zh-CN",
+ "examples": {
+ "zh-CN": []
+ }
+}
diff --git a/openspec/changes/add-dsh-native-plugin/contracts/skill-examples.schema.json b/openspec/changes/add-dsh-native-plugin/contracts/skill-examples.schema.json
new file mode 100644
index 000000000..4a49d3a5a
--- /dev/null
+++ b/openspec/changes/add-dsh-native-plugin/contracts/skill-examples.schema.json
@@ -0,0 +1,61 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "SkillHub packaged task examples v1",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schemaVersion",
+ "defaultLocale",
+ "examples"
+ ],
+ "properties": {
+ "schemaVersion": {
+ "const": 1,
+ "type": "integer"
+ },
+ "examples": {
+ "type": "object",
+ "minProperties": 1,
+ "maxProperties": 10,
+ "propertyNames": {
+ "pattern": "^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$"
+ },
+ "additionalProperties": {
+ "type": "array",
+ "maxItems": 20,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "id",
+ "title",
+ "prompt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "maxLength": 64,
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
+ },
+ "title": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 80,
+ "pattern": "[^\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]"
+ },
+ "prompt": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 4000,
+ "pattern": "[^\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]"
+ }
+ }
+ }
+ }
+ },
+ "defaultLocale": {
+ "type": "string",
+ "pattern": "^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$"
+ }
+ }
+}
diff --git a/openspec/changes/add-dsh-native-plugin/contracts/skill-examples.valid.json b/openspec/changes/add-dsh-native-plugin/contracts/skill-examples.valid.json
new file mode 100644
index 000000000..bb6b201ff
--- /dev/null
+++ b/openspec/changes/add-dsh-native-plugin/contracts/skill-examples.valid.json
@@ -0,0 +1,30 @@
+{
+ "schemaVersion": 1,
+ "defaultLocale": "zh-CN",
+ "examples": {
+ "zh-CN": [
+ {
+ "id": "meeting-summary",
+ "title": "整理会议纪要",
+ "prompt": "请使用 {{skill}} 整理会议记录,提取结论和待办。"
+ },
+ {
+ "id": "compare-proposals",
+ "title": "对比方案",
+ "prompt": "请使用 {{skill}} 对比两份方案,列出差异、风险和待确认问题。"
+ }
+ ],
+ "en": [
+ {
+ "id": "meeting-summary",
+ "title": "Summarize meeting notes",
+ "prompt": "Use {{skill}} to summarize meeting notes and extract action items."
+ },
+ {
+ "id": "compare-proposals",
+ "title": "Compare proposals",
+ "prompt": "Use {{skill}} to compare two proposals and identify differences, risks, and open questions."
+ }
+ ]
+ }
+}
diff --git a/openspec/changes/add-dsh-native-plugin/dsh-integration-design.md b/openspec/changes/add-dsh-native-plugin/dsh-integration-design.md
index 1d4751230..7417c581b 100644
--- a/openspec/changes/add-dsh-native-plugin/dsh-integration-design.md
+++ b/openspec/changes/add-dsh-native-plugin/dsh-integration-design.md
@@ -165,3 +165,19 @@ DSH 官方接口与机制参考:
首版单一版本基线、依赖锁定、能力检测及平台验收遵循 [DSH 兼容性设计](compatibility-design.md)。
+
+
+## 原生技能草稿衔接
+
+“使用此技能”通过已声明的 `@deepseek-ai/dsh-client-ui-conversation` Client 模块,读取 session/session-maybe slot 标准 `inputActions`。两版支持基线均提供 `captureInsertion()`、带 revision 比较的 `insertText()` 及 `conversation.input.for(scope).focus()`;不使用新 RC 专有草稿持久化接口。该 Client 注入同步加入运行时兼容清单,沿用现有同版校验,不增加 Host runtime peer。
+
+技能中心与工具视图绑定原生输入动作;会话输入框贡献负责内联案例选择和确认后的聚焦,不增加任务发送按钮或替换宿主编辑器;技能中心入口通过官方 API 在同工作区新建对话,对话内入口不新建。会话、绑定、来源、工作区与组件生命周期在异步核验前后检查;不能将仍被历史视图持有的会话当成当前主会话。没有原生服务时显示不可用原因,不查找 DOM 或用模拟输入兜底。产品时序和验收见 [使用此技能](use-skill-design.md)。
+
+
+### 新对话与内联案例的官方接缝
+
+技能中心通过当前会话所属工作区创建全新会话,避免宿主可复用空会话中已有草稿被污染。使用 `sessions.create({workspaceId,cwd})` 创建、`sessions.retain(...).ready` 等待可用、`uiWorkspace.openSession` 导航;新会话临时 retain 在完成或失败时释放,主视图接管正常会话生命周期。原会话有草稿/附件时需独立保留完整原生 shell,按返回接管、清空、作用域失效和插件卸载释放,具体限额与失败行为见使用设计。原会话及工作区变化时停止,不能把 `startSession` 可能复用的空会话当成新建成功。
+
+案例区挂载于官方 `conversation.input.dock`,为原生输入框上方的全宽贡献。原 `conversation.input.left` 继续仅承担动作绑定与确认后聚焦。案例状态由常驻控制器管理,不能留在技能卡片或安装弹窗中,否则技能中心卸载会丢失待选项。`ui-workspace`、`api-workspace-controller` 只作为开发类型依赖,运行时由宿主提供,不复制宿主会话控制器。
+
+上述官方接口按两版支持 RC 的本地发行源码核对;实际加载与交互仍须由本轮双基线浏览器结果证明,不因类型检查通过而视为验收。
diff --git a/openspec/changes/add-dsh-native-plugin/implementation-design.md b/openspec/changes/add-dsh-native-plugin/implementation-design.md
index 3f9c4b06a..c92da1e47 100644
--- a/openspec/changes/add-dsh-native-plugin/implementation-design.md
+++ b/openspec/changes/add-dsh-native-plugin/implementation-design.md
@@ -269,3 +269,8 @@ getOperation 对已完成的安装、升级增加当前会话的只读发现观
## 对话安装去除详情展示前置
`conversation-selections.ts` 区分真实展示关联与后台核验的安装快照;`index.ts` 的 `installConversation` 在原请求去重与事务准备内解析安装快照,未展示时调用精确版本读取,完成后重新校验实际上下文。`search-tool.ts` / `install-tool.ts` 仅更新模型指引,不新增参数、工具或公共 Remote。测试覆盖未展示、已展示、版本不漂移、引用失效、上下文竞争、并发核验与失败重试。安装事务、归档验证和恢复机制保持不变。
+
+
+## 技能专属案例:本轮执行范围
+
+固定文件、多语言对象及技能占位符协议见 [案例实现设计](skill-examples-design.md)。本轮的开源模块边界、P0–P5实施顺序、验收矩阵及SaaS后续同步统一由 [案例执行方案](skill-examples-execution-plan.md) 定义,不复用旧外置缓存或独立刷新设计。后续执行不改变本文件中此前已交付能力的事实状态。
diff --git a/openspec/changes/add-dsh-native-plugin/interface-design.md b/openspec/changes/add-dsh-native-plugin/interface-design.md
index 6724dc061..6b327ec83 100644
--- a/openspec/changes/add-dsh-native-plugin/interface-design.md
+++ b/openspec/changes/add-dsh-native-plugin/interface-design.md
@@ -360,3 +360,27 @@ accepted 不表示安装完成。工具立即返回 accepted 及 operationId,
保留 `checkUpdate` 与 `prepareUpgrade` 的原签名及“检查最新版”行为,新增 Agent-scoped `checkVersionUpdate(sourceRevision,itemKey,version,signal)` 和 `prepareVersionUpgrade(sourceRevision,itemKey,version,signal)`。两组接口共享当前库存重读、完整目标基线、版本比较、文件修改保护与确认计划;定向接口必须按指定 version 解析,响应版本不一致即拒绝,不使用 latest 替换不可用版本。执行仍只提交原 executePlan,无新文件写入接口或 SaaS 安装接口。
详情按 source/session/itemKey/所选 version 隔离只读检查缓存,切换仅触发读取。较新且可核验的版本显示升级到该版本,相同版本不重复显示升级,旧版本或不可排序版本明确阻断。确认使用捕获的库存项与精确目标,不因后台库存刷新重建另一升级意图;升级完成使库存失效,旧 itemKey 的检查结果不会绑定到新记录。
+
+
+### 使用此技能:只读当前会话核验
+
+`@RemoteScope('agent') prepareSkillUse(sourceRevision, itemKey, signal)` 接受当前来源 revision 和 opaque 安装记录键。返回 `SkillUsePreparation`:
+
+```ts
+type SkillUsePreparation =
+ | { status: 'ready'; name: string; version: string; scope: 'user' | 'project'; examples: SkillExamples }
+ | { status: 'blocked'; reason: SkillUseBlockReason };
+type SkillUseBlockReason =
+ | 'installation-unverified' | 'operation-pending' | 'shared-installation'
+ | 'files-changed' | 'skill-unavailable' | 'skill-shadowed'
+ | 'user-invocation-disabled' | 'unsupported-name' | 'host-unavailable';
+```
+
+`name` 来自已核验本地 SKILL.md 与当前 DSH 胜出技能,禁止使用市场 slug 或远程说明猜测。来源/会话/范围或记录变化沿用 context-stale;中止、服务失效不降级为未经核验的调用。只读检查不创建安装/升级/卸载计划或操作,不写文件,不下载,不注册模型工具。没有实际调用权时 Client 显示明确原因并保留草稿。技能中心新对话的 itemKey 必须由该新会话的 listInstalled 重新取得并匹配原来源、坐标、版本和范围;对话入口沿用原会话核验。案例选择不增加新 Host Remote,使用官方 conversation.input.dock 贡献,详见使用设计。
+
+Client 使用原生 `InputActions.captureInsertion()` 和 `insertText(text, span)` 的 revision 比较;保留选中文本时在捕获位置末端插入,不用 clipboard 字符长度计算编辑坐标。普通任务文字在前,实际 `/name` 以空白分隔嵌入,避免自动创建 slash command claim。既有 claimed/submitting 等草稿不改写。完整交互与时序见 [使用设计](use-skill-design.md)。
+
+
+## 固定技能案例协议
+
+案例直接随技能包分发,已安装使用读取 `.skillhub/examples.json`;不新增案例查询端点或数据库权威内容。现有精确版本 file 接口可返回原始文件流,不能按 ApiResponse.data 解析。prepareSkillUse 的 ready 返回值扩展有界案例投影,发布预检扩展可选识别摘要;精确定义和状态规则统一见 [案例实现设计](skill-examples-design.md#3-本地读取与原生草稿衔接)。既有 Remote 参数及不自动发送边界保持。扩展已在开源代码实现,旧 API 响应允许缺少预检字段并按未知处理;SaaS 需后续同步。
diff --git a/openspec/changes/add-dsh-native-plugin/lifecycle-design.md b/openspec/changes/add-dsh-native-plugin/lifecycle-design.md
index eaaa1545e..b7d51c43e 100644
--- a/openspec/changes/add-dsh-native-plugin/lifecycle-design.md
+++ b/openspec/changes/add-dsh-native-plugin/lifecycle-design.md
@@ -280,3 +280,8 @@ Inventory 变更必须在 mutateAtomic 的最新快照里比较旧身份、版
### 定向升级补充
用户在详情选中精确版本时,先通过 checkVersionUpdate 核验升级方向,再通过 prepareVersionUpgrade 固定候选;最新版检查与计划仍使用原接口。固定候选不可用或版本不符时阻断,不回退 latest。用户确认的计划列出全部共享库存目标,执行使用原锁定基线与候选快照。较旧与不可排序版本不允许覆盖安装;取消计划不写文件。
+
+
+## 固定技能案例文件(待实施补充)
+
+`.skillhub/examples.json` 为版本化作者内容;无论协议是否可识别,都保留原始字节并参与 manifest、指纹、升级/删除保护与恢复。只排除安装器内部 `.skillhub/metadata.json`,不能继续跳过整个目录。上传、安装器内部记录保护与普通作者文件处理统一见 [案例实现设计](skill-examples-design.md#5-发布内容与内部安装记录),旧版消费者不透明兼容的事实和分阶段发布约束见该文 §6。当前共享核心仍待修改;此前通过的生命周期测试不代表新案例文件已受支持。
diff --git a/openspec/changes/add-dsh-native-plugin/product-design.md b/openspec/changes/add-dsh-native-plugin/product-design.md
index f293eab9d..2057b02fc 100644
--- a/openspec/changes/add-dsh-native-plugin/product-design.md
+++ b/openspec/changes/add-dsh-native-plugin/product-design.md
@@ -488,3 +488,10 @@ Skeleton沿用局部shadcn组件风格和DSH主题变量,不引入新依赖或
切换版本是查看,不自动改变安装。已有安装时,查看的精确版本比当前版本新且通过来源核验,操作区显示“升级到 vX”;相同版本仅显示已安装,较旧版本说明不支持降级,不提供覆盖安装;不可可靠比较的版本阻断自动升级。不可访问版本不回退到最新版。
升级使用原地确认弹窗,展示旧版本→精确新版本及实际受影响的全部范围;当前底层共享库存升级可能同时更新用户和当前项目的安装,不能暗示只更新下拉框选中范围。存在当前会话不能管理的其他目标时仍整体阻断。市场和已安装列表的检查更新继续面向最新版,也使用同一精简弹窗。取消不修改文件;确认后显示处理与结果,异常保留原请求核验和恢复。
+
+
+## 使用此技能
+
+已安装卡片、所选范围的已安装版本详情以及安装成功结果提供小尺寸“使用此技能”按钮。技能中心入口在同工作区新建对话,对话入口保留当前会话;进入目标对话并核验后,在原生输入框上方展开案例区。选择并确认后保留目标草稿、附件和引用,加入可编辑的技能调用及任务引导,由用户发送。核验失败就地说明原因,不把“安装完成”当成“当前会话可用”。
+
+市场详情切换到其他版本不更换本地调用对象;本期只在所看版本等于该范围安装版本时展示使用入口。历史安装回执重新读取当前同版本记录,不静默使用升级后的其他版本。无活动会话不新建、不切换到历史会话。具体入口、状态和异常交互由 [使用设计](use-skill-design.md) 定义。
diff --git a/openspec/changes/add-dsh-native-plugin/saas-agent-prompt.md b/openspec/changes/add-dsh-native-plugin/saas-agent-prompt.md
index e38c25ed6..afb7ce559 100644
--- a/openspec/changes/add-dsh-native-plugin/saas-agent-prompt.md
+++ b/openspec/changes/add-dsh-native-plugin/saas-agent-prompt.md
@@ -2,6 +2,8 @@
将以下正文交给拥有 SaaS 仓库访问权限的研发 Agent。仓库、测试环境、公开测试技能及部署权限由 SaaS 项目提供;示例值不是实际配置。正式契约以本文引用的 OpenSpec 和插件实际消费代码为准。
+> 新增的固定案例文件适配是独立增量任务,见 [技能案例SaaS交接](skill-examples-saas-agent-prompt.md)。已有Registry能力先核对复用,不因本次案例需求重做本篇全部任务。
+
---
你负责在 SkillHub SaaS 中核验并补齐原生 DSH 插件所需的 Registry HTTP API,使同一个插件包能够连接开源版或 SaaS 实例,完成匿名浏览、搜索、查看精确版本详情、下载以及在 DSH 主机上的安装管理。
diff --git a/openspec/changes/add-dsh-native-plugin/skill-examples-design.md b/openspec/changes/add-dsh-native-plugin/skill-examples-design.md
new file mode 100644
index 000000000..4be789a3d
--- /dev/null
+++ b/openspec/changes/add-dsh-native-plugin/skill-examples-design.md
@@ -0,0 +1,202 @@
+# 技能使用案例:协议与实现设计
+
+本文承接 [使用设计](use-skill-design.md#技能专属使用案例固定文件约定),定义本项目的固定案例契约。开源实现与本地验收已完成,结果见 [验证记录](verification.md#固定技能案例协议开源实现验收);SaaS 后续适配。WorkBuddy 本机多案例文件提供交互与内容组织参考,不是本项目协议或兼容声明。
+
+## 1. 文件协议
+
+固定相对路径为 `.skillhub/examples.json`,随技能版本发布并原样安装,仅一份文件。作者不修改 SKILL.md,不在 Web 填写额外配置。示例:
+
+```json
+{
+ "schemaVersion": 1,
+ "defaultLocale": "zh-CN",
+ "examples": {
+ "zh-CN": [
+ {
+ "id": "meeting-summary",
+ "title": "整理会议纪要",
+ "prompt": "请使用 {{skill}} 整理会议记录,提取结论和待办。"
+ },
+ {
+ "id": "compare-proposals",
+ "title": "对比方案",
+ "prompt": "请使用 {{skill}} 对比两份方案,列出差异、风险和待确认问题。"
+ }
+ ],
+ "en": [
+ {
+ "id": "meeting-summary",
+ "title": "Summarize meeting notes",
+ "prompt": "Use {{skill}} to summarize meeting notes and extract action items."
+ },
+ {
+ "id": "compare-proposals",
+ "title": "Compare proposals",
+ "prompt": "Use {{skill}} to compare two proposals and identify differences, risks, and open questions."
+ }
+ ]
+ }
+}
+```
+
+规范性 schema 为 [skill-examples.schema.json](contracts/skill-examples.schema.json),有效样例见 [多案例](contracts/skill-examples.valid.json) 和 [空集合](contracts/skill-examples.empty.json)。这两份样例是协议文档素材,不是运行时默认案例,不自动写入用户技能。
+
+| 项目 | 规则 |
+| --- | --- |
+| 编码/体积 | UTF-8,最多 65,536 原始字节;允许开头单个 UTF-8 BOM,解析时去除,存储和指纹不改字节 |
+| 根结构 | schemaVersion、defaultLocale、examples 三个字段;schemaVersion 必须为整数 1 |
+| examples | 语言标签到有序案例对象数组的映射,1–10种语言;每种0–20条,同一语言内 id 唯一 |
+| 单条对象 | 必含 id、title、prompt,不接受旧字符串条目;不把数字、null 等自动转成字符串 |
+| id | 1–64个ASCII字符,小写字母/数字以连字符分段;同一语言内唯一,同一逻辑案例的不同语言版本使用相同id |
+| title / prompt | title为1–80、prompt为1–4,000个Unicode码点,均至少含一个非空白字符;空白由schema显式码点定义,不依赖语言的 \s/\S |
+| 顺序 | 保留作者顺序,不按评分、语言或模型推测重排 |
+| 语言 | 使用语言标签,如 zh-CN、zh-TW、en、ja、ko;支持范围为 schema 限定的基础语言及子标签,不声明支持所有 BCP 47 扩展;不自动翻译或识别文本语言 |
+| 内容使用 | 仅以纯文本展示和带入草稿,不渲染不可信 HTML,不执行脚本或工具指令,不打开模板中的 URL |
+| 扩展 | 未知字段或协议版本按不支持处理,不静默取部分内容;将来扩展需升级协议与消费者 |
+
+语言选择规则:对宿主提供的界面语言和作者标签做规范化,依次匹配完整标签、逐级父标签,最后使用 defaultLocale。例如 zh-Hant-TW → zh-Hant → zh → defaultLocale,不能把 zh-TW 自动当成 zh-CN。若宿主未提供可用语言,直接使用作者默认语言,不读取浏览器语言覆盖 Host 上下文。同一文档内规范化后重复的语言键不合法;defaultLocale 必须对应实际声明的键。这两项及各语言集合内 id 唯一性为 schema 之外必须执行的语义校验。首期不增加语言切换控件。
+
+命中的语言数组若明确为空,直接视为没有案例,不继续跨语言兜底。一次只展示一个语言集合,不混排中英文;语言切换使旧选择失效。文件64KiB上限覆盖全部语种,单条长度与数量规则按各语言数组执行。允许某案例只提供部分语言,不要求各语言数组数量一致;不得用数组位置匹配翻译。
+
+超出案例解析体积、格式不合法或结构不合法,只意味着不识别为案例;通过通用包策略的原始文件仍保留。违反通用包大小、路径或安全规则仍按原规则拒绝。不能因案例识别失败就跳过文件哈希。
+
+### 技能占位符与插入文本
+
+`prompt` 仅支持精确占位符 `{{skill}}`,代表核验后的 DSH 原生调用(例如 `/document-helper`)。实际值来自当前会话胜出技能的真实名称,不使用市场 slug、展示名称或作者传入路径猜测。`id`、`title` 不做占位替换。
+
+- 有占位符:逐字替换所有精确的 `{{skill}}`;不解析模板表达式、过滤器或任意变量。
+- 无占位符:在 prompt 前补一行包含已核验原生调用的引导,再接作者任务文本;若现有草稿已含相同原生调用,可省略重复引导,但仍须插入明确选择的任务。
+- 其他类似 `{{...}}` 的文本保持字面值,不执行、不请求模型补值;本期没有其他可用变量。
+- title用于卡片标题,prompt作为纯文本预览与最终任务内容,不能把title当执行指令。
+
+## 2. 解析与数据状态
+
+共享 TypeScript 解析器由 CLI 核心提供,插件通过已有内置核心消费,避免 CLI 与 DSH 自建两套规则。服务端预检按相同 schema/样例识别摘要;不增加 JSON Schema 运行时生产依赖作为前提。
+
+| 状态 | 触发条件 | 产品行为 |
+| --- | --- | --- |
+| ready | 文件符合支持的协议,保留原始摘要与全部语言集合 | 按宿主语言选择;有条目时展示案例列表和自定义任务,命中空数组时直接自定义使用 |
+| none | 确认 ENOENT | 不展示空选择区,直接自定义使用 |
+| invalid | 可读取,但 JSON、结构、版本、编码或解析上限不符合协议 | 不展示案例,普通文件保留;作者预检给非阻断提示,使用端保留自定义入口 |
+| unavailable | I/O、权限或读取中断,无法确认内容 | 提示暂时无法读取;只有既有技能使用核验通过才允许自定义,不能伪装为无案例 |
+
+不把取消或来源/会话失效作为 none 返回。文件完整性失败、符号链接或安装身份失效继续阻止使用,不能利用自定义入口绕过。
+
+## 3. 本地读取与原生草稿衔接
+
+```mermaid
+sequenceDiagram
+ actor U as 用户
+ participant C as DSH 插件 Client
+ participant H as 插件 Host
+ participant K as 共享 CLI 核心
+ participant I as DSH 原生输入框
+ U->>C: 使用此技能
+ alt 技能中心入口
+ C->>I: 官方创建同工作区新对话并导航
+ else 对话入口
+ C->>I: 保留当前对话
+ end
+ C->>H: prepareSkillUse(sourceRevision, itemKey)
+ H->>K: 核验所选安装与全部发布文件,读取固定案例文件
+ K-->>H: 原始文件摘要与解析状态
+ H->>H: 核验当前会话胜出技能并复查安装
+ H-->>C: ready + examples 状态
+ alt 有案例
+ C-->>U: 输入框上方展示案例卡片与自定义入口
+ U->>C: 选择案例,确认填入
+ else 无适用案例
+ U->>C: 自定义使用
+ end
+ C->>I: 捕获当前草稿插入位置
+ C->>H: 再次 prepareSkillUse,核验相同安装和案例摘要
+ H-->>C: 当前核验结果
+ C->>I: 比较草稿版本并插入真实技能调用与任务文本
+ Note over U,I: 保留文字、引用与附件,不自动发送
+```
+
+复用现有 agent-scoped prepareSkillUse 的参数和上下文校验,扩展 ready 返回值,不新增模型工具或任意路径读取 RPC。返回类型如下:
+
+```ts
+type SkillExample = { id: string; title: string; prompt: string };
+type SkillExamples =
+ | { state: 'ready'; digest: string; defaultLocale: string; items: Record }
+ | { state: 'none' }
+ | { state: 'invalid' }
+ | { state: 'unavailable'; retryable: boolean };
+// 现有 ready.name/version/scope 保留,新增 examples: SkillExamples。
+```
+
+`digest` 为案例原始字节 SHA-256,摘要不作为安装权限。Host 返回全部有界语言集合,Client 按宿主界面语言选中集合;确认状态须保持所选 locale 一致。Client 只保留所选安装标识、摘要、语言和案例 id;最终核验重新返回同摘要,且宿主界面语言未变后,使用当前返回的所选语言集合中该 id 的 prompt,不信任第一次响应中的陈旧文本。模板选择期间安装、来源、范围或会话变化立即使选择失效;摘要不匹配不得自动替换为新案例。
+
+点击选择面板的“填入输入框”才捕获当前插入位置;初次打开面板不改草稿。最终异步核验期间用户修改草稿则比较失败,保留其内容并提示重试。即使真实技能调用已存在,用户明确选择的新模板仍应可带入,不能沿用旧逻辑“发现调用 token 就忽略全部插入”;重复提交同一次动作仍需去重。
+
+案例读取需在可信目标路径下执行,限制字节、拒绝符号链接,与前后文件快照绑定;不能在完整性复查后另读未经核验的版本。传输只返回有界文本,不返回本地目录、内部 metadata 或文件读写能力。
+
+## 4. Registry 与 Web 边界
+
+技能安装时案例随 ZIP 获取,不单独下载或刷新。已安装使用路径不请求市场最新案例,不新增案例表、可变服务端配置或专用 `/examples` 接口。
+
+既有原始文件接口可供作者和版本文件浏览使用:
+
+```text
+GET /api/v1/skills/{namespace}/{slug}/versions/{version}/file?path=.skillhub%2Fexamples.json
+```
+
+响应是原始文件流,不是 ApiResponse.data JSON。路径分段与 query 需分别编码,权限和版本可见性沿用既有文件接口。本期不新增安装前案例选择或远程模板预览。
+
+现有服务端部分缺失/对象存储读取错误都映射为 HTTP 400;不得把任意 HTTP 400、404 或网络错误自动归类为没有案例。本期本地使用无需修改该通用接口错误语义。
+
+Web 复用现有发布预检,不增加表单。为实现非阻断作者提示,现有 SkillPackagePreviewResponse 增加可选 `examplesStatus: 'ready' | 'none' | 'invalid'` 和 `examplesCount`;这两个字段为向后兼容的可选扩展。examplesCount统计全部语言集合的条目总数;合法文件总数大于0为ready,合法全空或无文件为none且count=0,invalid不返回count,旧响应缺字段表示未知。此预检状态不等于某个界面语言实际可选条数。只返回识别摘要,不在预检返回第二份案例权威内容;旧响应缺少字段视为未知,不显示错误提示。OpenAPI 生成类型与消费方测试同步维护。CLI 以共享解析器对待发布文件作同样非阻断提示,不把提示混进 --json 的纯 JSON 输出。
+
+预检统计示例(与DSH实际选中语言的条目数区分):
+
+| 文件内容 | 预检结果 |
+| --- | --- |
+| zh-CN为空,en有1条 | ready,examplesCount=1;DSH选中zh-CN时仍不展示案例 |
+| zh-CN和en各1条同id翻译 | ready,examplesCount=2,不按逻辑id去重统计 |
+| 所有语言集合为空,或没有文件 | none,examplesCount=0 |
+| 文件无法识别为协议 | invalid,省略examplesCount;旧接口无摘要字段为未知 |
+
+## 5. 发布内容与内部安装记录
+
+| 路径/入口 | 规则 |
+| --- | --- |
+| `.skillhub/examples.json` | 合法或非法协议均按原始字节进入包、manifest、指纹与安装目录 |
+| `.skillhub/metadata.json` | 仅安装器生成,排除内容指纹;不接受作者上传的安装身份 |
+| `.skillhub` 其他安全作者文件 | 普通版本文件,不识别为案例,不静默删除;纳入发布清单和完整性校验 |
+| CLI 发布目录、sync push、Web 文件夹打包 | 遍历 .skillhub,仅排除根目录内部 metadata.json,保留作者文件 |
+| 手工 ZIP 或直接 HTTP 上传 | 服务端共享包验证器对规范化后的内部 metadata 路径明确拒绝,返回可操作错误;不能只依赖 Web 过滤 |
+| 下载/本地安装 | 生成内部记录前再次拒绝包内内部 metadata,避免旧来源绕过校验;失败不发布目标或 inventory |
+
+路径判定在包根目录归一化后进行,覆盖分隔符、`.`、重复目标、大小写别名及文件/目录冲突;在跨平台存在歧义时明确拒绝,不能误将作者文件映射成内部记录。禁止符号链接、越界和不安全文件的规则保持。不能用 substring 匹配剥掉所有叫 metadata.json 的资源。
+
+## 6. 指纹、恢复与兼容发布
+
+目前 CLI/shared-core 跳过整个 .skillhub,原生恢复检查也只接受 metadata.json。两处都必须改:指纹按所有作者原始文件计算,仅排除安装器内部记录;安全目录检查结合精确 manifest 保留所有者和普通文件检查,未知增加的文件继续被视为本地改动。所有恢复/清理调用点均须审计,不得简单移除 guard 后扩大删除范围。
+
+服务端与客户端必须采用一致的路径排序、SHA-256 和聚合格式,不新建单独“案例指纹”。套件和同步复用共享内容策略,不复制模板解析或生命周期管理逻辑。现有发布版本的 ZIP、manifest 和 fingerprint 不追溯重算。
+
+已静态核实正式插件 0.3.1 的内置核心仍跳过 .skillhub 且拒绝额外文件,因此它不具备新内容规则。不能承诺“旧插件透明支持带案例的包”。首期不新增服务端消费者协商协议,采用明确最低消费者要求与旧消费者安全拒绝策略:
+
+1. 先完成并发布支持新内容规则的 CLI 和 DSH 插件,再推广包含该文件的技能版本;实际最低版本号待发布时填写,不预设不存在的包版本。
+2. 新消费者必须兼容无案例的旧技能;历史含有被旧指纹排除的作者文件须先盘点。若存在,不得修改旧版本指纹或无提示重新认领,需单独迁移设计;未完成前不能宣称该历史子集受支持。
+3. 旧消费者遇新包允许在指纹/安全检查处拒绝,但必须验证目标与库存未被覆盖;不能声称旧包会显示新代码才有的友好提示。
+4. 新安装目录被旧消费者访问时,分别验证旧原生 DSH 的内容保护与旧 CLI 的实际行为。旧 CLI remove 原本直接删除目录,强制重装也可覆盖,不能承诺它们会因新文件而拒绝;混用这些旧命令不属于支持的管理方式,应先升级消费者。不得为适配旧版伪造 metadata 或减弱指纹。
+5. 代码发布与 npm 发布为后续独立操作,本轮完成开源实现和本地验证,不自动合并、部署或发布。
+
+## 7. 实施切片与验证
+
+| 切片 | 最小产物 | 必须验证 |
+| --- | --- | --- |
+| 协议与共享内容规则 | 共享解析器/路径策略、指纹、目录与 ZIP 处理 | 合法/非法文本均计入哈希;修改/删除/新增受保护;内部 metadata 不可上传 |
+| 服务端与发布前端 | 包验证、Web 文件夹过滤、预检识别摘要 | 目录/手工 ZIP/直接 API 一致;无效案例非阻断;生成类型一致 |
+| DSH 使用交互 | 复用 ready 投影、多案例选择与自定义 | 安装范围/版本一致、上下文与摘要复验、保留草稿附件、不自动发送 |
+| 跨消费者验收 | 新旧公开包/候选、完整生命周期与真实 Host | 新→旧包、新→新包、旧→新包、旧访问新安装目录的真实边界 |
+
+核心回归包括 install/retry/upgrade/remove/sync/publish/recovery/rollback/suite 与 Use;完整命令沿用 Makefile 的 lint-cli/typecheck-cli/test-cli/build-cli、插件 check 及 Web/后端受影响测试。具体文件与命令由本地执行计划链接当前源码测试,不把源代码审计当作端到端通过。
+
+schema 样例检查与运行时验收分别记录。生产 parser、预检可选字段、生命周期及双 DSH 基线的开源验收结果见 [验证记录](verification.md#固定技能案例协议开源实现验收);SaaS、Windows/macOS 原生宿主和 npm 发布不包含在本轮。
+
+
+实施顺序、开源与SaaS范围及验收门禁见 [执行方案](skill-examples-execution-plan.md);SaaS对应增量见 [专用交接Prompt](skill-examples-saas-agent-prompt.md)。
diff --git a/openspec/changes/add-dsh-native-plugin/skill-examples-execution-plan.md b/openspec/changes/add-dsh-native-plugin/skill-examples-execution-plan.md
new file mode 100644
index 000000000..f536cc041
--- /dev/null
+++ b/openspec/changes/add-dsh-native-plugin/skill-examples-execution-plan.md
@@ -0,0 +1,110 @@
+# 技能使用案例:执行范围、实施顺序与 SaaS 同步
+
+## 1. 目标与当前状态
+
+在 DSH 点击“使用此技能”后,用户可以选择该技能的本地版本案例,或自定义任务,再将包含真实技能调用的内容填入原生输入框。案例固定为技能包中的 `.skillhub/examples.json`,采用多语言 id/title/prompt 对象与 `{{skill}}` 占位符,随技能版本更新。
+
+协议唯一来源是 [案例实现设计](skill-examples-design.md) 与 [JSON Schema](contracts/skill-examples.schema.json),本文只确定执行范围、依赖和验收,不定义第二套协议。
+
+开源实现与本地验收已完成:共享核心和 CLI 保留随包案例并纳入指纹,开源后端/Web 提供非阻断预检反馈,DSH 支持多语言案例选择、末次核验与草稿衔接。完整检查、真实后端和双 DSH 浏览器结果见 [验证记录](verification.md#固定技能案例协议开源实现验收)。改动尚未提交或发布;SaaS 实施与联验后续另做,不作为本轮完成前置。
+
+## 2. 范围与责任
+
+| 模块 | 本轮开源研发范围 | 不在本轮范围 |
+| --- | --- | --- |
+| 作者协议 | 固定文件、字段/语种/占位符校验、可复制说明及样例 | 作者填写额外Web表单、修改SKILL.md、生成案例内容 |
+| 共享CLI核心 | 包路径策略、解析器、完整性指纹、内部记录保护;安装/升级/同步/恢复接缝 | 独立案例缓存、自动修复作者JSON、案例独立刷新 |
+| CLI | 目录发布与sync push保留作者文件,非阻断协议提示,正常管理带案例技能 | 新增案例管理命令、改变既有remove/force产品语义 |
+| 开源服务端 | 上传路径与内部metadata边界、发布预检识别摘要;核验版本文件/ZIP/指纹链路 | 新案例数据库表、案例编辑API、独立案例版本或CRUD系统 |
+| 开源Web | 文件夹打包精确过滤内部记录;现有发布页显示识别结果/非阻断提示 | 案例编辑器、语言配置页、市场案例展示新页面 |
+| DSH Host | 可信安装目录读取、解析状态与摘要投影、复用现有prepareSkillUse | 任意路径读取RPC、模型执行或新增服务端安装任务 |
+| DSH Client | 多案例面板、语言选择规则、标题/正文预览、自定义、占位符与原生草稿衔接 | 语言切换设置、自动翻译、自动发送、输入材料变量系统 |
+| SaaS | 本轮提供独立交接文档和一致性测试清单 | 本轮直接修改SaaS仓库、数据库、部署或生产内容 |
+| 发布 | 本轮确定依赖和门禁,研发后准备可审阅候选 | 自动提交/合并、tag、npm发布或上线 |
+
+共享核心改变后套件等现有消费者需要回归,但本轮不增加DSH套件产品能力。不将不同源手工技能纳入SkillHub案例管理。
+
+## 3. 实施顺序与交付切片
+
+```mermaid
+flowchart TD
+ A[P0 协议与历史内容基线] --> B[P1 共享核心与CLI内容规则]
+ A --> C[P2 开源服务端与Web发布链路]
+ B --> D[P3 DSH多案例与原生草稿]
+ B --> E[P4 跨端生命周期与旧消费者验收]
+ C --> E
+ D --> E
+ C --> S[SaaS Agent按同一协议适配测试环境]
+ E --> R[审阅候选与发布准备]
+ S --> T[SaaS测试环境与真实DSH联验]
+ R --> T
+ T --> P[授权后分阶段发布]
+```
+
+| 阶段 | 具体动作与产物 | 退出条件 |
+| --- | --- | --- |
+| P0 基线 | 固定schema/样例,记录实际检出基线;本地夹具建立旧包/新包/非法案例/内部记录冲突;确认宿主语言来源;盘点可访问历史包是否存在已被旧规则忽略的.skillhub作者文件 | 设计冲突解决;未知历史子集明确标注;不访问未授权生产数据 |
+| P1 共享核心与CLI | 统一根相对路径策略;指纹纳入作者文件、仅排除内部记录;解析对象/语言/占位符;修正目录发布和sync;审计恢复/回滚所有调用点 | 有效/无效案例字节均保留,未知新增/修改仍被保护;内部metadata不可伪造;CLI范围检查通过 |
+| P2 开源服务端与Web | 校验预览和发布共用内部记录拒绝规则;保留examples;现有预检响应增加可选识别摘要;Web目录打包精确过滤,显示非阻断提示;生成API类型 | 手工ZIP、文件夹、直接API一致;无案例旧包正常;存储清单/下载/resolve指纹一致 |
+| P3 DSH消费与交互 | prepareSkillUse扩展有界对象投影;使用界面语言→父语言→作者默认;面板标题/正文、按id选择、自定义;确认时复验摘要/语言/安装/草稿并替换真实调用 | 多入口共用,取消不写草稿;保留文字/引用/附件,不发送;语言与安装范围不串用 |
+| P4 端到端与兼容 | 使用实际打包候选和旧公开包完成兼容矩阵;真实本地Registry与双RC官方Host跑生命周期、浏览器及原生输入框 | 记录通过/失败/不支持,独立review无阻断问题;不以mock或schema检查代替产品验收 |
+| P5 交付准备 | 清理临时证据、回写OpenSpec与双语使用说明;提交/PR及包发布按后续授权执行 | 候选、检查记录和未验范围可审阅;不擅自发布 |
+
+P1和P2可在协议固定后分别实施,但必须使用同一组路径/字节夹具;P3可以先用受控解析夹具开发,P4必须依赖完整真实链路。先运行最小测试,再运行受影响模块完整检查。
+
+## 4. 开源代码接缝
+
+下表为已核对的当前文件,不要求为匹配规划而重命名或复制现有模块;新增helper位置由实现时的最小复用决定。
+
+| 责任 | 现有接缝 |
+| --- | --- |
+| 指纹与发布打包 | `cli/src/services/skill-fingerprint.ts`、`cli/src/commands/publish.ts`、`cli/src/services/sync-service.ts`、`cli/src/platform/archive.ts` |
+| 安装记录与安全恢复 | `cli/src/services/install-service.ts`、`cli/src/integrations/recovery-files.ts`、`upgrade-baseline.ts`、`native-installed.ts`及所有恢复调用点 |
+| 服务端路径/包检查 | `server/skillhub-domain/.../skill/validation/SkillPackagePolicy.java`、`SkillPackageValidator.java`,以及上传提取的包根归一化 |
+| 预检识别摘要 | `server/skillhub-app/.../service/SkillPackagePreviewService.java`、`dto/SkillPackagePreviewResponse.java` |
+| 版本内容核对 | `SkillPublishService`文件manifest/存储与`SkillQueryService.computeFingerprint`;现有服务端按全部SkillFile聚合,若已符合则只补测试,不重写算法 |
+| Web发布 | `web/src/features/publish/folder-zip.ts`、`web/src/pages/dashboard/publish.tsx`、现有查询hook/翻译资源、生成的API类型 |
+| DSH使用 | `plugins/dsh/src/host/index.ts`的prepareSkillUse、`host/types.ts`、`src/client/use-skill.tsx`及既有使用入口 |
+
+公开API新增字段仅限已有预检响应的可选识别摘要,旧响应缺字段保持兼容;精确返回字段见案例实现设计。已有版本文件接口已经返回原始内容流,安装后Use无需额外网络请求。实现如发现还需新端点、表或安装状态字段,先回到设计评审,不能扩大范围后再补文档。
+
+## 5. SaaS 后续同步:必须、条件项与无需调整项
+
+SaaS实际模块名称、数据结构和已部署行为需由SaaS Agent核实;下面是目标契约,不是已完成状态。
+
+| SaaS项目 | 必须达到的行为 | 是否必改代码 |
+| --- | --- | --- |
+| 文件接收和存储 | 不丢弃/改写.skillhub/examples.json;合法、非法协议均按普通安全文件保留 | 先对照,已符合则补验证 |
+| 预检和正式发布 | 同一规范化路径下,拒绝上传内部metadata;合法协议识别多语对象,非法仅非阻断提示;不能绕过扫描/审核 | 对照后补缺口 |
+| Web文件夹上传 | 只过滤根目录内部metadata,保留examples和其他允许作者文件;无案例编辑表单 | 若有目录上传或既有过滤则同步 |
+| 清单、ZIP、resolve指纹 | 作者文件全部覆盖且算法与新消费者一致,原始字节/版本/坐标对应 | 核验为必需,算法已一致不改 |
+| 精确版本文件读取 | 如已有通用file接口则复用,返回原始字节;权限/租户/可见性不变 | 现有能力充足不新增接口 |
+| 发布提示摘要 | 若复用开源Web预检体验,同步可选examplesStatus/examplesCount及OpenAPI类型;不得影响旧客户端 | SaaS有相同预检入口时实施 |
+| 本地技能管理/案例选择/占位替换 | 由CLI与DSH负责,SaaS不读取用户项目或主机目录 | 不新增服务端能力 |
+| 案例持久化 | 复用版本包文件、现有对象存储与文件清单 | 本期不新增案例表或数据迁移 |
+
+SaaS的发布、审核、删除和版本复制流程都应自然保留该文件;若存在技能包重建/过滤中间层,必须补齐该层,而非只在上传Controller加字段。原始文件API及对象存储URL不能变成绕过私有技能或跨租户隔离的通道。
+
+可直接交接的增量任务见 [SaaS案例适配Prompt](skill-examples-saas-agent-prompt.md)。旧的总体DSH交接见 [saas-agent-prompt.md](saas-agent-prompt.md),不要重复实施已经上线的通用Registry能力。
+
+## 6. 验收矩阵与完成定义
+
+| 层级 | 核心场景 | 可观察结果 |
+| --- | --- | --- |
+| 协议 | 多语对象、空集合、非法JSON/结构/语种/id、上限和未知版本 | 解析状态准确;非法内容不变成案例,也不因此丢失文件 |
+| 发布 | CLI目录、sync push、Web目录、手工ZIP、直接API | examples路径/字节保留;内部metadata精确过滤或明确拒绝 |
+| 本地生命周期 | install/retry/upgrade/remove/sync/rollback/recovery/suite | manifest/库存/目录一致;本地案例改动受保护,恢复不误删未知文件 |
+| UI | 多案例和自定义、语言回退/变化、占位符/无占位符、重复点击/取消 | 选择按id,真实name而非slug;保留草稿附件且无自动发送 |
+| 上下文 | 用户/项目、同名覆盖、不同Registry/版本、读取期间切会话 | 不串用、不插入陈旧任务,不因模板存在跳过可调用性核验 |
+| 消费者兼容 | 新消费者→旧无案例包、新→新案例包、旧→新包、旧访问新安装目录 | 用实际产物观察成功或拒绝/原行为,明确不支持混用,不假称全兼容 |
+| SaaS测试环境 | 发布→精确版本读取/下载→新DSH安装→选择案例→升级/卸载 | 真实目标环境全链路成立;不能仅以HTTP200或单测代替 |
+
+兼容发布顺序为:开源候选完成联验→授权发布支持新规则的CLI/DSH→SaaS适配测试环境联验→授权部署SaaS→推广带案例的新技能版本。SaaS研发可提前并行,不必等npm发布,但生产推广必须等兼容消费者可用。
+
+新旧包与旧二进制不能全量透明兼容。旧CLI的remove/force行为沿原语义,新设计不能改变已经发布的二进制;旧DSH拒绝的具体动作需实际验证。历史包字节、版本号和指纹不得回算。出现历史被忽略作者文件时保持独立迁移门禁,不自动修改旧安装记录。
+
+回退软件版本前先确认是否仍需管理新格式安装;优先停止推广新案例技能并保留已安装目录及可管理它的新消费者,不通过删除examples或伪造metadata来迁就旧客户端。SaaS应用回退不修改历史对象/文件清单。
+
+本功能的确定性验收以原生草稿与文件生命周期为主,不要求真实模型调用来证明模板填入正确;若增加模型执行体验验收,单独记录模型配置、额度和真实对话证据,不把模型输出当作文件兼容证明。
+
+完成定义:P1–P4实现与验证通过、文档/任务状态与证据一致、SaaS增量交接可执行。SaaS部署、npm发布及生产可用性为后续独立交付状态,不能被本地完成所代替。
diff --git a/openspec/changes/add-dsh-native-plugin/skill-examples-saas-agent-prompt.md b/openspec/changes/add-dsh-native-plugin/skill-examples-saas-agent-prompt.md
new file mode 100644
index 000000000..00f499753
--- /dev/null
+++ b/openspec/changes/add-dsh-native-plugin/skill-examples-saas-agent-prompt.md
@@ -0,0 +1,77 @@
+# SaaS Agent 增量任务:版本化多语言技能案例
+
+以下正文可以直接交给拥有SaaS仓库访问权限的Agent。当前任务是接入固定案例文件协议,不重做已有DSH Registry支持。先输出基于真实代码的差异表,再按SaaS项目流程研发、测试和提交PR;没有授权不要部署生产。
+
+## 目标与先读资料
+
+让同一个新SkillHub CLI/DSH候选能够从SaaS发布、下载并安装带`.skillhub/examples.json`的技能,原始文件完整保留,案例选择和真实技能占位符由DSH本地处理。
+
+开源权威资料:
+
+- `openspec/changes/add-dsh-native-plugin/skill-examples-design.md`:路径、协议、语言回退、内部记录与兼容边界。
+- `openspec/changes/add-dsh-native-plugin/contracts/skill-examples.schema.json`及valid/empty样例:唯一协议契约。
+- `openspec/changes/add-dsh-native-plugin/skill-examples-execution-plan.md`:范围、验收与阶段顺序。
+- `openspec/changes/add-dsh-native-plugin/registry-api-contract.md`:已有HTTP访问规则。
+
+开源工作分支已完成本功能及本地验收,但尚未提交或发布。移植前确认通过验收的最终开源提交和双方真实差异,以运行代码、测试和本文契约为依据;开源本地通过不代表 SaaS 已兼容。
+
+## 协议概要
+
+文件是技能包的版本化作者资源,不是安装后生成的缓存,不独立刷新。根包含schemaVersion、defaultLocale、examples;examples按语言标签分组,每条含id/title/prompt,同一逻辑案例跨语言同id。prompt中的精确`{{skill}}`由DSH使用核验后的真实调用替换,服务端不替换、不执行、不翻译。
+
+合法协议识别为案例;错误JSON、结构或不支持协议按普通文件保留,不阻断正常技能发布;通用包大小/路径/安全规则仍有效。缺失文件不影响旧技能。不要兼容此前讨论但已废弃的字符串数组、外置缓存或单独案例表方案。
+
+## 第一步:差异调查
+
+逐项给出“实际类/方法或路由、现有行为、缺口、拟改位置、测试方式”:
+
+1. ZIP提取和包根归一化、Windows路径/大小写及重复目标处理。
+2. .skillhub是否被目录上传、包重建、扫描输入、对象存储或下载链路排除。
+3. 文件manifest、每文件SHA、resolve聚合fingerprint和下载ZIP是否覆盖全部作者文件。
+4. 上传metadata是否被信任或覆盖,预检与正式发布是否走相同规则。
+5. 是否已有包预检摘要、原始文件流接口和可选字段向后兼容机制。
+6. 审核/复制/版本切换/删除中间层是否保留文件和访问隔离。
+
+不要猜测与开源同名类,也不要先创建表或新接口。已经符合的部分只补验证,不为对齐开源结构而重构。
+
+## 第二步:实现最小差异
+
+### A. 文件与内部记录
+
+- 原样保存`.skillhub/examples.json`,包括无法识别为协议但通过通用包校验的文件。
+- `.skillhub/metadata.json`仅安装器所有,不允许作者上传的身份进入发布包。Web目录打包可精确排除;手工ZIP/直接API在服务端归一化后明确拒绝,不静默覆盖。
+- 允许的其他作者文件按普通内容保留,不能整体过滤.skillhub或忽略所有JSON。
+- 不新增任意路径读取,不放宽路径穿越、符号链接、跨租户、私有/撤回/未审核访问规则。
+
+### B. 协议识别与Web体验
+
+- 按schema和补充语义验证:默认语言存在、规范化语言不重名、同语言id不重复、Unicode长度和字节上限一致。
+- 在现有预检区域展示“已识别案例”或简短非阻断格式提示,不增加案例编辑器或语言设置表单。
+- 若采用开源预检投影,新增可选examplesStatus/examplesCount,并同步OpenAPI/消费类型。count统一统计全部语言条目总数;合法非空为ready,合法全空或无文件为none且count=0,invalid省略count,旧响应缺字段视为未知;不只统计默认语言。
+- 预检输出不创建第二份服务端可变案例;源文件仍是唯一内容。
+
+### C. 分发与指纹
+
+- 复用现有版本文件和对象存储,不新增案例数据库表;若声称必须迁移先解释当前存储为何不能保存普通JSON文件。
+- resolve指纹覆盖的路径/哈希与新消费者快照一致,合法及非法案例都参与。保持既有算法,若已包含全部作者文件则不改。
+- 已有精确版本file接口足够时直接复用,返回原始字节,不加ApiResponse包装;本地Use不需要服务端新案例端点。
+- 缺失、读取失败、拒绝访问、限流或不可见不可混同;不要为新案例顺带重构通用错误码。实际语义如影响新消费必须单独列明。
+
+## 第三步:测试与联验
+
+使用隔离测试技能和新版本,不改现网作者内容。至少覆盖:
+
+- 无案例旧技能;中英文及第三语言;同id翻译/不同语言条数;默认回退与显式空集合。
+- 错误JSON/未知版本/重复id/过长文本按普通内容发布且字节不变;危险包仍被拒绝。
+- CLI目录、Web目录、手工ZIP、直接API;metadata路径别名、重复目标、大小写冲突和符号链接。
+- 发布审核后的文件清单、原始file读取、下载ZIP与resolve指纹逐一一致。
+- 私有、跨租户、撤回和不可见版本保持原规则。
+- 新CLI/DSH候选接测试Registry,完成安装→多案例选择→原生草稿→升级→卸载;用户/项目两个范围各验,不自动发送。
+
+旧消费者兼容不能用源码猜测通过。至少记录旧→旧、新→旧、新→新、旧→新包的实际结果;某些旧CLI删除/force会按原规则执行,不能承诺被新协议阻止。历史包指纹/ZIP不能回算;不支持的历史子集先列出,不自动迁移。
+
+## 交付与发布边界
+
+交付:差异表、SaaS PR、变更/无须改动说明、测试命令与结果、生成类型、实际测试环境版本、新插件候选信息及脱敏联验报告。对未执行平台、环境或模型测试明确标注,不报作通过。
+
+先完成测试环境验证。支持新内容的CLI/DSH正式可用、SaaS测试联验通过并获得授权后,再部署SaaS及推广新案例技能;不得因收到本Prompt就发布npm、部署生产或改历史版本。生产验收必须针对实际目标环境另行记录。
diff --git a/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md b/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md
index a8ea61574..1b4e938e7 100644
--- a/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md
+++ b/openspec/changes/add-dsh-native-plugin/specs/dsh-native-experience/spec.md
@@ -420,3 +420,135 @@ Selecting a version SHALL be read-only. For an installed skill, a newer verifiab
- **WHEN** any required installed target cannot pass the existing baseline or ownership checks
- **THEN** confirmation is blocked for the entire upgrade and no target is overwritten
- **AND** current installed state remains independently queryable
+
+
+### Requirement: Verified native skill-use handoff
+The plugin SHALL offer a common “使用此技能” action on installed cards, installed or matching-version skill details, and completed installation surfaces. It SHALL revalidate the exact current-Registry installed record, selected scope, local file integrity and effective native DSH skill in the active session before inserting an editable invocation into the native composer. It SHALL preserve the existing draft, references and attachments and SHALL NOT submit messages, call a model, execute a skill, install files or change skill enablement. See use-skill-design.md for interaction and architecture.
+
+#### Scenario: Installed market coordinate differs from native skill name
+- **WHEN** the selected installed record is valid and its parsed native skill name differs from the market slug
+- **THEN** the plugin inserts the verified native user invocation rather than guessing from the market slug
+- **AND** focuses the target session composer, leaving sending to the user.
+
+#### Scenario: Installed skill cannot be safely invoked
+- **WHEN** files or inventory have changed, the native winner belongs to another location, the selected skill is not discovered, or user invocation is disabled
+- **THEN** the action gives a concise actionable reason and leaves the draft and attachments untouched.
+
+#### Scenario: Context or draft changes during verification
+- **WHEN** the active session, workspace, source revision, composer or component lifetime changes, or native insertion comparison fails
+- **THEN** the late result does not overwrite or submit any draft, including a newly selected session.
+
+#### Scenario: Completed receipt is historical
+- **WHEN** use is requested from an installation-success surface
+- **THEN** the plugin rereads current inventory and matches source, coordinate, scope and version before preparing use
+- **AND** a removed or upgraded historical target is not silently substituted with another installed version.
+
+
+### Requirement: Versioned skill examples at one fixed package path
+SkillHub SHALL recognize task examples only at `.skillhub/examples.json` relative to the skill root. Upload, download and installation SHALL preserve this single file and its bytes as versioned skill content. Examples SHALL follow the selected installed version. The feature SHALL NOT require editing SKILL.md, a separate Web configuration editor, an external examples cache or an independently refreshed copy. Detailed schema and cross-layer lifecycle rules SHALL be defined before implementation; see use-skill-design.md.
+
+#### Scenario: An author uploads valid multiple examples
+- **WHEN** a package contains protocol-valid `.skillhub/examples.json` with multiple task templates
+- **THEN** the templates are associated with that exact skill version and remain at the same relative path after installation
+- **AND** selecting use offers applicable templates and a custom-task entry without automatically sending a message.
+
+#### Scenario: Example protocol is invalid or the file is absent
+- **WHEN** the fixed file is absent or has invalid JSON or an invalid example structure
+- **THEN** the skill remains usable through custom task entry without fabricated examples
+- **AND** an existing invalid file is retained as ordinary versioned skill content with a non-blocking publication notice, subject to all normal package safety checks.
+
+#### Scenario: A similarly named file or unsafe package is uploaded
+- **WHEN** JSON is supplied at another path or package content violates general safety rules
+- **THEN** other-path JSON is not interpreted as task examples and unsafe content follows existing rejection rules
+- **AND** uploaded content never overrides or gains the authority of installer-internal `.skillhub/metadata.json`.
+
+#### Scenario: Skill content passes through publishing and local lifecycle operations
+- **WHEN** supported CLI, Web and DSH consumers publish, install, retry, sync, upgrade, remove or recover a skill containing the fixed file
+- **THEN** package manifests, fingerprints, backups and directory checks consistently account for that file, including protocol-invalid ordinary content
+- **AND** local modifications retain existing overwrite protection rather than being ignored as cache changes.
+
+#### Scenario: Version or installation scope differs
+- **WHEN** a user selects a different installed version, source or user/project installation
+- **THEN** templates come from that exact selected installation, never from another version or scope
+- **AND** existing native invocation, current-session and draft checks still apply.
+
+#### Scenario: Legacy skills and consumers are evaluated
+- **WHEN** an old skill has no examples file or an older CLI/plugin accesses a new-format installation
+- **THEN** the old skill remains usable and consumer compatibility is explicitly tested and documented
+- **AND** unsupported consumers are not claimed compatible merely because the current implementation is updated.
+
+
+### Requirement: Localized packaged skill examples
+The examples protocol SHALL support language-tagged arrays of objects with id, title and prompt, and an author-declared defaultLocale. Consumers SHALL select the host UI locale by exact tag, progressively less-specific parent tags, then the declared default. They SHALL NOT automatically translate, mix language collections or select an arbitrary first language. Canonically duplicate tags or a missing declared default collection SHALL be protocol-invalid, with ordinary-file fallback.
+
+#### Scenario: Multiple languages are supplied
+- **WHEN** a valid examples file provides Chinese, English or other supported language collections
+- **THEN** the matching host-language collection is displayed in author order
+- **AND** missing matches use the declared default while an explicitly empty matched collection remains empty.
+
+#### Scenario: Locale changes while selecting an example
+- **WHEN** the host language changes after a template selection is prepared
+- **THEN** the old selection is invalidated rather than inserting a different localized item under the stale selection.
+
+
+### Requirement: Structured examples and verified skill placeholder
+Each example SHALL contain a stable id, display title and editable prompt. IDs SHALL be unique within each language collection; translations of the same logical example SHALL use the same id without requiring equal collection sizes. String-only entries SHALL be protocol-invalid. The consumer SHALL replace only the exact `{{skill}}` placeholder in prompt with the verified native skill invocation and SHALL NOT evaluate template expressions or infer the invocation from the market slug.
+
+#### Scenario: A localized structured example is chosen
+- **WHEN** the user confirms an example selected by language and id
+- **THEN** the current file digest and installation are revalidated and the current matching prompt is used
+- **AND** all exact skill placeholders are replaced with the verified invocation while title remains presentation text.
+
+#### Scenario: Prompt has no supported skill placeholder
+- **WHEN** the chosen prompt has no exact skill placeholder
+- **THEN** the consumer supplies the verified invocation separately and preserves the chosen task text
+- **AND** other template-like text remains literal, with no code evaluation or automatic sending.
+
+
+### Requirement: Native conversation case selection by entry context
+Catalog skill-use entries SHALL create and activate a new conversation in the same workspace. Conversation and tool-receipt entries SHALL remain in their current conversation. After target-session verification, task examples SHALL appear inline above the native composer rather than in a modal. The plugin SHALL use supported official session and slot APIs and SHALL NOT send messages or call models.
+
+#### Scenario: Catalog use starts a separate task
+- **WHEN** the user selects use from a skill-center installed card, matching detail or installation-success surface
+- **THEN** exactly one new conversation is opened in the same workspace and task examples expand above its composer
+- **AND** the original conversation draft, references, attachments and messages remain unchanged
+- **AND** the selected source, installed version and scope are revalidated for the new conversation rather than replaced by latest or another installation.
+
+#### Scenario: Conversation use preserves context
+- **WHEN** use is selected from a conversation detail or tool installation receipt
+- **THEN** the existing conversation remains active and its composer displays the inline case options
+- **AND** case confirmation inserts editable text while preserving existing draft content and attachments.
+
+#### Scenario: Cancel, missing examples and stale navigation
+- **WHEN** the user cancels inline case selection
+- **THEN** no text is inserted and no conversation or message is deleted
+- **WHEN** no applicable case exists
+- **THEN** no empty case picker appears and the verified custom-use handoff remains available
+- **WHEN** session creation fails, or the source, workspace or active conversation changes before insertion
+- **THEN** late work cannot overwrite the old draft or redirect the user back to a stale session.
+
+
+#### Scenario: Native draft ownership survives the navigation
+- **WHEN** catalog use would release the original conversation's last native view reference while its input has draft text, references, attachments or an unfinished input state
+- **THEN** the plugin retains the original native session shell before navigation and preserves its full native input state
+- **AND** it releases this temporary protection when the original main view resumes, the draft is cleared/sent, the session is removed or invalidated, or the plugin is disposed
+- **AND** creation failure or cancellation before navigation releases the temporary reference without changing the draft
+- **WHEN** eight previous nonempty drafts are already protected
+- **THEN** another catalog-use action is refused before creating a conversation, with a concise instruction to revisit earlier drafts rather than evicting them
+- **AND** this runtime protection does not promise attachment persistence across browser reload, shutdown or plugin disposal.
+
+#### Scenario: 案例填入不产生额外首尾空行
+
+- **WHEN** 用户确认案例或自定义任务并填入空的原生输入框
+- **THEN** 插件不在正文前后额外添加换行,保留作者模板原有换行
+- **AND** 插入已有草稿时只为紧邻且缺少换行的正文增加必要分隔,不重复已有换行
+- **AND** 带引用、中文及 emoji 时仍使用原生捕获跨度和草稿版本,保留引用与附件,不自动发送
+
+#### Scenario: Conversation detail hands off without blocking case selection
+- **WHEN** the user selects use from an installed skill in a conversation search-detail modal and initial verification succeeds
+- **THEN** the resident controller takes ownership before closing the detail modal and revealing inline cases
+- **AND** unmounting the originating button does not cancel the handed-off choice, while session/source/CAS guards remain active
+- **WHEN** initial verification fails
+- **THEN** the detail remains open with an error
+- **WHEN** the user cancels case selection
+- **THEN** no draft is written and the detail is not reopened.
diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md
index cf82ba4b3..08dbbb229 100644
--- a/openspec/changes/add-dsh-native-plugin/tasks.md
+++ b/openspec/changes/add-dsh-native-plugin/tasks.md
@@ -302,3 +302,44 @@
- [ ] 38.1 将包版本及双语安装、发布说明更新为 `0.3.1`,完成候选检查与发布准备 PR。
- [ ] 38.2 使用 `dsh-v0.3.1` 和专用工作流发布 npm,核对实际上传产物、公开版本和完整性。
- [ ] 38.3 从 npm 下载公开包,完成隔离 DSH 安装与关键操作复验,并记录已验证平台及未验范围。
+
+
+## 39. 使用此技能
+
+- [x] 39.1 完成真实宿主输入框与技能发现研究,更新产品、接口和正式需求;创建并维护本地 Trellis 任务。
+- [x] 39.2 Host 提供只读使用核验,绑定当前来源、记录、范围与会话,核验文件完整性、实际技能与用户调用规则。
+- [x] 39.3 已安装卡片/详情/安装成功提供统一入口;保留原生草稿和附件、回到会话并聚焦,不自动发送。
+- [x] 39.4 补齐竞态与阻止分支测试,完成完整插件检查及两版真实 DSH 浏览器验收。
+- [x] 39.5 独立 Review、清理公开 diff 并保存任务证据,回写验证结果;本期不发布 npm。
+
+
+## 40. 技能专属使用案例:固定文件协议
+
+- [x] 40.1 对照官方资料与本机 WorkBuddy 文件,区分已验证的多案例元数据与尚未核实的内部 API/刷新机制。
+- [x] 40.2 确认固定 `.skillhub/examples.json`、单份随包发布、自动识别、无 Web 编辑入口及无效协议按普通文件处理;替代此前缓存/独立刷新方案。
+- [x] 40.3 更新需求、设计和领域术语,补齐正式验收场景;本轮仅文档调整。
+- [x] 40.4 冻结 JSON schema、有效/空样例、边界限制和按语言标签组织的 id/title/prompt 多语言案例对象、{{skill}} 替换与明确回退规则;明确复用文件 API、拟新增 Remote/预检投影、内部 metadata 上传处理与旧消费者不透明兼容策略。见 skill-examples-design.md;这里只完成设计,不表示代码或跨版本验收通过。
+- [x] 40.5 实现共享核心指纹/目录/发布规则与 CLI、Web、服务端包处理一致性;覆盖升级、卸载、同步、恢复及套件。
+- [x] 40.6 实现当前安装版本的案例读取、模板选择和自定义草稿衔接,不新增独立刷新和自动发送。
+- [x] 40.7 完成跨端生命周期、双 DSH 基线、旧技能和旧消费者兼容验证,记录实际支持边界与剩余缺口。
+
+
+### 40执行与SaaS交接补充
+
+- [x] 40.8 确定开源P0–P5执行范围、依赖和验收,提供SaaS增量交接Prompt;本项完成仅代表方案交付。
+- [ ] 40.9 【后续,非本轮开源门禁】SaaS Agent核对真实仓库并提交差异表/适配PR,完成测试环境与新消费者联验;部署与生产验收另记,不纳入当前开源本地完成状态。
+
+执行权威见 skill-examples-execution-plan.md,协议见 skill-examples-design.md;40.5–40.7开源实现与本地验收已完成,详见 verification.md;SaaS、原生 Windows/macOS、真实模型及发布保持独立验收。
+
+
+## 41. 原生新对话与输入框案例选择
+
+- [x] 41.1 确认技能中心新建同工作区对话、对话入口保留当前会话、输入框上方案例区与取消/无案例边界,同步正式设计与场景。
+- [x] 41.2 核对两版官方会话创建/导航和输入框 slot,落实所有入口的统一内联选择、严格安装身份重读、去重与导航失效保护。
+- [x] 41.3 完成相关行为测试、插件完整检查和双基线真实浏览器验证;保留原会话草稿/附件并确认无发送/模型调用,更新预览与结果。
+
+- [x] 41.4 对每语种20条、最长标题/正文进行真实宿主布局压力验证,确保固定操作区、列表独立滚动、行高不压缩、长词不横向溢出;检查矮窗口与窄窗口。
+
+- [x] 41.5 修复模板填入空输入框时多出首行空白;验证已有换行、中文/emoji、原生引用及附件保留,完成双 RC 浏览器复验。
+
+- [ ] 41.6 最终独立评审修复对话详情弹层阻挡案例选择及 Windows 分隔符 ZIP 目录识别;补相应回归,核对最终产物与正式协议。
diff --git a/openspec/changes/add-dsh-native-plugin/use-skill-design.md b/openspec/changes/add-dsh-native-plugin/use-skill-design.md
new file mode 100644
index 000000000..93be1d8c3
--- /dev/null
+++ b/openspec/changes/add-dsh-native-plugin/use-skill-design.md
@@ -0,0 +1,190 @@
+# 使用此技能:产品与实现设计
+
+## 目标和边界
+
+将“安装完成”衔接到用户发起技能任务。用户点击“使用此技能”,插件核验当前会话实际可用的本地技能,按入口选择新对话或当前对话,将原生技能调用和可编辑任务引导带入对应输入框。用户自行补充材料并发送;本功能不发送消息、不调用模型、不执行技能、不变更技能文件。
+
+本期仅消费当前 Registry 的可信安装记录。场景助手、技能套件、专家中心、技能启停、自动执行、其他来源管理及 SaaS 接口调整不在范围内。不新增 HTTP API、模型工具或持久化使用记录。
+
+## 产品交互
+
+| 入口 | 展示条件 | 点击行为 |
+| --- | --- | --- |
+| 技能中心中的已安装卡片、已安装详情、匹配安装版本的市场详情与安装成功面板 | 当前范围记录为 present,或重读库存确认同坐标/版本/范围仍存在 | 在同一工作区创建新对话,打开原生对话页,再核验并在输入框上方展开案例区 |
+| 对话中的详情、工具安装回执 | 当前库存包含所选同坐标、版本与范围 | 保留当前对话,在原生输入框上方展开案例区 |
+
+入口复用小尺寸 shadcn Button 和 DSH 主题。技能中心不把新任务追加进原会话;原会话的文字、引用、图片与消息记录均保留。新对话必须属于进入技能中心时的当前工作区,不创建新项目、不猜测其他工作区、不沿用另一会话的安装授权。新建、切换与原生输入框挂载期间显示忙碌并去重;失败清楚提示,不写入旧会话,也不自动调用模型。
+
+### 对话页案例区
+
+- 使用 DSH 官方会话和输入框扩展能力,不替换宿主编辑器,不弹出第二个案例弹窗。
+- 案例区置于目标对话的原生输入框上方,显示技能名称、可点击的案例卡片(标题与简短 prompt 预览)、取消、自定义任务及确认填入。
+- 每种语言最多20条案例。多案例列表在有限高度内独立滚动,头部和底部操作固定;卡片行高按内容确定,不为容纳全部案例压缩行高。标题和正文预览分别最多2行,长英文单词/路径可断行,完整模板仍按原始prompt填入,不因视觉截断丢内容。面板最多两列,内容宽度不超过540px时切为单列;面板高度上限340px,与宿主输入框宽度变量对齐。最后一条可滚动到达、选择,底部确认保持可见。
+- 模板与自定义任务写入空输入框时不额外添加首尾换行;插入已有正文时仅在紧邻内容且原边界没有换行时增加分隔。保留作者模板原有换行,不 trim 用户草稿或作者内容。引用 chip 按宿主 detect 投影判断边界,仍使用原生捕获的跨度与草稿版本进行比较插入。
+- 对话详情中的“使用此技能”首次核验成功且驻留控制器接管请求后,关闭详情弹层,再展开输入框案例区;详情不得遮挡选择。首次核验失败保留详情与错误;取消案例不写草稿、不重开详情。无案例的自定义衔接保持成功填入后关闭。
+- 展开案例区不写草稿。选择卡片后点击“填入输入框”才将对应模板带入;用户可继续编辑和补充附件,最后自行发送。
+- 没有适用案例时不显示空卡片区,按既有核验流程带入技能引用与自定义任务提示;暂时读不到案例与确实无案例分别提示。
+- 对话入口保留该会话已有文字、引用、附件;技能中心入口的新对话保持原工作区,所选安装范围不改变。用户级技能仍须核验当前项目中的实际胜出 Provider。
+- 关闭案例区不填入、不发送、不删除对话;已创建的新对话保持为空供用户继续使用,不自动删除会话。
+- 来源、目标会话、工作区、安装版本/范围、宿主语言变化时撤销陈旧选择;不能把选项或响应迁移到用户手动切换的另一会话。用户在案例选择期间可以编辑草稿,最终异步核验期间发生编辑则比较失败,不覆盖内容。
+
+### 原草稿的原生生命周期保护
+
+两个支持 RC 的原生会话在最后一个 retain 释放后会销毁输入 shell 并回收未发送附件,旧 RC 的语义引用也不能依赖草稿持久化恢复。因此新对话导航前,必须用官方会话 retain 保留存在文字、引用、附件或未结束输入状态的原会话,保留完整原生输入对象,不序列化/复制附件或查找 DOM。
+
+用户返回原会话且 mainView 接管后释放临时保留;原草稿已清空/发送、会话移除或真实作用域失效、插件卸载时也应清理。创建失败或尚未切离原会话就取消时及时释放。最多保护 8 个仍含草稿的原会话;到达上限时在新建前拒绝本次动作,提示返回已有对话处理草稿,不驱逐旧会话或静默丢弃内容。空会话不占保护名额。
+
+该机制保护本次运行期间的会话切换,不承诺跨浏览器刷新、关闭页面或插件停用的附件持久化;这些场景仍遵循宿主原生生命周期。两 RC 验收必须切回原会话实际确认文字、引用 chip 和可解码图片均仍存在,不能以新会话草稿正确替代此项。
+
+### 会话与安装身份
+
+新对话通过支持版本的官方创建和导航 API 建立;新对话的原生输入动作就绪后才允许选择与填入。记录绑定仍由 Host 提供,若新会话需要新的 opaque itemKey,必须在该会话重读库存,严格匹配原来源、坐标、版本和范围,不能改用 latest 或同名技能。新建前后的来源、工作区与活动会话都需复查;创建失败或用户中途导航时停止,不跳回抢占用户正在编辑的会话。
+
+## 状态与失败处理
+
+```mermaid
+stateDiagram-v2
+ [*] --> idle
+ idle --> checking: 点击使用
+ checking --> blocked: 记录变化/本地文件变化/不可发现/同名覆盖/禁止用户调用
+ checking --> stale: 来源或会话、工作区、输入框变化
+ checking --> choosing: 本地身份及原生调用核验通过且有案例
+ choosing --> drafting: 确认案例或自定义
+ checking --> drafting: 无适用案例
+ choosing --> idle: 取消
+ choosing --> stale: 上下文变化
+ drafting --> stale: 原生插入比较失败
+ drafting --> ready: 保留草稿并插入调用
+ blocked --> idle: 用户处理后重试
+ stale --> idle: 回到当前会话后重试
+ ready --> [*]: 用户自行编辑和发送
+```
+
+核验仅说明所选技能在核验时可用,不等于安全审核通过或任务执行成功。就地错误说明无法使用的原因;不展示内部路径、原始文件内容或冗长诊断作为常规结果。
+
+## 架构与模块边界
+
+```mermaid
+sequenceDiagram
+ actor User as 用户
+ participant UI as 插件使用入口
+ participant Input as DSH 原生输入框
+ participant Host as SkillHub Host Remote
+ participant Core as 本地安装核心
+ participant Skills as DSH Skills
+ User->>UI: 使用此技能
+ alt 技能中心入口
+ UI->>Input: 官方创建同工作区新对话并显示输入框
+ else 对话入口
+ UI->>Input: 保留当前会话
+ end
+ UI->>Host: prepareSkillUse(sourceRevision,itemKey,signal)
+ Host->>Core: 重读选定安装记录与可信范围,校验文件指纹
+ Host->>Skills: 按 SKILL.md 实际名称读取当前会话胜出技能
+ Skills-->>Host: 真实技能、Provider 与调用规则
+ Host->>Core: 再核验记录与目标,防止异步期间变化
+ Host-->>UI: ready(name,version,scope) 或 blocked(reason)
+ UI->>UI: 再核验来源、当前会话和输入框身份
+ UI->>Input: 展开输入框上方案例区,等待选择
+ User->>UI: 确认案例或自定义
+ UI->>Host: 重新核验安装、案例摘要与上下文
+ UI->>Input: captureInsertion / insertText,保留附件与既有文字
+ Input-->>UI: 比较成功或草稿已变化
+ UI->>Input: 收起案例区并聚焦目标输入框
+ Note over User,Input: 插件不发送;用户编辑后自行发送
+```
+
+Host 是安装身份与文件校验边界;Client 只提交 opaque itemKey 和 sourceRevision,不提交本地路径或期望技能名称。Host 复用 readInstalledItem、可信根解析和现有只读指纹校验,不复用会进入安装/卸载事务的执行函数。当前直接安装记录可以复用单目标只读 baseline 检查;共享所有权等暂不支持的记录明确阻止,不能使用整组升级 baseline 导致其他项目范围被误判。
+
+临时 FileSystemSkillProvider 仅加载已验证目录,includeDefaultRoots=false、watch=false,始终 dispose,不注册远程 Provider。按真实 name 对比 DSH skills.get 的胜出目录、路径、正文、调用字段和 metadata。名称必须可由 DSH 原生用户调用语法准确表达,userInvocable=false 不允许通过自然语言绕过。
+
+Client 在统一会话组合层封装服务,所有入口复用同一路径。使用原生 InputActions.captureInsertion / insertText 的比较机制保留内容与附件,不使用 DOM 查找、模拟按键、替换草稿、beginCommand 或自动 submit。服务身份比较使用 Cordis 的原始实例,不能把每次查询产生的新代理当成服务替换。异步期间检查 session 对象、cwd、来源 revision、活动会话、输入框服务及组件生命周期;取消和陈旧响应不写入另一个输入框。
+
+## 插件 Remote 接口
+
+| 项目 | 定义 |
+| --- | --- |
+| 方法 | agent-scoped prepareSkillUse(sourceRevision: string, itemKey: string, signal: AbortSignal) |
+| ready | { status: 'ready', name: string, version: string, scope: 'user' \| 'project', examples: SkillExamples } |
+| blocked | { status: 'blocked', reason: string };reason 使用受控类型,Client 映射中文提示 |
+| 上下文失效 | 沿用 skillhub/context-stale;没有副作用,不自动恢复或切换来源 |
+| 返回限制 | 不返回本地目录、技能全文、附件内容;ready 仅用于当前一次原生草稿衔接 |
+
+实际错误 reason 枚举与行为由同目录 [interface-design.md](interface-design.md#使用此技能只读当前会话核验) 及插件共享类型保持一致;新增接口由现有 Typert 构建生成,兼容两版共同 ABI。此接口不是模型工具,不接受调用路径、任意名称或文件写入参数。
+
+## 兼容与验收
+
+支持现有明确验收的 DSH 0.2.0-rc.2、0.2.1-rc.2。原生输入框相关模块按宿主服务声明加载;缺少服务时显示不可用,不用轮询或页面 DOM 兜底。
+
+| 验收层 | 必须证明 |
+| --- | --- |
+| Host 行为 | slug 与真实 name 不同、文件/资源被改、记录/来源/会话变更、项目覆盖用户级技能、禁止用户调用、未发现、取消和服务失效 |
+| Client 行为 | 三入口共用、版本/范围准确、原生草稿比较、保留文字附件、重复点击、异步切会话/来源/输入框、无自动发送 |
+| 构建 | 固定 Bun 版本的完整插件 check;契约生成、lint、类型与全部插件测试 |
+| 双 RC 真宿主 | 隔离 Profile 安装候选,真实浏览器完成三入口、原生编辑器草稿与附件、失败不污染草稿;记录宿主版本和实际产物 |
+
+运行报告、截图和临时宿主不进入公开 PR。验收通过后在 verification.md 写入简短结果与证据位置;尚未执行的项保持未勾选。模型执行不是“带入草稿”的验收前提,不能把无模型浏览器测试称为真实模型验收。本轮不发布 npm。
+
+
+## 技能专属使用案例:固定文件约定
+
+### 产品与范围
+
+案例是技能作者提供的可复用任务模板,同一技能支持多条,不同技能拥有各自的模板集合。点击“使用此技能”时,有适用案例则展示选择面板,并提供自定义任务入口;无案例的旧技能仍可自定义使用。选择结果形成可编辑任务草稿,延续既有真实技能核验、原生输入框比较插入、保留文字和附件、不自动发送的边界。
+
+首期采用固定位置文件、随技能版本发布的方案。不建设 Web 案例编辑器或独立配置入口,不修改 SKILL.md,不生成虚构案例。此前讨论的外置缓存、两份案例文件、独立刷新案例和作者更新 API 不属于本期方案。
+
+### 唯一文件位置
+
+```text
+技能目录/
+├── SKILL.md
+└── .skillhub/
+ └── examples.json
+```
+
+作者上传的技能包与安装后的技能目录保持相同相对路径:`.skillhub/examples.json`。不将文件改名或复制为另一个案例副本,不在浏览器、插件安装目录或 DSH 全局缓存另建权威案例文件。案例默认属于所安装的技能版本,更新案例通过发布及安装相应技能版本完成;切换版本不得沿用另一版本的案例。
+
+`.skillhub/metadata.json` 是安装器内部身份与生命周期记录,与作者案例文件职责不同。上传内容不得覆盖、伪造或获得该内部记录的信任;其上传处理规则按 [案例实现设计](skill-examples-design.md#5-发布内容与内部安装记录) 执行,并在实现中验证。不能因为 examples.json 合法就信任整个 .skillhub 目录。
+
+### 自动识别与失败处理
+
+| 输入 | 目标行为 |
+| --- | --- |
+| 指定路径的 JSON 符合案例协议 | 识别为当前技能版本的多条任务模板 |
+| 指定路径存在,但 JSON 无法解析或不符合案例协议 | 按普通技能文件原样保留,不启用案例功能;发布结果提供简短非阻断提示 |
+| 指定文件不存在,或合法协议明确没有案例 | 不显示空案例区,保留自定义使用 |
+| 其他位置的 JSON 或相似文件名 | 按普通文件处理,不猜测或全目录扫描识别案例 |
+| 文件违反通用路径、类型、大小或安全规则 | 沿用原有拒绝或安全处理,不得以“普通文件回退”为由放行 |
+| 文件读取发生 I/O 错误 | 与明确无案例区分;可提示重试,保留自定义入口,不绕过技能完整性核验 |
+
+协议识别失败不等于技能发布、安装或使用失败;但普通文件仍属于技能内容,不能静默忽略修改、删改或执行其中内容。JSON 中的模板只作为用户可编辑文本,不能作为权限、工具调用、路径或安装身份依据。
+
+协议、状态、接口投影、内部记录保护与旧消费者策略见 [案例实现设计](skill-examples-design.md)。首期采用 schemaVersion=1、defaultLocale 和按语言标签组织的 examples 对象数组映射;每条包含 id/title/prompt,prompt 支持 {{skill}} 占位符;规范 schema 与样例由该分册链接,本节不重复维护。
+
+### 分发和消费边界
+
+作者经现有技能包上传/发布链路提供文件。服务端识别结果必须绑定精确的来源、技能坐标与版本;如通过 API 展示案例,API 只是该版本文件的投影,不构成独立编辑或独立更新源。本期复用既有精确版本原始文件接口,不新增案例表或专用端点;预检识别摘要与本地 Remote 投影见 [案例实现设计](skill-examples-design.md),开源实现与本地验证见 verification.md;SaaS 另行适配。
+
+DSH 的使用入口消费选定安装版本的固定文件,不能以市场最新版本模板替代已安装版本。用户级与项目级安装分别读取所选目录;不得跨来源、技能、版本或范围串用模板。案例存在不表示该安装在当前会话可调用,仍需执行既有只读使用核验。
+
+### CLI、Web 与 DSH 生命周期兼容
+
+固定案例的实现行为如下:
+
+- CLI 按目录发布、同步推送及 Web 文件夹打包须保留 `.skillhub/examples.json`;不得继续笼统排除整个 `.skillhub`,也不得误带安装器内部 metadata.json。
+- 服务端文件清单、每文件哈希、下载包与本地校验必须一致地覆盖作者案例文件;案例协议不合法时也按普通发布文件保留和校验。
+- 共享指纹与安全目录检查须识别该明确路径,并保留路径、普通文件、符号链接和所有权防护。不能整体忽略 .skillhub 内文件的修改。
+- 安装保留作者文件,升级按目标版本替换,卸载随技能清理;备份、回滚、恢复和套件生命周期遵守相同内容规则。
+- 安装后修改案例文件按本地内容修改处理,沿用现有保护,不增加静默覆盖或强制操作。案例不作为独立可变缓存写回。
+- 没有该文件的旧技能保持可用。旧版 CLI/插件与新目录规则的互操作须实测;不能承诺旧二进制自动兼容。若确有不兼容,应在实现前定义最低消费者版本或明确的阻止提示。
+
+### 验收要求
+
+1. 合法多案例、无案例、错误 JSON、错误结构和其他路径文件均按识别规则处理;普通文件回退不削弱安全检查。
+2. Web 与 CLI 上传/目录打包、手工 ZIP、服务端存储及下载、安装目录的文件路径与字节一致;内部安装记录不得由上传内容覆盖。
+3. 安装、同版本重试、同步、升级、卸载、恢复、回滚、套件及“使用此技能”均覆盖合法和不合法案例文件;本地修改继续受到保护。
+4. 多模板选择、自定义、草稿/附件保留、取消和上下文变更不自动发送,不执行文件中的指令。
+5. 不同来源、坐标、版本、用户/项目范围不串用;覆盖旧技能与实际旧版消费者兼容,记录支持边界。
+
+开源实现及生命周期验证结果见 verification.md 的“固定技能案例协议:开源实现验收”。SaaS 和生产部署不在本轮范围。
diff --git a/openspec/changes/add-dsh-native-plugin/verification.md b/openspec/changes/add-dsh-native-plugin/verification.md
index e9f920940..32e4b4182 100644
--- a/openspec/changes/add-dsh-native-plugin/verification.md
+++ b/openspec/changes/add-dsh-native-plugin/verification.md
@@ -153,3 +153,107 @@
- 使用公开 tarball,通过官方安装器装入干净隔离 Profile;复用已安装的官方 DSH 程序,运行基线和插件生命周期探针均通过。该路径不等于重新从网络安装整套 DSH 依赖。
- 现有 Linux 预览升级到公开 `0.3.0`,81 个交付文件逐字节核对;15 个历史对话归档及技能状态文件在升级和重启后保持一致。浏览器技能中心加载 3 张联调技能卡片,无页面脚本异常。
- 本轮发布后未新增模型请求,不将先前候选的真实模型验收写为公开包的新模型验收;预览使用本地开源市场,不代表 SaaS 新契约已部署。后续发布将公开读取窗口扩大到 40 次、间隔 30 秒,保留严格完整性比对与不重复发布边界。
+
+
+## 使用此技能:开发候选验收
+
+本轮仅验证原生插件中的草稿衔接能力,没有发送消息、执行技能或调用模型;不代表真实模型效果或生产 Registry 验收。候选沿用包版本字段 `0.3.1`,但包含尚未发布的功能,不能与公开 npm `0.3.1` 混同。
+
+| 层级 | 结果 |
+| --- | --- |
+| 完整插件检查 | 固定 Bun 1.3.13 的 build、typecheck、lint、419 项测试及 10286 次断言通过。没有修改 CLI 共享核心、服务端或数据库。 |
+| 独立评审 | 修复请求状态复位、去重时草稿版本检查、末次查询后的运行状态检查;真实宿主发现的 Cordis 代理误判已用稳定服务身份修复,并补真实 Cordis Service 回归。最终无未修复的高置信静态问题。 |
+| 官方安装 | 同一冻结候选分别由两版官方 DSH 安装器安装,追加严格 peers 校验;安装后的包文件与候选逐字节核对通过。 |
+| 真宿主与浏览器 | Linux / Node 24.17.0,DSH 0.2.0-rc.2、0.2.1-rc.2,各完成 12 项检查:安装成功、已安装卡片、已安装详情、市场详情、工具安装回执、用户级安装后使用、重复使用去重;资源修改拒绝、切会话晚响应、核验期间编辑草稿、用户级真实文件及项目同名覆盖。 |
+| 草稿与副作用 | 各正向入口均保留已有文字、真实原生文件引用 chip 和可解码图片附件,使用 SKILL.md 实际名称并恢复输入焦点;没有消息提交或模型请求,使用动作不创建安装操作或修改技能文件。独立只读复验还逐字节确认技能目录、库存与操作日志前后不变。 |
+| 产物与清理 | 包中仅包含运行代码、类型、README 和许可证;测试脚本、截图、日志、任务记录、认证入口不进入 npm 包。过程材料保存在仓库外,公开 diff 只保留实现、规范和可复用行为测试。 |
+
+浏览器场景使用本地 HTTP Registry 与真实 ZIP。对话回执由真实工具安装结果组成,历史明确标记“非模型”。匿名 Web Host 的 `/open-in-app/icon/filemanager` 返回 404,属于宿主文件图标表现;已单独记录,未将其混同脚本异常或图片附件失败,其余 HTTP/console 异常和页面脚本异常为零。
+
+用户禁止直接调用、未知依赖、来源变化和历史版本变化等其他阻止分支由 Host/Client 行为测试覆盖;本轮没有把每个分支扩展为浏览器操作。未重新验证原生 Windows/macOS、桌面客户端、真实模型执行、SaaS 或生产环境。场景助手、套件、Git 提交/合并和 npm 发布不在本轮。
+
+
+## 固定技能案例协议:设计验证
+
+本轮仅完成 tasks.md §40.4 的实现契约,不修改运行时代码。新增固定文件 schema、多案例/空集合样例和跨端实现设计;没有引入数据库、专用案例接口、Web编辑器或外置缓存。
+
+- JSON Schema Draft 2020-12 自身校验、有效/无效与边界样例、Python/ECMAScript 空白码点一致性及文档链接合计 27 项检查通过;包含未知版本/字段、空白/重复、20/21条、4000/4001码点、UTF-8/BOM及文件体积规则。
+- OpenSpec strict 与 git diff --check 通过;Trellis implement/check 上下文 manifest 的 7 个条目各自验证通过。
+- 独立设计检查发现并修正旧 CLI 删除/强制覆盖阻止的过度承诺,以及跨语言 \S 空白定义差异。
+- 已下载正式 npm 0.3.1 的内置核心静态审计确认其不支持新目录内容;不是运行旧包的真实兼容验收。新旧消费者矩阵、CLI/Web/服务端生命周期、DSH多案例UI均未实现或运行,本轮未调用模型、提交、部署或发布。
+
+原始研究、校验报告及后续执行上下文保存在本地任务与仓库外报告中;本节只保留可评审的验证摘要,不将协议样例校验等同产品验收。
+
+### 多语言协议补充
+
+单语言草案已由多语言映射替代:schemaVersion=1、defaultLocale、examples语言集合。更新后的样例、协议边界与语言回退共23项设计校验通过,OpenSpec strict和diff check通过。此前27项是单语言草案的历史设计验证,不代表当前多语言实现验收;本次23项同样仅为协议参考校验,生产parser/界面尚未实现。复核当前Host类型、Use控制器及共享CLI代码,未发现本功能案例解析/选择实现,tasks40.5–40.7保持未完成。
+
+### 结构化案例与技能占位符补充
+
+字符串条目草案已替换为多语言 id/title/prompt 对象。schema、有效/空样例与字段/重复ID/语言/长度边界和字面占位替换参考断言共21项通过;OpenSpec严格验证与diff检查通过。此为协议文档验证,字面替换断言不是生产实现测试,尚未实现案例parser、选择UI或生命周期兼容。此前单语言27项及多语言字符串23项属于历史草案验证,不代表当前功能验收。
+
+### 执行范围与SaaS增量交接
+
+新增正式执行方案、P0–P5范围与验收矩阵及可独立交付的SaaS案例适配Prompt。只修改规划文档,未改功能代码或SaaS仓库、未部署。独立方案review发现预检统计规则不明确,已统一为全部语言条目数并区分包预检与DSH所选语言状态,复核关闭。文档链接/结构与预检统计参考断言共52项通过,OpenSpec严格校验和diff检查通过;这些不是产品或SaaS验收。SaaS实际代码与测试环境仍待其Agent核对。
+
+
+## 固定技能案例协议:开源实现验收
+
+本节替代前述设计阶段对“尚未实现”的状态描述。完成范围为固定 `.skillhub/examples.json`、共享核心/CLI、开源 Java/Web 和 DSH 选择案例;不涉及 SaaS 代码或部署。案例原始字节随版本保存并参与指纹,内部 metadata 不作为作者内容;无效案例保留为普通文件并给出非阻断提示。
+
+| 验证层 | 实际结果 |
+| --- | --- |
+| CLI/共享核心 | 完整 735 项测试、3171 次断言通过;后续预检修复的相关四文件 87 项测试、362 次断言通过。lint、typecheck、build、Node 产物 smoke 通过。完整测试使用 30 秒 runner 超时以避免并行负载下既有子进程用例超时,未削弱断言或崩溃恢复时限。 |
+| 开源后端 | `make test-backend-app` 全部 2671 项实际执行测试通过,零失败;另有 30 项既有条件跳过(S3 2、GitHub HTTP 互操作 26、Go 归档 1、Redis Cluster 1)。新增案例测试全部执行;最终打包及 JRE 21 实际启动通过。 |
+| Web | 完整 255 文件、1160 项测试通过,lint、typecheck、build 通过。实际浏览器对最终后端上传有效、无效、无案例 ZIP,三种提示正确,无页面脚本异常。未新增案例编辑器。 |
+| DSH | build、typecheck、lint、435 项测试和 10366 次断言通过。原有引用压力测试在无并行负载下按原超时通过,未调整时限。 |
+| 实际后端生命周期 | 隔离实例通过现有发布接口发布 1.0.0、1.1.0;匿名精确版本 resolve、原始文件读取、下载 ZIP 的案例字节和聚合 fingerprint 核验通过;共享核心真实安装、升级及 CLI 删除通过。另有 5 项真实 HTTP 预检检查通过,包括内部 metadata 与大小写歧义路径拒绝。管理员自动发布未用于证明安全扫描质量。 |
+| 新旧消费者 | 实际公开 npm 0.3.1 内置核心与候选核心分别安装旧格式包通过;新核心安装新案例包通过;旧核心对新包因指纹不一致拒绝且未留下目标或库存。共享新安装的原生卸载预检:旧核心阻止、新核心允许。共 7 项实测通过,不能据此声称旧 CLI 删除/强制覆盖也有相同保护。 |
+| 双 DSH 真宿主 | 同一候选分别经官方安装器安装到 0.2.0-rc.2、0.2.1-rc.2,严格 peers 与包字节核验通过;每版 11 项主流程浏览器检查通过:实际项目安装后的案例选择、案例字节/指纹、取消、已安装卡片、自定义任务、两种详情入口、文件与库存不变、修改案例拒绝、真实工具安装回执、末次核验期间草稿编辑保护。每版另通过 1 项用户级安装案例选择检查,保留草稿与附件并核对用户目录中的案例指纹。 |
+| 草稿与副作用 | 保留已有文字、原生文件引用和可解码图片,使用实际 SKILL.md name 替换占位符并恢复焦点;零消息发送、零模型调用。浏览器按实际宿主英文 locale 选择英文案例;中文多案例、语言回退/切换与显式空集合由行为测试覆盖。 |
+| 独立评审 | CLI 预检缺失、SKILL.md 提升路径一致性和 stored ZIP 长度边界问题均修复并补测,静态评审无剩余已报告缺陷。 |
+
+实际浏览器仍记录宿主 `/open-in-app/icon/filemanager` 的既有 404,已从其余网络/脚本异常中单独区分。没有把工具组成的“非模型”回执称为真实模型对话。原生 Windows/macOS、桌面客户端、SaaS、生产、模型执行与 npm 发布均未在本轮验证。历史版本不回算指纹、不重写包;历史上被旧目录规则忽略的作者文件,如发现,需要单独迁移评估。
+
+所有脚本、日志、截图、隔离配置与候选包保存在仓库外本地报告目录 `dsh-examples-20261022/`;可观察入口为 `REPORT.md`、`core-compatibility-live.json`、`backend-http-preflight-final.json`、`live-registry.json`、`web-browser-preflight.json` 与 `evidence/{old,new}/result.json`。这些是当前未提交工作分支的验收证据,不代表 main、正式 npm 或 SaaS 已具备本功能。
+
+
+## 原生新对话与输入框案例交互
+
+技能中心的已安装卡片、两类详情和安装成功入口现在创建同工作区新对话;对话安装回执保持当前会话。案例由常驻控制器在官方 `conversation.input.dock` 展示,不再依赖原卡片弹窗。确认前后保留安装身份、版本/范围、宿主语种、摘要与原生草稿比较,未新增 HTTP 或模型工具。
+
+| 验证 | 本轮结果 |
+| --- | --- |
+| 完整插件检查 | 最终 `bun run check` 的 build、typecheck、lint、471 项测试及 11289 次断言通过,78 个测试文件,未修改既有压力预算。 |
+| 独立评审 | 官方双RC API、导航/retain/opaque key重绑定与清理边界复核通过;最终草稿保护相关75项测试/306次断言、模块/包基线19项测试、组件呈现2项测试通过。组件渲染测试不作为实际布局证明。 |
+| 最终双RC候选 | 同一未发布候选经两版官方安装器安装、严格peer校验,并将安装内容与候选85个文件逐字节核对;本次新增的两个官方工作区Client模块纳入同版兼容基线。 |
+| 真实浏览器 | Linux两版Host各完成9项:项目安装成功后新对话与第二案例、原文字/引用/可解码PNG保留、已安装卡片双击去重及取消、已安装详情自定义、市场详情、对话回执不新建且保留附件、最终核验期间编辑保护、导航撤销、用户级安装成功后新对话。每次创建均核对原工作区;零消息发送、模型调用和页面脚本异常。 |
+| 真实布局 | 桌面1440×1000,两张案例卡片及自定义/确认按钮首屏可见,通过原生祖先overflow裁切交集与实际点击点命中验证,且已目视核对两版截图。案例列表独立滚动,不收缩头尾操作。 |
+| 规范 | OpenSpec strict、Trellis上下文manifest和diff check通过;tasks41和正式交互、生命周期、验收同步。 |
+
+真实验收先发现旧会话最后引用释放时,宿主销毁输入shell并回收附件,旧RC语义引用也丢失。修复采用官方retain保留原生shell,返回mainView接管、草稿清空/发送完成、会话移除或作用域失效、插件卸载时清理;最多8份非空旧草稿,达到上限拒绝新建,不驱逐旧内容。原丢失记录与修复后切回原会话实测分开保存;此前模拟测试不能替代这一边界验证。跨刷新、关闭页面和插件停用的附件持久化仍由宿主决定。
+
+另一个实际目视问题为Dock中flex收缩导致案例区仅露出顶部;最终仅补插件自身布局约束,与官方Dock组件一致,未改宿主全局样式。无模型配置的隔离Host在新空会话弹出原生引导,测试选择“稍后配置/关闭”;未填写凭证或调用模型。重复使用旧测试根时,外源目录保护曾正确拒绝用户级写入,最终改用干净隔离根完成同包验收,没有绕过保护或删除原来源数据。
+
+证据保存于仓库外本地报告 `dsh-composer-cases-20261022/REPORT.md`,最终原始浏览器结果在 `final-preview/evidence/{old,new}/result.json`,截图同目录。旧候选、失败和中间阶段记录不标为最终通过。CLI/Java/Web在本轮未改动,不将前轮检查冒充此次重跑。SaaS、真实模型执行、原生Windows/macOS、桌面及新增交互的移动端验收均未执行;未提交、推送、部署或发布npm。
+
+
+### 多案例布局压力补验
+
+用户提出“案例特别多”的人工评审问题后,真实DSH布局压力测试发现原grid行高被压缩、长标题重叠及横向溢出,已最小修复:复用官方Dock宽度变量,最多两列、窄面板单列,grid自然行高且独立滚动,标题/预览最多两行并允许长词换行,头尾操作固定。此改动仅涉及插件样式与呈现回归,无协议或依赖变更。
+
+- 最终完整插件check:build/typecheck/lint、472项测试、11294次断言通过,独立样式补审及3项组件呈现测试通过;呈现测试不替代布局验收。
+- 同一新增候选在两支持RC经官方严格安装,并逐字节核对85个包文件;两版真实原生slot各验证7种视口:1440×1000、1280×720、1024×600、760×600、1440×500、560×700、390×720。20条长案例无卡片交叠、列表/页面横向溢出,列表内部滚动、末项可选,操作按钮的可见交集和点击命中通过,已目视桌面及窄屏截图。
+- 旧RC采用明确的RPC布局fixture;新RC除相同fixture外,进一步安装真实20案例包、核对作者原字节和metadata文件哈希,并重新跑7视口。样例35950字节,通过生产解析器,含80码点标题及4000码点正文的边界,未超64KiB文件上限。
+- 新RC实际选择第20项并确认:完整500码点prompt带入原生输入框,视觉两行截断不改变原始模板。无消息提交或模型请求。该增量不冒充20案例的升级/卸载/全部平台生命周期验收;底层生命周期仍由前轮独立证据承担。
+
+原先33217预览保持运行,新增20案例人工演示使用独立实例,实际入口记录在仓库外 `dsh-composer-cases-20261022/MANY-CASES.md`。原始测量/截图在 `many-cases-evidence/{old,new}/`,实际包检查和末项填入分别为 `many-cases-real-install.json` 与 `many-case-use-evidence/new/result.json`。窄视口检查只证明案例组件布局,不扩大为整个DSH手机/桌面平台认证。原生空白首载曾使一次脚本未进入业务,重新打开同一有效入口完整通过,保留失败记录,不声称修复宿主启动问题。未提交、发布或修改SaaS。
+
+
+### 案例填入首行空白修复
+
+人工预览发现空输入框填入模板时首行空白,原因为插件无条件在模板前后增加换行。现改为按原生detect投影判断插入边界:空输入不添加首尾换行,已有换行不重复添加,作者原始换行保留,原生捕获跨度与CAS不变。
+
+- 完整插件check的build/typecheck/lint和486项测试、11344次断言通过;独立增量评审、60项测试及类型/静态检查通过。
+- 同一未发布候选在两支持RC经官方严格安装并核对85个文件;每版实际浏览器通过5项:空草稿案例、空草稿自定义、已有换行及中文/emoji、原生引用chip和可解码PNG保留、技能中心新建空对话后填入。直接比较完整正文,不使用trim掩盖首尾空白;无RPC模拟、消息发送或模型调用,页面脚本异常为零。
+- 新人工预览使用20个简短有效案例,原压力样例及旧预览保留。已存在草稿不自动改写。证据位于仓库外 `dsh-leading-line-20261022/REPORT.md` 与 `evidence/{old,new}/`。本增量不涉及SaaS、CLI/Java/Web、原生Windows/macOS、模型执行或发布。
diff --git a/plugins/dsh/README.en.md b/plugins/dsh/README.en.md
index 79f99ee78..1598608cf 100644
--- a/plugins/dsh/README.en.md
+++ b/plugins/dsh/README.en.md
@@ -10,6 +10,13 @@ Version `0.2.1` defaults to `https://skill.xfyun.cn` when `registryUrl` is omitt
Version `0.3.1` fixes compatibility with both enumerated DSH RCs, uses the independent `dsh-v0.3.1` release tag and retains the marketplace interactions and version management from `0.3.0`. Until npm publication and public-package verification finish, validate this version using its built tarball.
+## Use an installed skill (in development)
+
+Choose “使用此技能” on an installed card, matching installed-version detail or completed installation result. Catalog entries open a new conversation in the same workspace; conversation entries keep their current session. After installation verification, examples appear above the native composer. Confirm an example or choose a custom task to insert an editable invocation. The original conversation is preserved, and in-conversation use retains its existing text, references and attachments. Complete the task and send it yourself; this action does not submit messages, call a model or install files.
+
+An active session is required. Shadowed skills, modified files, disabled user invocation or changed context are reported without modifying the draft. Historical receipts are not proof of current installation. This feature is under development and is not part of published `0.3.1`.
+
+
## Requirements and validation scope
| Component | Requirement or scope |
diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md
index d799e9124..a95ef8f01 100644
--- a/plugins/dsh/README.md
+++ b/plugins/dsh/README.md
@@ -10,6 +10,13 @@ SkillHub 的 DSH 原生插件。在 DSH 侧栏打开 **技能中心**,匿名
`0.3.1` 为 DSH 双 RC 兼容修复版本,使用独立发布标签 `dsh-v0.3.1`,保留 `0.3.0` 的市场交互与版本管理能力。npm 发布及公开包验收完成前,使用本版本构建的 tarball 验证。
+## 使用已安装技能(开发中)
+
+在已安装卡片、已安装版本详情或安装成功结果点击“使用此技能”。技能中心入口在同一工作区新建对话,对话内入口保留当前会话。核验所选安装后,案例显示在原生输入框上方;确认案例或自定义任务后,带入可编辑的技能调用。原会话草稿不会被覆盖,对话内操作保留已有文字、引用和附件。补充任务和材料后自行发送;点击使用不会自动发送、调用模型或安装文件。
+
+需要当前活动会话。被项目同名技能覆盖、本地文件变更、禁止用户调用或会话/来源变化时会提示原因;历史回执不能代替当前安装状态。此功能正在开发,尚不属于已发布的 `0.3.1` 包。
+
+
## 环境与验证范围
| 项目 | 要求或范围 |
diff --git a/plugins/dsh/bun.lock b/plugins/dsh/bun.lock
index 87d5028a7..8484fe9ec 100644
--- a/plugins/dsh/bun.lock
+++ b/plugins/dsh/bun.lock
@@ -25,7 +25,9 @@
"@deepseek-ai/dsh-agent": "0.2.1-rc.2",
"@deepseek-ai/dsh-api-gateway": "0.2.1-rc.2",
"@deepseek-ai/dsh-api-session-controller": "0.2.1-rc.2",
+ "@deepseek-ai/dsh-api-workspace-controller": "0.2.1-rc.2",
"@deepseek-ai/dsh-app-boot": "0.2.1-rc.2",
+ "@deepseek-ai/dsh-client-locale": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-chat": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.2",
@@ -36,6 +38,7 @@
"@deepseek-ai/dsh-client-ui-sidebar": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-tool": "0.2.1-rc.2",
+ "@deepseek-ai/dsh-client-ui-workspace": "0.2.1-rc.2",
"@deepseek-ai/dsh-fs": "0.2.1-rc.2",
"@deepseek-ai/dsh-home-paths": "0.2.1-rc.2",
"@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
@@ -102,6 +105,8 @@
"@deepseek-ai/dsh-api-session-controller": ["@deepseek-ai/dsh-api-session-controller@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-api-session-controller/-/dsh-api-session-controller-0.2.1-rc.2.tgz", { "dependencies": { "@deepseek-ai/dsh-brand": "0.2.1-rc.2", "@deepseek-ai/dsh-deque": "0.2.1-rc.2", "@deepseek-ai/schemastery": "~3.18.5", "mime-types": "^3.0.2", "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5", "@deepseek-ai/dsh-agent": "0.2.1-rc.2", "@deepseek-ai/dsh-agent-default-model": "0.2.1-rc.2", "@deepseek-ai/dsh-agent-preset-registry": "0.2.1-rc.2", "@deepseek-ai/dsh-api-gateway": "0.2.1-rc.2", "@deepseek-ai/dsh-attachment": "0.2.1-rc.2", "@deepseek-ai/dsh-client-connection": "0.2.1-rc.2", "@deepseek-ai/dsh-client-file-upload": "0.2.1-rc.2", "@deepseek-ai/dsh-commands": "0.2.1-rc.2", "@deepseek-ai/dsh-credentials": "0.2.1-rc.2", "@deepseek-ai/dsh-file-reference": "0.2.1-rc.2", "@deepseek-ai/dsh-fs": "0.2.1-rc.2", "@deepseek-ai/dsh-llm": "0.2.1-rc.2", "@deepseek-ai/dsh-native-command": "0.2.1-rc.2", "@deepseek-ai/dsh-scope": "0.2.1-rc.2", "@deepseek-ai/dsh-session": "0.2.1-rc.2", "@deepseek-ai/dsh-session-persistence": "0.2.1-rc.2", "@deepseek-ai/dsh-session-projection": "0.2.1-rc.2", "@deepseek-ai/dsh-session-projection-cache": "0.2.1-rc.2", "@deepseek-ai/dsh-session-query": "0.2.1-rc.2", "@deepseek-ai/dsh-session-title": "0.2.1-rc.2", "@deepseek-ai/dsh-settings": "0.2.1-rc.2", "@deepseek-ai/dsh-skill": "0.2.1-rc.2", "@deepseek-ai/dsh-subagent": "0.2.1-rc.2", "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2", "@deepseek-ai/dsh-typert-registry": "0.2.1-rc.2", "@deepseek-ai/dsh-util-time": "0.2.1-rc.2", "@deepseek-ai/dsh-util-values": "0.2.1-rc.2", "@deepseek-ai/dsh-util-workspace-path": "0.2.1-rc.2", "@deepseek-ai/dsh-working-directory": "0.2.1-rc.2", "@deepseek-ai/dsh-workspace": "0.2.1-rc.2" }, "optionalPeers": ["@deepseek-ai/dsh-session-persistence", "@deepseek-ai/dsh-session-projection-cache", "@deepseek-ai/dsh-settings"] }, "sha512-AvYB5Seza/91BAKzataRn/5UYUqxNHU6W9ryH0JL0gslWkcURbGFFhvkRe9SXNo6DO0Jlv66NbBHME2t0XRPKA=="],
+ "@deepseek-ai/dsh-api-workspace-controller": ["@deepseek-ai/dsh-api-workspace-controller@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-api-workspace-controller/-/dsh-api-workspace-controller-0.2.1-rc.2.tgz", { "dependencies": { "@deepseek-ai/dsh-deque": "0.2.1-rc.2", "@deepseek-ai/dsh-native-command": "0.2.1-rc.2", "@deepseek-ai/schemastery": "~3.18.5", "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5", "@deepseek-ai/dsh-api-gateway": "0.2.1-rc.2", "@deepseek-ai/dsh-client-connection": "0.2.1-rc.2", "@deepseek-ai/dsh-host-directory-picker": "0.2.1-rc.2", "@deepseek-ai/dsh-session": "0.2.1-rc.2", "@deepseek-ai/dsh-storage-domain": "0.2.1-rc.2", "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2", "@deepseek-ai/dsh-workspace": "0.2.1-rc.2" } }, "sha512-v1ZxPEY5suajD8dqCE7TjYzPllK/JS8LT1aCKGksiBqvgpzruG5YpPzw9JnY3Z3yJSrgwUYHlKpuHReG3Kbfvw=="],
+
"@deepseek-ai/dsh-app-boot": ["@deepseek-ai/dsh-app-boot@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-app-boot/-/dsh-app-boot-0.2.1-rc.2.tgz", { "dependencies": { "@deepseek-ai/dsh-atomic-write": "0.2.1-rc.2", "@deepseek-ai/dsh-package-manifest": "0.2.1-rc.2", "@eslint-community/regexpp": "^4.12.2", "ajv": "^8.20.0", "js-yaml": "^4.3.2", "node-addon-require-builtin": "^0.1.9", "resolve.exports": "^2.0.3", "semver": "^7.8.5" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5", "@deepseek-ai/cordis-plugin-group": "~1.0.5", "@deepseek-ai/cordis-plugin-include": "~1.0.10", "@deepseek-ai/cordis-plugin-loader": "~1.0.6", "@deepseek-ai/dsh-home-paths": "0.2.1-rc.2", "@deepseek-ai/dsh-launch-environment": "0.2.1-rc.2" } }, "sha512-kMS4f6AGOPjGsTz3UXWihl5l2Vnm8y9dIayXIF9EafV6F73vgGR+uoBAq4p8qxSq72QfUSGx2yuDP2Hjw4ktlg=="],
"@deepseek-ai/dsh-atomic-write": ["@deepseek-ai/dsh-atomic-write@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-atomic-write/-/dsh-atomic-write-0.2.1-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-yCvLoL55Uoaxsi06I8hOcPgvMOhIt7w8vE/FLe3iGzvGq5OfrqkanAmt++apq069Jo6R/fP552XOhBlW8S3utg=="],
@@ -116,6 +121,8 @@
"@deepseek-ai/dsh-client-file-upload": ["@deepseek-ai/dsh-client-file-upload@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-client-file-upload/-/dsh-client-file-upload-0.2.1-rc.2.tgz", { "dependencies": { "@deepseek-ai/dsh-brand": "0.2.1-rc.2", "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2", "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-gLrn7EIxv59e/IBcxOv3x/1AMzkoBdjWAIAGnMLQOnPLl1HLddF1X1gLUAJ73XqhyPp4ePWmz/nReNOluTDN9A=="],
+ "@deepseek-ai/dsh-client-locale": ["@deepseek-ai/dsh-client-locale@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-client-locale/-/dsh-client-locale-0.2.1-rc.2.tgz", { "dependencies": { "@deepseek-ai/schemastery": "~3.18.5" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-TeNrG6Gpj83AZdTJblHixtBnPqu6++8XLv96r0bwRrGBD28rqysUU+xvf009YVnBlz0c8g8sdhNZK+WAfJPQ2Q=="],
+
"@deepseek-ai/dsh-client-ui-chat": ["@deepseek-ai/dsh-client-ui-chat@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-client-ui-chat/-/dsh-client-ui-chat-0.2.1-rc.2.tgz", { "dependencies": { "@deepseek-ai/schemastery": "~3.18.5" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-ZDT17V3+LoavO3CJZ5alZf6edoh3+Qnllb3Y2DKOSmJYfpbPvR45uWl2DYxcnU4EAW2XvT8/k6KkbklFR1ZV8Q=="],
"@deepseek-ai/dsh-client-ui-conversation": ["@deepseek-ai/dsh-client-ui-conversation@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-client-ui-conversation/-/dsh-client-ui-conversation-0.2.1-rc.2.tgz", { "dependencies": { "@deepseek-ai/schemastery": "~3.18.5" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-ER7x0so7Owcfgs5ok8WTv3PYcuHUbwUbv9IV2kP6PbxwB+SxCAXtSRRCKlnkTt3WFd+KbwbluVfWBByLJEl9hQ=="],
@@ -136,6 +143,8 @@
"@deepseek-ai/dsh-client-ui-tool": ["@deepseek-ai/dsh-client-ui-tool@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-client-ui-tool/-/dsh-client-ui-tool-0.2.1-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-661V8pHw7gBunPXKdMnDrabubtxbOC6P2Dd0DOjWqxmBKUAgLDtDcg0YRn8kRomK7dauJnCx7e6H1oy2DM+fRg=="],
+ "@deepseek-ai/dsh-client-ui-workspace": ["@deepseek-ai/dsh-client-ui-workspace@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-client-ui-workspace/-/dsh-client-ui-workspace-0.2.1-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-v3ov7wH9/0npaqpACSCAnVIgc5ho8NofeU2k06xGFNmZwxM3//KPeKzHd17/rzl32agw6G2NV2+FbxMx6l3xJw=="],
+
"@deepseek-ai/dsh-commands": ["@deepseek-ai/dsh-commands@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-commands/-/dsh-commands-0.2.1-rc.2.tgz", { "dependencies": { "@deepseek-ai/dsh-util-crypto": "0.2.1-rc.2", "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5", "@deepseek-ai/dsh-agent": "0.2.1-rc.2", "@deepseek-ai/dsh-attachment": "0.2.1-rc.2", "@deepseek-ai/dsh-brand": "0.2.1-rc.2", "@deepseek-ai/dsh-llm": "0.2.1-rc.2", "@deepseek-ai/dsh-scope": "0.2.1-rc.2", "@deepseek-ai/dsh-session": "0.2.1-rc.2", "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2" } }, "sha512-wLeEVXHuTULr0WYVHcCf1djQTEql2oxNSkbckbyfzw/r381ki1y39CPHw3Xtt6D9eXKulxTzgZzDPnHdh29QkQ=="],
"@deepseek-ai/dsh-compaction": ["@deepseek-ai/dsh-compaction@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-compaction/-/dsh-compaction-0.2.1-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5", "@deepseek-ai/dsh-brand": "0.2.1-rc.2", "@deepseek-ai/dsh-commands": "0.2.1-rc.2", "@deepseek-ai/dsh-llm": "0.2.1-rc.2", "@deepseek-ai/dsh-session": "0.2.1-rc.2" } }, "sha512-rag79zVeoMWXqKfIB0uRyx/1RRXSvucXJVUMOxM+iJxBgHObCn61RTLiFrI8M5DabHnmy+ugCGAeL5HwD/JPVw=="],
@@ -154,6 +163,8 @@
"@deepseek-ai/dsh-home-paths": ["@deepseek-ai/dsh-home-paths@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-home-paths/-/dsh-home-paths-0.2.1-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-+byrn2eS9XaFt6pl1yZXIgkm7GbbzTwzX3Eo19Ew9P1xzzucw/j2HSmdFo221/GK+5PH7ct1ldeT+fAxAJ1e9w=="],
+ "@deepseek-ai/dsh-host-directory-picker": ["@deepseek-ai/dsh-host-directory-picker@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-host-directory-picker/-/dsh-host-directory-picker-0.2.1-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-CXFXrq5wz77Hf66KFyyEoyDkDIMWyRDXhRUml033qm6Dg4+CnD7pXz3LdncmfXjYGQPT3HtloPU9RmH65JSmIQ=="],
+
"@deepseek-ai/dsh-launch-environment": ["@deepseek-ai/dsh-launch-environment@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-launch-environment/-/dsh-launch-environment-0.2.1-rc.2.tgz", { "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-EXoLY+5i71V2uBrqa1Pk+eamwqc/6Di7N2BIvqs9GDZ09RFk6ohsqs/hObev2Z/ehWr6BYIS112c1qbf/P79Wg=="],
"@deepseek-ai/dsh-llm": ["@deepseek-ai/dsh-llm@0.2.1-rc.2", "https://registry.npmmirror.com/@deepseek-ai/dsh-llm/-/dsh-llm-0.2.1-rc.2.tgz", { "dependencies": { "@deepseek-ai/dsh-brand": "0.2.1-rc.2", "@deepseek-ai/dsh-timeout": "0.2.1-rc.2", "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.2", "@deepseek-ai/dsh-util-crypto": "0.2.1-rc.2", "@deepseek-ai/dsh-util-values": "0.2.1-rc.2", "@deepseek-ai/schemastery": "~3.18.5", "zod": "4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "~4.0.5" } }, "sha512-3xfoMDHEpUJGcH2Lu+lNXUJYRBclYfBkdCG9Au02VzIjYAm9BrFmc/MNuaVTg0vwgvPpTsd6Fx1IeGtfmQFvlg=="],
@@ -608,8 +619,12 @@
"@deepseek-ai/dsh-api-session-controller/@deepseek-ai/schemastery": ["@deepseek-ai/schemastery@3.18.5", "https://registry.npmmirror.com/@deepseek-ai/schemastery/-/schemastery-3.18.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@standard-schema/spec": "^1.1.0" } }, "sha512-Mnobvqf9rHsZYv4g5edj5ddxS1oF1kIdSlz0DunUgnY3LWG5F26MqvH3YOOsSaodCgyzJ16OMN9O99/s5Crtdw=="],
+ "@deepseek-ai/dsh-api-workspace-controller/@deepseek-ai/schemastery": ["@deepseek-ai/schemastery@3.18.5", "https://registry.npmmirror.com/@deepseek-ai/schemastery/-/schemastery-3.18.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@standard-schema/spec": "^1.1.0" } }, "sha512-Mnobvqf9rHsZYv4g5edj5ddxS1oF1kIdSlz0DunUgnY3LWG5F26MqvH3YOOsSaodCgyzJ16OMN9O99/s5Crtdw=="],
+
"@deepseek-ai/dsh-client-connection/@deepseek-ai/schemastery": ["@deepseek-ai/schemastery@3.18.5", "https://registry.npmmirror.com/@deepseek-ai/schemastery/-/schemastery-3.18.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@standard-schema/spec": "^1.1.0" } }, "sha512-Mnobvqf9rHsZYv4g5edj5ddxS1oF1kIdSlz0DunUgnY3LWG5F26MqvH3YOOsSaodCgyzJ16OMN9O99/s5Crtdw=="],
+ "@deepseek-ai/dsh-client-locale/@deepseek-ai/schemastery": ["@deepseek-ai/schemastery@3.18.5", "https://registry.npmmirror.com/@deepseek-ai/schemastery/-/schemastery-3.18.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@standard-schema/spec": "^1.1.0" } }, "sha512-Mnobvqf9rHsZYv4g5edj5ddxS1oF1kIdSlz0DunUgnY3LWG5F26MqvH3YOOsSaodCgyzJ16OMN9O99/s5Crtdw=="],
+
"@deepseek-ai/dsh-client-ui-chat/@deepseek-ai/schemastery": ["@deepseek-ai/schemastery@3.18.5", "https://registry.npmmirror.com/@deepseek-ai/schemastery/-/schemastery-3.18.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@standard-schema/spec": "^1.1.0" } }, "sha512-Mnobvqf9rHsZYv4g5edj5ddxS1oF1kIdSlz0DunUgnY3LWG5F26MqvH3YOOsSaodCgyzJ16OMN9O99/s5Crtdw=="],
"@deepseek-ai/dsh-client-ui-conversation/@deepseek-ai/schemastery": ["@deepseek-ai/schemastery@3.18.5", "https://registry.npmmirror.com/@deepseek-ai/schemastery/-/schemastery-3.18.5.tgz", { "dependencies": { "@deepseek-ai/cosmokit": "~1.8.6", "@standard-schema/spec": "^1.1.0" } }, "sha512-Mnobvqf9rHsZYv4g5edj5ddxS1oF1kIdSlz0DunUgnY3LWG5F26MqvH3YOOsSaodCgyzJ16OMN9O99/s5Crtdw=="],
diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json
index 3af9f3811..60da1ead7 100644
--- a/plugins/dsh/package.json
+++ b/plugins/dsh/package.json
@@ -63,7 +63,10 @@
"@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-tool",
"@deepseek-ai/dsh-api-session-controller",
- "@deepseek-ai/dsh-client-ui-session"
+ "@deepseek-ai/dsh-client-ui-session",
+ "@deepseek-ai/dsh-client-ui-conversation",
+ "@deepseek-ai/dsh-api-workspace-controller",
+ "@deepseek-ai/dsh-client-ui-workspace"
],
"external": [
"react",
@@ -102,7 +105,9 @@
"@deepseek-ai/dsh-agent": "0.2.1-rc.2",
"@deepseek-ai/dsh-api-gateway": "0.2.1-rc.2",
"@deepseek-ai/dsh-api-session-controller": "0.2.1-rc.2",
+ "@deepseek-ai/dsh-api-workspace-controller": "0.2.1-rc.2",
"@deepseek-ai/dsh-app-boot": "0.2.1-rc.2",
+ "@deepseek-ai/dsh-client-locale": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-chat": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.2",
@@ -113,6 +118,7 @@
"@deepseek-ai/dsh-client-ui-sidebar": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-tool": "0.2.1-rc.2",
+ "@deepseek-ai/dsh-client-ui-workspace": "0.2.1-rc.2",
"@deepseek-ai/dsh-typert-generator": "0.2.0-rc.2",
"@types/bun": "1.3.13",
"@types/react": "18.3.1",
diff --git a/plugins/dsh/src/client/conversation-detail.tsx b/plugins/dsh/src/client/conversation-detail.tsx
index bd6087bbd..521529bf0 100644
--- a/plugins/dsh/src/client/conversation-detail.tsx
+++ b/plugins/dsh/src/client/conversation-detail.tsx
@@ -40,10 +40,10 @@ export function ConversationDetail({ reference, callId, originalSource, services
style={{ margin: '0 0 0 auto', height: '100dvh', maxHeight: '100dvh', width: 'min(720px, 100vw)', maxWidth: '100vw', boxSizing: 'border-box', border: '1px solid #8885', padding: 24, overflowY: 'auto', font: 'inherit', lineHeight: 1.6, overflowWrap: 'anywhere' }}>
-
+
;
}
-function Content({ reference, callId, originalSource, services, browseVersions, allowHistoricalReopen }: { reference: string; callId: string; originalSource: string; services: SearchViewServices; browseVersions?: () => void; allowHistoricalReopen: boolean }) {
+function Content({ reference, callId, originalSource, services, close, browseVersions, allowHistoricalReopen }: { reference: string; callId: string; originalSource: string; services: SearchViewServices; close(): void; browseVersions?: () => void; allowHistoricalReopen: boolean }) {
const [reopening, setReopening] = useState(false);
const query = useQuery({ queryKey: ['skillhub-conversation-detail', services.installation.sessionId, callId, reference, reopening], retry: false, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false,
queryFn: async ({ signal }) => {
@@ -64,7 +64,7 @@ function Content({ reference, callId, originalSource, services, browseVersions,
{reopening && 已重新核验详情。安装需要另行确认。
}
{detail.title}
@{detail.namespace}/{detail.slug} · {detail.version}
{detail.summary}
- { void query.refetch(); }} />
+ { void query.refetch(); }} />
{ void query.refetch(); }} cacheIdentity={JSON.stringify([services.installation.sessionId, context.sourceRevision, detail.namespace, detail.slug, detail.version])} onContextStale={() => { void query.refetch(); }} onSelectVersion={browseVersions} versionActionLabel="前往广场选择版本" />
来源信息
原搜索来源:{originalSource}
当前详情来源:{context.registryUrl}
>;
diff --git a/plugins/dsh/src/client/index.tsx b/plugins/dsh/src/client/index.tsx
index 4c944e1c0..3bd54b20e 100644
--- a/plugins/dsh/src/client/index.tsx
+++ b/plugins/dsh/src/client/index.tsx
@@ -1,3 +1,9 @@
+import { SkillDraftRetentions } from './use-skill-drafts.js';
+import { newSkillConversation } from './use-skill-navigation.js';
+import type {} from '@deepseek-ai/dsh-client-locale/client';
+import { captureSkillUse } from './use-skill-context.js';
+import { SkillUseBinding, SkillUseController, SkillUseFocus, SkillCaseChoices } from './use-skill.js';
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client';
import { resolveReceiptContext, receiptSessionGuard, type ReceiptReads } from './receipt-context.js';
import { UiAvailability, UiAvailabilityNotice } from './ui-availability.js';
import type { Context } from '@deepseek-ai/cordis';
@@ -55,7 +61,39 @@ export async function apply(ctx: Context): Promise<() => Promise> {
const upgradeRequests = new UpgradeRequests(() => window.sessionStorage);
const removalRequests = new RemovalRequests(() => window.sessionStorage);
ctx.effect(() => () => { requests.dispose(); upgradeRequests.dispose(); removalRequests.dispose(); client.clear(); });
- function installationFor(sessionId: string): InstallationServices {
+ const drafts = new SkillDraftRetentions(ctx);
+ ctx.effect(() => () => drafts.dispose());
+ const useSkills = new SkillUseController({
+ capture: (sessionId, actions) => captureSkillUse(ctx, sessionId, actions),
+ revision: async () => {
+ const response = await ctx.remote.skillhub.getContext();
+ if (!response.ok || !response.value.configured) throw new Error('来源不可用,请检查插件设置。');
+ return response.value.sourceRevision;
+ },
+ prepare: async (sessionId, revision, itemKey, signal) => {
+ const scope = ctx.sessions.scope(SessionId(sessionId));
+ if (!scope) throw new Error('当前会话已关闭。');
+ return withSessionRemote(scope, remote => remote.skillhub.prepareSkillUse(revision, itemKey, signal));
+ },
+ newConversation: (sessionId, revision, key, signal) => newSkillConversation(ctx, sessionId, revision, key, signal, async (target, source, abort) => {
+ const scope = ctx.sessions.scope(SessionId(target));
+ if (!scope) throw new Error('新对话尚未就绪,请重试。');
+ const response = await withSessionRemote(scope, remote => remote.skillhub.listInstalled(source, abort));
+ if (!response.ok) throw new Error('来源或安装记录已变化,请重试。');
+ return response.value;
+ }, drafts),
+ current: sessionId => (ctx.sessions.list.getSnapshot().byId[SessionId(sessionId)]?.retainedBy.mainView ?? 0) > 0,
+ subscribeContext: listener => ctx.sessions.list.subscribe(listener),
+ locale: () => ctx.get('locale')?.getLocale().active,
+ subscribeLocale: listener => ctx.on('locale/change', listener),
+ reveal: () => ctx.get('layout')?.selectPanel(null),
+ });
+ ctx.effect(() => () => useSkills.dispose());
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'skillhub-use-focus' },
+ props => ));
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ name: 'conversation.input.dock', id: 'skillhub-case-choices' },
+ props => ));
+ function installationFor(sessionId: string, catalog = false): InstallationServices {
const call = async (action: (remote: AgentContext['remote']) => Promise): Promise => {
const scope = ctx.sessions.scope(SessionId(sessionId));
if (!scope) throw new Error('当前会话已关闭,请重新打开会话。');
@@ -68,7 +106,7 @@ export async function apply(ctx: Context): Promise<() => Promise> {
operation: (id, host) => call(remote => remote.skillhub.getOperation(id, host)),
recoverOperation: input => call(remote => remote.skillhub.recoverOperation(input)),
};
- return { sessionId, requests, upgrades, registryIdentity: async revision => { const source = await ctx.remote.skillhub.getContext(); if (!source.ok || !source.value.configured || source.value.sourceRevision !== revision) throw new Error('来源已变化,请重新读取详情。'); return source.value.registryUrl; },
+ return { sessionId, requests, upgrades, skillUse: catalog ? useSkills.catalogServices(sessionId) : useSkills.services(sessionId), registryIdentity: async revision => { const source = await ctx.remote.skillhub.getContext(); if (!source.ok || !source.value.configured || source.value.sourceRevision !== revision) throw new Error('来源已变化,请重新读取详情。'); return source.value.registryUrl; },
checkVersionUpdate: (revision, itemKey, version, signal) => call(remote => remote.skillhub.checkVersionUpdate(revision, itemKey, version, signal)),
manageInstalled: (sourceRevision, item) => {
const layout = ctx.get('layout');
@@ -136,7 +174,7 @@ export async function apply(ctx: Context): Promise<() => Promise> {
layout.selectPanel(panelId);
} } };
},
- }, props => <>>));
+ }, props => ));
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
name: 'tool.call.toolview', key: 'astron_skillhub_search',
inject: sessionId => ({ services: {
@@ -173,7 +211,7 @@ export async function apply(ctx: Context): Promise<() => Promise> {
return withSessionRemote(scope, remote => remote.skillhub.acknowledgeConversationDetail(reference, token));
},
} }),
- }, props => <>>));
+ }, props => ));
ctx.inject(['layout'], (ctx) => {
ctx.slots.inject('main', () => availability.enable('main'));
ctx.slots.inject('sidebar.panellist', () => availability.enable('sidebar'));
@@ -181,8 +219,8 @@ export async function apply(ctx: Context): Promise<() => Promise> {
children: { 'skillhub.catalog': { kind: 'single', scope: 'session-maybe' } },
}, ({ renderSlot }) => renderSlot('skillhub.catalog', {})));
ctx.slots.inject('skillhub.catalog', () => ctx.slots.register({ name: 'skillhub.catalog',
- inject: sessionId => ({ installation: sessionId ? installationFor(sessionId) : undefined,
- installed: sessionId ? { sessionId, upgradeRequests, removalRequests, compatibility: () => ctx.remote.skillhub.getCompatibility(),
+ inject: sessionId => ({ installation: sessionId ? installationFor(sessionId, true) : undefined,
+ installed: sessionId ? { sessionId, skillUse: useSkills.catalogServices(sessionId), upgradeRequests, removalRequests, compatibility: () => ctx.remote.skillhub.getCompatibility(),
operation: installationFor(sessionId).operation,
recoverOperation: async input => {
const scope = ctx.sessions.scope(SessionId(sessionId));
@@ -216,8 +254,8 @@ export async function apply(ctx: Context): Promise<() => Promise> {
if (!scope) throw new Error('当前会话已关闭,请重新打开会话。');
return withSessionRemote(scope, remote => remote.skillhub.checkUpdate(revision, itemKey, signal));
} } satisfies InstalledServices : undefined }),
- }, ({ installation, installed }) => ctx.layout.selectPanel(null)} />));
+ }, ({ installation, installed, inputActions, sessionId }) => ctx.layout.selectPanel(null)} />));
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: panelId, label: '技能中心', order: 10 }, () => ));
});
});
diff --git a/plugins/dsh/src/client/install-control.tsx b/plugins/dsh/src/client/install-control.tsx
index cfd2a4ed0..c851247ba 100644
--- a/plugins/dsh/src/client/install-control.tsx
+++ b/plugins/dsh/src/client/install-control.tsx
@@ -1,3 +1,4 @@
+import { UseSkillButton, type SkillUseServices } from './use-skill.js';
import { UpgradeControl, type UpgradeServices } from './upgrade-control.js';
import { InstallationProgressSummary, type InstallationProgressJob } from './installation-progress.js';
import { isInventoryScopeVerified } from './installed-inventory.js';
@@ -12,6 +13,7 @@ import type { InstallationRequests } from './installation-requests.js';
export interface InstallationServices extends CompatibilityServices {
sessionId: string;
+ skillUse?: SkillUseServices;
upgrades?: UpgradeServices;
registryIdentity?(revision: string): Promise;
checkVersionUpdate?(revision: string, itemKey: string, version: string, signal: AbortSignal): Promise>;
@@ -127,6 +129,7 @@ export function InstallControl({ detail, sourceRevision, services, initiatedFrom
{inventoryVerified && !current && (!attempt || completed) && }
+ {inventoryVerified && current?.state === 'present' && current.version === detail.version && }
{current && completed && (result.warningCount > 0 || !!result.resultDetails?.warnings.length) && 安装有告警,请在已安装中核验。
}
{submissionError && {submissionError}
}
{progressVisible && attempt && 关闭窗口不会取消已提交的安装。
>
: { setJob(value); expand(); }} onContextStale={() => { void result.refetch(); }} />}
- {job && }
+ {job && }
;
}
diff --git a/plugins/dsh/src/client/install-tool-view.tsx b/plugins/dsh/src/client/install-tool-view.tsx
index c3b8530df..a44ef29a3 100644
--- a/plugins/dsh/src/client/install-tool-view.tsx
+++ b/plugins/dsh/src/client/install-tool-view.tsx
@@ -1,3 +1,4 @@
+import { exactInstalledSkill, UseSkillButton } from './use-skill.js';
import { matchesReceipt, resolveReceiptContext, readReceiptInventory, type ReceiptReads } from './receipt-context.js';
import { receiptInventoryIssue, receiptPresentation } from './install-receipt-presentation.js';
import { Button } from './ui.js';
@@ -22,7 +23,7 @@ export interface InstallViewServices {
client: QueryClient;
receipt: ReceiptReads;
confirmation?: SearchViewServices;
- installation: Pick;
+ installation: Pick;
openInstalled(result: InstallResult): Promise;
}
@@ -97,7 +98,11 @@ function InstallationResult({ result, services, receipt, openInstalled }: {
v{result.version} · {result.targetLabel}
{presentation.message}
- ');
expect(html).not.toContain('>安装到此项目'); expect(html).not.toContain('安装完成');
+ expect(html).toContain('>使用此技能');
expect(html).not.toContain('当前范围未安装'); expect(html).not.toContain('DSH Host 上该用户');
});
@@ -42,3 +43,9 @@ test('empty verified inventory renders an explicit scoped install action', () =>
expect(html).not.toContain('安装完成'); expect(html).not.toContain('重新安装到');
expect(html).not.toContain('>卸载');
});
+
+test('use entry belongs to the installed version and never a different viewed version or invalid record', () => {
+ for (const item of [{ ...record, version: '1.1.0' }, { ...record, state: 'missing' as const }, { ...record, state: 'mismatch' as const }]) {
+ expect(render([item])).not.toContain('>使用此技能');
+ }
+});
diff --git a/plugins/dsh/test/package.test.ts b/plugins/dsh/test/package.test.ts
index ac761d41e..001ce0b78 100644
--- a/plugins/dsh/test/package.test.ts
+++ b/plugins/dsh/test/package.test.ts
@@ -55,8 +55,8 @@ describe('native M0 package', () => {
});
test('official generator publishes the declared endpoints with scoped writes and strict codec', () => {
- expect(TYPERT_REMOTE.descriptors.map(item => item.method).sort()).toEqual(['acknowledgeConversationDetail', 'checkUpdate', 'checkVersionUpdate', 'executePlan', 'getCompatibility', 'getContext', 'getInstalledDetail', 'getOperation', 'getSkillDetail', 'getStatus', 'listCatalog', 'listCatalogByLabel', 'listInstallTargets', 'listInstalled', 'listLabels', 'listReviews', 'listSkills', 'listVersions', 'openConversationDetail', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'recoverOperation', 'reopenConversationDetail', 'startInstall', 'testRegistryConnection']);
- for (const method of ['openConversationDetail', 'reopenConversationDetail', 'acknowledgeConversationDetail', 'listInstallTargets', 'listInstalled', 'getInstalledDetail', 'checkUpdate', 'checkVersionUpdate', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'executePlan', 'startInstall', 'getOperation', 'recoverOperation']) {
+ expect(TYPERT_REMOTE.descriptors.map(item => item.method).sort()).toEqual(['acknowledgeConversationDetail', 'checkUpdate', 'checkVersionUpdate', 'executePlan', 'getCompatibility', 'getContext', 'getInstalledDetail', 'getOperation', 'getSkillDetail', 'getStatus', 'listCatalog', 'listCatalogByLabel', 'listInstallTargets', 'listInstalled', 'listLabels', 'listReviews', 'listSkills', 'listVersions', 'openConversationDetail', 'prepareSkillUse', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'recoverOperation', 'reopenConversationDetail', 'startInstall', 'testRegistryConnection']);
+ for (const method of ['openConversationDetail', 'reopenConversationDetail', 'acknowledgeConversationDetail', 'listInstallTargets', 'listInstalled', 'getInstalledDetail', 'checkUpdate', 'checkVersionUpdate', 'prepareSkillUse', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'executePlan', 'startInstall', 'getOperation', 'recoverOperation']) {
const scoped = TYPERT_REMOTE.descriptors.find(item => item.method === method)!;
expect(scoped.invocation).toMatchObject({ kind: 'context', context: 'agent', wire: 'agentId' });
}
@@ -143,3 +143,33 @@ test('shared RPC ABI keeps Host/Client descriptors, cancellation, argument order
expect(imports).toContain('@deepseek-ai/cordis');
expect(imports).toContain('@deepseek-ai/dsh-typert-protocol');
});
+
+
+test('skill-use RPC exposes only an agent-bound cancellable read and a restricted handoff projection', () => {
+ const use = TYPERT_REMOTE.descriptors.find(item => item.method === 'prepareSkillUse')!;
+ expect(use.invocation).toMatchObject({ kind: 'context', context: 'agent', wire: 'agentId' });
+ expect(use.parameters.map(item => ({ name: item.name, wire: item.wire, source: item.source }))).toEqual([
+ { name: 'sourceRevision', wire: 'sourceRevision', source: 'json' },
+ { name: 'itemKey', wire: 'itemKey', source: 'json' },
+ ]);
+ expect(use.cancellation).toEqual({ parameter: 'signal' });
+ for (const parameter of use.parameters) {
+ const parameterCodec = parameter.codec;
+ if (parameterCodec.mode !== 'strict') throw new Error('Expected strict parameter codec');
+ expect(() => parameterCodec.create().parse({ path: '/caller-controlled' })).toThrow();
+ }
+ if (use.result?.mode !== 'strict') throw new Error('Expected strict skill-use result codec');
+ const codec = use.result.create();
+ const ready = { status: 'ready', name: 'actual-name', version: '1.2.0', scope: 'project', examples: { state: 'none' } };
+ expect(codec.parse(ready)).toEqual(ready);
+ const withExamples = { ...ready, examples: { state: 'ready', digest: '0'.repeat(64), defaultLocale: 'zh-CN', items: {
+ 'zh-CN': [{ id: 'summary', title: '整理会议', prompt: '使用 {{skill}} 整理。' }],
+ en: [{ id: 'summary', title: 'Summary', prompt: 'Use {{skill}}.' }],
+ } } };
+ expect(codec.parse(withExamples)).toEqual(withExamples);
+ expect(() => codec.parse({ ...ready, examples: { state: 'ready', items: {} } })).toThrow();
+ expect(codec.parse({ ...ready, path: '/private', content: 'private skill body' })).toEqual(ready);
+ expect(codec.parse({ status: 'blocked', reason: 'user-invocation-disabled' })).toEqual({ status: 'blocked', reason: 'user-invocation-disabled' });
+ expect(() => codec.parse({ ...ready, scope: 'arbitrary' })).toThrow();
+ expect(() => codec.parse({ status: 'blocked', reason: 'arbitrary' })).toThrow();
+});
diff --git a/plugins/dsh/test/prepare-skill-use.test.ts b/plugins/dsh/test/prepare-skill-use.test.ts
new file mode 100644
index 000000000..a80576f58
--- /dev/null
+++ b/plugins/dsh/test/prepare-skill-use.test.ts
@@ -0,0 +1,245 @@
+import { expect, test } from 'bun:test';
+import { Context, symbols } from '@deepseek-ai/cordis';
+import { FileSystemSkillProvider } from '@deepseek-ai/dsh-skill-filesystem';
+import { readSkillExamples } from '../../../cli/src/services/skill-examples.js';
+import { randomBytes, createHash } from 'node:crypto';
+import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import SkillHubService from '../src/host/index.js';
+import type { LoadedSkill } from '../src/host/skill-discovery.js';
+import type { InstalledProjectionItem, RemovalBaseline } from '../src/host/generated/native-contract.js';
+import type { SkillUsePreparation } from '../src/host/types.js';
+
+async function fixture(run: (f: {
+ use(signal?: AbortSignal, key?: string): Promise
; key: string; directory: string;
+ setLookup(callback: (loaded: LoadedSkill) => Promise): void;
+ change(kind: string): void; observations(): number;
+}) => Promise, frontmatter = '', examples?: string) {
+ const root = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-use-')));
+ const previousHome = process.env.DSH_HOME;
+ const home = join(root, 'home'); const cwd = join(root, 'project');
+ const directory = join(home, 'skills', 'market-slug');
+ await mkdir(directory, { recursive: true }); await mkdir(cwd); process.env.DSH_HOME = home;
+ const body = `---\nname: actual-name\ndescription: Prepare documents\n${frontmatter}---\nUse references/note.md.`;
+ await writeFile(join(directory, 'SKILL.md'), body);
+ await mkdir(join(directory, 'references')); await writeFile(join(directory, 'references/note.md'), 'original');
+ if (examples !== undefined) { await mkdir(join(directory, '.skillhub')); await writeFile(join(directory, '.skillhub/examples.json'), examples); }
+ const ctx = new Context(); const controller = new AbortController();
+ const provider = new FileSystemSkillProvider(ctx, { signal: controller.signal, invalidate: () => {} }, {
+ providerName: 'fixture', includeDefaultRoots: false, customSkillDirs: [directory], watch: false,
+ });
+ try {
+ const listed = await provider.list({ signal: controller.signal });
+ const candidates = Array.isArray(listed) ? listed : listed.candidates;
+ const loaded = await provider.get(candidates[0]!, { signal: controller.signal });
+ if (!loaded) throw new Error('Missing fixture skill');
+ let lookup = async (skill: LoadedSkill): Promise => skill;
+ let revision = 'source'; let observations = 0; let pendingOperations = 0; let shared = false;
+ const fs = { processPathFromHostPath: (path: string) => path,
+ resolve: async (path: string) => ({ displayPath: path }), processPath: (target: { displayPath: string }) => target.displayPath,
+ listDir: async (target: { displayPath: string }) => (await readdir(target.displayPath, { withFileTypes: true })).map(entry => ({
+ name: entry.name, type: entry.isDirectory() ? 'directory' : 'file', target: { displayPath: join(target.displayPath, entry.name) },
+ })),
+ stat: async (target: { displayPath: string }) => ({ type: (await lstat(target.displayPath)).isDirectory() ? 'directory' : 'file' }),
+ readText: (target: { displayPath: string }) => readFile(target.displayPath, 'utf8'),
+ };
+ let currentFs = fs;
+ const session = { id: 'session-a', header: { cwd } };
+ const agent = { session, ctx };
+ const nativeGet = ctx.get.bind(ctx);
+ ctx.get = ((name: string) => name === 'fs' ? currentFs : nativeGet(name as never)) as typeof ctx.get;
+ const skills = { get: async (name: string, options: { cwd: string; scope: unknown; signal: AbortSignal }) => {
+ expect(name).toBe('actual-name'); expect(options.cwd).toBe(cwd); expect(options.scope).toBe(agent);
+ options.signal.throwIfAborted(); observations++; return lookup(loaded);
+ } };
+ let currentSkills: (typeof skills & { [symbols.original]?: object }) | undefined = skills;
+ let currentAgent: typeof agent | undefined = agent;
+ const item: InstalledProjectionItem = { namespace: 'demo', slug: 'market-slug', version: '1.2.0', scope: 'user',
+ state: 'present', fingerprint: 'fingerprint', targetPath: directory };
+ const items = [item];
+ const baseline: RemovalBaseline = { status: 'ready', kind: 'uninstall', inventoryItem: {
+ registry: 'https://registry.example', namespace: item.namespace, slug: item.slug, version: item.version,
+ fingerprint: item.fingerprint, targets: [{ agent: 'dsh', rootDir: join(home, 'skills'), installDir: directory, installedAt: 'now' }],
+ }, target: { scope: 'user', root: join(home, 'skills'), path: directory, files: examples === undefined ? {} : { '.skillhub/examples.json': createHash('sha256').update(examples).digest('hex') }, metadata: {
+ registry: 'https://registry.example', namespace: item.namespace, slug: item.slug, version: item.version,
+ } } };
+ const runtime = { getStatus: () => ({ state: 'ready', pendingOperations }),
+ readSkillExamples,
+ listInstalled: async () => ({ items, complete: true, issues: [] }),
+ inspectRemoval: async (): Promise => {
+ if (shared) return { status: 'blocked', reason: 'shared-target' };
+ // Model the existing baseline's all-resource checks; real bundled core is covered by native acceptance.
+ if (await readFile(join(directory, 'SKILL.md'), 'utf8') !== body
+ || await readFile(join(directory, 'references/note.md'), 'utf8') !== 'original'
+ || (examples !== undefined && await readFile(join(directory, '.skillhub/examples.json'), 'utf8') !== examples)) {
+ return { status: 'blocked', reason: 'local-changes' };
+ }
+ return structuredClone(baseline);
+ } };
+ const service = Object.assign(Object.create(SkillHubService.prototype), {
+ lifetime: controller, installedReferenceKey: randomBytes(32), localRuntime: runtime,
+ ctx: { invocation: { request: { args: { agentId: 'session-a' } } }, get: (name: string) =>
+ name === 'agents' ? { get: () => currentAgent } : name === 'skills' ? currentSkills : undefined },
+ assertSource: (value: string) => { if (value !== revision) throw new Error('source changed'); },
+ getContext: () => ({ configured: true, registryUrl: 'https://registry.example' }),
+ });
+ const key = (await service.listInstalled('source', new AbortController().signal)).items[0].itemKey;
+ await run({ use: (signal = new AbortController().signal, selected = key) => service.prepareSkillUse('source', selected, signal),
+ key, directory, observations: () => observations, setLookup: value => { lookup = value; }, change: kind => {
+ if (kind === 'source') revision = 'other';
+ if (kind === 'session') session.id = 'session-b';
+ if (kind === 'cwd') session.header.cwd = home;
+ if (kind === 'agent') currentAgent = { ...agent };
+ if (kind === 'fs') currentFs = { ...fs };
+ if (kind === 'skills') currentSkills = { ...skills };
+ if (kind === 'proxy') currentSkills = { ...skills, [symbols.original]: skills };
+ if (kind === 'missing-skills') currentSkills = undefined;
+ if (kind === 'runtime') service.localRuntime = { ...runtime };
+ if (kind === 'record') items.length = 0;
+ if (kind === 'version') item.version = '2.0.0';
+ if (kind === 'shared') shared = true;
+ if (kind === 'pending') pendingOperations = 1;
+ if (kind === 'dispose') controller.abort();
+ } });
+ } finally {
+ await provider.dispose();
+ if (previousHome === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previousHome;
+ await rm(root, { recursive: true, force: true });
+ }
+}
+
+test('use returns actual SKILL.md name, never the market slug or local paths', async () => {
+ await fixture(async f => {
+ expect(await f.use()).toEqual({ status: 'ready', name: 'actual-name', version: '1.2.0', scope: 'user', examples: { state: 'none' } });
+ expect(f.observations()).toBe(2);
+ f.change('proxy'); expect((await f.use()).status).toBe('ready');
+ });
+});
+
+test('unavailable and overridden native winners cannot authorize an invocation', async () => {
+ await fixture(async f => {
+ f.setLookup(async () => undefined);
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'skill-unavailable' });
+ f.setLookup(async loaded => ({ ...loaded, resourceBase: { kind: 'directory', path: '/other' } }));
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'skill-shadowed' });
+ f.setLookup(async loaded => ({ ...loaded, content: 'modified winner' }));
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'skill-shadowed' });
+ });
+});
+
+test('actual user-invocable policy blocks handoff even when model invocation is allowed', async () => {
+ await fixture(async f => {
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'user-invocation-disabled' });
+ }, 'user-invocable: false\n');
+});
+
+for (const filename of ['SKILL.md', 'references/note.md']) {
+ test(`changed ${filename} is blocked before discovery and after asynchronous lookup`, async () => {
+ await fixture(async f => {
+ f.setLookup(async loaded => { await writeFile(join(f.directory, filename), 'changed'); return loaded; });
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'files-changed' });
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'files-changed' });
+ expect(f.observations()).toBe(1);
+ });
+ });
+}
+
+for (const kind of ['source', 'session', 'cwd', 'agent', 'fs', 'skills', 'runtime', 'record', 'version', 'dispose']) {
+ test(`use rejects ${kind} changing during native discovery`, async () => {
+ await fixture(async f => {
+ f.setLookup(async loaded => { f.change(kind); return loaded; });
+ await expect(f.use()).rejects.toThrow();
+ });
+ });
+}
+
+for (const [kind, reason] of [['shared', 'shared-installation'], ['pending', 'operation-pending'], ['missing-skills', 'host-unavailable']] as const) {
+ test(`use blocks ${kind} without reading a native skill`, async () => {
+ await fixture(async f => {
+ f.change(kind); expect(await f.use()).toEqual({ status: 'blocked', reason }); expect(f.observations()).toBe(0);
+ });
+ });
+}
+
+test('stale and forged references and aborted requests never load a skill', async () => {
+ await fixture(async f => {
+ await expect(f.use(AbortSignal.abort())).rejects.toThrow();
+ await expect(f.use(undefined, `${f.key}!`)).rejects.toThrow();
+ f.change('record'); await expect(f.use()).rejects.toThrow(); expect(f.observations()).toBe(0);
+ });
+});
+
+test('winner is checked again after the second filesystem baseline', async () => {
+ await fixture(async f => {
+ let lookups = 0;
+ f.setLookup(async loaded => ++lookups === 1 ? loaded : { ...loaded,
+ resourceBase: { kind: 'directory', path: '/replacement' } });
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'skill-shadowed' });
+ expect(lookups).toBe(2);
+ });
+});
+
+test('deleted local resources never produce a ready handoff', async () => {
+ await fixture(async f => {
+ f.setLookup(async loaded => { await rm(join(f.directory, 'references/note.md')); return loaded; });
+ expect((await f.use()).status).toBe('blocked');
+ });
+});
+
+test('late native service replacement and cancellation remain errors at the final lookup', async () => {
+ for (const kind of ['skills', 'dispose', 'cwd']) {
+ await fixture(async f => {
+ let lookups = 0;
+ f.setLookup(async loaded => { if (++lookups === 2) f.change(kind); return loaded; });
+ await expect(f.use()).rejects.toThrow();
+ expect(lookups).toBe(2);
+ });
+ }
+});
+
+
+test('an operation becoming pending during final discovery blocks a ready handoff', async () => {
+ await fixture(async f => {
+ let lookups = 0;
+ f.setLookup(async loaded => { if (++lookups === 2) f.change('pending'); return loaded; });
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'operation-pending' });
+ expect(lookups).toBe(2);
+ });
+});
+
+const multilingual = JSON.stringify({ schemaVersion: 1, defaultLocale: 'zh-CN', examples: {
+ 'zh-CN': [{ id: 'summary', title: '会议纪要', prompt: '请使用 {{skill}} 整理会议。' }],
+ en: [{ id: 'summary', title: 'Summary', prompt: 'Use {{skill}} to summarize.' }],
+} });
+test('Host returns exact selected installation examples with raw digest and no path disclosure', async () => {
+ await fixture(async f => {
+ const prepared = await f.use(); expect(prepared.status).toBe('ready');
+ if (prepared.status !== 'ready') throw new Error('must be ready');
+ expect(prepared.name).toBe('actual-name'); expect(prepared.examples.state).toBe('ready');
+ if (prepared.examples.state !== 'ready') throw new Error('must have examples');
+ expect(prepared.examples.digest).toBe(createHash('sha256').update(multilingual).digest('hex'));
+ expect(prepared.examples.items.en?.[0]?.title).toBe('Summary');
+ expect(JSON.stringify(prepared)).not.toContain(f.directory);
+ }, '', multilingual);
+});
+test('invalid packaged examples remain ordinary protected content and retain custom use', async () => {
+ await fixture(async f => {
+ const prepared = await f.use(); expect(prepared.status).toBe('ready');
+ if (prepared.status === 'ready') expect(prepared.examples).toEqual({ state: 'invalid' });
+ }, '', '{not json');
+});
+test('examples modified during native discovery prevent ready even if valid protocol', async () => {
+ await fixture(async f => {
+ f.setLookup(async loaded => { await writeFile(join(f.directory, '.skillhub/examples.json'), multilingual + ' '); return loaded; });
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'files-changed' });
+ }, '', multilingual);
+});
+
+test('examples changed at final winner lookup are blocked by the last content barrier', async () => {
+ await fixture(async f => {
+ let lookups = 0;
+ f.setLookup(async loaded => { if (++lookups === 2) await writeFile(join(f.directory, '.skillhub/examples.json'), multilingual + ' '); return loaded; });
+ expect(await f.use()).toEqual({ status: 'blocked', reason: 'files-changed' });
+ }, '', multilingual);
+});
diff --git a/plugins/dsh/test/runtime-execution.test.ts b/plugins/dsh/test/runtime-execution.test.ts
index 8819b667b..4f6581e4d 100644
--- a/plugins/dsh/test/runtime-execution.test.ts
+++ b/plugins/dsh/test/runtime-execution.test.ts
@@ -4,6 +4,7 @@ import type { NativeRuntime, NativeInstallExecution, NativeRecoveryResult, Nativ
function native(): NativeRuntime {
return {
+ readSkillExamples: async () => ({ state: 'none' }),
observeInstallation: async () => false,
hostInstanceId: 'fixture', listPending: async () => [], getOperation: async () => undefined,
acceptInstall: async () => undefined,
diff --git a/plugins/dsh/test/use-skill-context.test.ts b/plugins/dsh/test/use-skill-context.test.ts
new file mode 100644
index 000000000..fd833bc53
--- /dev/null
+++ b/plugins/dsh/test/use-skill-context.test.ts
@@ -0,0 +1,60 @@
+import { expect, test } from 'bun:test';
+import { Context, Service } from '@deepseek-ai/cordis';
+import { SessionId } from '@deepseek-ai/dsh-session/types';
+import type { InputState } from '@deepseek-ai/dsh-client-ui-conversation/client';
+import { captureSkillUse } from '../src/client/use-skill-context.js';
+import { SkillUseController } from '../src/client/use-skill.js';
+
+async function fixture() {
+ const ctx = new Context();
+ const scope = new Context();
+ const binding = {};
+ const listeners = new Set<() => void>();
+ let record = { cwd: '/workspace', retainedBy: { mainView: 1 } };
+ let state: InputState = { draft: '原草稿', draftRev: 1, phase: 'plain', attachmentIds: [], occurrences: [], queue: [] };
+ const input = { state: { getSnapshot: () => state }, focus() {} };
+ class Conversation extends Service { input = { for: () => input }; constructor(ctx: Context) { super(ctx, 'conversation'); } }
+ class Layout extends Service { constructor(ctx: Context) { super(ctx, 'layout'); } selectPanel() {} }
+ const cleanup = [ctx.provide('sessions', {
+ scope: () => scope, binding: () => binding,
+ list: { getSnapshot: () => ({ byId: { [SessionId('session')]: record } }), subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; } },
+ } as never)];
+ let conversation = ctx.plugin(Conversation); let layout = ctx.plugin(Layout);
+ await conversation; await layout;
+ const actions = { captureInsertion: () => ({ start: 0, end: 0, draftRev: state.draftRev }), insertText: (text: string) => { state = { ...state, draft: text + state.draft, draftRev: state.draftRev + 1 }; return true; } };
+ const controller = new SkillUseController({ capture: (sessionId, actions) => captureSkillUse(ctx, sessionId, actions), revision: async () => 'source',
+ prepare: async () => ({ ok: true, value: { status: 'ready', name: 'verified-name', version: '1', scope: 'project', examples: { state: 'none' } } }), reveal() {} });
+ controller.bind('session', actions);
+ return { ctx, controller, actions, state: () => state, listeners, update: (next: typeof record) => { record = next; for (const listener of listeners) listener(); },
+ replace: async (kind: 'conversation' | 'layout') => {
+ if (kind === 'conversation') { await conversation.dispose(); conversation = ctx.plugin(Conversation); await conversation; }
+ else { await layout.dispose(); layout = ctx.plugin(Layout); await layout; }
+ },
+ cleanup: async () => { controller.dispose(); await conversation.dispose(); await layout.dispose(); for (const remove of cleanup) await remove(); },
+ };
+}
+
+test('real Cordis service facades change identity without blocking native draft preparation', async () => {
+ const f = await fixture();
+ try {
+ expect(f.ctx.get('conversation')).not.toBe(f.ctx.get('conversation'));
+ expect(f.ctx.get('layout')).not.toBe(f.ctx.get('layout'));
+ await f.controller.services('session').useSkill('source', 'item', new AbortController().signal);
+ expect(f.state().draft).toContain('请使用 /verified-name'); expect(f.state().draft).toContain('原草稿');
+ expect(f.listeners.size).toBe(0);
+ } finally { await f.cleanup(); }
+});
+for (const kind of ['conversation', 'layout'] as const) {
+ test(`real Cordis ${kind} replacement invalidates the captured input even if its methods are unchanged`, async () => {
+ const f = await fixture(); const capture = captureSkillUse(f.ctx, 'session', f.actions);
+ try { capture.assertCurrent(); await f.replace(kind); expect(capture.assertCurrent).toThrow('当前会话或输入框已变化'); }
+ finally { capture.dispose?.(); await f.cleanup(); }
+ });
+}
+test('real session subscription latches workspace and main-view changes even after switching back', async () => {
+ for (const change of [{ cwd: '/other', retainedBy: { mainView: 1 } }, { cwd: '/workspace', retainedBy: { mainView: 0 } }]) {
+ const f = await fixture(); const capture = captureSkillUse(f.ctx, 'session', f.actions);
+ try { f.update(change); f.update({ cwd: '/workspace', retainedBy: { mainView: 1 } }); expect(capture.assertCurrent).toThrow(); }
+ finally { capture.dispose?.(); await f.cleanup(); }
+ }
+});
diff --git a/plugins/dsh/test/use-skill-drafts.test.ts b/plugins/dsh/test/use-skill-drafts.test.ts
new file mode 100644
index 000000000..348488c2b
--- /dev/null
+++ b/plugins/dsh/test/use-skill-drafts.test.ts
@@ -0,0 +1,82 @@
+import { expect, test } from 'bun:test';
+import { Context } from '@deepseek-ai/cordis';
+import type { InputState } from '@deepseek-ai/dsh-client-ui-conversation/client';
+import { SkillDraftRetentions } from '../src/client/use-skill-drafts.js';
+
+function fixture() {
+ const ctx = new Context();
+ const listListeners = new Set<() => void>();
+ const records = new Map>();
+ function record(draft = '未发送草稿') {
+ let state: InputState = { draft, attachmentIds: [], occurrences: [], draftRev: 1, phase: 'plain', queue: [] };
+ let sessionState = { removed: false, pendingSubmissions: [] as unknown[] };
+ const inputListeners = new Set<() => void>(), sessionListeners = new Set<() => void>();
+ const scope = new Context();
+ const input = { state: { getSnapshot: () => state, subscribe: (listener: () => void) => { inputListeners.add(listener); return () => { inputListeners.delete(listener); }; } } };
+ const session = { getSnapshot: () => sessionState, subscribe: (listener: () => void) => { sessionListeners.add(listener); return () => { sessionListeners.delete(listener); }; } };
+ return { scope, binding: { session }, input, main: 1, refs: 0, releases: 0,
+ state: () => state, update: (value: Partial) => { state = { ...state, ...value }; for (const listener of inputListeners) listener(); },
+ pending: (count: number) => { sessionState = { ...sessionState, pendingSubmissions: Array(count).fill({}) }; for (const listener of sessionListeners) listener(); },
+ remove: () => { sessionState = { ...sessionState, removed: true }; for (const listener of sessionListeners) listener(); },
+ listeners: () => inputListeners.size + sessionListeners.size,
+ };
+ }
+ ctx.provide('sessions', {
+ scope: (id: string) => records.get(id)?.scope, binding: (id: string) => records.get(id)?.binding,
+ list: { getSnapshot: () => ({ byId: Object.fromEntries([...records].map(([id, value]) => [id, { retainedBy: { mainView: value.main } }])) }),
+ subscribe: (listener: () => void) => { listListeners.add(listener); return () => { listListeners.delete(listener); }; } },
+ retain: (id: string, options: unknown) => { expect(options).toEqual({ source: 'workspaceOperation' }); const value = records.get(id)!; value.refs++;
+ return { ready: Promise.resolve(value.binding), release: () => { value.refs--; value.releases++; } }; },
+ } as never);
+ ctx.provide('conversation', { input: { for: (scope: Context) => [...records.values()].find(value => value.scope === scope)!.input } } as never);
+ const drafts = new SkillDraftRetentions(ctx);
+ return { drafts, listListeners, add: (id: string, draft?: string) => { const value = record(draft); records.set(id, value); return value; },
+ notify: () => { for (const listener of listListeners) listener(); },
+ };
+}
+test('native origin binding stays retained while away and transfers ownership when mainView returns', () => {
+ const f = fixture(), original = f.add('old');
+ const state = original.state(); f.drafts.preserve('old'); expect(original.refs).toBe(1);
+ original.main = 0; f.notify(); expect(original.refs).toBe(1); expect(original.state()).toBe(state);
+ original.main = 1; f.notify(); expect(original.refs).toBe(0); expect(original.releases).toBe(1);
+ expect(original.listeners()).toBe(0); expect(f.listListeners.size).toBe(0); f.drafts.dispose(); expect(original.releases).toBe(1);
+});
+for (const content of ['attachment', 'reference', 'claimed', 'adjudicating', 'submitting', 'queue', 'pending-submission'] as const) {
+ test(`empty text still protects native ${content} without copying or rewriting it`, () => {
+ const f = fixture(), original = f.add('old', '');
+ if (content === 'attachment') original.update({ attachmentIds: ['native-image-id'] as never });
+ if (content === 'reference') original.update({ occurrences: [{ id: 'native-chip' }] as never });
+ if (content === 'claimed') original.update({ phase: 'claimed', claim: { name: 'command', token: '/command' } });
+ if (content === 'adjudicating' || content === 'submitting') original.update({ phase: content });
+ if (content === 'queue') original.update({ queue: [{}] as never });
+ if (content === 'pending-submission') original.pending(1);
+ const state = original.state(); f.drafts.preserve('old'); original.main = 0; f.notify();
+ expect(original.refs).toBe(1); expect(original.state()).toBe(state); f.drafts.dispose(); expect(original.refs).toBe(0);
+ });
+}
+test('a truly empty origin needs no retained reference', () => {
+ const f = fixture(), original = f.add('old', ''); f.drafts.preserve('old')(); expect(original.refs).toBe(0); expect(original.listeners()).toBe(0); f.drafts.dispose();
+});
+test('cleared draft releases after native submission settlement; a same-turn restored draft stays protected', async () => {
+ const f = fixture(), original = f.add('old'); f.drafts.preserve('old'); original.main = 0; f.notify();
+ original.update({ draft: '' }); original.update({ draft: '发送失败恢复的草稿' }); await Bun.sleep(5); expect(original.refs).toBe(1);
+ original.update({ draft: '' }); original.pending(1); await Bun.sleep(5); expect(original.refs).toBe(1);
+ original.pending(0); await Bun.sleep(5); expect(original.refs).toBe(0); f.drafts.dispose();
+});
+test('navigation failure rollback, session removal and plugin disposal release native references once', () => {
+ const f = fixture(), original = f.add('old'); const rollback = f.drafts.preserve('old'); rollback(); rollback(); expect(original.releases).toBe(1);
+ f.drafts.preserve('old'); original.remove(); expect(original.releases).toBe(2);
+ const other = f.add('other'); f.drafts.preserve('other'); f.drafts.dispose(); f.drafts.dispose(); expect(other.refs).toBe(0); expect(other.releases).toBe(1); expect(f.listListeners.size).toBe(0);
+});
+test('eight protected drafts are bounded without eviction; ninth is rejected and a returned origin frees capacity', () => {
+ const f = fixture(); const originals = Array.from({ length: 9 }, (_, i) => f.add(`old-${i}`));
+ for (let i = 0; i < 8; i++) { f.drafts.preserve(`old-${i}`); originals[i]!.main = 0; f.notify(); }
+ expect(() => f.drafts.preserve('old-8')).toThrow('8 个对话'); expect(originals.slice(0, 8).every(value => value.refs === 1)).toBe(true); expect(originals[8]!.refs).toBe(0);
+ originals[0]!.main = 1; f.notify(); f.drafts.preserve('old-8'); expect(originals[8]!.refs).toBe(1); f.drafts.dispose(); expect(originals.every(value => value.refs === 0)).toBe(true);
+});
+
+
+test('replacement of the actual original scope releases its retained generation', () => {
+ const f = fixture(), original = f.add('old'); f.drafts.preserve('old'); original.main = 0; f.notify();
+ f.add('old'); f.notify(); expect(original.refs).toBe(0); expect(original.releases).toBe(1); expect(original.listeners()).toBe(0); f.drafts.dispose();
+});
diff --git a/plugins/dsh/test/use-skill-navigation.test.ts b/plugins/dsh/test/use-skill-navigation.test.ts
new file mode 100644
index 000000000..bb4c3912a
--- /dev/null
+++ b/plugins/dsh/test/use-skill-navigation.test.ts
@@ -0,0 +1,71 @@
+import { expect, test } from 'bun:test';
+import { Context } from '@deepseek-ai/cordis';
+import { SessionId } from '@deepseek-ai/dsh-session/types';
+import { newSkillConversation } from '../src/client/use-skill-navigation.js';
+import type { InstalledSkillList } from '../src/host/types.js';
+
+function fixture() {
+ const ctx = new Context(); const scope = new Context(); const binding = {};
+ const listeners = new Set<() => void>(), workspaceListeners = new Set<() => void>();
+ const navigation = new AbortController();
+ let current = true, created = 0, retained = 0, released = 0, opened = '', createFailure = false;
+ let cwd = '/project';
+ const rows = () => ({ old: { cwd: '/project', retainedBy: { mainView: current ? 1 : 0 } }, fresh: { cwd, retainedBy: {} } });
+ let onCreate = () => {};
+ ctx.provide('sessions', {
+ scope: () => scope, binding: () => binding,
+ list: { getSnapshot: () => ({ byId: rows() }), subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; } },
+ create: async (options: unknown) => { expect(options).toEqual({ workspaceId: 'workspace', cwd: '/project' }); created++; onCreate(); if (createFailure) throw new Error('Host unavailable'); return SessionId('fresh'); },
+ retain: (_id: unknown, options: unknown) => { retained++; expect(options).toMatchObject({ source: 'workspaceOperation' }); return { ready: Promise.resolve({}), release: () => { released++; } }; },
+ } as never);
+ ctx.provide('workspaces', { list: { getSnapshot: () => ({ items: [{ workspaceId: 'workspace', sessionIds: [SessionId('old')] }] }), subscribe: (listener: () => void) => { workspaceListeners.add(listener); return () => { workspaceListeners.delete(listener); }; } } } as never);
+ ctx.provide('layout', { beginNavigation: () => navigation.signal } as never);
+ ctx.provide('uiWorkspace', { openSession: (id: string) => { opened = id; } } as never);
+ const original = { itemKey: 'old-key', namespace: 'global', slug: 'writer', version: '1', scope: 'user', state: 'present' } as const;
+ const reads: string[] = [];
+ let fresh = { ...original, itemKey: 'new-key' } as InstalledSkillList['items'][number];
+ const installed = async (id: string): Promise => { reads.push(id); return { complete: true, unavailable: [], issues: [], items: [id === 'old' ? original : fresh] }; };
+ let preserve = () => () => {};
+ const use = () => newSkillConversation(ctx, 'old', 'source', 'old-key', new AbortController().signal, installed, { preserve: () => preserve() });
+ return { use, reads, listeners, workspaceListeners, preserve: (callback: typeof preserve) => { preserve = callback; }, counts: () => ({ created, retained, released, opened }),
+ create: (callback: () => void) => { onCreate = callback; }, fail: () => { createFailure = true; },
+ supersede: () => navigation.abort(), move: () => { cwd = '/other'; },
+ change: () => { current = false; for (const listener of listeners) listener(); current = true; for (const listener of listeners) listener(); },
+ fresh: (value: Partial) => { fresh = { ...fresh, ...value }; },
+ };
+}
+test('fresh same-workspace session remaps the exact user install identity and releases temporary ownership', async () => {
+ const f = fixture(); expect(await f.use()).toEqual({ sessionId: SessionId('fresh'), itemKey: 'new-key' });
+ expect(f.reads).toEqual(['old', 'fresh']); expect(f.counts()).toEqual({ created: 1, retained: 1, released: 1, opened: 'fresh' });
+ expect(f.listeners.size).toBe(0); expect(f.workspaceListeners.size).toBe(0);
+});
+for (const change of [{ version: '2' }, { scope: 'project' }, { slug: 'other' }, { namespace: 'other' }, { state: 'missing' }] as const) {
+ test(`new session cannot substitute changed installation ${JSON.stringify(change)}`, async () => {
+ const f = fixture(); f.fresh(change); await expect(f.use()).rejects.toThrow('安装记录已变化');
+ expect(f.counts().opened).toBe(''); expect(f.counts().released).toBe(1);
+ });
+}
+for (const scenario of ['failure', 'superseded', 'session-ABA', 'wrong-cwd'] as const) {
+ test(`navigation ${scenario} leaves the original view and releases all temporary references`, async () => {
+ const f = fixture();
+ if (scenario === 'failure') f.fail();
+ if (scenario === 'superseded') f.create(f.supersede);
+ if (scenario === 'session-ABA') f.create(f.change);
+ if (scenario === 'wrong-cwd') f.create(f.move);
+ await expect(f.use()).rejects.toThrow();
+ expect(f.counts().opened).toBe(''); expect(f.counts().retained).toBe(f.counts().released);
+ expect(f.listeners.size).toBe(0); expect(f.workspaceListeners.size).toBe(0);
+ });
+}
+
+
+test('protected draft capacity refuses navigation before creating another session', async () => {
+ const f = fixture(); f.preserve(() => { throw new Error('已有 8 个对话保留草稿'); });
+ await expect(f.use()).rejects.toThrow('8 个对话'); expect(f.counts().created).toBe(0); expect(f.counts().opened).toBe('');
+});
+test('native origin draft protection is rechecked immediately before navigation and rolled back on failure', async () => {
+ const f = fixture(); let checks = 0, rollbacks = 0;
+ f.preserve(() => { if (++checks === 2) throw new Error('late attachment cannot be retained'); return () => { rollbacks++; }; });
+ await expect(f.use()).rejects.toThrow('late attachment'); expect(checks).toBe(2); expect(rollbacks).toBe(1);
+ expect(f.counts().opened).toBe(''); expect(f.counts().retained).toBe(f.counts().released);
+});
diff --git a/plugins/dsh/test/use-skill-render.test.tsx b/plugins/dsh/test/use-skill-render.test.tsx
new file mode 100644
index 000000000..bc126b762
--- /dev/null
+++ b/plugins/dsh/test/use-skill-render.test.tsx
@@ -0,0 +1,55 @@
+import { expect, test } from 'bun:test';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { SkillCaseChoices, SkillUseController } from '../src/client/use-skill.js';
+import type { InputState } from '@deepseek-ai/dsh-client-ui-conversation/client';
+
+async function panel(items: { id: string; title: string; prompt: string }[]) {
+ const state: InputState = { draft: '', draftRev: 0, phase: 'plain', attachmentIds: [], occurrences: [], queue: [] };
+ const actions = { captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), insertText: () => true };
+ const controller = new SkillUseController({
+ capture: () => ({ actions, input: { state: { getSnapshot: () => state }, focus() {} }, assertCurrent() {} }),
+ revision: async () => 'source', reveal() {},
+ prepare: async () => ({ ok: true, value: { status: 'ready', name: 'native-name', version: '1', scope: 'project',
+ examples: { state: 'ready', digest: 'digest', defaultLocale: 'zh-CN', items: { 'zh-CN': items } } } }),
+ });
+ controller.bind('session', actions);
+ const pending = controller.services('session').useSkill('source', 'item', new AbortController().signal).catch(() => {});
+ await Bun.sleep(0);
+ return { controller, close: async () => { controller.dispose(); await pending; } };
+}
+test('inline cases render contextual header, both task previews and persistent actions outside the task list', async () => {
+ const f = await panel([{ id: 'summary', title: '整理会议', prompt: '请使用 {{skill}} 整理会议。' }, { id: 'compare', title: '对比方案', prompt: '使用 {{skill}} 比较方案。' }]);
+ try {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('aria-label="技能任务案例"'); expect(html).toContain('整理会议'); expect(html).toContain('对比方案');
+ expect(html).toContain('请使用 /native-name 整理会议。'); expect(html).not.toContain('{{skill}}');
+ expect(html).toMatch(/.*aria-label="关闭案例选择"/u);
+ // Long lists scroll independently; actions must not become list items.
+ expect(html).toMatch(/<\/div>