diff --git a/Makefile b/Makefile index 3bf04b28..f6640d8d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci +.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke suite-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci DEV_DIR := .dev DEV_SERVER_PID := $(DEV_DIR)/server.pid @@ -147,6 +147,9 @@ dev-server-restart: ## 重启后端开发服务器 namespace-smoke: ## 运行命名空间工作流 smoke test ./scripts/namespace-smoke-test.sh $(DEV_API_URL) +suite-smoke: ## 运行 Skill Suite 生命周期 smoke test + ./scripts/suite-smoke-test.sh $(DEV_API_URL) + dev-down: ## 停止本地开发环境(含 skill-scanner) $(DEV_COMPOSE) down --remove-orphans diff --git a/charts/skillhub/README.md b/charts/skillhub/README.md index 4d071b88..1e8bc1a3 100644 --- a/charts/skillhub/README.md +++ b/charts/skillhub/README.md @@ -298,6 +298,20 @@ standalone → replication、Redis standalone/replication → Sentinel 等切换 Cluster,也不将其计入上述内置架构运行时验证范围。应用侧应另行验证 Spring Data、Spring Session 与 Redisson Stream 链路。 +### Skill Suite 审核滚动升级门禁 + +Chart 默认将 `server.suiteReviewWritesEnabled` 设为 `false`,避免新旧 Server Pod 混跑时, +旧实例读取到无法识别的 Suite 审核任务。全新安装可以直接启用: + +```yaml +server: + suiteReviewWritesEnabled: true +``` + +从不支持 Suite 的版本滚动升级时,先保持 `false` 完成全部 Server Pod 升级;确认集群中不再有 +旧版实例后,再改为 `true` 并执行一次滚动更新。单实例 `compose.release.yml` 不存在混跑窗口, +因此已默认启用。 + ### Redis Sentinel 内置 Sentinel 使用 Bitnami Redis 的同一份密码同时保护 Redis 数据节点和 diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index 149cb203..a6e4e788 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -77,6 +77,8 @@ spec: {{- $profiles = printf "%s,redis-sentinel" $profiles }} {{- end }} value: {{ $profiles | quote }} + - name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED + value: {{ .Values.server.suiteReviewWritesEnabled | quote }} # Database - name: SPRING_DATASOURCE_URL diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index 20e98d3f..cd297881 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -37,6 +37,14 @@ fi grep -Fq 'fsGroup: 101' "$TMP_DIR/default.yaml" grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml" grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml" +grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/default.yaml" \ + | grep -Fq 'value: "false"' + +render suite-review-enabled "$CHART_DIR" \ + --set server.suiteReviewWritesEnabled=true \ + --show-only templates/server-deployment.yaml >"$TMP_DIR/suite-review-enabled.yaml" +grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/suite-review-enabled.yaml" \ + | grep -Fq 'value: "true"' render custom-server-fsgroup "$CHART_DIR" \ --set server.podSecurityContext.fsGroup=2000 \ diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index 30c62503..1bede63b 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -370,10 +370,11 @@ { "type": "object", "additionalProperties": false, - "required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "podSecurityContext", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], + "required": ["enabled", "replicaCount", "suiteReviewWritesEnabled", "image", "dependencyWait", "service", "storage", "podSecurityContext", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"], "properties": { "enabled": { "type": "boolean" }, "replicaCount": { "type": "integer", "minimum": 1 }, + "suiteReviewWritesEnabled": { "type": "boolean" }, "image": { "$ref": "#/definitions/image" }, "dependencyWait": { "type": "object", diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index d99b47f1..092e73e4 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -282,6 +282,10 @@ server: enabled: true replicaCount: 1 + # Keep false while old and new Server versions overlap. Set true after every active + # Server instance supports typed review subjects; fresh non-rolling installs may enable it directly. + suiteReviewWritesEnabled: false + image: registry: "" tag: "" diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index ab787cec..a64d5ca3 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -76,6 +76,58 @@ export interface DryRunResponse { resolvedVersion: string | null } +export interface ServerMetadata { + apiBase?: string + capabilities?: string[] +} + +export interface SuiteInstallMember { + skillId: number + skillVersionId: number + namespace: string + slug: string + version: string + fingerprint: string + downloadUrl: string + position: number + entry: boolean +} + +export interface SuiteInstallPlan { + operationId: string + namespace: string + slug: string + version: string + fingerprint: string + members: SuiteInstallMember[] +} + +export interface SuiteDetailMember { + skillId: number + skillVersionId: number + namespace: string + slug: string + version: string + fingerprint: string + position: number + entry: boolean + blockingReason?: string | null +} + +export interface SuiteDetail { + id: number + versionId: number + namespace: string + slug: string + displayName: string + summary?: string | null + version: string + status: string + visibility: string + available: boolean + members: SuiteDetailMember[] +} + interface PublicErrorFields { msg?: string requestId?: string @@ -94,6 +146,84 @@ export class SkillHubClient { return this.getJson('/auth/whoami') } + async serverMetadata(): Promise { + let response: Response + try { + response = await this.fetchImpl(`${this.registry}/.well-known/clawhub.json`) + } catch { + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) + } + if (!response.ok) return {} + let body: unknown + try { + body = await response.json() + } catch { + // Older registries and reverse proxies may return an HTML landing page at this path. + return {} + } + return typeof body === 'object' && body !== null ? body as ServerMetadata : {} + } + + async suiteInstallPlan( + namespace: string, + slug: string, + version?: string, + idempotencyKey?: string + ): Promise { + const params = version ? `?version=${encodeURIComponent(version)}` : '' + const url = `${this.registry}/api/v1/suites/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/install-plan${params}` + for (let attempt = 0; attempt < 2; attempt += 1) { + let response: Response + try { + response = await this.fetchImpl(url, { + method: 'POST', + headers: { ...this.headers(), ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) } + }) + } catch { + if (attempt === 0) continue + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) + } + if (attempt === 0 && [502, 503, 504].includes(response.status)) continue + try { + return await this.handleJsonResponse(response) + } catch (error) { + // A successful response whose body is truncated is safe to retry with the same key. + if (attempt === 0 && !(error instanceof CliError)) continue + throw error + } + } + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry }) + } + + async suiteDetail(namespace: string, slug: string, version?: string): Promise { + const params = version ? `?version=${encodeURIComponent(version)}` : '' + let response: Response + try { + response = await this.fetchImpl( + `${this.registry}/api/v1/suites/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}${params}`, + { headers: this.headers() } + ) + } catch { + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) + } + if (response.status === 404) { + const error = await this.createResponseError(response, 'json') + throw new CliError(error.message, error.exitCode, { ...error.details, status: 404 }) + } + return this.handleJsonResponse(response) + } + + async downloadFromUrl(downloadUrl: string): Promise { + let response: Response + try { + response = await this.fetchImpl(new URL(downloadUrl, `${this.registry}/`).toString(), { headers: this.headers() }) + } catch { + throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' }) + } + if (!response.ok) throw await this.createResponseError(response, 'download') + return response + } + async search(query: string, limit: number): Promise { const params = new URLSearchParams({ q: query, limit: String(limit) }) return this.getJson(`/skills/search?${params}`) @@ -244,7 +374,7 @@ export class SkillHubClient { exitCode = EXIT.auth } else if (response.status === 404) { fallback = kind === 'download' ? 'skill or version not found' : 'resource not found' - } else if (response.status === 502 || response.status === 503) { + } else if (response.status === 502 || response.status === 503 || response.status === 504) { fallback = kind === 'download' ? `download failed with status ${response.status}` : `registry returned ${response.status}` diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index fb6a623d..7d70cfb7 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -45,6 +45,16 @@ export const commands = { 'skillhub install pdf-parser --scope project --agent codex' ] }, + suite: { + summary: 'Manage Skill Suites on compatible registries', + usage: 'skillhub suite [options]', + examples: [ + 'skillhub suite install @global/marketing --scope user', + 'skillhub suite check @global/marketing', + 'skillhub suite upgrade @global/marketing --check', + 'skillhub suite remove @global/marketing' + ] + }, upgrade: { summary: 'Upgrade explicitly selected installed skills', usage: 'skillhub upgrade [--namespace ] [--agent ] [--dir ] [--registry ] [--token ] [--check] [--force] [--json]', diff --git a/cli/src/commands/suite.ts b/cli/src/commands/suite.ts new file mode 100644 index 00000000..2d9723c4 --- /dev/null +++ b/cli/src/commands/suite.ts @@ -0,0 +1,155 @@ +import { ConfigStore } from '../stores/config-store' +import { CredentialsStore } from '../stores/credentials-store' +import { resolveRegistry, resolveToken } from '../services/registry-service' +import { resolveSkillName } from '../shared/skill-name-parser' +import { resolveInstallTargets } from '../agents/resolver' +import { resolveEffectiveScope } from './install' +import { computeStrictIsTTY } from '../shared/tty' +import { CliError } from '../shared/errors' +import { EXIT } from '../shared/constants' +import { checkSuite, installSuite, planSuiteUpgrade, removeSuite, upgradeSuite } from '../services/suite-service' + +export interface SuiteCommandOptions { + version?: string | undefined + scope?: string | undefined + agent?: string[] | undefined + dir?: string | undefined + force?: boolean | undefined + check?: boolean | undefined + registry?: string | undefined + token?: string | undefined + json?: boolean | undefined +} + +export async function suiteCommand( + action: string, + coordinate: string, + options: SuiteCommandOptions +): Promise { + if (!['install', 'check', 'upgrade', 'remove'].includes(action)) { + throw new CliError(`unknown suite action: ${action}`, EXIT.usage, { + next: 'use install, check, upgrade, or remove' + }) + } + const configStore = new ConfigStore() + const credentialsStore = new CredentialsStore() + const registry = resolveRegistry(options, process.env, await configStore.read()) + const token = resolveToken(options, process.env, await credentialsStore.getToken(registry)) + const { namespace, slug } = resolveSkillName(coordinate) + const common = { registry, token, namespace, slug } + + if (action === 'install') { + const isTTY = computeStrictIsTTY({ + stdinIsTTY: process.stdin.isTTY === true, + stdoutIsTTY: process.stdout.isTTY === true, + json: Boolean(options.json) + }) + const scope = await resolveEffectiveScope(options, { + isTTY, + promptScope: async () => { + const prompts = await import('prompts') + const { selected } = await prompts.default({ + type: 'select', + name: 'selected', + message: 'Install Suite for user or project?', + choices: [ + { title: 'User', value: 'user' }, + { title: 'Project', value: 'project' } + ] + }) + if (!selected) throw new CliError('installation cancelled', EXIT.usage) + return selected as 'user' | 'project' + } + }) + const targets = await resolveInstallTargets({ + cwd: process.cwd(), + scope, + dir: options.dir, + agents: options.agent ?? [], + json: Boolean(options.json), + interactive: isTTY + }) + const result = await installSuite({ + ...common, + version: options.version, + targets, + force: Boolean(options.force) + }) + if (options.json) return JSON.stringify({ ok: true, suite: result.plan, installed: result.installed, reused: result.reused }) + return [ + `Installed Suite @${namespace}/${slug}@${result.plan.version}`, + ...result.installed.map(item => `Installed @${item.namespace}/${item.slug} -> ${item.dir} (${item.agent})`), + ...result.reused.map(item => `Reused @${item.namespace}/${item.slug} -> ${item.dir} (${item.agent})`) + ].join('\n') + } + + if (hasInstallOnlyOptions(options) || (options.force === true && action !== 'upgrade')) { + throw new CliError( + '--scope, --agent, --dir, and --version are only valid with suite install; --force is valid with install or upgrade', + EXIT.usage) + } + + if (action === 'check') { + const result = await checkSuite(common) + if (options.json) return JSON.stringify({ ok: result.current, ...result }) + return [ + `Suite @${namespace}/${slug}@${result.suite.version}: ${result.current ? 'current' : 'changes detected'}`, + ...(result.remoteVersion && result.remoteVersion !== result.suite.version + ? [`Remote version: ${result.remoteVersion}`] + : []), + ...(!result.installedVersionAvailable + ? [`Installed version unavailable: ${result.blockingReasons.join(', ') || 'unknown reason'}`] + : []), + ...result.members.map(member => `${member.status.padEnd(8)} @${member.namespace}/${member.slug}@${member.version} ${member.dir}`) + ].join('\n') + } + + if (action === 'remove') { + const result = await removeSuite(common) + if (options.json) return JSON.stringify({ ok: true, ...result }) + return [ + `Removed Suite @${namespace}/${slug}`, + ...result.removed.map(dir => `Removed member: ${dir}`), + ...result.preserved.map(item => `Preserved member (${item.reason}): ${item.dir}`) + ].join('\n') + } + + if (options.check) { + const plan = await planSuiteUpgrade(common) + return renderUpgradePlan(plan, Boolean(options.json)) + } + const { upgrade, result } = await upgradeSuite({ ...common, force: Boolean(options.force) }) + if (options.json) return JSON.stringify({ ok: true, upgrade, result }) + if (!result) return `Suite @${namespace}/${slug}@${upgrade.current.version} is current` + return [ + `Upgraded Suite @${namespace}/${slug}: ${upgrade.current.version} -> ${upgrade.remote.version}`, + ...upgrade.changes.map(change => renderChange(change)) + ].join('\n') +} + +function hasInstallOnlyOptions(options: SuiteCommandOptions): boolean { + return options.scope !== undefined || options.agent !== undefined || options.dir !== undefined || + options.version !== undefined +} + +function renderUpgradePlan(plan: Awaited>, json: boolean): string { + if (json) return JSON.stringify({ + ok: true, + currentVersion: plan.current.version, + remoteVersion: plan.remote.version, + changes: plan.changes + }) + return [ + `Suite upgrade plan: ${plan.current.version} -> ${plan.remote.version}`, + ...(plan.changes.length === 0 ? ['No member changes'] : plan.changes.map(renderChange)) + ].join('\n') +} + +function renderChange(change: Awaited>['changes'][number]): string { + const versions = change.action === 'add' + ? ` -> ${change.toVersion}` + : change.action === 'remove' + ? ` ${change.fromVersion} -> removed` + : ` ${change.fromVersion} -> ${change.toVersion}` + return `${change.action.padEnd(7)} ${change.coordinate}${versions}` +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 053e1e52..0fd549d8 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -9,6 +9,7 @@ import { logoutCommand } from './commands/logout' import { publishCommand, type PublishCommandOptions } from './commands/publish' import { removeCommand, type RemoveCommandOptions } from './commands/remove' import { searchCommand } from './commands/search' +import { suiteCommand, type SuiteCommandOptions } from './commands/suite' import { syncDiffCommand, syncPullCommand, syncPushCommand, syncStatusCommand, type SyncCommonOptions, type SyncPullOptions, type SyncPushOptions } from './commands/sync' import { updateCommand } from './commands/update' import { upgradeCommand, type UpgradeCommandOptions } from './commands/upgrade' @@ -248,6 +249,24 @@ cli return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json)) }) +cli + .command('suite ', 'Manage Skill Suites on compatible registries') + .option('--version ', 'Exact Suite version for install') + .option('--scope ', 'Install scope: user or project') + .option('--agent ', 'Agent profile (repeatable)') + .option('--dir ', 'Install directory') + .option('--force', 'Replace local changes during install or upgrade') + .option('--check', 'Show an upgrade plan without writing') + .option('--registry ', 'Registry URL') + .option('--token ', 'API token') + .option('--json', 'Output JSON') + .action((action: string, coordinate: string, options: SuiteCommandOptions & { agent?: string | string[] }) => { + return runCommand( + () => suiteCommand(action, coordinate, { ...options, agent: toArray(options.agent) }), + Boolean(options.json) + ) + }) + cli .command('upgrade [...coordinates]', 'Upgrade explicitly selected installed skills') .option('--namespace ', 'Filter a bare slug by namespace') diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index ba98ac5a..0bfc7cf7 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -31,6 +31,7 @@ export interface InstallOptions { expectedTargetFiles?: Record> | undefined allowTargetDrift?: boolean | undefined requireExistingTargets?: boolean | undefined + client?: SkillHubClient | undefined /** Internal test seam for lock lifecycle failures; production uses acquireSkillTargetLock. */ acquireTargetLock?: typeof acquireSkillTargetLock } @@ -99,9 +100,11 @@ export async function installSkill(options: InstallOptions): Promise Promise) | undefined +} + +export interface SuiteInstallResult { + plan: SuiteInstallPlan + installed: Array<{ namespace: string; slug: string; agent: string; dir: string }> + reused: Array<{ namespace: string; slug: string; agent: string; dir: string }> +} + +export interface SuiteCheckResult { + suite: InventorySuite + remoteVersion?: string + current: boolean + installedVersionAvailable: boolean + blockingReasons: string[] + members: Array<{ + namespace: string + slug: string + version: string + dir: string + status: 'ok' | 'missing' | 'modified' + }> +} + +export interface SuiteRemoveResult { + removed: string[] + preserved: Array<{ dir: string; reason: 'shared' | 'modified' | 'missing' }> +} + +export interface SuiteRemoveOptions { + registry: string + namespace: string + slug: string + home?: string | undefined + /** Internal seam used to verify state observed immediately after target locking. */ + afterTargetLocksAcquired?: (() => Promise) | undefined +} + +export interface SuiteUpgradePlan { + current: InventorySuite + remote: SuiteDetail + targets: AgentCandidate[] + changes: Array<{ + coordinate: string + action: 'add' | 'remove' | 'change' + fromVersion?: string + toVersion?: string + }> +} + +interface PreparedTarget { + member: SuiteInstallPlan['members'][number] + target: AgentCandidate + installDir: string + stagedDir: string + replace: boolean + reuse: boolean + backupDir: string | undefined + committed: boolean +} + +interface RetiredTarget { + item: InventoryItem + target: InventoryTarget + backupDir: string + fingerprint: string + moved: boolean +} + +export function suiteSource(namespace: string, slug: string, version: string): string { + return `suite:@${namespace}/${slug}@${version}` +} + +export async function assertSuiteCapability(client: SkillHubClient): Promise { + const metadata = await client.serverMetadata() + if (!metadata.capabilities?.includes(SUITE_CAPABILITY)) { + throw new CliError('registry does not support Skill Suites', EXIT.validation, { + capability: SUITE_CAPABILITY, + next: 'upgrade the SkillHub server before using suite commands' + }) + } +} + +/** + * Installs every exact member as one local transaction. Downloads and fingerprint checks finish + * before any live Skill directory is replaced; commit failures restore all captured backups. + */ +export async function installSuite(options: SuiteInstallOptions): Promise { + const client = options.client ?? new SkillHubClient(options.registry, options.token) + const renameOperation = options.renameOperation ?? rename + await assertSuiteCapability(client) + // The same client key survives the single HTTP retry; the Server owns the operation ID. + const idempotencyKey = randomUUID() + const plan = await client.suiteInstallPlan( + options.namespace, + options.slug, + options.version, + idempotencyKey + ) + assertNoTargetCollisions(plan) + + return installSuiteWithPlan(options, client, renameOperation, plan) +} + +async function installSuiteWithPlan( + options: SuiteInstallOptions, + client: SkillHubClient, + renameOperation: typeof rename, + plan: SuiteInstallPlan, + expectedCurrentSuite?: InventorySuite, + allowVersionReplacement = options.force +): Promise { + const releaseSuiteLock = await acquireSuiteOperationLock( + options.home, options.registry, plan.namespace, plan.slug) + try { + return await installSuiteTransaction( + options, client, renameOperation, plan, expectedCurrentSuite, allowVersionReplacement) + } finally { + await releaseSuiteLock().catch(() => {}) + } +} + +async function installSuiteTransaction( + options: SuiteInstallOptions, + client: SkillHubClient, + renameOperation: typeof rename, + plan: SuiteInstallPlan, + expectedCurrentSuite?: InventorySuite, + allowVersionReplacement = options.force +): Promise { + const store = new InventoryStore(options.home) + const before = await store.read() + const previousSuite = installedSuites(before).find(candidate => + candidate.registry === options.registry && candidate.namespace === plan.namespace && candidate.slug === plan.slug) + if (expectedCurrentSuite) { + assertSuiteSnapshotUnchanged(expectedCurrentSuite, previousSuite) + } + const source = suiteSource(plan.namespace, plan.slug, plan.version) + const stageHome = await mkdtemp(join(tmpdir(), 'skillhub-suite-inventory-')) + const stageToken = `${process.pid}-${Date.now()}` + const prepared: PreparedTarget[] = [] + const retired = await prepareRetiredTargets(before, previousSuite, plan, stageToken) + + try { + await preflightExistingTargets( + before, options.registry, plan, options.targets, options.force, allowVersionReplacement) + + for (const member of plan.members) { + const stagingTargets = options.targets.map((target, index) => ({ + ...target, + rootDir: join(resolve(target.rootDir), `.skillhub-suite-stage-${stageToken}-${index}`) + })) + await installSkill({ + registry: options.registry, + token: options.token, + namespace: member.namespace, + slug: member.slug, + version: member.version, + targets: stagingTargets, + force: false, + home: stageHome, + client, + resolved: { + namespace: member.namespace, + slug: member.slug, + version: member.version, + versionId: member.skillVersionId, + fingerprint: member.fingerprint, + downloadUrl: member.downloadUrl + } + }) + + for (let index = 0; index < options.targets.length; index += 1) { + const target = options.targets[index]! + const installDir = join(resolve(target.rootDir), member.slug) + const stagedDir = join(stagingTargets[index]!.rootDir, member.slug) + const reuse = await isReusable(before, options.registry, member, installDir) + prepared.push({ + member, + target: { ...target, rootDir: resolve(target.rootDir) }, + installDir, + stagedDir, + replace: await pathExists(installDir) && !reuse, + reuse, + committed: false, + backupDir: undefined + }) + } + } + + const releases: Array<() => Promise> = [] + try { + const lockTargets = [ + ...prepared.map(item => ({ rootDir: item.target.rootDir, slug: item.member.slug })), + ...retired.map(item => ({ rootDir: item.target.rootDir, slug: item.item.slug })) + ].sort((a, b) => join(a.rootDir, a.slug).localeCompare(join(b.rootDir, b.slug))) + const lockedPaths = new Set() + for (const target of lockTargets) { + const path = join(resolve(target.rootDir), target.slug) + if (lockedPaths.has(path)) continue + lockedPaths.add(path) + releases.push(await acquireSkillTargetLock(target.rootDir, target.slug)) + } + await options.afterTargetLocksAcquired?.() + // Recheck after target locks so a concurrent direct install cannot invalidate preflight. + const lockedInventory = await store.read() + const lockedPreviousSuite = installedSuites(lockedInventory).find(candidate => + candidate.registry === options.registry && candidate.namespace === plan.namespace && candidate.slug === plan.slug) + assertSuiteSnapshotUnchanged(previousSuite, lockedPreviousSuite) + await preflightExistingTargets( + lockedInventory, options.registry, plan, options.targets, options.force, allowVersionReplacement) + for (const item of prepared) { + item.reuse = await isReusable(lockedInventory, options.registry, item.member, item.installDir) + item.replace = await pathExists(item.installDir) && !item.reuse + } + const lockedRetired = await prepareRetiredTargets( + lockedInventory, lockedPreviousSuite, plan, stageToken) + retired.splice(0, retired.length, ...lockedRetired.filter(item => + lockedPaths.has(resolve(item.target.installDir)))) + + for (const item of prepared) { + if (item.reuse) { + await rm(item.stagedDir, { recursive: true, force: true }) + continue + } + if (item.replace) { + item.backupDir = `${item.installDir}.skillhub-suite-backup-${stageToken}` + await renameOperation(item.installDir, item.backupDir) + } + await renameOperation(item.stagedDir, item.installDir) + item.committed = true + } + for (const item of retired) { + if (await pathExists(item.target.installDir)) { + await renameOperation(item.target.installDir, item.backupDir) + item.moved = true + if ((await snapshotSkillDirectory(item.backupDir)).fingerprint !== item.fingerprint) { + throw new CliError(`retired Suite member changed before commit: ${item.target.installDir}`, EXIT.validation, { + path: item.target.installDir, + next: 'restore the retained directory and retry the Suite upgrade' + }) + } + } + } + + try { + await store.mutateAtomic(inventory => commitInventory( + inventory, options.registry, plan, source, prepared, retired)) + } catch (error) { + await rollbackTransaction(prepared, retired, error, renameOperation) + throw error + } + + for (const item of prepared) { + if (item.backupDir) { + await rm(item.backupDir, { recursive: true, force: true }).catch(() => {}) + item.backupDir = undefined + } + } + for (const item of retired) { + await rm(item.backupDir, { recursive: true, force: true }).catch(() => {}) + item.moved = false + } + } catch (error) { + if (prepared.some(item => item.committed || item.backupDir) || retired.some(item => item.moved)) { + await rollbackTransaction(prepared, retired, error, renameOperation) + } + throw error + } finally { + for (const release of releases.reverse()) await release().catch(() => {}) + } + + return { + plan, + installed: prepared.filter(item => !item.reuse).map(item => ({ + namespace: item.member.namespace, + slug: item.member.slug, + agent: item.target.agent, + dir: item.installDir + })), + reused: prepared.filter(item => item.reuse).map(item => ({ + namespace: item.member.namespace, + slug: item.member.slug, + agent: item.target.agent, + dir: item.installDir + })) + } + } finally { + await rm(stageHome, { recursive: true, force: true }).catch(() => {}) + for (const item of prepared) { + await rm(dirname(item.stagedDir), { recursive: true, force: true }).catch(() => {}) + } + } +} + +export async function checkSuite(options: { + registry: string + token?: string | undefined + namespace: string + slug: string + home?: string | undefined + client?: SkillHubClient | undefined +}): Promise { + const client = options.client ?? new SkillHubClient(options.registry, options.token) + await assertSuiteCapability(client) + const inventory = await new InventoryStore(options.home).read() + const suite = findInstalledSuite(inventory, options.registry, options.namespace, options.slug) + const installedRemote = await client.suiteDetail(options.namespace, options.slug, suite.version) + let latestRemote: SuiteDetail | undefined + try { + latestRemote = await client.suiteDetail(options.namespace, options.slug) + } catch (error) { + if (!(error instanceof CliError) || error.details.status !== 404) throw error + } + const members: SuiteCheckResult['members'] = [] + for (const member of suite.members) { + for (const installDir of member.installDirs) { + let status: SuiteCheckResult['members'][number]['status'] = 'missing' + if (await pathExists(installDir)) { + status = (await snapshotSkillDirectory(installDir)).fingerprint === member.fingerprint + ? 'ok' + : 'modified' + } + members.push({ + namespace: member.namespace, + slug: member.slug, + version: member.version, + dir: installDir, + status + }) + } + } + return { + suite, + ...(latestRemote ? { remoteVersion: latestRemote.version } : {}), + current: latestRemote?.version === suite.version + && installedRemote.available + && members.every(member => member.status === 'ok'), + installedVersionAvailable: installedRemote.available, + blockingReasons: installedRemote.members + .flatMap(member => member.blockingReason ? [member.blockingReason] : []), + members + } +} + +export async function removeSuite(options: SuiteRemoveOptions): Promise { + const releaseSuiteLock = await acquireSuiteOperationLock( + options.home, options.registry, options.namespace, options.slug) + try { + return await removeSuiteTransaction(options) + } finally { + await releaseSuiteLock().catch(() => {}) + } +} + +async function removeSuiteTransaction(options: SuiteRemoveOptions): Promise { + const store = new InventoryStore(options.home) + const inventory = await store.read() + const suite = findInstalledSuite(inventory, options.registry, options.namespace, options.slug) + const source = suiteSource(suite.namespace, suite.slug, suite.version) + const candidates: Array<{ + item: InventoryItem + target: InventoryTarget + fingerprint: string + backupDir: string + }> = [] + const removable: typeof candidates = [] + const preserved: SuiteRemoveResult['preserved'] = [] + const token = `${process.pid}-${Date.now()}` + + for (const member of suite.members) { + const item = inventory.items.find(candidate => + candidate.registry === options.registry && candidate.namespace === member.namespace && candidate.slug === member.slug) + if (!item) continue + for (const installDir of member.installDirs) { + const target = item.targets.find(candidate => resolve(candidate.installDir) === resolve(installDir)) + if (!target) continue + candidates.push({ + item, + target, + fingerprint: member.fingerprint, + backupDir: `${installDir}.skillhub-suite-remove-${token}` + }) + } + } + + const releases: Array<() => Promise> = [] + const moved: typeof removable = [] + try { + for (const candidate of [...candidates].sort((a, b) => a.target.installDir.localeCompare(b.target.installDir))) { + releases.push(await acquireSkillTargetLock(candidate.target.rootDir, candidate.item.slug)) + } + await options.afterTargetLocksAcquired?.() + const lockedInventory = await store.read() + const lockedSuite = findInstalledSuite( + lockedInventory, options.registry, options.namespace, options.slug) + assertSuiteSnapshotUnchanged(suite, lockedSuite) + for (const candidate of candidates) { + const current = lockedInventory.items.find(item => + item.registry === candidate.item.registry && item.namespace === candidate.item.namespace && + item.slug === candidate.item.slug) + const target = current?.targets.find(item => + resolve(item.installDir) === resolve(candidate.target.installDir)) + if (!current || !target || !(await pathExists(candidate.target.installDir))) { + preserved.push({ dir: candidate.target.installDir, reason: 'missing' }) + } else if (targetInstalledBy(current, target).some(candidateSource => candidateSource !== source)) { + preserved.push({ dir: candidate.target.installDir, reason: 'shared' }) + } else if ((await snapshotSkillDirectory(candidate.target.installDir)).fingerprint !== candidate.fingerprint) { + preserved.push({ dir: candidate.target.installDir, reason: 'modified' }) + } else { + removable.push({ ...candidate, item: current, target }) + } + } + for (const candidate of removable) { + await rename(candidate.target.installDir, candidate.backupDir) + moved.push(candidate) + if ((await snapshotSkillDirectory(candidate.backupDir)).fingerprint !== candidate.fingerprint) { + throw new CliError(`Suite member changed before removal: ${candidate.target.installDir}`, EXIT.validation, { + path: candidate.target.installDir, + next: 'restore the retained directory and run `skillhub suite check` before retrying' + }) + } + } + await store.mutateAtomic(current => { + current.suites = installedSuites(current).filter(candidate => + candidate.registry !== options.registry || candidate.namespace !== options.namespace || candidate.slug !== options.slug) + for (const item of current.items) { + if (item.registry !== options.registry) continue + const deletedDirs = new Set(removable + .filter(candidate => candidate.item.registry === item.registry && + candidate.item.namespace === item.namespace && candidate.item.slug === item.slug) + .map(candidate => resolve(candidate.target.installDir))) + item.targets = item.targets + .filter(target => !deletedDirs.has(resolve(target.installDir))) + .map(target => { + const remainingSources = targetInstalledBy(item, target) + .filter(candidate => candidate !== source) + return { ...target, installedBy: remainingSources.length > 0 ? remainingSources : ['direct'] } + }) + item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) + } + current.items = current.items.filter(item => item.targets.length > 0) + }) + } catch (error) { + for (const candidate of moved.reverse()) { + await rename(candidate.backupDir, candidate.target.installDir).catch(() => {}) + } + throw error + } finally { + for (const release of releases.reverse()) await release().catch(() => {}) + } + + for (const candidate of removable) { + await rm(candidate.backupDir, { recursive: true, force: true }).catch(() => {}) + } + return { removed: removable.map(candidate => candidate.target.installDir), preserved } +} + +export async function planSuiteUpgrade(options: { + registry: string + token?: string | undefined + namespace: string + slug: string + home?: string | undefined + client?: SkillHubClient | undefined +}): Promise { + const client = options.client ?? new SkillHubClient(options.registry, options.token) + await assertSuiteCapability(client) + const inventory = await new InventoryStore(options.home).read() + const current = findInstalledSuite(inventory, options.registry, options.namespace, options.slug) + const remote = await client.suiteDetail(options.namespace, options.slug) + if (!remote.available) { + throw new CliError(`Suite @${options.namespace}/${options.slug}@${remote.version} is unavailable`, EXIT.validation, { + blockedMembers: remote.members.filter(member => member.blockingReason).map(member => ({ + coordinate: `@${member.namespace}/${member.slug}@${member.version}`, + reason: member.blockingReason + })) + }) + } + + const currentMembers = new Map(current.members.map(member => [`${member.namespace}\u0000${member.slug}`, member])) + const remoteMembers = new Map(remote.members.map(member => [`${member.namespace}\u0000${member.slug}`, member])) + const changes: SuiteUpgradePlan['changes'] = [] + for (const [key, member] of remoteMembers) { + const existing = currentMembers.get(key) + if (!existing) { + changes.push({ coordinate: `@${member.namespace}/${member.slug}`, action: 'add', toVersion: member.version }) + } else if (existing.version !== member.version || existing.fingerprint !== member.fingerprint) { + changes.push({ + coordinate: `@${member.namespace}/${member.slug}`, + action: 'change', + fromVersion: existing.version, + toVersion: member.version + }) + } + } + for (const [key, member] of currentMembers) { + if (!remoteMembers.has(key)) { + changes.push({ coordinate: `@${member.namespace}/${member.slug}`, action: 'remove', fromVersion: member.version }) + } + } + return { current, remote, targets: installedSuiteTargets(inventory, current), changes } +} + +export async function upgradeSuite(options: { + registry: string + token?: string | undefined + namespace: string + slug: string + force?: boolean | undefined + home?: string | undefined + client?: SkillHubClient | undefined +}): Promise<{ upgrade: SuiteUpgradePlan; result?: SuiteInstallResult }> { + const client = options.client ?? new SkillHubClient(options.registry, options.token) + const upgrade = await planSuiteUpgrade({ ...options, client }) + if (upgrade.current.version === upgrade.remote.version && upgrade.changes.length === 0) { + return { upgrade } + } + const installPlan = await client.suiteInstallPlan( + options.namespace, + options.slug, + upgrade.remote.version, + randomUUID() + ) + assertNoTargetCollisions(installPlan) + const result = await installSuiteWithPlan({ + ...options, + version: upgrade.remote.version, + targets: upgrade.targets, + force: Boolean(options.force) + }, client, rename, installPlan, upgrade.current, true) + return { upgrade, result } +} + +function findInstalledSuite( + inventory: Inventory, + registry: string, + namespace: string, + slug: string +): InventorySuite { + const suite = installedSuites(inventory).find(candidate => + candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) + if (!suite) { + throw new CliError(`Suite @${namespace}/${slug} is not installed`, EXIT.validation, { + next: `run \`skillhub suite install @${namespace}/${slug}\`` + }) + } + return suite +} + +function installedSuiteTargets(inventory: Inventory, suite: InventorySuite): AgentCandidate[] { + const dirs = new Set(suite.members.flatMap(member => member.installDirs).map(installDir => dirname(resolve(installDir)))) + const targets = new Map() + for (const item of inventory.items) { + for (const target of item.targets) { + if (!dirs.has(resolve(target.rootDir))) continue + targets.set(resolve(target.rootDir), { + agent: target.agent, + rootDir: resolve(target.rootDir), + scope: 'user', + source: 'explicit' + }) + } + } + if (targets.size === 0) { + throw new CliError('installed Suite has no recoverable target directories', EXIT.validation, { + next: 'remove the stale Suite inventory entry and install it again' + }) + } + return [...targets.values()] +} + +function assertNoTargetCollisions(plan: SuiteInstallPlan): void { + const seen = new Map() + for (const member of plan.members) { + const coordinate = `@${member.namespace}/${member.slug}` + const existing = seen.get(member.slug) + if (existing && existing !== coordinate) { + throw new CliError(`suite members ${existing} and ${coordinate} use the same local directory`, EXIT.validation, { + slug: member.slug, + next: 'publish a Suite version without colliding member slugs' + }) + } + seen.set(member.slug, coordinate) + } +} + +async function preflightExistingTargets( + inventory: Inventory, + registry: string, + plan: SuiteInstallPlan, + targets: AgentCandidate[], + force: boolean, + allowVersionReplacement: boolean +): Promise { + for (const member of plan.members) { + const selectedDirs = new Set(targets.map(target => join(resolve(target.rootDir), member.slug))) + const sameItem = inventory.items.find(item => + item.registry === registry && + item.namespace === member.namespace && item.slug === member.slug) + if (sameItem && sameItem.version !== member.version) { + const retained = sameItem.targets.filter(target => !selectedDirs.has(resolve(target.installDir))) + if (retained.length > 0) { + throw new CliError(`partial Suite install would split versions for @${member.namespace}/${member.slug}`, EXIT.validation, { + retainedTargets: retained.map(target => target.installDir), + next: 'select every installed target or keep the existing Suite version' + }) + } + } + + for (const target of targets) { + const installDir = join(resolve(target.rootDir), member.slug) + const owner = inventory.items.find(item => + item.targets.some(existing => resolve(existing.installDir) === installDir)) + if (!owner && await pathExists(installDir)) { + throw new CliError(`unmanaged directory already exists at ${installDir}`, EXIT.validation, { + path: installDir, + next: 'move the directory or import it with `skillhub doctor` before installing the Suite' + }) + } + if (owner && (owner.registry !== registry || owner.namespace !== member.namespace || owner.slug !== member.slug)) { + throw new CliError(`install target is owned by @${owner.namespace}/${owner.slug}`, EXIT.validation, { + path: installDir, + next: 'choose another target or remove the conflicting Skill explicitly' + }) + } + const currentSuitePrefix = `suite:@${plan.namespace}/${plan.slug}@` + const ownerTarget = owner?.targets.find(existing => resolve(existing.installDir) === installDir) + if (owner && ownerTarget && !force && await pathExists(installDir) + && (await snapshotSkillDirectory(installDir)).fingerprint !== owner.fingerprint) { + throw new CliError(`local changes detected at ${installDir}`, EXIT.validation, { + path: installDir, + next: 'pass --force only if replacing these local changes is intended' + }) + } + if (owner && ownerTarget && owner.version !== member.version && targetInstalledBy(owner, ownerTarget).some(source => + source.startsWith('suite:') && !source.startsWith(currentSuitePrefix))) { + throw new CliError(`shared Suite member @${member.namespace}/${member.slug} cannot change version in place`, EXIT.validation, { + path: installDir, + next: 'install the Suite into another target or upgrade the sharing Suite first' + }) + } + if (owner && owner.version !== member.version && !allowVersionReplacement) { + throw new CliError(`different Skill version already installed at ${installDir}`, EXIT.validation, { + currentVersion: owner.version, + requestedVersion: member.version, + next: 'pass --force only if replacing this same Skill is intended' + }) + } + } + } +} + +async function isReusable( + inventory: Inventory, + registry: string, + member: SuiteInstallPlan['members'][number], + installDir: string +): Promise { + const item = inventory.items.find(candidate => + candidate.registry === registry && candidate.namespace === member.namespace && candidate.slug === member.slug && + candidate.version === member.version && candidate.fingerprint === member.fingerprint && + candidate.targets.some(target => resolve(target.installDir) === installDir)) + if (!item || !(await pathExists(installDir))) return false + return (await snapshotSkillDirectory(installDir)).fingerprint === member.fingerprint +} + +function commitInventory( + inventory: Inventory, + registry: string, + plan: SuiteInstallPlan, + source: string, + prepared: PreparedTarget[], + retired: RetiredTarget[] +): void { + const suitePrefix = `suite:@${plan.namespace}/${plan.slug}@` + for (const item of inventory.items) { + if (item.registry === registry) { + item.targets = item.targets.map(target => ({ + ...target, + installedBy: targetInstalledBy(item, target) + .filter(candidate => !candidate.startsWith(suitePrefix)) + })) + item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) + } + } + for (const member of plan.members) { + let item = inventory.items.find(candidate => + candidate.registry === registry && candidate.namespace === member.namespace && candidate.slug === member.slug) + if (!item) { + item = { + registry, + namespace: member.namespace, + slug: member.slug, + version: member.version, + fingerprint: member.fingerprint, + installedBy: [source], + targets: [] + } + inventory.items.push(item) + } + item.version = member.version + item.fingerprint = member.fingerprint + item.installedBy = Array.from(new Set([...installedBy(item), source])) + for (const preparedTarget of prepared.filter(candidate => candidate.member === member)) { + const target: InventoryTarget = { + agent: preparedTarget.target.agent, + rootDir: preparedTarget.target.rootDir, + installDir: preparedTarget.installDir, + installedAt: new Date().toISOString(), + installedBy: [source] + } + const index = item.targets.findIndex(existing => resolve(existing.installDir) === preparedTarget.installDir) + if (index >= 0) { + target.installedBy = Array.from(new Set([ + ...targetInstalledBy(item, item.targets[index]!), + source + ])) + item.targets[index] = target + } + else item.targets.push(target) + } + item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) + } + + const suite: InventorySuite = { + registry, + namespace: plan.namespace, + slug: plan.slug, + version: plan.version, + fingerprint: plan.fingerprint, + members: plan.members.map(member => ({ + namespace: member.namespace, + slug: member.slug, + version: member.version, + fingerprint: member.fingerprint, + installDirs: prepared.filter(item => item.member === member).map(item => item.installDir) + })) + } + inventory.suites = installedSuites(inventory).filter(candidate => + candidate.registry !== registry || candidate.namespace !== plan.namespace || candidate.slug !== plan.slug) + inventory.suites.push(suite) + + const retiredDirs = new Set(retired.map(item => resolve(item.target.installDir))) + const preparedDirs = new Set(prepared.map(item => resolve(item.installDir))) + for (const item of inventory.items) { + item.targets = item.targets + .filter(target => !retiredDirs.has(resolve(target.installDir))) + .map(target => { + const installDir = resolve(target.installDir) + if ((target.installedBy?.length ?? 0) > 0 || preparedDirs.has(installDir)) return target + // A retired directory that became shared or locally modified while waiting for locks is + // preserved as user-owned instead of becoming eligible for a later automatic deletion. + return { ...target, installedBy: ['direct'] } + }) + item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) + } + inventory.items = inventory.items.filter(item => item.targets.length > 0) +} + +async function rollbackTransaction( + prepared: PreparedTarget[], + retired: RetiredTarget[], + originalError: unknown, + renameOperation: typeof rename +): Promise { + const failures: Array<{ path: string; error: string }> = [] + for (const item of [...retired].reverse()) { + if (!item.moved) continue + try { + await renameOperation(item.backupDir, item.target.installDir) + item.moved = false + } catch (error) { + failures.push({ path: item.backupDir, error: describe(error) }) + } + } + for (const item of [...prepared].reverse()) { + try { + if (item.committed) await rm(item.installDir, { recursive: true, force: true }) + if (item.backupDir) await renameOperation(item.backupDir, item.installDir) + item.committed = false + item.backupDir = undefined + } catch (error) { + failures.push({ path: item.backupDir ?? item.installDir, error: describe(error) }) + } + } + if (failures.length > 0) { + throw new CliError('Suite installation failed and rollback was incomplete', EXIT.filesystem, { + originalError: describe(originalError), + rollbackFailures: failures, + next: 'restore the retained backup directories before retrying' + }) + } +} + +async function prepareRetiredTargets( + inventory: Inventory, + previous: InventorySuite | undefined, + next: SuiteInstallPlan, + token: string +): Promise { + if (!previous) return [] + const nextCoordinates = new Set(next.members.map(member => `${member.namespace}\u0000${member.slug}`)) + const oldSource = suiteSource(previous.namespace, previous.slug, previous.version) + const retired: RetiredTarget[] = [] + for (const member of previous.members) { + if (nextCoordinates.has(`${member.namespace}\u0000${member.slug}`)) continue + const item = inventory.items.find(candidate => + candidate.registry === previous.registry && candidate.namespace === member.namespace && candidate.slug === member.slug) + if (!item) continue + for (const installDir of member.installDirs) { + const target = item.targets.find(candidate => resolve(candidate.installDir) === resolve(installDir)) + if (!target || !(await pathExists(installDir))) continue + if (targetInstalledBy(item, target).some(source => source !== oldSource)) continue + if ((await snapshotSkillDirectory(installDir)).fingerprint !== member.fingerprint) continue + retired.push({ + item, + target, + backupDir: `${installDir}.skillhub-suite-retired-${token}`, + fingerprint: member.fingerprint, + moved: false + }) + } + } + return retired +} + +function assertSuiteSnapshotUnchanged( + before: InventorySuite | undefined, + locked: InventorySuite | undefined +): void { + if (suiteSnapshot(before) === suiteSnapshot(locked)) return + throw new CliError('installed Suite changed while waiting for target locks', EXIT.validation, { + next: 'run `skillhub suite check` and retry' + }) +} + +function suiteSnapshot(suite: InventorySuite | undefined): string { + if (!suite) return '' + const members = suite.members.map(member => ({ + namespace: member.namespace, + slug: member.slug, + version: member.version, + fingerprint: member.fingerprint, + installDirs: member.installDirs.map(installDir => resolve(installDir)).sort() + })).sort((left, right) => + `${left.namespace}\0${left.slug}`.localeCompare(`${right.namespace}\0${right.slug}`)) + return JSON.stringify({ + registry: suite.registry, + namespace: suite.namespace, + slug: suite.slug, + version: suite.version, + fingerprint: suite.fingerprint, + members + }) +} + +/** Serializes local install, upgrade, and remove operations for one Suite inventory identity. */ +async function acquireSuiteOperationLock( + home: string | undefined, + registry: string, + namespace: string, + slug: string +): Promise<() => Promise> { + const uid = typeof process.getuid === 'function' ? process.getuid() : 'user' + const lockDir = join(tmpdir(), `skillhub-cli-suite-locks-${uid}`) + await ensurePrivateLockDir(lockDir) + const digest = createHash('sha256') + .update(`${userStateDir(home)}\0${registry}\0${namespace}\0${slug}`) + .digest('hex') + const lockPath = join(lockDir, `${digest}.lock`) + try { + return await lock(lockPath, { + lockfilePath: lockPath, + realpath: false, + stale: 30_000, + update: 10_000, + retries: 0 + }) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ELOCKED') { + throw new CliError(`Suite operation is busy: @${namespace}/${slug}`, EXIT.filesystem, { + next: 'wait for the other SkillHub CLI process to finish and retry' + }) + } + throw error + } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/cli/src/stores/inventory-store.ts b/cli/src/stores/inventory-store.ts index 8d540744..6ef4b0fb 100644 --- a/cli/src/stores/inventory-store.ts +++ b/cli/src/stores/inventory-store.ts @@ -8,6 +8,8 @@ export interface InventoryTarget { rootDir: string installDir: string installedAt: string + /** Sources that own this exact target. Missing values inherit the legacy item-level sources. */ + installedBy?: string[] } export interface InventoryItem { @@ -16,11 +18,54 @@ export interface InventoryItem { slug: string version: string fingerprint?: string + /** Missing on legacy records and therefore interpreted as a direct install. */ + installedBy?: string[] targets: InventoryTarget[] } +export interface InventorySuiteMember { + namespace: string + slug: string + version: string + fingerprint: string + installDirs: string[] +} + +export interface InventorySuite { + registry: string + namespace: string + slug: string + version: string + fingerprint: string + members: InventorySuiteMember[] +} + export interface Inventory { items: InventoryItem[] + suites?: InventorySuite[] +} + +export function installedBy(item: InventoryItem): string[] { + return item.installedBy ?? ['direct'] +} + +export function targetInstalledBy(item: InventoryItem, target: InventoryTarget): string[] { + return target.installedBy ?? installedBy(item) +} + +function addDirectSource(item: InventoryItem, target: InventoryTarget): InventoryTarget { + return { + ...target, + installedBy: Array.from(new Set([...targetInstalledBy(item, target), 'direct'])) + } +} + +function refreshItemSources(item: InventoryItem): void { + item.installedBy = Array.from(new Set(item.targets.flatMap(target => targetInstalledBy(item, target)))) +} + +export function installedSuites(inventory: Inventory): InventorySuite[] { + return inventory.suites ?? [] } export class InventoryVersionConflictError extends Error { @@ -58,7 +103,7 @@ export class InventoryStore { } } - private async mutateAtomic(mutate: (inventory: Inventory) => T): Promise { + async mutateAtomic(mutate: (inventory: Inventory) => T): Promise { await ensureDir(dirname(this.path)) let release: (() => Promise) | null = null try { @@ -118,8 +163,11 @@ export class InventoryStore { item.version = version if (fingerprint !== undefined) item.fingerprint = fingerprint const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir) - if (existingIdx >= 0) item.targets[existingIdx] = target - else item.targets.push(target) + const previous = existingIdx >= 0 ? item.targets[existingIdx]! : target + const next = addDirectSource(item, { ...target, installedBy: targetInstalledBy(item, previous) }) + if (existingIdx >= 0) item.targets[existingIdx] = next + else item.targets.push(next) + refreshItemSources(item) }) } @@ -165,12 +213,13 @@ export class InventoryStore { let item = inventory.items.find(candidate => candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) if (!item) { - item = { registry, namespace, slug, version, targets: [] } + item = { registry, namespace, slug, version, installedBy: ['direct'], targets: [] } inventory.items.push(item) } item.version = version if (fingerprint !== undefined) item.fingerprint = fingerprint - item.targets.push(target) + item.targets.push({ ...target, installedBy: ['direct'] }) + refreshItemSources(item) }) } @@ -204,12 +253,13 @@ export class InventoryStore { let item = inventory.items.find(candidate => candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) if (!item) { - item = { registry, namespace, slug, version, targets: [] } + item = { registry, namespace, slug, version, installedBy: ['direct'], targets: [] } inventory.items.push(item) } item.version = version if (fingerprint !== undefined) item.fingerprint = fingerprint - item.targets.push(...targets) + item.targets.push(...targets.map(target => ({ ...target, installedBy: ['direct'] }))) + refreshItemSources(item) }) } } diff --git a/cli/test/integration/help-command.test.ts b/cli/test/integration/help-command.test.ts index 8931ca50..0a50779f 100644 --- a/cli/test/integration/help-command.test.ts +++ b/cli/test/integration/help-command.test.ts @@ -37,6 +37,16 @@ describe('help command', () => { expect(result.stdout).toContain('skillhub search') }) + test('states that Suite commands require a compatible registry', async () => { + const topic = await runCli(['help', 'suite']) + expect(topic.exitCode).toBe(0) + expect(topic.stdout).toContain('Manage Skill Suites on compatible registries') + + const root = await runCli(['--help']) + expect(root.exitCode).toBe(0) + expect(root.stdout).toContain('Manage Skill Suites on compatible registries') + }) + test('distinguishes skill upgrade from CLI self-update and namespace sync', async () => { const upgrade = await runCli(['help', 'upgrade']) expect(upgrade.exitCode).toBe(0) diff --git a/cli/test/unit/services/suite-service.test.ts b/cli/test/unit/services/suite-service.test.ts new file mode 100644 index 00000000..6a962c01 --- /dev/null +++ b/cli/test/unit/services/suite-service.test.ts @@ -0,0 +1,1136 @@ +import { createHash } from 'node:crypto' +import { access, mkdir, mkdtemp, readFile, readdir, rename, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, test } from 'bun:test' +import { zipSync } from 'fflate' +import { SkillHubClient, type SuiteInstallPlan } from '../../../src/clients/skillhub-client' +import { InventoryStore } from '../../../src/stores/inventory-store' +import { + checkSuite, + installSuite, + planSuiteUpgrade, + removeSuite, + upgradeSuite +} from '../../../src/services/suite-service' + +const registry = 'http://registry.test' + +function archive(content: string): { bytes: Uint8Array; fingerprint: string } { + const bytes = zipSync({ 'SKILL.md': new TextEncoder().encode(content) }) + const aggregate = createHash('sha256') + const fileHash = createHash('sha256').update(content).digest('hex') + aggregate.update(`SKILL.md:${fileHash}\n`, 'utf8') + return { bytes, fingerprint: `sha256:${aggregate.digest('hex')}` } +} + +function clientFor( + plan: SuiteInstallPlan, + downloads: Record, + capabilities: string[] = ['skill-suite-v1'], + available = true +): SkillHubClient { + const fetchImpl = (async (input: URL | RequestInfo) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ apiBase: '/api/v1', capabilities }) + } + if (url.pathname.endsWith('/install-plan')) return Response.json({ code: 0, data: plan }) + if (url.pathname === `/api/v1/suites/${plan.namespace}/${plan.slug}`) { + return Response.json({ code: 0, data: { + id: 1, + versionId: 2, + namespace: plan.namespace, + slug: plan.slug, + displayName: plan.slug, + version: plan.version, + status: 'PUBLISHED', + visibility: 'PUBLIC', + available, + members: plan.members.map(member => ({ + ...member, + blockingReason: available ? null : 'member_unavailable' + })) + } }) + } + const bytes = downloads[url.pathname] + return bytes + ? new Response(bytes.slice().buffer as ArrayBuffer, { status: 200 }) + : Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + return new SkillHubClient(registry, undefined, fetchImpl) +} + +function clientWithPlanFailure(status: number, message: string): SkillHubClient { + const fetchImpl = (async (input: URL | RequestInfo) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ apiBase: '/api/v1', capabilities: ['skill-suite-v1'] }) + } + if (url.pathname.endsWith('/install-plan')) { + return Response.json({ code: status, msg: message }, { status }) + } + return Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + return new SkillHubClient(registry, undefined, fetchImpl) +} + +function makePlan(): { plan: SuiteInstallPlan; downloads: Record } { + const alpha = archive('# Alpha') + const beta = archive('# Beta') + return { + plan: { + operationId: 'operation-1', + namespace: 'global', + slug: 'starter-pack', + version: '1.0.0', + fingerprint: 'sha256:suite', + members: [ + { + skillId: 10, + skillVersionId: 11, + namespace: 'global', + slug: 'alpha', + version: '1.0.0', + fingerprint: alpha.fingerprint, + downloadUrl: '/downloads/alpha', + position: 0, + entry: true + }, + { + skillId: 20, + skillVersionId: 21, + namespace: 'global', + slug: 'beta', + version: '2.0.0', + fingerprint: beta.fingerprint, + downloadUrl: '/downloads/beta', + position: 1, + entry: false + } + ] + }, + downloads: { '/downloads/alpha': alpha.bytes, '/downloads/beta': beta.bytes } + } +} + +async function exists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +describe('Suite local lifecycle', () => { + test('installs every exact member and writes one Suite inventory snapshot', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + + const result = await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + expect(result.installed).toHaveLength(2) + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.suites).toEqual([expect.objectContaining({ slug: 'starter-pack', version: '1.0.0' })]) + expect(inventory.items).toHaveLength(2) + expect(inventory.items[0].installedBy).toEqual(['suite:@global/starter-pack@1.0.0']) + + const checked = await checkSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + client: clientFor(plan, downloads) + }) + expect(checked.current).toBe(true) + expect(checked.members.every(member => member.status === 'ok')).toBe(true) + }) + + test('serializes the same Suite across different Agent targets', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-first-root-')) + const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-second-root-')) + const { plan, downloads } = makePlan() + let signalFirstLocked: (() => void) | undefined + let releaseFirst: (() => void) | undefined + const firstLocked = new Promise((resolvePromise) => { signalFirstLocked = resolvePromise }) + const holdFirst = new Promise((resolvePromise) => { releaseFirst = resolvePromise }) + + const firstInstall = installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads), + afterTargetLocksAcquired: async () => { + signalFirstLocked?.() + await holdFirst + } + }) + + await firstLocked + try { + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'claude', rootDir: secondRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + })).rejects.toThrow('Suite operation is busy') + } finally { + releaseFirst?.() + await firstInstall + } + + const inventory = await new InventoryStore(home).read() + expect(inventory.suites).toHaveLength(1) + expect(inventory.suites?.[0]?.members.every(member => + member.installDirs.every(dir => dir.startsWith(firstRoot)))).toBe(true) + expect(await exists(join(secondRoot, 'alpha'))).toBe(false) + }) + + test('does not change live directories or inventory when a member fingerprint fails', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.members[1]!.fingerprint = 'sha256:wrong' + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + })).rejects.toThrow('fingerprint does not match') + + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + expect(await exists(join(rootDir, 'beta'))).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('sends one idempotency key with the install-plan request', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + let idempotencyKey: string | null = null + const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ capabilities: ['skill-suite-v1'] }) + } + if (url.pathname.endsWith('/install-plan')) { + idempotencyKey = new Headers(init?.headers).get('Idempotency-Key') + return Response.json({ code: 0, data: plan }) + } + const bytes = downloads[url.pathname] + return bytes + ? new Response(bytes.slice().buffer as ArrayBuffer) + : Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: new SkillHubClient(registry, undefined, fetchImpl) + }) + + expect(idempotencyKey).toMatch(/^[0-9a-f-]{36}$/) + }) + + test('retries a transient install-plan failure with the same idempotency key', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + const keys: Array = [] + let planAttempts = 0 + const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ capabilities: ['skill-suite-v1'] }) + } + if (url.pathname.endsWith('/install-plan')) { + keys.push(new Headers(init?.headers).get('Idempotency-Key')) + planAttempts += 1 + if (planAttempts === 1) return Response.json({ msg: 'temporary outage' }, { status: 503 }) + return Response.json({ code: 0, data: plan }) + } + const bytes = downloads[url.pathname] + return bytes + ? new Response(bytes.slice().buffer as ArrayBuffer) + : Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: new SkillHubClient(registry, undefined, fetchImpl) + }) + + expect(keys).toHaveLength(2) + expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/) + expect(keys[1]).toBe(keys[0]) + }) + + test('reports an installed exact version as stale when a member becomes unavailable', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + const checked = await checkSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + client: clientFor(plan, downloads, ['skill-suite-v1'], false) + }) + + expect(checked.current).toBe(false) + expect(checked.installedVersionAvailable).toBe(false) + expect(checked.blockingReasons).toContain('member_unavailable') + }) + + test('checks the installed version when the suite has no latest published version', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + const fetchImpl = (async (input: URL | RequestInfo) => { + const url = new URL(String(input)) + if (url.pathname === '/.well-known/clawhub.json') { + return Response.json({ capabilities: ['skill-suite-v1'] }) + } + if (url.pathname === `/api/v1/suites/${plan.namespace}/${plan.slug}`) { + if (!url.searchParams.has('version')) { + return Response.json({ code: 404, msg: 'no latest version' }, { status: 404 }) + } + return Response.json({ code: 0, data: { + id: 1, + versionId: 2, + namespace: plan.namespace, + slug: plan.slug, + displayName: plan.slug, + version: plan.version, + status: 'YANKED', + visibility: 'PUBLIC', + available: false, + members: plan.members.map(member => ({ ...member, blockingReason: 'VERSION_UNAVAILABLE' })) + } }) + } + return Response.json({ code: 404 }, { status: 404 }) + }) as unknown as typeof fetch + + const checked = await checkSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + client: new SkillHubClient(registry, undefined, fetchImpl) + }) + + expect(checked.remoteVersion).toBeUndefined() + expect(checked.current).toBe(false) + expect(checked.installedVersionAvailable).toBe(false) + expect(checked.blockingReasons).toContain('VERSION_UNAVAILABLE') + }) + + test('rejects an old Server before resolving or writing Suite members', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads, []) + })).rejects.toThrow('registry does not support Skill Suites') + + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('treats a non-JSON metadata response as an unsupported old Server', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const fetchImpl = (async () => new Response('legacy registry', { + status: 200, + headers: { 'Content-Type': 'text/html' } + })) as unknown as typeof fetch + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: new SkillHubClient(registry, undefined, fetchImpl) + })).rejects.toThrow('registry does not support Skill Suites') + + expect(await exists(join(rootDir, 'starter-pack'))).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('keeps every target untouched when the Server denies a member plan', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + + await expect(installSuite({ + registry, + namespace: 'private-team', + slug: 'restricted-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientWithPlanFailure(403, 'access denied') + })).rejects.toThrow('access denied') + + expect(await readdir(rootDir)).toEqual([]) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('blocks an upgrade when the exact remote snapshot has an unavailable member', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + await expect(planSuiteUpgrade({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + client: clientFor(plan, downloads, ['skill-suite-v1'], false) + })).rejects.toThrow('is unavailable') + + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + }) + + test('rolls back all live members when a filesystem commit fails midway', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + const renameOperation: typeof rename = async (source, target): Promise => { + if (String(source).includes('.skillhub-suite-stage-') && String(target) === join(rootDir, 'beta')) { + throw new Error('injected rename failure') + } + await rename(source, target) + } + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads), + renameOperation + })).rejects.toThrow('injected rename failure') + + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + expect(await exists(join(rootDir, 'beta'))).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('rolls back the first Agent target when the second target commit fails', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const codexRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-codex-')) + const claudeRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-claude-')) + const { plan, downloads } = makePlan() + const renameOperation: typeof rename = async (source, target): Promise => { + if (String(source).includes('.skillhub-suite-stage-') + && String(target) === join(claudeRoot, 'alpha')) { + throw new Error('injected second target failure') + } + await rename(source, target) + } + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [ + { agent: 'codex', rootDir: codexRoot, scope: 'project', source: 'explicit' }, + { agent: 'claude', rootDir: claudeRoot, scope: 'project', source: 'explicit' } + ], + force: false, + home, + client: clientFor(plan, downloads), + renameOperation + })).rejects.toThrow('injected second target failure') + + expect(await readdir(codexRoot)).toEqual([]) + expect(await readdir(claudeRoot)).toEqual([]) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('reports retained backup paths when rollback cannot restore a replaced member', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + const old = archive('# Old Alpha') + const alphaDir = join(rootDir, 'alpha') + await mkdir(alphaDir, { recursive: true }) + await writeFile(join(alphaDir, 'SKILL.md'), '# Old Alpha') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({ + items: [{ + registry, + namespace: 'global', + slug: 'alpha', + version: '0.9.0', + fingerprint: old.fingerprint, + installedBy: ['direct'], + targets: [{ agent: 'codex', rootDir, installDir: alphaDir, installedAt: '2026-09-01T00:00:00Z' }] + }], + suites: [] + })) + const renameOperation: typeof rename = async (source, target): Promise => { + const from = String(source) + const to = String(target) + if (from.includes('.skillhub-suite-stage-') && to === join(rootDir, 'beta')) { + throw new Error('injected commit failure') + } + if (from.includes('.skillhub-suite-backup-') && to === alphaDir) { + throw new Error('injected restore failure') + } + await rename(source, target) + } + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home, + client: clientFor(plan, downloads), + renameOperation + })).rejects.toThrow('rollback was incomplete') + + const retainedBackup = (await readdir(rootDir)).find(name => name.startsWith('alpha.skillhub-suite-backup-')) + expect(retainedBackup).toBeDefined() + expect(await readFile(join(rootDir, retainedBackup!, 'SKILL.md'), 'utf8')).toBe('# Old Alpha') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.items[0]).toMatchObject({ slug: 'alpha', version: '0.9.0', installedBy: ['direct'] }) + }) + + test('installs one exact snapshot across multiple Agent targets', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const codexRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-codex-')) + const claudeRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-claude-')) + const { plan, downloads } = makePlan() + + const result = await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [ + { agent: 'codex', rootDir: codexRoot, scope: 'project', source: 'explicit' }, + { agent: 'claude', rootDir: claudeRoot, scope: 'project', source: 'explicit' } + ], + force: false, + home, + client: clientFor(plan, downloads) + }) + + expect(result.installed).toHaveLength(4) + expect(await readFile(join(codexRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + expect(await readFile(join(claudeRoot, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.suites[0].members[0].installDirs).toHaveLength(2) + }) + + test('rejects member coordinates that collide in the local directory layout', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.members[1] = { ...plan.members[1]!, namespace: 'team', slug: 'alpha' } + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + })).rejects.toThrow('use the same local directory') + + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + }) + + test('does not create a Suite directory when a Skill and Suite share one coordinate', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.slug = 'marketing' + plan.members = [plan.members[1]!] + const directSkillDir = join(rootDir, 'marketing') + await mkdir(directSkillDir, { recursive: true }) + await writeFile(join(directSkillDir, 'SKILL.md'), '# Direct marketing Skill') + + await installSuite({ + registry, + namespace: 'global', + slug: 'marketing', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + expect(await readFile(join(directSkillDir, 'SKILL.md'), 'utf8')).toBe('# Direct marketing Skill') + expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') + }) + + test('preserves a member shared by two installed Suites', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const first = makePlan() + first.plan.members = [first.plan.members[0]!] + await installSuite({ + registry, + namespace: 'global', + slug: first.plan.slug, + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + const secondPlan = { ...first.plan, slug: 'editor-pack', fingerprint: 'sha256:editor-pack' } + await installSuite({ + registry, + namespace: 'global', + slug: secondPlan.slug, + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(secondPlan, first.downloads) + }) + + const removed = await removeSuite({ + registry, + namespace: 'global', + slug: first.plan.slug, + home + }) + + expect(removed.preserved).toEqual([{ dir: join(rootDir, 'alpha'), reason: 'shared' }]) + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.items[0].installedBy).toEqual(['suite:@global/editor-pack@1.0.0']) + }) + + test('removing a Suite does not rewrite the same coordinate from another registry', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-registry-a-')) + const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-registry-b-')) + const secondRegistry = 'http://registry-b.test' + const { plan, downloads } = makePlan() + plan.members = [plan.members[0]!] + + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + await installSuite({ + registry: secondRegistry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'claude', rootDir: secondRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + + const afterFirstRemoval = await new InventoryStore(home).read() + expect(afterFirstRemoval.suites).toEqual([ + expect.objectContaining({ registry: secondRegistry, slug: 'starter-pack' }) + ]) + expect(afterFirstRemoval.items).toEqual([ + expect.objectContaining({ + registry: secondRegistry, + slug: 'alpha', + installedBy: ['suite:@global/starter-pack@1.0.0'] + }) + ]) + expect(await readFile(join(secondRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + + const secondRemoval = await removeSuite({ + registry: secondRegistry, + namespace: 'global', + slug: 'starter-pack', + home + }) + expect(secondRemoval.removed).toEqual([join(secondRoot, 'alpha')]) + expect(await exists(join(secondRoot, 'alpha'))).toBe(false) + }) + + test('reuses a matching legacy direct install and preserves it when Suite is removed', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.members = [plan.members[0]!] + const skillDir = join(rootDir, 'alpha') + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# Alpha') + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({ + items: [{ + registry, + namespace: 'global', + slug: 'alpha', + version: '1.0.0', + fingerprint: plan.members[0]!.fingerprint, + targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }] + }] + })) + + const installed = await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + expect(installed.reused).toHaveLength(1) + + const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + expect(removed.preserved).toEqual([{ dir: skillDir, reason: 'shared' }]) + expect(await exists(skillDir)).toBe(true) + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.suites).toEqual([]) + expect(inventory.items[0].installedBy).toEqual(['direct']) + }) + + test('requires force before replacing a locally modified same-version member', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + plan.members = [plan.members[0]!] + const member = plan.members[0]! + const skillDir = join(rootDir, member.slug) + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# Alpha') + const store = new InventoryStore(home) + await store.upsertTarget(registry, member.namespace, member.slug, member.version, { + agent: 'codex', rootDir, installDir: skillDir, installedAt: new Date().toISOString() + }, member.fingerprint) + await writeFile(join(skillDir, 'SKILL.md'), '# Locally modified Alpha') + + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + })).rejects.toThrow('local changes') + + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf8')).toBe('# Locally modified Alpha') + expect((await store.read()).suites ?? []).toEqual([]) + }) + + test('tracks direct and Suite ownership independently for each Agent target', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const directRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-direct-root-')) + const suiteOnlyRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-only-root-')) + const { plan, downloads } = makePlan() + const alpha = plan.members[0]! + await mkdir(join(directRoot, alpha.slug), { recursive: true }) + await writeFile(join(directRoot, alpha.slug, 'SKILL.md'), '# Alpha') + const store = new InventoryStore(home) + await store.upsertTarget(registry, alpha.namespace, alpha.slug, alpha.version, { + agent: 'codex', rootDir: directRoot, installDir: join(directRoot, alpha.slug), + installedAt: new Date().toISOString() + }, alpha.fingerprint) + + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [ + { agent: 'codex', rootDir: directRoot, scope: 'project', source: 'explicit' }, + { agent: 'claude', rootDir: suiteOnlyRoot, scope: 'project', source: 'explicit' } + ], + force: false, + home, + client: clientFor(plan, downloads) + }) + await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + + expect(await access(join(directRoot, 'alpha')).then(() => true)).toBe(true) + await expect(access(join(suiteOnlyRoot, 'alpha'))).rejects.toThrow() + const inventory = await store.read() + const alphaItem = inventory.items.find(item => item.slug === 'alpha')! + expect(alphaItem.targets).toHaveLength(1) + expect(alphaItem.targets[0]!.installedBy).toEqual(['direct']) + }) + + test('removes unmodified members that are owned only by the Suite', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + + expect(removed.removed.sort()).toEqual([join(rootDir, 'alpha'), join(rootDir, 'beta')].sort()) + expect(await exists(join(rootDir, 'alpha'))).toBe(false) + expect(await exists(join(rootDir, 'beta'))).toBe(false) + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory).toMatchObject({ items: [], suites: [] }) + }) + + test('upgrades the exact snapshot and retires members removed by the new Suite version', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const first = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + + const alphaV2 = archive('# Alpha v2') + const gamma = archive('# Gamma') + const second: SuiteInstallPlan = { + ...first.plan, + operationId: 'operation-2', + version: '2.0.0', + fingerprint: 'sha256:suite-v2', + members: [ + { ...first.plan.members[0]!, skillVersionId: 12, version: '2.0.0', fingerprint: alphaV2.fingerprint }, + { + skillId: 30, + skillVersionId: 31, + namespace: 'global', + slug: 'gamma', + version: '1.0.0', + fingerprint: gamma.fingerprint, + downloadUrl: '/downloads/gamma', + position: 1, + entry: false + } + ] + } + const client = clientFor(second, { + '/downloads/alpha': alphaV2.bytes, + '/downloads/gamma': gamma.bytes + }) + + const upgraded = await upgradeSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client }) + + expect(upgraded.upgrade.changes.map(change => change.action).sort()).toEqual(['add', 'change', 'remove']) + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha v2') + expect(await exists(join(rootDir, 'beta'))).toBe(false) + expect(await readFile(join(rootDir, 'gamma', 'SKILL.md'), 'utf8')).toBe('# Gamma') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.suites[0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) + expect(inventory.items.map((item: { slug: string }) => item.slug).sort()).toEqual(['alpha', 'gamma']) + }) + + test('does not overwrite a locally modified member during Suite upgrade by default', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const first = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + await writeFile(join(rootDir, 'alpha', 'SKILL.md'), '# Locally modified Alpha') + + const alphaV2 = archive('# Alpha v2') + const second: SuiteInstallPlan = { + ...first.plan, + operationId: 'operation-2', + version: '2.0.0', + fingerprint: 'sha256:suite-v2', + members: [ + { ...first.plan.members[0]!, skillVersionId: 12, version: '2.0.0', fingerprint: alphaV2.fingerprint }, + first.plan.members[1]! + ] + } + + await expect(upgradeSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + client: clientFor(second, { ...first.downloads, '/downloads/alpha': alphaV2.bytes }) + })).rejects.toThrow('local changes') + + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Locally modified Alpha') + const unchangedInventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(unchangedInventory.suites[0]).toMatchObject({ version: '1.0.0', fingerprint: 'sha256:suite' }) + + await upgradeSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + force: true, + home, + client: clientFor(second, { ...first.downloads, '/downloads/alpha': alphaV2.bytes }) + }) + + expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha v2') + const upgradedInventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(upgradedInventory.suites[0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) + }) + + test('does not reinstall a Suite removed after upgrade planning', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const first = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + + const nextPlan = { ...first.plan, operationId: 'operation-2', version: '2.0.0' } + const client = clientFor(nextPlan, first.downloads) + const fetchDetail = client.suiteDetail.bind(client) + let signalPlanRead: (() => void) | undefined + let releasePlan: (() => void) | undefined + const planRead = new Promise((resolvePromise) => { signalPlanRead = resolvePromise }) + const holdPlan = new Promise((resolvePromise) => { releasePlan = resolvePromise }) + client.suiteDetail = async (...args) => { + signalPlanRead?.() + await holdPlan + return fetchDetail(...args) + } + + const upgrading = upgradeSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client }) + await planRead + await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + releasePlan?.() + + await expect(upgrading).rejects.toThrow('installed Suite changed while waiting for target locks') + expect(await readdir(rootDir)).toEqual([]) + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory).toMatchObject({ items: [], suites: [] }) + }) + + test('rejects stale upgrade targets after the same Suite is reinstalled elsewhere', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const originalRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-original-')) + const replacementRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-replacement-')) + const first = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir: originalRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + + const nextPlan = { ...first.plan, operationId: 'operation-2', version: '2.0.0' } + const upgradeClient = clientFor(nextPlan, first.downloads) + const fetchDetail = upgradeClient.suiteDetail.bind(upgradeClient) + let signalPlanRead: (() => void) | undefined + let releasePlan: (() => void) | undefined + const planRead = new Promise((resolvePromise) => { signalPlanRead = resolvePromise }) + const holdPlan = new Promise((resolvePromise) => { releasePlan = resolvePromise }) + upgradeClient.suiteDetail = async (...args) => { + signalPlanRead?.() + await holdPlan + return fetchDetail(...args) + } + + const upgrading = upgradeSuite({ + registry, namespace: 'global', slug: 'starter-pack', home, client: upgradeClient + }) + await planRead + try { + await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'claude', rootDir: replacementRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + } finally { + releasePlan?.() + } + + await expect(upgrading).rejects.toThrow('installed Suite changed while waiting for target locks') + expect(await readdir(originalRoot)).toEqual([]) + expect(await readFile(join(replacementRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) + expect(inventory.suites[0]).toMatchObject({ version: '1.0.0', fingerprint: 'sha256:suite' }) + expect(inventory.suites[0].members.every((member: { installDirs: string[] }) => + member.installDirs.every(dir => dir.startsWith(replacementRoot)))).toBe(true) + }) + + test('preserves a member modified after removal starts but before locked validation', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + const removed = await removeSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + afterTargetLocksAcquired: async () => { + await writeFile(join(rootDir, 'beta', 'SKILL.md'), '# Locally modified Beta') + } + }) + + expect(removed.removed).toEqual([join(rootDir, 'alpha')]) + expect(removed.preserved).toEqual([{ dir: join(rootDir, 'beta'), reason: 'modified' }]) + expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Locally modified Beta') + const inventory = await new InventoryStore(home).read() + expect(inventory.items).toEqual([expect.objectContaining({ + slug: 'beta', + installedBy: ['direct'] + })]) + }) + + test('preserves a retired member that gains direct ownership before locked upgrade validation', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const first = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + + const second: SuiteInstallPlan = { + ...first.plan, + operationId: 'operation-2', + version: '2.0.0', + fingerprint: 'sha256:suite-v2', + members: [first.plan.members[0]!] + } + const beta = first.plan.members[1]! + const betaDir = join(rootDir, beta.slug) + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + version: '2.0.0', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home, + client: clientFor(second, first.downloads), + afterTargetLocksAcquired: async () => { + await new InventoryStore(home).upsertTarget( + registry, beta.namespace, beta.slug, beta.version, + { agent: 'codex', rootDir, installDir: betaDir, installedAt: new Date().toISOString() }, + beta.fingerprint) + } + }) + + expect(await readFile(join(betaDir, 'SKILL.md'), 'utf8')).toBe('# Beta') + const inventory = await new InventoryStore(home).read() + expect(inventory.items.find(item => item.slug === 'beta')).toMatchObject({ installedBy: ['direct'] }) + }) +}) diff --git a/cli/test/unit/stores/inventory-store.test.ts b/cli/test/unit/stores/inventory-store.test.ts index 58ccaf21..3b6f9750 100644 --- a/cli/test/unit/stores/inventory-store.test.ts +++ b/cli/test/unit/stores/inventory-store.test.ts @@ -122,6 +122,6 @@ describe('InventoryStore', () => { const inventory = await store.read() expect(inventory.items).toHaveLength(1) expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: 'fp-v1' }) - expect(inventory.items[0]?.targets).toEqual([retained]) + expect(inventory.items[0]?.targets).toEqual([{ ...retained, installedBy: ['direct'] }]) }) }) diff --git a/compose.release.yml b/compose.release.yml index 97381d11..317226b5 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -71,6 +71,8 @@ services: SPRING_DATA_REDIS_TIMEOUT: SPRING_DATA_REDIS_CLIENT_NAME: SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST: + # Compose replaces its single Server without an old/new mixed-version window. + SKILLHUB_SUITE_REVIEW_WRITES_ENABLED: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:-true} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-} DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-} diff --git a/docs/25-skill-suites.md b/docs/25-skill-suites.md new file mode 100644 index 00000000..b03850f4 --- /dev/null +++ b/docs/25-skill-suites.md @@ -0,0 +1,151 @@ +# Skill Suite 设计与使用 + +## 定位 + +Skill Suite 是一个有独立身份和版本的 Skill 集合。它只引用当前 SkillHub 中已经发布的精确 +SkillVersion,不复制 Skill 文件,也不替成员重新执行扫描或审核。 + +Skill 与 Suite 的完整身份都包含资源类型,因此下列两个资源可以同时存在: + +```text +SKILL @global/marketing +SUITE @global/marketing +``` + +原有 `skillhub install @global/marketing` 始终安装 Skill;Suite 必须使用 +`skillhub suite install @global/marketing`,不会根据名称猜测类型。 + +## 版本和生命周期 + +SuiteVersion 固定成员的 Skill ID、SkillVersion ID、坐标、版本和 fingerprint。成员发布新版本不会 +改变现有 SuiteVersion;调整成员、顺序、Entry Skill 或可见范围都需要创建新的 SuiteVersion。 + +```text +DRAFT -> PENDING_REVIEW -> PUBLISHED -> YANKED + \-> REJECTED -> DRAFT +``` + +- PRIVATE Suite 可以从 DRAFT 直接发布。 +- PUBLIC 和 NAMESPACE_ONLY Suite 需要一次 Suite 级审核。 +- Suite 没有可执行包,因此没有 SCANNING 或 SCAN_FAILED 状态。 +- 隐藏、归档、下架或删除 Suite 不会改变任何成员 Skill。 +- 成员失效后,已发布 SuiteVersion 保留历史快照并显示为不可安装,不会自动切换到成员最新版本。 + +## 成员与权限 + +一个 SuiteVersion 最多包含 100 个不同 Skill,并且必须明确选择其中一个普通成员作为 Entry Skill。 +Entry Skill 仍是完整、可独立安装的 Skill。跨 Namespace 的 PUBLIC Skill 可以作为 Entry;非 PUBLIC +成员仍必须满足下表中的同 Namespace 受众约束。 +v1 不支持嵌套 Suite、版本范围、外部 Registry 成员或条件成员。 + +Suite 的可见范围不能宽于成员: + +| Suite 可见性 | 允许的成员 | +| --- | --- | +| PUBLIC | 仅 PUBLIC Skill | +| NAMESPACE_ONLY | PUBLIC,或同 Namespace 的 NAMESPACE_ONLY Skill | +| PRIVATE | PUBLIC,或同 Namespace 的 NAMESPACE_ONLY/PRIVATE Skill | + +创建、提交、审核和安装时都会重新检查成员资格。成员被下架、隐藏、归档、收窄权限或硬删除后, +SuiteVersion 仍为 PUBLISHED,但安装计划会整体失败。硬删除只清空成员外键;坐标、版本和 fingerprint +快照继续用于历史展示和审计。 + +创作页面保存成员时会携带候选接口返回的精确 `skillVersionId`。服务端按 ID 读取版本,并校验坐标和 +版本一致后再保存快照,不会按名称重新解析到另一个所有者的同名 Skill。 + +## `suite.yaml` 定义 + +`suite.yaml` 是可移植的 Suite 创作格式,不是上传到 Agent 的多 Skill ZIP: + +```yaml +apiVersion: skillhub.iflytek.com/v1alpha1 +kind: SkillSuite +metadata: + namespace: global + slug: superpowers + version: 1.0.0 + displayName: Superpowers +spec: + visibility: PUBLIC + entrySkill: "@global/using-superpowers@1.0.0" + members: + - skill: "@global/using-superpowers" + version: 1.0.0 + - skill: "@global/brainstorming" + version: 2.1.0 +``` + +当前版本通过 Web 编辑器或 Suite API 创建同一份定义;CLI v1 负责安装生命周期,尚不读取或发布 +`suite.yaml`。保留该格式是为了后续增加 CLI 导入时不改变服务端领域模型。 + +## CLI 安装生命周期 + +```bash +skillhub suite install @global/superpowers --version 1.0.0 +skillhub suite check @global/superpowers +skillhub suite upgrade @global/superpowers --check +skillhub suite upgrade @global/superpowers +skillhub suite remove @global/superpowers +``` + +安装、升级和卸载先获取当前 Suite 的本地操作锁,避免两个 CLI 进程基于同一份旧 inventory 并发 +提交。安装随后解析精确计划并下载、校验全部成员,再按稳定顺序锁定目标目录并整体提交。提交中途 +失败时,CLI 恢复本次替换的目录并保持安装前 inventory。卸载只移除当前 Suite 的来源;直接安装、 +被其他 Suite 共享或已被本地修改的成员目录会保留。 + +CLI inventory 向后兼容旧记录。旧记录没有 `installedBy` 时按直接安装处理,不会在移除 Suite 时被 +误删。新 CLI 在 Server 未声明 `skill-suite-v1` 能力时会明确停止 Suite 命令,普通 Skill 命令不受影响。 + +CLI 获取安装计划时会发送独立的 `Idempotency-Key`,遇到网络错误或 502/503/504 时使用同一个 key +重试一次。Server 按登录用户隔离该 key;匿名请求使用经过哈希的请求来源、客户端标识和 Suite 坐标 +隔离,不保存原始身份字段。Server 为计划生成 `operationId`,在 24 小时窗口内避免重复记录 Suite +安装请求和审计。 +安装计划本身不预增成员下载数;每个成员仍由原有 Skill 下载接口按实际请求计数。 + +本地 `local` profile 可直接运行 `make suite-smoke`。验证 release Compose 时必须使用真实管理员会话: + +```bash +SMOKE_ADMIN_USERNAME=admin \ +SMOKE_ADMIN_PASSWORD='' \ +./scripts/suite-smoke-test.sh http://localhost:8080 +``` + +脚本不会输出密码,并在结束时删除其创建的临时 Suite 和 Skill。 + +## API 与发现 + +- Suite 管理与详情:`/api/v1/suites/**`、`/api/web/suites/**` +- 当前用户可管理的 Suite:`/api/v1/me/suites`、`/api/web/me/suites` +- 类型化资源发现:`/api/v1/resources`、`/api/web/resources` +- 原有 Skill 搜索接口继续只返回 Skill。 + +类型化发现结果通过 `resourceType=SKILL|SUITE` 区分同名资源。Suite 详情返回固定版本、按顺序排列 +的成员快照、Entry Skill、实时可安装状态和阻塞原因。普通 Skill 详情会列出当前用户可见、以该 Skill +作为 Entry 的最新已发布 SuiteVersion,并链接到完整 Suite;Skill 的独立安装能力保持不变。 + +## 部署顺序 + +数据库迁移会先把既有审核任务回填为 `SKILL_VERSION`,保留旧 Skill 专用列,并通过数据库触发器 +把旧版 Server 新写入的 Skill 审核同步补全为类型化 subject。官方单实例 +`compose.release.yml` 和本地开发 profile 已默认开启 Suite 审核写入,因为它们不会同时运行新旧 Server。 + +其他部署方式默认保持关闭。全新安装、单实例升级或停机升级可直接设置: + +```bash +SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=true +``` + +旧版与新版 Server 会同时运行的滚动升级,应在发布新版前保持: + +```bash +SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=false +``` + +用该配置完成所有 Server 实例升级;确认不再有旧版实例后,将其改为 `true` 并再次滚动重启。开关关闭 +期间,现有 Skill 审核保持可用,Suite 草稿和 PRIVATE 直发不受影响,PUBLIC 与 NAMESPACE_ONLY +Suite 的新审核提交会被拒绝。Server 启动时会记录明确告警,避免门禁被长期遗忘。 + +## 日志与审计 + +Suite 创建、编辑、提交、审核、发布、下架、隐藏、恢复、归档和删除都会写审计记录。业务日志仅记录 +Suite ID、SuiteVersion ID、actor ID 和 request ID 等定位字段,不记录成员内容、Token 或下载地址。 diff --git a/openspec/changes/add-skill-suites/design.md b/openspec/changes/add-skill-suites/design.md index e454324a..36bf24f3 100644 --- a/openspec/changes/add-skill-suites/design.md +++ b/openspec/changes/add-skill-suites/design.md @@ -12,7 +12,7 @@ SkillHub 的现有发布单元是一个根目录包含 `SKILL.md` 的 Skill 包 | Suite | Namespace 所有的、可版本化的 Skill 集合;它不是 Skill,也不是多 Skill ZIP。 | | SuiteVersion | Suite 在某一时刻不可变的成员快照和元数据。 | | Member | SuiteVersion 引用的一个精确、已发布 SkillVersion。 | -| Entry Skill | 可选的普通 Member,用于表达工作流入口;不通过名称推断。 | +| Entry Skill | 必填的普通 Member,用于表达工作流入口;仍是完整、可独立安装的 Skill,不通过名称推断。 | | Install plan | 服务端解析出的 SuiteVersion、成员精确版本、fingerprint 和下载信息。 | | Degraded Suite | 已发布 SuiteVersion 的至少一个成员当前不可下载;历史快照仍可查看,但不能完整安装。 | @@ -53,12 +53,12 @@ skill_suite created_by, created_at, updated_by, updated_at skill_suite_version - id, suite_id, version, status, visibility, changelog, entry_skill_version_id, + id, suite_id, version, status, visibility, changelog, published_at, yanked_at, yanked_by, yank_reason, created_by, created_at skill_suite_version_member - suite_version_id, skill_version_id(nullable), position, + suite_version_id, skill_version_id(nullable), position, entry, namespace_slug_snapshot, skill_slug_snapshot, skill_version_snapshot, fingerprint_snapshot ``` @@ -95,7 +95,7 @@ skillhub suite install @global/marketing ### 3. SuiteVersion 引用精确的已发布 SkillVersion -作者可以在 `suite.yaml` 中选择成员坐标和版本: +Suite 定义可稳定映射为以下 `suite.yaml` 交换格式: ```yaml apiVersion: skillhub.iflytek.com/v1alpha1 @@ -107,6 +107,9 @@ metadata: displayName: Superpowers spec: visibility: PUBLIC + overview: | + ## How to use this suite + Run the entry skill first, then use the remaining skills as needed. entrySkill: "@global/using-superpowers@1.0.0" members: - skill: "@global/using-superpowers" @@ -115,11 +118,13 @@ spec: version: 2.1.0 ``` -该文件是 API/CLI 的创作输入,不是下载到 Agent 的包。服务端在创建 SuiteVersion 时解析并保存精确 `skillVersionId` 和 fingerprint。一个 SuiteVersion 最多包含 100 个不同 Skill;同一 Skill 不允许重复出现。 +该文件不是下载到 Agent 的包。v1 通过 Web/API 提交等价字段;CLI 导入 `suite.yaml` 属于后续增量能力。候选接口返回精确 `skillVersionId`,创作请求原样携带该 ID;服务端按 ID 读取版本,并校验请求中的坐标和版本与该记录一致后保存 fingerprint。服务端不按坐标二次解析版本,避免同一 Namespace 和 slug 下的历史所有权冲突绑定到错误 Skill。一个 SuiteVersion 最多包含 100 个不同 Skill;同一 Skill 不允许重复出现。 + +SuiteVersion 同时保存短 `summary` 和可选的 Markdown `overview`。`summary` 用于搜索卡片和详情页首屏摘要;`overview` 用于说明成员组合方式、推荐顺序、输入输出和使用边界,并作为审核快照的一部分随 SuiteVersion 冻结。详情页将概述与成员列表分开呈现,成员列表批量解析实时展示名称和摘要,但仅向有权读取该 Skill 的当前查看者返回;安装与审计仍以快照坐标、精确版本和 fingerprint 为准,受限或硬删除成员只保留不可点击且不含实时元数据的历史快照。 成员发布新版本不会改变已有 SuiteVersion。采用新版本、添加、删除、重排成员或修改 Entry Skill 都必须创建新的 SuiteVersion。 -为了降低创作成本,Web/CLI 在添加 Member 时默认推荐该 Skill 当前可安装的最新版本,但保存时立即解析为精确 `skillVersionId`、version 和 fingerprint,并向作者展示实际固定的版本。作者可以显式选择其他仍处于 PUBLISHED 的历史版本。 +为了降低创作成本,Web 在添加 Member 时默认推荐该 Skill 当前可安装的最新版本,但保存时提交候选结果中的精确 `skillVersionId`、version 和坐标,服务端校验三者一致后固定 fingerprint,并向作者展示实际固定的版本。作者可以显式选择其他仍处于 PUBLISHED 的历史版本。 不得在已发布 SuiteVersion 中保存 `latest` 或在安装时重新解析最新版本。可以提供“更新成员版本”辅助操作,但该操作必须先展示版本差异,并创建或修改 DRAFT SuiteVersion;它不是后台自动升级。 @@ -174,6 +179,8 @@ Suite 容器复用 `ACTIVE/ARCHIVED` 和独立的 hidden 治理覆盖。Suite 这意味着 Suite 是成员 Skill 的“版本化清单”,不是它们的父生命周期。删除 Suite 不删除 Skill;更新 Skill 不更新 Suite;更新 Suite 也不重新发布 Skill。 +Suite 删除是容器级硬删除:与 Skill 硬删除保持一致,删除其版本、成员关系和 Suite 审核任务,避免审核中心保留无法解析的多态目标;`DELETE_SKILL_SUITE` 审计记录独立保留。该规则不影响 REJECTED Suite 在删除前保留并查询全部审核轮次。 + ### 5. 审核任务支持类型化目标 现有 `review_task` 只指向 SkillVersion。为了保留一个审核中心和一致权限规则,将其扩展为类型化审核目标: @@ -234,7 +241,9 @@ Suite 操作权限为: //SKILL.md ``` -如果 Suite 表达工作流,`entrySkillVersionId` 指向一个普通 Member。Entry Skill 可以与 Suite 同名,也可以不同名;关系只来自显式 ID,不由 slug 推断。 +每个 SuiteVersion 必须把一个普通 Member 标记为 Entry Skill。Entry Skill 保留完整 Skill 包及独立安装能力,可以与 Suite 同名,也可以不同名;关系只来自成员快照上的显式 `entry` 标记,不由 slug 推断。Entry 与普通 Member 使用相同的候选、可见性和生命周期规则:跨 Namespace 的 PUBLIC Skill 可以作为 Entry,非 PUBLIC Skill 仍受同 Namespace 受众约束。 + +Skill 详情只返回当前用户有权查看的、以该 Skill 为 Entry 的最新 PUBLISHED SuiteVersion。Web 将这些关系显示为“被套件用作入口”,并链接到完整 Suite;普通 Skill 的独立安装入口保持不变。Suite 被隐藏、归档或对当前用户不可见时,不返回其坐标或名称。 这避免腾讯 SkillSet 当前把编排提示写入普通 Skill 目录造成的覆盖问题,也保证所有 Agent 只需理解标准 Skill。 @@ -246,11 +255,11 @@ Suite 操作权限为: 2. 检查 Suite/成员权限、状态、目标目录、现有来源冲突和空间限制。 3. 下载全部成员到目标根目录内的临时目录。 4. 校验每个成员的 fingerprint 和 Skill 元数据。 -5. 按稳定顺序获取所有目标锁,备份将被替换的同源目录。 +5. 先获取当前 Suite 的本地操作锁,再按稳定顺序获取所有目标锁,备份将被替换的同源目录。 6. 移动全部成员并一次性写入 inventory。 7. 任一步失败时恢复所有备份并保持原 inventory。 -服务端只返回安装计划和成员下载能力,不尝试对用户文件系统提供分布式事务。CLI 在现有 staged install、target lock 和 rollback 机制上扩展为多成员计划。 +服务端只返回安装计划和成员下载能力,不尝试对用户文件系统提供分布式事务。CLI 在现有 staged install、target lock 和 rollback 机制上扩展为多成员计划;Suite 级操作锁串行化同一 registry、Suite 坐标和本地状态目录上的安装、升级与卸载,避免不同目标组合并发覆盖 inventory。 ### 9. inventory 记录来源集合而不是单一所有者 @@ -285,7 +294,9 @@ inventory schema 增加 `suites`,并让 Skill 安装目标记录来源集合 ### 10. 查询、升级和展示保持类型明确 -新增的类型化资源发现入口返回 `resourceType`,Web 使用类型徽标及独立 `/skills/...`、`/suites/...` 页面。现有 Skill 搜索接口继续只返回 Skill,避免旧 CLI 或第三方客户端把 Suite 响应按 Skill 反序列化。Suite 详情显示版本、精确成员、Entry Skill、可用状态和阻塞原因。 +新增的类型化资源发现入口返回 `resourceType`,Web 使用类型徽标及独立 `/skills/...`、`/suites/...` 页面。现有 Skill 搜索接口继续只返回 Skill,避免旧 CLI 或第三方客户端把 Suite 响应按 Skill 反序列化。Suite 详情显示版本、精确成员、Entry Skill、可用状态和阻塞原因;普通 Skill 详情显示当前可见 Suite 的 Entry 反向引用,但不把 Suite 混入 Skill 搜索结果。 + +Suite 详情响应同时返回服务端计算的管理能力,Web 不自行推断 Namespace 角色。作者可编辑草稿、显式重开被拒版本并基于已发布快照创建新版本;Namespace 管理员还可下架版本、隐藏、归档和删除 Suite。后端继续对每个命令独立鉴权,响应能力只用于正确展示入口,不作为安全边界。 `suite check` 比较 inventory 快照、磁盘 fingerprint 和远端 SuiteVersion;`suite upgrade` 先显示成员增删改计划,再使用与安装相同的原子流程应用新的精确 SuiteVersion。升级不会单独追随 Member 的最新版本。 @@ -311,7 +322,7 @@ Server 和 CLI 按以下组合兼容: SuiteVersion 被拒绝后允许由管理者退回 DRAFT,保留原审核记录并修改未发布版本后再次提交。PUBLISHED/YANKED SuiteVersion 永远不可编辑;这些版本的任何变化都创建新 SuiteVersion。每次重新提交创建新的审核轮次,不覆盖旧决定。 -一次 Suite 安装计划使用服务端生成的唯一 `operationId` 串联一条 Suite 安装计划审计和多条 Member 下载审计。服务端成功签发完整安装计划后,Suite 安装请求数增加一次;计划内每个 Member SkillVersion 按现有下载统计口径增加一次,并标记 `source=SUITE`。同一 `operationId` 的安全重试不得重复计数。 +CLI 为一次安装计划生成独立的 `Idempotency-Key`,网络重试复用该 key;服务端按调用者隔离 retry key:登录请求使用用户 ID,匿名请求使用经过哈希的请求来源、客户端标识和 Suite 坐标,不持久化原始组合值。服务端生成唯一 `operationId` 串联 Suite 安装计划及 Member 下载意图审计。成功签发完整计划后仅增加一次 Suite 安装请求数,不预增 Member 下载数。Member 继续由现有 Skill 下载接口按实际请求计数,避免计划签发和文件下载对同一 Member 重复计数。服务端保留 retry-key 映射 24 小时,并复用现有幂等清理任务删除过期映射,避免无界增长。 服务端无法可靠知道 CLI 最终是否完成本地文件提交,因此该指标表示“安装计划/下载已签发”,不宣称是本地安装成功数。CLI 后续校验或提交失败不反向扣减服务端计数;v1 不增加客户端完成回调或遥测上报。 @@ -323,13 +334,14 @@ SuiteVersion 被拒绝后允许由管理者退回 DRAFT,保留原审核记录 - **共享成员卸载可能误删直接安装内容** → inventory 保存多来源引用,本地修改和来源不明时 fail closed。 - **Skill/Suite 同 slug 可能让自然语言含糊** → CLI、API、URL、搜索结果和安装提示始终携带资源类型;旧 `install` 固定解析 Skill。 - **在现有 Skill 搜索中直接混入 Suite 会破坏旧客户端** → 保留 Skill-only 旧接口,另增类型化资源发现入口。 -- **审核目标和状态枚举扩展可能破坏滚动升级** → Suite 使用独立状态;审核表按兼容窗口增量迁移,并在混合版本验证后再收紧旧字段。 +- **审核目标和状态枚举扩展可能破坏滚动升级** → Suite 使用独立状态;审核表回填历史记录,并在兼容窗口用数据库触发器补全旧版 Server 写入的类型化字段,混合版本验证后再开放 Suite 审核。 - **不支持一份 ZIP 创建全部成员,首次迁移多 Skill 仓库仍需发布成员** → v1 优先保证领域和生命周期正确;以后可增加调用现有发布 API 的批量 CLI 编排,但不改变 Suite 模型。 ## Migration Plan 1. 新增 Suite 三张表、索引和类型化审核字段;回填现有审核任务为 `SKILL_VERSION`。 -2. 先发布兼容旧 API 的 Server;新表为空时现有行为不变。 +2. 先发布兼容旧 API 的 Server;新表为空时现有行为不变。单实例 Compose 和本地 profile 默认开放 + Suite 审核;未知或多实例部署保持 fail-closed,并在确认所有实例升级后显式开放。 3. 发布 Web 的 Suite 管理和类型化审核展示,重新生成 OpenAPI 类型。 4. 发布支持 Suite 和新版 inventory 的 CLI;读取旧 inventory 时将缺少的 `suites`、`installedBy` 视为空。 5. 用本地 exact-SHA 镜像验证 Skill 正常流、Suite 生命周期、成员失效和整组回滚。 diff --git a/openspec/changes/add-skill-suites/proposal.md b/openspec/changes/add-skill-suites/proposal.md index da2a30e7..567a520c 100644 --- a/openspec/changes/add-skill-suites/proposal.md +++ b/openspec/changes/add-skill-suites/proposal.md @@ -11,7 +11,7 @@ SkillHub 当前只能逐个发布和安装 Skill,无法把一组已经发布 - 新增 `skillhub suite install/check/upgrade/remove`,成员继续安装为标准 Agent Skill;Suite 本身不生成同名 `SKILL.md`。 - Suite 安装采用完整预检、全部暂存、fingerprint 校验和整体提交/回滚,避免部分安装。 - CLI inventory 记录 Suite 快照以及每个成员的直接安装和 Suite 来源,安全处理共享成员的卸载。 -- 新增人类可编辑的 `suite.yaml`,仅作为 Suite 定义和发布输入,不改变现有单 Skill ZIP 协议。 +- 定义人类可编辑的 `suite.yaml` 交换格式,为后续 CLI 导入预留稳定边界;v1 仍通过 Web/API 创作,不改变现有单 Skill ZIP 协议。 ## Decision Relative to Issue #715 diff --git a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md index 1ba0fce9..ea03ea8e 100644 --- a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md +++ b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md @@ -22,13 +22,18 @@ ### Requirement: SuiteVersion SHALL reference immutable published Skill versions -系统 SHALL 只允许 SuiteVersion 引用同一 Registry 中状态为 PUBLISHED 的精确 SkillVersion,并 SHALL 保存成员坐标、版本和 fingerprint 快照。一个 SuiteVersion SHALL 最多包含 100 个不同 Skill。 +系统 SHALL 只允许 SuiteVersion 引用同一 Registry 中状态为 PUBLISHED 的精确 SkillVersion。创作请求 SHALL 携带候选接口返回的 `skillVersionId`,服务端 SHALL 按该 ID 读取版本并校验随请求提交的坐标与版本一致,避免同名 Skill 被重新解析到其他所有者。系统 SHALL 保存成员坐标、版本和 fingerprint 快照。一个 SuiteVersion SHALL 最多包含 100 个不同 Skill。 #### Scenario: Create a valid SuiteVersion - **WHEN** 管理者提交不超过 100 个不同的已发布 SkillVersion - **THEN** 系统创建 DRAFT SuiteVersion - **AND** 每个 Member 保存精确 SkillVersion ID、坐标、版本、fingerprint 和顺序 +#### Scenario: Reject mismatched member identity +- **WHEN** 请求中的 `skillVersionId` 与同时提交的坐标或版本不一致 +- **THEN** 系统拒绝该 SuiteVersion 定义 +- **AND** 不按坐标重新解析到另一个同名 SkillVersion + #### Scenario: Add a Member without choosing a version - **WHEN** 作者添加一个 Skill 且没有显式选择版本 - **THEN** 系统向作者推荐当前可安装的最新 SkillVersion @@ -55,9 +60,9 @@ - **THEN** 系统拒绝该定义 - **AND** 不创建部分成员关系 -### Requirement: Entry Skill SHALL be an explicit optional Member +### Requirement: Entry Skill SHALL be one explicit ordinary Member -SuiteVersion MAY 指定一个 Entry Skill。指定时,Entry Skill SHALL 精确指向该 SuiteVersion 的一个 Member;系统 SHALL NOT 根据 Suite 和 Skill 的同名关系推断入口。 +SuiteVersion SHALL 指定且仅指定一个 Entry Skill。Entry Skill SHALL 精确指向该 SuiteVersion 的一个普通 Member,保留完整 Skill 包和独立安装能力;系统 SHALL NOT 根据 Suite 和 Skill 的同名关系推断入口,也 SHALL NOT 要求 Entry 与 Suite 属于同一 Namespace。 #### Scenario: Valid Entry Skill - **WHEN** SuiteVersion 将一个现有 Member 指定为 Entry Skill @@ -69,9 +74,13 @@ SuiteVersion MAY 指定一个 Entry Skill。指定时,Entry Skill SHALL 精确 - **THEN** 系统拒绝该 SuiteVersion #### Scenario: Suite has no Entry Skill -- **WHEN** Suite 仅表示安装集合 -- **THEN** 系统允许 Entry Skill 为空 -- **AND** 安装后不生成额外的编排 Skill +- **WHEN** 提交的 SuiteVersion 没有指定 Entry Skill +- **THEN** 系统拒绝该 SuiteVersion + +#### Scenario: Public cross-Namespace Entry Skill +- **WHEN** SuiteVersion 将其他 Namespace 中符合目标受众规则的 PUBLIC Member 指定为 Entry Skill +- **THEN** 系统允许该 Entry Skill +- **AND** 引用不改变该 Skill 的所有权或生命周期 ### Requirement: Member candidates SHALL be filtered by the Server @@ -264,6 +273,18 @@ CLI SHALL 在修改目标目录前完成全部成员和全部 Agent 目标的解 - **AND** 恢复备份和安装前 inventory - **AND** 无法完成的回滚必须保留备份路径并明确报告 +#### Scenario: Concurrent operations target the same local Suite +- **WHEN** 两个 CLI 进程并发安装、升级或卸载同一 registry 和 Suite 坐标 +- **THEN** CLI 通过 Suite 级本地锁只允许一个操作进入事务 +- **AND** 另一个操作明确报告繁忙,不得基于旧 inventory 提交 + +#### Scenario: Existing Member has local changes +- **WHEN** Suite 安装或升级将复用或替换一个已登记但 fingerprint 已变化的 Member 目录 +- **AND** 用户未明确传入 `--force` +- **THEN** CLI 在写入任何目标或 inventory 前拒绝该操作 +- **AND** 保留本地文件和现有 inventory +- **AND** 只有用户显式传入 `--force` 时才允许覆盖本地修改 + ### Requirement: Suite installation SHALL preserve Agent Skills compatibility CLI SHALL 将每个 Member 作为普通 Skill 安装到 Agent 已支持的 Skill 根目录。CLI SHALL NOT 为 Suite 创建同名 `SKILL.md` 或要求 Agent 理解 Suite 协议。 @@ -293,6 +314,11 @@ CLI inventory SHALL 记录已安装 SuiteVersion、精确成员快照,以及 - **THEN** CLI 将缺失的 Suite 和来源集合按空值处理 - **AND** 已安装 Skill 记录和目标路径保持不变 +#### Scenario: Same Suite coordinate is installed from different registries +- **WHEN** 两个 Registry 各自安装了相同 Namespace 和 slug 的 Suite +- **THEN** inventory 按 Registry 分别记录 Suite 与 Member 来源 +- **AND** 移除其中一个 Registry 的 Suite 不得修改另一个 Registry 的来源或文件 + ### Requirement: Suite removal SHALL be ownership-safe `skillhub suite remove` SHALL 仅移除该 Suite 的来源记录。CLI SHALL 只自动删除不再被直接安装、未被其他 Suite 引用且未被本地修改的 Member 目录。 @@ -360,6 +386,44 @@ Suite SHALL 有独立 API 和 Web URL。新的类型化资源发现结果中, - **WHEN** 授权用户通过 Suite 专用接口解析某个版本 - **THEN** 响应包含 SuiteVersion 身份以及有序的精确 Member 版本、fingerprint 和可下载状态 +#### Scenario: Skill detail shows visible Suite entry references +- **WHEN** 当前 Skill 是一个或多个最新 PUBLISHED SuiteVersion 的 Entry Skill +- **THEN** Skill 详情返回当前查看者有权读取的 Suite 摘要、精确版本和成员数量 +- **AND** Web 将其表达为“被套件用作入口”并链接到完整 Suite +- **AND** Skill 仍保留普通的独立安装入口 +- **AND** 系统不返回对当前查看者不可见、已隐藏或已归档的 Suite 信息 + +### Requirement: Suite authors SHALL have a complete Web management flow + +Web SHALL expose only the Suite actions authorized by the Server. An authorized author SHALL be +able to create and inspect a Suite, edit a DRAFT, reopen a REJECTED version before editing, and +create a new immutable version from a published snapshot. Namespace administrators SHALL additionally +be able to yank a published version, hide or restore discovery, archive or restore the Suite container, +and delete a Suite when no review is pending. These actions SHALL NOT modify Member Skills. + +#### Scenario: Author manages editable and immutable versions +- **WHEN** an authorized author opens a DRAFT, REJECTED, PUBLISHED, or YANKED SuiteVersion +- **THEN** Web shows only actions permitted for that actor and state +- **AND** REJECTED is explicitly reopened before editing +- **AND** PUBLISHED and YANKED versions remain immutable and changes create a new version + +#### Scenario: Administrator governs or deletes a Suite +- **WHEN** a Namespace administrator yanks, hides, restores, archives, unarchives, or deletes a Suite +- **THEN** Web requires confirmation for destructive container or publication actions +- **AND** a yank requires an audit reason +- **AND** deletion is unavailable while a review is pending +- **AND** hard deletion removes Suite-owned review tasks while retaining the deletion audit record +- **AND** no Member Skill lifecycle or content changes + +#### Scenario: Public reader views a manageable Suite +- **WHEN** an unauthenticated or unauthorized reader opens a public Suite detail page +- **THEN** Web shows the Suite summary and its author-provided Markdown overview as separate information levels +- **AND** Web shows every ordered Member as a Skill card with its exact pinned version, Entry Skill marker, and current availability +- **AND** an available Member exposes live display metadata and links to Skill detail only when the current viewer can read that Skill +- **AND** a viewer-restricted or deleted Member remains a non-navigable snapshot without exposing live display metadata +- **AND** Web does not display edit, version creation, governance, or deletion controls +- **AND** Server authorization remains the enforcement boundary + ### Requirement: Suite operations SHALL be authorized and audited Suite 创建、编辑、提交、审核、发布、下架、隐藏、恢复、归档和删除 SHALL 使用现有 Namespace 与平台角色原则,并 SHALL 产生包含 Suite 类型、Suite ID、SuiteVersion ID、操作者和变更摘要的审计记录。 @@ -385,6 +449,11 @@ Suite 创建、编辑、提交、审核、发布、下架、隐藏、恢复、 - **THEN** 用户可以查看 Suite 公开元数据 - **AND** 只有全部 Member 仍公开且可安装时才能获得完整安装计划 +#### Scenario: Anonymous user accesses a Suite in an archived Namespace +- **WHEN** Namespace 已归档且匿名用户访问其中的 PUBLISHED PUBLIC SuiteVersion +- **THEN** 系统拒绝查看和安装 +- **AND** Namespace 成员和平台管理员仍按现有归档 Namespace 规则访问 + #### Scenario: Namespace member accesses a namespace Suite - **WHEN** 当前 Namespace MEMBER 访问 PUBLISHED NAMESPACE_ONLY SuiteVersion - **THEN** 用户可以查看并在全部 Member 校验通过后安装 @@ -413,16 +482,27 @@ REJECTED SuiteVersion MAY 由有权限的管理者退回 DRAFT、修改并重新 - **THEN** 系统拒绝修改 - **AND** 提示创建新的 SuiteVersion -### Requirement: Suite download metrics SHALL remain attributable and idempotent +#### Scenario: A stale draft edit races with publication +- **WHEN** 一个请求读取 DRAFT 后,另一事务先将同一 SuiteVersion 发布 +- **AND** 旧请求随后尝试保存编辑结果 +- **THEN** 系统拒绝旧请求的并发更新 +- **AND** 已发布状态、发布时间和发布内容保持不变 -一次 Suite 安装计划 SHALL 使用服务端生成的唯一 operation ID 关联 Suite 请求与 Member 下载。服务端成功签发完整安装计划后,SHALL 记录一次 Suite 安装请求,并 SHALL 按现有下载口径为计划内每个 Member SkillVersion 记录一次来源为 SUITE 的下载。同一 operation ID 的重试 SHALL NOT 重复计数。该指标 SHALL 表示服务端计划/下载签发,不得标记为 CLI 本地安装成功。 +### Requirement: Suite plan and Member download metrics SHALL remain attributable and idempotent + +客户端 SHALL 为一次安装计划生成独立的 idempotency key,并在安全重试时复用;服务端 SHALL 按调用者隔离该 key,并生成 operation ID 关联该计划的审计记录。服务端成功签发完整计划后 SHALL 记录一次 Suite 安装请求,但 SHALL NOT 在此时预增 Member 下载数。每个 Member 继续通过现有 Skill 下载接口按实际下载请求计数,避免计划签发与文件下载对同一 Member 重复计数。这些指标表示服务端计划签发和实际下载请求,不表示 CLI 本地安装成功。 #### Scenario: Issue a complete Suite install plan - **WHEN** 服务端完成 Suite 和全部 Member 的权限、状态及可下载性预检并签发完整安装计划 - **THEN** Suite 安装请求数增加一次 -- **AND** 每个计划内 Member SkillVersion 下载数按现有口径增加一次并记录 Suite 来源 +- **AND** 此时不增加 Member SkillVersion 下载数 - **AND** 相关审计记录共享同一个 operation ID +#### Scenario: Download an exact Member from the issued plan +- **WHEN** CLI 使用安装计划中的下载地址请求某个精确 Member SkillVersion +- **THEN** 现有 Skill 下载接口按原有口径记录一次该 Member 的下载 +- **AND** 同一 Member 不因此前签发安装计划而重复计数 + #### Scenario: Suite plan preflight fails - **WHEN** 服务端因权限、状态或成员不可用而无法签发完整安装计划 - **THEN** 不增加 Suite 安装请求数 @@ -430,13 +510,20 @@ REJECTED SuiteVersion MAY 由有权限的管理者退回 DRAFT、修改并重新 #### Scenario: Local installation fails after plan issuance - **WHEN** CLI 在服务端签发计划后因下载、校验或文件提交失败并回滚 -- **THEN** 服务端已记录的计划和下载计数保持不变 +- **THEN** 服务端已记录的计划计数保持不变 +- **AND** 仅实际发出的 Member 下载请求按现有口径保留计数 - **AND** 系统不将这些计数描述为本地安装成功数 -#### Scenario: Retry an already recorded operation -- **WHEN** 客户端使用相同 operation ID 安全重试已经记录成功的安装 -- **THEN** 系统返回已有计划或幂等成功 -- **AND** Suite 和 Member 统计不重复增加 +#### Scenario: Retry an already issued plan +- **WHEN** 客户端因网络或响应读取失败,使用相同 idempotency key 重试安装计划请求 +- **THEN** 服务端返回同一 operation ID 对应的计划或幂等成功 +- **AND** Suite 安装请求数和审计记录不重复增加 +- **AND** 该重试保证至少覆盖服务端约定的 24 小时幂等窗口 + +#### Scenario: Anonymous callers reuse the same client key +- **WHEN** 两个匿名调用者对 Suite 安装计划使用相同的 idempotency key +- **THEN** 服务端使用经过哈希的调用者上下文和 Suite 坐标隔离幂等记录 +- **AND** 不在幂等 actor key 中保存原始 IP 或 User-Agent ### Requirement: Existing Skill workflows SHALL remain compatible @@ -490,7 +577,13 @@ Suite 能力 SHALL 以增量方式提供。旧 CLI 使用新 Server 时 SHALL - **THEN** 新版本应用仍按原 SkillVersion 读取和处理该任务 - **AND** 其审核决定、权限和审计语义保持不变 +#### Scenario: Enable Suite review in a non-overlapping deployment +- **WHEN** 用户通过官方单实例 Compose 或本地 profile 运行 Server +- **THEN** Suite 审核写入默认可用 +- **AND** 用户无需修改环境变量或数据库才能提交 Suite 审核 + #### Scenario: Mixed application versions during rollout - **WHEN** 部署期间同时存在支持和不支持 Suite subject 的应用实例 - **THEN** 现有 Skill 审核流程保持可用 +- **AND** 数据库为旧版实例写入的 Skill 审核补全类型化 subject - **AND** Suite 审核写入只在所有处理实例均支持类型化 subject 后启用 diff --git a/openspec/changes/add-skill-suites/tasks.md b/openspec/changes/add-skill-suites/tasks.md index 80f16154..acb6629a 100644 --- a/openspec/changes/add-skill-suites/tasks.md +++ b/openspec/changes/add-skill-suites/tasks.md @@ -1,49 +1,53 @@ ## 1. Persistence and domain model -- [ ] 1.1 Add Flyway migrations for `skill_suite`, `skill_suite_version`, and `skill_suite_version_member`, including per-type slug uniqueness, version uniqueness, version-level visibility, ordering, snapshot fields, indexes, and `ON DELETE SET NULL` member references. -- [ ] 1.2 Implement Suite aggregate entities, statuses, repositories, package boundaries, and domain invariants for 100-member limits, exact versions, duplicate detection, and Entry Skill membership. -- [ ] 1.3 Add focused repository and domain tests for same-slug Skill/Suite coexistence, immutable published versions, hard-deleted member snapshots, and latest-version recalculation. -- [ ] 1.4 Implement computed Suite availability and blocking reasons without adding degraded to the persisted SuiteVersion lifecycle enum. +- [x] 1.1 Add Flyway migrations for `skill_suite`, `skill_suite_version`, and `skill_suite_version_member`, including per-type slug uniqueness, version uniqueness, version-level visibility, ordering, snapshot fields, indexes, and `ON DELETE SET NULL` member references. +- [x] 1.2 Implement Suite aggregate entities, statuses, repositories, package boundaries, and domain invariants for 100-member limits, exact versions, duplicate detection, and Entry Skill membership. +- [x] 1.3 Add focused repository and domain tests for same-slug Skill/Suite coexistence, immutable published versions, hard-deleted member snapshots, and latest-version recalculation. +- [x] 1.4 Implement computed Suite availability and blocking reasons without adding degraded to the persisted SuiteVersion lifecycle enum. ## 2. Lifecycle, authorization, and review -- [ ] 2.1 Implement Suite draft, submit, approve, reject, direct-private-publish, yank, hide, restore, archive, and delete workflows without Member lifecycle side effects. -- [ ] 2.2 Generalize review tasks to typed subjects, backfill existing rows as `SKILL_VERSION`, and preserve all existing Skill review behavior and queries. -- [ ] 2.3 Reuse Namespace/platform authorization rules and add Suite-specific audit events for every material lifecycle action. -- [ ] 2.4 Add tests covering roles, self-review rules, member eligibility revalidation at submit/approve/publish, visibility compatibility, namespace freeze/archive, and non-cascading governance. -- [ ] 2.5 Add a rollout compatibility gate so Suite review writes are enabled only after all active application versions support typed review subjects. -- [ ] 2.6 Implement rejected-to-draft resubmission with immutable review rounds, and prevent published/yanked version edits. +- [x] 2.1 Implement Suite draft, submit, approve, reject, direct-private-publish, yank, hide, restore, archive, and delete workflows without Member lifecycle side effects. +- [x] 2.2 Generalize review tasks to typed subjects, backfill existing rows as `SKILL_VERSION`, and preserve all existing Skill review behavior and queries. +- [x] 2.3 Reuse Namespace/platform authorization rules and add Suite-specific audit events for every material lifecycle action. +- [x] 2.4 Add tests covering roles, self-review rules, member eligibility revalidation at submit/approve/publish, visibility compatibility, namespace freeze/archive, and non-cascading governance. +- [x] 2.5 Add a rollout compatibility gate so Suite review writes are enabled only after all active application versions support typed review subjects. +- [x] 2.6 Implement rejected-to-draft resubmission with immutable review rounds, and prevent published/yanked version edits. ## 3. Server API and search -- [ ] 3.1 Add transport-only Suite controllers and application services for management, version history, review actions, detail, and typed resolution of install plans. -- [ ] 3.2 Add a typed resource discovery projection with `resourceType` and Suite metadata while keeping the existing Skill search endpoint Skill-only. -- [ ] 3.3 Return ordered Member snapshots, Entry Skill, availability, and degraded reasons without N+1 member resolution. -- [ ] 3.4 Regenerate `web/src/api/generated/schema.d.ts` with `make generate-api` and run the OpenAPI drift check. -- [ ] 3.5 Record idempotent Suite-plan and Member-download audit/statistics using a shared operation ID, preserving the existing server-side download-count semantics. -- [ ] 3.6 Add a server-filtered Member candidate query scoped by caller access, Suite Namespace, target visibility, current installability, and exact versions. +- [x] 3.1 Add transport-only Suite controllers and application services for management, version history, review actions, detail, and typed resolution of install plans. +- [x] 3.2 Add a typed resource discovery projection with `resourceType` and Suite metadata while keeping the existing Skill search endpoint Skill-only. +- [x] 3.3 Return ordered Member snapshots, mandatory Entry Skill, availability, and degraded reasons without N+1 member resolution. +- [x] 3.4 Regenerate `web/src/api/generated/schema.d.ts` with `make generate-api` and run the OpenAPI drift check. +- [x] 3.5 Record idempotent Suite-plan statistics with a client retry key and server operation ID; keep Member counters on actual existing download requests. +- [x] 3.6 Add a server-filtered Member candidate query scoped by caller access, Suite Namespace, target visibility, current installability, and exact versions. ## 4. Web experience -- [ ] 4.1 Add typed Skill/Suite search cards and independent Suite list/detail/version routes. -- [ ] 4.2 Add Suite creation and draft editing with the server-filtered Member picker, exact published versions, ordering, visibility, and optional Entry Skill. -- [ ] 4.3 Extend the review center with typed Suite review details and ensure existing Skill review actions remain unchanged. -- [ ] 4.4 Add install instructions using `skillhub suite install`, degraded-member explanations, and responsive/error/loading/empty states. -- [ ] 4.5 Default Member selection to the current installable version, display the pinned exact version, and provide an explicit version-diff update action for drafts. +- [x] 4.1 Add typed Skill/Suite search cards and independent Suite list/detail/version routes. +- [x] 4.2 Add Suite creation and draft editing with the server-filtered Member picker, exact published versions, ordering, visibility, and mandatory Entry Skill. +- [x] 4.3 Extend the review center with typed Suite review details and ensure existing Skill review actions remain unchanged. +- [x] 4.4 Add install instructions using `skillhub suite install`, degraded-member explanations, and responsive/error/loading/empty states. +- [x] 4.5 Default Member selection to the current installable version, display the pinned exact version, and provide an explicit version-diff update action for drafts. +- [x] 4.6 Complete Web management for new versions, rejected-version reopen, yank, hide/restore, archive/unarchive, and guarded deletion using Server-derived capabilities. +- [x] 4.7 Add a versioned Markdown overview and browsable Member Skill cards with display metadata, pinned versions, Entry markers, and tombstone handling. +- [x] 4.8 Show privacy-filtered current Suite references on an Entry Skill detail page while preserving standalone Skill installation. ## 5. CLI and local lifecycle -- [ ] 5.1 Add `skillhub suite install/check/upgrade/remove` and a typed Suite resolver without changing `skillhub install` resolution. -- [ ] 5.2 Extend the existing staged installer to preflight, download, fingerprint-check, lock, commit, and roll back all Members and selected Agent targets as one operation. -- [ ] 5.3 Extend inventory with backward-compatible Suite snapshots and multi-source `installedBy` provenance. -- [ ] 5.4 Implement safe Suite removal that preserves direct-installed, shared, unknown-source, or locally modified Member directories. -- [ ] 5.5 Add CLI tests for same-slug Skill/Suite, missing permissions, unavailable members, checksum failure, disk/rename failure, incomplete rollback reporting, shared members, legacy inventory, and multi-Agent targets. +- [x] 5.1 Add `skillhub suite install/check/upgrade/remove` and a typed Suite resolver without changing `skillhub install` resolution. +- [x] 5.2 Extend the existing staged installer to preflight, download, fingerprint-check, lock, commit, and roll back all Members and selected Agent targets as one operation. +- [x] 5.3 Extend inventory with backward-compatible Suite snapshots and multi-source `installedBy` provenance. +- [x] 5.4 Implement safe Suite removal that preserves direct-installed, shared, unknown-source, or locally modified Member directories. +- [x] 5.5 Add CLI tests for same-slug Skill/Suite, missing permissions, unavailable members, checksum failure, disk/rename failure, incomplete rollback reporting, shared members, legacy inventory, and multi-Agent targets. - [ ] 5.6 Add Server capability detection and old-Server/new-CLI plus new-Server/old-CLI compatibility tests. ## 6. Documentation and validation -- [ ] 6.1 Document `suite.yaml`, Suite/Skill terminology, typed coordinates, lifecycle boundaries, CLI commands, compatibility, and operator limits. -- [ ] 6.2 Run targeted backend tests, `make test-backend-app`, frontend unit/type/lint checks, CLI tests/build, and OpenAPI drift validation. -- [ ] 6.3 Build exact-SHA local Server/Web images and run authenticated Compose smoke tests for ordinary Skill and Suite flows. -- [ ] 6.4 Execute the OpenSpec scenario matrix, including normal flow, same-slug compatibility, lifecycle independence, degraded members, authorization, atomic failure/recovery, upgrade, removal, rolling review migration, legacy inventory, and old/new Server/CLI combinations. -- [ ] 6.5 Complete independent implementation review, manual Web/CLI retest instructions, privacy/readiness checks, and the Chinese merge-readiness report before requesting merge authorization. +- [x] 6.1 Document the reserved `suite.yaml` interchange format, Suite/Skill terminology, typed coordinates, lifecycle boundaries, CLI commands, compatibility, and operator limits. +- [x] 6.2 Run targeted backend tests, `make test-backend-app`, frontend unit/type/lint checks, CLI tests/build, and OpenAPI drift validation. +- [x] 6.3 Build exact-SHA local Server/Web images and run authenticated Compose smoke tests for ordinary Skill and Suite flows. +- [x] 6.4 Execute the Server/Web OpenSpec scenario matrix, including normal flow, same-slug compatibility, lifecycle independence, degraded members, authorization, review-history retention, transactional rollback, rolling review migration, and non-cascading deletion. +- [ ] 6.5 Execute the deferred CLI scenario matrix, including atomic failure/recovery, upgrade, removal, legacy inventory, and old/new Server/CLI combinations. +- [ ] 6.6 Complete independent implementation review, manual Web retest instructions, privacy/readiness checks, and the Chinese merge-readiness report before requesting merge authorization. Additional CLI compatibility work remains deferred under 5.6 and 6.5. diff --git a/scripts/suite-smoke-test.sh b/scripts/suite-smoke-test.sh new file mode 100755 index 00000000..aebd8d1f --- /dev/null +++ b/scripts/suite-smoke-test.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +COOKIE_FILE="$(mktemp)" +WORK_DIR="$(mktemp -d)" +TOKEN="$(date +%s)${RANDOM}" +SKILL_NAME="suite-smoke-member-${TOKEN}" +SUITE_SLUG="suite-smoke-${TOKEN}" +SKILL_ID="" +SKILL_VERSION_ID="" +SUITE_ID="" +SMOKE_ADMIN_USERNAME="${SMOKE_ADMIN_USERNAME:-}" +SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-}" +AUTH_HEADERS=() + +json_field() { + JSON_INPUT="$1" python3 - "$2" <<'PY' +import json +import os +import sys + +value = json.loads(os.environ["JSON_INPUT"]) +for part in sys.argv[1].split("."): + value = value[int(part)] if part.isdigit() else value[part] +print(json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value) +PY +} + +assert_code() { + local description="$1" + local body="$2" + local expected="$3" + local actual + actual="$(json_field "$body" code)" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $description (expected code $expected, got $actual)" + exit 1 + fi + echo "PASS: $description" +} + +assert_suite_availability() { + local description="$1" + local expected_available="$2" + local expected_reason="${3:-}" + local response + response="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \ + "${AUTH_HEADERS[@]}" \ + "$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.0.0")" + assert_code "$description" "$response" 0 + JSON_INPUT="$response" EXPECTED_AVAILABLE="$expected_available" EXPECTED_REASON="$expected_reason" \ + python3 - <<'PY' +import json +import os + +data = json.loads(os.environ["JSON_INPUT"])["data"] +expected_available = os.environ["EXPECTED_AVAILABLE"] == "true" +expected_reason = os.environ["EXPECTED_REASON"] or None +reasons = {member.get("blockingReason") for member in data["members"]} +if data["available"] is not expected_available: + raise SystemExit(1) +if expected_reason is not None and expected_reason not in reasons: + raise SystemExit(1) +PY + echo "PASS: $description has the expected availability" +} + +assert_install_plan_rejected() { + local description="$1" + local key="$2" + local status + status="$(curl -sS -o "$WORK_DIR/blocked-plan.json" -w '%{http_code}' \ + -b "$COOKIE_FILE" -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $CSRF_TOKEN" -H "Idempotency-Key: $key" -X POST \ + "$BASE_URL/api/web/suites/global/$SUITE_SLUG/install-plan?version=1.0.0")" + if [[ "$status" != "400" ]]; then + echo "FAIL: $description should return HTTP 400, got $status" + exit 1 + fi + echo "PASS: $description" +} + +assert_install_plan_available() { + local description="$1" + local key="$2" + local response + response="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \ + "${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Idempotency-Key: $key" -X POST \ + "$BASE_URL/api/web/suites/global/$SUITE_SLUG/install-plan?version=1.0.0")" + assert_code "$description" "$response" 0 +} + +cleanup() { + if [[ -n "$SUITE_ID" && -n "${CSRF_TOKEN:-}" ]]; then + curl -sS -o /dev/null -b "$COOKIE_FILE" -c "$COOKIE_FILE" \ + "${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -X DELETE "$BASE_URL/api/web/suites/$SUITE_ID" || true + fi + if [[ -n "$SKILL_ID" && -n "${CSRF_TOKEN:-}" ]]; then + curl -sS -o /dev/null -b "$COOKIE_FILE" -c "$COOKIE_FILE" \ + "${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -X DELETE "$BASE_URL/api/v1/skills/id/$SKILL_ID" || true + fi + rm -f "$COOKIE_FILE" + rm -rf "$WORK_DIR" +} + +trap cleanup EXIT + +echo "=== Skill Suite Smoke Test ===" +echo "Target: $BASE_URL" +echo "Suite: @global/$SUITE_SLUG" + +if [[ -n "$SMOKE_ADMIN_USERNAME" || -n "$SMOKE_ADMIN_PASSWORD" ]]; then + if [[ -z "$SMOKE_ADMIN_USERNAME" || -z "$SMOKE_ADMIN_PASSWORD" ]]; then + echo "FAIL: SMOKE_ADMIN_USERNAME and SMOKE_ADMIN_PASSWORD must be set together" + exit 1 + fi + curl -sS -c "$COOKIE_FILE" "$BASE_URL/api/v1/auth/me" >/dev/null +else + AUTH_HEADERS=(-H "X-Mock-User-Id: local-admin") + curl -sS -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \ + "$BASE_URL/api/v1/auth/providers" >/dev/null +fi +CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_FILE" | tail -n 1)" +if [[ -z "$CSRF_TOKEN" ]]; then + echo "FAIL: could not bootstrap CSRF token" + exit 1 +fi + +if [[ -n "$SMOKE_ADMIN_USERNAME" ]]; then + LOGIN_PAYLOAD="$(SMOKE_ADMIN_USERNAME="$SMOKE_ADMIN_USERNAME" SMOKE_ADMIN_PASSWORD="$SMOKE_ADMIN_PASSWORD" \ + python3 - <<'PY' +import json +import os + +print(json.dumps({ + "username": os.environ["SMOKE_ADMIN_USERNAME"], + "password": os.environ["SMOKE_ADMIN_PASSWORD"], +})) +PY +)" + LOGIN_STATUS="$(curl -sS -o "$WORK_DIR/login.json" -w '%{http_code}' \ + -b "$COOKIE_FILE" -c "$COOKIE_FILE" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \ + -H "Content-Type: application/json" -X POST \ + "$BASE_URL/api/v1/auth/local/login" -d "$LOGIN_PAYLOAD")" + if [[ "$LOGIN_STATUS" != "200" ]]; then + echo "FAIL: local administrator login returned HTTP $LOGIN_STATUS" + exit 1 + fi + CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_FILE" | tail -n 1)" + echo "PASS: authenticated with the local administrator account" +else + echo "PASS: authenticated with the local mock administrator" +fi + +cat > "$WORK_DIR/SKILL.md" <