diff --git a/cli/README.md b/cli/README.md index 10447c1f6..6e5b053d1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -607,7 +607,7 @@ subject to the server's package validation. ### Installation inventory compatibility -The local installation inventory uses schema version 2. Each installation directory owns its +The local installation inventory uses schema version 3 in the unreleased enable/disable increment. Each installation directory owns its version, fingerprint, timestamp and ownership, so a user installation and a project installation of the same skill can remain on different versions. `list --json` reports the actual version for each target. `upgrade --check --json` and upgrade results contain one row per selected installation @@ -617,13 +617,19 @@ Direct install/upgrade/remove and sync pruning refuse Suite-owned or shared posi with `--force`. Manage those positions through Suite commands; unrelated direct installations remain independently manageable. -Reading a legacy inventory does not change it. The first mutation validates it, creates a -byte-verified `inventory.v1..backup.json` beside `inventory.json`, and atomically writes -v2 (`schemaVersion`, `records`, `suites`). Missing legacy fingerprint evidence stays missing. -Unknown schemas and ambiguous ownership stop the operation before skill files are changed. +Reading a v1/v2 inventory does not change it. The first mutation validates it, preserves its +original bytes in an immutable digest-addressed backup beside `inventory.json`, and atomically +writes v3 (`schemaVersion`, `records`, `suites`). Each target records `installationState` +(`enabled` or `disabled`); legacy targets project to enabled without inventing missing integrity. -Upgrade the CLI and DSH plugin together when they manage the same installation inventory. Stop -old writers and allow each DSH host to finish pending recovery before the first v2 mutation; -the CLI cannot discover every host's private operation directory. Old clients do not understand -v2 and must not be used to mutate these installations. A saved v1 inventory is recovery evidence, -not a rollback command: restoring it alone after targets diverged would misrepresent files on disk. +Stop incompatible CLI writers and let DSH finish pending recovery before migration. Published +CLI `0.2.0` and `0.1.12` must not share writes with v3. Protocol rejection is not a complete +cross-process guard: historical commands have different read paths. Release the compatible CLI +before exposing this plugin increment. Restoring old inventory alone is not a safe downgrade; +verify entry names, package bytes, metadata and operation logs together. + +This CLI does not add enable/disable commands. Use DSH for explicit toggles and disabled updates; +CLI upgrade refuses disabled targets even with `--force`. Namespace sync retains disabled +installations without update/pruning, Suite cannot adopt them, and doctor preserves trusted +records without claiming loose `.disabled` files. Standard publishing requires `SKILL.md`; +restore the entry through the compatible manager before publishing a disabled installation. diff --git a/cli/src/integrations/install-recovery.ts b/cli/src/integrations/install-recovery.ts index b00da155f..a415041bb 100644 --- a/cli/src/integrations/install-recovery.ts +++ b/cli/src/integrations/install-recovery.ts @@ -1,9 +1,9 @@ +import { snapshotInstalledSkillDirectory, snapshotJournalInstallation } from '../services/installed-skill-snapshot' import { lstat, realpath, rm } from 'node:fs/promises' import { basename, dirname, isAbsolute, resolve } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { captureInstallInventory } from '../services/inventory-cas' import { readInstalledSkillMetadata, sameInstalledSkillSource } from '../services/installed-skill-metadata' -import { snapshotSkillDirectory } from '../services/skill-fingerprint' import { acquireSkillTargetLock, orderSkillTargetLocks } from '../services/skill-target-lock' import { targetInstalledBy, type InventoryStore } from '../stores/inventory-store' import type { OperationRecord, OperationStore } from './operation-store' @@ -60,7 +60,7 @@ async function inspectInstallation(options: InstallRecoveryOptions, mode: 'inspe if (!initial || !['install', 'upgrade'].includes(initial.operation.kind)) throw new Error('Expected an owned installation operation') const result = (outcome: InstallRecoveryInspection['outcome'], reason: string, revision = initial.revision): InstallRecoveryInspection => ({ operationId, revision, outcome, reason }) - if (initial.schemaVersion === 1 && await inventory.diskVersion() === 2) return result('unknown', 'legacy-journal-needs-v1-inventory') + if (initial.schemaVersion === 1 && await inventory.diskVersion() >= 2) return result('unknown', 'legacy-journal-needs-v1-inventory') const bound = initial.operation.targetPaths // Native recovery owns the execution lease: no staging intent means content writes never began. const unstarted = !initial.transaction && !initial.existingInstallation && !initial.removal @@ -135,7 +135,7 @@ async function inspectInstallation(options: InstallRecoveryOptions, mode: 'inspe || metadata.installedAt !== target.installedAt || metadata.source !== 'skillhub') { return result('unknown', 'metadata-conflict') } - const snapshot = await snapshotSkillDirectory(path) + const snapshot = await snapshotInstalledSkillDirectory(item, target) if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) { return result('unknown', 'installed-content-changed') } @@ -264,7 +264,7 @@ async function assertBackupContent(record: OperationRecord, move: InstallMove, p if (!sameInstalledSkillSource(metadata, identity) || metadata.version !== target.version || metadata.fingerprint !== target.fingerprint || metadata.installedAt !== target.installedAt || metadata.agent !== target.agent || metadata.source !== 'skillhub') throw new Error('Backup metadata does not match its baseline') - const snapshot = await snapshotSkillDirectory(path) + const snapshot = await snapshotJournalInstallation(record, path) if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) throw new Error('Backup content was modified') } diff --git a/cli/src/integrations/install-rollback.ts b/cli/src/integrations/install-rollback.ts index 6bc855255..e71f9dd80 100644 --- a/cli/src/integrations/install-rollback.ts +++ b/cli/src/integrations/install-rollback.ts @@ -1,9 +1,9 @@ +import { snapshotJournalInstallation } from '../services/installed-skill-snapshot' import { lstat, realpath, rename, rm } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, resolve } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { captureInstallInventory } from '../services/inventory-cas' import { readInstalledSkillMetadata, sameInstalledSkillSource, type InstalledSkillMetadata } from '../services/installed-skill-metadata' -import { snapshotSkillDirectory } from '../services/skill-fingerprint' import { targetInstalledBy } from '../stores/inventory-store' import type { InstallRecoveryInspection, InstallRecoveryOptions } from './install-recovery' import type { OperationRecord } from './operation-store' @@ -87,16 +87,16 @@ async function planRestore(record: OperationRecord): Promise { const backupExists = backup !== null && await exists(backup) if (backupExists) { if (!original) throw new Error('Backup has no old identity') - await assertContent(backup!, original) + await assertContent(record, backup!, original) } const stageExists = await exists(target.stagingDir) - if (stageExists) await assertContent(target.stagingDir, fresh) + if (stageExists) await assertContent(record, target.stagingDir, fresh) const targetExists = await exists(target.skillDir) let targetIsOld = false let targetIsFresh = false if (targetExists) { - if (original) targetIsOld = await matchesContent(target.skillDir, original) - targetIsFresh = await matchesContent(target.skillDir, fresh) + if (original) targetIsOld = await matchesContent(record, target.skillDir, original) + targetIsFresh = await matchesContent(record, target.skillDir, fresh) if (targetIsOld === targetIsFresh) throw new Error('Target identity is unknown or ambiguous') } if (targetIsFresh) { @@ -133,17 +133,17 @@ async function exists(path: string): Promise { catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error } } -async function matchesContent(path: string, expected: InstalledSkillMetadata): Promise { - try { await assertContent(path, expected); return true } catch { return false } +async function matchesContent(record: OperationRecord, path: string, expected: InstalledSkillMetadata): Promise { + try { await assertContent(record, path, expected); return true } catch { return false } } -async function assertContent(path: string, expected: InstalledSkillMetadata): Promise { +async function assertContent(record: OperationRecord, path: string, expected: InstalledSkillMetadata): Promise { await assertOwnedRegularTree(path) const read = await readInstalledSkillMetadata(path) if (read.status !== 'valid') throw new Error('Recovery metadata is unavailable') const value = read.metadata if (!sameInstalledSkillSource(value, expected) || value.source !== 'skillhub' || value.version !== expected.version || value.fingerprint !== expected.fingerprint || value.agent !== expected.agent || value.installedAt !== expected.installedAt) throw new Error('Recovery identity changed') - const snapshot = await snapshotSkillDirectory(path) + const snapshot = await snapshotJournalInstallation(record, path) if (snapshot.fingerprint !== expected.fingerprint || !isDeepStrictEqual(snapshot.files, value.files)) throw new Error('Recovery content changed') } diff --git a/cli/src/integrations/installed-projection.ts b/cli/src/integrations/installed-projection.ts index e85ee41ff..a1c31e11e 100644 --- a/cli/src/integrations/installed-projection.ts +++ b/cli/src/integrations/installed-projection.ts @@ -1,3 +1,4 @@ +import { assertEntryAbsent } from '../services/installed-skill-snapshot' import { createHash } from 'node:crypto' import { lstat, realpath } from 'node:fs/promises' import { isAbsolute, join, resolve } from 'node:path' @@ -10,7 +11,7 @@ export type { InstalledProjection, InstalledProjectionItem, InstalledProjectionT const text = z.string().min(1) const itemSchema = z.object({ registry: text, namespace: text, slug: text, targets: z.array(z.object({ - version: text, fingerprint: z.string().optional(), installedBy: z.array(text).optional(), agent: text, rootDir: text, installDir: text, installedAt: text, + installationState: z.enum(['enabled', 'disabled']).optional(), version: text, fingerprint: z.string().optional(), installedBy: z.array(text).optional(), agent: text, rootDir: text, installDir: text, installedAt: text, })) }) /** Read only the selected Registry's DSH records in Host-approved roots; never repair or hash all files. */ @@ -43,9 +44,9 @@ export async function readInstalledProjection(inventory: Pick target.root === resolve(entry.rootDir))) { const path = join(target.root, item.slug) const projected: InstalledProjectionItem = { scope: target.scope, namespace: item.namespace, - slug: item.slug, version: entry.version, ...(entry.fingerprint ? { fingerprint: entry.fingerprint } : {}), targetPath: path, state: 'present', + slug: item.slug, installationState: entry.installationState ?? 'enabled', version: entry.version, ...(entry.fingerprint ? { fingerprint: entry.fingerprint } : {}), targetPath: path, state: 'present', recordRevision: createHash('sha256').update(JSON.stringify([item.registry.replace(/\/+$/, ''), item.namespace, item.slug, - entry.agent, entry.rootDir, entry.installDir, entry.version, entry.fingerprint ?? null, entry.installedAt, + entry.installationState ?? 'enabled', entry.agent, entry.rootDir, entry.installDir, entry.version, entry.fingerprint ?? null, entry.installedAt, [...new Set(entry.installedBy ?? ['direct'])].sort()])).digest('hex') } const key = JSON.stringify([target.scope, path, item.namespace, item.slug]) const previous = seen.get(key) @@ -66,10 +67,11 @@ export async function readInstalledProjection(inventory: Pick + | { status: 'unchanged'; installationState: InstallationState } + | { status: 'ready'; inventoryItem: Extract['inventoryItem']; + target: Extract['targets'][number]; + beforeState: InstallationState; afterState: InstallationState } +export interface NativeToggleExecution { + operationId: string + ownerId: string + baseline: Extract + authorize: NativeRemovalExecution['authorize'] +} export interface NativeRuntime { + /** Host-only verified raw entry, including disabled entries; never accept a Client path. */ + readInstallationEntry(selected: UpgradeSelection, target: InstalledProjectionTarget): Promise + inspectToggle(selected: UpgradeSelection, target: InstalledProjectionTarget, desiredState: InstallationState): Promise + acceptToggle(request: NativeOperationRequest, validateNew: () => Promise): Promise + executeToggle(request: NativeToggleExecution): Promise /** 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. */ @@ -76,9 +94,9 @@ export interface NativeRuntime { export interface UpgradeSelection { registry: string; namespace: string; slug: string; version: string } /** Private snapshot carried between the bundled core and Host, never supplied by the Client. */ export type UpgradeBaseline = - | { status: 'blocked'; reason: 'record-changed' | 'target-outside-context' | 'shared-target' | 'target-alias' | 'local-changes' | 'metadata-invalid' | 'target-unavailable' } + | { status: 'blocked'; reason: 'record-changed' | 'target-outside-context' | 'shared-target' | 'target-alias' | 'local-changes' | 'metadata-invalid' | 'target-unavailable' | 'entry-conflict' } | { status: 'ready'; inventoryItem: Omit & { - targets: Array<{ agent: string; rootDir: string; installDir: string; installedAt: string; version: string; fingerprint?: string; installedBy?: string[] }> + targets: Array<{ agent: string; rootDir: string; installDir: string; installedAt: string; version: string; fingerprint?: string; installedBy?: string[]; installationState?: InstallationState }> }; targets: Array<{ scope: 'user' | 'project'; root: string; path: string; files: Record; metadata: UpgradeSelection & { schemaVersion?: number; versionId?: number; fingerprint?: string; @@ -113,14 +131,14 @@ export interface NativeInstallAcceptance { export interface NativeOperation { operationId: string revision: number - kind: 'install' | 'upgrade' | 'uninstall' | 'remove-stale-record' + kind: 'install' | 'upgrade' | 'uninstall' | 'remove-stale-record' | 'enable' | 'disable' namespace: string slug: string version: string targetLabel: string context: NativeInstallAcceptance['context'] & { hostInstanceId: string; createdAt: string; updatedAt: string } phase: 'accepted' | 'staging' | 'revalidating' | 'committing' | 'recovering' | 'completed' | 'failed' | 'recovery-required' | 'unknown' - files: 'pending' | 'installed' | 'already-installed' | 'upgraded' | 'removed' | 'stale-record-removed' | 'failed' | 'unknown' + files: 'pending' | 'installed' | 'already-installed' | 'upgraded' | 'removed' | 'stale-record-removed' | 'enabled' | 'disabled' | 'failed' | 'unknown' discovery: 'unconfirmed' | 'confirmed' | 'failed' recovery: 'not-needed' | 'restored' | 'incomplete' | 'unknown' warningCount: number @@ -128,7 +146,7 @@ export interface NativeOperation { resultDetails?: { beforeVersion: string | null afterVersion: string | null - targets: { label: string; outcome: 'installed' | 'upgraded' | 'removed' | 'stale-record-removed' | 'restored' | 'unknown' }[] + targets: { label: string; outcome: 'installed' | 'upgraded' | 'removed' | 'stale-record-removed' | 'enabled' | 'disabled' | 'restored' | 'unknown' }[] warnings: ('backup-cleanup' | 'removal-cleanup' | 'verification-required')[] } } diff --git a/cli/src/integrations/native-removal.ts b/cli/src/integrations/native-removal.ts index 652860397..8d1b2c589 100644 --- a/cli/src/integrations/native-removal.ts +++ b/cli/src/integrations/native-removal.ts @@ -3,7 +3,7 @@ import { dirname, join } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { acquireSkillTargetLock } from '../services/skill-target-lock' import { readInstalledSkillMetadata, sameInstalledSkillSource } from '../services/installed-skill-metadata' -import { snapshotSkillDirectory } from '../services/skill-fingerprint' +import { snapshotJournalInstallation } from '../services/installed-skill-snapshot' import type { InventoryStore } from '../stores/inventory-store' import type { NativeRecoveryResult, NativeRemovalExecution } from './native-contract' import type { OperationStore } from './operation-store' @@ -50,7 +50,7 @@ export async function executeNativeRemoval(operations: OperationStore, inventory if (!quarantine || baseline.kind !== 'uninstall') return await assertOwnedRegularTree(quarantine) const metadata = await readInstalledSkillMetadata(quarantine) - const snapshot = await snapshotSkillDirectory(quarantine) + const snapshot = await snapshotJournalInstallation(record!, quarantine) if (metadata.status !== 'valid' || !isDeepStrictEqual(metadata.metadata, baseline.target.metadata) || snapshot.fingerprint !== releaseTarget.fingerprint || !isDeepStrictEqual(snapshot.files, baseline.target.files)) { throw new Error('Isolated removal files changed') diff --git a/cli/src/integrations/native-runtime.ts b/cli/src/integrations/native-runtime.ts index 63d43951d..b3e1b89ef 100644 --- a/cli/src/integrations/native-runtime.ts +++ b/cli/src/integrations/native-runtime.ts @@ -1,3 +1,8 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import { inspectToggle, executeNativeToggle, recoverToggle } from './native-toggle' import { readSkillExamples } from '../services/skill-examples' import { observeInstallationDiscovery } from './install-discovery' import { readInstalledProjection } from './installed-projection' @@ -20,13 +25,39 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven const operations = await OperationStore.open(options.stateDirectory) const inventory = new InventoryStore(options.inventoryHome) const ensureCompatible = async () => { - if ((await operations.listForRecovery()).some(record => record.schemaVersion === 1)) { + if ((await operations.listForRecovery()).some(record => record.schemaVersion < 3)) { throw new OperationStoreError('TARGET_RECOVERY_REQUIRED') } await inventory.ensureWritable() } return { hostInstanceId: operations.hostInstanceId, + readInstallationEntry: async (selected, target) => { + const baseline = await inspectUpgradeBaseline(inventory, { ...selected }, [{ ...target }]) + if (baseline.status !== 'ready' || baseline.targets.length !== 1) return undefined + const entry = baseline.inventoryItem.targets[0]! + const content = await readFile(join(entry.installDir, entry.installationState === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md')) + if (createHash('sha256').update(content).digest('hex') !== baseline.targets[0]!.files['SKILL.md'] + || !isDeepStrictEqual(baseline, await inspectUpgradeBaseline(inventory, { ...selected }, [{ ...target }]))) return undefined + return content.toString('utf8') + }, + inspectToggle: (selected, target, desiredState) => inspectToggle(inventory, { ...selected }, { ...target }, desiredState), + acceptToggle: async (request, validateNew) => { + const result = await operations.accept({ hostInstanceId: request.hostInstanceId, ownerId: request.ownerId, + requestKey: request.requestKey, mode: request.mode, requestDigest: operationRequestDigest({ ...request.parameters, kind: 'toggle' }) }, + async () => { + const input = await validateNew() + if (!['enable', 'disable'].includes(input.kind)) throw new Error('Expected toggle acceptance') + return input + }) + return result ? projectOperation(result) : undefined + }, + executeToggle: async request => { + await ensureCompatible() + const snapshot = { ...request, baseline: structuredClone(request.baseline) } + return withOperationLease(operations, snapshot.operationId, snapshot.ownerId, + () => executeNativeToggle(operations, inventory, snapshot)) + }, readSkillExamples, observeInstallation: (operationId, ownerId, trustedRoots, observe) => observeInstallationDiscovery({ operations, inventory, operationId, ownerId, trustedRoots, observe @@ -34,7 +65,7 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven findPlanOperation: async request => { let conflict: OperationStoreError | undefined // Preserve existing per-kind digests, including operations accepted by earlier plugin builds. - for (const kind of ['upgrade', 'removal'] as const) { + for (const kind of ['upgrade', 'removal', 'toggle'] as const) { try { const result = await operations.accept({ hostInstanceId: request.hostInstanceId, ownerId: request.ownerId, requestKey: request.requestKey, mode: 'retry', @@ -42,7 +73,7 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven async () => { throw new Error('Plan lookup cannot accept new work') }) if (result) { if (kind === 'upgrade' ? result.operation.kind !== 'upgrade' - : !['uninstall', 'remove-stale-record'].includes(result.operation.kind)) throw new Error('Plan operation kind mismatch') + : kind === 'toggle' ? !['enable', 'disable'].includes(result.operation.kind) : !['uninstall', 'remove-stale-record'].includes(result.operation.kind)) throw new Error('Plan operation kind mismatch') return projectOperation(result) } } catch (error) { @@ -111,6 +142,7 @@ export async function openNativeRuntime(options: { stateDirectory: string; inven const result = await withOperationLease(operations, operationId, ownerId, async () => { const record = await operations.get(operationId, ownerId) const input = { operations, inventory, operationId, ownerId, trustedTargetPaths: paths, authorizeMutation: authorize } + if (record && ['enable', 'disable'].includes(record.operation.kind)) return recoverToggle(operations, inventory, operationId, ownerId, paths, authorize) return record && ['uninstall', 'remove-stale-record'].includes(record.operation.kind) ? recoverRemoval(input) : recoverInstallation(input) }) diff --git a/cli/src/integrations/native-toggle.ts b/cli/src/integrations/native-toggle.ts new file mode 100644 index 000000000..9c0236dd9 --- /dev/null +++ b/cli/src/integrations/native-toggle.ts @@ -0,0 +1,194 @@ +import { link, lstat, open, realpath, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import type { InventoryStore } from '../stores/inventory-store' +import { captureInstallInventory } from '../services/inventory-cas' +import { acquireSkillTargetLock } from '../services/skill-target-lock' +import { assertEntryAbsent, snapshotJournalInstallation } from '../services/installed-skill-snapshot' +import { sameInstalledSkillSource } from '../services/installed-skill-metadata' +import { inspectUpgradeBaseline } from './upgrade-baseline' +import type { OperationRecord, OperationStore } from './operation-store' +import type { InstalledProjectionTarget, InstallationState, ToggleBaseline, UpgradeSelection, NativeToggleExecution, NativeRecoveryResult } from './native-contract' + +export async function inspectToggle(inventory: Pick, selected: UpgradeSelection, + target: InstalledProjectionTarget, desiredState: InstallationState): Promise { + if (!['enabled', 'disabled'].includes(desiredState)) throw new Error('Invalid installation state') + const baseline = await inspectUpgradeBaseline(inventory, selected, [target]) + if (baseline.status !== 'ready') return baseline + if (baseline.targets.length !== 1 || baseline.inventoryItem.targets.length !== 1) return { status: 'blocked', reason: 'target-alias' } + const beforeState = baseline.inventoryItem.targets[0]!.installationState ?? 'enabled' + if (baseline.targets[0]!.files['SKILL.md.disabled']) return { status: 'blocked', reason: 'entry-conflict' } + if (beforeState === desiredState) return { status: 'unchanged', installationState: beforeState } + return { status: 'ready', inventoryItem: baseline.inventoryItem, target: baseline.targets[0]!, beforeState, afterState: desiredState } +} + +async function syncDirectory(path: string): Promise { + if (process.platform === 'win32') return + const file = await open(path, 'r') + try { await file.sync() } finally { await file.close() } +} + +export async function executeNativeToggle(operations: OperationStore, inventory: InventoryStore, + request: NativeToggleExecution): Promise { + const baseline = structuredClone(request.baseline) + const item = baseline.inventoryItem + const target = item.targets[0]! + let record = await operations.get(request.operationId, request.ownerId) + if (!record || record.operation.status.phase !== 'accepted' + || record.operation.kind !== (baseline.afterState === 'enabled' ? 'enable' : 'disable') + || !isDeepStrictEqual(record.operation.targetPaths, [baseline.target.path]) + || !sameInstalledSkillSource(item, { registry: record.operation.context.sourceIdentity, namespace: record.operation.namespace, slug: record.operation.slug }) + || record.operation.version !== target.version) throw new Error('Toggle is not owned and accepted') + const release = await acquireSkillTargetLock(baseline.target.root, item.slug) + try { + record = await operations.update(record.operationId, record.ownerId, record.revision, { ...record.operation.status, phase: 'revalidating' }) + await operations.assertTargetsAvailable([baseline.target.path], request) + await request.authorize([baseline.target.root, baseline.target.path], 'replace', record.operationId) + const current = await inspectToggle(inventory, { ...item, version: target.version }, baseline.target, baseline.afterState) + if (!isDeepStrictEqual(current, baseline)) throw new Error('Toggle baseline changed') + await assertEntryAbsent(join(target.installDir, baseline.afterState === 'enabled' ? 'SKILL.md' : 'SKILL.md.disabled')) + record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { + inventoryItem: item, target: { root: baseline.target.root, path: baseline.target.path, scope: baseline.target.scope }, + files: baseline.target.files, beforeState: baseline.beforeState, afterState: baseline.afterState, + from: join(target.installDir, baseline.beforeState === 'enabled' ? 'SKILL.md' : 'SKILL.md.disabled'), + to: join(target.installDir, baseline.afterState === 'enabled' ? 'SKILL.md' : 'SKILL.md.disabled'), stage: 'intent' + }) + const intent = record.toggle! + await link(intent.from, intent.to) + record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'linked' }) + await assertSameEntry(intent.from, intent.to) + await unlink(intent.from) + await syncDirectory(target.installDir) + record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'moved' }) + const paths = new Set([target.installDir]) + const expected = await captureInstallInventory({ items: [item] }, item, paths) + await inventory.mutateAtomic(current => { + const match = current.items.find(candidate => sameInstalledSkillSource(candidate, item)) + const entry = match?.targets.find(entry => entry.installDir === target.installDir) + if (!entry || !isDeepStrictEqual(entry, target)) throw new Error('Toggle inventory changed') + entry.installationState = baseline.afterState + }, async before => { + if (await captureInstallInventory(before, item, paths) !== expected) throw new Error('Toggle ownership changed') + await request.authorize([target.rootDir, target.installDir], 'replace', request.operationId) + await verifyFiles(record!, baseline.afterState) + }) + record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'committed' }) + return complete(operations, record) + } catch (error) { + const current = await operations.get(request.operationId, request.ownerId) + if (current?.toggle) { + // Reconcile the actual inventory before considering a reverse rename. + const result = await reconcileToggle(operations, inventory, current, request.authorize) + if (result.outcome === 'committed') return result + } else if (current && current.operation.status.phase === 'revalidating') { + await operations.update(current.operationId, current.ownerId, current.revision, + { ...current.operation.status, phase: 'failed', files: 'failed', recovery: 'not-needed' }) + } + throw error + } finally { await release() } +} + +async function verifyFiles(record: OperationRecord, state: InstallationState): Promise { + const intent = record.toggle! + const item = intent.inventoryItem + const target = item.targets[0]! + if (await realpath(target.rootDir) !== target.rootDir || await realpath(target.installDir) !== target.installDir) throw new Error('Toggle root changed') + const snapshot = await snapshotJournalInstallation(record, target.installDir, state) + if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, intent.files)) throw new Error('Toggle content changed') + if (state === 'enabled' && !await isLinkedEntry(intent.from, intent.to)) await assertEntryAbsent(join(target.installDir, 'SKILL.md.disabled')) +} + +async function complete(operations: OperationStore, record: OperationRecord): Promise { + const saved = await operations.update(record.operationId, record.ownerId, record.revision, { ...record.operation.status, + phase: 'completed', files: record.toggle!.afterState, recovery: 'not-needed' }) + return { operationId: saved.operationId, revision: saved.revision, outcome: 'committed', reason: saved.toggle!.afterState } +} + +/** Caller holds the operation lease and target lock. No inventory write is replayed. */ +async function reconcileToggle(operations: OperationStore, inventory: InventoryStore, record: OperationRecord, + authorize: NativeToggleExecution['authorize']): Promise { + const unknown = async (): Promise => { + const current = await operations.get(record.operationId, record.ownerId) + if (current && !['completed', 'failed', 'recovery-required'].includes(current.operation.status.phase)) { + record = await operations.update(current.operationId, current.ownerId, current.revision, + { ...current.operation.status, phase: 'recovery-required', files: 'unknown', recovery: 'unknown' }) + } + return { operationId: record.operationId, revision: record.revision, outcome: 'unknown', reason: 'toggle-requires-verification' } + } + try { + const intent = record.toggle! + const before = intent.inventoryItem + const after = structuredClone(before) + after.targets[0]!.installationState = intent.afterState + const paths = new Set([intent.target.path]) + return await inventory.withLockedSnapshot(async actual => { + const fingerprint = await captureInstallInventory(actual, before, paths) + if (fingerprint === await captureInstallInventory({ items: [after] }, after, paths)) { + await verifyFiles(record, intent.afterState) + if (record.operation.status.phase === 'completed') return { operationId: record.operationId, revision: record.revision, outcome: 'committed', reason: intent.afterState } + if (['unknown', 'recovery-required'].includes(record.operation.status.phase)) record = await operations.update(record.operationId, record.ownerId, record.revision, { ...record.operation.status, phase: 'recovering' }) + if (record.toggle!.stage !== 'committed') record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'committed' }) + return complete(operations, record) + } + if (record.toggle!.stage === 'committed' || fingerprint !== await captureInstallInventory({ items: [before] }, before, paths)) return unknown() + if (record.operation.status.phase === 'failed' && record.operation.status.recovery === 'restored') { + await verifyFiles(record, intent.beforeState) + return { operationId: record.operationId, revision: record.revision, outcome: 'restored', reason: 'toggle-restored' } + } + record = await operations.update(record.operationId, record.ownerId, record.revision, { ...record.operation.status, phase: 'recovering' }) + // An interrupted exclusive link leaves two names for exactly the same inode. + // Only this persisted intent and a full-content check authorize removing the extra name. + if (await isLinkedEntry(intent.from, intent.to)) { + await verifyFiles(record, intent.beforeState) + await authorize([intent.target.root, intent.from, intent.to], 'rollback', record.operationId) + await assertSameEntry(intent.from, intent.to) + await unlink(intent.to) + await syncDirectory(intent.target.path) + } + let unchanged = false + try { await verifyFiles(record, intent.beforeState); unchanged = true } catch { /* Inspect the other exact state before any mutation. */ } + if (!unchanged) { + await verifyFiles(record, intent.afterState) + await authorize([intent.target.root, intent.from, intent.to], 'rollback', record.operationId) + await assertEntryAbsent(intent.from) + record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'restoring' }) + await link(intent.to, intent.from) + record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'restore-linked' }) + await assertSameEntry(intent.from, intent.to) + await unlink(intent.to) + await syncDirectory(intent.target.path) + await verifyFiles(record, intent.beforeState) + } + if (record.toggle!.stage !== 'restored') record = await operations.recordToggle(record.operationId, record.ownerId, record.revision, { ...intent, stage: 'restored' }) + record = await operations.update(record.operationId, record.ownerId, record.revision, { ...record.operation.status, + phase: 'failed', files: 'failed', recovery: 'restored' }) + return { operationId: record.operationId, revision: record.revision, outcome: 'restored', reason: 'toggle-restored' } + }) + } catch { return unknown() } +} + +export async function recoverToggle(operations: OperationStore, inventory: InventoryStore, operationId: string, ownerId: string, + trustedPaths: readonly string[], authorize: (paths: readonly string[], phase: 'cleanup' | 'rollback') => Promise): Promise { + let record = await operations.get(operationId, ownerId) + if (!record || !isDeepStrictEqual(record.operation.targetPaths, [...trustedPaths]) || trustedPaths.length !== 1) throw new Error('Untrusted toggle recovery') + const root = record.toggle?.target.root ?? trustedPaths[0]!.slice(0, -(record.operation.slug.length + 1)) + const release = await acquireSkillTargetLock(root, record.operation.slug) + try { + if (!record.toggle) { + if (!['accepted', 'revalidating'].includes(record.operation.status.phase)) throw new Error('Missing toggle evidence') + record = await operations.update(operationId, ownerId, record.revision, { ...record.operation.status, phase: 'failed', files: 'failed', recovery: 'not-needed' }) + return { operationId, revision: record.revision, outcome: 'restored', reason: 'toggle-not-started' } + } + return reconcileToggle(operations, inventory, record, (paths, phase) => authorize(paths, phase === 'rollback' ? 'rollback' : 'cleanup')) + } finally { await release() } +} + +async function isLinkedEntry(left: string, right: string): Promise { + try { + const [a, b] = await Promise.all([lstat(left), lstat(right)]) + return a.isFile() && b.isFile() && !a.isSymbolicLink() && !b.isSymbolicLink() && a.dev === b.dev && a.ino === b.ino + } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error } +} +async function assertSameEntry(left: string, right: string): Promise { + if (!await isLinkedEntry(left, right)) throw new Error('Toggle entry changed during transition') +} diff --git a/cli/src/integrations/operation-projection.ts b/cli/src/integrations/operation-projection.ts index 10cb2bd48..58d33db06 100644 --- a/cli/src/integrations/operation-projection.ts +++ b/cli/src/integrations/operation-projection.ts @@ -4,7 +4,7 @@ import type { OperationRecord } from './operation-store' /** Project the original journal only, never the current inventory or raw error messages. */ export function projectOperation(record: OperationRecord): NativeOperation { const { operation, transaction, removal } = record - const before = removal?.intent.inventoryItem ?? (transaction?.preparation?.before ?? transaction?.inventoryCommit?.before)?.items.find(item => + const before = record.toggle?.inventoryItem ?? removal?.intent.inventoryItem ?? (transaction?.preparation?.before ?? transaction?.inventoryCommit?.before)?.items.find(item => item.registry === operation.context.sourceIdentity && item.namespace === operation.namespace && item.slug === operation.slug) // A same-coordinate installation in another scope is not this operation's prior target. const beforeVersion = operation.targetPaths?.length && operation.targetPaths.every(path => @@ -13,8 +13,8 @@ export function projectOperation(record: OperationRecord): NativeOperation { ? before?.targets.find(target => operation.targetPaths!.includes(target.installDir))?.version ?? null : null const completed = operation.status.phase === 'completed' const files = operation.status.files - const outcome = completed && ['installed', 'already-installed', 'upgraded', 'removed', 'stale-record-removed'].includes(files) - ? files === 'already-installed' ? 'installed' : files as 'installed' | 'upgraded' | 'removed' | 'stale-record-removed' + const outcome = completed && ['installed', 'already-installed', 'upgraded', 'removed', 'stale-record-removed', 'enabled', 'disabled'].includes(files) + ? files === 'already-installed' ? 'installed' : files as 'installed' | 'upgraded' | 'removed' | 'stale-record-removed' | 'enabled' | 'disabled' : operation.status.phase === 'failed' && operation.status.recovery === 'restored' ? 'restored' : 'unknown' return { operationId: record.operationId, revision: record.revision, kind: operation.kind, namespace: operation.namespace, slug: operation.slug, version: operation.version, targetLabel: operation.targetLabel, @@ -22,7 +22,7 @@ export function projectOperation(record: OperationRecord): NativeOperation { discovery: operation.status.discovery, recovery: operation.status.recovery, warningCount: operation.status.warnings.length, resultDetails: { beforeVersion: completed && files === 'already-installed' ? operation.version : beforeVersion, - afterVersion: completed && ['installed', 'already-installed', 'upgraded'].includes(files) ? operation.version : null, + afterVersion: completed && ['installed', 'already-installed', 'upgraded', 'enabled', 'disabled'].includes(files) ? operation.version : null, targets: (operation.targetPaths ?? []).map((_, index, targets) => ({ label: targets.length === 1 ? operation.targetLabel : `目标 ${index + 1}(确认计划顺序)`, outcome })), diff --git a/cli/src/integrations/operation-store.ts b/cli/src/integrations/operation-store.ts index 935a34031..a4710e1e5 100644 --- a/cli/src/integrations/operation-store.ts +++ b/cli/src/integrations/operation-store.ts @@ -1,3 +1,5 @@ +import { isDeepStrictEqual } from 'node:util' +import { toggleJournal, validateToggleJournal, type ToggleJournal } from './toggle-journal' import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises' @@ -18,20 +20,20 @@ const phase = z.enum(['accepted', 'staging', 'revalidating', 'committing', 'reco const context = z.object({ hostInstanceId: id, hostLabel: text, sourceIdentity: text, sourceLabel: text, sourceRevision: text, initiatedFrom: z.enum(['plaza', 'conversation']), createdAt: z.string().datetime(), updatedAt: z.string().datetime() }).strict() -const input = z.object({ kind: z.enum(['install', 'upgrade', 'uninstall', 'remove-stale-record']), +const input = z.object({ kind: z.enum(['install', 'upgrade', 'uninstall', 'remove-stale-record', 'enable', 'disable']), namespace: text, slug: text, version: text, targetLabel: text, targetPaths: z.array(text.refine(isAbsolute, 'Expected a trusted canonical target path')).min(1).max(100).optional(), context: context.omit({ hostInstanceId: true, createdAt: true, updatedAt: true }) }).strict() const status = z.object({ phase, - files: z.enum(['pending', 'installed', 'already-installed', 'upgraded', 'removed', 'stale-record-removed', 'failed', 'unknown']), + files: z.enum(['pending', 'installed', 'already-installed', 'upgraded', 'removed', 'stale-record-removed', 'enabled', 'disabled', 'failed', 'unknown']), discovery: z.enum(['unconfirmed', 'confirmed', 'failed']), warnings: z.array(text).max(100), recovery: z.enum(['not-needed', 'restored', 'incomplete', 'unknown']) }).strict() const existingInstallation = z.object({ fingerprint: text, versionId: z.number().int().positive(), inventoryDigest: z.string().regex(/^[a-f0-9]{64}$/) }).strict() -const record = z.object({ schemaVersion: z.union([z.literal(1), z.literal(2)]), operationId: id, ownerId: text, +const record = z.object({ schemaVersion: z.union([z.literal(1), z.literal(2), z.literal(3)]), operationId: id, ownerId: text, requestKey: id, requestDigest: z.string().regex(/^[a-f0-9]{64}$/), revision: z.number().int().nonnegative(), operation: input.omit({ context: true }).extend({ context, status }), transaction: transactionState.optional(), - removal: removalJournal.optional(), existingInstallation: existingInstallation.optional() }).strict() + toggle: toggleJournal.optional(), removal: removalJournal.optional(), existingInstallation: existingInstallation.optional() }).strict() export type AcceptedOperationInput = z.infer export type OperationRecord = z.infer export type OperationStatus = z.infer @@ -80,7 +82,7 @@ export class OperationStore { const duplicate = await this.findRequest(request) if (duplicate) return duplicate const now = new Date().toISOString() - const value: OperationRecord = { schemaVersion: 2, operationId: randomUUID(), ownerId: request.ownerId, + const value: OperationRecord = { schemaVersion: 3, operationId: randomUUID(), ownerId: request.ownerId, requestKey: request.requestKey, requestDigest: request.requestDigest, revision: 0, operation: { ...accepted, context: { ...accepted.context, hostInstanceId: this.hostInstanceId, createdAt: now, updatedAt: now }, status: { phase: 'accepted', files: 'pending', discovery: 'unconfirmed', recovery: 'not-needed', warnings: [] } } } @@ -112,7 +114,7 @@ export class OperationStore { if (pending.operationId === current.operationId || !needsRecovery(pending)) continue const status = pending.operation.status const mayHaveChangedFiles = !!pending.transaction?.moves.length - || !!pending.removal?.intent.quarantine + || !!pending.removal?.intent.quarantine || !!pending.toggle || ['committing', 'recovering', 'recovery-required', 'unknown'].includes(status.phase) || ['incomplete', 'unknown'].includes(status.recovery) if (!mayHaveChangedFiles) continue @@ -354,6 +356,30 @@ export class OperationStore { }) } + async recordToggle(operationId: string, ownerId: string, revision: number, journal: ToggleJournal): Promise { + return this.locked(async () => { + const current = await this.get(operationId, ownerId) + if (!current || current.revision !== revision) throw new OperationStoreError('REVISION_CONFLICT') + const parsed = toggleJournal.parse(journal) + validateToggleJournal(current, parsed) + if (!['revalidating', 'committing', 'recovering'].includes(current.operation.status.phase)) throw new OperationStoreError('INVALID_TRANSITION') + if (current.toggle && (!isDeepStrictEqual({ ...current.toggle, stage: parsed.stage }, parsed) + || current.toggle.stage === 'committed' || current.toggle.stage === 'restored')) throw new OperationStoreError('INVALID_TRANSITION') + if (!current.toggle && parsed.stage !== 'intent') throw new OperationStoreError('INVALID_TRANSITION') + const transitions: Record = { + intent: ['linked', 'restored'], linked: ['moved', 'restoring', 'restored'], moved: ['committed', 'restoring', 'restored'], + restoring: ['restore-linked', 'restored'], 'restore-linked': ['restored'], committed: [], restored: [] + } + if (current.toggle && !transitions[current.toggle.stage].includes(parsed.stage)) throw new OperationStoreError('INVALID_TRANSITION') + const value = record.parse({ ...current, toggle: parsed, revision: revision + 1, + operation: { ...current.operation, status: { ...current.operation.status, + phase: current.operation.status.phase === 'recovering' ? 'recovering' : parsed.stage === 'intent' ? 'revalidating' : 'committing' }, + context: { ...current.operation.context, updatedAt: new Date().toISOString() } } }) + await writeOperationRecord(this.path(operationId), value) + return value + }) + } + private validateRequest(request: OperationRequest): void { if (request.hostInstanceId !== this.hostInstanceId) throw new OperationStoreError('HOST_MISMATCH') text.parse(request.ownerId); id.parse(request.requestKey) @@ -366,6 +392,8 @@ export class OperationStore { if (json === undefined) return undefined const parsed = record.safeParse(json) if (!parsed.success || parsed.data.operationId !== operationId || parsed.data.operation.context.hostInstanceId !== this.hostInstanceId) throw new OperationStoreError('CORRUPT_RECORD') + if (parsed.data.schemaVersion < 3 && (parsed.data.toggle || ['enable', 'disable'].includes(parsed.data.operation.kind))) throw new OperationStoreError('CORRUPT_RECORD') + if (parsed.data.toggle) validateToggleJournal(parsed.data, parsed.data.toggle) return parsed.data } private async findRequest(request: OperationRequest): Promise { @@ -437,8 +465,9 @@ function assertTransition(current: OperationRecord, next: OperationStatus): void if (next.phase === 'completed') { if (['uninstall', 'remove-stale-record'].includes(current.operation.kind) && !current.removal?.inventoryCommit?.committed) throw new OperationStoreError('INVALID_TRANSITION') + if (['enable', 'disable'].includes(current.operation.kind) && current.toggle?.stage !== 'committed') throw new OperationStoreError('INVALID_TRANSITION') const outcomes = { install: ['installed', 'already-installed'], upgrade: ['upgraded', 'already-installed'], - uninstall: ['removed'], 'remove-stale-record': ['stale-record-removed'] } + uninstall: ['removed'], 'remove-stale-record': ['stale-record-removed'], enable: ['enabled'], disable: ['disabled'] } if (!outcomes[current.operation.kind].includes(next.files) || !['not-needed', 'restored'].includes(next.recovery)) throw new OperationStoreError('INVALID_TRANSITION') if (['accepted', 'revalidating'].includes(prior.phase) && next.files !== 'already-installed') throw new OperationStoreError('INVALID_TRANSITION') } @@ -472,9 +501,9 @@ async function atomicWrite(path: string, value: unknown): Promise { /** Recovery may advance v1 status, but never rewrite the original serialized evidence. */ async function writeOperationRecord(path: string, value: OperationRecord): Promise { - if (value.schemaVersion !== 1) return atomicWrite(path, value) + if (value.schemaVersion === 3) return atomicWrite(path, value) const previous = await readJson(path) as OperationRecord | undefined - if (!previous || previous.schemaVersion !== 1) throw new Error('Legacy operation evidence missing') + if (!previous || previous.schemaVersion !== value.schemaVersion) throw new Error('Legacy operation evidence missing') const preserved = structuredClone(value) if (preserved.transaction && previous.transaction) { if (preserved.transaction.preparation && previous.transaction.preparation) { diff --git a/cli/src/integrations/removal-recovery.ts b/cli/src/integrations/removal-recovery.ts index 24b2069cb..2665fa7bf 100644 --- a/cli/src/integrations/removal-recovery.ts +++ b/cli/src/integrations/removal-recovery.ts @@ -3,7 +3,7 @@ import { dirname } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { captureInstallInventory } from '../services/inventory-cas' import { readInstalledSkillMetadata, sameInstalledSkillSource } from '../services/installed-skill-metadata' -import { snapshotSkillDirectory } from '../services/skill-fingerprint' +import { snapshotJournalInstallation } from '../services/installed-skill-snapshot' import { acquireSkillTargetLock } from '../services/skill-target-lock' import type { InventoryStore } from '../stores/inventory-store' import type { NativeRecoveryResult } from './native-contract' @@ -28,7 +28,7 @@ export async function recoverRemoval(options: RemovalRecoveryOptions): Promise ({ operationId, revision: record!.revision, outcome, reason }) - if (record.schemaVersion === 1 && await inventory.diskVersion() === 2) return result('unknown', 'legacy-journal-needs-v1-inventory') + if (record.schemaVersion === 1 && await inventory.diskVersion() >= 2) return result('unknown', 'legacy-journal-needs-v1-inventory') if (!isDeepStrictEqual(record.operation.targetPaths, [...options.trustedTargetPaths]) || options.trustedTargetPaths.length !== 1) return result('unknown', 'target-binding-mismatch') const append = async (event: RemovalEvent) => { @@ -83,8 +83,8 @@ export async function recoverRemoval(options: RemovalRecoveryOptions): Promise { await assertOwnedRegularTree(path) const metadata = await readInstalledSkillMetadata(path) - const snapshot = await snapshotSkillDirectory(path) const expected = item.targets.find(entry => entry.installDir === target.path)! + const snapshot = await snapshotJournalInstallation(record!, path) if (metadata.status !== 'valid' || !sameInstalledSkillSource(metadata.metadata, item) || metadata.metadata.version !== expected.version || metadata.metadata.fingerprint !== expected.fingerprint || metadata.metadata.agent !== 'dsh' || metadata.metadata.installedAt !== expected.installedAt diff --git a/cli/src/integrations/toggle-journal.ts b/cli/src/integrations/toggle-journal.ts new file mode 100644 index 000000000..31c21c289 --- /dev/null +++ b/cli/src/integrations/toggle-journal.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { inventoryItemSchema } from './transaction-journal' +import { join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import type { OperationRecord } from './operation-store' +import { sameInstalledSkillSource } from '../services/installed-skill-metadata' + +const state = z.enum(['enabled', 'disabled']) +export const toggleJournal = z.object({ + inventoryItem: inventoryItemSchema, + target: z.object({ scope: z.enum(['user', 'project']), root: z.string(), path: z.string() }).strict(), + files: z.record(z.string(), z.string()), beforeState: state, afterState: state, + from: z.string(), to: z.string(), + stage: z.enum(['intent', 'linked', 'moved', 'restoring', 'restore-linked', 'committed', 'restored']) +}).strict() +export type ToggleJournal = z.infer + +export function validateToggleJournal(record: OperationRecord, value: ToggleJournal): void { + const item = value.inventoryItem + const target = item.targets[0] + if (record.schemaVersion !== 3 || !['enable', 'disable'].includes(record.operation.kind) + || item.targets.length !== 1 || !target || target.agent !== 'dsh' + || !sameInstalledSkillSource(item, { registry: record.operation.context.sourceIdentity, + namespace: record.operation.namespace, slug: record.operation.slug }) + || target.version !== record.operation.version || !target.fingerprint + || !isDeepStrictEqual(target.installedBy ?? ['direct'], ['direct']) + || !isDeepStrictEqual(record.operation.targetPaths, [target.installDir]) + || target.installDir !== value.target.path || target.rootDir !== value.target.root + || join(value.target.root, item.slug) !== value.target.path + || (target.installationState ?? 'enabled') !== value.beforeState || value.beforeState === value.afterState + || value.afterState !== (record.operation.kind === 'enable' ? 'enabled' : 'disabled') + || value.from !== join(target.installDir, value.beforeState === 'enabled' ? 'SKILL.md' : 'SKILL.md.disabled') + || value.to !== join(target.installDir, value.afterState === 'enabled' ? 'SKILL.md' : 'SKILL.md.disabled') + || !value.files['SKILL.md'] || value.files['SKILL.md.disabled']) throw new Error('Invalid toggle evidence') +} diff --git a/cli/src/integrations/upgrade-baseline.ts b/cli/src/integrations/upgrade-baseline.ts index 0c4e706d1..1d08090bc 100644 --- a/cli/src/integrations/upgrade-baseline.ts +++ b/cli/src/integrations/upgrade-baseline.ts @@ -4,7 +4,7 @@ import { isAbsolute, join, resolve } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { canonicalizeExistingPath, pathsOverlap } from '../platform/paths' import { readInstalledSkillMetadata, sameInstalledSkillSource } from '../services/installed-skill-metadata' -import { snapshotSkillDirectory } from '../services/skill-fingerprint' +import { snapshotInstalledSkillDirectory } from '../services/installed-skill-snapshot' import { targetInstalledBy } from '../stores/inventory-store' import type { InventoryItem, InventoryStore } from '../stores/inventory-store' import type { InstalledProjectionTarget, UpgradeBaseline } from './native-contract' @@ -64,7 +64,7 @@ export async function inspectUpgradeBaseline(store: Pick || metadata.agent !== 'dsh' || metadata.installedAt !== target.installedAt) { return { status: 'blocked', reason: 'metadata-invalid' } } - const snapshot = await snapshotSkillDirectory(target.installDir) + const snapshot = await snapshotInstalledSkillDirectory(item, target) if (snapshot.fingerprint !== target.fingerprint || !isDeepStrictEqual(snapshot.files, metadata.files)) { return { status: 'blocked', reason: 'local-changes' } } diff --git a/cli/src/services/doctor-service.ts b/cli/src/services/doctor-service.ts index ee4d26743..4b151ab21 100644 --- a/cli/src/services/doctor-service.ts +++ b/cli/src/services/doctor-service.ts @@ -116,7 +116,8 @@ export async function runDoctor(cwd: string, home?: string): Promise owner.registry !== scanned.registry || owner.namespace !== scanned.namespace || owner.slug !== scanned.slug)) throw new Error('Doctor source ownership conflict') const index = existing.targets.findIndex(saved => saved.installDir === target.installDir) if (index >= 0) { - existing.targets[index] = { ...target, installedBy: existing.targets[index]!.installedBy ?? ['direct'] } + existing.targets[index] = { ...target, installationState: existing.targets[index]!.installationState ?? 'enabled', + installedBy: existing.targets[index]!.installedBy ?? ['direct'] } } else existing.targets.push({ ...target, installedBy: ['direct'] }) } } @@ -179,6 +180,17 @@ async function scanMetadata(cwd: string, skipped: DoctorResult['skipped']): Prom skipped.push({ path: slugPath, reason: 'cannot stat' }) continue } + // A renamed entry is never sufficient evidence to reconstruct disabled ownership. + try { + await lstat(join(slugPath, 'SKILL.md.disabled')) + skipped.push({ path: slugPath, reason: 'disabled entry requires its existing inventory record' }) + continue + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + skipped.push({ path: slugPath, reason: 'cannot verify disabled entry' }) + continue + } + } const skillhubDir = join(slugPath, '.skillhub') try { const skillhubSt = await lstat(skillhubDir) diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index 6a46c7895..0f803a3ed 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -1,7 +1,8 @@ +import { assertEntryAbsent, snapshotInstalledSkillDirectory, snapshotPreparedBackup } from './installed-skill-snapshot' import { assertDirectTargetOwnership } from './inventory-ownership' import { captureInstallInventory } from './inventory-cas' import { randomUUID } from 'node:crypto' -import type { InstallMove, InstallTransactionObserver, InstallTransactionWarning } from './install-transaction' +import type { InstallMove, InstallPreparation, InstallTransactionObserver, InstallTransactionWarning } from './install-transaction' import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { join, relative, resolve } from 'node:path' import { SkillHubClient } from '../clients/skillhub-client' @@ -61,6 +62,7 @@ interface StagedInstall { canonicalSkillDir: string tempDir: string installedAt: string + installationState: 'enabled' | 'disabled' backupDir: string | null movedIntoPlace: boolean } @@ -91,6 +93,10 @@ async function preflightInstallTargets( // user path so macOS aliases and Windows short names remain stable in CLI output. for (const item of inventory.items) for (const claimed of item.targets) { const canonicalClaim = await canonicalInventoryInstallDir(claimed) + if (canonicalClaim === canonicalSkillDir && claimed.installationState === 'disabled' + && (claimed.rootDir !== rootDir || claimed.installDir !== join(rootDir, identity.slug))) { + throw new CliError('disabled installation path aliases cannot be replaced', EXIT.validation) + } if (canonicalClaim !== canonicalSkillDir && pathsOverlap(canonicalClaim, canonicalSkillDir)) { throw new CliError('install target overlaps another managed installation', EXIT.validation) } @@ -124,6 +130,15 @@ export async function installSkill(options: InstallOptions): Promise inventory.items.flatMap(item => item.targets.map(target => ({ item, target }))) + .find(entry => entry.target.installDir === path) + for (const prepared of preparedTargets) { + const previous = previousTarget(prepared.skillDir) + if (previous?.target.installationState !== 'disabled') continue + if (!options.transactionObserver?.beforePreparation) throw new CliError('disabled installations require the native transactional upgrade manager', EXIT.validation) + const snapshot = await snapshotInstalledSkillDirectory(previous.item, previous.target) + if (snapshot.fingerprint !== previous.target.fingerprint) throw new CliError('disabled installation has local changes', EXIT.validation) + } const canonicalDirs = new Set(preparedTargets.map(target => target.canonicalSkillDir)) if (expectedItem !== undefined) { if (expectedItem && !sameInstalledSkillSource(expectedItem, identity)) throw inventoryChangedError() @@ -183,7 +198,7 @@ export async function installSkill(options: InstallOptions): Promise item.target), options.slug)) { @@ -226,14 +242,23 @@ export async function installSkill(options: InstallOptions): Promise { await assertInventoryCurrent(current) - await observer.beforePreparation!({ before: structuredClone(current), + preparation = { before: structuredClone(current), release: { registry: options.registry, namespace: options.namespace, slug: options.slug, version: resolved.version, fingerprint: resolved.fingerprint }, targets: staged.map(item => ({ agent: item.target.agent, canonicalSkillDir: item.canonicalSkillDir, - skillDir: item.skillDir, stagingDir: item.tempDir, installedAt: item.installedAt })) }) + skillDir: item.skillDir, stagingDir: item.tempDir, installedAt: item.installedAt })) } + await observer.beforePreparation!(preparation) }) } + // The preparation journal now binds each candidate directory and immutable release. + for (const item of staged) { + if (item.installationState !== 'disabled') continue + await assertEntryAbsent(join(item.tempDir, 'SKILL.md.disabled')) + await options.authorizeTargetMutation?.([item.tempDir], 'replace') + await rename(join(item.tempDir, 'SKILL.md'), join(item.tempDir, 'SKILL.md.disabled')) + } + for (const [index, item] of staged.entries()) { const targetExists = await pathExists(item.skillDir) if (!targetExists && options.requireExistingTargets) { @@ -264,7 +289,10 @@ export async function installSkill(options: InstallOptions): Promise 0) { throw new CliError(`local changes detected after upgrade planning at ${item.skillDir}`, EXIT.validation, { @@ -298,7 +326,8 @@ export async function installSkill(options: InstallOptions): Promise { + try { await lstat(path) } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + throw new Error('Skill entry destination is occupied') +} + +export async function snapshotInstalledSkillDirectory(item: InventoryItem, target: InventoryTarget): Promise { + if (!item.targets.some(entry => isDeepStrictEqual(entry, target)) + || await realpath(target.rootDir) !== target.rootDir || await realpath(target.installDir) !== target.installDir + || join(target.rootDir, item.slug) !== target.installDir) throw new Error('Installation identity changed') + return snapshotBoundInstallation(target.installDir, item, target) +} + +/** Internal lifecycle evidence: callers must bind this location to their persisted move intent before reading it. */ +async function snapshotBoundInstallation(directory: string, item: InventoryItem, target: InventoryTarget, linkedTransition = false): Promise { + await assertOwnedRegularTree(directory) + const read = await readInstalledSkillMetadata(directory) + if (read.status !== 'valid' || !sameInstalledSkillSource(read.metadata, item) + || read.metadata.version !== target.version || read.metadata.agent !== target.agent + || read.metadata.installedAt !== target.installedAt || read.metadata.fingerprint !== target.fingerprint + || !read.metadata.files || !target.fingerprint) throw new Error('Installation metadata changed') + const disabled = target.installationState === 'disabled' + const entry = disabled ? 'SKILL.md.disabled' : 'SKILL.md' + const info = await lstat(join(directory, entry)) + if (!info.isFile() || info.isSymbolicLink()) throw new Error('Unsafe skill entry') + if (disabled && !linkedTransition) await assertEntryAbsent(join(directory, 'SKILL.md')) + const snapshot = await snapshotSkillDirectory(directory) + if (!disabled && !linkedTransition) return snapshot + if ('SKILL.md.disabled' in read.metadata.files) throw new Error('Disabled entry collides with authored content') + const files: Record = { ...snapshot.files, 'SKILL.md': snapshot.files['SKILL.md.disabled']! } + delete files['SKILL.md.disabled'] + const aggregate = createHash('sha256') + for (const path of Object.keys(files).sort()) aggregate.update(`${path}:${files[path]}\n`, 'utf8') + return { files, fingerprint: `sha256:${aggregate.digest('hex')}` } +} + +/** Only persisted operation bindings may authorize nonstandard locations or transition states. */ +export async function snapshotJournalInstallation(record: OperationRecord, directory: string, + toggleState?: 'enabled' | 'disabled'): Promise { + if (record.toggle) { + const intent = record.toggle + if (directory !== intent.target.path || !record.operation.targetPaths?.includes(directory) + || !toggleState || ![intent.beforeState, intent.afterState].includes(toggleState)) throw new Error('Unbound toggle snapshot') + let linked = false + try { + const [a, b] = await Promise.all([lstat(intent.from), lstat(intent.to)]) + linked = a.isFile() && b.isFile() && !a.isSymbolicLink() && !b.isSymbolicLink() && a.dev === b.dev && a.ino === b.ino + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } + if (linked && ['committed', 'restored'].includes(intent.stage)) throw new Error('Unexpected linked entry after completion') + return snapshotBoundInstallation(directory, intent.inventoryItem, + { ...intent.inventoryItem.targets[0]!, installationState: toggleState }, linked) + } + if (toggleState) throw new Error('Transition state requires toggle evidence') + if (record.removal) { + const intent = record.removal.intent + if (![intent.target.path, intent.quarantine].includes(directory) + || !record.operation.targetPaths?.includes(intent.target.path)) throw new Error('Unbound removal snapshot') + const target = intent.inventoryItem.targets.find(target => target.installDir === intent.target.path) + if (!target) throw new Error('Removal snapshot missing target') + return snapshotBoundInstallation(directory, intent.inventoryItem, target) + } + const preparation = record.transaction?.preparation + if (!preparation) throw new Error('Installation snapshot missing preparation') + for (const staged of preparation.targets) { + if (!record.operation.targetPaths?.includes(staged.canonicalSkillDir)) continue + const old = preparation.before.items.flatMap(item => item.targets.map(target => ({ item, target }))) + .find(entry => entry.target.installDir === staged.skillDir) + const backup = record.transaction!.moves.find(entry => entry.move.phase === 'backup' && entry.move.canonicalSkillDir === staged.canonicalSkillDir && entry.move.to === directory) + if (backup) return snapshotPreparedBackup(preparation, backup.move) + if (![staged.skillDir, staged.stagingDir].includes(directory)) continue + const read = await readInstalledSkillMetadata(directory) + if (read.status !== 'valid') throw new Error('Installation snapshot metadata unavailable') + if (old && read.metadata.version === old.target.version && read.metadata.installedAt === old.target.installedAt) { + return snapshotBoundInstallation(directory, old.item, old.target) + } + const target: InventoryTarget = { agent: staged.agent, rootDir: dirname(staged.skillDir), installDir: staged.skillDir, + installedAt: staged.installedAt, version: preparation.release.version, fingerprint: preparation.release.fingerprint, + installationState: old?.target.installationState ?? 'enabled' } + const item = { ...preparation.release, targets: [target] } + // A crash can precede the staged entry conversion; both states are authorized only in this exact staging directory. + if (directory === staged.stagingDir && target.installationState === 'disabled') { + try { return await snapshotBoundInstallation(directory, item, target) } catch { + const enabled = await snapshotBoundInstallation(directory, item, { ...target, installationState: 'enabled' }) + if (enabled.files['SKILL.md.disabled']) throw new Error('Candidate entry collision') + return enabled + } + } + return snapshotBoundInstallation(directory, item, target) + } + throw new Error('Snapshot path is not bound to the operation') +} + +/** Called only after the shared installer has awaited its durable preparation and move intent hooks. */ +export async function snapshotPreparedBackup(preparation: InstallPreparation, move: InstallMove): Promise { + const staged = preparation.targets[move.index] + if (move.phase !== 'backup' || !staged || staged.skillDir !== move.from || staged.canonicalSkillDir !== move.canonicalSkillDir + || staged.stagingDir !== move.stagingDir || move.backupDir !== move.to || dirname(move.to) !== dirname(move.from) + || !basename(move.to).startsWith(`${basename(move.from)}.skillhub-backup-`)) throw new Error('Unbound backup snapshot') + const matches = preparation.before.items.flatMap(item => item.targets.map(target => ({ item, target }))) + .filter(entry => entry.target.installDir === staged.skillDir && sameInstalledSkillSource(entry.item, preparation.release)) + if (matches.length !== 1) throw new Error('Backup snapshot has ambiguous ownership') + return snapshotBoundInstallation(move.to, matches[0]!.item, matches[0]!.target) +} diff --git a/cli/src/services/inventory-cas.ts b/cli/src/services/inventory-cas.ts index 1dc03189e..a71da8e23 100644 --- a/cli/src/services/inventory-cas.ts +++ b/cli/src/services/inventory-cas.ts @@ -17,7 +17,7 @@ export async function captureInstallInventory( const canonicalRoot = await canonicalizeExistingPath(root) return { agent: target.agent, rootDir: canonicalRoot, installDir: resolve(canonicalRoot, relative(root, resolve(target.installDir))), - version: target.version, fingerprint: target.fingerprint ?? null, installedAt: target.installedAt, installedBy: [...new Set(targetInstalledBy(item, target))].sort() } + installationState: target.installationState ?? 'enabled', version: target.version, fingerprint: target.fingerprint ?? null, installedAt: target.installedAt, installedBy: [...new Set(targetInstalledBy(item, target))].sort() } })) const selected = targets.filter(target => [...canonicalDirs].some(path => pathsOverlap(path, target.installDir))) if (!selected.length) continue diff --git a/cli/src/services/suite-service.ts b/cli/src/services/suite-service.ts index 37cbff0a9..f19198622 100644 --- a/cli/src/services/suite-service.ts +++ b/cli/src/services/suite-service.ts @@ -651,6 +651,11 @@ async function preflightExistingTargets( } const currentSuitePrefix = `suite:@${plan.namespace}/${plan.slug}@` const ownerTarget = owner?.targets.find(existing => resolve(existing.installDir) === installDir) + if (ownerTarget?.installationState === 'disabled') { + throw new CliError(`disabled installation cannot be adopted by a Suite: ${installDir}`, EXIT.validation, { + next: 'enable the installation explicitly before adding Suite ownership' + }) + } if (owner && ownerTarget && !force && await pathExists(installDir) && (await snapshotSkillDirectory(installDir)).fingerprint !== ownerTarget.fingerprint) { throw new CliError(`local changes detected at ${installDir}`, EXIT.validation, { diff --git a/cli/src/services/sync-service.ts b/cli/src/services/sync-service.ts index a9cf70455..21554c238 100644 --- a/cli/src/services/sync-service.ts +++ b/cli/src/services/sync-service.ts @@ -1,7 +1,7 @@ import { assertDirectTargetOwnership } from './inventory-ownership' import { acquireSkillTargetLock } from './skill-target-lock' import { readdir, readFile, rename, rm, stat } from 'node:fs/promises' -import { basename, join } from 'node:path' +import { basename, join, resolve } from 'node:path' import { SkillHubClient, type NamespaceSyncItem } from '../clients/skillhub-client' import { installSkill } from './install-service' import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint' @@ -79,6 +79,11 @@ export async function inspectNamespaceWorkspace(options: { }): Promise<{ entries: SyncStatusEntry[]; remoteItems: NamespaceSyncItem[] }> { const remoteItems = options.remoteItems ?? await listAllNamespaceSkills(options.client, options.namespace) const managed = await scanManagedSkills(options.rootDir, options.registry, options.namespace) + const inventory = await new InventoryStore().read() + const isDisabled = (slug: string) => inventory.items.some(item => item.registry === options.registry + && item.namespace === options.namespace && item.slug === slug + && item.targets.some(target => resolve(target.installDir) === resolve(options.rootDir, slug) + && target.installationState === 'disabled')) const entries: SyncStatusEntry[] = [] const remoteSlugs = new Set(remoteItems.map(item => item.slug)) @@ -94,6 +99,11 @@ export async function inspectNamespaceWorkspace(options: { continue } + if (isDisabled(remote.slug)) { + entries.push({ ...baseEntry(remote, 'blocked'), localVersion: metadata.version, + reason: 'installation is disabled; use explicit upgrade or enable it before namespace sync' }) + continue + } const snapshot = await snapshotSkillDirectory(skillDir) const changedFiles = snapshot.fingerprint === metadata.fingerprint ? [] @@ -146,6 +156,11 @@ export async function inspectNamespaceWorkspace(options: { for (const [slug, metadata] of managed) { if (!remoteSlugs.has(slug)) { + if (isDisabled(slug)) { + entries.push({ namespace: options.namespace, slug, status: 'blocked', localVersion: metadata.version, + changedFiles: [], reason: 'disabled installation is retained; remove it explicitly instead of pruning' }) + continue + } const snapshot = await snapshotSkillDirectory(join(options.rootDir, slug)) const orphan: SyncStatusEntry = { namespace: options.namespace, diff --git a/cli/src/services/upgrade-service.ts b/cli/src/services/upgrade-service.ts index 101311f39..55cf98b23 100644 --- a/cli/src/services/upgrade-service.ts +++ b/cli/src/services/upgrade-service.ts @@ -7,6 +7,7 @@ import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' import { hasExplicitNamespace, parseSkillName, resolveSkillName } from '../shared/skill-name-parser' import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint' +import { snapshotInstalledSkillDirectory } from './installed-skill-snapshot' import { installSkill } from './install-service' import { readInstalledSkillMetadata, sameInstalledSkillSource } from './installed-skill-metadata' import { compareSkillVersions } from './skill-version-order' @@ -365,7 +366,9 @@ async function inspectTargets(item: InventoryItem, targets: InventoryTarget[]): if (!result.metadata.files) { baselineMissing = true } else { - const snapshot = await snapshotSkillDirectory(installDir) + const snapshot = target.installationState === 'disabled' + ? await snapshotInstalledSkillDirectory(item, target) + : await snapshotSkillDirectory(installDir) currentFiles[target.installDir] = snapshot.files for (const path of diffSkillFiles(result.metadata.files, snapshot.files)) { changedFiles.add(`${target.installDir}:${path}`) diff --git a/cli/src/stores/inventory-store.ts b/cli/src/stores/inventory-store.ts index d979b23f3..790198873 100644 --- a/cli/src/stores/inventory-store.ts +++ b/cli/src/stores/inventory-store.ts @@ -1,13 +1,14 @@ import { createHash } from 'node:crypto' import { z } from 'zod' import { isDeepStrictEqual } from 'node:util' -import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { lstat, open, readFile, rename, rm } from 'node:fs/promises' import { dirname, isAbsolute, relative, resolve } from 'node:path' import { lock } from 'proper-lockfile' import { acquireAcquisitionGate } from '../services/skill-target-lock' import { canonicalizeExistingPath, pathsOverlap, joinPath, userStateDir, ensureDir } from '../platform/paths' export interface InventoryTarget { + installationState?: 'enabled' | 'disabled' agent: string rootDir: string installDir: string @@ -80,25 +81,29 @@ const identity = { registry: text, namespace: text, slug: text } const suiteSchema = z.object({ ...identity, version: text, fingerprint: text, members: z.array(z.object({ namespace: text, slug: text, version: text, fingerprint: text, installDirs: z.array(text) }).strict()) }).strict() -export const inventoryTargetSchema = z.object({ ...targetShape, version: text, fingerprint: text.optional() }).strict() +const v2TargetSchema = z.object({ ...targetShape, version: text, fingerprint: text.optional() }).strict() +export const inventoryTargetSchema = v2TargetSchema.extend({ installationState: z.enum(['enabled', 'disabled']).optional() }).strict() +const v3TargetSchema = inventoryTargetSchema.extend({ installationState: z.enum(['enabled', 'disabled']) }) export const inventoryRecordSchema = z.object({ ...identity, targets: z.array(inventoryTargetSchema) }).strict() export const inventoryMemorySchema = z.object({ items: z.array(inventoryRecordSchema), suites: z.array(suiteSchema).optional() }).strict() export const legacyInventorySchema = z.object({ items: z.array(z.object({ ...identity, version: text, fingerprint: text.optional(), installedBy: z.array(text).optional(), targets: z.array(z.object(targetShape).strict()) }).strict()), suites: z.array(suiteSchema).optional() }).strict() -const inventoryDiskSchema = z.object({ schemaVersion: z.literal(2), records: z.array(inventoryRecordSchema), +const inventoryDiskSchema = z.object({ schemaVersion: z.literal(2), records: z.array(z.object({ ...identity, targets: z.array(v2TargetSchema) }).strict()), suites: z.array(suiteSchema).optional() }).strict() +const inventoryV3Schema = z.object({ schemaVersion: z.literal(3), records: z.array(z.object({ ...identity, targets: z.array(v3TargetSchema) }).strict()), suites: z.array(suiteSchema).optional() }).strict() + /** A legacy read is a projection only. Missing integrity evidence is never synthesized. */ export function parseInventory(value: unknown): Inventory { if (typeof value !== 'object' || value === null) throw new Error('Invalid inventory') if ('schemaVersion' in value) { - const parsed = inventoryDiskSchema.parse(value) - return { items: parsed.records, ...(parsed.suites === undefined ? {} : { suites: parsed.suites }) } as Inventory + const parsed = (value.schemaVersion === 3 ? inventoryV3Schema : inventoryDiskSchema).parse(value) + return { items: parsed.records.map(item => ({ ...item, targets: item.targets.map(target => ({ installationState: 'enabled' as const, ...target })) })), ...(parsed.suites === undefined ? {} : { suites: parsed.suites }) } as Inventory } const legacy = legacyInventorySchema.parse(value) return { items: legacy.items.map(item => ({ registry: item.registry, namespace: item.namespace, slug: item.slug, - targets: item.targets.map(target => ({ ...target, version: item.version, + targets: item.targets.map(target => ({ ...target, installationState: 'enabled', version: item.version, ...(item.fingerprint === undefined ? {} : { fingerprint: item.fingerprint }), installedBy: target.installedBy ?? item.installedBy ?? ['direct'] })) })), ...(legacy.suites === undefined ? {} : { suites: legacy.suites }) } as Inventory @@ -147,12 +152,12 @@ export class InventoryStore { try { await this.migrateUnderLock() } finally { await release() } } - async diskVersion(): Promise<1 | 2> { + async diskVersion(): Promise<1 | 2 | 3> { try { const value: unknown = JSON.parse(await readFile(this.path, 'utf8')) parseInventory(value) - return typeof value === 'object' && value !== null && 'schemaVersion' in value ? 2 : 1 - } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 2; throw error } + return typeof value === 'object' && value !== null && 'schemaVersion' in value ? value.schemaVersion as 2 | 3 : 1 + } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 3; throw error } } private async migrateUnderLock(): Promise { @@ -164,10 +169,13 @@ export class InventoryStore { const value: unknown = JSON.parse(raw.toString('utf8')) const inventory = parseInventory(value) await validateInventoryClaims(inventory) - if (typeof value === 'object' && value !== null && 'schemaVersion' in value) return + if (typeof value === 'object' && value !== null && 'schemaVersion' in value && value.schemaVersion === 3) return const digest = createHash('sha256').update(raw).digest('hex') - const backup = joinPath(dirname(this.path), `inventory.v1.${digest}.backup.json`) - try { await writeFile(backup, raw, { flag: 'wx', mode: 0o600 }) } + const backup = joinPath(dirname(this.path), `inventory.v${typeof value === 'object' && value !== null && 'schemaVersion' in value ? 2 : 1}.${digest}.backup.json`) + try { + const file = await open(backup, 'wx', 0o600) + try { await file.writeFile(raw); await file.sync() } finally { await file.close() } + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error } const backupInfo = await lstat(backup) if (!backupInfo.isFile() || backupInfo.isSymbolicLink()) throw new Error('Unsafe inventory migration backup') @@ -175,7 +183,7 @@ export class InventoryStore { try { await this.writeUnderLock(inventory) } catch (error) { try { - if (await this.diskVersion() === 2 && isDeepStrictEqual(await this.read(), inventory)) return + if (await this.diskVersion() === 3 && isDeepStrictEqual(await this.read(), inventory)) return } catch { /* Preserve the original migration failure. */ } throw error } @@ -213,6 +221,7 @@ export class InventoryStore { const inventory = await this.read() const before = structuredClone(inventory) const result = mutate(inventory) + for (const item of inventory.items) for (const target of item.targets) target.installationState ??= 'enabled' if (beforeCommit) await beforeCommit(structuredClone(before), structuredClone(inventory)) try { await this.writeUnderLock(inventory) } catch (writeError) { @@ -233,13 +242,18 @@ export class InventoryStore { protected async writeUnderLock(inventory: Inventory): Promise { inventoryMemorySchema.parse(inventory) await validateInventoryClaims(inventory) - const payload = JSON.stringify({ schemaVersion: 2, records: inventory.items, suites: inventory.suites }, null, 2) + const payload = JSON.stringify(inventoryV3Schema.parse({ schemaVersion: 3, records: inventory.items.map(item => ({ ...item, targets: item.targets.map(target => ({ ...target, installationState: target.installationState ?? 'enabled' })) })), suites: inventory.suites }), null, 2) parseInventory(JSON.parse(payload)) const tmpPath = `${this.path}.${process.pid}.${Date.now()}.tmp` try { - await writeFile(tmpPath, payload) + const file = await open(tmpPath, 'wx', 0o600) + try { await file.writeFile(payload); await file.sync() } finally { await file.close() } parseInventory(JSON.parse(await readFile(tmpPath, 'utf-8'))) await rename(tmpPath, this.path) + if (process.platform !== 'win32') { + const directory = await open(dirname(this.path), 'r') + try { await directory.sync() } finally { await directory.close() } + } } finally { await rm(tmpPath, { force: true }).catch(() => {}) } diff --git a/cli/test/fixtures/removal-crash-child.ts b/cli/test/fixtures/removal-crash-child.ts index 7089a6e0c..489f7b051 100644 --- a/cli/test/fixtures/removal-crash-child.ts +++ b/cli/test/fixtures/removal-crash-child.ts @@ -50,7 +50,7 @@ if (mode === 'recover') { await mkdir(join(installDir, '.skillhub'), { recursive: true }) await writeFile(join(installDir, 'SKILL.md'), content) const snapshot = await snapshotSkillDirectory(installDir) - const target = { version: identity.version, fingerprint: snapshot.fingerprint, agent: 'dsh', rootDir, installDir, installedAt: '2026-10-14' } + const target = { installationState: 'enabled' as const, version: identity.version, fingerprint: snapshot.fingerprint, agent: 'dsh', rootDir, installDir, installedAt: '2026-10-14' } targets.push(target) await writeFile(join(installDir, '.skillhub/metadata.json'), JSON.stringify({ ...identity, ...snapshot, agent: 'dsh', installedAt: target.installedAt })) diff --git a/cli/test/fixtures/toggle-crash-child.ts b/cli/test/fixtures/toggle-crash-child.ts new file mode 100644 index 000000000..38576fdb7 --- /dev/null +++ b/cli/test/fixtures/toggle-crash-child.ts @@ -0,0 +1,21 @@ +import { join } from 'node:path' +import { InventoryStore } from '../../src/stores/inventory-store' +import { OperationStore } from '../../src/integrations/operation-store' +import { inspectToggle, executeNativeToggle } from '../../src/integrations/native-toggle' + +const [home, operationId, boundary] = process.argv.slice(2) +if (!home || !operationId || !['intent', 'linked', 'moved', 'restore-linked', 'committed'].includes(boundary!)) throw new Error('Invalid fixture arguments') +const inventory = new InventoryStore(home) +const operations = await OperationStore.open(join(home, 'operations')) +const baseline = await inspectToggle(inventory, { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0' }, + { root: join(home, 'skills'), scope: 'user' }, 'disabled') +if (baseline.status !== 'ready') throw new Error('Fixture baseline unavailable') +const append = operations.recordToggle.bind(operations) +operations.recordToggle = async (...args) => { + const saved = await append(...args) + if (args[3].stage === boundary) process.kill(process.pid, 'SIGKILL') + return saved +} +if (boundary === 'restore-linked') inventory.mutateAtomic = async () => { throw new Error('Injected CAS rejection') } +await executeNativeToggle(operations, inventory, { operationId, ownerId: 'owner', baseline, authorize: async () => {} }) +throw new Error('Crash boundary was not reached') diff --git a/cli/test/integration/doctor-command.test.ts b/cli/test/integration/doctor-command.test.ts index d8c5b4a5f..4a4bf9fe6 100644 --- a/cli/test/integration/doctor-command.test.ts +++ b/cli/test/integration/doctor-command.test.ts @@ -195,7 +195,7 @@ describe('doctor command', () => { const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')) as { schemaVersion: number; records: Array<{ targets: Array<{ version: string }> }> } - expect(inventory.schemaVersion).toBe(2) + expect(inventory.schemaVersion).toBe(3) expect(inventory.records).toHaveLength(1) expect(inventory.records[0]!.targets.map(target => target.version).sort()).toEqual(['1.0.0', '2.0.0']) }) diff --git a/cli/test/integration/sync-command.test.ts b/cli/test/integration/sync-command.test.ts index d4b159abb..22fba93f2 100644 --- a/cli/test/integration/sync-command.test.ts +++ b/cli/test/integration/sync-command.test.ts @@ -1,6 +1,6 @@ import { InventoryStore } from '../../src/stores/inventory-store' import { createHash } from 'node:crypto' -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { strToU8, zipSync } from 'fflate' import { describe, expect, test } from 'bun:test' @@ -908,3 +908,27 @@ test.each(['ready', 'none'] as const)('sync push reports %s examples for raw ZIP } } finally { registry.stop() } }) + + +test('namespace sync preserves a disabled installation even with force and prune', async () => { + const env = await createTempHome() + const skill = { namespace: 'team-a', slug: 'demo', ...makeSkill('# disabled fixture') } + const registry = await startFakeRegistry({ token: 'fixture-token', skills: [skill] }) + const root = join(env.cwd, 'workspace') + const flags = ['--registry', registry.url, '--token', 'fixture-token', '--json'] + try { + const installed = await runCli(['install', '@team-a/demo', '--dir', root, ...flags], { HOME: env.home }, { cwd: env.cwd }) + expect(installed.exitCode).toBe(0) + const dir = join(root, 'demo') + await rename(join(dir, 'SKILL.md'), join(dir, 'SKILL.md.disabled')) + const store = new InventoryStore(env.home) + await store.mutateAtomic(inventory => { inventory.items[0]!.targets[0]!.installationState = 'disabled' }) + const before = await readFile(store.path, 'utf8') + const result = await runCli(['sync', 'pull', '--namespace', 'team-a', '--skill', 'demo', '--dir', root, + '--force', '--prune', ...flags], { HOME: env.home }, { cwd: env.cwd }) + expect(result.stdout + result.stderr).toContain('disabled') + expect(await readFile(store.path, 'utf8')).toBe(before) + expect(await readFile(join(dir, 'SKILL.md.disabled'), 'utf8')).toBe('# disabled fixture') + expect(await Bun.file(join(dir, 'SKILL.md')).exists()).toBe(false) + } finally { registry.stop() } +}) diff --git a/cli/test/unit/integrations/native-removal.test.ts b/cli/test/unit/integrations/native-removal.test.ts index f07ce95b6..aca0e8f74 100644 --- a/cli/test/unit/integrations/native-removal.test.ts +++ b/cli/test/unit/integrations/native-removal.test.ts @@ -20,7 +20,7 @@ test.each(['uninstall', 'stale', 'stale-root-file', 'local-change', 'commit-deni await writeFile(join(path, 'SKILL.md'), '# Original') const snapshot = await snapshotSkillDirectory(path) const selected = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0' } - const entry = { version: selected.version, fingerprint: snapshot.fingerprint, agent: 'dsh', rootDir: target.root, installDir: path, installedAt: '2026-10-14' } + const entry = { installationState: 'enabled' as const, version: selected.version, fingerprint: snapshot.fingerprint, agent: 'dsh', rootDir: target.root, installDir: path, installedAt: '2026-10-14' } const retained = { ...entry, agent: 'other', rootDir: join(home, 'other'), installDir: join(home, 'other/writer') } const item = { registry: selected.registry, namespace: selected.namespace, slug: selected.slug, targets: [entry, retained] } const inventory = new InventoryStore(home) diff --git a/cli/test/unit/integrations/native-toggle.test.ts b/cli/test/unit/integrations/native-toggle.test.ts new file mode 100644 index 000000000..e1e6b2e6d --- /dev/null +++ b/cli/test/unit/integrations/native-toggle.test.ts @@ -0,0 +1,216 @@ +import { expect, test } from 'bun:test' +import { randomUUID } from 'node:crypto' +import { cp, mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { InventoryStore } from '../../../src/stores/inventory-store' +import { snapshotSkillDirectory } from '../../../src/services/skill-fingerprint' +import { openNativeRuntime } from '../../../src/integrations/native-runtime' +import { OperationStore } from '../../../src/integrations/operation-store' +import { executeNativeToggle, inspectToggle } from '../../../src/integrations/native-toggle' + +async function fixture() { + const home = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-toggle-'))) + const root = join(home, 'skills') + const path = join(root, 'writer') + await mkdir(join(path, '.skillhub'), { recursive: true }) + await writeFile(join(path, 'SKILL.md'), '# Original\n') + await writeFile(join(path, 'resource.txt'), 'keep bytes') + const selected = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', version: '1.0.0' } + const snapshot = await snapshotSkillDirectory(path) + const target = { agent: 'dsh', rootDir: root, installDir: path, installedAt: '2026-10-27', version: selected.version, fingerprint: snapshot.fingerprint } + const inventory = new InventoryStore(home) + await inventory.writeAtomic({ items: [{ registry: selected.registry, namespace: selected.namespace, slug: selected.slug, targets: [target] }] }) + await writeFile(join(path, '.skillhub/metadata.json'), JSON.stringify({ ...selected, ...snapshot, agent: 'dsh', installedAt: target.installedAt })) + const stateDirectory = join(home, 'operations') + const runtime = await openNativeRuntime({ inventoryHome: home, stateDirectory }) + return { home, path, selected, inventory, runtime, stateDirectory, target: { root, scope: 'user' as const } } +} + +test('offline toggles rename only entry, preserve bytes, reject stale plans, and uninstall disabled', async () => { + const f = await fixture() + try { + const metadata = await readFile(join(f.path, '.skillhub/metadata.json')) + for (const desired of ['disabled', 'enabled', 'disabled'] as const) { + const baseline = await f.runtime.inspectToggle(f.selected, f.target, desired) + if (baseline.status !== 'ready') throw new Error(JSON.stringify(baseline)) + const operation = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', + mode: 'new', requestKey: randomUUID(), parameters: { desired } }, async () => ({ + namespace: f.selected.namespace, slug: f.selected.slug, version: f.selected.version, kind: desired === 'disabled' ? 'disable' : 'enable', targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + const result = await f.runtime.executeToggle({ operationId: operation!.operationId, ownerId: 'owner', baseline, authorize: async () => {} }) + expect(result.outcome).toBe('committed') + expect((await f.runtime.getOperation(operation!.operationId, 'owner'))?.files).toBe(desired) + expect(await readFile(join(f.path, desired === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('# Original\n') + expect(await readFile(join(f.path, '.skillhub/metadata.json'))).toEqual(metadata) + expect(await readFile(join(f.path, 'resource.txt'), 'utf8')).toBe('keep bytes') + expect((await f.runtime.listInstalled(f.selected.registry, [f.target])).items[0]?.installationState).toBe(desired) + expect((await f.runtime.inspectToggle(f.selected, f.target, desired)).status).toBe('unchanged') + } + const removal = await f.runtime.inspectRemoval(f.selected, f.target) + expect(removal.status).toBe('ready') + if (removal.status !== 'ready') return + const operation = await f.runtime.acceptRemoval({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { remove: true } }, async () => ({ + namespace: f.selected.namespace, slug: f.selected.slug, version: f.selected.version, kind: removal.kind, targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + expect((await f.runtime.executeRemoval({ operationId: operation!.operationId, ownerId: 'owner', baseline: removal, authorize: async () => {} })).outcome).toBe('committed') + expect((await f.inventory.read()).items).toEqual([]) + } finally { await rm(f.home, { recursive: true, force: true }) } +}) + +test.each(['modified', 'alias-collision', 'shared'] as const)('toggle blocks %s before a write', async mode => { + const f = await fixture() + try { + if (mode === 'modified') await writeFile(join(f.path, 'resource.txt'), 'changed') + if (mode === 'alias-collision') await writeFile(join(f.path, 'SKILL.md.disabled'), 'authored') + if (mode === 'shared') await f.inventory.mutateAtomic(value => { value.items[0]!.targets[0]!.installedBy = ['direct', 'suite:demo'] }) + expect((await f.runtime.inspectToggle(f.selected, f.target, 'disabled')).status).toBe('blocked') + expect(await readFile(join(f.path, 'SKILL.md'), 'utf8')).toBe('# Original\n') + } finally { await rm(f.home, { recursive: true, force: true }) } +}) + +test.each(['cas-before', 'cas-after', 'intent', 'committed-log'] as const)('toggle reconciles failure at %s', async mode => { + const f = await fixture() + try { + const operations = await OperationStore.open(f.stateDirectory) + const baseline = await inspectToggle(f.inventory, f.selected, f.target, 'disabled') + if (baseline.status !== 'ready') throw new Error(JSON.stringify(baseline)) + const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { mode } }, async () => ({ + namespace: f.selected.namespace, slug: f.selected.slug, version: f.selected.version, kind: 'disable', targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + const mutate = f.inventory.mutateAtomic.bind(f.inventory) + if (mode.startsWith('cas')) f.inventory.mutateAtomic = async (...args) => { + if (mode === 'cas-after') await mutate(...args) + throw new Error('Injected CAS failure') + } + const append = operations.recordToggle.bind(operations) + let once = true + operations.recordToggle = async (...args) => { + if (once && (mode === 'intent' && args[3].stage === 'intent' || mode === 'committed-log' && args[3].stage === 'committed')) { + once = false + throw new Error('Injected journal failure') + } + return append(...args) + } + const run = () => executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, authorize: async () => {} }) + if (mode === 'cas-after' || mode === 'committed-log') expect((await run()).outcome).toBe('committed') + else await expect(run()).rejects.toThrow('Injected') + const state = (await f.inventory.read()).items[0]!.targets[0]!.installationState + expect(state).toBe(mode === 'cas-after' || mode === 'committed-log' ? 'disabled' : 'enabled') + expect(await readFile(join(f.path, state === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('# Original\n') + } finally { await rm(f.home, { recursive: true, force: true }) } +}) + +test.each([false, true])('restart recovers a renamed entry without overwriting a new occupant: conflict=%s', async conflict => { + const f = await fixture() + try { + const baseline = await f.runtime.inspectToggle(f.selected, f.target, 'disabled') + if (baseline.status !== 'ready') throw new Error(JSON.stringify(baseline)) + const request = { hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new' as const, requestKey: randomUUID(), parameters: { disable: true } } + const accepted = await f.runtime.acceptToggle(request, async () => ({ + namespace: 'demo', slug: 'writer', version: '1.0.0', kind: 'disable', targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + expect((await f.runtime.acceptToggle(request, async () => { throw new Error('Must replay') }))?.operationId).toBe(accepted!.operationId) + const operations = await OperationStore.open(f.stateDirectory) + const append = operations.recordToggle.bind(operations) + operations.recordToggle = async (...args) => { + if (args[3].stage === 'moved') throw new Error('Crash window after rename') + return append(...args) + } + await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, + authorize: async (_paths, phase) => { if (phase === 'rollback') throw new Error('Recovery unavailable') } })).rejects.toThrow('Crash window') + expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe('enabled') + expect(await readFile(join(f.path, 'SKILL.md.disabled'), 'utf8')).toBe('# Original\n') + if (conflict) await writeFile(join(f.path, 'SKILL.md'), 'New occupant') + const restarted = await openNativeRuntime({ inventoryHome: f.home, stateDirectory: f.stateDirectory }) + expect((await restarted.listPending()).map(record => record.operationId)).toContain(accepted!.operationId) + const result = await restarted.recover(accepted!.operationId, 'owner', [f.path], async () => {}) + expect(result.outcome).toBe(conflict ? 'unknown' : 'restored') + expect(await readFile(join(f.path, 'SKILL.md'), 'utf8')).toBe(conflict ? 'New occupant' : '# Original\n') + if (conflict) expect(await readFile(join(f.path, 'SKILL.md.disabled'), 'utf8')).toBe('# Original\n') + } finally { await rm(f.home, { recursive: true, force: true }) } +}) + +test.skipIf(process.platform === 'win32')('killed toggle writers recover intent, renamed and committed boundaries', async () => { + const fixtures = await Promise.all(['intent', 'linked', 'moved', 'restore-linked', 'committed'].map(async boundary => { + const f = await fixture() + const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { boundary } }, async () => ({ + namespace: 'demo', slug: 'writer', version: '1.0.0', kind: 'disable', targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + return { ...f, boundary, operationId: accepted!.operationId } + })) + try { + await Promise.all(fixtures.map(async f => { + const child = Bun.spawn([process.execPath, new URL('../../fixtures/toggle-crash-child.ts', import.meta.url).pathname, f.home, f.operationId, f.boundary], + { stdout: 'pipe', stderr: 'pipe' }) + expect(await child.exited).not.toBe(0) + expect(await new Response(child.stderr).text()).toBe('') + })) + // Reverse-link recovery holds the inventory lease; its stale timeout is 30 seconds. + await Bun.sleep(31_500) + for (const f of fixtures) { + const restarted = await openNativeRuntime({ inventoryHome: f.home, stateDirectory: f.stateDirectory }) + const result = await restarted.recover(f.operationId, 'owner', [f.path], async () => {}) + expect(result.outcome).toBe(f.boundary === 'committed' ? 'committed' : 'restored') + const state = (await f.inventory.read()).items[0]!.targets[0]!.installationState + expect(state).toBe(f.boundary === 'committed' ? 'disabled' : 'enabled') + expect(await readFile(join(f.path, state === 'disabled' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('# Original\n') + } + } finally { await Promise.all(fixtures.map(f => rm(f.home, { recursive: true, force: true }))) } +}, 60_000) + +test.each(['forward', 'rollback'] as const)('exclusive entry creation preserves a competitor appearing after preflight: %s', async direction => { + const f = await fixture() + try { + const baseline = await f.runtime.inspectToggle(f.selected, f.target, 'disabled') + if (baseline.status !== 'ready') throw new Error(JSON.stringify(baseline)) + const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { direction } }, async () => ({ + namespace: 'demo', slug: 'writer', version: '1.0.0', kind: 'disable', targetLabel: 'User', targetPaths: [f.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + const operations = await OperationStore.open(f.stateDirectory) + const append = operations.recordToggle.bind(operations) + operations.recordToggle = async (...args) => { + const saved = await append(...args) + if (direction === 'forward' && args[3].stage === 'intent') await writeFile(args[3].to, 'Competing entry', { flag: 'wx' }) + if (direction === 'rollback' && args[3].stage === 'restoring') await writeFile(args[3].from, 'Competing entry', { flag: 'wx' }) + return saved + } + if (direction === 'rollback') f.inventory.mutateAtomic = async () => { throw new Error('CAS denied') } + await expect(executeNativeToggle(operations, f.inventory, { operationId: accepted!.operationId, ownerId: 'owner', baseline, authorize: async () => {} })).rejects.toThrow() + expect(await readFile(join(f.path, direction === 'forward' ? 'SKILL.md.disabled' : 'SKILL.md'), 'utf8')).toBe('Competing entry') + expect(await readFile(join(f.path, direction === 'forward' ? 'SKILL.md' : 'SKILL.md.disabled'), 'utf8')).toBe('# Original\n') + expect((await f.inventory.read()).items[0]!.targets[0]!.installationState).toBe('enabled') + expect((await f.runtime.getOperation(accepted!.operationId, 'owner'))?.phase).toBe('recovery-required') + } finally { await rm(f.home, { recursive: true, force: true }) } +}) + +test('independent toggle transactions preserve both targets in shared inventory', async () => { + const f = await fixture() + try { + const other = { scope: 'project' as const, root: join(f.home, 'project') } + const otherPath = join(other.root, 'writer') + await mkdir(other.root) + await cp(f.path, otherPath, { recursive: true }) + await f.inventory.mutateAtomic(inventory => { + const item = inventory.items[0]! + item.targets.push({ ...item.targets[0]!, rootDir: other.root, installDir: otherPath }) + }) + const requests = await Promise.all([f.target, other].map(async target => { + const baseline = await f.runtime.inspectToggle(f.selected, target, 'disabled') + if (baseline.status !== 'ready') throw new Error(JSON.stringify(baseline)) + const accepted = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'owner', mode: 'new', requestKey: randomUUID(), parameters: { scope: target.scope } }, async () => ({ + namespace: 'demo', slug: 'writer', version: '1.0.0', kind: 'disable', targetLabel: target.scope, targetPaths: [baseline.target.path], + context: { sourceIdentity: f.selected.registry, sourceLabel: 'Registry', sourceRevision: 'r1', hostLabel: 'Host', initiatedFrom: 'plaza' } + })) + return { operationId: accepted!.operationId, ownerId: 'owner', baseline, authorize: async () => {} } + })) + expect((await Promise.all(requests.map(request => f.runtime.executeToggle(request)))).map(result => result.outcome)).toEqual(['committed', 'committed']) + expect((await f.inventory.read()).items[0]!.targets.map(target => target.installationState)).toEqual(['disabled', 'disabled']) + } finally { await rm(f.home, { recursive: true, force: true }) } +}) diff --git a/cli/test/unit/integrations/native-upgrade.test.ts b/cli/test/unit/integrations/native-upgrade.test.ts index aee2a7aa5..f65696087 100644 --- a/cli/test/unit/integrations/native-upgrade.test.ts +++ b/cli/test/unit/integrations/native-upgrade.test.ts @@ -9,15 +9,18 @@ import { InventoryStore } from '../../../src/stores/inventory-store' import { openNativeRuntime } from '../../../src/integrations/native-runtime' import type { NativeUpgradeExecution } from '../../../src/integrations/native-contract' -async function fixture(downloadFailure = false) { +async function fixture(downloadFailure = false, entryCollision = 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 examples = JSON.stringify({ schemaVersion: 1, defaultLocale: 'en', examples: { en: [{ id: 'one', title: version, prompt: 'Use {{skill}} ' + version }] } }) - const fingerprint = 'sha256:' + createHash('sha256') + const collision = entryCollision && index === 1 + const aggregate = 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 } + .update('SKILL.md:' + createHash('sha256').update(content).digest('hex') + '\n') + if (collision) aggregate.update('SKILL.md.disabled:' + createHash('sha256').update('authored alias').digest('hex') + '\n') + const fingerprint = 'sha256:' + aggregate.digest('hex') + return { version, versionId: index + 1, content, examples, fingerprint, collision } }) let downloads = 0 const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) { @@ -29,7 +32,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), '.skillhub/examples.json': new TextEncoder().encode(version.examples) })).buffer) + return new Response(new Uint8Array(zipSync({ 'SKILL.md': new TextEncoder().encode(version.content), '.skillhub/examples.json': new TextEncoder().encode(version.examples), ...(version.collision ? { 'SKILL.md.disabled': new TextEncoder().encode('authored alias') } : {}) })).buffer) } }) const registry = server.url.origin const inventory = new InventoryStore(join(root, 'home')) @@ -189,3 +192,37 @@ test('upgrade download failure before staging ends without a recovery requiremen expect(await f.runtime.listPending()).toEqual([]) } finally { await f.close() } }) + +test.each(['success', 'replace-denied', 'entry-collision'] as const)('disabled upgrade retains its entry state through %s', async mode => { + const f = await fixture(false, mode === 'entry-collision') + try { + const root = f.roots[0]! + const selection = { registry: f.request.registry, namespace: 'demo', slug: 'writer', version: '1.0.0' } + const toggle = await f.runtime.inspectToggle(selection, root, 'disabled') + if (toggle.status !== 'ready') throw new Error(JSON.stringify(toggle)) + const context = { sourceIdentity: selection.registry, sourceLabel: 'Fixture', sourceRevision: 'source', hostLabel: 'Host', initiatedFrom: 'plaza' as const } + const disable = await f.runtime.acceptToggle({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'session', requestKey: randomUUID(), mode: 'new', parameters: { disable: true } }, + async () => ({ kind: 'disable', namespace: 'demo', slug: 'writer', version: '1.0.0', targetLabel: 'User', targetPaths: [toggle.target.path], context })) + await f.runtime.executeToggle({ operationId: disable!.operationId, ownerId: 'session', baseline: toggle, authorize: async () => {} }) + const baseline = await f.runtime.inspectUpgrade(selection, [root]) + if (baseline.status !== 'ready') throw new Error(JSON.stringify(baseline)) + const accepted = await f.runtime.acceptUpgrade({ hostInstanceId: f.runtime.hostInstanceId, ownerId: 'session', requestKey: randomUUID(), mode: 'new', parameters: { disabledUpdate: true } }, + async () => ({ namespace: 'demo', slug: 'writer', version: '2.0.0', targetLabel: 'User', targetPaths: [toggle.target.path], context })) + let replacing = 0 + const run = () => f.runtime.executeUpgrade({ ...f.request, operationId: accepted!.operationId, baseline, + authorize: async (paths, phase) => { + if (phase === 'replace' && paths.includes(toggle.target.path) && ++replacing === 2 && mode === 'replace-denied') throw new Error('Injected disabled update failure') + } }) + if (mode === 'success') expect((await run()).outcome).toBe('committed') + else { + await expect(run()).rejects.toThrow(mode === 'entry-collision' ? 'entry destination is occupied' : 'Injected disabled update failure') + expect((await f.runtime.recover(accepted!.operationId, 'session', [toggle.target.path], async () => {})).outcome).toBe('restored') + } + const installed = (await f.inventory.read()).items[0]!.targets.find(target => target.rootDir === root.root)! + expect(installed.installationState).toBe('disabled') + expect(installed.version).toBe(mode === 'success' ? '2.0.0' : '1.0.0') + expect(await readFile(join(root.root, 'writer/SKILL.md.disabled'), 'utf8')).toBe(`# Version ${installed.version}`) + await expect(readFile(join(root.root, 'writer/SKILL.md'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(join(f.roots[1]!.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 1.0.0') + } finally { await f.close() } +}) diff --git a/cli/test/unit/integrations/removal-inventory.test.ts b/cli/test/unit/integrations/removal-inventory.test.ts index e299a0505..3c227a40b 100644 --- a/cli/test/unit/integrations/removal-inventory.test.ts +++ b/cli/test/unit/integrations/removal-inventory.test.ts @@ -11,7 +11,7 @@ test('removal commits exactly one target, journals before writing and preserves try { const store = new InventoryStore(home) const root = join(home, 'skills') - const target = { version: '1.0.0', fingerprint: 'fingerprint', agent: 'dsh', rootDir: root, installDir: join(root, 'writer'), installedAt: '2026-10-14' } + const target = { installationState: 'enabled' as const, version: '1.0.0', fingerprint: 'fingerprint', agent: 'dsh', rootDir: root, installDir: join(root, 'writer'), installedAt: '2026-10-14' } const retained = { ...target, agent: 'other', rootDir: join(home, 'other'), installDir: join(home, 'other/writer') } const item = { registry: 'https://registry.example', namespace: 'demo', slug: 'writer', targets: [target, retained] } const other = { ...item, slug: 'reader', targets: [{ ...target, installDir: join(root, 'reader') }] } diff --git a/cli/test/unit/services/doctor-service.test.ts b/cli/test/unit/services/doctor-service.test.ts index d48b1d6ee..8446b26c2 100644 --- a/cli/test/unit/services/doctor-service.test.ts +++ b/cli/test/unit/services/doctor-service.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { mkdtemp, mkdir, writeFile, symlink, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { InventoryStore } from '../../../src/stores/inventory-store' import { runDoctor } from '../../../src/services/doctor-service' type InventoryFile = { @@ -297,3 +298,23 @@ describe('doctor-service', () => { expect(imageResizerItem.targets).toHaveLength(1) }) }) + + +test('doctor preserves a trusted disabled record and does not adopt a loose disabled entry', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'doctor-disabled-')) + const home = await mkdtemp(join(tmpdir(), 'doctor-disabled-home-')) + const metadata = { registry: 'https://example.test', namespace: 'global', slug: 'kept', + version: '1.0.0', agent: 'dsh', installedAt: '2026-10-27T00:00:00Z' } + const dir = await setupSkillDir(cwd, '.dsh', 'kept', metadata) + const loose = await setupSkillDir(cwd, '.dsh', 'loose', { ...metadata, slug: 'loose' }) + await writeFile(join(dir, 'SKILL.md.disabled'), 'unchanged') + await writeFile(join(loose, 'SKILL.md.disabled'), 'unclaimed') + const store = new InventoryStore(home) + await store.mutateAtomic(inventory => inventory.items.push({ registry: metadata.registry, + namespace: metadata.namespace, slug: metadata.slug, targets: [{ agent: 'dsh', rootDir: join(dir, '..'), + installDir: dir, installedAt: metadata.installedAt, version: '1.0.0', installationState: 'disabled' }] })) + const before = await store.read() + await runDoctor(cwd, home) + expect(await store.read()).toEqual(before) + expect(await readFile(join(dir, 'SKILL.md.disabled'), 'utf8')).toBe('unchanged') +}) diff --git a/cli/test/unit/services/suite-service.test.ts b/cli/test/unit/services/suite-service.test.ts index dc13abea7..36e661776 100644 --- a/cli/test/unit/services/suite-service.test.ts +++ b/cli/test/unit/services/suite-service.test.ts @@ -1136,3 +1136,23 @@ describe('Suite local lifecycle', () => { expect(inventory.items.find(item => item.slug === 'beta')).toMatchObject({ targets: [expect.objectContaining({ installedBy: ['direct'] })] }) }) }) + + +test('Suite cannot adopt a disabled direct installation even with force', async () => { + const home = await mkdtemp(join(tmpdir(), 'suite-disabled-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'suite-disabled-root-')) + const { plan, downloads } = makePlan() + const installDir = join(rootDir, 'alpha') + await mkdir(installDir) + await writeFile(join(installDir, 'SKILL.md.disabled'), '# Alpha') + const store = new InventoryStore(home) + await store.mutateAtomic(inventory => inventory.items.push({ registry, namespace: 'global', slug: 'alpha', targets: [{ + agent: 'dsh', rootDir, installDir, installedAt: '2026-10-27T00:00:00Z', version: '1.0.0', + fingerprint: plan.members[0]!.fingerprint, installedBy: ['direct'], installationState: 'disabled' + }] })) + const before = await readFile(store.path, 'utf8') + await expect(installSuite({ client: clientFor(plan, downloads), registry, namespace: 'global', slug: 'starter-pack', + targets: [{ agent: 'dsh', rootDir, scope: 'user', source: 'explicit' }], force: true, home })).rejects.toThrow('disabled installation') + expect(await readFile(store.path, 'utf8')).toBe(before) + expect(await readFile(join(installDir, 'SKILL.md.disabled'), 'utf8')).toBe('# Alpha') +}) diff --git a/cli/test/unit/stores/inventory-store.test.ts b/cli/test/unit/stores/inventory-store.test.ts index 7fe411020..d5a710c4c 100644 --- a/cli/test/unit/stores/inventory-store.test.ts +++ b/cli/test/unit/stores/inventory-store.test.ts @@ -15,6 +15,7 @@ function makeTarget(slug: string) { agent: 'claude', rootDir: `/projects/${slug}`, installDir: `/projects/${slug}/.skillhub/skills/${slug}`, + installationState: 'enabled' as const, installedAt: new Date().toISOString(), } } diff --git a/cli/test/unit/stores/inventory-v2.test.ts b/cli/test/unit/stores/inventory-v2.test.ts index 49d3a8bde..c497e0f3e 100644 --- a/cli/test/unit/stores/inventory-v2.test.ts +++ b/cli/test/unit/stores/inventory-v2.test.ts @@ -58,7 +58,7 @@ test('v1 read preserves bytes and migration expands ownership without inventing expect(backups).toHaveLength(1) expect(await readFile(join(dirname(f.store.path), backups[0]!), 'utf8')).toBe(bytes) const disk = JSON.parse(await readFile(f.store.path, 'utf8')) - expect(disk.schemaVersion).toBe(2); expect(disk).not.toHaveProperty('items'); expect(disk.records[0]).not.toHaveProperty('version') + expect(disk.schemaVersion).toBe(3); expect(disk).not.toHaveProperty('items'); expect(disk.records[0]).not.toHaveProperty('version') expect(await f.store.read()).toEqual(read) } finally { await f.close() } }) @@ -74,7 +74,7 @@ test.each(['before', 'after'] as const)('migration %s rename failure leaves one const store = new FailingStore(join(f.root, 'home')) if (mode === 'before') await expect(store.ensureWritable()).rejects.toThrow('injected') else await store.ensureWritable() - expect(await store.diskVersion()).toBe(mode === 'before' ? 1 : 2) + expect(await store.diskVersion()).toBe(mode === 'before' ? 1 : 3) expect(await store.read()).toEqual({ items: [] }) expect((await readdir(dirname(store.path))).filter(name => name.includes('.backup.json'))).toHaveLength(1) } finally { await f.close() } @@ -153,7 +153,7 @@ test('v2 recovery preserves a sibling advanced after the failed selected update' function legacySnapshot(inventory: Inventory) { return { ...inventory, items: inventory.items.map(item => ({ ...item, version: item.targets[0]!.version, ...(item.targets[0]!.fingerprint ? { fingerprint: item.targets[0]!.fingerprint } : {}), - targets: item.targets.map(({ version, fingerprint, ...target }) => { void version; void fingerprint; return target }) })) } + targets: item.targets.map(({ version, fingerprint, installationState, ...target }) => { void version; void fingerprint; void installationState; return target }) })) } } test('pending v1 recovery refuses v2 inventory; on v1 it preserves original journal snapshots', async () => { @@ -242,10 +242,10 @@ test('ancestor and descendant claims block writes but prefix siblings remain ind await expect(f.store.writeAtomic(overlapping)).rejects.toThrow('Ambiguous inventory target ownership') expect(await f.store.read()).toEqual(before) // Simulate a pre-existing or externally corrupted claim; read-only native previews must fail closed too. - await writeFile(f.store.path, JSON.stringify({ schemaVersion: 2, records: overlapping.items })) + await writeFile(f.store.path, JSON.stringify({ schemaVersion: 3, records: overlapping.items })) expect(await f.runtime.inspectRemoval({ ...f.identity, version: '1.0.0' }, f.roots[0]!)).toEqual({ status: 'blocked', reason: 'shared-target' }) expect(await f.runtime.inspectUpgrade({ ...f.identity, version: '1.0.0' }, [f.roots[0]!])).toEqual({ status: 'blocked', reason: 'shared-target' }) - await writeFile(f.store.path, JSON.stringify({ schemaVersion: 2, records: before.items })) + await writeFile(f.store.path, JSON.stringify({ schemaVersion: 3, records: before.items })) expect(await readFile(join(selected.installDir, 'SKILL.md'), 'utf8')).toBe('# Version 1.0.0') } const sibling = structuredClone(before) @@ -288,3 +288,23 @@ test.each(['suite-only', 'shared', 'suite-claim'] as const)('direct CLI mutation expect(await readFile(join(f.roots[0]!.root, 'writer/SKILL.md'), 'utf8')).toBe('# Version 1.0.0') } finally { await f.close() } }) + +test('v2 read is byte-preserving; v3 migration backs up exact v2 bytes and makes target states explicit', async () => { + const f = await fixture() + try { + await f.install(0, '1.0.0') + const v3 = await f.store.read() + const v2 = { schemaVersion: 2, records: v3.items.map(item => ({ ...item, + targets: item.targets.map(({ installationState, ...target }) => { void installationState; return target }) })) } + const bytes = JSON.stringify(v2, null, 4) + await writeFile(f.store.path, bytes) + expect((await f.store.read()).items[0]!.targets[0]!.installationState).toBe('enabled') + expect(await readFile(f.store.path, 'utf8')).toBe(bytes) + await f.store.ensureWritable() + const digest = createHash('sha256').update(bytes).digest('hex') + expect(await readFile(join(dirname(f.store.path), `inventory.v2.${digest}.backup.json`), 'utf8')).toBe(bytes) + const disk = JSON.parse(await readFile(f.store.path, 'utf8')) + expect(disk.schemaVersion).toBe(3) + expect(disk.records).toEqual(v3.items) + } finally { await f.close() } +}) diff --git a/openspec/changes/add-dsh-native-plugin/README.md b/openspec/changes/add-dsh-native-plugin/README.md index 325228ea2..627c0fdad 100644 --- a/openspec/changes/add-dsh-native-plugin/README.md +++ b/openspec/changes/add-dsh-native-plugin/README.md @@ -55,3 +55,9 @@ - API 同步与交付:Registry 契约 → 验证、部署与发布方案。 首期功能范围以本目录规范为准。当前研发基线为 DSH 0.2.0-rc.2,包名为 `@astron-team/skillhub-dsh`;正式平台支持与发布决定按验收门禁执行。过程日志、截图批次和临时研究材料在仓库外留档,仓库仅保留正式规格、验证摘要及可复用测试。 + +## 后续增量:安装级技能启停(设计阶段) + +[安装级启停设计](skill-toggle-design.md)定义受管理安装的停用、恢复、更新与卸载联动;正式验收见消费规范,执行拆分见 tasks.md 第44节。此增量尚未实现,不属于已发布0.5.0的能力。 + +- [使用优先页面与二级安装管理](skill-use-first-design.md):日常动作、只读安装选择及精确管理边界。 diff --git a/openspec/changes/add-dsh-native-plugin/interface-design.md b/openspec/changes/add-dsh-native-plugin/interface-design.md index ae88ebeb7..0b3dd9fd3 100644 --- a/openspec/changes/add-dsh-native-plugin/interface-design.md +++ b/openspec/changes/add-dsh-native-plugin/interface-design.md @@ -420,3 +420,13 @@ Client 的 `beginConversationManagement(sourceRevision,itemKey,action,version,re `SkillAvailability` 用独立字段表达 `integrity`(verified/changed/missing/unknown)、`discovery`(winner/shadowed/not-discovered/unknown)、`association`(managed-reference/not-associated)与可选 `invocation`(userInvocable/modelInvocable)。`checkedAt` 为检查时刻,`reason` 为受控原因。`name` 仅在实际技能名可核对时返回,不能用市场 slug 冒充调用名。托管目标的 `installation` 仅包含精确记录中的 namespace、slug、version、scope;顶层可选 scope 与该目标一致。即使缺少文件、无法解析调用名,也可据此显示被检查目标。按 name 查询不返回 installation 或 scope,不能从名称猜测来源。 `managed-reference` 仅证明选中了当前来源管理记录,不代表文件完整或当前可调用;`not-associated` 表示此次按名观察没有建立管理归属,不能解释为已确认属于其他市场。未知状态不得转成未安装。结果不携带路径、原始宿主对象、文档正文或异常文本。所有只读结果均不授予修改权限。 + + +### 官网链接字段 + +技能详情API和私有Host SkillDetail新增可选`webUrl`,完整URL由服务端返回;空/缺失/非法隐藏按钮,无插件拼接或默认回退。具体接口、可信配置和兼容语义以[官网链接契约](registry-api-contract.md#官网详情链接的开源接口增量)为准。 + + +### 按技能使用的私有接口 + +`selectSkillUse(sourceRevision, namespace, slug, signal): SkillUseSelection`只读返回唯一实际可用安装的itemKey或blocked原因,不产生安装/草稿/模型副作用,不新增Registry HTTP或LLM工具。返回引用仍须经过原新会话/填入前预检。契约与竞态边界见[使用优先设计](skill-use-first-design.md)。 diff --git a/openspec/changes/add-dsh-native-plugin/proposal.md b/openspec/changes/add-dsh-native-plugin/proposal.md index a24c132bb..7ac8e3936 100644 --- a/openspec/changes/add-dsh-native-plugin/proposal.md +++ b/openspec/changes/add-dsh-native-plugin/proposal.md @@ -46,3 +46,7 @@ - [详细实现方案](engineering-blueprint.md) - [本地生命周期](lifecycle-design.md) - [DSH 原生接入](dsh-integration-design.md) + +## 安装级启停增量 + +为SkillHub可确认归属的DSH安装增加按位置停用与恢复,将入口重命名为 SKILL.md.disabled,保持目录、资源及正文内容不变。用户级与项目级互不隐式联动;会话级调用控制、外部技能管理与SaaS不纳入。扩展共享核心库存和操作日志,CLI兼容及迁移必须先通过门禁,不能将新字段伪装为现有v2。当前设计不代表已实现或已发布;见[启停设计](skill-toggle-design.md)及tasks.md第44节。 diff --git a/openspec/changes/add-dsh-native-plugin/registry-api-contract.md b/openspec/changes/add-dsh-native-plugin/registry-api-contract.md index 9020196ba..4cc0653ac 100644 --- a/openspec/changes/add-dsh-native-plugin/registry-api-contract.md +++ b/openspec/changes/add-dsh-native-plugin/registry-api-contract.md @@ -189,3 +189,19 @@ sequenceDiagram ### 选定版本升级的 Registry 复用 定向升级仍使用现有 `GET /api/cli/v1/skills/{namespace}/{slug}/resolve?version={精确版本}` 与固定版本下载路径,不新增 SaaS 文件安装或升级 API。插件新增的 checkVersionUpdate、prepareVersionUpgrade 是本地 DSH Host 的 Agent-scoped 类型化方法,不是 SaaS HTTP 接口。resolve 返回其他版本时拒绝;不可用候选不重新请求无 version 的 latest。原最新版检查/计划方法继续使用无 version 的解析。 + + +## 官网详情链接的开源接口增量 + +`GET /api/v1/skills/{namespace}/{slug}`(既有 `/api/web/skills/{namespace}/{slug}` 别名一致)的详情响应新增可选 `webUrl: string | null`,示例: + +```json +{"namespace":"global","slug":"example","webUrl":"https://skill.example.com/space/global/example"} +``` + +- API提供完整网页地址;该字段不是下载URL或作者仓库,不要求插件知道网站路由。 +- 未配置可信网站地址时返回null;旧来源可不返回字段。缺失、null、空串、仅空白或无效地址时,DSH隐藏官网按钮,不影响其余功能。 +- 插件仅接受绝对HTTP(S),拒绝userinfo、控制字符、反斜杠及危险协议;合法页面query/fragment保留,不附加本地认证信息。新标签页使用noopener/noreferrer,详情必须仍匹配当前来源。 +- 开源实现复用 `skillhub.public.base-url` / `SKILLHUB_PUBLIC_BASE_URL`,支持部署前缀;基址仅来自部署配置,无合法配置则null。不读取客户端地址、请求Host或Forwarded等头构造公网链接。不新增数据库列。 +- 插件不再有websiteUrl设置、本地拼路由或Registry默认回退;上轮网站配置方案被本契约取代。已保存旧配置可忽略。 +- `webUrl`是兼容增量,非必需字段;SaaS未同步前按钮隐藏属于预期。配置网站基址只影响链接,不改变Registry下载、安装路径或安装来源。 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 afb7ce559..efe97d05e 100644 --- a/openspec/changes/add-dsh-native-plugin/saas-agent-prompt.md +++ b/openspec/changes/add-dsh-native-plugin/saas-agent-prompt.md @@ -125,3 +125,10 @@ bun run probe:registry --registry 'https://registry.example' --namespace 'demo' - 版本列表接受 `downloadableOnly=true`,过滤在分页前执行,`total` 与过滤一致。 - 公开版本全局按发布时间倒序排列;不改变缺省管理历史和技能访问政策。 - 使用发布时间与创建顺序相反、多页、不可下载及撤回版本测试,再用发布插件独立验证;存储不可达与领域不可安装分开处理。 + + +## 官网链接字段交接(待SaaS实现) + +在现有匿名技能详情API添加可选`webUrl: string | null`,返回该技能在SaaS网站上的完整网页地址。复用SaaS自身可信Web公网配置及真实路由,不从Host/Forwarded等请求头或客户端参数生成。没有可用配置时返回null,不编造链接;无需数据库迁移,也不新增登录流程。遵循[官网链接契约](registry-api-contract.md#官网详情链接的开源接口增量)。 + +DSH已移除websiteUrl设置及路由推测,空/缺失/无效会隐藏按钮。请验证非空准确跳转、null/省略/空白兼容、Unicode坐标及部署前缀、匿名详情权限不变、请求头伪造不会改变返回域名,并更新SaaS OpenAPI。现网未返回该字段时按钮隐藏是预期;开源本地验证不等于SaaS上线。 diff --git a/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md b/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md new file mode 100644 index 000000000..c48258bcb --- /dev/null +++ b/openspec/changes/add-dsh-native-plugin/skill-toggle-design.md @@ -0,0 +1,175 @@ +# 受管理安装位置的技能启停 + +状态:实现契约,验收范围见 verification.md。现有按位置安装/更新/卸载继续以 [生命周期设计](lifecycle-design.md) 和 [对话管理设计](conversation-management-design.md) 为基线;本增量对停用安装的读取、更新和卸载补充如下。 + +## 1. 范围与取舍 + +启停作用于当前 Registry 受管理的一个 DSH 安装位置,跨会话持久保存。停用时将精确入口 `SKILL.md` 重命名为 `SKILL.md.disabled`,启用时恢复原名;目录、资源位置、入口字节和调用策略保持不变,不执行包内内容。不是会话级过滤,也不承诺全机器或所有 provider 同名技能停用。 + +仅管理可信用户级及当前项目级安装,保留单一 direct 归属、内容基线与路径保护。外部来源、手工安装、Suite/shared ownership、路径别名及重叠认领不开放启停。新安装仍默认当前用户;项目安装保留高级入口并要求可信项目根。默认值不用于猜测已有安装的启停目标:唯一匹配可准备确认,多位置必须明确选择。 + +社区调查显示入口重命名、完整目录隔离、逐 agent Provider 覆盖均有实例。本轮选择入口重命名:整包移动会使空闲历史会话已加载正文中的资源路径失效,单纯检查活动任务不能解决。保留目录及资源路径减少该风险,但入口原路径仍变化,历史上下文不能视为已撤回。完整策略 Provider 需承担发现与胜出解析;单会话 scoped register 可以更轻,但语义是当前会话调用控制,不用于本轮安装级开关。此前整包移动实验不能证明新方案可行,入口重命名须重新验证;不复用或执行第三方插件代码。 + +## 2. 安装状态、存储与发现分离 + +安装逻辑模型如下,序列化协议见 §7: + +| 维度 | 含义 | +| --- | --- | +| 安装身份 | Registry 规范化身份 + namespace + slug + agent + 规范化原安装根和原安装目录;保留稳定位置身份 | +| 安装状态 | enabled / disabled;独立于 Host 是否发现、是否允许用户/模型调用 | +| 文件位置 | 两态使用同一安装目录;disabled 使用记录绑定的精确入口别名,目录继续被认领 | +| 版本与基线 | 每位置版本、指纹、时间及归属;停用不丢失、不伪造原元数据 | +| 完整性 | 内容匹配、已修改、缺失或未知;disabled 本身不是文件缺失 | +| 操作状态 | 处理中、结果未知或需恢复属于事务状态,不能用 enabled/disabled 掩盖 | +| 发现观察 | 当前会话是否发现目标、同名赢家、缓存或未知;必须单独采集证据 | + +停用入口不是垃圾,不得按清理期限删除。状态记录绑定精确安装及入口别名;不能以 `SKILL.md` 缺失推导 disabled,不能看到任意 `.disabled` 文件就认领。所有安装列表、详情、诊断、使用前校验及运行时上下文读取同一共享投影,模型和 UI 只收到白名单公开状态。 + +稳态读取指纹仅在受信库存记录为 disabled 且精确安装身份匹配时,按该状态将该安装内精确 `SKILL.md.disabled` 逻辑映射为 `SKILL.md` 计算,保留原始内容字节;其余作者文件照常参与。不对任意扩展名归一化,不跳过别名内容。两入口同时存在、入口缺失、别名冲突、目录或链接类型异常均阻断;原包自带该同名作者文件时必须阻断启停;升级候选包自带该文件则阻断停用状态下的升级,不能覆盖合法作者资源,也不因此改变普通上传协议。发布/导出必须先恢复原稿,或由经过验证的规范导出流程在输出副本恢复入口名并校验原指纹,不能把停用目录原样作为标准包发布。标准打包仍要求 SKILL.md,停用目录不能直接发布。 首次停用改名后且库存提交前、启用恢复及升级暂存等转换阶段,只能由受信操作日志与不可变计划绑定的精确目标、阶段和基线授权入口映射;不得通过通用布尔参数对任意目录放开映射。 + +停用成功仅表示选定位置的文件与库存提交完成。项目副本停用后用户副本可能接替,外部 Provider 也可能仍提供同名技能;不自动启停赢家、不自动将旧“使用”动作改绑给赢家。停用安装不提供“使用”,可提供启用、检查更新、更新和卸载。启用后仍需发现和策略核验,不能直接标记可调用。 + +## 3. 精确计划与真人确认 + +页面和对话共享 Host 计划与核心事务,不派生 CLI 进程。Host prepareToggle 生成确认计划,共享核心提供 inspectToggle / acceptToggle / executeToggle;对话启用/停用工具复用该路径。 + +1. Host 根据当前查询引用解析精确身份,核验当前 source revision、可信 session/agent/cwd、根目录及服务身份;客户端与模型不得提交自由路径。 +2. 读取状态、版本、指纹、归属、精确入口状态,核验完整包。已达目标状态返回无变化,不进行重复移动。 +3. 生成五分钟有效的不可变计划,绑定 before/after、上下文和原目标;确认显示技能、范围、当前状态及拟执行动作,提示同名副本可能继续可用、入口重命名不会撤回已加载内容,旧入口引用可能失效。不能承诺撤回运行任务或历史上下文。 +4. 原生确认可用时使用原生界面,否则使用既有确认卡。自然语言定位不等于真人批准;取消不写文件。确认期间不可切换原目标。 +5. 提交时在锁内重校验。源、会话、项目根、安装状态、版本、内容或归属变化使未接收计划失效;用户须重新查询、选择和确认,不能自动重绑。 +6. 沿用 requestKey 的精确重放规则:同 key 同参数返回原 operationId,不同参数拒绝。已接收请求的安全结果查询不变成新执行;响应丢失查询原操作,不能换 key 自动重试。会话变化不会撤销已经提交的写入,但禁止旧确认授权新写入。 + +### 旧会话和活动使用边界 + +资源目录保留不等于所有会话无影响:历史正文仍存在,代码可能直接引用原 `SKILL.md` 路径。停用不能撤回已加载正文或强制终止任务。入口加载与重命名竞争时明确返回加载失败并允许重新查询,不静默选择同名副本,不虚构活动资源检查 API 或要求全进程静止。跨进程使用不可证明时仅承诺受管理入口的发现控制,不保证外部程序停止使用。支持 RC 的读取、缓存与竞争行为须重新验证。 + +## 4. 共享核心文件事务 + +复用并扩展 `cli/src/stores/inventory-store.ts`、`cli/src/services/inventory-cas.ts`、目标锁及 `cli/src/integrations/operation-store.ts` 等现有边界;Host 只负责可信上下文、确认及展示。入口事务由 native-toggle.ts / toggle-journal.ts 实现,可信入口映射由 installed-skill-snapshot.ts 提供。 + +入口及别名必须由可信适配器在同一真实安装目录推导,不从模型或 Client 接收。设计门禁要求验证祖先可信、两端均非符号链接/重解析重定向、目标别名无占用,并证明受支持发现器不读取停用别名。本功能仅控制受支持官方 filesystem Provider 的标准入口发现,不承诺阻止任意第三方 Provider 读取别名或原正文。若观察到第三方仍提供该资源,明确报告当前仍被提供;不以快照缺席或 `.disabled` 名称作为全机排除证明。 + +统一按 canonical key 排序获取原目标及入口变更所需锁,再取 inventory lock;逻辑目标在停用前后使用稳定锁身份。CLI 与插件共用实现,不新增反向取锁路径。停用安装的原路径继续被认领,安装/同步/其他目标写入不能当作空位接管。入口别名也受冲突校验保护。 + +```mermaid +flowchart TD + A[真人确认精确位置计划] --> B[共享核心获取锁并复核基线] + B --> C{两个入口路径及状态匹配} + C -->|否| D[无写入拒绝旧计划] + C -->|是| E[持久化入口变更意图与恢复基线] + E --> F[排他创建目标硬链接并记录 linked 阶段] + F --> F2[核验同 inode 后移除旧入口] + F2 --> G[在最新库存 CAS 提交所选位置状态] + G -->|确定已提交| H[保留提交结果并完成日志] + G -->|确定未提交| I[核验后无覆盖恢复入口] + G -->|未知| J[重读库存与两侧文件] + J -->|匹配 after| H + J -->|匹配 before 且可逆| I + J -->|冲突或证据不足| K[保留现场并要求恢复] + H --> L[单独观察发现状态] +``` + +CAS 是逻辑提交点;只合并所选目标状态,保留其他位置最新变更。入口变更前日志保存身份、before/after、精确路径与阶段。文件转换失败不得直接改状态;不支持同目录硬链接时拒绝,不退化为可能覆盖目标的 rename 或复制删除。日志与库存采用既有持久化流程;进程强杀恢复测试不等于硬件断电耐久性验证。 + +启用时标准入口必须不存在且停用入口可信;标准入口存在任何内容、链接或无法读取均阻断,不覆盖、不合并。停用时别名目标必须不存在。停用入口被修改、指纹未知或两入口均缺失同样阻断。两入口均存在、库存与文件矛盾或未知协议进入待核验状态。 + +## 5. 崩溃、恢复和并发 + +| 故障窗口 | 结果与恢复规则 | +| --- | --- | +| intent 写入失败或未移动 | 不移动、不更新状态;报告失败 | +| linked 中间态或已转换,确定 CAS 未提交 | 同一锁域检查两侧与原基线,安全时逆向移动;否则保留现场并要求恢复 | +| CAS 报错但提交结果未知 | 重读 inventory 和两侧文件,禁止立即反向移动 | +| CAS 已提交,完成日志失败或响应丢失 | 保留新状态与文件,回执告警或待核验;不得反向恢复旧入口 | +| 重启发现未完成事务 | 获取执行租约和兼容锁后核验;不自动重放启停,不抢占活跃操作 | +| 恢复目标被占用 | 保留停用入口及日志,拒绝覆盖并提供受控诊断;不删除新占用内容 | + +目标恢复完成前阻断冲突写入;其他独立目标仍可按共享库存 CAS 更新。恢复仅处理本次目标,不恢复整份旧库存快照。共享核心需支持新日志版本,旧未完成操作必须先按兼容路径收尾或阻止迁移,不能将旧日志强制转换。 + +## 6. 停用期间的生命周期 + +- 查询仍展示停用安装及其版本、范围;停用入口不可读为未知,不冒充已卸载。可用性诊断先报告已停用,再独立报告 Host 时点观察。 +- 更新检查仍只读;更新执行使用同样的精确版本、内容指纹与真人确认,在原安装目录完成可恢复替换,新版本入口在暂存阶段转换为停用别名并按规范指纹校验,保持 disabled、安装位置认领和其他目标不变。不得先恢复标准入口再更新。 +- 卸载确认明确删除所选停用安装;从其可信原安装目录进入可恢复删除流程,提交后释放该目标认领,其他安装不变。远端离线不妨碍本地基线可信的停用/启用/卸载。 +- 旧卡片引用、更新计划和“使用”草稿请求在状态变化后失效;先前已加载进会话或正在执行的内容不承诺撤回。不得通过启停扩大作者的用户/模型调用权限。 +- 新装、升级、删除、sync、Suite 和 doctor 等库存消费者必须理解停用位置和保留路径。Suite 共享目标仍受现有归属限制,不因停用绕过。 +- 插件自身禁用/卸载不自动启用全部技能;持久状态和包应保留,可由兼容管理核心核验恢复。不能宣称卸载插件等同撤销文件变更。 + +## 7. 库存协议与实施门禁 + +先前 `inventory-store.ts` 的磁盘协议为 `schemaVersion: 2`,target/record/顶层均为 strict schema,target 无启停或入口状态字段。不得在 v2 上偷偷添加字段,不能依赖旧客户端忽略字段,也不能把启停状态另存为绕过库存的第二可写权威。 + +本轮冻结库存与操作日志为显式 v3。库存保留 records/suites 结构,target 新增必填 installationState(enabled/disabled);入口名由状态推导,不另存冗余字段。v1/v2 读取投影为 enabled;正常首次写入 v3 前,在兼容锁域保留旧库存原始字节及 SHA-256 对应的不可覆盖备份。旧 strict v2 消费者应拒绝新格式,实际写入前拒绝仍须矩阵验证。日志 v3 增加 enable/disable、精确入口 before/after 与恢复阶段,兼容读取旧 v2 日志,未知版本拒绝。以下门禁约束实现与交付: + +1. 旧 v1/v2 读取只做投影,不写回;旧记录默认逻辑 enabled,但缺失文件、缺失基线仍保留异常,不伪造可用。 +2. 首次写迁移在兼容锁域保存不可变原始字节及摘要备份,原子提交;保留所有其他位置、Suite、归属、版本及未知完整性事实。未完成旧日志阻断不安全迁移。 +3. CLI 与插件共同升级;所有库存/生命周期消费者通过同一模型。旧 strict v2 拒绝新格式的源码事实不证明所有旧命令在修改文件前拒绝,必须运行明确历史版本的读写矩阵,覆盖安装、升级、删除、sync 和 Suite。 +4. 迁移前停止不兼容写入进程;当前核心只检查可信操作状态目录内可发现的未完成日志,不宣称能够扫描或协调全部外部进程;新 schema 不能阻止任意旧程序直接修改文件。未通过旧消费者证据门禁不得开放启停或发布仅 Host 支持的中间版本。 +5. 回滚前停写并核验全部停用入口、标准入口占用和版本;先安全恢复或导出并完成目标协议验证,再讨论降级。只恢复 v2 库存备份不是有效回滚;冲突时保留现状,不提供未经证明的一键降级。 +6. 入口别名映射与库存状态必须参与同一逻辑目标锁和 CAS 比较;v3、恢复日志及旧消费者验证通过前不得发布仅支持 Host 的中间版本。平台耐久保证与发现边界由实际验证记录,不以方案文字代替通过。 + +## 8. 发现反馈与验收 + +支持宿主的 watcher 可能刷新发现,但 watch 关闭、失败、缓存未失效或自定义扫描均不保证瞬时生效。提交回执首先报告“该位置已停用/启用”;仅在官方当前会话证据匹配精确资源时补充发现结论。缺少完整证据显示“发现状态待核验”,提供按实际宿主能力执行的后续检查建议,不虚构刷新 API、不自动重启 Host。 + +实施验收使用临时 home/cwd、fake Registry 和惰性测试包,不执行第三方插件或技能脚本;真实模型验收使用另行授权的模型配置,并与工具/浏览器测试分别记录。可执行场景见 [正式 requirements](specs/dsh-skill-consumption/spec.md)。重点故障注入在 intent、linked、moved、restore-linked、CAS 前后及终态日志阶段,断言 inventory、安装目录、两入口及其他资源逐文件摘要、操作状态及未选目标保持一致。 + +交付证据分层:共享核心单元/多进程及历史消费者测试 → CLI/plugin lint/typecheck/tests/build → 两个已声明支持 RC 的真实 Host 发现与浏览器确认 → 后续获得授权的真实模型对话。模拟工具结果不能充当真实模型证据。停用后的同名回退、watch 关闭、入口加载竞争、旧入口路径限制、恢复冲突及停用中更新必须单独记录。 + +### 私有共享核心适配 + +NativeRuntime.inspectToggle / acceptToggle / executeToggle,沿用既有 operation 查询与 recover。inspect 返回 ready/blocked/unchanged;计划固定 beforeState/afterState 和单位置基线。InstalledProjectionItem 新增 installationState,与 present/missing 等物理完整性状态分离。公共 Registry HTTP API 不变;私有类型随插件构建生成,不要求用户另装CLI。 + +### 首期 CLI 消费范围 + +CLI 不新增启停命令,需正确读取 v3 并保护停用记录。缺少原生持久事务观察器的 CLI 停用升级安全拒绝,提示使用 DSH 执行;force 不绕过。namespace sync 不更新或 prune 停用安装;Suite 不接管停用安装;doctor 不通过散落别名重建停用归属,保留已有记录。DSH 停用更新与卸载是交付必需能力。 + +### 无覆盖入口变更 + +入口逻辑重命名不得使用“检查不存在后普通 rename”来保证无覆盖。采用同目录排他创建目标硬链接,再移除源入口;日志记录 linked/restoring 中间阶段。恢复仅在受信操作意图、两入口相同设备与inode、完整内容校验一致时收敛中间双入口;其他双入口冲突保留不动。目标已存在返回冲突,平台不支持该原语则无写入拒绝,不退化为可覆盖rename。 + +### 停用安装的发现诊断 + +诊断在两秒总时限内读取当前会话的 snapshot/get,并复核安装基线;不通过市场 slug 猜测实际技能名。实际名称从可信入口正文的 frontmatter 提取,仅接受唯一顶层简单 name(含简单引号字符串);复杂 YAML、重复字段、锚点、超限或无法核验时返回 unknown。此限制只影响只读发现诊断,不影响符合基线的启停操作。 + +同名其他位置胜出与选定安装停用分别展示;缓存仍保留原技能、snapshot/get 不一致或无法验证正文时不能声称已经退出发现。旧 RC 不保证在同一会话立即接替同名用户副本,新会话观察与旧会话观察分别记录;不自动重启宿主或撤回已加载正文。 + +### 安装详情的操作布局(已由使用优先设计替代) + +详情右侧安装管理使用显式动作按钮,不使用独立省略号菜单包住唯一的停用动作。统一动作区最多一个强调主按钮:已启用且可使用时为“使用此技能”,停用时为“启用”;升级为描边次操作,停用为低强调操作。按钮同高、间距一致,窄空间自然换行。查看的详情版本与安装版本不同时,若提供使用入口,须标为“使用已安装版本”,始终绑定当前安装引用并通过原有使用预检。停用状态以“启用”为主操作,仍保留安装范围与已有卸载入口。按钮布局不改变精确安装引用、真人确认及状态失效规则;对话卡片继续使用紧凑的更多操作菜单。 + +启停确认弹窗只保留动作标题、技能名称及坐标/版本、明确安装范围、一句影响说明和底部“取消/确认”按钮。范围不在多段正文重复,安装状态与停用状态分别表达,停用不使用绿色成功态。精确位置授权与已加载内容/同名安装的影响边界保持不变。 + +### 正常状态的精简呈现 + +安装详情、已安装卡片及对话管理卡在成功刷新后仅通过当前安装状态和动作表达结果,不常驻追加“该位置已启用/停用、发现状态请另行核验”等说明,不因成功说明改变按钮布局。独立操作回执保留简短的操作结果;发现观察仍保存在结构化结果与既有详情/显式可用性检查中,不将已启用冒充当前会话可调用。错误、冲突、告警及需恢复等需要处理的状态仍明确提示。 + +范围选择统一使用共享SelectField。“选择范围”为未选择占位,不是一个可提交选项;多安装位置必须由用户明确选择后才开放该位置操作,不通过视觉精简自动猜测范围。 + +### 技能市场卡片的范围与启停(已由使用优先设计替代) + +市场卡片不使用点击省略号后在原按钮行插入“停用”的交互,启停使用显式动作。初始只有一份安装时直接绑定该位置;多份安装要求明确选择。已有选择在刷新后消失时,即使仅剩一份也必须显式重选,不能自动改绑。保持底层用户/项目隔离,不猜测目标。范围控件置于管理动作之前。停用单份以中性色“已停用 + 版本 + 范围”摘要表达,不重复在动作行显示状态;混合多份分别保留各位置状态,不能整体误标停用。启用为主动作,升级及卸载为次操作,避免两个强调按钮并列。对话紧凑卡的独立交互不因市场布局变更而改变。 + +### 混合安装状态与官网入口 + +同技能用户级停用、项目级启用时,市场摘要按位置完整展示两项状态,不将技能整体标为停用,也不截断第二项;选择用户位置显示启用操作,选择项目位置显示停用操作。安装启用状态不代替当前会话发现/调用策略核验。 + +详情标题附近保留次要外链“在官网查看”。链接唯一来自技能详情API的可选`webUrl`,为完整绝对HTTP(S)地址;字段缺失、null、空白或不安全时隐藏按钮,不影响详情/安装等功能。插件不拼路由、不回退到Registry、固定官网或作者仓库,移除此前websiteUrl设置;旧持久化字段忽略。合法完整URL的页面参数及fragment保留,但不附加本地凭证,拒绝userinfo、控制字符、反斜杠和危险协议,使用noopener/noreferrer。当前来源与详情失配时不提供可点击链接。开源服务端通过可信skillhub.public.base-url配置生成网站详情地址,未配置或不合法返回null;不能采用请求Host/转发头决定公网域名。SaaS单独同步同字段,不由插件推测其站点路由。 + +## 使用优先的最新页面约定 + +日常位置选择及维护按钮布局以[使用优先设计](skill-use-first-design.md)为准;本文件的文件事务、权限及管理确认契约继续有效。此前直接在市场卡展示启停的布局已被二级管理替代。 + +### 详情位置操作菜单 + +每个正常安装位置只常驻范围、版本、状态及更多菜单;停用位置另保留启用。使用此位置、可用性、更新检查、停用及卸载通过浮层提供,不撑高卡片。菜单支持方向键和 Escape;菜单卸载不能取消使用请求。继续复用精确 itemKey、确认与 receipt,不改变页面主使用入口。 + +### 安装检查反馈 + +详情中的更新检查 current/普通失败和可用性摘要进入右上角可关闭通知,不累积卡片正文。普通成功 5 秒后消失,冲突/失败仅手动关闭;同一动作重复更新通知。通知随 source revision、技能和会话边界销毁,无焦点抢占,status/alert 播报,关闭支持键盘。可升级入口、blocked/stale 状态与确认错误继续显式展示。发现一致不等于允许调用;保留 invocation 与停用缓存说明,不推断未知 winner。 + +### 紧凑反馈与稳定安装布局 + +通知使用范围/版本标题与一句结论;普通发现不声称可调用,禁用、未知来源、停用缓存等限制保留简明告警。安装范围直接展示,沿用原默认范围。确认、进行中和成功保留相同左右区域,窄屏纵排且不溢出;成功使用入口与进度条分隔,继续原精确安装核验及使用交接。 diff --git a/openspec/changes/add-dsh-native-plugin/skill-use-first-design.md b/openspec/changes/add-dsh-native-plugin/skill-use-first-design.md new file mode 100644 index 000000000..63941b687 --- /dev/null +++ b/openspec/changes/add-dsh-native-plugin/skill-use-first-design.md @@ -0,0 +1,59 @@ +# 使用技能优先的页面与交互 + +本增量替代技能市场、已安装列表和默认详情中的位置下拉及并列维护按钮。安装位置、版本、启停和归属继续由原共享核心管理,不合并文件、不迁移安装,也不改变默认用户级安装。 + +## 1. 日常与管理分层 + +| 场景 | 主动作 | 次动作与状态 | +| --- | --- | --- | +| 库存已核实且无安装 | 安装 | 使用原安装确认,范围在高级选项中,默认用户级 | +| 有可候选的已启用安装 | 使用技能 | 管理安装;状态仅表达已安装,不冒充已核验可调用 | +| 全部安装停用 | 管理安装 | 简短“已停用”,不出现重复管理按钮 | +| 本地异常或库存不确定 | 管理安装或禁用使用 | 保留准确异常/未知,不能误显示未安装或自动重装 | +| 使用核验失败 | 保留管理入口 | 一条短错误,不自动启用、升级、换来源或选择其他不确定副本 | + +市场与已安装默认按来源、namespace、slug聚合为一张技能卡;正常摘要不展示每位置版本、启停与范围下拉。详情突出文档、版本内容、官网入口和使用动作;使用针对本地实际安装,不能把远端所选详情版本当成已安装版本。两者不一致时标为“使用已安装版本”。 + +对话中的安装查询卡采用同样的技能级主动作和二级管理入口。用户明确请求更新、卸载或启停后的原确认、结果卡保留精确安装范围与版本,不因日常界面精简而省略授权对象。 + +## 2. 管理安装 + +“管理安装”从卡片进入对应技能详情,直接在详情右侧展示安装管理,不打开管理弹窗。市场与已安装详情共用这一层级;对话管理沿已有详情导航,搜索详情容器中同样内联展示。窄屏转为上下布局。每个位置独立一行展示范围、版本和真实状态。行内复用原有检查更新、升级、启用/停用、卸载以及明确使用该安装的能力。每行绑定自己的itemKey及来源上下文,不默认选择第一行或将失效目标改绑到另一行。 + +管理操作继续走不可变计划与真实确认;取消不写入,完成后刷新当前库存而不修改历史操作事实。异常、未知、待恢复和共享归属继续阻止不安全操作。所有位置都被删除时展示空状态。对话原查询的过期引用不恢复为新的写入授权。 + +## 3. 技能级只读选择 + +私有Host接口:`selectSkillUse(sourceRevision, namespace, slug, signal): SkillUseSelection`。ready仅返回定位用途的itemKey;blocked区分无安装、无可用安装、无法核验、多个可用安装和超时。该接口不是新LLM工具,不写文件、创建会话、修改输入框或执行技能。 + +选择只限当前来源、当前可信会话、用户及当前项目中可见的受管理安装。通过现有listInstalled与prepareSkillUse核验完整内容、DSH实际winner资源和用户调用权限,不按项目优先、版本最新、数组顺序或存在SKILL.md猜测。完整发现快照、安装记录与上下文身份必须稳定;唯一ready才返回。明确停用、被同名安装覆盖或不允许用户调用的候选可排除;其他未知证据不能当作不存在。多个ready(例如版本使用了不同实际技能名)必须进入管理,不能任选一个。 + +检查有候选数量、并发及总时限边界,取消或超时后停止后续检查。选择结果只是时点定位;随后仍沿用新对话创建和填入前的原严格预检,不把返回itemKey当成新的持久授权。服务、来源、会话、目录、安装版本或状态变化后失败,不静默重绑。 + +```mermaid +flowchart TD + A[点击使用技能] --> B[Host核验当前来源与会话] + B --> C[读取候选并核验实际发现与调用权限] + C --> D{唯一可信可用安装} + D -->|否| E[短提示与管理安装入口] + D -->|是| F[返回精确安装引用] + F --> G[沿原流程打开同工作区新对话或保留当前对话] + G --> H[再次校验安装与原生输入框] + H --> I[选择案例或自定义任务] + I --> J[填入草稿 用户自行发送] +``` + +## 4. 明确目标的例外 + +管理面板内明确“使用此安装”、安装成功结果及已有精确位置操作回执继续使用原itemKey,不改成坐标级选择器。用户已明确选定目标时,不能静默使用同名赢家。主使用失败也不自动打开另一来源的技能。 + +## 5. 验收 + +- 日常市场/已安装/对话查询卡及默认详情不再展示范围下拉和维护按钮行;二级管理仍完整可操作。 +- 单份启用、双份混合、全部停用、异常、未安装、多份不同版本,以及同名外部资源覆盖,显示与动作正确。 +- 用户停用且项目真正可用、反向组合、两个真正可用、无法验证、来源/会话/服务/文件变化及超时均有选择契约测试。 +- 新对话保持工作区;原对话草稿与附件不覆盖;选择案例不自动发送或调用模型;取消不填入。 +- 二级管理确认显示准确目标,取消不改文件;在途和过期引用不能换目标。 +- 真实DSH浅深主题、窄屏、管理面板及返回使用链路;本轮无需调用真实模型,实际执行记录另存verification.md。 + +安装与危险动作仍保留确认框;确认层不得被卡片操作区的层叠上下文约束。常规页面使用独立body弹层,原生dialog内的确认保留在对应top layer内。验证必须包含遮罩下背景按钮命中检查。 diff --git a/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md b/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md index 7e048d480..0f31e899c 100644 --- a/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md +++ b/openspec/changes/add-dsh-native-plugin/specs/dsh-skill-consumption/spec.md @@ -259,3 +259,180 @@ The plugin SHALL diagnose a selected managed installation or a named current-ses #### Scenario: Path-free result - **WHEN** Host resources or errors contain paths, content, metadata or secrets - **THEN** only explicitly allowed diagnosis fields reach the model and UI, and no raw resource or error is serialized + +### Requirement: Persistent managed-installation enablement +The plugin SHALL enable or disable exactly one current-source managed DSH installation at a trusted user or current-project location. Disablement SHALL persist across sessions by renaming the exact SKILL.md entry to SKILL.md.disabled while preserving entry bytes, resource paths and invocation policy. It SHALL NOT implement this contract as a conversation-only filter or execute package scripts. See `../../skill-toggle-design.md` for the storage protocol and implementation gates. + +#### Scenario: Disable one of two installations +- **WHEN** user and project installations exist and the user confirms disabling the project installation +- **THEN** only that exact entry is renamed to the verified non-discovered alias, its inventory identity and version remain installed with disabled state, and user installation files and records remain unchanged + +#### Scenario: Default scope does not select a mutation target +- **WHEN** a request to disable a skill matches multiple managed locations without an explicit location +- **THEN** the Host requires a target selection before preparing confirmation; the user-scope default for new installation SHALL NOT select the target + +#### Scenario: Unmanaged or shared installation +- **WHEN** a selected target has external, unknown, Suite/shared or overlapping physical ownership +- **THEN** enabling or disabling is blocked without modifying files or claiming that installation + +#### Scenario: Already in requested state +- **WHEN** the verified exact installation already has the requested enablement state +- **THEN** the plugin reports unchanged without moving files or creating another successful mutation + +### Requirement: Enablement separate from discovery +The plugin SHALL represent persistent enablement, package integrity, current-session discovery and invocation policies as separate facts. Disabled installations SHALL remain visible in installed queries and SHALL NOT be treated as missing merely because the standard entry is absent. + +#### Scenario: Same-name fallback after disablement +- **WHEN** the selected installation is disabled and another location or provider wins the same skill name +- **THEN** the result identifies only the selected location as disabled, reports a winner only from verified Host evidence, and neither disables nor silently uses the other installation + +#### Scenario: Watcher unavailable or stale +- **WHEN** the filesystem transaction commits while watch is disabled, failed or discovery remains cached +- **THEN** the result reports the committed installation state and unconfirmed or observed discovery separately, without claiming instantaneous revocation, restarting the Host or terminating running tasks + +#### Scenario: Custom discovery includes disabled entries +- **WHEN** a custom provider can still supply the renamed entry or its content +- **THEN** the plugin limits its disablement claim to the managed entry under the supported official filesystem provider, reports any verified current-session discovery separately, and never treats a filename convention or absent snapshot as proof of machine-wide exclusion + +#### Scenario: Enablement preserves invocation policy +- **WHEN** a disabled package with restricted user or model invocation is successfully restored +- **THEN** its bytes and restrictions are preserved and it is not reported callable until the exact resource and relevant policy are independently verified + +### Requirement: Confirmed exact enablement plans +Enablement mutations SHALL use immutable Host plans, real user confirmation, trusted context, compatible locks and exact before-state revalidation. Page and conversation entry points SHALL use the shared core without launching a CLI process. A natural-language target reference SHALL NOT itself authorize execution. + +#### Scenario: Cancellation or changed context +- **WHEN** the user cancels or the source, session, project root, services, installation state, version, content or ownership changes before acceptance +- **THEN** the unaccepted plan cannot execute; changed context requires a fresh query and confirmation and cancellation leaves files and inventory unchanged + +#### Scenario: Repeated confirmation and lost response +- **WHEN** an accepted toggle is retried with the same owner, request key and parameter digest after a response is lost +- **THEN** the original operation is returned without another movement; different parameters are rejected and missing outcome evidence remains unknown rather than triggering a new request + +#### Scenario: Historical use action after disablement +- **WHEN** a historical card requests use or another mutation after the selected installation's state changes +- **THEN** the old reference is rejected without binding it to another location; a fresh query exposes the current state and disabled installations do not offer use + +### Requirement: Recoverable enablement file transactions +The shared core SHALL persist entry-transition intent, validate both entry paths, acquire canonical target locks before the inventory lock and CAS only the selected target. Inventory commit SHALL determine logical success; recovery SHALL reconcile inventory and actual files without overwriting unrelated or conflicting content. Disabled installation directories SHALL remain reserved against other managed installations. + +#### Scenario: Standard entry occupied +- **WHEN** enabling finds any existing content, link, conflicting claim or unreadable state at the standard entry path +- **THEN** restoration is blocked, the disabled entry and package and disabled record remain intact, and no merge, overwrite or alternate destination is chosen + +#### Scenario: Disabled package drift +- **WHEN** a disabled package is missing, modified or lacks a usable integrity baseline +- **THEN** enabling and destructive mutations are blocked with an explicit integrity or unknown-state result rather than reconstructing or deleting content + +#### Scenario: Crash after movement before inventory commit +- **WHEN** the entry has been renamed but the inventory still matches the before-state after interruption +- **THEN** recovery reacquires compatible locks and restores only when the non-overwriting inverse transition and baseline are verified safe; conflicts preserve evidence and require recovery rather than reporting success + +#### Scenario: Inventory committed but completion journal failed +- **WHEN** inventory matches the after-state and files match the committed entry name despite a failed terminal journal write +- **THEN** reconciliation preserves the new state and reports the actual outcome with warning, never reverting files against committed inventory + +#### Scenario: Unknown commit or ambiguous files +- **WHEN** CAS outcome is unknown or both standard and disabled entries exist or cannot be read reliably +- **THEN** the core rereads state under compatible locks and retains unresolved evidence without replaying the toggle or allowing conflicting new mutations + +#### Scenario: Concurrent independent targets +- **WHEN** different installations change concurrently while one is being disabled +- **THEN** selected-target CAS preserves the other installation's latest version, state, ownership and files; an original path reserved by a disabled target is not treated as unclaimed + +### Requirement: Disabled installation lifecycle +Disabled installations SHALL remain eligible for protected update checks, updates and exact local uninstall under the existing integrity and ownership rules. Updating SHALL preserve disabled state and SHALL NOT temporarily restore the standard discoverable entry. + +#### Scenario: Upgrade a disabled installation +- **WHEN** the user confirms an eligible exact newer version for a disabled target +- **THEN** the shared core replaces its package recoverably with the disabled entry prepared before commit, updates only its release baseline and preserves disabled state, installation-directory ownership and sibling installations + +#### Scenario: Uninstall a disabled installation offline +- **WHEN** the Registry is unavailable and the user confirms uninstall of a disabled installation with trusted local identity and baseline +- **THEN** its package files and exact record are removed through recoverable local deletion, its reservation is released, and other installations remain unchanged + +#### Scenario: Plugin itself is disabled +- **WHEN** the SkillHub plugin is disabled or removed after a completed skill disablement +- **THEN** it does not silently restore all disabled packages or discard their persistent recovery and storage information + +### Requirement: Explicit enablement protocol migration gate +Enablement SHALL NOT be encoded by silently extending the existing strict inventory v2 schema or introducing a competing writable state authority. A reviewed explicit inventory and journal protocol, compatible shared consumers, migration and rollback procedures, and historical-consumer verification SHALL precede enablement implementation and release. + +#### Scenario: Read legacy inventory +- **WHEN** a compatible new consumer reads v1 or v2 inventory before a mutation +- **THEN** it projects logical enabled state without writing the source or inventing missing files or fingerprints; first-write migration preserves verified original bytes and unrelated records under compatible locks + +#### Scenario: Historical CLI consumer +- **WHEN** a historical CLI reads or mutates a host with the proposed new inventory protocol +- **THEN** verification records whether each relevant command rejects before touching files; schema rejection alone SHALL NOT be accepted as proof of safe mixed-version operation, and incompatible writers must be stopped before migration + +#### Scenario: Rollback with disabled packages +- **WHEN** an operator attempts to return to a consumer without disabled-installation support +- **THEN** the procedure requires stopped writers, verified package and destination state and a validated restoration or export path; restoring an old inventory backup alone SHALL NOT be reported as successful rollback + +### Requirement: State-aware canonical package integrity +The shared core SHALL compute a disabled installation's canonical fingerprint by mapping only its exact recorded SKILL.md.disabled alias to SKILL.md, preserving bytes and all other package files. It SHALL NOT infer managed state from arbitrary disabled filenames. Publishing or export SHALL restore the canonical entry in the original or a validated export copy before producing a standard package. + +#### Scenario: Two entries or alias collision +- **WHEN** both entry names exist, the alias is occupied by author content, either entry is an unsafe link, or the expected entry is absent +- **THEN** toggle and destructive lifecycle operations are blocked without overwriting either entry or normalizing arbitrary files + +#### Scenario: Fingerprint remains canonical +- **WHEN** a trusted installation is disabled by the entry rename +- **THEN** the state-aware fingerprint equals the original canonical package fingerprint, and changing any entry bytes or resource bytes is still detected as local drift + +#### Scenario: Export a disabled package +- **WHEN** export or publication uses a disabled installation +- **THEN** it first restores the canonical entry or constructs a separately validated canonical export copy; it does not publish the disabled directory verbatim + +#### Scenario: Historical context and concurrent entry load +- **WHEN** a conversation already loaded the skill or reads the entry concurrently with a rename +- **THEN** resources retain their paths, loaded content is not claimed revoked, and a failed entry load is reported for fresh inspection without redirecting to another installation or claiming arbitrary external use has stopped + +#### Scenario: Upgrade candidate already contains the alias +- **WHEN** a new package for a disabled installation already contains an author-supplied SKILL.md.disabled file +- **THEN** the disabled upgrade is blocked before replacement without overwriting that resource or changing the ordinary upload contract; alias normalization requires both trusted disabled inventory state and matching exact installation identity + +#### Scenario: Exclusive entry transition interrupted between names +- **WHEN** an authorized transition has exclusively linked its destination but has not yet removed its source +- **THEN** recovery accepts the dual-entry state only with the matching journal phase, equal device/inode and verified canonical bytes; unrelated destination entries are preserved and block the transition + +#### Scenario: Disabled discovery cannot be verified +- **WHEN** the trusted entry name cannot be safely parsed, the observation times out, or Host snapshot and definition disagree +- **THEN** discovery remains unknown independently of disabled installation state, and the installation never receives invocation authorization from this diagnostic + + +### Requirement: API-owned skill website links +The plugin SHALL use only the detail API's optional webUrl for the website action. It SHALL NOT construct a website route, fall back to the Registry address, or require a plugin website setting. + +#### Scenario: Complete website URL provided +- **WHEN** the current source returns a safe absolute HTTP(S) webUrl for the matching detail +- **THEN** both detail entry points provide a new-tab link preserving that URL's page parameters, without appending local credentials or enabling opener access + +#### Scenario: Website URL absent or unsafe +- **WHEN** webUrl is absent, null, blank, non-HTTP(S), credential-bearing or otherwise unsafe +- **THEN** the website action is hidden and the remaining detail and installation features remain available + +#### Scenario: Server website identity +- **WHEN** the open-source server constructs the detail website URL +- **THEN** it uses only the configured trusted public website base and encoded skill coordinates, returns null without a valid base, and never trusts request Host or forwarding headers for this address + + +### Requirement: Use-first daily surfaces +Daily catalog, installed-skill groups, conversation inventory cards and default details SHALL foreground using a skill. Per-location maintenance SHALL remain available through secondary installation management, preserving exact location confirmation and existing lifecycle protections. + +#### Scenario: Daily skill card with multiple installations +- **WHEN** a skill has multiple visible installation records +- **THEN** its daily card is grouped by source and coordinates, does not require a scope selection before the use attempt, and exposes per-location management separately + +#### Scenario: Unique verified usable installation +- **WHEN** the user invokes skill-level use +- **THEN** the Host selects only a uniquely verified current-session usable managed installation, without ordering by scope, version or array position, and the existing new-session and draft-time checks still apply + +#### Scenario: Ambiguous or unverifiable use selection +- **WHEN** several installations are usable, observations are incomplete, context changes, or selection times out +- **THEN** no installation is guessed or changed, no draft is sent, and the UI provides a concise blocked result with access to installation management + +#### Scenario: Explicit installation management +- **WHEN** the user selects a particular installation in secondary management or uses an exact operation receipt +- **THEN** its exact reference remains authoritative for that request and is never silently replaced by the skill-level selector's winner diff --git a/openspec/changes/add-dsh-native-plugin/tasks.md b/openspec/changes/add-dsh-native-plugin/tasks.md index 97e1074a2..1be87d5d9 100644 --- a/openspec/changes/add-dsh-native-plugin/tasks.md +++ b/openspec/changes/add-dsh-native-plugin/tasks.md @@ -373,3 +373,26 @@ - [x] 43.2 实现 Host 诊断与只读对话工具,保留完整性、发现和调用策略的独立事实。 - [x] 43.3 管理卡片提供按选定安装位置检查的紧凑入口。 - [x] 43.4 边界测试、双 RC 宿主与浏览器组件验证;另补2轮真实模型与同会话 DSH 浏览器诊断入口,边界见 verification.md。 + +## 44. 受管理安装的技能启停 + +- [x] 44.1 调研官方发现/调用机制与社区方案,完成双RC Provider级移出/恢复实验;不代表完整Host或真实模型验收。 +- [x] 44.2 评审安装级产品范围、状态及生命周期设计;冻结库存协议、迁移、历史 CLI 实测边界和配套发布顺序。 +- [x] 44.3 在隔离环境验证双RC完整Host的目录监听、缓存、同名回退和双调用入口;不支持配置明确阻止或标注未确认。 +- [x] 44.4 实现共享核心按位置停用/恢复、事务恢复及更新/卸载/重装/同步/套件联动,验证旧消费者不误写。 +- [x] 44.5 实现Host计划/确认/执行、页面及对话入口;沿用精确目标与历史引用保护,紧凑显示结果。 +- [x] 44.6 验证并发、失败恢复、来源切换、文件修改、目标占用、共享归属及 Linux 进程强杀恢复。 +- [ ] 44.6a 发布前补充原生 Windows/macOS 文件转换与恢复验证;Linux 通过不替代跨平台验收。 +- [x] 44.7 完成双RC真实浏览器和真实模型验收,确认无自动发送、无外部安装误操作,保存独立证据。 +- [x] 44.8 独立评审、文档同步、清理差异并归档;CLI/DSH发布与SaaS适配另行安排。 + +- [x] 44.9 人工澄清:官网链接改由详情API返回可选webUrl,移除插件设置/本地拼接,验证空值隐藏并提供SaaS交接;SaaS部署另行验收。 + +## 45. 使用优先与二级安装管理 + +- [x] 45.1 明确日常按技能聚合、位置管理二级化、使用目标只读核验及原确认不变的方案。 +- [x] 45.2 实现Host唯一实际可用安装选择和未知/竞态/取消边界。 +- [x] 45.3 统一市场、已安装、详情与对话查询卡,保留二级完整管理能力。 +- [ ] 45.4 独立评审、插件检查和真实DSH新对话/案例取消/填入/管理验证已通过;原安装保留。旧草稿往返人工核对受宿主侧栏隔离会话不可见限制,自动保护测试已通过,待补人工证据。 + +- [x] 45.5 按人工反馈取消管理弹窗,统一详情内联管理,修复确认层被背景卡片覆盖并验证。 diff --git a/openspec/changes/add-dsh-native-plugin/verification.md b/openspec/changes/add-dsh-native-plugin/verification.md index 9ae20859c..4b4fff4cd 100644 --- a/openspec/changes/add-dsh-native-plugin/verification.md +++ b/openspec/changes/add-dsh-native-plugin/verification.md @@ -433,3 +433,89 @@ CLI 全量结果与后续专项分开记录,不声称最终新增用例都来 合并CI发现 Windows 的 `chmod(0)` 不移除目录读权限,原POSIX权限夹具仍可读取。已将真实chmod场景限定为POSIX,元数据场景保持全平台,并新增全平台provider EACCES注入及恢复断言。修复仅涉及测试,插件生产源码与真实验收产物保持一致;最终跨平台结果以该提交CI为准。 + + +## 受管理安装位置启停:本地实现验证 + +本增量采用显式库存/操作日志 v3,只变更精确安装位置的入口。逻辑改名以不覆盖目标的同目录 link/unlink 实现;中间双入口仅由操作日志、同设备/inode 和内容基线授权恢复。普通双入口冲突仍阻断。 + +- 固定 Bun 1.3.13:CLI 全套 803 项通过、0 失败,3551 次断言;随后追加升级候选别名冲突回归,所在升级测试文件 21 项通过。CLI lint/typecheck/build 通过。 +- 最终插件完整检查:build/typecheck/lint 通过,565 项测试、12089 次断言通过。OpenSpec strict 和 diff 检查通过。 +- 核心覆盖目标占用、正向/恢复竞争、独立目标 CAS、五个进程强杀窗口、库存迁移备份、停用中升级/卸载、sync 保留、Suite 不接管和 doctor 不认领散落别名。进程强杀不等于硬件断电验证。 +- 历史 CLI 实包矩阵:0.2.0、0.1.12 共 18 个有效 disabled-v3 命令路径均非零退出,库存及 home/project 文件不变;legacy 正对照均成功。0.1.12 不支持 Suite,不计覆盖。0.1.12 对 enabled-v3 的 sync 仍可写工作区索引,因此必须先停止不兼容写入进程,不能宣称 schema 阻止全部旧程序写入。 +- 官方 DSH 0.2.0-rc.2 / 0.2.1-rc.2:安装、取消、停用、启用、停用中升级和卸载,以及同名接替、watch 关闭探针通过。旧 RC 原会话观察可能 unknown,新会话才确认用户副本接替;watch 关闭观察也随缓存时点变化。只承诺入口状态提交,不承诺即时撤回当前会话发现。 +- 真实 astron-code-latest:新版 4 个用户轮次、旧版 3 个用户轮次,真实调用 list/disable/enable/operation,浏览器完成原生确认。项目入口按状态转换,用户安装摘要始终不变;停用后使用预检拒绝,未执行技能内容,未人工注入工具历史。安装夹具由隔离工具准备,不代表模型安装验收。模型偶有把安装状态表述为“正常工作”的措辞,不作为实际发现或可调用证明。 +- 独立评审已关闭并发覆盖、停用发现诊断、独立诊断卡漏显示停用状态及文档边界冲突。最后补齐成功提示按安装位置隔离,防止切换范围后显示另一位置的结果。两次修正只改 Client;最终 UI 产物与前述模型使用产物分别留证,Host/core 内容 SHA 完全一致。 +- 最终真实浏览器在双 RC 各通过 12 项检查:查询、原生与卡片确认、取消、停用诊断、停用使用阻止、操作中范围锁定、完成后切换范围提示隔离及恢复;无脚本异常。最终候选保留原真实模型会话供人工验收。 + +本轮证据保存在仓库外 `dsh-toggle-20261027`,不提交截图、模型配置、会话、历史包或测试运行日志。原生 Windows/macOS 文件操作、远端 CI、npm 发布和 SaaS 不属于本地通过结论;原生跨平台验证仍保留发布门禁。本轮未提交、合并或发布。 + + +### 人工预览反馈:详情按钮与多技能样本 + +详情安装管理将“使用此技能”和显式次要“停用”同排,移除该位置多余省略号;停用态以“启用”为主操作,对话卡菜单不变。39项相关测试、243次断言及typecheck/lint/build通过;新版真实DSH浏览器验证启用态、停用态、确认取消和页面布局,无脚本异常。本次纯详情排版未重复旧RC或模型调用。 + +隔离预览增加8个惰性演示技能,每个提供1.2.0/1.3.0包、详情和两条中文案例,覆盖用户/项目/双位置、停用、可升级和未安装。16个包、名称搜索与版本分页通过;原技能文件和安装记录按操作前即时基线保持不变,不回滚用户人工操作。演示数据和报告仅在仓库外,不属于正式市场数据。 + + +### 人工反馈:统一动作层级与精简启停确认 + +安装面板统一为一个主动作、次要升级、低强调停用;停用状态使用中性色标签,查看新版详情而使用旧安装时明确标注“使用已安装版本”,仍绑定精确安装引用。插件启停弹窗保留技能身份、版本、范围标签、一句影响说明及右对齐取消/确认,消除重复段落,原生问答和对话回执默认行为不变。 + +43项相关测试/257次断言、typecheck/lint/build、独立评审通过。新版真实DSH验证深浅主题、320/360窄视口、确认取消、已启用有更新、停用最新版以及停用旧版可更新组合;最后一项在独立layout-review-project构造,保留原project用户人工操作。截图及运行证据位于仓库外dsh-toggle-20261027/real,未调用模型、未重跑旧RC纯排版验收,未发布。 + + +### 人工反馈:范围占位与正常成功信息精简 + +范围选择改为共享SelectField可选placeholder,移除choose伪选项,多位置仍需明确选定。删除安装详情、管理卡片的常驻成功notice及其状态,启用后动作行不再被说明撑宽;正常完成结果不额外常驻发现解释,结构化事实、显式诊断、错误/告警/恢复提示保留。 + +最终插件build/typecheck/lint及569项测试/12113断言通过。真实新版DSH深浅主题下拉、键盘、未选禁用、成功后动作同排及360窄屏通过;在独立layout工作区完成启停,原project与user的全部安装target和文件SHA前后不变。未调用模型或重跑旧RC。独立评审无阻塞。证据:仓库外dsh-toggle-20261027/real/scope-success-acceptance.md及scope-success-original-preserved.json。原预览和真实会话保留。 + + +### 人工反馈:市场启停动作与范围理解 + +市场卡显式显示启停动作,去掉省略号展开后行内插入按钮及重复停用状态。单停用安装以中性摘要表达,混合安装分别标位置状态;升级为次操作。初始唯一安装直接管理,多份明确选择;已选目标消失时必须重新选择,修复多份变单份后自动改绑另一安装的问题。 + +最终插件完整检查573项测试/12133断言及build/typecheck/lint通过,独立评审通过。真实新版DSH深浅色、360窄屏、单份启停、多份未选/选中和确认取消通过,inventory及原project/user/layout全部技能文件摘要保持不变。未调用模型,未重跑旧RC。 + +首次整套检查中Host“examples modified during native discovery”用例出现一次Skill root changed异常;该文件定向复跑及最终整套通过,未修改Host,根因未确认。旧完整日志被重跑覆盖,保留的是工具异常摘录,不能作为完整原日志引用。详情见仓库外real/market-actions-check-notes.md;最终验收见real/market-actions-acceptance.md。发布前仍须按既有门禁执行跨平台与CI检查。 + + +### 混合安装状态与官网详情入口 + +新增隔离demo/mixed-scope-demo:用户1.2停用、layout-review-project项目1.3启用;卡片逐位置两行完整显示,选择后分别确认启用/停用且取消不写入,原11个安装target及文件摘要不变。用户级停用不代表关闭独立项目副本。 + +官网按钮覆盖市场及已安装详情标题入口,使用当前Registry或可选websiteUrl网站根地址拼开源Web的/space/{namespace}/{slug},安全打开新标签;不是作者仓库/下载地址,也不固定当前版本。新增可选设置不改变Registry请求或来源身份,真实填写保存与清空回退通过。默认skill.xfyun.cn配置与本地fixture落地页分开:浏览器验证的是明确标注的本地演示页面,不能声称此次验证了生产详情内容。 + +发现并修复websiteUrl的Schema.transform不能跨官方配置schema JSON重建的问题,改为可序列化pattern及Host消费规范化,补4项42断言回归。另预览CLI overlay阻止settings保存,为私有profile迁移同值配置并修启动runner防止重新覆盖;无Registry来源/安装变化。 + +最终完整检查592项测试/12217断言、build/typecheck/lint通过,独立评审通过;真实新版Host浅深色、360窄屏、混合两位置确认取消、两详情实际popup及网站设置保存/清空通过。原会话、原安装和预览端口保留,无模型调用、未发布,未重复旧RC本轮新增UI验收。仓库外证据real/website-mixed-acceptance.md、website-settings-browser-result.json及mixed-website-review.md。 + + +### 人工澄清:官网URL由详情API返回,空则隐藏 + +本项取代前述websiteUrl插件设置方案。开源详情API新增可选nullable webUrl,复用可信SKILLHUB_PUBLIC_BASE_URL生成绝对页面地址,无合法基址则null,不读取请求Host/Forwarded;无需数据库迁移。OpenAPI来自真实Spring /v3/api-docs并经现有工具生成,web类型仅增加对应字段。 + +插件只消费metadata.webUrl→SkillDetail,删除网站地址设置与本地拼路由/Registry回退,旧持久化字段忽略。null、缺失、空白和非法地址隐藏按钮;安全的完整HTTP(S)页面URL保留query/fragment,不附本地凭证,来源门控仍在。 + +后端19项测试0skip通过,最终插件597项测试/12198断言及build/typecheck/lint通过,独立评审通过。原42845新版真实浏览器验证有效URL两详情popup、null/missing/invalid隐藏、设置无旧入口、light/dark/360及mixed状态;原安装和会话不变,无模型调用。未验证SaaS上线、未发布,旧RC本轮未重复。原全量检查中的测试旧字段预期误设已修为实际忽略语义,初始日志保留,不修改官方Schema未知字段处理。 + +仓库外证据:real/api-weburl-acceptance.md、api-weburl-review.md、backend-web-url-tests-final.log与backend-web-url-openapi.json。SaaS交接见saas-agent-prompt.md新小节;尚未返回webUrl的SaaS隐藏按钮是预期,不以开源验收代替上线。 + + +### 人工反馈:使用优先与二级安装管理 + +日常市场、聚合已安装、两类详情及对话查询卡改为技能级“使用技能 / 管理安装”;位置、版本、启停、更新、卸载移入二级逐位置管理,保留安装其他位置和精确位置使用。全部停用以管理为主,无安装仍走原确认。 + +新增私有Host只读selectSkillUse:仅在完整、稳定的库存与DSH发现中返回唯一实际可用安装;未知、多候选、超时及上下文变化不猜选。新对话和填入前仍执行原prepare,案例填入不自动发送。独立评审发现并修复最后一份记录消失导致管理窗口空数组崩溃,稳定技能坐标仅用于查询,不作为安装授权。 + +最终插件618项测试、12345次断言及build/typecheck/lint通过,独立Host与Client评审无阻塞,OpenSpec严格校验通过。新版真实DSH验证单安装、混合安装、全停用、未安装、聚合列表/详情、二级位置确认取消及深浅主题/窄屏;混合技能使用进入新对话和案例面板,取消不填入。当前验收未调用模型,也未重复旧RC及原生Windows/macOS验收;本地预览不等于发布。 + +最终包另外验证案例选中后填入可编辑草稿、不发送;原会话恢复。返回隔离旧会话核对既有草稿的人工步骤未完成:宿主侧栏不展示脚本建立的说明会话,未改用用户真实会话做有损测试。该项仅有自动状态/CAS与取消测试,不能宣称浏览器旧草稿往返已通过。证据见仓库外real/use-first-acceptance.md、real/use-first-new-conversation-result.json和use-first-review.md。 + + +### 人工反馈:取消管理弹窗与修复确认层遮挡 + +管理安装改为进入对应详情,右侧直接展示各位置管理;对话入口沿已有详情导航。安装与危险操作确认保留。修复portal位于卡片操作区z-index上下文导致背景按钮盖过确认框的问题;共享确认层移出卡片,原生dialog内保留top layer。 + +最终插件619项测试/12357断言、build/typecheck/lint通过。新版真实DSH市场/已安装详情、两位置、深浅色/360窄屏、确认取消和elementFromPoint背景遮挡验证通过,50份技能文件SHA不变。旧历史会话引用按预期拒绝,重新读取后进入详情。无模型调用、未重验旧RC;搜索drawer嵌套确认本轮未独立点击。外部证据real/inline-management-acceptance.md,包SHA256 de575972578be50f4860472cb436111467c0bf9f8dc66ebd6401871b7aa73f03。未提交或发布。 diff --git a/plugins/dsh/README.en.md b/plugins/dsh/README.en.md index 9734a889e..ce204ede5 100644 --- a/plugins/dsh/README.en.md +++ b/plugins/dsh/README.en.md @@ -14,7 +14,9 @@ Version `0.4.0` uses the independent `dsh-v0.4.0` tag. It adds versioned example ## Use an installed skill -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. +Market, installed and everyday conversation lists show one card per skill. “使用技能” asks the Host to verify the unique installation actually usable in the current session. Catalog use creates a conversation in the same workspace; conversation use keeps the current session. Choose an example or custom task to insert an editable draft. This never sends a message, invokes a model or overwrites the original draft. + +“管理安装” opens skill details, where the inline installation panel shows each location’s version, state and explicit use/update/enable/disable/removal actions. Disabled or unverified skills lead to management. Installation scope remains in advanced confirmation options, with the current-user default unchanged. 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 available from `0.4.0`. @@ -136,7 +138,7 @@ Each visibility setting accepts `auto`, `show` or `hide`. Auto detects actual an - Browse supports search, server sorting, real labels and explicit **Load more**, normally 12 results per page. A first-page 404/405 can fall back to bounded legacy search, with an explicit pagination/sorting limitation. A failed label filter never silently returns unfiltered results. - Details show fixed-version documentation and optional tabs. Select a downloadable version before confirming installation. Existing-target protection remains; skill downgrades are not supported. -- Installed records can be filtered by source, scope and keyword. The right-side version card contains detail refresh and update checks. Upgrade and removal affect only the selected location; other locations retain their versions. Missing directories have a separate stale-record cleanup action. +- Installed skills are grouped by identity and support keyword filtering. Open “管理安装” for independent location rows and precise confirmed operations; missing directories retain stale-record cleanup. - `astron_skillhub_search` only searches. Result cards provide a detail action; the drawer offers explicit installation confirmation. - An explicit request to install a uniquely selected result does not require opening the drawer first. `astron_skillhub_install` verifies the search-time version and content snapshot. It accepts a `selectionRef` or a full coordinate, exclusively, and a `user`/`project` scope. Expired or ambiguous references are rejected without switching to current latest. @@ -194,11 +196,11 @@ See the [release guide](../../openspec/changes/add-dsh-native-plugin/release-gui ### Marketplace interaction and installation management -Market cards open details and offer separate installation-management actions. Multiple installed scopes require a selected target. After an explicit install request, an omitted scope defaults to current user; project scope must be explicitly selected. Market install actions open the same dialog without detail-page navigation. Current user is preselected, with project scope in advanced options; the dialog never submits automatically. A later explicit user scope may start a new call. Details omit complete leading frontmatter and reuse public reads for 60 seconds without bypassing write validation. The versions tab requests `downloadableOnly=true`, requiring the updated open-source backend; SaaS synchronization and acceptance remain separate. +Market cards prioritize skill use. Their management action opens details with a separate inline section for each installation location. Explicit management actions retain their exact target. After an explicit install request, an omitted scope defaults to current user; project scope must be explicitly selected. Market install actions open the same dialog without detail-page navigation. Current user is preselected, with project scope in advanced options; the dialog never submits automatically. A later explicit user scope may start a new call. Details omit complete leading frontmatter and reuse public reads for 60 seconds without bypassing write validation. The versions tab requests `downloadableOnly=true`, requiring the updated open-source backend; SaaS synchronization and acceptance remain separate. After confirmation, the installation dialog expands to the right, keeping the skill, version, and scope on the left and concise real progress on the right. Narrow screens stack the sections. Progress remains indeterminate until completion; closing the dialog does not cancel a submitted installation. -Installation controls use current inventory rather than historical success receipts. An existing installation selects its scope and offers removal. Project-only scope uses the project directory; all-project scope uses the current user directory on this Host. Changing scope never installs automatically; a removed installation may be explicitly installed again. +Management reads current inventory, not historical success. Project scope is the current project directory; all-project scope is this Host’s current user directory. “安装其他位置” opens installation confirmation; changing scope never installs automatically. Removal from market cards, installed rows, and detail controls opens an in-place confirmation. It shows the skill, version, selected scope, and impact. Opening only prepares a plan; confirmation executes it. Cancel and Escape never remove files, and existing expiry, identity, and local-file checks still apply. @@ -214,6 +216,25 @@ New conversational installs default to user scope after an explicit install requ ### Skill availability diagnosis (0.5.0) -Choose “更多 → 检查可用性” on a conversational installed-skill card to inspect its selected location. A conversation can also use the read-only `astron_skillhub_check_availability` tool to explain why a skill cannot be used. +Choose “管理安装 → 检查可用性” on a conversational installed-skill card to inspect its selected location. A conversation can also use the read-only `astron_skillhub_check_availability` tool to explain why a skill cannot be used. The result distinguishes local integrity, current-session discovery, and user/model invocation policies. Shadowing, local modifications and unknown Host state are separate outcomes. A name-only lookup reports Host observations without proving installation ownership or integrity. Diagnosis never modifies, removes or enables/disables a skill, edits a draft, or sends a message. A historical result is not a guarantee about a later invocation. + + +## Managed skill enable/disable (unreleased increment) + +Open “管理安装” and choose enable or disable on the explicit location row. User and project copies are independent: disabling the user copy does not disable a project copy. UI and conversation mutations require real human confirmation. `astron_skillhub_disable` / `astron_skillhub_enable` accept fresh installation references, never arbitrary paths. + +Disabling changes only the entry from `SKILL.md` to `SKILL.md.disabled`, keeping directory, resources and version. Enabling restores the entry without overwriting an occupied destination. Modified, shared or untrusted installations are blocked. DSH can still check, update or uninstall a disabled installation; updates preserve disabled state. + +This does not revoke loaded conversation content or stop tasks. Another same-named installation can remain available. Unverified Host cache observations remain unknown; the older RC may need a new session to observe a fallback copy. Removing this plugin does not enable its disabled installations. + +The increment writes inventory v3 with per-location `installationState`. Reading v1/v2 is non-mutating; migration preserves the original bytes and a digest-addressed backup. The plugin bundles its compatible core and does not invoke an external CLI. Stop old writers and use the matching compatible CLI build before sharing inventory: published CLI `0.2.0` and `0.1.12` are incompatible. Release versions and order are not assigned yet. An old inventory backup alone is not a downgrade procedure. Native Windows/macOS entry conversion still requires acceptance before release. + +The detail-header website link uses only the detail API’s optional `webUrl`. Missing, null, blank or unsafe values hide the action without breaking skill details or installation. There is no plugin website setting, route construction or Registry fallback; legacy `websiteUrl` settings are ignored. The open-source server uses its trusted `SKILLHUB_PUBLIC_BASE_URL` to supply an absolute link, or null when no valid website base is configured. SaaS must provide this additive API field separately. + +Installation locations in the detail view expose secondary actions through a More menu. Disabled locations retain Enable. The menu supports arrow keys and Escape without expanding the location card. Closing it preserves the selected installation’s in-flight use request. + +Detail installation checks use a dismissible top-right notification. Ordinary success expires after five seconds; conflicts and failures remain dismissible. Repeated checks replace the notification, and leaving the skill clears it. Upgrade candidates and safety blocks stay in the installation row. + +Check notifications use a scope/version heading and one concise conclusion. Installation scope is directly visible without an advanced disclosure. Confirmation, progress and completion share a stable two-column layout (stacked on narrow screens), with a separate use action after completion. Default scope selection is unchanged. diff --git a/plugins/dsh/README.md b/plugins/dsh/README.md index d6f2ba10a..613e6944e 100644 --- a/plugins/dsh/README.md +++ b/plugins/dsh/README.md @@ -14,7 +14,9 @@ SkillHub 的 DSH 原生插件。在 DSH 侧栏打开 **技能中心**,匿名 ## 使用已安装技能 -在已安装卡片、已安装版本详情或安装成功结果点击“使用此技能”。技能中心入口在同一工作区新建对话,对话内入口保留当前会话。核验所选安装后,案例显示在原生输入框上方;确认案例或自定义任务后,带入可编辑的技能调用。原会话草稿不会被覆盖,对话内操作保留已有文字、引用和附件。补充任务和材料后自行发送;点击使用不会自动发送、调用模型或安装文件。 +市场、已安装列表和对话中的日常列表均按技能展示。点击“使用技能”,由 Host 核验当前会话实际可用的唯一安装;不会让用户猜安装位置。技能中心入口在同一工作区新建对话,对话内入口保留当前会话。案例显示在原生输入框上方,确认案例或自定义任务后填入可编辑草稿;不会自动发送、调用模型或覆盖原会话草稿。 + +“管理安装”进入技能详情,右侧直接展示每个位置的版本和状态,可明确使用此位置、检查更新、启停或卸载。全停用或状态无法确认时先进入管理安装。安装范围放在安装确认的高级选项中,未安装技能仍默认安装到当前用户。 需要当前活动会话。被项目同名技能覆盖、本地文件变更、禁止用户调用或会话/来源变化时会提示原因;历史回执不能代替当前安装状态。此功能自 `0.4.0` 起提供。 @@ -136,7 +138,7 @@ Loader entry ID 为 `astron-skillhub`;`skillhub` 是 Host/Remote 服务名。 - **浏览技能**:搜索、服务端排序、真实标签筛选、点击加载更多。新目录接口每批 12 项;旧来源首批 404/405 可回退受限搜索,明确提示不支持分页和排序。选中标签失败不会退回未过滤结果。 - **技能详情**:查看文档、版本和可选模块;可“查看此版本”后确认安装指定的可下载版本。已有其他版本时保留覆盖保护,不执行降级。 -- **已安装**:按来源、范围和关键词查看本地记录。详情右侧“版本与更新”提供重新读取及检查更新;升级与卸载只影响选中的一个安装位置,其他位置的版本保持不变。目录缺失时可单独确认清理失效记录。 +- **已安装**:按技能聚合并支持关键词过滤。默认提供使用与管理安装;详情右侧按位置显示版本、状态和操作,升级、启停与卸载均只影响确认的位置。目录缺失时仍可清理失效记录。 - **对话搜索**:`astron_skillhub_search` 只搜索,不安装或执行技能。结果提供明确详情动作;详情抽屉可确认安装。 - **对话安装**:用户明确要求安装唯一搜索结果时,不必先打开详情。`astron_skillhub_install` 核验搜索时的精确版本和快照;接受 `selectionRef` 或完整坐标,二者互斥,范围为 `user`/`project`。过期或歧义引用拒绝执行,不改装当前 latest。 @@ -198,9 +200,11 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh 安装确认后弹窗保留并向右展开:左侧固定技能、版本和范围,右侧展示简洁的实际进度与结果;窄屏上下排列。处理中不模拟百分比,关闭窗口不取消已提交的安装。 -安装管理以当前库存为准,历史安装成功不代表目前仍已安装。已有记录时默认选中对应范围,并提供卸载入口;“仅此项目”对应项目目录,“所有项目”对应当前 Host 的当前用户级目录。切换范围不会自动安装;卸载后可再次明确安装。 +详情页每个安装位置的“更多操作”菜单提供使用此位置、检查可用性、检查更新、停用和卸载;停用位置保留“启用”按钮。菜单不撑高位置卡片,支持方向键选择和 Escape 关闭。检查更新和可用性的普通摘要以“范围/版本 + 一句结论”显示在右上角可关闭通知中;普通成功 5 秒后消失,冲突和失败保留到手动关闭。同一检查替换通知,离开技能详情清除通知。可升级版本和安全阻断仍保留在安装位置中。使用操作继续精确定位所选安装,关闭菜单不会取消正在核验的使用请求。 -市场卡片、已安装列表和详情中的卸载可直接打开确认弹窗,不再为卸载跳转到另一详情页。确认框保留技能、版本、所选范围和影响说明;打开仅核验,确认才执行。取消与 Esc 不卸载,过期计划和异常安装仍由原事务校验阻断。 +安装管理以当前库存为准,历史成功不代表仍已安装。“仅此项目”对应项目目录,“所有项目”对应当前 Host 的用户级目录。二级管理中的“安装其他位置”会重新打开安装确认;选择范围不会自动安装。 + +从“管理安装”的对应位置发起卸载,确认框保留技能、版本、范围和影响说明。打开仅核验,确认才执行;取消与 Esc 不卸载,过期计划和异常安装仍由原事务校验阻断。 切换版本只查看内容,不会自动更新。已安装时选择可核验的较新版本,可“升级到该版本”;同版本不重复升级,旧版本不支持降级,不可排序版本阻断升级。确认框只列出本次选定的安装位置。检查最新版仍可从市场或已安装列表使用;升级原地确认,保留本地修改保护与恢复。 @@ -217,6 +221,21 @@ dsh plugin --profile skillhub-test remove @astron-team/skillhub-dsh ### 技能可用性诊断(0.5.0) -在对话安装列表的技能卡片中选择“更多 → 检查可用性”,检查当前选定位置。也可以在对话中询问“这个技能为什么不能用”,由 `astron_skillhub_check_availability` 返回只读结果。 +在技能卡片中打开“管理安装”,选择对应位置的“检查可用性”。也可以在对话中询问“这个技能为什么不能用”,由 `astron_skillhub_check_availability` 返回只读结果。 诊断分别核验本地文件、当前会话加载结果,以及用户/模型调用策略。同名技能生效、本地修改和宿主无法核验会给出不同结论。按技能名查询只说明当前宿主观察到的事实,不证明安装来源或内容完整性。检查不会修改、卸载、启停技能或发送消息;历史结果不保证下一次调用仍然可用。 + + +## 受管理技能启停(未发布增量) + +在“管理安装”中,对明确位置选择“停用”或“启用”。用户级和项目级副本独立管理;停用用户副本不会关闭项目副本。页面与对话均需真实用户确认,取消不写文件。对话工具 `astron_skillhub_disable` / `astron_skillhub_enable` 使用新查询中的位置引用,不能提交自由路径。 + +停用保留技能目录、版本和资源,只将入口 `SKILL.md` 转换为 `SKILL.md.disabled`;启用恢复入口。目标文件被占用、内容修改、共享归属或安装记录不可信时拒绝操作。停用中仍可通过 DSH 检查更新、更新及卸载,更新后继续停用。 + +这不撤回会话已加载的正文,也不终止任务;同名的其他安装可能继续可用。宿主缓存未确认时明确显示未知,不把文件操作成功当作发现已刷新。旧 RC 的同名接替可能需新会话才能观察到。卸载插件不会自动启用这些技能。 + +本增量首次写入使用库存 v3(每位置 `installationState`),读取 v1/v2 不改写,迁移保留原始库存字节及摘要备份。插件内置兼容核心,仍不调用外部 CLI;同机使用 CLI 管理相同库存时,须先停止旧写入进程并使用配套兼容构建。现有公开 CLI `0.2.0`/`0.1.12` 不支持此协议,不可混写;配套正式版本与发布顺序另行确定。不能只恢复旧库存文件来降级,须核对入口、安装元数据及操作日志。原生 Windows/macOS 文件转换的实机验收仍是发布前门禁。 + +详情标题旁“在官网查看”仅使用详情API返回的可选`webUrl`。字段缺失、null、空白或不安全时隐藏按钮,详情与安装照常使用。插件不再提供网站地址配置、不拼路由、不从Registry回退;旧`websiteUrl`配置忽略。开源服务端复用可信`SKILLHUB_PUBLIC_BASE_URL`生成完整地址,未配置合法网站基址则返回null。SaaS需单独同步此可选字段。 + +安装确认直接展示安装范围,默认范围规则不变。确认、进行中与成功共用稳定的双栏布局,小屏自动纵排;右侧在提交前显示等待提示,完成后使用按钮与进度条分开显示。 diff --git a/plugins/dsh/bun.lock b/plugins/dsh/bun.lock index 8484fe9ec..6873c341b 100644 --- a/plugins/dsh/bun.lock +++ b/plugins/dsh/bun.lock @@ -8,6 +8,7 @@ "@deepseek-ai/schemastery": "3.18.4", "@radix-ui/react-avatar": "1.3.1", "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-dropdown-menu": "2.2.1", "@radix-ui/react-select": "2.4.1", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tabs": "1.2.1", @@ -285,12 +286,16 @@ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="], + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.2.1", "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.2.1.tgz", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-menu": "2.2.1", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-controllable-state": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.4.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.2.1", "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.2.1.tgz", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-collection": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-direction": "1.2.0", "@radix-ui/react-dismissable-layer": "1.2.1", "@radix-ui/react-focus-guards": "1.2.0", "@radix-ui/react-focus-scope": "1.3.1", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.4.0", "@radix-ui/react-portal": "1.2.0", "@radix-ui/react-presence": "1.1.11", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-roving-focus": "1.2.1", "@radix-ui/react-slot": "1.5.0", "@radix-ui/react-use-callback-ref": "1.1.5", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.4.0", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.4.0.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.17", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.3.0", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-use-rect": "1.1.4", "@radix-ui/react-use-size": "1.1.5", "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="], @@ -675,10 +680,24 @@ "@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], + "@radix-ui/react-dropdown-menu/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.3.0", "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg=="], + "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], "@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], + "@radix-ui/react-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.2.1", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", { "dependencies": { "@radix-ui/primitive": "1.2.0", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-callback-ref": "1.1.5", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg=="], + + "@radix-ui/react-menu/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.2.0", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA=="], + + "@radix-ui/react-menu/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.3.1", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-callback-ref": "1.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg=="], + + "@radix-ui/react-menu/@radix-ui/react-portal": ["@radix-ui/react-portal@1.2.0", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.2.0", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g=="], + + "@radix-ui/react-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA=="], + + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.5.0", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ=="], + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.5.0", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ=="], diff --git a/plugins/dsh/package.json b/plugins/dsh/package.json index 13f3911e2..98b6ada22 100644 --- a/plugins/dsh/package.json +++ b/plugins/dsh/package.json @@ -92,6 +92,7 @@ "@deepseek-ai/schemastery": "3.18.4", "@radix-ui/react-avatar": "1.3.1", "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-dropdown-menu": "2.2.1", "@radix-ui/react-select": "2.4.1", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tabs": "1.2.1", diff --git a/plugins/dsh/scripts/probe-host.mjs b/plugins/dsh/scripts/probe-host.mjs index 808cab4f5..ea0e12a71 100644 --- a/plugins/dsh/scripts/probe-host.mjs +++ b/plugins/dsh/scripts/probe-host.mjs @@ -17,6 +17,8 @@ const probes = { 'runtime-unsupported': 'runtime-unsupported-result.json', 'installed-state': 'installed-state-result.json', 'skill-availability': 'skill-availability-result.json', + 'skill-toggle': 'skill-toggle-result.json', + 'skill-toggle-watch-off': 'skill-toggle-result.json', 'upgrade-candidate': 'upgrade-candidate-result.json', 'upgrade-drift': 'upgrade-drift-result.json', 'plan-context': 'plan-context-result.json', @@ -78,14 +80,14 @@ const observer = join(run, 'observer.mjs'); observerStatusFile = join(run, 'observer-status.json'); dependencyStatusFile = join(run, 'dependency-status.json'); const runtimeProbe = values.probe === 'runtime-baseline' || values.probe === 'runtime-unsupported'; -const source = await readFile(join(fixtureRoot, runtimeProbe ? 'runtime-baseline-probe.mjs' : values.probe === 'plugin-coexistence' ? 'plugin-lifecycle-fixture.mjs' : `${values.probe}-fixture.mjs`), 'utf8'); +const source = await readFile(join(fixtureRoot, runtimeProbe ? 'runtime-baseline-probe.mjs' : values.probe === 'plugin-coexistence' ? 'plugin-lifecycle-fixture.mjs' : `${values.probe === 'skill-toggle-watch-off' ? 'skill-toggle' : values.probe}-fixture.mjs`), 'utf8'); await writeFile(observer, source.replaceAll(probes[values.probe], resultName), { flag: 'wx', mode: 0o600 }); const state = join(run, 'update-state.txt'); const downloads = join(run, 'download-state.txt'); await writeFile(state, 'current'); await writeFile(downloads, 'normal'); const patch = join(run, 'observer.yml'); -const config = { root, ...(['plugin-lifecycle', 'plugin-coexistence'].includes(values.probe) ? { bundle: true } : {}), +const config = { root, ...(values.probe === 'skill-toggle-watch-off' ? { watchOff: true } : {}), ...(['plugin-lifecycle', 'plugin-coexistence'].includes(values.probe) ? { bundle: true } : {}), ...(values.probe === 'plugin-coexistence' ? { referencePackage: '@cocofhu/skillhub' } : {}) }; if (runtimeProbe) { const installedPackage = join(root, 'home/profiles/skillhub-m0/node_modules/@astron-team/skillhub-dsh'); @@ -113,7 +115,7 @@ export function apply(ctx) { } const referencePatch = values.probe === 'plugin-coexistence' ? '- id: skillhub\n config:\n apiBase: http://127.0.0.1:43219\n webBase: http://127.0.0.1:43219\n timeoutMs: 1000\n' : ''; -await writeFile(patch, `${diagnosticPatch}${referencePatch}- id: m0-observer\n disabled: true\n- id: skillhub-local-runtime-probe\n disabled: true\n- id: ${installedLoaderId}\n config:\n registryUrl: http://127.0.0.1:43219\n- insert:\n - id: skillhub-probe-${id}\n name: ${JSON.stringify(observer)}\n config: ${JSON.stringify(config)}\n`); +await writeFile(patch, `${values.probe === 'skill-toggle-watch-off' ? '- id: skill-filesystem\n config:\n watch: false\n' : ''}${diagnosticPatch}${referencePatch}- id: m0-observer\n disabled: true\n- id: skillhub-local-runtime-probe\n disabled: true\n- id: ${installedLoaderId}\n config:\n registryUrl: http://127.0.0.1:43219\n- insert:\n - id: skillhub-probe-${id}\n name: ${JSON.stringify(observer)}\n config: ${JSON.stringify(config)}\n`); // Pin every spelling used by Node to the parent-validated temporary directory. // Windows process.env is case-insensitive in the main thread, but not in Workers. const inherited = Object.fromEntries(Object.entries(process.env) diff --git a/plugins/dsh/src/client/catalog-page.tsx b/plugins/dsh/src/client/catalog-page.tsx index f196a89bb..7fb4ce62a 100644 --- a/plugins/dsh/src/client/catalog-page.tsx +++ b/plugins/dsh/src/client/catalog-page.tsx @@ -1,8 +1,10 @@ +import { SkillWebsiteLink } from './skill-website-link.js'; import { UpgradeControl } from './upgrade-control.js'; import { RemovalControl } from './removal-control.js'; import { SkillInstallDialog } from './install-dialog.js'; import { readCacheOptions } from './read-cache.js'; import { catalogLabel } from './catalog-label.js'; +import { InstallationManagement } from './management-tool-view.js'; import { MarketCardActions } from './market-card-actions.js'; import { CardSkeletons, CatalogSkeleton, DetailSkeleton } from './loading-layout.js'; import { UiAvailabilityNotice, type UiAvailability } from './ui-availability.js'; @@ -15,14 +17,12 @@ import { installedNavigationFor, type InstalledNavigation } from './installed-na import { InstalledSkills } from './installed-skills.js'; import type { InstalledServices } from './installed-skills.js'; import { DetailContent } from './detail-content.js'; -import { InstallControl } from './install-control.js'; import type { InstallationServices } from './install-control.js'; import { SelectField, Button, Input, Tabs, TabsContent, TabsList, TabsTrigger } from './ui.js'; import { Icon, SkillAvatar, SkillCard, SkillMetrics } from './skill-card.js'; import type { InfiniteData } from '@tanstack/react-query'; import type { CatalogPageResult, SkillList, InstalledSkillList } from '../host/types.js'; -import { summarizeInstallation } from '../host/installation-summary.js'; -import { InstallationStatus } from './installation-status.js'; +import { skillInstallationSummary } from './skill-primary-actions.js'; import '../../lib/typert.remote-client.js'; type Item = SkillList['items'][number]; @@ -173,25 +173,32 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa

{sourceMatches ? detail.data?.title || selected.displayName || selected.slug : selected.displayName || selected.slug}

-

@{selected.namespace}/{selected.slug} · {selected.latestVersion}

+

@{selected.namespace}/{selected.slug} · {selected.latestVersion}

+

{sourceMatches ? detail.data?.summary ?? selected.summary : selected.summary}

{!sourceMatches ? error('来源已变化,请返回技能广场重新选择。', back) : detail.isPending ? : detail.isError ? error(detail.error.message, () => { void detail.refetch(); }) - : <>
+ : <> + {installation && installed && item.namespace === selected.namespace && item.slug === selected.slug) ?? []} + sourceRevision={revision!} services={installed} verified={!!inventory.data && !inventory.isError && inventory.data.complete && !inventory.data.unavailable.length} + onInstall={() => setInstallCandidate({ item: selected, revision: revision! })} />} +
{ void detail.refetch(); }} cacheIdentity={JSON.stringify([installation?.sessionId ?? installed?.sessionId, revision, selected.namespace, selected.slug, selected.latestVersion])} onContextStale={() => { void detail.refetch(); }} onSelectVersion={version => { const next = { ...selected, latestVersion: version }; navigation.selected = next; setSelected(next); }} /> -
} : <> 浏览技能 { navigation.installedOrigin = undefined; setInstalledOrigin(undefined); }}>已安装 {installedOrigin && (installedOrigin.sessionId !== installed?.sessionId || installedOrigin.sourceRevision !== revision) ?

会话或来源已变化,请点击“已安装”查看当前上下文的记录。

- : installed ? manageSkill(item, 'remove')} onUpgrade={item => manageSkill(item, 'upgrade')} navigation={installedNavigation!} source={context.data.registryUrl} sourceRevision={revision!} remote={remote} modes={context.data.detailTabs} scrollContainer={scroller} /> + : installed ? setInstallCandidate({ item: { namespace: item.namespace, slug: item.slug, latestVersion: item.version, summary: '' }, revision: revision! })} onRemove={item => manageSkill(item, 'remove')} onUpgrade={item => manageSkill(item, 'upgrade')} navigation={installedNavigation!} source={context.data.registryUrl} sourceRevision={revision!} remote={remote} modes={context.data.detailTabs} scrollContainer={scroller} /> :

请先打开一个 DSH 会话,再查看该用户和项目的安装记录。

}
{ event.preventDefault(); search(draft.trim()); }}> @@ -215,11 +222,11 @@ function Catalog({ remote, navigation, onReturn, installation, installed, availa :
{items.map(item => record.namespace === item.namespace && record.slug === item.slug) - ? : undefined} + ? {skillInstallationSummary(inventory.data.items.filter(record => record.namespace === item.namespace && record.slug === item.slug))} : undefined} actions={ record.namespace === item.namespace && record.slug === item.slug) ?? []} services={installed} sourceRevision={revision!} verified={!!inventory.data && !inventory.isError && inventory.data.complete && !inventory.data.unavailable.length} - onInstall={() => revision && setInstallCandidate({ item, revision })} onManage={manageSkill} />} + onInstall={() => revision && setInstallCandidate({ item, revision })} onManage={() => openSkill(item)} />} ref={node => { if (lastSelected.current?.namespace === item.namespace && lastSelected.current?.slug === item.slug) lastCard.current = node; }} onOpen={event => openSkill(item, event.currentTarget)} />)}
} {listing.isRefetchError &&

刷新失败,当前保留上次加载的结果,尚未确认是否为最新内容。{listing.error.message}

} diff --git a/plugins/dsh/src/client/conversation-detail.tsx b/plugins/dsh/src/client/conversation-detail.tsx index 521529bf0..9bfd7330b 100644 --- a/plugins/dsh/src/client/conversation-detail.tsx +++ b/plugins/dsh/src/client/conversation-detail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query'; import type { QueryClient } from '@tanstack/react-query'; import type { RemoteResult, TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol'; @@ -6,7 +6,11 @@ import type { ConversationDetail as ConversationDetailData } from '../host/types import { NativeControlStyles } from './native-control-styles.js'; import { SkillAvatar, SkillMetrics } from './skill-card.js'; import { DetailContent } from './detail-content.js'; -import { InstallControl } from './install-control.js'; +import { SkillInstallDialog } from './install-dialog.js'; +import { SkillPrimaryActions } from './skill-primary-actions.js'; +import { InstallationManagement } from './management-tool-view.js'; +import { SkillWebsiteLink } from './skill-website-link.js'; +import { Button } from './ui.js'; import type { InstallationServices } from './install-control.js'; import '../../lib/typert.remote-client.js'; import type { ConversationSearchResult } from '../host/search-result.js'; @@ -37,7 +41,7 @@ export function ConversationDetail({ reference, callId, originalSource, services return () => { element.close(); if (previous instanceof HTMLElement && previous.isConnected) previous.focus(); }; }, []); return { event.preventDefault(); close(); }} - 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' }}> + style={{ margin: '0 0 0 auto', height: '100dvh', maxHeight: '100dvh', width: 'min(1080px, 100vw)', maxWidth: '100vw', boxSizing: 'border-box', border: '1px solid #8885', padding: 24, overflowY: 'auto', font: 'inherit', lineHeight: 1.6, overflowWrap: 'anywhere' }}> @@ -62,13 +66,35 @@ function Content({ reference, callId, originalSource, services, close, browseVer const { context, detail } = query.data; return <> {reopening &&

已重新核验详情。安装需要另行确认。

} -

{detail.title}

@{detail.namespace}/{detail.slug} · {detail.version}

{detail.summary}

+

{detail.title}

@{detail.namespace}/{detail.slug} · {detail.version}

{detail.summary}

- { 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="前往广场选择版本" />
+ +
{ 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}

; } +function ConversationSkillActions({ detail, revision, services, close, children }: { detail: ConversationDetailData; revision: string; services: SearchViewServices; close(): void; children: ReactNode }) { + const [install, setInstall] = useState(false); + const inventory = useQuery({ queryKey: ['skillhub-installed', services.installation.sessionId, revision], retry: false, + queryFn: async ({ signal }) => value(await services.installation.installed(revision, signal)) }); + const records = inventory.data?.items.filter(item => item.namespace === detail.namespace && item.slug === detail.slug) ?? []; + const verified = !!inventory.data && !inventory.isError && inventory.data.complete && !inventory.data.unavailable.length; + return <> + {records.length ? + : } +
{children}
+ {install && setInstall(false)} + load={async signal => { + const context = value(await services.remote.getContext()); + if (context.sourceRevision !== revision) throw new Error('来源已变化,请重新打开详情。'); + return { detail: value(await services.openDetail(detail.selectionRef, signal)), sourceRevision: revision }; + }} />} + ; +} function Acknowledgment({ reference, token, acknowledge }: { reference: string; token: string; acknowledge: SearchViewServices['acknowledgeDetail']; }) { diff --git a/plugins/dsh/src/client/index.tsx b/plugins/dsh/src/client/index.tsx index ecefa3278..005b30a95 100644 --- a/plugins/dsh/src/client/index.tsx +++ b/plugins/dsh/src/client/index.tsx @@ -95,6 +95,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { 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 { + let management: ManagementViewServices | undefined; const call = async (action: (remote: AgentContext['remote']) => Promise): Promise => { const scope = ctx.sessions.scope(SessionId(sessionId)); if (!scope) throw new Error('当前会话已关闭,请重新打开会话。'); @@ -107,13 +108,13 @@ 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, 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; }, + return { sessionId, requests, upgrades, get management() { return management ??= managementFor(sessionId); }, 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'); if (!layout || !ctx.sessions.scope(SessionId(sessionId))) throw new Error('当前会话不可用。'); const installed = installedNavigationFor(navigation, sessionId, sourceRevision); - Object.assign(installed, { scope: item.scope, keyword: '', selected: item, intent: 'remove', completed: null, removalCompleted: null, triggerKey: item.itemKey }); + Object.assign(installed, { scope: item.scope, keyword: '', selected: item, intent: 'detail', completed: null, removalCompleted: null, triggerKey: item.itemKey }); navigation.selected = null; navigation.tab = 'installed'; navigation.scrollTop = 0; navigation.installedOrigin = { sessionId, sourceRevision }; layout.selectPanel(panelId); @@ -138,33 +139,36 @@ export async function apply(ctx: Context): Promise<() => Promise> { installed: (revision, signal) => { assertCurrent(); return withSessionRemote(scope!, remote => remote.skillhub.listInstalled(revision, signal)); }, }; } - for (const name of ['astron_skillhub_check_availability', 'astron_skillhub_list_installed', 'astron_skillhub_check_update', 'astron_skillhub_upgrade', 'astron_skillhub_uninstall', 'astron_skillhub_operation']) { + function managementFor(sessionId: string): ManagementViewServices { + const receipt = receiptReadsFor(sessionId); + const call = async (action: (remote: AgentContext['remote']) => Promise): Promise => { + receipt.assertCurrent(); + if (!(ctx.sessions.list.getSnapshot().byId[SessionId(sessionId)]?.retainedBy.mainView ?? 0)) throw new Error('请回到原会话后重试。'); + const scope = ctx.sessions.scope(SessionId(sessionId)); + if (!scope) throw new Error('当前会话已关闭。'); + return withSessionRemote(scope, action); + }; + const services: ManagementViewServices = { selectSkillUse: (revision, namespace, slug, signal) => call(remote => remote.skillhub.selectSkillUse(revision, namespace, slug, signal)), client, installation: installationFor(sessionId), assertCurrent: receipt.assertCurrent, + refreshInstalled: signal => call(async remote => { + const source = await ctx.remote.skillhub.getContext(); + if (!source.ok || !source.value.configured) throw new Error('当前技能来源不可用。'); + receipt.assertCurrent(); + const installed = await remote.skillhub.listInstalled(source.value.sourceRevision, signal); + if (!installed.ok) throw new Error('无法读取当前安装记录。'); + receipt.assertCurrent(); + return { sourceRevision: source.value.sourceRevision, list: installed.value }; + }), + availability: (revision, itemKey, signal) => call(remote => remote.skillhub.checkAvailability(revision, itemKey, signal)), + check: (revision, itemKey, signal) => call(remote => remote.skillhub.checkUpdate(revision, itemKey, signal)), + begin: (revision, itemKey, action, version, requestKey, signal) => call(remote => remote.skillhub.beginConversationManagement(revision, itemKey, action, version, requestKey, signal)), + confirm: (reference, confirmed, signal) => call(remote => remote.skillhub.confirmConversationManagement(reference, confirmed, signal)), + }; + return services; + } + for (const name of ['astron_skillhub_check_availability', 'astron_skillhub_list_installed', 'astron_skillhub_check_update', 'astron_skillhub_upgrade', 'astron_skillhub_uninstall', 'astron_skillhub_enable', 'astron_skillhub_disable', 'astron_skillhub_operation']) { ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: name, inject: sessionId => { - const receipt = receiptReadsFor(sessionId); - const call = async (action: (remote: AgentContext['remote']) => Promise): Promise => { - receipt.assertCurrent(); - if (!(ctx.sessions.list.getSnapshot().byId[SessionId(sessionId)]?.retainedBy.mainView ?? 0)) throw new Error('请回到原会话后重试。'); - const scope = ctx.sessions.scope(SessionId(sessionId)); - if (!scope) throw new Error('当前会话已关闭。'); - return withSessionRemote(scope, action); - }; - const services: ManagementViewServices = { client, installation: installationFor(sessionId), assertCurrent: receipt.assertCurrent, - refreshInstalled: signal => call(async remote => { - const source = await ctx.remote.skillhub.getContext(); - if (!source.ok || !source.value.configured) throw new Error('当前技能来源不可用。'); - receipt.assertCurrent(); - const installed = await remote.skillhub.listInstalled(source.value.sourceRevision, signal); - if (!installed.ok) throw new Error('无法读取当前安装记录。'); - receipt.assertCurrent(); - return { sourceRevision: source.value.sourceRevision, list: installed.value }; - }), - availability: (revision, itemKey, signal) => call(remote => remote.skillhub.checkAvailability(revision, itemKey, signal)), - check: (revision, itemKey, signal) => call(remote => remote.skillhub.checkUpdate(revision, itemKey, signal)), - begin: (revision, itemKey, action, version, requestKey, signal) => call(remote => remote.skillhub.beginConversationManagement(revision, itemKey, action, version, requestKey, signal)), - confirm: (reference, confirmed, signal) => call(remote => remote.skillhub.confirmConversationManagement(reference, confirmed, signal)), - }; - return { services }; + return { services: managementFor(sessionId) }; }, }, props => )); } @@ -251,7 +255,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { }, ({ renderSlot }) => renderSlot('skillhub.catalog', {}))); ctx.slots.inject('skillhub.catalog', () => ctx.slots.register({ name: 'skillhub.catalog', inject: sessionId => ({ installation: sessionId ? installationFor(sessionId, true) : undefined, - installed: sessionId ? { sessionId, skillUse: useSkills.catalogServices(sessionId), upgradeRequests, removalRequests, compatibility: () => ctx.remote.skillhub.getCompatibility(), + installed: sessionId ? { sessionId, management: managementFor(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)); diff --git a/plugins/dsh/src/client/install-control.tsx b/plugins/dsh/src/client/install-control.tsx index fe9c5dc63..cc5b62ab7 100644 --- a/plugins/dsh/src/client/install-control.tsx +++ b/plugins/dsh/src/client/install-control.tsx @@ -1,3 +1,5 @@ +import { ToggleControl } from './toggle-control.js'; +import type { ManagementViewServices } from './management-tool-view.js'; import { UseSkillButton, type SkillUseServices } from './use-skill.js'; import { UpgradeControl, type UpgradeServices } from './upgrade-control.js'; import { InstallationProgressSummary, type InstallationProgressJob } from './installation-progress.js'; @@ -14,6 +16,7 @@ import type { InstallationRequests } from './installation-requests.js'; export interface InstallationServices extends CompatibilityServices { sessionId: string; skillUse?: SkillUseServices; + management?: ManagementViewServices; upgrades?: UpgradeServices; registryIdentity?(revision: string): Promise; checkVersionUpdate?(revision: string, itemKey: string, version: string, signal: AbortSignal): Promise>; @@ -110,30 +113,31 @@ export function InstallControl({ detail, sourceRevision, services, initiatedFrom services={services.upgrades} sourceRevision={sourceRevision} source={upgradeCandidate.source} item={upgradeCandidate.item} targetVersion={upgradeCandidate.version} onClosed={() => setUpgradeCandidate(null)} />} - {records.length > 0 ? :
- 高级选项{scope === 'project' ? ' · 当前项目' : ''} +
-
} +

{scope === 'project' ? '仅当前项目可用' : '当前用户的所有项目可用'}

{targets.isError &&

无法读取安装位置。

} {local.isPending || (!local.data && local.isFetching) ?

正在读取安装状态…

: !isInventoryScopeVerified(local.data, scope) || local.isError ?

安装状态暂不可用。

- : current ?
- {current.state === 'present' ? `已安装 v${current.version}` : '安装记录异常'} + : current ?
+ {current.state === 'present' ? `已安装 v${current.version}` : '安装记录异常'}{current.installationState === 'disabled' && 已停用} {(onManage || services.manageInstalled) && }
: records.length > 0 ?

已安装:{records.map(item => `${item.scope === 'user' ? '所有项目' : '仅此项目'} v${item.version}`).join('、')}

: null} {upgradeSource.isError &&

无法核验当前来源。

} - {current?.state === 'present' && current.version !== detail.version && services.checkVersionUpdate &&
- {versionUpdate.isFetching ?

正在核对所选版本…

: versionUpdate.isError ?

无法核对所选版本。

- : versionUpdate.data?.state === 'upgradable' && versionUpdate.data.candidateVersion === detail.version ? - : versionUpdate.data &&

{selectedVersionUpdateMessage(versionUpdate.data)}

} -
} {inventoryVerified && !current && (!attempt || completed) && } - {inventoryVerified && current?.state === 'present' && current.version === detail.version && } +
+ {inventoryVerified && current?.state === 'present' && current.installationState !== 'disabled' && } + {current?.state === 'present' && current.version !== detail.version && services.checkVersionUpdate &&
+ {versionUpdate.isFetching ?

正在核对所选版本…

: versionUpdate.isError ?

无法核对所选版本。

+ : versionUpdate.data?.state === 'upgradable' && versionUpdate.data.candidateVersion === detail.version ? + : versionUpdate.data &&

{selectedVersionUpdateMessage(versionUpdate.data)}

} +
} + {current?.state === 'present' && records.filter(item => item.scope === scope).length === 1 && } +
{current && completed && (result.warningCount > 0 || !!result.resultDetails?.warnings.length) &&

安装有告警,请在已安装中核验。

} {submissionError &&

{submissionError}

} {progressVisible && attempt && (null); - const [expanded, setExpanded] = useState(false); const opener = useRef(typeof document === 'undefined' ? null : document.activeElement); return
{container && { if (!open) close(); }}> - { event.preventDefault(); if (opener.current instanceof HTMLElement && opener.current.isConnected) opener.current.focus(); }}> + { event.preventDefault(); if (opener.current instanceof HTMLElement && opener.current.isConnected) opener.current.focus(); }}> 安装技能选择技能的可用范围 - setExpanded(true)} /> + }
; } -function Content({ identity, load, services, initiatedFrom, close, expand, onManage }: { - identity: string; expand(): void; load(signal: AbortSignal): Promise<{ detail: SkillDetail; sourceRevision: string }>; +function Content({ identity, load, services, initiatedFrom, close, onManage }: { + identity: string; load(signal: AbortSignal): Promise<{ detail: SkillDetail; sourceRevision: string }>; services: InstallationServices; initiatedFrom: HostInstallRequest['initiatedFrom']; close(): void; onManage?(item: InstalledSkillList['items'][number]): void; }) { const [job, setJob] = useState(null); @@ -37,7 +36,7 @@ function Content({ identity, load, services, initiatedFrom, close, expand, onMan {job ? <>
安装范围
{job.scope === 'project' ? '仅此项目' : '所有项目'}

关闭窗口不会取消已提交的安装。

: { setJob(value); expand(); }} onContextStale={() => { void result.refetch(); }} />} -
{job && } + presentation="dialog" onClose={close} onManage={onManage} onSubmitted={value => { setJob(value); }} onContextStale={() => { void result.refetch(); }} />} + ; } diff --git a/plugins/dsh/src/client/install-tool-view.tsx b/plugins/dsh/src/client/install-tool-view.tsx index a44ef29a3..b6c27c642 100644 --- a/plugins/dsh/src/client/install-tool-view.tsx +++ b/plugins/dsh/src/client/install-tool-view.tsx @@ -98,7 +98,7 @@ function InstallationResult({ result, services, receipt, openInstalled }: {

{result.slug}

@{result.namespace}/{result.slug}

{presentation.label}

v{result.version} · {result.targetLabel}

{presentation.message}

-
{canInspectCurrent && operation?.phase === 'completed' && !context.isError && context.data && { +
{canInspectCurrent && operation?.phase === 'completed' && !context.isError && context.data && entries.some(item => item.state === 'present' && item.installationState !== 'disabled' && item.version === result.version && (item.scope === 'user' ? '当前用户' : '当前项目') === operation.targetLabel) && { const inventory = await readReceiptInventory(receipt, result, context.data!, signal); const scope = operation.targetLabel === '当前用户' ? 'user' : operation.targetLabel === '当前项目' ? 'project' : undefined; return scope ? exactInstalledSkill(inventory, { ...result, scope })?.itemKey ?? '' : ''; @@ -125,7 +125,7 @@ function InstallationResult({ result, services, receipt, openInstalled }: { : <> {!current.data.complete &&

当前安装记录读取不完整,不能判断所有范围是否仍安装。

} {entries.length ?
    {entries.map(item =>
  • - {item.scope === 'project' ? '当前项目' : '当前用户'} · {item.version} · {item.state === 'present' ? '文件存在' : '本地状态异常,请前往已安装核验'} + {item.scope === 'project' ? '当前项目' : '当前用户'} · {item.version} · {item.installationState === 'disabled' ? '已停用' : item.state === 'present' ? '文件存在' : '本地状态异常,请前往已安装核验'}
  • )}
: current.data.complete ?

当前会话可见范围内已无此技能的安装记录。

: null} } } diff --git a/plugins/dsh/src/client/installation-progress.tsx b/plugins/dsh/src/client/installation-progress.tsx index 49d807b5c..fec40e330 100644 --- a/plugins/dsh/src/client/installation-progress.tsx +++ b/plugins/dsh/src/client/installation-progress.tsx @@ -19,9 +19,9 @@ export function installationProgressState(attempt: InstallationAttempt | undefin } if (!operation) return { label: '正在提交安装', message: '请稍候…', state: 'running' as const }; if (operation.phase === 'completed') { - const warning = operation.warningCount > 0 || !!operation.resultDetails?.warnings.length; + const warning = operation.warningCount > 0 || !!operation.resultDetails?.warnings.length || ['incomplete', 'unknown'].includes(operation.recovery ?? ''); return { label: warning ? '安装完成,需核验告警' : '安装完成', - message: warning ? '请在已安装中核验当前状态。' : operation.discovery === 'confirmed' ? '技能已就绪。' : '文件已安装,DSH 发现尚待确认。', state: 'complete' as const }; + message: warning ? '请在已安装中核验当前状态。' : '', state: 'complete' as const }; } if (operation.phase === 'failed') return { label: '安装未完成', message: '请核验当前状态后再处理。', state: 'stopped' as const }; if (operation.phase === 'recovery-required' || operation.phase === 'unknown') return { label: '安装结果待核验', message: '操作需要恢复核验,请在已安装中处理。', state: 'stopped' as const }; @@ -38,8 +38,8 @@ export function InstallationProgressSummary({ attempt, operation, readFailed, on {view.label}
-

{view.message}

- {onCheck && view.state !== 'running' && (view.state !== 'complete' || operation?.discovery !== 'confirmed' || !!operation.warningCount) + {view.message &&

{view.message}

} + {onCheck && view.state !== 'running' && (view.state !== 'complete' || !!operation?.warningCount || ['incomplete', 'unknown'].includes(operation?.recovery ?? '')) && }
; } @@ -61,10 +61,10 @@ export function InstallationProgressPanel({ job, services, onUsed }: { job: Inst
{job.title}

v{job.version} · {job.scope === 'project' ? '仅此项目' : '所有项目'}

{ void check(); }} /> - {completed && { +
{completed && { const inventory = await services.installed(job.sourceRevision, signal); if (!inventory.ok) throw new Error('无法核验当前安装,请在已安装中重试。'); return exactInstalledSkill(inventory.value, job)?.itemKey ?? ''; - }} onHandoff={onUsed} onUsed={onUsed} />} + }} onHandoff={onUsed} onUsed={onUsed} />}
; } diff --git a/plugins/dsh/src/client/installation-status.tsx b/plugins/dsh/src/client/installation-status.tsx index f0132536c..9bbb32516 100644 --- a/plugins/dsh/src/client/installation-status.tsx +++ b/plugins/dsh/src/client/installation-status.tsx @@ -13,19 +13,23 @@ export function InstallationStatus({ installation, pending = false, snapshot = f }) { if (hideEmpty && !pending && installation?.complete && !installation.unavailable.length && !installation.records.length) return null; const present = installation?.records.filter(record => record.state === 'present') ?? []; + const hasDisabled = present.some(record => record.installationState === 'disabled'); + const presentLabel = present.length === 1 && hasDisabled ? `已停用 · v${present[0]!.version} · ${scopeLabel[present[0]!.scope]}` + : hasDisabled ? present.map(record => `${scopeLabel[record.scope]} · v${record.version} · ${record.installationState === 'disabled' ? '已停用' : '已启用'}`).join(' / ') + : `已安装 ${present.map(record => `v${record.version} · ${scopeLabel[record.scope]}`).join(' / ')}`; const abnormal = installation?.records.filter(record => record.state !== 'present') ?? []; const partial = !installation?.complete || !!installation.unavailable.length; const label = pending ? '核对安装状态…' : !installation ? (snapshot ? '安装状态未记录' : '安装状态未知') - : present.length ? `已安装 ${present.map(record => `v${record.version} · ${scopeLabel[record.scope]}`).join(' / ')}${abnormal.length ? ' · 部分异常' : ''}${partial ? ' · 部分未知' : ''}` + : present.length ? `${presentLabel}${abnormal.length ? ' · 部分异常' : ''}${partial ? ' · 部分未知' : ''}` : abnormal.length ? '安装记录异常' : partial ? '安装状态未知' : '无本来源记录'; const detail = pending ? '正在读取当前来源的本地安装记录。' : !installation ? '没有可核验的安装状态,不代表该技能未安装。' : [ - ...installation.records.map(record => `${scopeLabel[record.scope]} · v${record.version} · ${stateLabel[record.state]}`), + ...installation.records.map(record => `${scopeLabel[record.scope]} · v${record.version} · ${stateLabel[record.state]}${record.installationState === 'disabled' ? ' · 已停用' : ''}`), partial ? '部分安装记录无法读取,不能排除其他安装位置。' : '', !installation.records.length && !partial ? '未发现本来源安装记录;不代表其他来源或手动安装的同名技能不存在。' : '', snapshot ? `查询时状态:${readableDate(installation.checkedAt, true)};不代表当前仍然安装。` : '文件存在不代表 DSH 已启用或技能已执行。', ].filter(Boolean).join(';'); - return {label}; + return 1 || undefined} data-tone={pending || !installation ? 'neutral' : abnormal.length ? 'warning' : present.length && !hasDisabled ? 'success' : 'neutral'} + aria-label={`${label}。${detail}`} title={detail}>{present.length > 1 && !pending ? <>{present.map((record, index) => {scopeLabel[record.scope]} · v{record.version} · {record.installationState === 'disabled' ? '已停用' : '已启用'})}{(abnormal.length > 0 || partial) && {[abnormal.length > 0 ? '部分异常' : '', partial ? '部分未知' : ''].filter(Boolean).join(' · ')}} : label}; } diff --git a/plugins/dsh/src/client/installed-skills.tsx b/plugins/dsh/src/client/installed-skills.tsx index 8b837dd6d..d446cf78d 100644 --- a/plugins/dsh/src/client/installed-skills.tsx +++ b/plugins/dsh/src/client/installed-skills.tsx @@ -1,86 +1,53 @@ -import { UseSkillButton, type SkillUseServices } from './use-skill.js'; -import { isInventoryScopeVerified } from './installed-inventory.js'; +import { SkillWebsiteLink } from './skill-website-link.js'; +import { groupManagementItems, InstallationManagement, type ManagementViewServices } from './management-tool-view.js'; +import { SkillPrimaryActions, skillInstallationSummary } from './skill-primary-actions.js'; +import type { SkillUseServices } from './use-skill.js'; import { CardSkeletons } from './loading-layout.js'; -import { useState, useRef, useLayoutEffect, useCallback } from 'react'; -import type { RefObject } from 'react'; +import { useState, useRef, useLayoutEffect, type RefObject } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { RemoteResult, TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol'; import type { InstalledSkillList, InstalledSkillDetail, InstalledUpdateCheck, SkillHubConfig } from '../host/types.js'; -import { UpgradeControl, UpgradeHistory } from './upgrade-control.js'; -import type { UpgradeServices } from './upgrade-control.js'; +import { UpgradeHistory, type UpgradeServices } from './upgrade-control.js'; import { readCacheOptions } from './read-cache.js'; import { DetailContent } from './detail-content.js'; -import { RemovalControl, RemovalHistory, type RemovalServices } from './removal-control.js'; +import { RemovalHistory, type RemovalServices } from './removal-control.js'; import { InstalledStyles } from './installed-styles.js'; import type { InstalledNavigation } from './installed-navigation.js'; -import { SelectField, Button, Card, CardHeader, CardContent, CardFooter } from './ui.js'; +import { Button, Card, CardHeader, CardContent, CardFooter } from './ui.js'; import { SkillAvatar } from './skill-card.js'; -import { readableDate } from './skill-presentation.js'; -import { useInstalledUpdate } from './installed-update.js'; export interface InstalledServices extends UpgradeServices, RemovalServices { + management?: ManagementViewServices; sessionId: string; skillUse?: SkillUseServices; list(sourceRevision: string, signal: AbortSignal): Promise>; detail(sourceRevision: string, itemKey: string, signal: AbortSignal): Promise>; checkUpdate(sourceRevision: string, itemKey: string, signal: AbortSignal): Promise>; } -const states: Record = { - present: '文件存在', missing: '安装目录缺失', 'metadata-invalid': '安装记录无法识别', - mismatch: '安装记录与本地目标不一致', unreadable: '本地文件暂时无法读取', -}; -const updateReasons: Record = { - 'remote-older': '远端版本比已安装版本更旧,不执行降级。', - 'unknown-order': '无法确定版本先后顺序,请核实发布信息。', - 'same-version-content-changed': '同一版本的内容发生变化,需要核实,不能直接升级。', - 'missing-fingerprint': '缺少内容基线,暂时无法确认更新。', - 'local-record-unverified': '本地安装记录存在异常,请先核对安装状态。', - SOURCE_DENIED: '来源不允许匿名访问此技能。', SKILL_UNAVAILABLE: '技能或版本已不可用。', - RATE_LIMITED: '来源暂时限流,请稍后重试。', -}; - -export function InstalledSkills({ services, sourceRevision, source, remote, modes, scrollContainer, navigation, onRemove, onUpgrade }: { +type Item = InstalledSkillList['items'][number]; +export function InstalledSkills({ services, sourceRevision, source, remote, modes, scrollContainer, navigation, onInstall }: { services: InstalledServices; sourceRevision: string; source: string; remote: TypertRemoteNamespaceMap['skillhub']; modes: SkillHubConfig['detailTabs']; - scrollContainer: RefObject; navigation: InstalledNavigation; onRemove?(item: InstalledSkillList['items'][number]): void; onUpgrade?(item: InstalledSkillList['items'][number]): void; + scrollContainer: RefObject; navigation: InstalledNavigation; onInstall?(item: Item): void; onRemove?(item: Item): void; onUpgrade?(item: Item): void; }) { - const [scope, setScope] = useState(navigation.scope); const [keyword, setKeyword] = useState(navigation.keyword); const [selected, setSelected] = useState(navigation.selected); - const [completed, setCompleted] = useState(navigation.completed); - const [removalCompleted, setRemovalCompleted] = useState(navigation.removalCompleted); - const onRemovalCompleted = useCallback(() => { if (selected) setRemovalCompleted(selected.itemKey); }, [selected]); - const onUpgradeCompleted = useCallback((version: string) => { - if (selected) setCompleted(previous => previous?.itemKey === selected.itemKey && previous.version === version - ? previous : { itemKey: selected.itemKey, version }); - }, [selected]); - const intent = useRef(navigation.intent); - const upgradeSection = useRef(null); - const removalSection = useRef(null); + const heading = useRef(null); + const refreshButton = useRef(null); const trigger = useRef(null); const listScroll = useRef(navigation.listScroll); - const triggerKey = useRef(navigation.triggerKey); + useLayoutEffect(() => { Object.assign(navigation, { scope: 'all', keyword, selected, intent: 'detail', listScroll: listScroll.current }); }); useLayoutEffect(() => { - Object.assign(navigation, { scope, keyword, selected, completed, removalCompleted, - intent: intent.current, listScroll: listScroll.current, triggerKey: triggerKey.current }); - }); - const heading = useRef(null); - const filter = useRef(null); - useLayoutEffect(() => { - if (selected) { - const target = intent.current === 'upgrade' ? upgradeSection.current : intent.current === 'remove' ? removalSection.current : heading.current; - target?.focus(); - } + if (selected) heading.current?.focus(); else { if (trigger.current?.isConnected) trigger.current.focus({ preventScroll: true }); - else filter.current?.focus({ preventScroll: true }); + else refreshButton.current?.focus({ preventScroll: true }); if (scrollContainer.current) scrollContainer.current.scrollTop = listScroll.current; } }, [selected, scrollContainer]); const query = useQuery({ queryKey: ['skillhub-installed', services.sessionId, sourceRevision], retry: false, queryFn: async ({ signal }) => { const response = await services.list(sourceRevision, signal); - if (!response.ok) throw new Error(response.error.code === 'skillhub/context-stale' - ? '会话或来源已变化,请重新打开已安装列表。' : '无法读取本地安装记录,请检查 DSH 会话后重试。'); + if (!response.ok) throw new Error('会话或来源已变化,请重新读取已安装技能。'); return response.value; } }); const detail = useQuery({ ...readCacheOptions, queryKey: ['skillhub-installed-detail', services.sessionId, sourceRevision, selected?.itemKey], @@ -89,108 +56,50 @@ export function InstalledSkills({ services, sourceRevision, source, remote, mode if (!response.ok) throw new Error('安装记录已变化或暂时无法读取,请返回列表刷新。'); return response.value; } }); - const update = useInstalledUpdate(services, sourceRevision, selected?.itemKey); - const upgradedVersion = completed?.itemKey === selected?.itemKey ? completed?.version : undefined; - const refreshedItem = selected && query.data?.items.find(item => item.namespace === selected.namespace && item.slug === selected.slug && item.scope === selected.scope); - const removed = !!selected && removalCompleted === selected.itemKey && !!query.data && !refreshedItem; - const currentLocalState = detail.data?.item.state ?? selected?.state; + const verified = !!query.data && !query.isError && query.data.complete && !query.data.unavailable.length; + const records = (identity: Item) => query.data?.items.filter(item => item.namespace === identity.namespace && item.slug === identity.slug) ?? [identity]; + const actions = (items: Item[], label?: string) => { const identity = items[0]; if (identity) { listScroll.current = scrollContainer.current?.scrollTop ?? 0; setSelected(identity); } } : undefined} label={label} summaryVisible={!label} />; + const detailVerified = !!detail.data && !detail.isError && !detail.isFetching; if (selected) return
- -

{selected.slug}

-

@{selected.namespace}/{selected.slug} · {removed ? '原安装版本' : '已安装版本'} {upgradedVersion ?? selected.version}

-

{selected.scope === 'user' ? '所有项目' : '仅此项目'} · {removed ? '此安装记录已移除' : states[currentLocalState ?? selected.state]}

-
{removed ?

此安装位置的操作已完成,请查看下方结果或返回列表。

- : upgradedVersion ?

安装版本已更新。

- : detail.isPending ?

正在核对本地记录并读取固定版本详情…

+ +

{selected.slug}

+

@{selected.namespace}/{selected.slug} · 文档版本 {selected.version}

+ {actions(records(selected), '使用已安装版本')} +
{detail.isPending ?

正在读取技能说明…

: detail.isError ?

{detail.error.message}

- : detail.data.content.state === 'local-only' ?

远端详情暂时不可用,以上保留本地安装信息。未切换到其他版本。

+ : detail.data.content.state === 'local-only' ?

远端说明暂不可用,可在管理安装中查看本地状态。

: { void detail.refetch(); }} cacheIdentity={JSON.stringify([services.sessionId, sourceRevision, selected.namespace, selected.slug, detail.data.content.detail.version])} onContextStale={() => { void detail.refetch(); }} />} -
+
; const term = keyword.trim().toLocaleLowerCase(); - const items = query.data?.items.filter(item => (scope === 'all' || item.scope === scope) - && (!term || `@${item.namespace}/${item.slug} ${item.version}`.toLocaleLowerCase().includes(term))) ?? []; + const groups = groupManagementItems(query.data?.items ?? []).filter(items => !term || items.some(item => `@${item.namespace}/${item.slug} ${item.version}`.toLocaleLowerCase().includes(term))); return
-
- - - - {keyword && } -
-

显示当前 SkillHub 来源在当前用户和项目中的安装记录。“文件存在”不代表内容完整性或安全审核已通过。

- {query.isPending ? : query.isError ?

{query.error.message}

: <> - {(!query.data.complete || query.data.unavailable.length > 0) &&

部分安装记录无法读取,以下列表可能不完整。

- {query.data.unavailable.length > 0 &&

无法读取范围:{query.data.unavailable.map(value => value === 'user' ? '所有项目' : '仅此项目').join('、')}

} -
} -

当前显示 {items.length} 项安装记录。

- {items.length === 0 ?

{term ? '没有匹配的本地安装记录,请调整关键词或安装范围。' : (scope === 'all' ? query.data.complete && query.data.unavailable.length === 0 : isInventoryScopeVerified(query.data, scope)) ? '当前范围没有该来源的安装记录。' : '当前范围没有可展示的记录,请先处理读取问题。'}

- :
{items.map(item => { if (triggerKey.current === item.itemKey && intent.current === action) trigger.current = node; }} - navigate={(action, button) => { - if (action === 'remove' && onRemove) { onRemove(item); return; } - if (action === 'upgrade' && onUpgrade) { onUpgrade(item); return; } - listScroll.current = scrollContainer.current?.scrollTop ?? 0; - intent.current = action; triggerKey.current = item.itemKey; trigger.current = button; setSelected(item); - }} />)}
} +
+ {keyword && }
+ {query.isPending ? : query.isError ?

{query.error.message}

: <> + {(!query.data.complete || query.data.unavailable.length > 0) &&

部分安装记录无法读取,列表可能不完整。

} +

当前显示 {groups.length} 个技能。

+ {!groups.length ?

{term ? '没有匹配的技能。' : verified ? '当前来源暂无已安装技能。' : '暂未读取到安装记录。'}

+ :
{groups.map(items => { + const identity = items[0]!; + return +

{identity.slug}

@{identity.namespace}/{identity.slug}
+ {skillInstallationSummary(items)} + {actions(items)} +
; + })}
} } - filter.current?.focus()} /> - filter.current?.focus()} /> + refreshButton.current?.focus()} /> + refreshButton.current?.focus()} />
; } - - -function InstalledRow({ item, services, sourceRevision, navigate, buttonRef }: { - item: InstalledSkillList['items'][number]; services: InstalledServices; sourceRevision: string; - navigate(action: 'detail' | 'upgrade' | 'remove', button: HTMLButtonElement): void; - buttonRef(action: 'detail' | 'upgrade' | 'remove', node: HTMLButtonElement | null): void; -}) { - const update = useInstalledUpdate(services, sourceRevision, item.itemKey); - return -
-

{item.slug}

@{item.namespace}/{item.slug}
-
已安装版本 {item.version}{item.scope === 'user' ? '所有项目' : '仅此项目'}
-

{states[item.state]}

-
- {update.isFetching ? '正在检查更新…' : update.isError ? update.error.message : !update.data ? '尚未检查' - : update.data.state === 'upgradable' ? `可升级至 ${update.data.candidateVersion}` - : update.data.state === 'current' ? '版本及记录指纹一致' - : updateReasons[update.data.reason] ?? '暂时无法确认更新,请重试。'} - {update.data &&
检查记录
} -
- - {item.state === 'present' && } - - - {!update.isFetching && !update.isError && update.data?.state === 'upgradable' - && } - - -
; -} diff --git a/plugins/dsh/src/client/management-notice.tsx b/plugins/dsh/src/client/management-notice.tsx new file mode 100644 index 000000000..a8883fb01 --- /dev/null +++ b/plugins/dsh/src/client/management-notice.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { Button } from './ui.js'; + +interface Notice { title: string; content: ReactNode; success?: boolean } +const NoticeContext = createContext<((notice: Notice | undefined) => void) | undefined>(undefined); +export const useManagementNotice = () => useContext(NoticeContext); +/** One scoped slot replaces repeated checks; leaving the skill disposes its timer and notice. */ +export function ManagementNoticeBoundary({ children, container }: { children: ReactNode; container: HTMLElement | null }) { + const [notice, setNotice] = useState(); + useEffect(() => { + if (!notice?.success) return; + const timer = setTimeout(() => setNotice(undefined), 5000); + return () => clearTimeout(timer); + }, [notice]); + return {children}{notice && createPortal( + , container?.closest('dialog') ?? document.body)}; +} diff --git a/plugins/dsh/src/client/management-result.ts b/plugins/dsh/src/client/management-result.ts index cd7942284..e0aee5aa7 100644 --- a/plugins/dsh/src/client/management-result.ts +++ b/plugins/dsh/src/client/management-result.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; const text = z.string().min(1).max(2048); const item = z.object({ itemKey: text, namespace: text, slug: text, version: text, - scope: z.enum(['user', 'project']), state: z.enum(['present', 'missing', 'metadata-invalid', 'mismatch', 'unreadable']) }); + scope: z.enum(['user', 'project']), locationKey: text.optional(), installationState: z.enum(['enabled', 'disabled']).optional(), state: z.enum(['present', 'missing', 'metadata-invalid', 'mismatch', 'unreadable']) }); const base = { schemaVersion: z.literal(1), toolCallId: text, sourceRevision: text, sourceLabel: text }; const installed = z.object({ ...base, kind: z.literal('skillhub-installed'), items: z.array(item), complete: z.boolean(), issues: z.array(z.string()), unavailable: z.array(z.enum(['user', 'project'])) }); @@ -11,14 +11,14 @@ const check = z.object({ state: z.enum(['upgradable', 'current', 'blocked', 'una const update = z.object({ ...base, kind: z.literal('skillhub-update-check'), item, check }); const operation = z.object({ operationId: z.uuid(), context: z.object({ hostInstanceId: z.uuid() }) }); const management = z.object({ ...base, kind: z.literal('skillhub-management'), item, - action: z.enum(['upgrade', 'uninstall']), status: z.enum(['awaiting-confirmation', 'cancelled', 'blocked', 'accepted']), + action: z.enum(['upgrade', 'uninstall', 'enable', 'disable']), status: z.enum(['awaiting-confirmation', 'cancelled', 'blocked', 'accepted']), candidateVersion: text.nullable(), reason: z.string(), confirmationRef: text.nullable(), operation: operation.nullable() }); const operationResult = z.object({ schemaVersion: z.literal(1), kind: z.literal('skillhub-operation'), toolCallId: text, operation: operation.extend({ kind: z.string(), namespace: text.optional(), slug: text, version: text, targetLabel: text, phase: z.string(), warningCount: z.number(), discovery: z.enum(['unconfirmed', 'confirmed', 'failed']).optional(), recovery: z.enum(['not-needed', 'restored', 'incomplete', 'unknown']).optional() }).nullable() }); export const availabilitySchema = z.object({ installation: z.object({ namespace: text, slug: text, version: text, scope: z.enum(['user', 'project']) }).optional(), scope: z.enum(['user', 'project']).optional(), checkedAt: text, name: z.string().max(128).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(), - association: z.enum(['managed-reference', 'not-associated']), integrity: z.enum(['verified', 'changed', 'missing', 'unknown']), + installationState: z.enum(['enabled', 'disabled']).optional(), association: z.enum(['managed-reference', 'not-associated']), integrity: z.enum(['verified', 'changed', 'missing', 'unknown']), discovery: z.enum(['winner', 'shadowed', 'not-discovered', 'unknown']), invocation: z.object({ userInvocable: z.boolean(), modelInvocable: z.boolean() }).optional(), - reason: z.enum(['observed', 'host-unavailable', 'files-missing', 'files-unreadable', 'local-changes', 'baseline-unverified', + reason: z.enum(['installation-disabled', 'observed', 'host-unavailable', 'files-missing', 'files-unreadable', 'local-changes', 'baseline-unverified', 'definition-unverified', 'snapshot-unavailable', 'snapshot-incomplete', 'observation-changed', 'not-discovered', 'same-name-winner', 'definition-changed', 'read-failed']) }); const availability = z.object({ schemaVersion: z.literal(1), kind: z.literal('skillhub-availability'), toolCallId: text, diagnosis: availabilitySchema }); const schema = z.discriminatedUnion('kind', [installed, update, management, operationResult, availability]); @@ -34,6 +34,8 @@ export function parseManagementResult(value: unknown, callId: string): Managemen } export function managementReason(reason: string): string { const messages: Record = { + 'already-enabled': '该安装已启用,请重新查询。', + 'already-disabled': '该安装已停用,请重新查询。', 'same-content': '已安装此版本,无需更新。', 'local-changes': '存在本地修改,本次操作已阻止。', 'remote-older': '所选版本较旧,暂不支持降级。', diff --git a/plugins/dsh/src/client/management-tool-view.tsx b/plugins/dsh/src/client/management-tool-view.tsx index a2c190227..7b8df0eba 100644 --- a/plugins/dsh/src/client/management-tool-view.tsx +++ b/plugins/dsh/src/client/management-tool-view.tsx @@ -1,4 +1,6 @@ -import { createContext, useContext, useId, useMemo, useRef, useState, type ReactNode } from 'react'; +import { ManagementNoticeBoundary, useManagementNotice } from './management-notice.js'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { createContext, useEffect, useContext, useId, useMemo, useRef, useState, type ReactNode } from 'react'; import { QueryClientProvider, useQuery, type QueryClient } from '@tanstack/react-query'; import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'; import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'; @@ -7,17 +9,19 @@ import type { InstallationServices } from './install-control.js'; import { parseManagementResult, managementReason, type ManagementViewResult, type ManagementReceipt } from './management-result.js'; import { NativeControlStyles } from './native-control-styles.js'; import { ConversationStyles } from './conversation-styles.js'; -import { Button, Tabs, TabsList, TabsTrigger, TabsContent } from './ui.js'; +import { Button, Dialog, DialogContent, DialogTitle, DialogDescription } from './ui.js'; +import { SkillPrimaryActions, type SelectSkillUse, skillInstallationSummary } from './skill-primary-actions.js'; import { UseSkillButton } from './use-skill.js'; import { useInstallationOperation } from './use-installation-operation.js'; export interface ManagementViewServices { + selectSkillUse?: SelectSkillUse; client: QueryClient; installation: InstallationServices; refreshInstalled(signal: AbortSignal): Promise<{ sourceRevision: string; list: InstalledSkillList }>; availability?(revision: string, itemKey: string, signal: AbortSignal): Promise>; check(revision: string, itemKey: string, signal: AbortSignal): Promise>; - begin(revision: string, itemKey: string, action: 'upgrade' | 'uninstall', version: string | null, requestKey: string, signal: AbortSignal): Promise>; + begin(revision: string, itemKey: string, action: 'upgrade' | 'uninstall' | 'enable' | 'disable', version: string | null, requestKey: string, signal: AbortSignal): Promise>; confirm(ref: string, confirmed: boolean, signal: AbortSignal): Promise>; assertCurrent(): void; } @@ -37,20 +41,25 @@ function failureMessage(error: unknown): string { return stale(error) ? '安装记录已变化。' : '暂时无法读取安装记录。'; } /** Validate the historical reference without rebinding it to a newer version or another scope. */ -export async function verifyManagementReference(services: ManagementViewServices, revision: string, itemKey: string, signal: AbortSignal): Promise { +export async function verifyManagementReference(services: ManagementViewServices, revision: string, itemKey: string, signal: AbortSignal): Promise { services.assertCurrent(); const list = read(await services.installation.installed(revision, signal)); services.assertCurrent(); signal.throwIfAborted(); - if (!list.items.some(item => item.itemKey === itemKey)) { + const item = list.items.find(item => item.itemKey === itemKey); + if (!item) { if (!list.complete || list.unavailable.length) throw new ManagementRequestError('skillhub/installation-unavailable'); throw new ManagementRequestError('skillhub/context-stale'); } + return item; } /** Only reference validation retires a row; task selection cancellation belongs to UseSkillButton. */ export function verifiedManagementUse(services: ManagementViewServices, revision: string, itemKey: string, onInvalid: (message: string) => void): InstallationServices['skillUse'] { const use = services.installation.skillUse; return use ? { useSkill: async (...args) => { - try { await verifyManagementReference(services, revision, itemKey, args[2]); } + try { + const item = await verifyManagementReference(services, revision, itemKey, args[2]); + if (item.installationState === 'disabled') throw new ManagementRequestError('skillhub/context-stale'); + } catch (failure) { onInvalid(failureMessage(failure)); throw failure; } await use.useSkill(...args); } } : undefined; @@ -78,8 +87,31 @@ function ManagementResultContent({ result, services }: { result: ManagementViewR if (result.kind === 'skillhub-update-check') return ; return ; } +export function availabilityNotice(value: SkillAvailability): { message: string; success: boolean } { + if (value.installationState === 'disabled') return { message: value.discovery === 'winner' ? '此安装已停用,但当前会话仍有缓存记录。' : '此安装已停用。', success: false }; + const restricted = [value.invocation?.userInvocable === false ? '用户调用禁用' : '', value.invocation?.modelInvocable === false ? '模型调用禁用' : ''].filter(Boolean); + if (restricted.length) return { message: `此安装的${restricted.join('、')}。`, success: false }; + if (value.reason === 'observed') return { message: value.association === 'not-associated' ? '当前会话已发现同名技能,来源尚未关联。' : '当前会话已发现此安装。', success: value.association !== 'not-associated' }; + const messages: Partial> = { + 'same-name-winner': '此安装暂不可用,当前会话使用了另一份同名技能。', + 'host-unavailable': '暂时无法核验宿主状态,请稍后重试。', + 'files-missing': '此安装文件缺失,请重新查询安装记录。', + 'files-unreadable': '无法读取安装文件,请检查访问权限。', + 'local-changes': '安装内容已修改,请核对后再使用。', + 'baseline-unverified': '无法核验安装内容基线,请重新查询。', + 'definition-unverified': '无法核验技能定义或宿主加载状态。', + 'snapshot-unavailable': '宿主未提供完整快照,暂时无法核验。', + 'snapshot-incomplete': '宿主技能列表不完整,请稍后重试。', + 'observation-changed': '检查期间状态已变化,请重新检查。', + 'not-discovered': '当前会话尚未发现此安装。', + 'definition-changed': '文件与宿主加载内容不一致,请等待刷新。', + 'read-failed': '暂时无法核验,请稍后重试。', + }; + return { message: messages[value.reason] ?? '暂时无法核验此安装。', success: false }; +} export function AvailabilityResult({ value, standalone = false }: { value: SkillAvailability; standalone?: boolean }) { const messages: Record = { + 'installation-disabled': ['该安装已停用。', '不会撤回已加载内容;其他同名安装可能仍可用。'], observed: [value.association === 'not-associated' ? '当前会话已发现,来源未关联。' : '当前检查:文件与宿主加载一致。', '仅反映本次检查结果。'], 'host-unavailable': ['宿主状态无法核验。', '请稍后重新检查。'], 'files-missing': ['所选安装文件缺失。', '请重新查询安装记录。'], @@ -98,7 +130,10 @@ export function AvailabilityResult({ value, standalone = false }: { value: Skill const [message, advice] = messages[value.reason]; return
{standalone &&

{value.installation ? `@${value.installation.namespace}/${value.installation.slug} · v${value.installation.version}` : value.name ?? '技能可用性'}{value.scope && ` · ${scopeLabel(value.scope)}`}

} -

{message}

{value.invocation &&

用户调用{value.invocation.userInvocable ? '允许' : '禁用'} · 模型调用{value.invocation.modelInvocable ? '允许' : '禁用'}

}{advice} + {value.installationState === 'disabled' &&

该安装已停用。

} + {(value.installationState !== 'disabled' || value.reason !== 'installation-disabled') &&

{message}

} + {value.installationState === 'disabled' && value.discovery === 'winner' &&

当前会话仍发现此安装,可能存在缓存;不代表可调用。

} + {value.invocation &&

用户调用{value.invocation.userInvocable ? '允许' : '禁用'} · 模型调用{value.invocation.modelInvocable ? '允许' : '禁用'}

}{advice}
; } function ManagementOperation({ result, services }: { result: Extract; services: ManagementViewServices }) { @@ -111,13 +146,13 @@ function ManagementOperation({ result, services }: { result: Extract 0 - || (op.kind === 'upgrade' && op.discovery !== 'confirmed') || ['incomplete', 'unknown'].includes(op.recovery ?? '')); + || ['incomplete', 'unknown'].includes(op.recovery ?? '')); return
{op ? <>{verb}{op.phase === 'completed' ? '已完成' : op.phase === 'failed' ? '未完成' : ['unknown', 'recovery-required'].includes(op.phase) ? '待核验' : '处理中'} {op.namespace ? `@${op.namespace}/${op.slug}` : op.slug} · v{op.version} · {op.targetLabel}{op.warningCount > 0 &&

有告警需核验。

} - {op.phase === 'completed' && op.kind === 'upgrade' && op.discovery !== 'confirmed' && DSH 发现尚待确认}{op.recovery && ['incomplete', 'unknown'].includes(op.recovery) && 恢复状态待核验} :

暂时无法读取原操作。

} + {op.recovery && ['incomplete', 'unknown'].includes(op.recovery) && 恢复状态待核验} :

暂时无法读取原操作。

} {query.isError &&

查询失败,请重试。

} {needsQuery && }
; @@ -142,30 +177,68 @@ function InstalledList({ list, revision, services, refreshed = false }: { list: return <>

{groups.length} 个技能 · {list.items.length} 处安装

当前来源{refreshed ? ' · 已重新查询' : ''}
{(!list.complete || list.unavailable.length > 0) &&

部分位置无法读取,列表可能不完整。{list.unavailable.map(scopeLabel).join('、')}{list.unavailable.length > 0 ? '状态未知。' : ''}

} {!list.items.length &&

{list.complete && !list.unavailable.length ? '当前来源暂无安装记录。' : '暂未读取到安装记录。'}

} -
{groups.slice(0, limit).map(items => )}
+
{groups.slice(0, limit).map(items => )}
{groups.length > limit && } {(!list.complete || list.unavailable.length > 0) && } ; } -function ManagementGroup({ items, revision, services }: { items: Item[]; revision: string; services: ManagementViewServices }) { - const [selected, setSelected] = useState(String(Math.max(0, items.findIndex(item => item.scope === 'project')))); - const [pendingMutation, setPendingMutation] = useState(false); - const [receipt, setReceipt] = useState<{ result: ConversationManagementResult; label: string }>(); - const item = items[Number(selected)] ?? items[0]!; - const label = managementTargetLabel(items, item); - if (receipt) return setReceipt(undefined)} />; - const row = setReceipt({ result, label })} />; - return
-
{item.slug}@{item.namespace}
- {items.length === 1 ?
{label}
{row}
: { if (!pendingMutation) setSelected(value); }}> -
{items.map((target, index) => {managementTargetLabel(items, target)})}
- - {row} - -
} +/** Refresh is allowed only after accepted success, using a stable Host location identity. */ +export async function refreshManagementLocation(services: ManagementViewServices, revision: string, item: Item): Promise { + services.assertCurrent(); + const current = await services.refreshInstalled(new AbortController().signal); + services.assertCurrent(); + if (current.sourceRevision !== revision || !item.locationKey || !current.list.complete || current.list.unavailable.includes(item.scope) + || current.list.items.filter(candidate => candidate.locationKey === item.locationKey).length !== 1) throw new ManagementRequestError('skillhub/context-stale'); + return current.list; +} +function ManagementGroup({ items, revision, services, verified }: { items: Item[]; revision: string; services: ManagementViewServices; verified: boolean }) { + const identity = items[0]!; + return
+
{identity.slug}@{identity.namespace}
+ {skillInstallationSummary(items)} + services.installation.manageInstalled!(revision, identity) : undefined} />
; } + +/** Read-only position inventory rendered directly in a skill detail, never a management modal. */ +export function InstallationManagement({ identity, revision, services, useSkill, onInstall, onUsed }: { + identity: Pick; revision: string; services: ManagementViewServices; onInstall?(): void; onUsed?(): void; useSkill?: import('./use-skill.js').SkillUseServices; +}) { + const [receipt, setReceipt] = useState(); + const [container, setContainer] = useState(null); + const scoped = useMemo(() => useSkill ? { ...services, installation: { ...services.installation, skillUse: useSkill } } : services, [services, useSkill]); + const query = useQuery({ queryKey: ['skillhub-installed', services.installation.sessionId, revision, 'management-locations', identity.namespace, identity.slug], + gcTime: 0, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false, queryFn: async ({ signal }) => { + services.assertCurrent(); + const list = read(await services.installation.installed(revision, signal)); + services.assertCurrent(); signal.throwIfAborted(); return list; + } }); + return
+

安装管理

+ {query.isPending ?

正在读取安装位置…

: query.isError ?

安装记录暂不可用。

: <> + {(!query.data.complete || query.data.unavailable.length > 0) &&

部分位置无法读取,以下记录可能不完整。

} + item.namespace === identity.namespace && item.slug === identity.slug)} revision={revision} services={scoped} onUsed={onUsed} onManagement={setReceipt} /> + } + {onInstall && } + {receipt && container && { if (!open) setReceipt(undefined); }}> + {receipt.action === 'enable' ? '启用技能' : receipt.action === 'disable' ? '停用技能' : receipt.action === 'upgrade' ? '更新技能' : '卸载技能'} + 核对安装位置后确认操作 + + setReceipt(undefined)} /> + } +
; +} +export function ManagementLocations({ items, revision, services, onUsed, onManagement }: { + items: Item[]; revision: string; services: ManagementViewServices; onUsed?(): void; onManagement?(result: ConversationManagementResult): void; +}) { + return <>{!items.length &&

该技能暂无安装记录。

}{items.map(item => { + const label = managementTargetLabel(items, item).replace('当前用户', '所有项目').replace('当前项目', '仅此项目'); + return
+

{label}

v{item.version}{item.state === 'present' ? item.installationState === 'disabled' ? '已停用' : '已启用' : '安装待核验'}
+ +
; + })}; +} const RefreshContext = createContext<{ pending: boolean; failed: boolean; refresh(): void } | undefined>(undefined); /** A refresh replaces the tool body; it never nests current records under a historical installation. */ function ManagementRefreshBoundary({ services, previousRevision, children }: { services: ManagementViewServices; previousRevision?: string; children: ReactNode }) { @@ -190,11 +263,13 @@ function RefreshInstalled() { function Identity({ item, compact = false, targetLabel }: { item: Item; compact?: boolean; targetLabel?: string }) { return
@{item.namespace}/{item.slug}{!compact && v{item.version}}{targetLabel ?? scopeLabel(item.scope)}
; } -function ManagementRow({ item, revision, services, initialCheck, checkOnly = false, grouped = false, targetLabel, onPendingMutation, onManagement }: { - item: Item; revision: string; services: ManagementViewServices; initialCheck?: InstalledUpdateCheck; checkOnly?: boolean; grouped?: boolean; targetLabel?: string; - onPendingMutation?(pending: boolean): void; onManagement?(result: ConversationManagementResult): void; +function ManagementRow({ item, revision, services, initialCheck, checkOnly = false, grouped = false, expanded = false, targetLabel, onPendingMutation, onManagement, onUsed }: { + item: Item; revision: string; services: ManagementViewServices; initialCheck?: InstalledUpdateCheck; checkOnly?: boolean; grouped?: boolean; expanded?: boolean; targetLabel?: string; + onUsed?(): void; onPendingMutation?(pending: boolean): void; onManagement?(result: ConversationManagementResult): void; }) { const [check, setCheck] = useState(initialCheck); + const notify = useManagementNotice(); + const noticeTitle = `${targetLabel ?? scopeLabel(item.scope)} · v${item.version}`; const availabilityId = useId(); const availability = useQuery({ queryKey: ['skillhub-availability', services.installation.sessionId, revision, item.itemKey, availabilityId], enabled: false, retry: false, gcTime: 0, queryFn: async ({ signal }) => { @@ -204,6 +279,12 @@ function ManagementRow({ item, revision, services, initialCheck, checkOnly = fal services.assertCurrent(); signal.throwIfAborted(); return value; } }); + useEffect(() => { + if (!expanded || !notify) return; + if (availability.isFetching) { notify(undefined); return; } + if (availability.isError) notify({ title: noticeTitle, content:

{stale(availability.error) ? '安装记录已变化,请重新查询。' : '可用性暂时无法核验,请重试。'}

}); + else if (availability.data) { const summary = availabilityNotice(availability.data); notify({ title: noticeTitle, success: summary.success, content:

{summary.message}

}); } + }, [expanded, notify, noticeTitle, availability.dataUpdatedAt, availability.errorUpdatedAt, availability.isFetching, availability.isError, availability.error, availability.data]); const [moreOpen, setMoreOpen] = useState(false); const [useError, setUseError] = useState(''); const moreTrigger = useRef(null); @@ -213,50 +294,72 @@ function ManagementRow({ item, revision, services, initialCheck, checkOnly = fal const [management, setManagement] = useState(); const request = useRef<{ action: string; version: string | null; key: string }>(); const skillUse = useMemo(() => verifiedManagementUse(services, revision, item.itemKey, setError), [services, revision, item.itemKey]); - async function run(action: 'check' | 'upgrade' | 'uninstall') { + async function run(action: 'check' | 'upgrade' | 'uninstall' | 'enable' | 'disable') { if (lock.current || error) return; + if (expanded) notify?.(undefined); lock.current = true; setPending(true); if (action !== 'check') onPendingMutation?.(true); try { const signal = new AbortController().signal; await verifyManagementReference(services, revision, item.itemKey, signal); - if (action === 'check') setCheck(read(await services.check(revision, item.itemKey, signal))); + if (action === 'check') { + const result = read(await services.check(revision, item.itemKey, signal)); setCheck(result); + if (expanded && notify && ['current', 'unavailable'].includes(result.state)) notify({ title: noticeTitle, success: result.state === 'current', content:

{result.state === 'current' ? '已是最新版本' : '更新检查未完成,请重试。'}

}); + } else { const version = action === 'upgrade' ? check?.candidateVersion ?? null : null; if (!request.current || request.current.action !== action || request.current.version !== version) request.current = { action, version, key: crypto.randomUUID() }; const result = read(await services.begin(revision, item.itemKey, action, version, request.current.key, signal)); + if (result.status === 'cancelled') { request.current = undefined; return; } if (onManagement) onManagement(result); else setManagement(result); } - } catch (failure) { setError(failureMessage(failure)); } + } catch (failure) { if (action === 'check' && expanded && notify && !stale(failure)) notify({ title: noticeTitle, content:

更新检查未完成,请重试。

}); else setError(failureMessage(failure)); } finally { lock.current = false; setPending(false); if (action !== 'check') onPendingMutation?.(false); } } if (management) return { request.current = undefined; setManagement(undefined); }} />; return
{ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setMoreOpen(false); }} - onKeyDown={event => { if (event.key === 'Escape' && moreOpen) { event.preventDefault(); setMoreOpen(false); moreTrigger.current?.focus(); } }}> - {grouped ? {targetLabel && `${targetLabel} · `}{check?.state !== 'upgradable' && `v${item.version}`} : } + onBlur={event => { if (!expanded && !event.currentTarget.contains(event.relatedTarget as Node | null)) setMoreOpen(false); }} + onKeyDown={event => { if (!expanded && event.key === 'Escape' && moreOpen) { event.preventDefault(); setMoreOpen(false); moreTrigger.current?.focus(); } }}> + {!expanded && (grouped ? {targetLabel && `${targetLabel} · `}{check?.state !== 'upgradable' && `v${item.version}`} : )} {error ? <>

{error}

: <> + {!expanded && item.installationState === 'disabled' && 已停用} {item.state !== 'present' &&

{item.state === 'missing' ? '安装文件缺失' : '安装记录待核验'}

} - {check &&

{check.state === 'upgradable' ? `v${check.currentVersion} → v${check.candidateVersion}` : check.state === 'current' ? '已是最新版本' : check.state === 'unavailable' ? '更新检查未完成,请重试。' : managementReason(check.reason)}

} + {check && (!expanded || !notify || ['upgradable', 'blocked'].includes(check.state)) &&

{check.state === 'upgradable' ? `v${check.currentVersion} → v${check.candidateVersion}` : check.state === 'current' ? '已是最新版本' : check.state === 'unavailable' ? '更新检查未完成,请重试。' : managementReason(check.reason)}

} {checkOnly && check?.state === 'blocked' && ['local-record-unverified', 'record-changed', 'metadata-invalid'].includes(check.reason) && } {!checkOnly && item.state !== 'present' && item.state !== 'missing' && }
{check?.state === 'upgradable' && } - {check?.state === 'unavailable' && } - {!checkOnly && !check && item.state === 'present' && } - {!checkOnly && (services.availability || (item.state === 'present' && (!check || check.state === 'upgradable'))) && } + {!expanded && check?.state === 'unavailable' && } + {!checkOnly && item.state === 'present' && item.installationState === 'disabled' && } + {!expanded && !checkOnly && !check && item.state === 'present' && item.installationState !== 'disabled' && } + {expanded && !checkOnly && ( + + + + + {item.state === 'present' && item.installationState !== 'disabled' && 使用此位置} + {services.availability && { void availability.refetch(); }}>检查可用性} + {item.state === 'present' && { void run('check'); }}>检查更新} + {item.state === 'present' && item.installationState !== 'disabled' && { void run('disable'); }}>停用} + {item.state === 'present' && { void run('uninstall'); }}>卸载} + + + + )} />} + {!expanded && !checkOnly && (services.availability || item.state === 'present') && } {!checkOnly && item.state === 'missing' && }
{useError &&

{useError}

} - {availability.isFetching ?

正在检查可用性…

: availability.isError ? <>

{stale(availability.error) ? '安装记录已变化,请重新查询。' : '可用性暂时无法核验。'}

{stale(availability.error) && } : availability.data && } - {moreOpen &&
+ {(!expanded || !notify || (availability.isError && stale(availability.error))) && (availability.isFetching ?

正在检查可用性…

: availability.isError ? <>

{stale(availability.error) ? '安装记录已变化,请重新查询。' : '可用性暂时无法核验。'}

{stale(availability.error) && } : availability.data && )} + {!expanded && moreOpen &&
{services.availability && } {!check && item.state === 'present' && } + {item.state === 'present' && item.installationState !== 'disabled' && } {item.state === 'present' && }
} }
; } -export function ManagementConfirmation({ initial, services, onCancelled, targetLabel }: { initial: ManagementReceipt; services: ManagementViewServices; onCancelled?(): void; targetLabel?: string }) { +export function ManagementConfirmation({ initial, services, onCancelled, onCompleted, targetLabel, presentation = 'card', title }: { presentation?: 'card' | 'dialog'; title?: string; initial: ManagementReceipt; services: ManagementViewServices; onCancelled?(): void; onCompleted?(): void; targetLabel?: string }) { const [result, setResult] = useState(initial); const [pending, setPending] = useState(false); const lock = useRef(false); @@ -271,16 +374,18 @@ export function ManagementConfirmation({ initial, services, onCancelled, targetL catch (failure) { setExpired(stale(failure)); setError(stale(failure) ? failureMessage(failure) : '确认结果暂时无法读取,请查询原请求。'); } finally { lock.current = false; setPending(false); } } - const verb = result.action === 'upgrade' ? '更新' : result.item.state === 'missing' ? '清理记录' : '卸载'; + const toggle = result.action === 'enable' || result.action === 'disable'; + const verb = result.action === 'enable' ? '启用' : result.action === 'disable' ? '停用' : result.action === 'upgrade' ? '更新' : result.item.state === 'missing' ? '清理记录' : '卸载'; const completed = operation.data?.phase === 'completed'; + useEffect(() => { if (completed && toggle && operation.data?.warningCount === 0 && !operation.data.resultDetails?.warnings.length && !['incomplete', 'unknown'].includes(operation.data.recovery)) onCompleted?.(); }, [completed, toggle, onCompleted, operation.data]); const warned = (operation.data?.warningCount ?? 0) > 0 || !!operation.data?.resultDetails?.warnings.length; - const discoveryPending = completed && result.action === 'upgrade' && operation.data?.discovery !== 'confirmed'; const recoveryPending = !!operation.data && ['incomplete', 'unknown'].includes(operation.data.recovery); - return
-

v{result.item.version}{result.action === 'upgrade' && result.candidateVersion ? ` → v${result.candidateVersion}` : ''}

+ return
{presentation === 'dialog' && toggle ?
{title || result.item.slug}@{result.item.namespace}/{result.item.slug} · v{result.item.version}{result.item.scope === 'user' ? '所有项目 · 当前用户' : '仅此项目'}
: } + {!(presentation === 'dialog' && toggle) &&

v{result.item.version}{result.action === 'upgrade' && result.candidateVersion ? ` → v${result.candidateVersion}` : ''}

} {expired ? <>

{error}

: <> - {result.status === 'awaiting-confirmation' && (error ? <>

{error}

: <>

确认{verb}这份安装?{result.item.scope === 'user' ? '影响当前用户的多个项目。' : '仅影响当前项目。'}

-
)} + {toggle && result.status === 'awaiting-confirmation' &&

{result.action === 'disable' ? '仅停用此位置,不撤回已加载内容;其他同名安装可能仍可用。' : '仅恢复此位置,保留原有调用权限。'}

} + {result.status === 'awaiting-confirmation' && (error ? <>

{error}

: <>{!(presentation === 'dialog' && toggle) &&

确认{verb}这份安装?{result.item.scope === 'user' ? '影响当前用户的多个项目。' : '仅影响当前项目。'}

} +
)} {result.status === 'cancelled' && <>

已取消,未执行{verb}。

} {result.status === 'blocked' && <>

{managementReason(result.reason)}

{['record-changed', 'local-record-unverified', 'metadata-invalid'].includes(result.reason) && }} {result.status === 'accepted' &&
@@ -288,8 +393,8 @@ export function ManagementConfirmation({ initial, services, onCancelled, targetL : completed ?

{verb}已完成{warned ? ',有告警需核验。' : '。'}

: operation.data?.phase === 'failed' ?

{verb}未完成,请核验当前安装。

: operation.data && ['unknown', 'recovery-required'].includes(operation.data.phase) ?

结果待核验,请勿重复操作。

:

正在{verb}…

} - {discoveryPending &&

文件已更新,DSH 发现尚待确认。

}{recoveryPending &&

恢复状态待核验。

} - {(warned || discoveryPending || recoveryPending || operation.isError || operation.data === null || (!!operation.data && ['failed', 'unknown', 'recovery-required'].includes(operation.data.phase))) && } + {recoveryPending &&

恢复状态待核验。

} + {(warned || recoveryPending || operation.isError || operation.data === null || (!!operation.data && ['failed', 'unknown', 'recovery-required'].includes(operation.data.phase))) && }
} }
; diff --git a/plugins/dsh/src/client/market-card-actions.tsx b/plugins/dsh/src/client/market-card-actions.tsx index 2ee2fd00b..26f5daf86 100644 --- a/plugins/dsh/src/client/market-card-actions.tsx +++ b/plugins/dsh/src/client/market-card-actions.tsx @@ -1,33 +1,12 @@ -import { useState } from 'react'; import type { InstalledSkillList } from '../host/types.js'; import type { InstalledServices } from './installed-skills.js'; -import { useInstalledUpdate } from './installed-update.js'; -import { Button, SelectField } from './ui.js'; +import { SkillPrimaryActions } from './skill-primary-actions.js'; +import { Button } from './ui.js'; -type InstalledItem = InstalledSkillList['items'][number]; -export function MarketCardActions({ records, services, sourceRevision, verified, onInstall, onManage }: { - records: InstalledItem[]; services?: InstalledServices; sourceRevision: string; verified: boolean; - onInstall(): void; onManage(item: InstalledItem, intent: 'remove' | 'upgrade'): void; +export function MarketCardActions({ records, services, sourceRevision, verified, onInstall, onManage, label }: { + records: InstalledSkillList['items']; services?: InstalledServices; sourceRevision: string; verified: boolean; + onInstall(): void; onManage?(): void; label?: string; }) { - if (!records.length) return ; - return services && ; -} -function ManagedActions({ records, services, sourceRevision, verified, onManage }: { - records: InstalledItem[]; services: InstalledServices; sourceRevision: string; verified: boolean; - onManage(item: InstalledItem, intent: 'remove' | 'upgrade'): void; -}) { - const [scope, setScope] = useState('choose'); - const item = records.length === 1 ? records[0] : records.find(record => record.scope === scope); - const update = useInstalledUpdate(services, sourceRevision, item?.itemKey); - const removable = verified && !!item && ['present', 'missing'].includes(item.state); - const upgradable = verified && item?.state === 'present' && !update.isFetching && !update.isError && update.data?.state === 'upgradable'; - return <> - {records.length > 1 && ({ value: record.scope, label: record.scope === 'user' ? '所有项目' : '仅此项目' }))]} />} - {upgradable ? - : } - - ; + if (!records.length) return ; + return ; } diff --git a/plugins/dsh/src/client/native-control-styles.tsx b/plugins/dsh/src/client/native-control-styles.tsx index 329915d4a..83fceae29 100644 --- a/plugins/dsh/src/client/native-control-styles.tsx +++ b/plugins/dsh/src/client/native-control-styles.tsx @@ -1,6 +1,12 @@ /** Scoped shadcn component styles; never reset the DSH document or other plugins. */ export function NativeControlStyles() { return ; } diff --git a/plugins/dsh/src/client/operation-details.tsx b/plugins/dsh/src/client/operation-details.tsx index ac7318825..bc17eb8e4 100644 --- a/plugins/dsh/src/client/operation-details.tsx +++ b/plugins/dsh/src/client/operation-details.tsx @@ -1,6 +1,6 @@ import type { NativeOperation } from '../host/types.js'; -const outcomes = { installed: '安装完成', upgraded: '升级完成', removed: '已移除', +const outcomes = { enabled: '已启用', disabled: '已停用', installed: '安装完成', upgraded: '升级完成', removed: '已移除', 'stale-record-removed': '失效记录已清理', restored: '已恢复', unknown: '结果待核验' }; const warnings = { 'backup-cleanup': '备份清理需要核验。', 'removal-cleanup': '卸载隔离文件清理需要核验。', 'verification-required': '操作日志含有需要进一步核验的告警。' }; diff --git a/plugins/dsh/src/client/skill-primary-actions.tsx b/plugins/dsh/src/client/skill-primary-actions.tsx new file mode 100644 index 000000000..fce2b4b32 --- /dev/null +++ b/plugins/dsh/src/client/skill-primary-actions.tsx @@ -0,0 +1,40 @@ +import type { InstalledSkillList, SkillUseSelection } from '../host/types.js'; +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'; +import { UseSkillButton, type SkillUseServices } from './use-skill.js'; +import { Button } from './ui.js'; + +type Item = InstalledSkillList['items'][number]; +export type SelectSkillUse = (revision: string, namespace: string, slug: string, signal: AbortSignal) => Promise>; +const reasons: Record['reason'], string> = { + 'no-installation': '安装记录已变化,请查看管理安装。', + 'no-usable-installation': '当前没有可用安装。', + 'selection-unverified': '当前可用安装尚未确认。', + 'multiple-usable-installations': '存在多份可用安装,请管理安装。', + 'selection-timeout': '核验超时,请重试或管理安装。', +}; +export async function selectSkillItem(select: SelectSkillUse, revision: string, namespace: string, slug: string, signal: AbortSignal): Promise { + const response = await select(revision, namespace, slug, signal); + signal.throwIfAborted(); + if (!response.ok) throw new Error('会话或安装状态已变化,请重新查询。'); + if (response.value.status === 'blocked') throw new Error(reasons[response.value.reason]); + return response.value.itemKey; +} +/** A local summary is not a claim that DSH can call any particular installation. */ +export function skillInstallationSummary(items: Item[]): string { + if (!items.length) return '未安装'; + if (items.some(item => item.state !== 'present')) return '安装待核验'; + if (items.every(item => item.installationState === 'disabled')) return '已停用'; + return '已安装'; +} +export function SkillPrimaryActions({ items, revision, verified, select, useSkill, onManage, label = '使用技能', summaryVisible = false, onUsed }: { + items: Item[]; revision: string; verified: boolean; select?: SelectSkillUse; useSkill?: SkillUseServices; + onManage?(): void; label?: string; summaryVisible?: boolean; onUsed?(): void; +}) { + const identity = items[0]; + const potentiallyUsable = verified && items.some(item => item.state === 'present' && item.installationState !== 'disabled'); + return
+ {potentiallyUsable && identity && select ? selectSkillItem(select, revision, identity.namespace, identity.slug, signal)} /> : (!summaryVisible || !verified || !select) && {verified ? skillInstallationSummary(items) === '已停用' ? '技能已停用' : '当前安装待核验' : '安装状态待核验'}} + {onManage && } +
; +} diff --git a/plugins/dsh/src/client/skill-website-link.tsx b/plugins/dsh/src/client/skill-website-link.tsx new file mode 100644 index 000000000..e6697d25b --- /dev/null +++ b/plugins/dsh/src/client/skill-website-link.tsx @@ -0,0 +1,20 @@ +import { Button } from './ui.js'; +import { Icon } from './skill-card.js'; + +/** Use only the API's complete public page URL; never append local configuration or credentials. */ +export function safeSkillWebUrl(value: string | null | undefined): string | undefined { + if (!value) return undefined; + const text = value.trim(); + // eslint-disable-next-line no-control-regex -- Reject characters URL parsing would silently normalize. + if (!/^https?:\/\//iu.test(text) || /[\\\u0000-\u001f\u007f]/u.test(value)) return undefined; + try { + const url = new URL(text); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return undefined; + return url.href; + } catch { return undefined; } +} + +export function SkillWebsiteLink({ webUrl, enabled }: { webUrl?: string | null; enabled: boolean }) { + const href = enabled ? safeSkillWebUrl(webUrl) : undefined; + return href ? : null; +} diff --git a/plugins/dsh/src/client/toggle-control.tsx b/plugins/dsh/src/client/toggle-control.tsx new file mode 100644 index 000000000..fafadf3ef --- /dev/null +++ b/plugins/dsh/src/client/toggle-control.tsx @@ -0,0 +1,61 @@ +import { useCallback, useRef, useState } from 'react'; +import type { ConversationManagementResult, InstalledSkillList } from '../host/types.js'; +import { ManagementConfirmation, refreshManagementLocation, verifyManagementReference, type ManagementViewServices } from './management-tool-view.js'; +import { Button, Dialog, DialogContent, DialogTitle, DialogDescription } from './ui.js'; + +type Item = InstalledSkillList['items'][number]; +/** Both page and conversation actions use the same native confirmation and exact Host plan. */ +export function ToggleControl({ services, revision, item, onRefreshed, eligible = true, presentation = 'menu', title }: { + services?: ManagementViewServices; revision: string; item: Item; eligible?: boolean; presentation?: 'menu' | 'button'; title?: string; onRefreshed?(item: Item): void; +}) { + const [open, setOpen] = useState(false); + const [more, setMore] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(''); + const [receipt, setReceipt] = useState(); + const [container, setContainer] = useState(null); + const request = useRef(); + const lock = useRef(false); + const trigger = useRef(null); + const refreshed = useRef(false); + const refresh = useCallback(() => { + if (!services || !receipt || refreshed.current) return; + refreshed.current = true; + void refreshManagementLocation(services, revision, receipt.item).then(list => { + onRefreshed?.(list.items.find(candidate => candidate.locationKey === receipt.item.locationKey)!); + setReceipt(undefined); setOpen(false); request.current = undefined; refreshed.current = false; + }).catch(() => { refreshed.current = false; setError('状态已保存,重新读取失败,请刷新安装记录。'); }); + }, [services, revision, receipt, onRefreshed]); + if (!services || item.state !== 'present') return null; + const disabled = item.installationState === 'disabled'; + async function begin() { + if (!services || !eligible || lock.current || receipt || error) return; + lock.current = true; setPending(true); setMore(false); setOpen(true); + try { + await verifyManagementReference(services, revision, item.itemKey, new AbortController().signal); + request.current ??= crypto.randomUUID(); + const response = await services.begin(revision, item.itemKey, disabled ? 'enable' : 'disable', null, request.current, new AbortController().signal); + services.assertCurrent(); + if (!response.ok) throw new Error('安装记录已变化或请求未确认,请重新查询;不要重复操作。'); + if (response.value.status === 'cancelled') { request.current = undefined; setOpen(false); } + else setReceipt(response.value); + } catch { setError('安装记录已变化或请求未确认,请重新查询;不要重复操作。'); } + finally { lock.current = false; setPending(false); } + } + return
{ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setMore(false); }} + onKeyDown={event => { if (event.key === 'Escape' && more) { event.preventDefault(); setMore(false); trigger.current?.focus(); } }}> + {receipt || error ? + : disabled ? + : presentation === 'button' ? + : <> + {more && }} + {container && { if (!pending) setOpen(value); }}> + {receipt?.action === 'enable' || (!receipt && disabled) ? '启用技能' : '停用技能'} + 核对安装位置后确认操作 + {pending &&

正在核验安装状态…

} + {error &&

{error}

} + {receipt && { setReceipt(undefined); request.current = undefined; setOpen(false); }} />} +
} +
; +} diff --git a/plugins/dsh/src/client/ui.tsx b/plugins/dsh/src/client/ui.tsx index d25d21f70..13a3e736c 100644 --- a/plugins/dsh/src/client/ui.tsx +++ b/plugins/dsh/src/client/ui.tsx @@ -60,8 +60,19 @@ export const TabsContent = TabsPrimitive.Content; export const Dialog = DialogPrimitive.Root; export const DialogTitle = DialogPrimitive.Title; export const DialogDescription = DialogPrimitive.Description; +/** Keep modal layers outside card action stacking contexts, but inside an owning native dialog's top layer. */ export function DialogContent({ container, children, ...props }: React.ComponentPropsWithoutRef & { container: HTMLElement | null }) { - return + const [layer, setLayer] = React.useState(null); + React.useLayoutEffect(() => { + if (!container) return; + const node = document.createElement('div'); + node.className = 'skillhub-controls astron-sh-modal-layer'; + (container.closest('dialog') ?? document.body).append(node); + setLayer(node); + return () => { node.remove(); }; + }, [container]); + if (!layer) return null; + return {children} ; @@ -69,14 +80,14 @@ export function DialogContent({ container, children, ...props }: React.Component /** shadcn/ui Select (MIT), New York registry select.json; scoped CSS replaces utilities. */ export const SelectField = React.forwardRef(({ label, value, onValueChange, disabled, options }, ref) => { +}>(({ label, value, onValueChange, disabled, options, placeholder }, ref) => { const [container, setContainer] = React.useState(null); return - + diff --git a/plugins/dsh/src/client/use-skill.tsx b/plugins/dsh/src/client/use-skill.tsx index 95e4b4c5d..59b80b15e 100644 --- a/plugins/dsh/src/client/use-skill.tsx +++ b/plugins/dsh/src/client/use-skill.tsx @@ -58,6 +58,7 @@ const blocked: Record['reaso 'installation-unverified': '安装记录已变化,请刷新已安装列表。', 'operation-pending': '此技能仍有操作进行中,请完成后重试。', 'shared-installation': '此安装暂不支持直接使用,请在 DSH 中选择技能。', + 'installation-disabled': '该安装已停用,请先启用。', 'files-changed': '本地技能文件已变化,请先核对安装。', 'skill-unavailable': '当前会话尚未发现此技能,请稍后重试。', 'skill-shadowed': '当前会话实际使用的是另一份同名技能,无法直接使用所选安装。请检查安装位置,并选择当前会话生效的那份技能。', @@ -274,7 +275,8 @@ export function SkillUseFocus({ controller, sessionId }: { controller: SkillUseC export function exactInstalledSkill(inventory: InstalledSkillList, identity: Pick): Item | undefined { if (!inventory.complete || inventory.unavailable.includes(identity.scope)) return; - return inventory.items.find(item => item.namespace === identity.namespace && item.slug === identity.slug && item.version === identity.version && item.scope === identity.scope && item.state === 'present'); + const matches = inventory.items.filter(item => item.namespace === identity.namespace && item.slug === identity.slug && item.version === identity.version && item.scope === identity.scope); + return matches.length === 1 && matches[0]?.state === 'present' && matches[0].installationState !== 'disabled' ? matches[0] : undefined; } /** Request ownership survives renders but is revoked when the entry's target changes. */ @@ -319,8 +321,10 @@ export function SkillCaseChoices({ controller, sessionId }: { controller: SkillU ; } -export function UseSkillButton({ services, revision, itemKey, resolve, onUsed, onHandoff, onError }: { - services?: SkillUseServices; revision: string; itemKey?: string; +export function UseSkillButton({ services, revision, itemKey, resolve, onUsed, onHandoff, onError, renderAction, label = '使用此技能' }: { + services?: SkillUseServices; revision: string; itemKey?: string; label?: string; + /** Keep the request owner mounted while a transient menu action closes. */ + renderAction?(action: { disabled: boolean; run(): void }): React.ReactNode; resolve?(signal: AbortSignal): Promise; onUsed?(): void; onHandoff?(): void; onError?(message: string): void; }) { const ref = useRef(); @@ -329,7 +333,9 @@ export function UseSkillButton({ services, revision, itemKey, resolve, onUsed, o const { busy, error } = useSyncExternalStore(requests.subscribe, requests.getSnapshot, requests.getSnapshot); useLayoutEffect(() => { requests.reset(); return () => requests.reset(); }, [requests, services, revision, itemKey]); useEffect(() => { onError?.(error); }, [error, onError]); - return {error && !onError && {error}}; + }; + if (renderAction) return <>{renderAction({ disabled: busy || !services, run })}{error && !onError && {error}}; + return {error && !onError && {error}}; } diff --git a/plugins/dsh/src/host/index.ts b/plugins/dsh/src/host/index.ts index ab2ac7c8b..bd937db7a 100644 --- a/plugins/dsh/src/host/index.ts +++ b/plugins/dsh/src/host/index.ts @@ -26,12 +26,13 @@ import { LocalRuntime } from './local-runtime.js'; import { assertRuntimeCompatibility } from './runtime-compatibility.js'; import type { HostCompatibility } from './types.js'; import { InstallationTargets } from './installation-targets.js'; -import { UpgradePlans, RemovalPlans } from './upgrade-plans.js'; -import type { UpgradePlanView, UninstallPlanView, HostExecutePlanRequest, HostRecoveryRequest } from './types.js'; +import { UpgradePlans, RemovalPlans, TogglePlans } from './upgrade-plans.js'; +import type { UpgradePlanView, UninstallPlanView, TogglePlanView, HostExecutePlanRequest, HostRecoveryRequest } from './types.js'; import type { ResolvedInstallationTarget } from './installation-targets.js'; import { assertInstallationMutation, assertRemovalMutation, resolveInstallationPath, resolveInstalledPath } from './installation-paths.js'; import { inspectDshSkill, observeDshInstallation, sameDshSkill } from './skill-discovery.js'; -import { inspectSkillAvailability } from './skill-availability.js'; +import { inspectSkillAvailability, inspectDisabledSkillAvailability } from './skill-availability.js'; +import { selectUsableInstallation } from './skill-use-selection.js'; import type { SkillAvailability } from './types.js'; import { validateDshSkill } from './skill-format.js'; import { installRequest, operationQuery, executePlanRequest } from './install-request.js'; @@ -42,7 +43,7 @@ import type { NativeOperation, HostInstallRequest } from './types.js'; import type { NativeRecoveryResult } from './generated/native-contract.js'; import type { InstallationTargetList } from './types.js'; import type { LocalRuntimeStatus } from './local-runtime.js'; -import type { InstalledSkillList, InstalledSkillDetail, InstalledUpdateCheck, SkillUsePreparation } from './types.js'; +import type { InstalledSkillList, InstalledSkillDetail, InstalledUpdateCheck, SkillUsePreparation, SkillUseSelection } from './types.js'; import { installationFailureCode } from './installation-failure.js'; import type { InstalledProjectionItem } from './generated/native-contract.js'; @@ -67,6 +68,7 @@ export class SkillHubService extends TypertRemoteService { private readonly installationTargets = new InstallationTargets(); private readonly upgradePlans = new UpgradePlans(); private readonly removalPlans = new RemovalPlans(); + private readonly togglePlans = new TogglePlans(); private readonly installedReferenceKey = randomBytes(32); private readonly capabilities = new Map(); private readonly details = new Map { this.installationTargets.clear(); this.upgradePlans.clear(); - this.removalPlans.clear(); + this.removalPlans.clear(); this.togglePlans.clear(); if (this.localRuntime === runtime) this.localRuntime = undefined; await runtime.dispose(); }; @@ -103,7 +105,7 @@ export class SkillHubService extends TypertRemoteService { ctx.effect(() => registerSearchTool(ctx.tools, (query, context, signal) => this.searchConversation(query, context, signal))); ctx.effect(() => registerInstallTool(ctx.tools, (request, context, signal) => this.installConversationWithChoice(request, context, signal))); }); - ctx.effect(() => () => { this.lifetime.abort(); this.pendingConversationInstalls.clear(); this.pendingConversationChoices.clear(); this.managementRequests.clear(); this.managementConfirmations.clear(); this.details.clear(); this.capabilities.clear(); this.selections.clear(); this.installationTargets.clear(); this.upgradePlans.clear(); this.removalPlans.clear(); }); + ctx.effect(() => () => { this.lifetime.abort(); this.pendingConversationInstalls.clear(); this.pendingConversationChoices.clear(); this.managementRequests.clear(); this.managementConfirmations.clear(); this.details.clear(); this.capabilities.clear(); this.selections.clear(); this.installationTargets.clear(); this.upgradePlans.clear(); this.removalPlans.clear(); this.togglePlans.clear(); }); } /** Internal diagnostics, deliberately not a Remote method. */ @@ -126,7 +128,7 @@ export class SkillHubService extends TypertRemoteService { this.installationTargets.clear(); this.upgradePlans.clear(); - this.removalPlans.clear(); + this.removalPlans.clear(); this.togglePlans.clear(); this.capabilities.clear(); this.registry = registry; this.sourceInput = source; @@ -255,7 +257,7 @@ export class SkillHubService extends TypertRemoteService { if (this.details.size >= 100) this.details.delete(this.details.keys().next().value!); this.details.set(detailToken, { sourceRevision, namespace, slug, version: resolved.version, skillId: metadata.id, versionId: resolved.versionId, fingerprint: resolved.fingerprint }); - return { detailToken, title: metadata.displayName?.trim() || slug, summary: metadata.summary ?? '', + return { detailToken, webUrl: metadata.webUrl, title: metadata.displayName?.trim() || slug, summary: metadata.summary ?? '', downloadCount: metadata.downloadCount, starCount: metadata.starCount, ratingAvg: metadata.ratingCount === 0 ? undefined : metadata.ratingAvg, ratingCount: metadata.ratingCount, namespace, slug, version: resolved.version, versionId: resolved.versionId, documentation, @@ -329,7 +331,8 @@ export class SkillHubService extends TypertRemoteService { const unavailable = targets.filter(entry => !entry.target).map(entry => entry.scope); const items = result.items.map(item => { const itemKey = this.installedItemKey(agent.session.id, cwd, sourceRevision, item); - return { itemKey, namespace: item.namespace, slug: item.slug, version: item.version, scope: item.scope, state: item.state }; + const locationKey = createHmac('sha256', this.installedReferenceKey).update(JSON.stringify([agent.session.id, cwd, sourceRevision, item.namespace, item.slug, item.scope, item.targetPath])).digest('base64url'); + return { itemKey, locationKey, installationState: item.installationState ?? 'enabled', namespace: item.namespace, slug: item.slug, version: item.version, scope: item.scope, state: item.state }; }); return { items, complete: result.complete, issues: result.issues, unavailable }; } @@ -361,6 +364,26 @@ export class SkillHubService extends TypertRemoteService { } }; valid(); + if (selected?.item.installationState === 'disabled') { + const target = async () => { + const resolved = await resolveInstalledPath({ scope: selected.item.scope, cwd: cwd!, fs: fs!, signal: combined }); + valid(); + if (join(resolved.root, selected.item.slug) !== selected.item.targetPath) throw new RemoteError('skillhub/context-stale', 'Skill root changed.', { retryable: false }); + return resolved; + }; + const identity = { registry: this.getContext().registryUrl, namespace: selected.item.namespace, + slug: selected.item.slug, version: selected.item.version }; + const options = { scope: agent!, cwd: cwd!, signal: combined }; + const diagnosis = await inspectDisabledSkillAvailability({ directory: selected.item.targetPath, + fingerprint: selected.item.fingerprint, signal: combined, valid, + baseline: async () => selected.runtime!.inspectRemoval(identity, await target()), + entry: async () => selected.runtime!.readInstallationEntry(identity, await target()), + reader: skills ? { get: (name, signal = combined) => skills.get(name, { ...options, signal }), + ...(typeof skills.snapshot === 'function' ? { snapshot: (signal?: AbortSignal) => skills.snapshot({ ...options, signal: signal ?? combined }) } : {}) } : undefined }); + valid(); + return { ...diagnosis, scope: selected.item.scope, installation: { namespace: selected.item.namespace, + slug: selected.item.slug, version: selected.item.version, scope: selected.item.scope } }; + } const options = { scope: agent!, cwd: cwd!, signal: combined }; const diagnosis = await inspectSkillAvailability({ ctx: agent!.ctx, signal: combined, valid, name: 'name' in parsed ? parsed.name : undefined, @@ -375,10 +398,55 @@ export class SkillHubService extends TypertRemoteService { namespace: selected.item.namespace, slug: selected.item.slug, version: selected.item.version }, target); } } : undefined }); valid(); - return { ...diagnosis, ...(selected ? { scope: selected.item.scope, installation: { namespace: selected.item.namespace, + return { ...diagnosis, ...(selected ? { installationState: selected.item.installationState ?? 'enabled' } : {}), ...(selected ? { scope: selected.item.scope, installation: { namespace: selected.item.namespace, slug: selected.item.slug, version: selected.item.version, scope: selected.item.scope } } : {}) }; } + /** Resolve coordinates through the current DSH winner; no scope/version preference or write. */ + @RemoteScope('agent') + async selectSkillUse(sourceRevision: string, namespace: string, slug: string, signal: AbortSignal): Promise { + const id = this.ctx.invocation?.request.args.agentId; + const agent = typeof id === 'string' ? this.ctx.get('agents')?.get(SessionId(id)) : undefined; + const sessionId = agent?.session.id; + const cwd = agent?.session.header.cwd; + const fs = agent?.ctx.get('fs'); + const skills = this.ctx.get('skills'); + const runtime = this.localRuntime; + const combined = AbortSignal.any([signal, this.lifetime.signal]); + const valid = () => { + combined.throwIfAborted(); this.assertSource(sourceRevision); + if (!agent || !sessionId || !cwd || agent.session.id !== sessionId || agent.session.header.cwd !== cwd + || this.ctx.get('agents')?.get(SessionId(sessionId)) !== agent || this.localRuntime !== runtime + || serviceIdentity(agent.ctx.get('fs')) !== serviceIdentity(fs) + || serviceIdentity(this.ctx.get('skills')) !== serviceIdentity(skills)) { + throw new RemoteError('skillhub/context-stale', 'Session changed.', { retryable: false }); + } + }; + valid(); + if (!namespace || !slug || namespace.length > 256 || slug.length > 256 || !skills?.snapshot) { + return { status: 'blocked', reason: 'selection-unverified' }; + } + return selectUsableInstallation({ namespace, slug, signal: combined, valid, + list: async signal => { + const snapshot = await skills.snapshot!({ scope: agent!, cwd: cwd!, signal }); + valid(); signal.throwIfAborted(); + const listed = await this.listInstalled(sourceRevision, signal); + return { ...listed, complete: listed.complete && snapshot.complete }; + }, + prepare: async (itemKey, signal) => { + const result = await this.prepareSkillUse(sourceRevision, itemKey, signal); + valid(); signal.throwIfAborted(); + if (result.status === 'ready') { + const snapshot = await skills.snapshot!({ scope: agent!, cwd: cwd!, signal }); + valid(); signal.throwIfAborted(); + if (!snapshot.complete || !snapshot.skills.some(skill => skill.name === result.name)) { + return { status: 'blocked', reason: 'skill-unavailable' }; + } + } + return result; + } }); + } + /** Read-only handoff: installed snapshots and market names never authorize an invocation. */ @RemoteScope('agent') async prepareSkillUse(sourceRevision: string, itemKey: string, signal: AbortSignal): Promise { @@ -393,6 +461,7 @@ export class SkillHubService extends TypertRemoteService { } }; readyContext(); + if (item.installationState === 'disabled') return { status: 'blocked', reason: 'installation-disabled' }; if (!skills || runtime!.getStatus().state !== 'ready') return { status: 'blocked', reason: 'host-unavailable' }; if (runtime!.getStatus().pendingOperations > 0) return { status: 'blocked', reason: 'operation-pending' }; if (item.state !== 'present' || !item.fingerprint) return { status: 'blocked', reason: 'installation-unverified' }; @@ -547,6 +616,40 @@ export class SkillHubService extends TypertRemoteService { targets: baseline.targets.map(target => ({ scope: target.scope, path: target.path })) }; } + @RemoteScope('agent') + async prepareToggle(sourceRevision: string, itemKey: string, desiredState: 'enabled' | 'disabled', signal: AbortSignal): Promise { + return this.prepareToggleForAgent(sourceRevision, itemKey, desiredState, signal); + } + + private async prepareToggleForAgent(sourceRevision: string, itemKey: string, desiredState: 'enabled' | 'disabled', signal: AbortSignal, agentId?: string): Promise { + if (!['enabled', 'disabled'].includes(desiredState)) throw new RemoteError('skillhub/invalid-installation-request', 'Invalid installation state.', { retryable: false }); + const { item, valid, agent, runtime, fs } = await this.readInstalledItem(sourceRevision, itemKey, signal, agentId); + const combined = AbortSignal.any([signal, this.lifetime.signal]); + const context = this.selectionScope(agent!); + const base: TogglePlanView = { hostInstanceId: runtime!.getStatus().hostInstanceId!, status: 'blocked', reason: '', + planId: null, expiresAt: null, currentVersion: item.version, beforeState: item.installationState ?? 'enabled', afterState: desiredState, target: null }; + if (runtime!.getStatus().pendingOperations > 0) return { ...base, reason: 'recovery-required' }; + if (item.state !== 'present') return { ...base, reason: 'local-record-unverified' }; + const policy = agent!.ctx.get('sandboxPolicy')?.resolve({ session: agent!.session }); + const target = await resolveInstallationPath({ scope: item.scope, cwd: context.cwd, fs: fs!, policy, signal: combined }); + if (join(target.root, item.slug) !== item.targetPath) throw new RemoteError('skillhub/context-stale', 'Toggle target changed.', { retryable: false }); + const baseline = await runtime!.inspectToggle({ registry: this.getContext().registryUrl, + namespace: item.namespace, slug: item.slug, version: item.version }, target, desiredState); + valid(); combined.throwIfAborted(); + if (baseline.status === 'blocked') return { ...base, reason: baseline.reason }; + if (baseline.status === 'unchanged') return { ...base, status: 'unchanged', reason: 'already-' + desiredState }; + const currentPolicy = agent!.ctx.get('sandboxPolicy')?.resolve({ session: agent!.session }); + const current = await resolveInstallationPath({ scope: item.scope, cwd: context.cwd, fs: fs!, policy: currentPolicy, signal: combined }); + if (current.root !== target.root || current.dshHome !== target.dshHome || baseline.beforeState !== base.beforeState) { + throw new RemoteError('skillhub/context-stale', 'Toggle target changed.', { retryable: false }); + } + await this.readInstalledItem(sourceRevision, itemKey, combined, agentId); + await assertInstallationMutation(target.root, [baseline.target.path, join(baseline.target.path, 'SKILL.md'), join(baseline.target.path, 'SKILL.md.disabled')], currentPolicy); + valid(); combined.throwIfAborted(); + return { ...base, ...this.togglePlans.issue(context, baseline), status: 'ready', reason: desiredState, + target: { scope: baseline.target.scope, path: baseline.target.path } }; + } + @RemoteScope('agent') async prepareUninstall(sourceRevision: string, itemKey: string, signal: AbortSignal): Promise { return this.prepareUninstallForAgent(sourceRevision, itemKey, signal); @@ -584,7 +687,7 @@ export class SkillHubService extends TypertRemoteService { private installedItemKey(sessionId: string, cwd: string, sourceRevision: string, item: InstalledProjectionItem): string { return createHmac('sha256', this.installedReferenceKey).update(JSON.stringify([ sessionId, cwd, sourceRevision, item.scope, item.namespace, item.slug, item.version, - item.targetPath, item.fingerprint ?? null, item.state, item.recordRevision ?? null, + item.targetPath, item.fingerprint ?? null, item.state, item.recordRevision ?? null, item.installationState ?? 'enabled', ])).digest('base64url'); } @@ -643,6 +746,46 @@ export class SkillHubService extends TypertRemoteService { const previous = await runtime.findPlanOperation({ hostInstanceId, ownerId: agent.session.id, requestKey: request.requestKey, mode: 'retry', parameters: { planId: request.planId } }); if (previous) return previous; + if (this.togglePlans.has(request.planId)) { + return await runtime.submitToggle({ hostInstanceId, ownerId: agent.session.id, requestKey: request.requestKey, + mode: 'new', parameters: { planId: request.planId } }, async () => { + const combined = AbortSignal.any([signal, this.lifetime.signal]); + combined.throwIfAborted(); + const context = this.selectionScope(agent); + const baseline = this.togglePlans.read(request.planId, context); + const source = this.getContext(); + const filesystem = agent.ctx.get('fs'); + if (!filesystem) throw new RemoteError('skillhub/context-stale', 'Filesystem unavailable.', { retryable: false }); + const policy = agent.ctx.get('sandboxPolicy')?.resolve({ session: agent.session }); + const target = await resolveInstallationPath({ scope: baseline.target.scope, cwd: context.cwd, fs: filesystem, policy, signal: combined }); + if (target.root !== baseline.target.root) throw new RemoteError('skillhub/context-stale', 'Removal root changed.', { retryable: false }); + const valid = () => { + if (agent.session.header.cwd !== context.cwd || serviceIdentity(agent.ctx.get('fs')) !== serviceIdentity(filesystem) + || this.ctx.get('agents')?.get(SessionId(agent.session.id)) !== agent || this.localRuntime !== runtime) { + throw new RemoteError('skillhub/context-stale', 'Removal session changed.', { retryable: false }); + } + }; + valid(); combined.throwIfAborted(); this.assertSource(context.sourceRevision); + this.togglePlans.claim(request.planId, context, request.requestKey); + const item = baseline.inventoryItem; + const operationSignal = new AbortController().signal; + return { + acceptance: { kind: baseline.afterState === 'enabled' ? 'enable' : 'disable', namespace: item.namespace, slug: item.slug, version: item.targets.find(target => target.installDir === baseline.target.path)!.version, + targetLabel: target.scope === 'user' ? '当前用户' : '当前项目', targetPaths: [baseline.target.path], + context: { sourceIdentity: source.registryUrl, sourceLabel: source.registryUrl, sourceRevision: context.sourceRevision, + hostLabel: 'DSH Host', initiatedFrom } }, + execution: { baseline, authorize: async (paths: readonly string[]) => { + valid(); + const currentPolicy = agent.ctx.get('sandboxPolicy')?.resolve({ session: agent.session }); + const current = await resolveInstallationPath({ scope: target.scope, cwd: context.cwd, fs: filesystem, + policy: currentPolicy, signal: operationSignal }); + valid(); + if (current.root !== target.root || current.dshHome !== target.dshHome) throw new Error('Removal root changed'); + await assertInstallationMutation(target.root, paths, currentPolicy); + } }, + }; + }) ?? null; + } if (this.removalPlans.has(request.planId)) { return await runtime.submitRemoval({ hostInstanceId, ownerId: agent.session.id, requestKey: request.requestKey, mode: 'new', parameters: { planId: request.planId } }, async () => { @@ -921,7 +1064,7 @@ export class SkillHubService extends TypertRemoteService { const { operationId, hostInstanceId } = parsed.data; const operation = await this.getOperation(operationId, hostInstanceId); if (!operation) return null; - if (!['install', 'upgrade', 'uninstall', 'remove-stale-record'].includes(operation.kind)) { + if (!['install', 'upgrade', 'uninstall', 'remove-stale-record', 'enable', 'disable'].includes(operation.kind)) { throw new RemoteError('skillhub/installation-unavailable', 'Recovery is unavailable for this operation kind.', { retryable: false }); } const agentId = this.ctx.invocation!.request.args.agentId as string; @@ -1094,7 +1237,7 @@ export class SkillHubService extends TypertRemoteService { if (!runtime || !agent || !cwd || !filesystem) return Promise.reject(new Error('Recovery context unavailable')); const signal = new AbortController().signal; return runtime.recoverOperation(operationId, agent.session.id, async operation => { - if (!['install', 'upgrade', 'uninstall', 'remove-stale-record'].includes(operation.kind) + if (!['install', 'upgrade', 'uninstall', 'remove-stale-record', 'enable', 'disable'].includes(operation.kind) || (operation.kind === 'upgrade' ? operation.targetPaths.length < 1 || operation.targetPaths.length > 2 : operation.targetPaths.length !== 1) || new Set(operation.targetPaths).size !== operation.targetPaths.length || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(operation.slug)) throw new Error('Unsupported recovery target set'); @@ -1125,7 +1268,7 @@ export class SkillHubService extends TypertRemoteService { } const policy = agent.ctx.get('sandboxPolicy')?.resolve({ session: agent.session }); if (filesystem.sandboxMode !== undefined && !policy) throw new Error('Recovery policy unavailable'); - if (operation.kind === 'install' || operation.kind === 'upgrade') { + if (operation.kind === 'install' || operation.kind === 'upgrade' || operation.kind === 'enable' || operation.kind === 'disable') { for (const path of paths) { const accepted = targets.find(target => { const offset = relative(target.root, path); @@ -1252,7 +1395,7 @@ export class SkillHubService extends TypertRemoteService { const source = this.getContext(); if (action === 'list') return { schemaVersion: 1, kind: 'skillhub-installed', toolCallId: context.callId, sourceRevision: source.sourceRevision, sourceLabel: source.registryUrl, ...await this.listInstalledForAgent(context.sessionId, source.sourceRevision, signal, context.cwd) }; - const selected = action === 'uninstall' ? managementRequests.uninstall.parse(request) : managementRequests.check.parse(request); + const selected = ['uninstall', 'enable', 'disable'].includes(action) ? managementRequests.uninstall.parse(request) : managementRequests.check.parse(request); // Retain one preparation and UUIDv4 for each model call; failed calls never silently re-resolve latest. const key = JSON.stringify([context.sessionId, context.callId]); const parameters = JSON.stringify([action, request, context.cwd]); @@ -1278,9 +1421,11 @@ export class SkillHubService extends TypertRemoteService { check: await this.checkUpdateForVersion(selected.sourceRevision, selected.itemKey, version, signal, context.sessionId) }; const plan = action === 'upgrade' ? await this.prepareUpgradeForVersion(selected.sourceRevision, selected.itemKey, version, signal, context.sessionId) + : action === 'enable' || action === 'disable' + ? await this.prepareToggleForAgent(selected.sourceRevision, selected.itemKey, action === 'enable' ? 'enabled' : 'disabled', signal, context.sessionId) : await this.prepareUninstallForAgent(selected.sourceRevision, selected.itemKey, signal, context.sessionId); const view: ConversationManagementResult = { schemaVersion: 1, kind: 'skillhub-management', toolCallId: context.callId, - action: action === 'upgrade' ? 'upgrade' : 'uninstall', sourceRevision: selected.sourceRevision, sourceLabel: source.registryUrl, item, + action: action === 'upgrade' || action === 'enable' || action === 'disable' ? action : 'uninstall', sourceRevision: selected.sourceRevision, sourceLabel: source.registryUrl, item, status: plan.status === 'ready' ? 'awaiting-confirmation' : 'blocked', reason: plan.reason, candidateVersion: 'candidateVersion' in plan ? plan.candidateVersion : null, confirmationRef: null, operation: null }; if (plan.status !== 'ready' || !plan.planId) return view; @@ -1290,12 +1435,12 @@ export class SkillHubService extends TypertRemoteService { sourceRevision: selected.sourceRevision, planId: plan.planId, requestKey: randomUUID(), view }); const questions = this.ctx.get('userQuestions'); if (!questions || !useQuestions) return view; - const label = action === 'upgrade' ? '确认更新' : '确认卸载'; + const label = action === 'upgrade' ? '确认更新' : action === 'enable' ? '确认启用' : action === 'disable' ? '确认停用' : '确认卸载'; let answer; try { - answer = await questions.ask({ agent, signal, questions: [{ id: 'skillhub-management', header: action === 'upgrade' ? '更新技能' : '卸载技能', + answer = await questions.ask({ agent, signal, questions: [{ id: 'skillhub-management', header: `${label.slice(2)}技能`, question: `${label} @${item.namespace}/${item.slug}?`, - detail: `${item.scope === 'user' ? '当前用户' : '当前项目'} · ${item.version}${view.candidateVersion ? ` → ${view.candidateVersion}` : ''}`, + detail: `${item.scope === 'user' ? '当前用户' : '当前项目'} · ${item.version}${view.candidateVersion ? ` → ${view.candidateVersion}` : ''}${action === 'enable' || action === 'disable' ? ` · ${item.installationState === 'disabled' ? '已停用' : '已启用'}。仅改变这份安装;同名副本可能继续可用,已加载内容不会撤回,旧入口引用可能失效。` : ''}`, options: [{ label, description: '仅操作这份安装,其他位置保持不变。' }, { label: '取消', description: '不修改文件。' }], multiSelect: false }] }); } catch (error) { if (error && typeof error === 'object' && Reflect.get(error, 'code') === 'NO_PROVIDER') return view; @@ -1311,8 +1456,8 @@ export class SkillHubService extends TypertRemoteService { } @RemoteScope('agent') - async beginConversationManagement(sourceRevision: string, itemKey: string, action: 'upgrade' | 'uninstall', version: string | null, requestKey: string, signal: AbortSignal): Promise { - if (!['upgrade', 'uninstall'].includes(action) || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestKey)) { + async beginConversationManagement(sourceRevision: string, itemKey: string, action: 'upgrade' | 'uninstall' | 'enable' | 'disable', version: string | null, requestKey: string, signal: AbortSignal): Promise { + if (!['upgrade', 'uninstall', 'enable', 'disable'].includes(action) || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestKey)) { throw new RemoteError('skillhub/invalid-installation-request', 'Invalid management request.', { retryable: false }); } const id = this.ctx.invocation?.request.args.agentId; diff --git a/plugins/dsh/src/host/installation-summary.ts b/plugins/dsh/src/host/installation-summary.ts index cfbb34667..5749c9ee8 100644 --- a/plugins/dsh/src/host/installation-summary.ts +++ b/plugins/dsh/src/host/installation-summary.ts @@ -10,6 +10,6 @@ export function summarizeInstallation( complete: !!inventory?.complete && inventory.unavailable.length === 0, unavailable: inventory ? [...inventory.unavailable] : ['user', 'project'], records: inventory?.items.filter(item => item.namespace === identity.namespace && item.slug === identity.slug) - .map(({ scope, version, state }) => ({ scope, version, state })) ?? [], + .map(({ scope, version, state, installationState }) => ({ scope, version, state, installationState: installationState ?? 'enabled' })) ?? [], }; } diff --git a/plugins/dsh/src/host/local-runtime.ts b/plugins/dsh/src/host/local-runtime.ts index 809fb73f3..187e62b5f 100644 --- a/plugins/dsh/src/host/local-runtime.ts +++ b/plugins/dsh/src/host/local-runtime.ts @@ -6,6 +6,7 @@ import { homedir } from 'node:os'; import type { UpgradeSelection, UpgradeBaseline } from './generated/native-contract.js'; import type { NativeUpgradeExecution } from './generated/native-contract.js'; import type { RemovalBaseline, NativeRemovalExecution } from './generated/native-contract.js'; +import type { ToggleBaseline, NativeToggleExecution, InstallationState } from './generated/native-contract.js'; import { join } from 'node:path'; import type { NativeCore, NativeRuntime, NativeInstallExecution, NativeInstallAcceptance, NativeOperationRequest, NativeOperation, NativeRecoveryResult, NativePendingOperation } from './generated/native-contract.js'; @@ -31,6 +32,11 @@ export interface PreparedNativeRemoval { execution: Omit; } +export interface PreparedNativeToggle { + acceptance: NativeInstallAcceptance & { kind: 'enable' | 'disable' }; + execution: Omit; +} + /** Host-only initialization; no Client path input and no automatic replay of historical writes. */ export class LocalRuntime { private disposed = false; @@ -111,6 +117,14 @@ export class LocalRuntime { return this.call(runtime => runtime.inspectRemoval(identity, root)); } + inspectToggle(selected: UpgradeSelection, target: InstalledProjectionTarget, desiredState: InstallationState): Promise { + return this.call(runtime => runtime.inspectToggle({ ...selected }, { ...target }, desiredState)); + } + + readInstallationEntry(selected: UpgradeSelection, target: InstalledProjectionTarget): Promise { + return this.call(runtime => runtime.readInstallationEntry({ ...selected }, { ...target })); + } + readSkillExamples(directory: string): ReturnType { return this.call(runtime => runtime.readSkillExamples(directory)); } @@ -218,6 +232,27 @@ export class LocalRuntime { return receipt.promise; } + submitToggle(request: NativeOperationRequest, prepare: () => Promise): Promise { + const receipt = Promise.withResolvers(); + const input = { ...request, parameters: { ...request.parameters } }; + const workflow = this.call(async runtime => { + let prepared: PreparedNativeToggle | undefined; + const accepted = await runtime.acceptToggle(input, async () => { + this.assertWritableHost(); + const result = await prepare(); + prepared = { acceptance: structuredClone(result.acceptance), + execution: { ...result.execution, baseline: structuredClone(result.execution.baseline) } }; + return prepared.acceptance; + }); + receipt.resolve(accepted); + if (!accepted || !prepared || accepted.phase !== 'accepted') return; + const execution = { ...prepared.execution, operationId: accepted.operationId, ownerId: input.ownerId }; + await this.executeAccepted(accepted, () => runtime.executeToggle(execution)); + }); + void workflow.catch(error => receipt.reject(error)); + return receipt.promise; + } + private async executeAccepted(operation: NativeOperation, execute: () => Promise): Promise { try { return await execute(); } catch (error) { diff --git a/plugins/dsh/src/host/management-context.ts b/plugins/dsh/src/host/management-context.ts index 515cd3543..8e89da0ad 100644 --- a/plugins/dsh/src/host/management-context.ts +++ b/plugins/dsh/src/host/management-context.ts @@ -18,7 +18,7 @@ export function installationContext(list: InstalledSkillList): string { const items: { namespace: string; name: string; scope: string; version: string; state: string }[] = []; let truncated = false; for (const item of list.items) { - const entry = { namespace: item.namespace, name: item.slug, scope: item.scope, version: item.version, state: item.state }; + const entry = { namespace: item.namespace, name: item.slug, scope: item.scope, version: item.version, state: item.state, installationState: item.installationState ?? 'enabled' }; if (Object.values(entry).some(value => value.length > 200)) { truncated = true; continue; } if (items.length >= MAX_ITEMS || contextJson([...items, entry]).length > MAX_JSON_LENGTH) { truncated = true; break; } items.push(entry); diff --git a/plugins/dsh/src/host/management-tool.ts b/plugins/dsh/src/host/management-tool.ts index 74a1c74fe..22cdb36fe 100644 --- a/plugins/dsh/src/host/management-tool.ts +++ b/plugins/dsh/src/host/management-tool.ts @@ -5,6 +5,7 @@ import type { ConversationSearchContext } from './search-tool.js'; import { installationFailureCode } from './installation-failure.js'; export const MANAGEMENT_TOOLS = { + enable: 'astron_skillhub_enable', disable: 'astron_skillhub_disable', availability: 'astron_skillhub_check_availability', list: 'astron_skillhub_list_installed', check: 'astron_skillhub_check_update', upgrade: 'astron_skillhub_upgrade', uninstall: 'astron_skillhub_uninstall', operation: 'astron_skillhub_operation', @@ -12,6 +13,8 @@ export const MANAGEMENT_TOOLS = { export type ManagementAction = keyof typeof MANAGEMENT_TOOLS; const reference = z.string().min(1).max(2048); export const managementRequests = { + enable: z.object({ sourceRevision: reference, itemKey: reference }).strict(), + disable: z.object({ sourceRevision: reference, itemKey: reference }).strict(), availability: z.union([z.object({ sourceRevision: reference, itemKey: reference }).strict(), z.object({ name: z.string().min(1).max(128).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) }).strict()]), list: z.object({}).strict(), @@ -31,7 +34,7 @@ function modelView(result: Record): object { const invocation = value?.invocation as Record | undefined; return { schemaVersion: 1, kind: 'skillhub-availability', diagnosis: value && { checkedAt: value.checkedAt, name: value.name, scope: value.scope, association: value.association, integrity: value.integrity, - discovery: value.discovery, reason: value.reason, + discovery: value.discovery, reason: value.reason, installationState: value.installationState, installation: installation && { namespace: installation.namespace, slug: installation.slug, version: installation.version, scope: installation.scope }, invocation: invocation && { userInvocable: invocation.userInvocable, modelInvocable: invocation.modelInvocable }, } }; @@ -47,11 +50,11 @@ function modelView(result: Record): object { } }; } -const modelGuidance = 'List, update-check and operation results are point-in-time snapshots, not live installation state. UI buttons or external actions can change an installation after a result was returned. Before answering what is currently installed or its current version, or starting a new upgrade/uninstall request, call astron_skillhub_list_installed again in the current turn and use that fresh result; a prior operation receipt proves only that operation, not the current installation. Internal itemKey, sourceRevision, operationId and hostInstanceId values, including shortened prefixes, are tool-routing data only: never quote, compare or use them as evidence in user-facing explanations. Explain changes only with the returned skill name, installation scope, version and state. The tool card already presents the details: give a short conclusion instead of duplicating it as a long table or list. Report only returned state; do not fabricate confirmation or installation integrity.'; +const modelGuidance = 'List, update-check and operation results are point-in-time snapshots, not live installation state. UI buttons or external actions can change an installation after a result was returned. Before answering what is currently installed or its current version, or starting a new upgrade/uninstall/enable/disable request, call astron_skillhub_list_installed again in the current turn and use that fresh result; a prior operation receipt proves only that operation, not the current installation. Internal itemKey, sourceRevision, operationId and hostInstanceId values, including shortened prefixes, are tool-routing data only: never quote, compare or use them as evidence in user-facing explanations. Explain changes only with the returned skill name, installation scope, version and state. The tool card already presents the details: give a short conclusion instead of duplicating it as a long table or list. Report only returned state; do not fabricate confirmation or installation integrity.'; function modelInstructions(result: Record): string { const common = `\n${modelGuidance}`; - if (result.status === 'accepted') return common + ' REAL HUMAN APPROVAL IS ALREADY COMPLETE and the mutation IS ALREADY SUBMITTED. DO NOT ask for approval again and DO NOT call upgrade/uninstall again. Use astron_skillhub_operation with the returned operationId and hostInstanceId to check progress. If not terminal, report in progress; completed means report the confirmed outcome. Accepted alone is not completion.'; + if (result.status === 'accepted') return common + ' REAL HUMAN APPROVAL IS ALREADY COMPLETE and the mutation IS ALREADY SUBMITTED. DO NOT ask for approval again and DO NOT call upgrade/uninstall/enable/disable again. Use astron_skillhub_operation with the returned operationId and hostInstanceId to check progress. If not terminal, report in progress; completed means report the confirmed outcome. Accepted alone is not completion.'; if (result.status === 'awaiting-confirmation') return common + ' The existing confirmation card is awaiting the user; no mutation has been submitted. Direct the user to that card, do not create another confirmation or submit another tool call.'; if (result.status === 'cancelled') return common + ' The user cancelled. Do not resubmit or ask again unless the user explicitly starts a new request.'; return common; @@ -68,9 +71,9 @@ export function registerManagementTools(tools: Pick [{ type: 'text', text: JSON.stringify(modelView(result)) + modelInstructions(result) }], diff --git a/plugins/dsh/src/host/registry-adapter.ts b/plugins/dsh/src/host/registry-adapter.ts index 015895da4..b373a9e52 100644 --- a/plugins/dsh/src/host/registry-adapter.ts +++ b/plugins/dsh/src/host/registry-adapter.ts @@ -33,6 +33,7 @@ const resolveSchema = z.object({ downloadUrl: z.string().min(1), }); const metadataSchema = z.object({ + webUrl: z.string().nullish().catch(undefined), id: z.number().int().positive(), namespace: z.string(), slug: z.string(), displayName: z.string().nullable(), summary: z.string().nullable(), ...publicMetrics, diff --git a/plugins/dsh/src/host/search-result.ts b/plugins/dsh/src/host/search-result.ts index f6162a544..b0ebd7ea1 100644 --- a/plugins/dsh/src/host/search-result.ts +++ b/plugins/dsh/src/host/search-result.ts @@ -26,6 +26,7 @@ const schema = z.object({ records: z.array(z.object({ scope: z.enum(['user', 'project']), version: z.string().min(1), state: z.enum(['present', 'missing', 'metadata-invalid', 'mismatch', 'unreadable']), + installationState: z.enum(['enabled', 'disabled']).optional(), }).strict()), }).strict().optional(), }).strict()).max(20), diff --git a/plugins/dsh/src/host/skill-availability.ts b/plugins/dsh/src/host/skill-availability.ts index d52a5c9f8..938e9a803 100644 --- a/plugins/dsh/src/host/skill-availability.ts +++ b/plugins/dsh/src/host/skill-availability.ts @@ -8,8 +8,8 @@ import type { RemovalBaseline } from './generated/native-contract.js'; import type { SkillAvailability } from './types.js'; export interface AvailabilityReader { - snapshot?(): Promise<{ complete: boolean; skills: readonly { name: string }[] }>; - get(name: string): Promise; + snapshot?(signal?: AbortSignal): Promise<{ complete: boolean; skills: readonly { name: string }[] }>; + get(name: string, signal?: AbortSignal): Promise; } export const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -98,3 +98,79 @@ export async function inspectSkillAvailability(input: { valid(); result.discovery = 'unknown'; result.reason = 'read-failed'; return result; } } + +/** Conservative routing-name extraction, not a replacement YAML parser. Ambiguous syntax stays unknown. */ +export function installationEntryName(content: string): string | undefined { + if (content.length > 1024 * 1024 || !content.startsWith('---\n') && !content.startsWith('---\r\n')) return undefined; + const lines = content.split(/\r?\n/u); + const end = lines.indexOf('---', 1); + if (end < 0) return undefined; + const fields = lines.slice(1, end); + // Complex mappings/anchors can supply or replace name indirectly; do not infer their semantics. + if (fields.some(line => /^\s*(?:<<|["']name["'])\s*:/u.test(line) || /[&*]/u.test(line))) return undefined; + const names = fields.filter(line => /^name\s*:/u.test(line)); + if (names.length !== 1) return undefined; + const value = names[0]!.slice(names[0]!.indexOf(':') + 1).trim(); + const match = /^(?:([a-z0-9]+(?:-[a-z0-9]+)*)|'([a-z0-9]+(?:-[a-z0-9]+)*)'|"([a-z0-9]+(?:-[a-z0-9]+)*)")(?:\s+#.*)?$/u.exec(value); + const name = match?.[1] ?? match?.[2] ?? match?.[3]; + return name && name.length <= 128 ? name : undefined; +} + +/** Disabled state does not establish discovery exclusion: observe the real current-session winner. */ +export async function inspectDisabledSkillAvailability(input: { + directory: string; fingerprint?: string; signal: AbortSignal; valid(): void; + baseline(): Promise; entry(): Promise; reader?: AvailabilityReader; +}): Promise { + const result: SkillAvailability = { checkedAt: new Date().toISOString(), association: 'managed-reference', + installationState: 'disabled', integrity: 'unknown', discovery: 'unknown', reason: 'installation-disabled' }; + // A referenced timer also bounds readers that never settle on Windows Bun. + const deadline = new AbortController(); + const timer = setTimeout(() => deadline.abort(), 2000); + const signal = AbortSignal.any([input.signal, deadline.signal]); + const valid = () => { input.signal.throwIfAborted(); input.valid(); }; + let abort: (() => void) | undefined; + try { + return await Promise.race([(async () => { + const read = async (action: () => Promise): Promise => { + valid(); signal.throwIfAborted(); const value = await action(); valid(); signal.throwIfAborted(); return value; + }; + const baseline = await read(input.baseline); + if (baseline.status === 'blocked') { + result.integrity = baseline.reason === 'local-changes' ? 'changed' : 'unknown'; return result; + } + if (baseline.kind !== 'uninstall' || baseline.target.path !== input.directory || !input.fingerprint + || !baseline.inventoryItem.targets.some(target => target.installDir === input.directory && target.fingerprint === input.fingerprint + && target.installationState === 'disabled')) return result; + result.integrity = 'verified'; + const content = await read(input.entry); + if (content === undefined) { result.integrity = 'unknown'; result.reason = 'baseline-unverified'; return result; } + const name = installationEntryName(content); + if (!name) { result.reason = 'definition-unverified'; return result; } + result.name = name; + if (!input.reader?.snapshot) { result.reason = 'snapshot-unavailable'; return result; } + const snapshot = await read(() => input.reader!.snapshot!(signal)); + if (!snapshot.complete) { result.reason = 'snapshot-incomplete'; return result; } + const winner = await read(() => input.reader!.get(name, signal)); + const finalSnapshot = await read(() => input.reader!.snapshot!(signal)); + const finalWinner = await read(() => input.reader!.get(name, signal)); + if (!finalSnapshot.complete || !isDeepStrictEqual(snapshot, finalSnapshot) || !isDeepStrictEqual(winner, finalWinner) + || !isDeepStrictEqual(baseline, await read(input.baseline))) { result.reason = 'observation-changed'; return result; } + if (!winner) { + if (snapshot.skills.some(skill => skill.name === name)) { result.reason = 'definition-unverified'; return result; } + result.discovery = 'not-discovered'; result.reason = 'not-discovered'; return result; + } + if (winner.name !== name || !snapshot.skills.some(skill => skill.name === name)) { result.reason = 'observation-changed'; return result; } + if (winner.path === join(input.directory, 'SKILL.md') && winner.resourceBase?.kind === 'directory' + && winner.resourceBase.path === input.directory) { + result.discovery = 'winner'; // A cached selected entry can still be observed after disablement. + } else { result.discovery = 'shadowed'; result.reason = 'same-name-winner'; } + // No invocation permission is inferred for a disabled installation or transferred from its fallback. + return result; + })(), new Promise((_resolve, reject) => { + abort = () => reject(new Error('Discovery observation timed out')); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + })]); + } catch { valid(); return { ...result, discovery: 'unknown', reason: 'read-failed' }; } + finally { clearTimeout(timer); if (abort) signal.removeEventListener('abort', abort); } +} diff --git a/plugins/dsh/src/host/skill-use-selection.ts b/plugins/dsh/src/host/skill-use-selection.ts new file mode 100644 index 000000000..6399c2391 --- /dev/null +++ b/plugins/dsh/src/host/skill-use-selection.ts @@ -0,0 +1,63 @@ +import { isDeepStrictEqual } from 'node:util'; +import type { InstalledSkillList, SkillUsePreparation, SkillUseSelection } from './types.js'; + +/** A bounded observation of every managed candidate, never an invocation authorization. */ +export async function selectUsableInstallation(input: { + namespace: string; slug: string; signal: AbortSignal; valid(): void; + list(signal: AbortSignal): Promise; + prepare(itemKey: string, signal: AbortSignal): Promise; +}, timeoutMs = 5000): Promise { + const deadline = new AbortController(); + const signal = AbortSignal.any([input.signal, deadline.signal]); + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const valid = () => { input.signal.throwIfAborted(); input.valid(); signal.throwIfAborted(); }; + const blocked = (reason: Extract['reason']): SkillUseSelection => ({ status: 'blocked', reason }); + const read = async () => { + valid(); const list = await input.list(signal); valid(); + if (!list.complete || list.unavailable.length || list.issues.length) return undefined; + return list.items.filter(item => item.namespace === input.namespace && item.slug === input.slug) + .sort((a, b) => a.itemKey.localeCompare(b.itemKey)); + }; + const run = async (): Promise => { + const candidates = await read(); + if (!candidates || candidates.length > 16) return blocked('selection-unverified'); + if (!candidates.length) return blocked('no-installation'); + if (new Set(candidates.map(item => item.itemKey)).size !== candidates.length) return blocked('selection-unverified'); + const observe = async () => { + const results: SkillUsePreparation[] = new Array(candidates.length); + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(4, candidates.length) }, async () => { + while (cursor < candidates.length) { + const index = cursor++; + valid(); results[index] = await input.prepare(candidates[index].itemKey, signal); valid(); + } + })); + return results; + }; + const first = await observe(); + if (!isDeepStrictEqual(candidates, await read())) return blocked('selection-unverified'); + const second = await observe(); + if (!isDeepStrictEqual(first, second) || !isDeepStrictEqual(candidates, await read())) return blocked('selection-unverified'); + const ready = second.flatMap((result, index) => result.status === 'ready' ? [candidates[index]] : []); + if (ready.length > 1) return blocked('multiple-usable-installations'); + // A modified/unreadable/unknown sibling may hide another runtime name. Never guess around it. + if (second.some(result => result.status === 'blocked' + && !['installation-disabled', 'skill-shadowed', 'user-invocation-disabled'].includes(result.reason))) { + return blocked('selection-unverified'); + } + return ready.length ? { status: 'ready', itemKey: ready[0].itemKey } : blocked('no-usable-installation'); + }; + try { + const expired = new Promise((_, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + timer = setTimeout(() => deadline.abort(), timeoutMs); + }); + return await Promise.race([run(), expired]); + } catch { + input.signal.throwIfAborted(); input.valid(); + return blocked(deadline.signal.aborted ? 'selection-timeout' : 'selection-unverified'); + } finally { clearTimeout(timer); if (onAbort) signal.removeEventListener('abort', onAbort); deadline.abort(); } +} diff --git a/plugins/dsh/src/host/types.ts b/plugins/dsh/src/host/types.ts index f4dfbf7a2..bb6633d1a 100644 --- a/plugins/dsh/src/host/types.ts +++ b/plugins/dsh/src/host/types.ts @@ -63,6 +63,7 @@ export interface CatalogPageResult { /** Read-only fixed-version content; it is not an installation authorization. */ export interface SkillDetail { + webUrl?: string | null; detailToken: string; title: string; summary: string; @@ -90,15 +91,19 @@ export interface InstallationTargetList { /** Controlled, path-free reasons for refusing a native composer handoff. */ export type SkillUseBlockReason = 'installation-unverified' | 'operation-pending' | 'shared-installation' | 'files-changed' | 'skill-unavailable' | 'skill-shadowed' | 'user-invocation-disabled' - | 'unsupported-name' | 'host-unavailable'; + | 'unsupported-name' | 'host-unavailable' | 'installation-disabled'; export type { SkillExamples, SkillExample } from './generated/native-contract.js'; export type SkillUsePreparation = { status: 'ready'; name: string; version: string; scope: 'user' | 'project'; examples: import('./generated/native-contract.js').SkillExamples } | { status: 'blocked'; reason: SkillUseBlockReason }; +export type SkillUseSelection = { status: 'ready'; itemKey: string } + | { status: 'blocked'; reason: 'no-installation' | 'no-usable-installation' | 'selection-unverified' + | 'multiple-usable-installations' | 'selection-timeout' }; + export interface InstalledSkillList { - items: { itemKey: string; namespace: string; slug: string; version: string; scope: 'user' | 'project'; - state: 'present' | 'missing' | 'metadata-invalid' | 'mismatch' | 'unreadable' }[]; + items: { itemKey: string; locationKey?: string; namespace: string; slug: string; version: string; scope: 'user' | 'project'; + state: 'present' | 'missing' | 'metadata-invalid' | 'mismatch' | 'unreadable'; installationState?: 'enabled' | 'disabled' }[]; /** Completeness of the inventory projection; unavailable roots are reported independently. */ complete: boolean; issues: string[]; @@ -111,7 +116,7 @@ export interface SearchInstallationStatus { complete: boolean; unavailable: ('user' | 'project')[]; records: { scope: 'user' | 'project'; version: string; - state: 'present' | 'missing' | 'metadata-invalid' | 'mismatch' | 'unreadable' }[]; + state: 'present' | 'missing' | 'metadata-invalid' | 'mismatch' | 'unreadable'; installationState?: 'enabled' | 'disabled' }[]; } export interface InstalledSkillDetail { @@ -177,7 +182,7 @@ export interface ConversationUpdateResult { } export interface ConversationManagementResult { schemaVersion: 1; kind: 'skillhub-management'; toolCallId: string; - action: 'upgrade' | 'uninstall'; sourceRevision: string; sourceLabel: string; + action: 'upgrade' | 'uninstall' | 'enable' | 'disable'; sourceRevision: string; sourceLabel: string; item: InstalledSkillList['items'][number]; status: 'awaiting-confirmation' | 'cancelled' | 'blocked' | 'accepted'; candidateVersion: string | null; reason: string; @@ -186,6 +191,7 @@ export interface ConversationManagementResult { /** Path-free, read-only observations at checkedAt; never execution authorization. */ export interface SkillAvailability { + installationState?: 'enabled' | 'disabled'; installation?: { namespace: string; slug: string; version: string; scope: 'user' | 'project' }; scope?: 'user' | 'project'; checkedAt: string; @@ -194,7 +200,19 @@ export interface SkillAvailability { integrity: 'verified' | 'changed' | 'missing' | 'unknown'; discovery: 'winner' | 'shadowed' | 'not-discovered' | 'unknown'; invocation?: { userInvocable: boolean; modelInvocable: boolean }; - reason: 'observed' | 'host-unavailable' | 'files-missing' | 'files-unreadable' | 'local-changes' + reason: 'installation-disabled' | 'observed' | 'host-unavailable' | 'files-missing' | 'files-unreadable' | 'local-changes' | 'baseline-unverified' | 'definition-unverified' | 'snapshot-unavailable' | 'snapshot-incomplete' | 'observation-changed' | 'not-discovered' | 'same-name-winner' | 'definition-changed' | 'read-failed'; } + +export interface TogglePlanView { + hostInstanceId: string; + status: 'ready' | 'unchanged' | 'blocked'; + reason: string; + planId: string | null; + expiresAt: string | null; + currentVersion: string; + beforeState: 'enabled' | 'disabled'; + afterState: 'enabled' | 'disabled'; + target: { scope: 'user' | 'project'; path: string } | null; +} diff --git a/plugins/dsh/src/host/upgrade-plans.ts b/plugins/dsh/src/host/upgrade-plans.ts index 29afd41ee..e748216a3 100644 --- a/plugins/dsh/src/host/upgrade-plans.ts +++ b/plugins/dsh/src/host/upgrade-plans.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'; import type { SelectionScope } from './conversation-selections.js'; -import type { UpgradeBaseline, RemovalBaseline } from './generated/native-contract.js'; +import type { UpgradeBaseline, RemovalBaseline, ToggleBaseline } from './generated/native-contract.js'; import type { ResolvedSkill } from './registry-adapter.js'; export interface UpgradePlanSnapshot { @@ -62,3 +62,5 @@ class ConfirmationPlans { export class UpgradePlans extends ConfirmationPlans {} export class RemovalPlans extends ConfirmationPlans> {} + +export class TogglePlans extends ConfirmationPlans> {} diff --git a/plugins/dsh/test/catalog-service.test.ts b/plugins/dsh/test/catalog-service.test.ts index c11952aeb..a415a4f8a 100644 --- a/plugins/dsh/test/catalog-service.test.ts +++ b/plugins/dsh/test/catalog-service.test.ts @@ -35,11 +35,11 @@ describe('native catalog service', () => { } finally { await fiber.dispose(); fetchSpy.mockRestore(); } }); - test('detail pins both requests to the selected version and does not expose download addresses', async () => { + test.each([undefined, null, 'https://web.example/read/writer?version=1.2.0#examples'])('detail pins requests and preserves optional API page URL: %s', async webUrl => { const paths: string[] = []; const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) { const url = new URL(request.url); - if (url.pathname === '/api/v1/skills/team/writer') return Response.json({ data: { id: 42, namespace: 'team', slug: 'writer', displayName: 'Writer', summary: '' } }); + if (url.pathname === '/api/v1/skills/team/writer') return Response.json({ data: { id: 42, namespace: 'team', slug: 'writer', displayName: 'Writer', summary: '', webUrl } }); if (url.pathname.endsWith('/versions') || url.pathname.endsWith('/reviews')) return Response.json({ data: { items: [], total: 0, page: 0, size: 1 } }); paths.push(url.pathname + url.search); expect(request.headers.get('authorization')).toBeNull(); @@ -56,6 +56,7 @@ describe('native catalog service', () => { 'team', 'writer', '1.2.0', new AbortController().signal); expect(detail).toMatchObject({ namespace: 'team', slug: 'writer', version: '1.2.0', versionId: 12, documentation: { state: 'ready', content: '# Writer\n\nUse resources/example.txt.' } }); + expect(detail.webUrl).toBe(webUrl); expect(paths).toEqual(['/api/cli/v1/skills/team/writer/resolve?version=1.2.0', '/api/v1/skills/team/writer/versions/1.2.0/file?path=SKILL.md']); } finally { await fiber.dispose(); await server.stop(true); } diff --git a/plugins/dsh/test/client-entry-lifecycle.test.ts b/plugins/dsh/test/client-entry-lifecycle.test.ts index 8f0c31dc1..96a4c6c92 100644 --- a/plugins/dsh/test/client-entry-lifecycle.test.ts +++ b/plugins/dsh/test/client-entry-lifecycle.test.ts @@ -26,7 +26,7 @@ test('layout loss removes only catalog entries and preserves conversation regist const flush = async () => { await new Promise(resolve => setTimeout(resolve, 25)); }; try { await plugin; await flush(); - const tools = ['conversation.input.dock/skillhub-case-choices', 'conversation.input.left/skillhub-use-focus', 'tool.call.toolview/astron_skillhub_check_availability', 'tool.call.toolview/astron_skillhub_check_update', 'tool.call.toolview/astron_skillhub_install', 'tool.call.toolview/astron_skillhub_list_installed', 'tool.call.toolview/astron_skillhub_operation', 'tool.call.toolview/astron_skillhub_search', 'tool.call.toolview/astron_skillhub_uninstall', 'tool.call.toolview/astron_skillhub_upgrade']; + const tools = ['conversation.input.dock/skillhub-case-choices', 'conversation.input.left/skillhub-use-focus', 'tool.call.toolview/astron_skillhub_check_availability', 'tool.call.toolview/astron_skillhub_check_update', 'tool.call.toolview/astron_skillhub_disable', 'tool.call.toolview/astron_skillhub_enable', 'tool.call.toolview/astron_skillhub_install', 'tool.call.toolview/astron_skillhub_list_installed', 'tool.call.toolview/astron_skillhub_operation', 'tool.call.toolview/astron_skillhub_search', 'tool.call.toolview/astron_skillhub_uninstall', 'tool.call.toolview/astron_skillhub_upgrade']; expect([...entries.keys()].sort()).toEqual(tools); const original = tools.map(key => entries.get(key)); for (let cycle = 0; cycle < 2; cycle++) { diff --git a/plugins/dsh/test/client-slot-lifecycle.test.ts b/plugins/dsh/test/client-slot-lifecycle.test.ts index 999d8bce3..3434cee11 100644 --- a/plugins/dsh/test/client-slot-lifecycle.test.ts +++ b/plugins/dsh/test/client-slot-lifecycle.test.ts @@ -58,7 +58,7 @@ test('official slot declaration loss preserves catalog and independently removes const tools = slots.register({ name: 'test.tools', children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, } } as never, empty); - expect(slots.entriesOfSlot('tool.call.toolview')).toHaveLength(8); + expect(slots.entriesOfSlot('tool.call.toolview')).toHaveLength(10); tools(); await flush(); expect(slots.entriesOfSlot('tool.call.toolview')).toHaveLength(0); expect(slots.entriesOfSlot('main')[0]?.component).toBe(catalog); diff --git a/plugins/dsh/test/conversation-management-host.test.ts b/plugins/dsh/test/conversation-management-host.test.ts index 87aee47a4..88164b516 100644 --- a/plugins/dsh/test/conversation-management-host.test.ts +++ b/plugins/dsh/test/conversation-management-host.test.ts @@ -20,6 +20,7 @@ function harness(answer?: { selected: string[]; custom?: string }, providerError assertSource: (source: string) => { if (source !== revision) throw new Error('stale'); }, listInstalledForAgent: async (_id: string, source: string) => { if (source !== revision) throw new Error('stale'); return { items: [item], complete: true, issues: [], unavailable: [] }; }, prepareUpgradeForVersion: async () => ({ status: 'ready', planId: 'plan', candidateVersion: '2.0.0', reason: 'newer-version' }), + prepareToggleForAgent: async () => ({ status: 'ready', planId: 'toggle-plan', reason: 'disabled' }), prepareUninstallForAgent: async () => ({ status: 'ready', planId: 'plan', reason: 'uninstall' }), readInstalledItem: async () => { reads++; return {}; }, executePlanForAgent: async (_id: string, input: { requestKey: string }) => { @@ -99,3 +100,28 @@ test('source changes during native human confirmation never submit', async () => } } : get(name)) as typeof get; await expect(h.run()).rejects.toThrow('stale'); expect(h.writes()).toBe(0); }); + +for (const [action, label] of [['enable', '确认启用'], ['disable', '确认停用']]) { + test(`${action} requires exact real approval and does not replay duplicate confirmation`, async () => { + const h = harness(); + const pending = await h.run(action); + expect(pending.action).toBe(action); expect(pending.status).toBe('awaiting-confirmation'); expect(h.writes()).toBe(0); + const [a, b] = await Promise.all([h.confirm(pending.confirmationRef, true), h.confirm(pending.confirmationRef, true)]); + expect(a).toEqual(b); expect(a.status).toBe('accepted'); expect(h.writes()).toBe(1); + const cancelled = harness({ selected: ['取消'] }); + expect((await cancelled.run(action)).status).toBe('cancelled'); expect(cancelled.writes()).toBe(0); + const native = harness({ selected: [label] }); + expect((await native.run(action)).status).toBe('accepted'); expect(native.writes()).toBe(1); + const stale = harness(); await expect(stale.run(action, { itemKey: 'old' })).rejects.toThrow('selection changed'); + expect(stale.writes()).toBe(0); + }); +} + +test('toggle requests accept exact references only and never accept desired paths or authorization flags', () => { + for (const schema of [managementRequests.enable, managementRequests.disable]) { + expect(schema.safeParse({ sourceRevision: 'source', itemKey: 'key' }).success).toBe(true); + for (const extra of [{ confirmed: true }, { path: '/elsewhere' }, { version: '2' }, { scope: 'user' }]) { + expect(schema.safeParse({ sourceRevision: 'source', itemKey: 'key', ...extra }).success).toBe(false); + } + } +}); diff --git a/plugins/dsh/test/disabled-availability.test.ts b/plugins/dsh/test/disabled-availability.test.ts new file mode 100644 index 000000000..b21b5df7c --- /dev/null +++ b/plugins/dsh/test/disabled-availability.test.ts @@ -0,0 +1,79 @@ +import { join } from 'node:path'; +import { expect, test } from 'bun:test'; +import { inspectDisabledSkillAvailability, installationEntryName } from '../src/host/skill-availability.js'; +import type { RemovalBaseline } from '../src/host/generated/native-contract.js'; +import type { LoadedSkill } from '../src/host/skill-discovery.js'; + +function fixture() { + const directory = join('/skills', 'market-slug'); + const baseline: RemovalBaseline = { status: 'ready', kind: 'uninstall', inventoryItem: { + registry: 'https://registry.example', namespace: 'demo', slug: 'market-slug', targets: [{ agent: 'dsh', rootDir: '/skills', + installDir: directory, installedAt: 'now', version: '1', fingerprint: 'fp', installationState: 'disabled' }] }, + target: { path: directory, root: '/skills', scope: 'project', files: {}, metadata: { registry: 'https://registry.example', namespace: 'demo', slug: 'market-slug', version: '1' } } }; + const winner: LoadedSkill = { name: 'actual-name', description: 'fixture', content: 'content', source: 'user-dsh', provider: 'filesystem', + invocation: { userInvocable: true, modelInvocable: true }, path: join('/user/skills/copy', 'SKILL.md'), resourceBase: { kind: 'directory', path: join('/user/skills', 'copy') } }; + const input = { directory, fingerprint: 'fp', signal: new AbortController().signal, valid() {}, + baseline: async () => baseline, entry: async () => '---\nname: actual-name\ndescription: fixture\n---\nbody', + reader: { snapshot: async () => ({ complete: true, skills: [{ name: 'actual-name' }] }), get: async (name: string): Promise => { + expect(name).toBe('actual-name'); return winner; + } } }; + return { input, winner }; +} + +test('disabled diagnosis observes verified same-name fallback without authorizing use', async () => { + const { input } = fixture(); + expect(await inspectDisabledSkillAvailability(input)).toMatchObject({ installationState: 'disabled', name: 'actual-name', + integrity: 'verified', discovery: 'shadowed', reason: 'same-name-winner' }); + expect(await inspectDisabledSkillAvailability(input)).not.toHaveProperty('invocation'); +}); + +test('watch-off cached selected winner remains observed separately from committed disabled state', async () => { + const { input, winner } = fixture(); + input.reader.get = async () => ({ ...winner, path: join(input.directory, 'SKILL.md'), resourceBase: { kind: 'directory', path: input.directory } }); + expect(await inspectDisabledSkillAvailability(input)).toMatchObject({ installationState: 'disabled', discovery: 'winner', reason: 'installation-disabled' }); +}); + +test('absence, partial snapshots and changing winners remain distinct', async () => { + const { input } = fixture(); + input.reader.get = async () => undefined; + input.reader.snapshot = async () => ({ complete: true, skills: [] }); + expect((await inspectDisabledSkillAvailability(input)).discovery).toBe('not-discovered'); + input.reader.snapshot = async () => ({ complete: false, skills: [] }); + expect(await inspectDisabledSkillAvailability(input)).toMatchObject({ discovery: 'unknown', reason: 'snapshot-incomplete' }); +}); + +test('routing-name extraction refuses ambiguous YAML and never substitutes the package slug', () => { + expect(installationEntryName('---\nname: actual-name\n---\ntext')).toBe('actual-name'); + for (const fields of ['name: one\nname: two', 'name: [wrong]', 'name: |\n hidden', '<<: *defaults', '"name": hidden']) { + expect(installationEntryName(`---\n${fields}\n---\ntext`)).toBeUndefined(); + } +}); + +test('a changed winner is unknown', async () => { + const { input, winner } = fixture(); + let calls = 0; + input.reader.get = async () => ++calls === 1 ? winner : undefined; + expect(await inspectDisabledSkillAvailability(input)).toMatchObject({ discovery: 'unknown', reason: 'observation-changed' }); +}); + +test('an unresponsive reader is bounded', async () => { + const { input } = fixture(); + input.reader.snapshot = () => new Promise(() => {}); + const started = performance.now(); + expect(await inspectDisabledSkillAvailability(input)).toMatchObject({ installationState: 'disabled', discovery: 'unknown', reason: 'read-failed' }); + expect(performance.now() - started).toBeLessThan(3500); +}, 5000); + +test('caller cancellation is propagated while a disabled reader is unresponsive', async () => { + const { input } = fixture(); + const controller = new AbortController(); + input.signal = controller.signal; + let started!: () => void; + const reading = new Promise(resolve => { started = resolve; }); + input.reader.snapshot = () => { started(); return new Promise(() => {}); }; + const inspection = inspectDisabledSkillAvailability(input); + await reading; + const reason = new Error('caller cancelled'); + controller.abort(reason); + await expect(inspection).rejects.toBe(reason); +}); diff --git a/plugins/dsh/test/execute-plan.test.ts b/plugins/dsh/test/execute-plan.test.ts index 3da506253..1042f6dff 100644 --- a/plugins/dsh/test/execute-plan.test.ts +++ b/plugins/dsh/test/execute-plan.test.ts @@ -4,7 +4,7 @@ import { realpath, mkdtemp, mkdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import SkillHubService from '@astron-team/skillhub-dsh'; -import { UpgradePlans, RemovalPlans } from '../src/host/upgrade-plans.js'; +import { TogglePlans, UpgradePlans, RemovalPlans } from '../src/host/upgrade-plans.js'; import type { PreparedNativeUpgrade } from '../src/host/local-runtime.js'; test('executePlan accepts only a bound plan and guards every mutation against current Host policy', async () => { @@ -30,7 +30,7 @@ test('executePlan accepts only a bound plan and guards every mutation against cu if (!replay) prepared = await prepare(); return null; } }; - const context = { localRuntime: runtime, lifetime: new AbortController(), upgradePlans: plans, removalPlans: new RemovalPlans(), + const context = { togglePlans: new TogglePlans(), localRuntime: runtime, lifetime: new AbortController(), upgradePlans: plans, removalPlans: new RemovalPlans(), ctx: { invocation: { request: { args: { agentId: 'session', input } } }, get: () => ({ get: () => agent }) }, selectionScope: () => scope, assertSource: () => {}, getContext: () => ({ registryUrl: identity.registry }) }; Object.setPrototypeOf(context, SkillHubService.prototype); diff --git a/plugins/dsh/test/execute-uninstall.test.ts b/plugins/dsh/test/execute-uninstall.test.ts index 49ccd2700..8bc8d5615 100644 --- a/plugins/dsh/test/execute-uninstall.test.ts +++ b/plugins/dsh/test/execute-uninstall.test.ts @@ -4,7 +4,7 @@ import { realpath, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import SkillHubService from '@astron-team/skillhub-dsh'; -import { RemovalPlans } from '../src/host/upgrade-plans.js'; +import { TogglePlans, RemovalPlans } from '../src/host/upgrade-plans.js'; import type { PreparedNativeRemoval } from '../src/host/local-runtime.js'; import type { NativeOperation, RemovalBaseline } from '../src/host/generated/native-contract.js'; @@ -39,7 +39,7 @@ test.each(['uninstall', 'remove-stale-record'] as const)('executePlan routes %s const runtime = { getStatus: () => ({ hostInstanceId }), findPlanOperation: async () => replay ? receipt : undefined, submitRemoval: async (_request: unknown, prepare: () => Promise) => { prepared = await prepare(); return receipt; }, submitUpgrade: async () => { throw new Error('Removal must not execute an upgrade'); } }; - const context = { localRuntime: runtime, lifetime: new AbortController(), removalPlans: plans, + const context = { togglePlans: new TogglePlans(), localRuntime: runtime, lifetime: new AbortController(), removalPlans: plans, selectionScope: () => scope, assertSource() {}, getContext: () => ({ registryUrl: identity.registry }), ctx: { invocation: { request: { args: { agentId: 'session', input } } }, get: () => ({ get: () => agent }) } }; Object.setPrototypeOf(context, SkillHubService.prototype); diff --git a/plugins/dsh/test/install-control-state.test.tsx b/plugins/dsh/test/install-control-state.test.tsx index 57ef5ff41..65dd1fbec 100644 --- a/plugins/dsh/test/install-control-state.test.tsx +++ b/plugins/dsh/test/install-control-state.test.tsx @@ -18,7 +18,7 @@ test('existing scope is preferred and another skill never changes the selection' expect(preferredInstallationScope(inventory([{ ...record, scope: 'user' }]), detail, ['project'])).toBe('user'); }); -function render(items: InstalledSkillList['items']) { +function render(items: InstalledSkillList['items'], upgradable = false) { const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }); const requests = new InstallationRequests(); client.setQueryData(['skillhub-install-targets', 'session', 'detail'], { hostInstanceId: 'host', targets: [{ scope: 'project', targetToken: 'target' }, { scope: 'user', targetToken: 'user' }], unavailable: [] }); @@ -26,6 +26,13 @@ function render(items: InstalledSkillList['items']) { client.setQueryData(['skillhub-compatibility'], { dependencyBaseline: 'matched', localWritesAllowed: true }); const unexpected = async () => { throw new Error('SSR must not call transport'); }; const services: InstallationServices = { sessionId: 'session', skillUse: { useSkill: unexpected }, requests, targets: unexpected, installed: unexpected, start: unexpected, operation: unexpected, compatibility: unexpected, manageInstalled: () => {} }; + if (upgradable) { + services.upgrades = {} as NonNullable; + services.registryIdentity = unexpected; services.checkVersionUpdate = unexpected; + client.setQueryData(['skillhub-upgrade-source', 'session', 'source'], 'registry'); + client.setQueryData(['skillhub-update-check', 'session', 'source', 'item', '1.2.0'], { state: 'upgradable', candidateVersion: '1.2.0' }); + } + services.management = { client, installation: services, assertCurrent() {}, refreshInstalled: unexpected, check: unexpected, begin: unexpected, confirm: unexpected }; try { return renderToStaticMarkup(); } finally { client.clear(); requests.dispose(); } } @@ -41,13 +48,37 @@ test('current inventory renders a removal action without a duplicate install act test('empty verified inventory renders an explicit scoped install action', () => { const html = render([]); expect(html).toContain('>安装到所有项目'); - expect(html).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 }]) { + for (const item of [{ ...record, state: 'missing' as const }, { ...record, state: 'mismatch' as const }]) { expect(render([item])).not.toContain('>使用此技能'); } }); + +test('detail actions offer a clear secondary disable and primary enable without a separate menu', () => { + const enabled = render([record]); + expect(enabled).toContain('astron-sh-install-actions'); + expect(enabled).toMatch(/astron-sh-button-ghost[^>]*>停用<\/button>/); + expect(enabled).toContain('>使用此技能'); + expect(enabled).not.toContain('更多操作'); + const disabled = render([{ ...record, installationState: 'disabled' }]); + expect(disabled).toContain('已停用'); + expect(disabled).toMatch(/astron-sh-button-default[^>]*>启用<\/button>/); + expect(disabled).not.toContain('>使用此技能'); + expect(disabled).not.toContain('>停用'); +}); + +test('newer viewed version retains use of the explicitly installed item', () => { + expect(render([{ ...record, version: '1.1.0' }])).toContain('>使用已安装版本'); +}); + +test.each(['enabled', 'disabled'] as const)('available upgrade is secondary beside the %s installation action', installationState => { + const html = render([{ ...record, version: '1.1.0', installationState }], true); + expect(html).toMatch(/astron-sh-button-outline[^>]*>升级到 v1.2.0<\/button>/); + expect(html.match(/astron-sh-button-default/g)?.length).toBe(1); + expect(html).toContain(installationState === 'disabled' ? '>启用' : '>使用已安装版本'); +}); diff --git a/plugins/dsh/test/installation-progress.test.tsx b/plugins/dsh/test/installation-progress.test.tsx index 61ef5b712..ca23352e2 100644 --- a/plugins/dsh/test/installation-progress.test.tsx +++ b/plugins/dsh/test/installation-progress.test.tsx @@ -24,15 +24,16 @@ test('progress follows actual phases and never fabricates intermediate percentag expect(installationProgressState({ ...attempt, state: 'submitting', operation: null }, null, false).state).toBe('running'); }); -test('compact completion keeps essential discovery warning without exposing technical metadata', () => { +test('normal completion is concise without discovery guidance or technical metadata', () => { const value = { ...operation, phase: 'completed' as const, discovery: 'confirmed' as const }; const html = renderToStaticMarkup(); expect(html).toContain('安装完成'); - expect(html).toContain('技能已就绪'); + expect(html).not.toContain('技能已就绪'); expect(html).not.toContain('发现尚待确认'); expect(html).toContain('aria-valuenow="100"'); for (const text of ['registry.example', '2026-10-14', '原操作', '刷新发现状态', '操作 ID']) expect(html).not.toContain(text); - expect(installationProgressState(attempt, { ...value, discovery: 'unconfirmed' }, false).message).toContain('尚待确认'); + expect(installationProgressState(attempt, { ...value, discovery: 'unconfirmed' }, false).message).toBe(''); expect(installationProgressState(attempt, { ...value, warningCount: 1 }, false).label).toContain('告警'); + expect(installationProgressState(attempt, { ...value, recovery: 'unknown' }, false).message).toContain('核验'); }); test('errors and uncertain results stop animation instead of claiming success', () => { diff --git a/plugins/dsh/test/installation-status.test.tsx b/plugins/dsh/test/installation-status.test.tsx index 5265f7836..7f42cbfed 100644 --- a/plugins/dsh/test/installation-status.test.tsx +++ b/plugins/dsh/test/installation-status.test.tsx @@ -57,3 +57,12 @@ describe('source-aware installation badges', () => { expect(html).toContain('不代表当前仍然安装'); }); }); + +test('disabled package keeps its version and selected scope without becoming missing', () => { + const html = render({ ...snapshot, records: [ + { scope: 'user', version: '1.0.0', state: 'present', installationState: 'enabled' }, + { scope: 'project', version: '2.0.0', state: 'present', installationState: 'disabled' }, + ] }); + expect(html).toContain('所有项目 · v1.0.0 · 已启用 / 仅此项目 · v2.0.0 · 已停用'); + expect(html).not.toContain('安装目录缺失'); expect(html).not.toContain('安装记录异常'); +}); diff --git a/plugins/dsh/test/installed-references.test.ts b/plugins/dsh/test/installed-references.test.ts index 0a806c1cf..5cb308676 100644 --- a/plugins/dsh/test/installed-references.test.ts +++ b/plugins/dsh/test/installed-references.test.ts @@ -45,7 +45,7 @@ async function fixture(run: (fixture: { } function record(home: string, index: number): InstalledProjectionItem { - return { namespace: 'demo', slug: `helper-${index}`, version: '1.0', scope: 'user', state: 'present', + return { namespace: 'demo', slug: `helper-${index}`, version: '1.0', scope: 'user', state: 'present', installationState: 'enabled', fingerprint: 'fingerprint', targetPath: join(home, 'skills', `helper-${index}`) }; } @@ -110,3 +110,18 @@ test('identical release reinstalled or ownership changed invalidates the old ref expect((await list()).items[0]!.itemKey).not.toBe(key); }); }); + + +test('toggle invalidates old action references but preserves exact location identity', async () => { + await fixture(async ({ records, home, list, read }) => { + records.push(record(home, 0)); + const before = (await list()).items[0]; + records[0].installationState = 'disabled'; + const after = (await list()).items[0]; + expect(after.installationState).toBe('disabled'); + expect(after.locationKey).toBe(before.locationKey); + expect(after.itemKey).not.toBe(before.itemKey); + await expect(read(before.itemKey)).rejects.toThrow(); + await read(after.itemKey); + }); +}); diff --git a/plugins/dsh/test/integration/skill-toggle-fixture.mjs b/plugins/dsh/test/integration/skill-toggle-fixture.mjs new file mode 100644 index 000000000..764a38833 --- /dev/null +++ b/plugins/dsh/test/integration/skill-toggle-fixture.mjs @@ -0,0 +1,174 @@ +// Load a copied observer from a fresh temporary DSH profile; no model calls. +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +export const inject = ['sessionController', 'workspaceRegistry', 'skills', 'skillhub', 'typertGateway']; +export function apply(ctx, config) { + const root = resolve(config.root); + for (const path of [root, fileURLToPath(import.meta.url)]) { + const rel = relative(resolve(tmpdir()), path); + assert(rel && !rel.startsWith('..') && !isAbsolute(rel), 'Use an isolated temporary copy'); + } + assert.equal(process.env.DSH_HOME, join(root, 'home')); + assert.equal(process.env.DSH_AGENTS_HOME, join(root, 'agents')); + assert.equal(process.env.HOME, join(root, 'native-install-home')); + const abort = new AbortController(); + const timer = setTimeout(() => { + run(ctx, root, abort.signal, config.watchOff === true).then(result => ({ passed: true, ...result }), error => ({ passed: false, error: error.message })) + .then(result => writeFile(join(root, 'skill-toggle-result.json'), JSON.stringify(result, null, 2))).catch(() => {}); + }, 1000); + return () => { clearTimeout(timer); abort.abort(); }; +} + +async function hashes(directory) { + const result = {}; + async function walk(path, prefix = '') { + for (const entry of await readdir(path, { withFileTypes: true })) { + const name = prefix + entry.name; + if (entry.isDirectory()) await walk(join(path, entry.name), name + '/'); + else { assert(entry.isFile()); result[name] = createHash('sha256').update(await readFile(join(path, entry.name))).digest('hex'); } + } + } + await walk(directory); + return result; +} + +async function run(ctx, root, signal, watchOff) { + const project = join(root, 'toggle-project-' + randomUUID()); + await mkdir(join(project, '.git'), { recursive: true }); + const workspace = await ctx.workspaceRegistry.create(project); + const created = await ctx.sessionController.create({ workspaceId: workspace.id, agentPreset: 'standard' }); + const resolved = await ctx.sessionController.resolveAgent(created.sessionId); + if ('error' in resolved) throw resolved.error; + let agent = resolved.agent; + const options = { scope: agent, cwd: agent.session.header.cwd, signal }; + const invoke = (method, extra = {}) => ctx.typertGateway.invoke({ + peer: ctx.get('connection')?.browserPeer ?? ctx.get('connection')?.operator, + namespace: 'skillhub', method, args: { ...(method === 'getSkillDetail' ? {} : { agentId: agent.session.id }), ...extra }, signal, + }); + const source = ctx.skillhub.getContext(); + const sourceRevision = source.sourceRevision; + const detail = await invoke('getSkillDetail', { sourceRevision, namespace: process.env.DSH_FIXTURE_NAMESPACE ?? 'demo', slug: 'document-helper', version: '1.2.0' }); + const targets = await invoke('listInstallTargets', { detailToken: detail.detailToken }); + const target = targets.targets.find(item => item.scope === 'project'); assert(target); + const operations = []; + async function complete(receipt) { + assert(receipt); const deadline = Date.now() + 20000; + while (Date.now() < deadline) { + const operation = await invoke('getOperation', { operationId: receipt.operationId, hostInstanceId: receipt.context.hostInstanceId }); + if (operation.phase === 'completed') { operations.push({ kind: operation.kind, files: operation.files, discovery: operation.discovery }); return operation; } + assert(!['failed', 'recovery-required', 'unknown'].includes(operation.phase), JSON.stringify(operation)); + await delay(50, undefined, { signal }); + } + throw new Error('Operation timed out'); + } + await complete(await invoke('startInstall', { input: { detailToken: detail.detailToken, targetToken: target.targetToken, + hostInstanceId: targets.hostInstanceId, requestKey: randomUUID(), mode: 'new', initiatedFrom: 'plaza' } })); + const directory = join(project, '.dsh/skills/document-helper'); + const before = await hashes(directory); + const list = async () => (await invoke('listInstalled', { sourceRevision })).items.find(item => item.scope === 'project' && item.slug === 'document-helper'); + async function discovered(present) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + const snapshot = await ctx.skills.snapshot(options); + const loaded = await ctx.skills.get('document-helper', options); + if (snapshot.complete && snapshot.skills.some(item => item.name === 'document-helper') === present && !!loaded === present) return loaded; + await delay(100, undefined, { signal }); + } + throw new Error(`Full Host discovery did not become ${present}`); + } + const loaded = await discovered(true); assert.equal(loaded.resourceBase.path, directory); + async function mutate(action, item, version = null) { + const prepared = await invoke('beginConversationManagement', { sourceRevision, itemKey: item.itemKey, action, version, requestKey: randomUUID() }); + assert.equal(prepared.status, 'awaiting-confirmation', JSON.stringify(prepared)); + const receipt = await invoke('confirmConversationManagement', { confirmationRef: prepared.confirmationRef, confirmed: true }); + assert.equal(receipt.status, 'accepted'); return complete(receipt.operation); + } + const original = await list(); + if (watchOff) { + await mutate('disable', original); + const selected = await list(); + const diagnosis = await invoke('checkAvailability', { sourceRevision, itemKey: selected.itemKey }); + assert.equal(diagnosis.installationState, 'disabled'); + assert.equal(diagnosis.integrity, 'verified'); + assert.equal((await invoke('prepareSkillUse', { sourceRevision, itemKey: selected.itemKey })).reason, 'installation-disabled'); + assert.equal(operations.at(-1).discovery, 'unconfirmed'); + const snapshot = await ctx.skills.snapshot(options); + const cached = await ctx.skills.get('document-helper', options); + await mutate('uninstall', selected); + return { operations, watchOff: true, diagnosis: { installationState: diagnosis.installationState, + discovery: diagnosis.discovery, reason: diagnosis.reason }, + snapshotComplete: snapshot.complete, snapshotContainsName: snapshot.skills.some(item => item.name === 'document-helper'), + loadedAfterDisable: !!cached, noInstantRevocationClaim: true }; + } + const cancelled = await invoke('beginConversationManagement', { sourceRevision, itemKey: original.itemKey, action: 'disable', version: null, requestKey: randomUUID() }); + assert.equal((await invoke('confirmConversationManagement', { confirmationRef: cancelled.confirmationRef, confirmed: false })).status, 'cancelled'); + assert.deepEqual(await hashes(directory), before); + await mutate('disable', original); + await discovered(false); + let item = await list(); assert.equal(item.installationState, 'disabled'); assert.equal(item.state, 'present'); + assert.equal(item.locationKey, original.locationKey); assert.notEqual(item.itemKey, original.itemKey); + const disabled = await hashes(directory); + assert.equal(disabled['SKILL.md.disabled'], before['SKILL.md']); assert(!('SKILL.md' in disabled)); + const canonical = { ...disabled, 'SKILL.md': disabled['SKILL.md.disabled'] }; delete canonical['SKILL.md.disabled']; + assert.deepEqual(canonical, before); + assert.equal(await readFile(join(loaded.resourceBase.path, 'references/note.md'), 'utf8'), 'Native installation resource'); + await assert.rejects(invoke('prepareSkillUse', { sourceRevision, itemKey: original.itemKey })); + assert.equal((await invoke('prepareSkillUse', { sourceRevision, itemKey: item.itemKey })).reason, 'installation-disabled'); + await mutate('enable', item); await discovered(true); assert.deepEqual(await hashes(directory), before); + item = await list(); await mutate('disable', item); await discovered(false); + item = await list(); await mutate('upgrade', item, '1.3.0'); + item = await list(); assert.equal(item.installationState, 'disabled'); assert.equal(item.version, '1.3.0'); + assert.equal(await readFile(join(directory, 'references/note.md'), 'utf8'), 'Native upgraded resource 1.3.0'); + assert(!('SKILL.md' in await hashes(directory))); await discovered(false); + await mutate('uninstall', item); assert.equal(await list(), undefined); await discovered(false); + // Independent same-name user/project installations: only the project entry is disabled. + const installScope = async scope => { + const targets = await invoke('listInstallTargets', { detailToken: detail.detailToken }); + const selected = targets.targets.find(item => item.scope === scope); assert(selected); + await complete(await invoke('startInstall', { input: { detailToken: detail.detailToken, targetToken: selected.targetToken, + hostInstanceId: targets.hostInstanceId, requestKey: randomUUID(), mode: 'new', initiatedFrom: 'plaza' } })); + }; + await installScope('user'); await installScope('project'); + await discovered(true); + const userDirectory = join(root, 'home/skills/document-helper'); + const userBefore = await hashes(userDirectory); + await mutate('disable', await list()); + const deadline = Date.now() + 15000; + let fallback; + do { + await ctx.skills.snapshot(options); fallback = await ctx.skills.get('document-helper', options); + if (fallback?.resourceBase?.path === userDirectory) break; + await delay(100, undefined, { signal }); + } while (Date.now() < deadline); + const originalSessionFallback = fallback?.resourceBase?.path === userDirectory; + const originalSelected = await list(); + const originalDiagnosis = await invoke('checkAvailability', { sourceRevision, itemKey: originalSelected.itemKey }); + if (!originalSessionFallback) { + assert.equal(originalDiagnosis.installationState, 'disabled'); + assert.equal(originalDiagnosis.discovery, 'unknown'); + const fresh = await ctx.sessionController.create({ workspaceId: workspace.id, agentPreset: 'standard' }); + const current = await ctx.sessionController.resolveAgent(fresh.sessionId); + if ('error' in current) throw current.error; + agent = current.agent; options.scope = agent; + await ctx.skills.snapshot(options); fallback = await ctx.skills.get('document-helper', options); + } + assert.equal(fallback?.resourceBase?.path, userDirectory); + const selected = await list(); + const diagnosis = await invoke('checkAvailability', { sourceRevision, itemKey: selected.itemKey }); + assert.equal(diagnosis.installationState, 'disabled'); assert.equal(diagnosis.discovery, 'shadowed'); + assert.equal(diagnosis.reason, 'same-name-winner'); assert.equal(diagnosis.name, 'document-helper'); + assert.deepEqual(await hashes(userDirectory), userBefore); + assert.equal((await invoke('prepareSkillUse', { sourceRevision, itemKey: selected.itemKey })).reason, 'installation-disabled'); + await mutate('uninstall', selected); + const userItem = (await invoke('listInstalled', { sourceRevision })).items.find(item => item.scope === 'user' && item.slug === 'document-helper'); + await mutate('uninstall', userItem); + return { operations, originalSessionFallback, originalSessionDiscovery: originalDiagnosis.discovery, newSessionRequired: !originalSessionFallback, sameNameFallbackObserved: true, fallbackUntouched: true, cancellation: true, oldReferenceRejected: true, resourcePathsPreserved: true, + canonicalDigestsPreserved: true, disabledUpgrade: true, disabledUninstall: true, fullHostSnapshotAndGet: true, + confirmation: 'trusted Remote automation; not a human browser or model test' }; +} diff --git a/plugins/dsh/test/local-core-package.test.ts b/plugins/dsh/test/local-core-package.test.ts index a31bb8175..5c0eef837 100644 --- a/plugins/dsh/test/local-core-package.test.ts +++ b/plugins/dsh/test/local-core-package.test.ts @@ -146,7 +146,7 @@ assert.equal((await readdir(root)).includes('node_modules'), false); // runtime must reject it without changing installed resources or inventory. const journalPath = join(root, 'state', operation.operationId + '.json'); const journal = JSON.parse(await readFile(journalPath, 'utf8')); -const futureBytes = JSON.stringify({ ...journal, schemaVersion: 3 }); +const futureBytes = JSON.stringify({ ...journal, schemaVersion: 4 }); await writeFile(journalPath, futureBytes); const inventoryBefore = await new InventoryStore(home).read(); const filesBefore = (await readdir(join(root, 'state'))).sort(); diff --git a/plugins/dsh/test/management-context.test.ts b/plugins/dsh/test/management-context.test.ts index e1320191d..adc40e01a 100644 --- a/plugins/dsh/test/management-context.test.ts +++ b/plugins/dsh/test/management-context.test.ts @@ -17,7 +17,7 @@ function snapshot(text: string) { return JSON.parse(text.slice(text.lastIndexOf( test('snapshot exposes bounded installation facts, not routing references, paths or integrity claims', () => { const text = installationContext(list); expect(snapshot(text)).toEqual({ source: 'currently configured SkillHub registry', complete: true, truncated: false, unavailable: [], totalRecords: 1, - items: [{ namespace: 'team', name: 'writer', version: '1.0.0', scope: 'project', state: 'present' }] }); + items: [{ namespace: 'team', name: 'writer', version: '1.0.0', scope: 'project', state: 'present', installationState: 'enabled' }] }); expect(text).not.toContain(item.itemKey); expect(text).not.toContain('/private'); expect(text).toContain('Presence does not prove content integrity'); const literal = installationContext({ ...list, items: [{ ...item, slug: '{{private_variable}}' }] }); diff --git a/plugins/dsh/test/management-tool.test.ts b/plugins/dsh/test/management-tool.test.ts index 6ef9511f1..7cc318c22 100644 --- a/plugins/dsh/test/management-tool.test.ts +++ b/plugins/dsh/test/management-tool.test.ts @@ -16,7 +16,7 @@ test('management registration preserves occupied tools and strict parameters rej const dispose = registerManagementTools(tools, async () => { calls++; return {}; }); const tool = tools.get(MANAGEMENT_TOOLS.upgrade)!; expect(() => registerManagementTools(tools, async () => ({}))).toThrow('already registered'); - expect(tools.entries.size).toBe(6); + expect(tools.entries.size).toBe(8); for (const extra of [{ confirmed: true }, { planId: 'plan' }, { path: '/forged' }]) { await expect(tool.execute({ sourceRevision: 'source', itemKey: 'item', ...extra }, context)).rejects.toThrow(); } diff --git a/plugins/dsh/test/management-view.test.tsx b/plugins/dsh/test/management-view.test.tsx index 461c61245..416fe87b1 100644 --- a/plugins/dsh/test/management-view.test.tsx +++ b/plugins/dsh/test/management-view.test.tsx @@ -2,21 +2,21 @@ import { expect, test } from 'bun:test'; import { renderToStaticMarkup } from 'react-dom/server'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { parseManagementResult, type ManagementViewResult, type ManagementReceipt } from '../src/client/management-result.js'; -import { ManagementContent, groupManagementItems, managementTargetLabel, verifyManagementReference, verifiedManagementUse, type ManagementViewServices } from '../src/client/management-tool-view.js'; +import { availabilityNotice, ManagementContent, InstallationManagement, ManagementLocations, ManagementConfirmation, refreshManagementLocation, groupManagementItems, managementTargetLabel, verifyManagementReference, verifiedManagementUse, type ManagementViewServices } from '../src/client/management-tool-view.js'; import { InstallationRequests } from '../src/client/installation-requests.js'; const base = { schemaVersion: 1 as const, toolCallId: 'call-1', sourceRevision: 'revision', sourceLabel: 'https://private-source.example' }; const item = { itemKey: 'internal-key', namespace: 'global', slug: 'notes', version: '1.0.0', scope: 'user' as const, state: 'present' as const }; const installed = { ...base, kind: 'skillhub-installed' as const, items: [item], complete: true, unavailable: [], issues: [] }; const pending: ManagementReceipt = { ...base, kind: 'skillhub-management', action: 'upgrade', item, status: 'awaiting-confirmation', candidateVersion: '2.0.0', reason: '', confirmationRef: 'secret-confirmation-ref', operation: null }; -function render(result: ManagementViewResult, operation?: object) { +function render(result: ManagementViewResult, operation?: object, locations = false) { const client = new QueryClient(); if (operation && result.kind === 'skillhub-management' && result.operation) client.setQueryData(['skillhub-operation', 'session', result.operation.context.hostInstanceId, result.operation.operationId], operation); const requests = new InstallationRequests(); const unexpected = async () => { throw new Error('Rendering cannot execute requests'); }; - const services: ManagementViewServices = { client, refreshInstalled: unexpected, check: unexpected, begin: unexpected, confirm: unexpected, assertCurrent() {}, - installation: { sessionId: 'session', requests, targets: unexpected, installed: unexpected, start: unexpected, operation: unexpected, compatibility: unexpected } }; - try { return renderToStaticMarkup(); } + const services: ManagementViewServices = { selectSkillUse: unexpected, client, refreshInstalled: unexpected, check: unexpected, begin: unexpected, confirm: unexpected, assertCurrent() {}, + installation: { manageInstalled() {}, skillUse: { useSkill: unexpected }, sessionId: 'session', requests, targets: unexpected, installed: unexpected, start: unexpected, operation: unexpected, compatibility: unexpected } }; + try { return renderToStaticMarkup({locations && result.kind === 'skillhub-installed' ? : }); } finally { requests.dispose(); client.clear(); } } test('metadata belongs to this call and invalid records cannot offer management controls', () => { @@ -29,9 +29,9 @@ test('metadata belongs to this call and invalid records cannot offer management }); test('list is current-source scoped, limited, and keeps distinct actual installation scopes', () => { const html = render({ ...installed, items: Array.from({ length: 8 }, (_, i) => ({ ...item, slug: `notes-${i}`, itemKey: `key-${i}`, scope: i === 0 ? 'project' : 'user' })) }); - expect(html).toContain('当前来源'); expect(html).toContain('当前项目'); expect(html).toContain('当前用户'); + expect(html).toContain('当前来源'); expect(html).not.toContain('当前项目'); expect(html).not.toContain('当前用户'); expect(html).toContain('notes-5'); expect(html).not.toContain('notes-6'); expect(html).toContain('查看更多'); - expect(html).toContain('更多操作'); expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('管理安装'); expect(html).toContain('使用技能'); expect(html).not.toContain('aria-label="技能操作"'); expect(html).not.toContain('重新查询'); expect(html).not.toContain('刷新安装'); expect(html).not.toContain('查询时的安装记录'); expect(html).not.toContain(base.sourceLabel); expect(html).not.toContain('key-0'); @@ -55,7 +55,7 @@ test('local changes and cancelled result never show a submit button', () => { test('failed update lookup cannot render as latest and missing installation offers record cleanup', () => { const html = render({ ...base, kind: 'skillhub-update-check', item, check: { state: 'unavailable', currentVersion: '1.0.0', candidateVersion: null, reason: 'network', checkedAt: new Date().toISOString() } }); expect(html).toContain('更新检查未完成'); expect(html).not.toContain('已是最新版本'); - const missing = render({ ...installed, items: [{ ...item, state: 'missing' }] }); + const missing = render({ ...installed, items: [{ ...item, state: 'missing' }] }, undefined, true); expect(missing).toContain('清理记录'); expect(missing).not.toContain('>卸载'); }); test('author-controlled names stay escaped plain text', () => { @@ -82,7 +82,7 @@ test('operation result is compact, keeps warning and scope, and distinguishes pr const result = { schemaVersion: 1 as const, kind: 'skillhub-operation' as const, toolCallId: 'call-1', operation: op }; expect(render(result)).toContain('更新处理中'); expect(render(result)).not.toContain('检查更新'); const warning = render({ ...result, operation: { ...op, phase: 'completed', warningCount: 1, discovery: 'failed', recovery: 'unknown' } }); - expect(warning).toContain('有告警需核验'); expect(warning).toContain('恢复状态待核验'); expect(warning).toContain('DSH 发现尚待确认'); + expect(warning).toContain('有告警需核验'); expect(warning).toContain('恢复状态待核验'); expect(warning).not.toContain('DSH 发现尚待确认'); expect(warning).toContain('@global/notes'); expect(warning).toContain('当前用户'); expect(parseManagementResult(result, 'call-1')).toEqual(result); }); @@ -111,7 +111,7 @@ test('completed receipt stays concise while incomplete discovery and warnings re const html = render(result, clean); expect(html).toContain('更新已完成。'); expect(html).not.toContain('查询操作状态'); expect(html).not.toContain('检查更新'); expect(html).toContain('v1.0.0 → v2.0.0'); const uncertain = render(result, { ...clean, warningCount: 1, discovery: 'unconfirmed' }); - expect(uncertain).toContain('有告警需核验'); expect(uncertain).toContain('DSH 发现尚待确认'); expect(uncertain).toContain('查询操作状态'); + expect(uncertain).toContain('有告警需核验'); expect(uncertain).not.toContain('DSH 发现尚待确认'); expect(uncertain).toContain('查询操作状态'); }); test('read retry is reserved for unavailable or stale results, not healthy or completed cards', () => { @@ -142,15 +142,17 @@ test('grouping preserves exact targets and separates namespace identities', () = expect(oneSkill).toContain('data-single="true"'); expect((oneSkill.match(/class="astron-sh-management-group"/gu) ?? []).length).toBe(1); expect((html.match(/class="astron-sh-management-group"/gu) ?? []).length).toBe(2); - expect(html).toContain('当前用户 1'); expect(html).toContain('当前用户 2'); - expect(html).toContain('v2.0.0'); expect(html).not.toContain('v3.0.0'); + const managed = render({ ...installed, items: [item, project, secondUser] }, undefined, true); + expect(managed).toContain('所有项目 1'); expect(managed).toContain('所有项目 2'); + expect(managed).toContain('v2.0.0'); expect(managed).toContain('v3.0.0'); + expect(managed).not.toContain('role="tab"'); for (const key of ['internal-key', 'project-key', 'second-user-key', 'another-key']) expect(html).not.toContain(key); }); test('single installation has no tab and unhealthy targets do not offer destructive actions', () => { expect(render(installed)).not.toContain('role="tab"'); expect(render(installed)).not.toContain('role="tabpanel"'); for (const state of ['metadata-invalid', 'mismatch', 'unreadable'] as const) { const html = render({ ...installed, items: [{ ...item, state }] }); - expect(html).toContain('重新查询'); expect(html).not.toContain('更多操作'); + expect(html).toContain('管理安装'); expect(html).not.toContain('更多操作'); expect(html).not.toContain('使用此技能'); expect(html).not.toContain('>卸载'); } const partial = render({ ...installed, complete: false, unavailable: ['project'] }); @@ -158,7 +160,7 @@ test('single installation has no tab and unhealthy targets do not offer destruct }); test('normal controls use one disclosure, no permanent query maintenance button', () => { const html = render(installed); - expect(html).toContain('aria-label="更多操作"'); expect(html).toContain('aria-expanded="false"'); expect(html).not.toContain('aria-label="技能操作"'); + expect(html).toContain('管理安装'); expect(html).toContain('使用技能'); expect(html).not.toContain('aria-label="技能操作"'); expect(html).not.toContain('重新查询'); expect(html).not.toContain('操作详情'); }); test('unknown operation offers only original-operation lookup and processing receipt has no resubmit', () => { @@ -176,11 +178,9 @@ test('mixed single and dual installations share identity, position and action sl { ...item, slug: 'dual', scope: 'project', itemKey: 'dual-project' }, { ...item, slug: 'dual', scope: 'user', itemKey: 'dual-user' }, ] }); - expect(html.match(/class="astron-sh-management-position"/g)).toHaveLength(2); - expect(html.match(/aria-label="安装位置"/g)).toHaveLength(1); - expect(html).toContain('当前用户'); - expect(html).not.toContain('当前用户 · '); - expect(html.match(/class="astron-sh-management-version"/g)).toHaveLength(2); + expect(html.match(/class="astron-sh-management-group"/g)).toHaveLength(2); + expect(html.match(/>管理安装<\/button>/g)).toHaveLength(2); + expect(html).not.toContain('role="tab"'); expect(html).not.toContain('当前用户'); }); test('two-column catalog initially shows six cards and offers more for larger totals', () => { @@ -216,3 +216,127 @@ test('standalone missing definition identifies the selected installation without expect(html).toContain('@team/selected-package'); expect(html).toContain('v1.2.0'); expect(html).toContain('当前用户'); expect(html).toContain('文件缺失'); expect(html).not.toContain(' { + const disabled = { ...item, installationState: 'disabled' as const, locationKey: 'location-a' }; + const result = { ...installed, items: [disabled] }; + expect(parseManagementResult(result, 'call-1')).toEqual(result); + const html = render(result); + expect(html).toContain('已停用'); expect(html).toContain('管理安装'); expect(html).not.toContain('>启用'); + const advanced = render(result, undefined, true); expect(advanced).toContain('>启用'); expect(advanced).not.toContain('使用此位置'); + expect(html).not.toContain('使用此技能'); expect(html).not.toContain('安装文件缺失'); + expect(html).not.toContain('location-a'); expect(html).not.toContain('更多操作'); +}); +test('toggle confirmations preserve exact scope and explain historical context limits', () => { + for (const action of ['enable', 'disable'] as const) { + const value = { ...pending, action, candidateVersion: null }; + expect(parseManagementResult(value, 'call-1')).toEqual(value); + const html = render(value); + expect(html).toContain(action === 'enable' ? '确认启用' : '确认停用'); + expect(html).toContain('当前用户'); expect(html).toContain('取消'); + expect(html).toContain(action === 'enable' ? '保留原有调用权限' : '不撤回已加载内容'); + expect(html).not.toContain('确认卸载'); + } + const shared = render({ ...pending, action: 'disable', status: 'blocked', confirmationRef: null, reason: 'shared-target' }); + expect(shared).toContain('多个来源共同管理'); expect(shared).not.toContain('确认停用'); +}); +test('disabled reference cannot use even if a historical key was incorrectly retained', async () => { + let uses = 0; + const services = { assertCurrent() {}, installation: { + installed: async () => ({ ok: true as const, value: { ...installed, items: [{ ...item, installationState: 'disabled' }] } }), + skillUse: { useSkill: async () => { uses++; } }, + } } as unknown as ManagementViewServices; + const use = verifiedManagementUse(services, 'revision', item.itemKey, () => {})!; + await expect(use.useSkill('revision', item.itemKey, new AbortController().signal)).rejects.toMatchObject({ code: 'skillhub/context-stale' }); + expect(uses).toBe(0); +}); +test('disabled availability distinguishes installation state from author invocation policy', () => { + const value = { schemaVersion: 1 as const, kind: 'skillhub-availability' as const, toolCallId: 'call-1', diagnosis: { + checkedAt: 'now', installationState: 'disabled' as const, association: 'managed-reference' as const, + integrity: 'verified' as const, discovery: 'unknown' as const, reason: 'installation-disabled' as const, + invocation: { userInvocable: true, modelInvocable: false }, + } }; + expect(parseManagementResult(value, 'call-1')).toEqual(value); + const html = render(value); + expect(html).toContain('该安装已停用'); expect(html).toContain('用户调用允许'); expect(html).toContain('模型调用禁用'); +}); + +test.each([ + ['shadowed', 'same-name-winner', '所选安装被同名技能覆盖'], + ['not-discovered', 'not-discovered', '当前会话未发现此技能'], + ['winner', 'installation-disabled', '当前会话仍发现此安装'], + ['unknown', 'snapshot-incomplete', '宿主技能列表不完整'], +] as const)('standalone disabled diagnosis preserves state with %s discovery', (discovery, reason, observation) => { + const html = render({ schemaVersion: 1, kind: 'skillhub-availability', toolCallId: 'call-1', diagnosis: { + checkedAt: 'now', installationState: 'disabled', association: 'managed-reference', integrity: 'verified', discovery, reason, + } }); + expect(html).toContain('该安装已停用'); + expect(html).toContain(observation); + expect(html).not.toContain('使用技能'); + expect(html).not.toContain('用户调用允许'); +}); + +test('post-success refresh requires stable exact location and same source, never scope-only rebinding', async () => { + const target = { ...item, locationKey: 'location-user-a' }; + const next = { ...target, itemKey: 'after-toggle', installationState: 'disabled' as const }; + const sibling = { ...target, locationKey: 'location-user-b', itemKey: 'sibling' }; + const services = { assertCurrent() {}, refreshInstalled: async () => ({ sourceRevision: 'revision', list: { ...installed, items: [sibling, next] } }) } as unknown as ManagementViewServices; + expect((await refreshManagementLocation(services, 'revision', target)).items).toEqual([sibling, next]); + await expect(refreshManagementLocation(services, 'changed-source', target)).rejects.toMatchObject({ code: 'skillhub/context-stale' }); + await expect(refreshManagementLocation(services, 'revision', item)).rejects.toMatchObject({ code: 'skillhub/context-stale' }); + services.refreshInstalled = async () => ({ sourceRevision: 'revision', list: { ...installed, items: [sibling] } }); + await expect(refreshManagementLocation(services, 'revision', target)).rejects.toMatchObject({ code: 'skillhub/context-stale' }); +}); + +test('normal toggle operation is concise without asserting discovery success', () => { + const operation = { operationId: '11111111-1111-4111-8111-111111111111', context: { hostInstanceId: '22222222-2222-4222-8222-222222222222' }, kind: 'disable', slug: 'notes', version: '1.0.0', targetLabel: '当前用户', phase: 'completed', warningCount: 0, discovery: 'unconfirmed' as const }; + const html = render({ schemaVersion: 1, kind: 'skillhub-operation', toolCallId: 'call-1', operation }); + expect(html).toContain('停用已完成'); expect(html).not.toContain('DSH 发现尚待确认'); expect(html).not.toContain('已就绪'); expect(html).not.toContain('该位置已停用'); expect(html).not.toContain('查询原操作'); +}); + +test('fallback toggle confirmation has one scope and impact explanation with a separated footer', () => { + const client = new QueryClient(); + const services = { installation: {} } as ManagementViewServices; + const result = { ...pending, action: 'disable' as const, candidateVersion: null }; + try { + const html = renderToStaticMarkup(); + expect(html).toContain('笔记助手'); expect(html).toContain('@global/notes · v1.0.0'); + expect(html).toContain('所有项目 · 当前用户'); expect(html).toContain('不撤回已加载内容'); + expect(html).toContain('其他同名安装可能仍可用'); expect(html).toContain('astron-sh-dialog-actions'); + expect(html).not.toContain('确认停用这份安装?'); expect(html).not.toContain('影响当前用户的多个项目'); + } finally { client.clear(); } +}); + +test('management location list renders an explicit empty state after the final installation is removed', () => { + const html = render({ ...installed, items: [] }, undefined, true); + expect(html).toContain('该技能暂无安装记录'); expect(html).not.toContain(' { + const client = new QueryClient(); + const requests = new InstallationRequests(); + const unexpected = async () => { throw new Error('Rendering cannot execute requests'); }; + const services: ManagementViewServices = { selectSkillUse: unexpected, client, refreshInstalled: unexpected, check: unexpected, begin: unexpected, confirm: unexpected, assertCurrent() {}, + installation: { sessionId: 'session', requests, targets: unexpected, installed: unexpected, start: unexpected, operation: unexpected, compatibility: unexpected } }; + const key = ['skillhub-installed', 'session', 'revision', 'management-locations', item.namespace, item.slug]; + const renderPanel = () => renderToStaticMarkup(); + try { + client.setQueryData(key, { ...installed, items: [{ ...item, installationState: 'disabled' }, { ...item, itemKey: 'project-key', scope: 'project', version: '2', installationState: 'enabled' }] }); + const html = renderPanel(); + expect(html).toContain('aria-label="安装管理"'); expect(html).toContain('所有项目 v1.0.0'); expect(html).toContain('仅此项目 v2'); + expect(html).toContain('>启用'); expect(html).not.toContain('>停用'); + expect(html.match(/aria-label="更多操作"/g)).toHaveLength(2); + expect(html).not.toContain('使用此位置'); expect(html).not.toContain('检查可用性'); expect(html).not.toContain('>卸载'); + expect(html).not.toContain('role="dialog"'); expect(html).not.toContain('role="combobox"'); expect(html).not.toContain('>管理安装'); + client.setQueryData(key, { ...installed, items: [] }); + const empty = renderPanel(); expect(empty).toContain('该技能暂无安装记录'); expect(empty).not.toContain('>停用'); expect(empty).not.toContain('>启用'); + } finally { requests.dispose(); client.clear(); } +}); + +test('availability notices stay concise without converting discovery into invocation permission', () => { + const observed = { checkedAt: 'now', association: 'matched' as const, integrity: 'verified' as const, discovery: 'winner' as const, reason: 'observed' as const }; + expect(availabilityNotice({ ...observed, association: 'not-associated', integrity: 'unknown' }).success).toBe(false); + expect(availabilityNotice({ ...observed, association: 'not-associated', integrity: 'unknown', invocation: { userInvocable: false, modelInvocable: true } }).message).toContain('用户调用禁用'); + expect(availabilityNotice({ ...observed, association: 'not-associated', integrity: 'unknown', installationState: 'disabled' }).message).toContain('缓存'); + expect(availabilityNotice({ ...observed, association: 'not-associated', integrity: 'unknown', reason: 'same-name-winner' }).message).toBe('此安装暂不可用,当前会话使用了另一份同名技能。'); +}); diff --git a/plugins/dsh/test/market-card.test.tsx b/plugins/dsh/test/market-card.test.tsx index bce8b31fe..e8f994521 100644 --- a/plugins/dsh/test/market-card.test.tsx +++ b/plugins/dsh/test/market-card.test.tsx @@ -21,7 +21,7 @@ describe('market presentation and action boundaries', () => { test('unknown inventory disables direct install action', () => { const client = new QueryClient(); const render = (verified: boolean) => renderToStaticMarkup( - {}} onManage={() => {}} /> + {}} /> ); expect(render(false)).toContain('disabled=""'); expect(render(true)).not.toContain('disabled=""'); @@ -41,3 +41,29 @@ describe('market presentation and action boundaries', () => { ]) expect(catalogLabel({ slug: slug!, displayName: displayName! })).toBe(expected!); }); }); + +test.each(['single', 'mixed', 'disabled', 'abnormal'] as const)('market %s exposes use/management, never position controls', state => { + const client = new QueryClient(); + const unexpected = async () => { throw new Error('Rendering must not transport'); }; + const services = { skillUse: { useSkill: unexpected }, management: { selectSkillUse: unexpected } } as unknown as import('../src/client/installed-skills.js').InstalledServices; + const record = { itemKey: 'precise-record', namespace: 'demo', slug: 'document', version: '1', scope: 'project' as const, state: state === 'abnormal' ? 'mismatch' as const : 'present' as const, installationState: state === 'disabled' ? 'disabled' as const : 'enabled' as const }; + const records = state === 'mixed' ? [record, { ...record, itemKey: 'user', scope: 'user' as const, installationState: 'disabled' as const }] : [record]; + try { + const html = renderToStaticMarkup( {}} onInstall={() => {}} />); + expect(html).toContain('管理安装'); + expect(html.includes('使用技能')).toBe(state === 'single' || state === 'mixed'); + for (const text of ['role="combobox"', '更多操作', '>启用', '>停用', '>卸载', '检查更新']) expect(html).not.toContain(text); + expect(html.match(/astron-sh-button-default/g)?.length).toBe(1); + } finally { client.clear(); } +}); + +test('disabled and mixed installation summaries describe locations without implying callable skills', () => { + const base = { checkedAt: 'now', complete: true, unavailable: [] }; + const disabled = { scope: 'project' as const, state: 'present' as const, version: '1', installationState: 'disabled' as const }; + const single = renderToStaticMarkup(); + expect(single).toContain('data-tone="neutral"'); expect(single).toContain('>已停用 · v1 · 仅此项目'); + const mixed = renderToStaticMarkup(); + expect(mixed).toContain('所有项目 · v1 · 已启用 / 仅此项目 · v1 · 已停用'); + expect(mixed).toContain('data-multiple="true"'); expect(mixed.match(/class="astron-sh-location-summary"/g)?.length).toBe(2); + expect(mixed).toContain('文件存在不代表 DSH 已启用或技能已执行'); expect(mixed).not.toContain('可调用'); +}); diff --git a/plugins/dsh/test/package.test.ts b/plugins/dsh/test/package.test.ts index 80762758d..08a9eb2c5 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', 'beginConversationManagement', 'checkAvailability', 'checkUpdate', 'checkVersionUpdate', 'confirmConversationManagement', '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 ['checkAvailability', 'beginConversationManagement', 'confirmConversationManagement', 'openConversationDetail', 'reopenConversationDetail', 'acknowledgeConversationDetail', 'listInstallTargets', 'listInstalled', 'getInstalledDetail', 'checkUpdate', 'checkVersionUpdate', 'prepareSkillUse', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'executePlan', 'startInstall', 'getOperation', 'recoverOperation']) { + expect(TYPERT_REMOTE.descriptors.map(item => item.method).sort()).toEqual(['acknowledgeConversationDetail', 'beginConversationManagement', 'checkAvailability', 'checkUpdate', 'checkVersionUpdate', 'confirmConversationManagement', 'executePlan', 'getCompatibility', 'getContext', 'getInstalledDetail', 'getOperation', 'getSkillDetail', 'getStatus', 'listCatalog', 'listCatalogByLabel', 'listInstallTargets', 'listInstalled', 'listLabels', 'listReviews', 'listSkills', 'listVersions', 'openConversationDetail', 'prepareSkillUse', 'prepareToggle', 'prepareUninstall', 'prepareUpgrade', 'prepareVersionUpgrade', 'recoverOperation', 'reopenConversationDetail', 'selectSkillUse', 'startInstall', 'testRegistryConnection']); + for (const method of ['checkAvailability', 'beginConversationManagement', 'confirmConversationManagement', 'openConversationDetail', 'reopenConversationDetail', 'acknowledgeConversationDetail', 'listInstallTargets', 'listInstalled', 'getInstalledDetail', 'checkUpdate', 'checkVersionUpdate', 'prepareSkillUse', 'prepareToggle', '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' }); } diff --git a/plugins/dsh/test/prepare-skill-use.test.ts b/plugins/dsh/test/prepare-skill-use.test.ts index 48800e8ba..448debd8a 100644 --- a/plugins/dsh/test/prepare-skill-use.test.ts +++ b/plugins/dsh/test/prepare-skill-use.test.ts @@ -9,9 +9,10 @@ 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 { SkillAvailability, SkillUsePreparation } from '../src/host/types.js'; +import type { SkillAvailability, SkillUsePreparation, SkillUseSelection } from '../src/host/types.js'; async function fixture(run: (f: { + select(signal?: AbortSignal): Promise; availability(signal?: AbortSignal, key?: string): Promise; named(signal?: AbortSignal): Promise<{ diagnosis: SkillAvailability }>; use(signal?: AbortSignal, key?: string): Promise; key: string; directory: string; @@ -65,7 +66,7 @@ async function fixture(run: (f: { 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 }; + state: 'present', installationState: 'enabled', 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, @@ -95,7 +96,8 @@ async function fixture(run: (f: { getContext: () => ({ sourceRevision: revision, configured: true, registryUrl: 'https://registry.example' }), }); const key = (await service.listInstalled('source', new AbortController().signal)).items[0].itemKey; - await run({ availability: (signal = new AbortController().signal, selected = key) => service.checkAvailability('source', selected, signal), + await run({ select: (signal = new AbortController().signal) => service.selectSkillUse('source', 'demo', 'market-slug', signal), + availability: (signal = new AbortController().signal, selected = key) => service.checkAvailability('source', selected, signal), named: (signal = new AbortController().signal) => service.manageConversation('availability', { name: 'actual-name' }, { sessionId: 'session-a', cwd, callId: 'call' }, signal), use: (signal = new AbortController().signal, selected = key) => service.prepareSkillUse('source', selected, signal), key, directory, observations: () => observations, setLookup: value => { lookup = value; }, @@ -407,3 +409,36 @@ test('missing managed definition preserves selected installation identity withou expect((await f.named()).diagnosis.installation).toBeUndefined(); }); }); + + +test('coordinate selection observes the real native winner and rejects incomplete discovery', async () => { + await fixture(async f => { + expect(await f.select()).toEqual({ status: 'ready', itemKey: f.key }); + f.change('incomplete'); + expect(await f.select()).toEqual({ status: 'blocked', reason: 'selection-unverified' }); + }); +}); + +test('coordinate selection rejects external winners and context changes during native reads', async () => { + await fixture(async f => { + f.setLookup(async loaded => ({ ...loaded, resourceBase: { kind: 'directory', path: '/external' } })); + expect(await f.select()).toEqual({ status: 'blocked', reason: 'no-usable-installation' }); + f.setLookup(async loaded => { f.change('skills'); return loaded; }); + await expect(f.select()).rejects.toThrow('changed'); + }); +}); + +test('coordinate selection preserves session, source, filesystem and runtime identity fences', async () => { + for (const kind of ['source', 'session', 'cwd', 'agent', 'fs', 'skills', 'runtime', 'dispose']) { + await fixture(async f => { + f.setLookup(async loaded => { f.change(kind); return loaded; }); + await expect(f.select()).rejects.toThrow(); + }); + } + for (const kind of ['record', 'version']) { + await fixture(async f => { + f.setLookup(async loaded => { f.change(kind); return loaded; }); + expect(await f.select()).toEqual({ status: 'blocked', reason: 'selection-unverified' }); + }); + } +}); diff --git a/plugins/dsh/test/prepare-toggle.test.ts b/plugins/dsh/test/prepare-toggle.test.ts new file mode 100644 index 000000000..5dbe3e26d --- /dev/null +++ b/plugins/dsh/test/prepare-toggle.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from 'bun:test'; +import { realpath, mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import SkillHubService from '../src/host/index.js'; +import { TogglePlans } from '../src/host/upgrade-plans.js'; +import type { ToggleBaseline } from '../src/host/generated/native-contract.js'; + +test('toggle preparation is local, preserves exact target, and blocks recovery or read-only policies', async () => { + const cwd = await realpath(await mkdtemp(join(tmpdir(), 'skillhub-prepare-toggle-'))); + try { + await mkdir(join(cwd, '.git')); + const root = join(cwd, '.dsh/skills'); + const scope = { sessionId: 'session', sourceRevision: 'source', cwd }; + const identity = { registry: 'https://offline.invalid', namespace: 'demo', slug: 'writer', version: '1', fingerprint: 'fp' }; + let baseline: ToggleBaseline = { status: 'ready', beforeState: 'enabled', afterState: 'disabled', inventoryItem: { ...identity, targets: [] }, + target: { scope: 'project', root, path: join(root, 'writer'), metadata: identity, files: { 'SKILL.md': 'hash' } } }; + let mode = 'danger-full-access'; let pending = 0; + const plans = new TogglePlans(); + const context = { lifetime: new AbortController(), togglePlans: plans, selectionScope: () => scope, + getContext: () => ({ registryUrl: identity.registry }), + readSource: () => { throw new Error('Toggle must not query Registry'); }, + readInstalledItem: async () => ({ item: { ...identity, state: 'present', installationState: 'enabled', scope: 'project', targetPath: join(root, 'writer') }, valid() {}, + agent: { session: { id: 'session', header: { cwd } }, ctx: { get: () => ({ resolve: () => ({ mode }) }) } }, + fs: { sandboxMode: 'workspace-write', resolve: async (path: string) => path, + processPath: (path: string) => path, processPathFromHostPath: (path: string) => path }, + runtime: { getStatus: () => ({ pendingOperations: pending, hostInstanceId: 'host' }), + inspectToggle: async () => baseline } }), + }; + Object.setPrototypeOf(context, SkillHubService.prototype); + const prepare = () => Reflect.apply(SkillHubService.prototype.prepareToggle, context, ['source', 'item', 'disabled', new AbortController().signal]); + const ready = await prepare(); + expect(ready).toMatchObject({ status: 'ready', beforeState: 'enabled', afterState: 'disabled', target: { scope: 'project', path: join(root, 'writer') } }); + expect(plans.read(ready.planId!, scope)).toEqual(baseline); + baseline = { status: 'unchanged', installationState: 'disabled' }; + expect(await prepare()).toMatchObject({ status: 'unchanged', planId: null }); + baseline = { status: 'blocked', reason: 'entry-conflict' }; + expect(await prepare()).toMatchObject({ status: 'blocked', reason: 'entry-conflict', planId: null }); + pending = 1; + expect(await prepare()).toMatchObject({ status: 'blocked', reason: 'recovery-required' }); + pending = 0; mode = 'read-only'; + await expect(prepare()).rejects.toThrow('read-only'); + } finally { await rm(cwd, { recursive: true, force: true }); } +}); + +test('disabled installation refuses use before discovery or reading examples', async () => { + const context = { lifetime: new AbortController(), ctx: { get: () => { throw new Error('No discovery needed'); } }, + readInstalledItem: async () => ({ item: { installationState: 'disabled' }, valid() {}, agent: { session: { header: { cwd: '/tmp' } } } }) }; + // Discovery service may be captured, but must never be invoked for a disabled target. + context.ctx.get = () => undefined as never; + expect(await Reflect.apply(SkillHubService.prototype.prepareSkillUse, context, ['source', 'item', new AbortController().signal])) + .toEqual({ status: 'blocked', reason: 'installation-disabled' }); +}); diff --git a/plugins/dsh/test/runtime-execution.test.ts b/plugins/dsh/test/runtime-execution.test.ts index 4f6581e4d..a280b79d0 100644 --- a/plugins/dsh/test/runtime-execution.test.ts +++ b/plugins/dsh/test/runtime-execution.test.ts @@ -4,12 +4,16 @@ import type { NativeRuntime, NativeInstallExecution, NativeRecoveryResult, Nativ function native(): NativeRuntime { return { + readInstallationEntry: async () => undefined, readSkillExamples: async () => ({ state: 'none' }), observeInstallation: async () => false, hostInstanceId: 'fixture', listPending: async () => [], getOperation: async () => undefined, acceptInstall: async () => undefined, acceptUpgrade: async () => undefined, findPlanOperation: async () => undefined, + acceptToggle: async () => undefined, + inspectToggle: async () => { throw new Error('Unexpected toggle inspection'); }, + executeToggle: async () => { throw new Error('Unexpected toggle'); }, acceptRemoval: async () => undefined, executeRemoval: async () => { throw new Error('Unexpected removal execution'); }, executeUpgrade: async () => { throw new Error('Unexpected upgrade'); }, @@ -38,6 +42,7 @@ test('an unverified Host blocks all new mutations but still queries and recovers core.acceptInstall = rejectNew; core.acceptUpgrade = rejectNew; core.acceptRemoval = rejectNew; + core.acceptToggle = rejectNew; core.getOperation = async () => accepted; const runtime = new LocalRuntime(async () => core, () => { throw new Error('Host dependency mismatch'); }); await runtime.start(); @@ -47,6 +52,7 @@ test('an unverified Host blocks all new mutations but still queries and recovers await expect(runtime.submitInstall(input, prepare)).rejects.toThrow('Host dependency mismatch'); await expect(runtime.submitUpgrade(input, prepare)).rejects.toThrow('Host dependency mismatch'); await expect(runtime.submitRemoval(input, prepare)).rejects.toThrow('Host dependency mismatch'); + await expect(runtime.submitToggle(input, prepare)).rejects.toThrow('Host dependency mismatch'); expect(prepared).toBe(false); expect(await runtime.getOperation('operation', 'owner')).toEqual(accepted); core.acceptInstall = async () => accepted; diff --git a/plugins/dsh/test/search-installation.test.ts b/plugins/dsh/test/search-installation.test.ts index 046b0a15d..21fe7f91f 100644 --- a/plugins/dsh/test/search-installation.test.ts +++ b/plugins/dsh/test/search-installation.test.ts @@ -20,7 +20,7 @@ test('summary matches full coordinates, preserves scopes and abnormal records, a { ...item, scope: 'project', state: 'missing', version: '0.9.0' }] }; const value = summarizeInstallation(item, inventory, checkedAt); expect(value).toEqual({ checkedAt, complete: true, unavailable: [], records: [ - { scope: 'user', state: 'present', version: '1.0.0' }, { scope: 'project', state: 'missing', version: '0.9.0' }] }); + { scope: 'user', state: 'present', version: '1.0.0', installationState: 'enabled' }, { scope: 'project', state: 'missing', version: '0.9.0', installationState: 'enabled' }] }); expect(JSON.stringify(value)).not.toContain('private-key'); expect(summarizeInstallation(item, { ...inventory, unavailable: ['project'] }, checkedAt).complete).toBe(false); expect(summarizeInstallation(item, undefined, checkedAt)).toEqual({ checkedAt, complete: false, unavailable: ['user', 'project'], records: [] }); diff --git a/plugins/dsh/test/skill-primary-actions.test.tsx b/plugins/dsh/test/skill-primary-actions.test.tsx new file mode 100644 index 000000000..44a3b2f71 --- /dev/null +++ b/plugins/dsh/test/skill-primary-actions.test.tsx @@ -0,0 +1,44 @@ +import { expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { InstalledSkills, type InstalledServices } from '../src/client/installed-skills.js'; +import { installedNavigationFor } from '../src/client/installed-navigation.js'; +import { UpgradeRequests, RemovalRequests } from '../src/client/upgrade-requests.js'; +import { selectSkillItem } from '../src/client/skill-primary-actions.js'; +import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol'; + +const item = { itemKey: 'user', namespace: 'demo', slug: 'writer', scope: 'user' as const, version: '1', state: 'present' as const, installationState: 'disabled' as const }; +test('installed defaults to one skill card with no position filter or management controls', () => { + const client = new QueryClient(); + client.setQueryData(['skillhub-installed', 'session', 'revision'], { items: [item, { ...item, itemKey: 'project', scope: 'project', version: '2', installationState: 'enabled' }], complete: true, unavailable: [], issues: [] }); + const unexpected = async () => { throw new Error('render cannot request'); }; + const storage = () => ({ getItem: () => null, setItem() {}, removeItem() {}, key: () => null, length: 0 }); + const upgrades = new UpgradeRequests(storage), removals = new RemovalRequests(storage); + const services = { sessionId: 'session', upgradeRequests: upgrades, removalRequests: removals, list: unexpected, + skillUse: { useSkill: unexpected }, management: { selectSkillUse: unexpected } } as unknown as InstalledServices; + try { + const html = renderToStaticMarkup(); + expect(html.match(/astron-sh-installed-card"/g)).toHaveLength(1); + expect(html).toContain('当前显示 1 个技能'); expect(html).toContain('使用技能'); expect(html).toContain('管理安装'); + for (const text of ['筛选安装范围', 'role="combobox"', '>停用', '>启用', '>卸载', '检查更新']) expect(html).not.toContain(text); + } finally { client.clear(); upgrades.dispose(); removals.dispose(); } +}); +test('late cancelled selector responses cannot supply a key', async () => { + const abort = new AbortController(); + await expect(selectSkillItem(async () => { abort.abort(); return { ok: true, value: { status: 'ready', itemKey: 'stale-key' } }; }, 'revision', 'demo', 'writer', abort.signal)).rejects.toThrow(); +}); + +test('an open installation detail survives removal of its last record without selecting a sibling', () => { + const client = new QueryClient(); + client.setQueryData(['skillhub-installed', 'session', 'revision'], { items: [], complete: true, unavailable: [], issues: [] }); + client.setQueryData(['skillhub-installed-detail', 'session', 'revision', item.itemKey], { item, content: { state: 'local-only', reason: 'NOT_FOUND' } }); + const unexpected = async () => { throw new Error('render cannot request'); }; + const services = { sessionId: 'session', list: unexpected, management: { installation: { sessionId: 'session' }, selectSkillUse: unexpected }, skillUse: { useSkill: unexpected } } as unknown as InstalledServices; + const navigation = installedNavigationFor({}, 'session', 'revision'); navigation.selected = item; navigation.intent = 'remove'; + try { + const html = renderToStaticMarkup(); + expect(html).toContain('aria-label="安装管理"'); expect(html).not.toContain('使用已安装版本'); expect(html).not.toContain('role="dialog"'); + } finally { client.clear(); } +}); diff --git a/plugins/dsh/test/skill-use-selection.test.ts b/plugins/dsh/test/skill-use-selection.test.ts new file mode 100644 index 000000000..e5c26f298 --- /dev/null +++ b/plugins/dsh/test/skill-use-selection.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from 'bun:test'; +import { selectUsableInstallation } from '../src/host/skill-use-selection.js'; +import type { InstalledSkillList, SkillUsePreparation } from '../src/host/types.js'; + +const item = (itemKey: string, scope: 'user' | 'project' = 'user'): InstalledSkillList['items'][number] => ({ + itemKey, scope, namespace: 'demo', slug: 'helper', version: '1.0.0', state: 'present', installationState: 'enabled', +}); +const ready: SkillUsePreparation = { status: 'ready', name: 'actual-name', version: '1.0.0', scope: 'user', examples: { state: 'none' } }; +const blocked = (reason: Extract['reason']): SkillUsePreparation => ({ status: 'blocked', reason }); +function input(items = [item('user'), item('project', 'project')]) { + return { namespace: 'demo', slug: 'helper', signal: new AbortController().signal, valid() {}, + list: async (): Promise => ({ items, complete: true, unavailable: [], issues: [] }), + prepare: async (key: string): Promise => key === 'user' ? ready : blocked('skill-shadowed'), + }; +} + +test('selects the actual ready user even with a newer project first, filtering coordinates', async () => { + const options = input([{ ...item('other'), slug: 'different' }, { ...item('project', 'project'), version: '99.0.0' }, item('user')]); + const calls: string[] = []; + const prepare = options.prepare; + options.prepare = async key => { calls.push(key); return prepare(key); }; + expect(await selectUsableInstallation(options)).toEqual({ status: 'ready', itemKey: 'user' }); + expect(calls.filter(key => key === 'user')).toHaveLength(2); + expect(calls).not.toContain('other'); +}); + +test('disabled siblings are excluded but unknown, modified, missing, and pending siblings block guessing', async () => { + for (const reason of ['installation-disabled', 'files-changed', 'installation-unverified', 'operation-pending', 'skill-unavailable'] as const) { + const options = input(); options.prepare = async key => key === 'user' ? ready : blocked(reason); + expect(await selectUsableInstallation(options)).toEqual(reason === 'installation-disabled' + ? { status: 'ready', itemKey: 'user' } : { status: 'blocked', reason: 'selection-unverified' }); + } +}); + +test('either scope can be the sole usable copy while its sibling is disabled', async () => { + for (const scope of ['user', 'project'] as const) { + const options = input(); + options.prepare = async key => key === scope ? { ...ready, scope } : blocked('installation-disabled'); + expect(await selectUsableInstallation(options)).toEqual({ status: 'ready', itemKey: scope }); + } + expect(await selectUsableInstallation(input([item('user')]))).toEqual({ status: 'ready', itemKey: 'user' }); +}); + +test('zero and multiple usable copies never choose an arbitrary installation', async () => { + expect(await selectUsableInstallation(input([]))).toEqual({ status: 'blocked', reason: 'no-installation' }); + const options = input(); options.prepare = async key => ({ ...ready, name: `actual-${key}` }); + expect(await selectUsableInstallation(options)).toEqual({ status: 'blocked', reason: 'multiple-usable-installations' }); + options.prepare = async () => blocked('user-invocation-disabled'); + expect(await selectUsableInstallation(options)).toEqual({ status: 'blocked', reason: 'no-usable-installation' }); +}); + +test('incomplete inventory, unavailable roots, and changing candidates fail closed', async () => { + for (const patch of [{ complete: false }, { unavailable: ['project'] as const }, { issues: ['invalid'] }]) { + const options = input(); const list = options.list; + options.list = async () => ({ ...await list(), ...patch, unavailable: [...(patch.unavailable ?? [])] }); + expect(await selectUsableInstallation(options)).toEqual({ status: 'blocked', reason: 'selection-unverified' }); + } + const options = input(); const list = options.list; let reads = 0; + options.list = async () => ({ ...await list(), items: ++reads === 1 ? [item('user')] : [item('changed')] }); + expect(await selectUsableInstallation(options)).toEqual({ status: 'blocked', reason: 'selection-unverified' }); +}); + +test('winner changes between passes cannot become a stable selection', async () => { + const options = input(); let calls = 0; + options.prepare = async key => (++calls <= 2 ? key === 'user' : key === 'project') ? ready : blocked('skill-shadowed'); + expect(await selectUsableInstallation(options)).toEqual({ status: 'blocked', reason: 'selection-unverified' }); +}); + +test('bounds parallel reads and aborts a stalled provider without waiting for it', async () => { + const options = input(Array.from({ length: 8 }, (_, i) => item(String(i)))); + let active = 0; let max = 0; + options.prepare = async () => { active++; max = Math.max(max, active); await new Promise(resolve => setTimeout(resolve, 1)); active--; return blocked('installation-disabled'); }; + await selectUsableInstallation(options); expect(max).toBe(4); + options.prepare = async () => new Promise(() => {}); + expect(await selectUsableInstallation(options, 10)).toEqual({ status: 'blocked', reason: 'selection-timeout' }); + const abort = new AbortController(); options.signal = abort.signal; + const pending = selectUsableInstallation(options); abort.abort(); + await expect(pending).rejects.toThrow(); +}); + +test('a late inventory response after timeout never starts candidate checks', async () => { + const options = input(); const list = options.list; + let release!: () => void; let checks = 0; + const delayed = new Promise(resolve => { release = resolve; }); + options.list = async () => { await delayed; return list(); }; + options.prepare = async () => { checks++; return ready; }; + expect(await selectUsableInstallation(options, 10)).toEqual({ status: 'blocked', reason: 'selection-timeout' }); + release(); await new Promise(resolve => setTimeout(resolve, 0)); + expect(checks).toBe(0); +}); diff --git a/plugins/dsh/test/use-skill.test.ts b/plugins/dsh/test/use-skill.test.ts index 67eaa6197..c6213b3a1 100644 --- a/plugins/dsh/test/use-skill.test.ts +++ b/plugins/dsh/test/use-skill.test.ts @@ -394,3 +394,35 @@ test('shadowed guidance does not invent the winning scope or silently choose ano } expect(f.state()).toBe(f.original); expect(f.counts().inserted).toBe(0); }); + +test('historical use never falls back from a disabled location to a same-scope sibling', () => { + const item = { itemKey: 'old', namespace: 'global', slug: 'notes', version: '1.0.0', scope: 'user' as const, state: 'present' as const, installationState: 'disabled' as const }; + const inventory = { items: [item], complete: true, unavailable: [], issues: [] }; + expect(exactInstalledSkill(inventory, item)).toBeUndefined(); + expect(exactInstalledSkill({ ...inventory, items: [item, { ...item, itemKey: 'other', installationState: 'enabled' }] }, item)).toBeUndefined(); +}); + +// Skill-level entry resolves only on the Host, then follows the existing strict handoff. +for (const reason of ['no-installation', 'no-usable-installation', 'selection-unverified', 'multiple-usable-installations', 'selection-timeout'] as const) { + test(`skill selector ${reason} never creates a conversation or edits a draft`, async () => { + const { selectSkillItem } = await import('../src/client/skill-primary-actions.js'); + let created = 0; + const f = fixture({ newConversation: async () => { created++; throw new Error('must not create'); } }); + await expect(f.controller.catalogServices('session').useSkill('source', undefined, new AbortController().signal, + signal => selectSkillItem(async () => ({ ok: true, value: { status: 'blocked', reason } }), 'source', 'demo', 'skill', signal))).rejects.toThrow(); + expect(created).toBe(0); expect(f.state()).toBe(f.original); f.controller.dispose(); + }); +} +test('skill selector ready passes its exact key into handoff and still rechecks before offering a task', async () => { + const { selectSkillItem } = await import('../src/client/skill-primary-actions.js'); + const selected: string[] = [], prepared: string[] = []; + const f = fixture({ newConversation: async (_session, _revision, key) => { selected.push(key); return { sessionId: 'fresh', itemKey: 'rebound-exact-key' }; }, + prepare: async (_session, _revision, key) => { prepared.push(key); return { ok: true, value: caseReady() }; } }); + f.controller.bind('fresh', f.actions); + const use = f.controller.catalogServices('session').useSkill('source', undefined, new AbortController().signal, + signal => selectSkillItem(async () => ({ ok: true, value: { status: 'ready', itemKey: 'host-unique-key' } }), 'source', 'demo', 'skill', signal)); + await Bun.sleep(0); + expect(selected).toEqual(['host-unique-key']); expect(prepared).toEqual(['rebound-exact-key']); + f.controller.casePanel()!.finish(); await expect(use).rejects.toThrow('取消'); + expect(f.state()).toBe(f.original); expect(f.counts().inserted).toBe(0); f.controller.dispose(); +}); diff --git a/plugins/dsh/test/website-link.test.tsx b/plugins/dsh/test/website-link.test.tsx new file mode 100644 index 000000000..a2519ee57 --- /dev/null +++ b/plugins/dsh/test/website-link.test.tsx @@ -0,0 +1,41 @@ +import { expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { safeSkillWebUrl, SkillWebsiteLink } from '../src/client/skill-website-link.js'; +import { RegistryAdapter } from '../src/host/registry-adapter.js'; +import { Config } from '../src/host/config.js'; +import { Context } from '@deepseek-ai/cordis'; +import SkillHubService from '@astron-team/skillhub-dsh'; + +test('uses the complete API URL including legitimate page query and fragment without a fallback', () => { + const webUrl = 'https://pages.example/custom/skill?version=2&lang=zh#examples'; + expect(safeSkillWebUrl(webUrl)).toBe(webUrl); + const html = renderToStaticMarkup(); + expect(html).toContain('在官网查看'); expect(html).toContain('astron-sh-button-outline'); + expect(html).toContain('target="_blank"'); expect(html).toContain('rel="noopener noreferrer"'); + expect(html).toContain('version=2&lang=zh#examples'); + expect(renderToStaticMarkup()).toBe(''); +}); + +test.each([undefined, null, '', ' ', 'javascript:alert(1)', 'file:///tmp/a', '//example.test/a', '/space/demo/a', 'https://user:secret@example.test', 'https://example.test\\other', 'https://exam\nple.test', 'https://', '\nhttps://example.test', 'https://example.test\t'])('missing or unsafe API link is hidden: %s', webUrl => { + expect(safeSkillWebUrl(webUrl)).toBeUndefined(); + expect(renderToStaticMarkup()).toBe(''); +}); + +test.each([undefined, null, '', 'https://web.example/custom?tab=read#intro', 'javascript:bad', 42])('optional metadata URL does not break old or malformed-source details: %j', async webUrl => { + const fetcher = Object.assign(async () => Response.json({ data: { id: 1, namespace: 'demo', slug: 'writer', displayName: 'Writer', summary: '', ...(webUrl === undefined ? {} : { webUrl }) } }), { preconnect: fetch.preconnect }); + const metadata = await new RegistryAdapter('https://registry.example', fetcher).metadata('demo', 'writer'); + expect(metadata.id).toBe(1); + expect(metadata.webUrl).toBe(typeof webUrl === 'number' ? undefined : webUrl); +}); + +test('obsolete website setting cannot change Registry configuration or add a link override', async () => { + const oldConfiguration = { registryUrl: 'https://registry.example', websiteUrl: 'https://old.example' }; + expect(Config(oldConfiguration).get().registryUrl).toBe('https://registry.example'); + const ctx = new Context(); + const fiber = ctx.plugin(SkillHubService, oldConfiguration); + try { + await fiber; + expect(ctx.skillhub.getContext().registryUrl).toBe(oldConfiguration.registryUrl); + expect(ctx.skillhub.getContext()).not.toHaveProperty('websiteUrl'); + } finally { await fiber.dispose(); } +}); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 7389cd01c..6ccdd5a44 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -27,6 +27,8 @@ import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillLabelAppService; import com.iflytek.skillhub.service.SkillAvatarUrls; +import com.iflytek.skillhub.service.SkillWebUrls; +import org.springframework.beans.factory.annotation.Autowired; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.service.ComplianceSnapshotProjectionService; import com.iflytek.skillhub.service.SkillSuiteAppService; @@ -68,6 +70,8 @@ public class SkillController extends BaseApiController { private final SkillHubMetrics metrics; private final GithubSkillSourceQueryRepository githubSkillSourceQueryRepository; + private final SkillWebUrls skillWebUrls; + public SkillController( SkillQueryService skillQueryService, SkillDownloadService skillDownloadService, @@ -77,7 +81,24 @@ public class SkillController extends BaseApiController { SkillHubMetrics metrics, GithubSkillSourceQueryRepository githubSkillSourceQueryRepository, ApiResponseFactory responseFactory) { + this(skillQueryService, skillDownloadService, skillLabelAppService, complianceSnapshotProjectionService, + skillSuiteAppService, metrics, githubSkillSourceQueryRepository, responseFactory, + new SkillWebUrls("")); + } + + @Autowired + public SkillController( + SkillQueryService skillQueryService, + SkillDownloadService skillDownloadService, + SkillLabelAppService skillLabelAppService, + ComplianceSnapshotProjectionService complianceSnapshotProjectionService, + SkillSuiteAppService skillSuiteAppService, + SkillHubMetrics metrics, + GithubSkillSourceQueryRepository githubSkillSourceQueryRepository, + ApiResponseFactory responseFactory, + SkillWebUrls skillWebUrls) { super(responseFactory); + this.skillWebUrls = skillWebUrls; this.skillQueryService = skillQueryService; this.skillDownloadService = skillDownloadService; this.skillLabelAppService = skillLabelAppService; @@ -145,7 +166,8 @@ public class SkillController extends BaseApiController { detail.ownerReadOnly(), detail.publishedVersion() == null ? null : githubSkillSourceQueryRepository.findPublishedVersion(detail.publishedVersion().id()) - .orElse(null) + .orElse(null), + skillWebUrls.detail(namespace, detail.slug()) ); return ok("response.success.read", response); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index fcd94f5a8..12452c15e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -33,8 +33,45 @@ public record SkillDetailResponse( PageResponse memberOfSuites, String avatarUrl, boolean ownerReadOnly, - GithubSkillSourceResponse githubSource + GithubSkillSourceResponse githubSource, + @io.swagger.v3.oas.annotations.media.Schema(nullable = true, format = "uri", description = "Absolute public skill page URL, or null when no trusted public website is configured") + String webUrl ) { + public SkillDetailResponse( + Long id, + String slug, + String displayName, + String ownerId, + String ownerDisplayName, + String summary, + String visibility, + String status, + Long downloadCount, + Integer starCount, + Integer subscriptionCount, + BigDecimal ratingAvg, + Integer ratingCount, + boolean hidden, + String namespace, + List labels, + boolean canManageLifecycle, + boolean canSubmitPromotion, + boolean canInteract, + boolean canReport, + SkillLifecycleVersionResponse headlineVersion, + SkillLifecycleVersionResponse publishedVersion, + SkillLifecycleVersionResponse ownerPreviewVersion, + String ownerPreviewReviewComment, + String resolutionMode, + List entryForSuites, + PageResponse memberOfSuites, + String avatarUrl, + boolean ownerReadOnly, + GithubSkillSourceResponse githubSource + ) { + this(id, slug, displayName, ownerId, ownerDisplayName, summary, visibility, status, downloadCount, starCount, subscriptionCount, ratingAvg, ratingCount, hidden, namespace, labels, canManageLifecycle, canSubmitPromotion, canInteract, canReport, headlineVersion, publishedVersion, ownerPreviewVersion, ownerPreviewReviewComment, resolutionMode, entryForSuites, memberOfSuites, avatarUrl, ownerReadOnly, githubSource, null); + } + public SkillDetailResponse(Long id, String slug, String displayName, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillWebUrls.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillWebUrls.java new file mode 100644 index 000000000..a03399302 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillWebUrls.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.service; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** Public links use deployment configuration only, never request forwarding headers. */ +@Component +public final class SkillWebUrls { + private final String base; + + public SkillWebUrls(@Value("${skillhub.public.base-url:}") String configuredBase) { + this.base = trustedBase(configuredBase); + } + + public String detail(String namespace, String slug) { + if (base == null || namespace == null || slug == null) return null; + return base + "/space/" + segment(namespace) + "/" + segment(slug); + } + + private static String trustedBase(String value) { + if (value == null || value.isBlank()) return null; + String trimmed = value.trim(); + try { + URI uri = URI.create(trimmed); + if (!("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) + || uri.getHost() == null || uri.getRawUserInfo() != null + || uri.getRawQuery() != null || uri.getRawFragment() != null + || uri.getPort() > 65535) return null; + return uri.toASCIIString().replaceAll("/+$", ""); + } catch (IllegalArgumentException invalid) { + return null; + } + } + + private static String segment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index baff7d3b4..8abde7b7b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -85,6 +85,7 @@ class SkillControllerTest { mockMvc.perform(get("/api/web/skills/team/demo")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.webUrl").value(org.hamcrest.Matchers.nullValue())) .andExpect(jsonPath("$.data.githubSource.ownerLogin").value("upstream")) .andExpect(jsonPath("$.data.githubSource.skillPath").value("agents/demo")) .andExpect(jsonPath("$.data.githubSource.commitSha").value("a".repeat(40))) @@ -124,6 +125,8 @@ class SkillControllerTest { .andExpect(jsonPath("$.paths['/api/v1/skills/{namespace}/{slug}/versions/{version}/file'].get.responses['200'].content['application/octet-stream'].schema.format").value("binary")) .andExpect(jsonPath("$.paths['/api/v1/skills/{namespace}/{slug}/versions/{version}/download'].get.responses['200'].content['application/zip'].schema.format").value("binary")) .andExpect(jsonPath("$.paths['/api/v1/skills/{namespace}/{slug}/versions/{version}/download'].get.responses['302'].headers.Location.schema.format").value("uri")) + .andExpect(jsonPath("$.components.schemas.SkillDetailResponse.properties.webUrl.nullable").value(true)) + .andExpect(jsonPath("$.components.schemas.SkillDetailResponse.properties.webUrl.format").value("uri")) .andExpect(jsonPath("$.components.schemas.CliResolveResponse.required.length()").value(6)) .andExpect(jsonPath("$.components.schemas.SkillVersionResponse.properties.changelog.nullable").value(true)) .andReturn().getResponse(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillWebUrlControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillWebUrlControllerTest.java new file mode 100644 index 000000000..b585b52e2 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillWebUrlControllerTest.java @@ -0,0 +1,52 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.controller.portal.SkillController; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.service.SkillLabelAppService; +import com.iflytek.skillhub.service.SkillSuiteAppService; +import com.iflytek.skillhub.service.SkillWebUrls; +import java.time.Clock; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.context.support.StaticMessageSource; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class SkillWebUrlControllerTest { + @Test + void detailResponseUsesTrustedConfigurationAndIgnoresForwardingHeaders() throws Exception { + var query = mock(SkillQueryService.class); + var detail = new SkillQueryService.SkillDetailDTO( + 1L, "demo", "Demo", "owner-1", "Alice", "Example", + "PUBLIC", "ACTIVE", 0L, 0, 0, null, 0, false, 1L, + java.time.Instant.EPOCH, java.time.Instant.EPOCH, + false, false, true, true, null, null, null, null, "PUBLISHED", null, null); + when(query.getSkillDetail(anyString(), anyString(), isNull(), anyMap(), anySet())).thenReturn(detail); + var suites = mock(SkillSuiteAppService.class); + when(suites.findVisibleMemberships(anyLong(), isNull(), anyMap(), anySet(), eq(0), eq(20))) + .thenReturn(new PageResponse<>(List.of(), 0, 0, 20)); + var factory = new ApiResponseFactory(new StaticMessageSource(), Clock.systemUTC(), mock(RequestIdAccessor.class)); + for (String base : new String[] {"https://public.example/prefix/", ""}) { + var controller = new SkillController(query, null, mock(SkillLabelAppService.class), null, + suites, null, null, factory, new SkillWebUrls(base)); + var mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build(); + for (String path : List.of("/api/v1/skills/team/demo", "/api/web/skills/team/demo")) { + mvc.perform(get(path).header("Host", "attacker.example") + .header("X-Forwarded-Host", "attacker.example") + .header("X-Forwarded-Proto", "https")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.webUrl").value(base.isEmpty() + ? org.hamcrest.Matchers.nullValue() + : org.hamcrest.Matchers.is("https://public.example/prefix/space/team/demo"))); + } + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillWebUrlsTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillWebUrlsTest.java new file mode 100644 index 000000000..d49290942 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillWebUrlsTest.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.service; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class SkillWebUrlsTest { + @Test + void buildsEncodedSegmentsWithConfiguredDeploymentPrefix() { + var urls = new SkillWebUrls(" https://skills.example/team/ "); + assertThat(urls.detail("团队 /", "a+b#?%")) + .isEqualTo("https://skills.example/team/space/%E5%9B%A2%E9%98%9F%20%2F/a%2Bb%23%3F%25"); + } + + @Test + void absentOrUnsafeConfigurationNeverInventsAPublicAddress() { + for (String base : new String[] {null, "", " ", "//evil.example", "javascript:alert(1)", + "https://user:password@example.com", "https://example.com?q=1", "https://example.com#x", + "https://example.com\\evil", "https://example.com:99999", "https://example.com/\npath"}) { + assertThat(new SkillWebUrls(base).detail("team", "demo")).isNull(); + } + } +} diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 39f3f1f4f..c1b21995c 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9500,6 +9500,11 @@ export interface components { avatarUrl?: string; ownerReadOnly?: boolean; githubSource?: components["schemas"]["GithubSkillSourceResponse"]; + /** + * Format: uri + * @description Absolute public skill page URL, or null when no trusted public website is configured + */ + webUrl?: string | null; }; ApiResponseReviewSkillDetailResponse: { /** Format: int32 */ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 5058bb530..bbf46a062 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -365,6 +365,7 @@ export interface GithubSkillSource { export type SkillDetail = Omit< RequiredGenerated< GeneratedSkillDetail, + | 'webUrl' | 'ownerId' | 'ownerDisplayName' | 'ownerReadOnly'