Files

1838 lines
82 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# Installer for dws (DingTalk Workspace CLI) on Windows.
# Downloads the pre-built binary from GitHub Releases and installs agent skills.
# No Go, Node.js, or other dependencies required.
#
# Usage (from an existing PowerShell session):
# irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
#
# If you are launching from Win+R or cmd.exe and want the window to stay open:
# powershell -NoExit -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex"
#
# Environment variables (all optional):
# DWS_INSTALL_DIR — where to put the binary (default: ~/.local/bin)
# DWS_VERSION — version to install (default: latest)
# DWS_ARCH — architecture override (amd64 or arm64)
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else multi)
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
# Gitee API instead of GitHub (China mirror)
#
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
$ErrorActionPreference = "Stop"
$Repo = "DingTalk-Real-AI/dingtalk-workspace-cli"
$BinName = "dws"
# GitHub "latest release" URL; Resolve-LatestVersion follows its redirect to get the tag.
$LatestUrl = "https://github.com/$Repo/releases/latest"
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via the Gitee API.
$GiteeRepo = if ($env:DWS_GITEE_REPO) { $env:DWS_GITEE_REPO } else { "" }
# Auto-fallback Gitee mirror used when GitHub is unreachable (see Resolve-Source).
$GiteeFallbackRepo = if ($env:DWS_GITEE_FALLBACK_REPO) { $env:DWS_GITEE_FALLBACK_REPO } else { "DingTalk-Real-AI/dingtalk-workspace-cli" }
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$Version = if ($env:DWS_VERSION) { $env:DWS_VERSION } else { "latest" }
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
$SkillsOnly = $env:DWS_SKILLS_ONLY -eq "1"
$SkillName = "dws"
$SkillMode = ""
$SkillStateRoot = if ($env:DWS_CONFIG_DIR) { $env:DWS_CONFIG_DIR } else { Join-Path $HOME ".dws" }
$ManagedSkillDigestScope = "skill-directory-v1"
$LegacyOfficialMultiSkills = @(
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
"dingtalk-todo", "dingtalk-wiki", "dws-shared"
)
# Agent registry synchronized with vercel-labs/skills agents.ts (c6f69c6).
# Universal agents read the canonical store directly; their separate historical
# global directories are cleanup-only. Non-universal agents get junctions to
# canonical (or complete copies when junction creation is unavailable).
$AgentRegistry = @(
[pscustomobject]@{ Id = "aider-desk"; Universal = $false; Dir = ".aider-desk\skills" },
[pscustomobject]@{ Id = "amp"; Universal = $true; Dir = ".config\agents\skills" },
[pscustomobject]@{ Id = "antigravity"; Universal = $true; Dir = ".gemini\antigravity\skills" },
[pscustomobject]@{ Id = "antigravity-cli"; Universal = $true; Dir = ".gemini\antigravity-cli\skills" },
[pscustomobject]@{ Id = "astrbot"; Universal = $false; Dir = ".astrbot\data\skills" },
[pscustomobject]@{ Id = "autohand-code"; Universal = $false; Dir = ".autohand\skills" },
[pscustomobject]@{ Id = "augment"; Universal = $false; Dir = ".augment\skills" },
[pscustomobject]@{ Id = "bob"; Universal = $false; Dir = ".bob\skills" },
[pscustomobject]@{ Id = "claude-code"; Universal = $false; Dir = ".claude\skills" },
[pscustomobject]@{ Id = "openclaw"; Universal = $false; Dir = ".openclaw\skills" },
[pscustomobject]@{ Id = "cline"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "codearts-agent"; Universal = $false; Dir = ".codeartsdoer\skills" },
[pscustomobject]@{ Id = "codebuddy"; Universal = $false; Dir = ".codebuddy\skills" },
[pscustomobject]@{ Id = "codemaker"; Universal = $false; Dir = ".codemaker\skills" },
[pscustomobject]@{ Id = "codestudio"; Universal = $false; Dir = ".codestudio\skills" },
[pscustomobject]@{ Id = "codex"; Universal = $true; Dir = ".codex\skills" },
[pscustomobject]@{ Id = "command-code"; Universal = $false; Dir = ".commandcode\skills" },
[pscustomobject]@{ Id = "continue"; Universal = $false; Dir = ".continue\skills" },
[pscustomobject]@{ Id = "cortex"; Universal = $false; Dir = ".snowflake\cortex\skills" },
[pscustomobject]@{ Id = "crush"; Universal = $false; Dir = ".config\crush\skills" },
[pscustomobject]@{ Id = "cursor"; Universal = $true; Dir = ".cursor\skills" },
[pscustomobject]@{ Id = "deepagents"; Universal = $true; Dir = ".deepagents\agent\skills" },
[pscustomobject]@{ Id = "devin"; Universal = $false; Dir = ".config\devin\skills" },
[pscustomobject]@{ Id = "dexto"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "droid"; Universal = $false; Dir = ".factory\skills" },
[pscustomobject]@{ Id = "eve"; Universal = $false; Dir = $null },
[pscustomobject]@{ Id = "firebender"; Universal = $true; Dir = ".firebender\skills" },
[pscustomobject]@{ Id = "forgecode"; Universal = $false; Dir = ".forge\skills" },
[pscustomobject]@{ Id = "gemini-cli"; Universal = $true; Dir = ".gemini\skills" },
[pscustomobject]@{ Id = "github-copilot"; Universal = $true; Dir = ".copilot\skills" },
[pscustomobject]@{ Id = "goose"; Universal = $false; Dir = ".config\goose\skills" },
[pscustomobject]@{ Id = "grok"; Universal = $false; Dir = ".grok\skills" },
[pscustomobject]@{ Id = "hermes-agent"; Universal = $false; Dir = ".hermes\skills" },
[pscustomobject]@{ Id = "inference-sh"; Universal = $false; Dir = ".inferencesh\skills" },
[pscustomobject]@{ Id = "jazz"; Universal = $false; Dir = ".jazz\skills" },
[pscustomobject]@{ Id = "junie"; Universal = $false; Dir = ".junie\skills" },
[pscustomobject]@{ Id = "iflow-cli"; Universal = $false; Dir = ".iflow\skills" },
[pscustomobject]@{ Id = "kilo"; Universal = $false; Dir = ".kilocode\skills" },
[pscustomobject]@{ Id = "kimchi"; Universal = $false; Dir = ".config\kimchi\harness\skills" },
[pscustomobject]@{ Id = "kimi-code-cli"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "kiro-cli"; Universal = $false; Dir = ".kiro\skills" },
[pscustomobject]@{ Id = "kode"; Universal = $false; Dir = ".kode\skills" },
[pscustomobject]@{ Id = "lingma"; Universal = $false; Dir = ".lingma\skills" },
[pscustomobject]@{ Id = "loaf"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "mcpjam"; Universal = $false; Dir = ".mcpjam\skills" },
[pscustomobject]@{ Id = "minimax-code"; Universal = $false; Dir = ".minimax\skills" },
[pscustomobject]@{ Id = "mistral-vibe"; Universal = $false; Dir = ".vibe\skills" },
[pscustomobject]@{ Id = "moxby"; Universal = $false; Dir = ".moxby\skills" },
[pscustomobject]@{ Id = "mux"; Universal = $false; Dir = ".mux\skills" },
[pscustomobject]@{ Id = "opencode"; Universal = $true; Dir = ".config\opencode\skills" },
[pscustomobject]@{ Id = "openhands"; Universal = $false; Dir = ".openhands\skills" },
[pscustomobject]@{ Id = "ona"; Universal = $false; Dir = ".ona\skills" },
[pscustomobject]@{ Id = "pi"; Universal = $false; Dir = ".pi\agent\skills" },
[pscustomobject]@{ Id = "qoder"; Universal = $false; Dir = ".qoder\skills" },
[pscustomobject]@{ Id = "qoder-cn"; Universal = $false; Dir = ".qoder-cn\skills" },
[pscustomobject]@{ Id = "qwen-code"; Universal = $false; Dir = ".qwen\skills" },
[pscustomobject]@{ Id = "replit"; Universal = $true; Dir = ".config\agents\skills" },
[pscustomobject]@{ Id = "reasonix"; Universal = $false; Dir = ".reasonix\skills" },
[pscustomobject]@{ Id = "rovodev"; Universal = $false; Dir = ".rovodev\skills" },
[pscustomobject]@{ Id = "roo"; Universal = $false; Dir = ".roo\skills" },
[pscustomobject]@{ Id = "tabnine-cli"; Universal = $false; Dir = ".tabnine\agent\skills" },
[pscustomobject]@{ Id = "terramind"; Universal = $false; Dir = ".terramind\skills" },
[pscustomobject]@{ Id = "tinycloud"; Universal = $false; Dir = ".tinycloud\skills" },
[pscustomobject]@{ Id = "trae"; Universal = $false; Dir = ".trae\skills" },
[pscustomobject]@{ Id = "trae-cn"; Universal = $false; Dir = ".trae-cn\skills" },
[pscustomobject]@{ Id = "warp"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "windsurf"; Universal = $false; Dir = ".codeium\windsurf\skills" },
[pscustomobject]@{ Id = "zed"; Universal = $true; Dir = ".agents\skills" },
[pscustomobject]@{ Id = "zcode"; Universal = $false; Dir = ".zcode\skills" },
[pscustomobject]@{ Id = "zencoder"; Universal = $false; Dir = ".zencoder\skills" },
[pscustomobject]@{ Id = "zenflow"; Universal = $false; Dir = ".zencoder\skills" },
[pscustomobject]@{ Id = "neovate"; Universal = $false; Dir = ".neovate\skills" },
[pscustomobject]@{ Id = "pochi"; Universal = $false; Dir = ".pochi\skills" },
[pscustomobject]@{ Id = "promptscript"; Universal = $true; Dir = $null },
[pscustomobject]@{ Id = "adal"; Universal = $false; Dir = ".adal\skills" },
[pscustomobject]@{ Id = "universal"; Universal = $true; Dir = ".config\agents\skills" }
)
# DWS compatibility targets that are not part of the upstream registry.
# Qoderwork remains a non-universal install target; the other entries are
# migration cleanup targets only and intentionally do not count as agents.
$LegacyAgentCleanupTargets = @(
[pscustomobject]@{ Id = "dws-qoderwork"; Universal = $false; Dir = ".qoderwork\skills" },
[pscustomobject]@{ Id = "dws-legacy-github"; Universal = $true; Dir = ".github\skills" },
[pscustomobject]@{ Id = "dws-legacy-amp"; Universal = $true; Dir = ".amp\skills" },
[pscustomobject]@{ Id = "dws-legacy-cline"; Universal = $true; Dir = ".cline\skills" },
[pscustomobject]@{ Id = "dws-legacy-windsurf"; Universal = $true; Dir = ".windsurf\skills" }
)
# Kept as a compatibility surface for policy tests and downstream packagers.
$AgentDirs = @($AgentRegistry | Where-Object { $null -ne $_.Dir } | ForEach-Object { $_.Dir })
# ── Helpers ──────────────────────────────────────────────────────────────────
function Write-Say {
param([string]$Message)
Write-Host " $Message"
}
function Write-Err {
param([string]$Message)
Write-Host " ❌ $Message" -ForegroundColor Red
exit 1
}
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
function Test-ManagedMultiSkillDir {
param([string]$Dir)
$name = Split-Path $Dir -Leaf
if ($LegacyOfficialMultiSkills -contains $name) { return $true }
$statePath = Join-Path $SkillStateRoot "skills-state.json"
if (!(Test-Path $statePath -PathType Leaf)) { return $false }
try {
$state = Get-Content -Path $statePath -Raw | ConvertFrom-Json -ErrorAction Stop
return @($state.managed_skills | Where-Object { $_.name -eq $name }).Count -gt 0
} catch {
return $false
}
}
function Get-SkillDirectoryDigest {
param([string]$Dir)
$root = [System.IO.Path]::GetFullPath($Dir).TrimEnd([char[]]@('\', '/'))
$files = @(
Get-ChildItem -Path $root -Recurse -File -Force |
ForEach-Object {
[pscustomobject]@{
Relative = $_.FullName.Substring($root.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/')
FullName = $_.FullName
}
} |
Sort-Object -Property Relative
)
$stream = [System.IO.MemoryStream]::new()
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
foreach ($file in $files) {
$pathBytes = [System.Text.Encoding]::UTF8.GetBytes($file.Relative)
$stream.Write($pathBytes, 0, $pathBytes.Length)
$stream.WriteByte(0)
$content = [System.IO.File]::ReadAllBytes($file.FullName)
$stream.Write($content, 0, $content.Length)
$stream.WriteByte(0)
}
$hash = $sha.ComputeHash($stream.ToArray())
return "sha256:" + ([System.BitConverter]::ToString($hash).Replace("-", "").ToLowerInvariant())
} finally {
$sha.Dispose()
$stream.Dispose()
}
}
function Write-SkillsState {
param([string]$MultiSrc)
$stateDir = $SkillStateRoot
New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
$versionValue = if ([string]::IsNullOrWhiteSpace($Version)) { "unknown" } else { $Version }
$skills = @(Get-ChildItem -Path $MultiSrc -Directory | Where-Object {
Test-Path (Join-Path $_.FullName "SKILL.md")
} | Sort-Object -Property Name)
$names = @($skills | ForEach-Object { $_.Name })
$managed = @($skills | ForEach-Object {
[ordered]@{
name = $_.Name
version = $versionValue
source = "install.ps1"
digest = Get-SkillDirectoryDigest -Dir $_.FullName
digest_scope = $ManagedSkillDigestScope
}
})
$state = [ordered]@{
version = $versionValue
official_skills = $names
updated_skills = $names
managed_skills = $managed
updated_at = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
}
$statePath = Join-Path $stateDir "skills-state.json"
$tempPath = Join-Path $stateDir (".skills-state-" + [guid]::NewGuid().ToString("N") + ".tmp")
$backupPath = Join-Path $stateDir (".skills-state-" + [guid]::NewGuid().ToString("N") + ".previous")
try {
[System.IO.File]::WriteAllText($tempPath, (($state | ConvertTo-Json -Depth 5) + "`n"), [System.Text.UTF8Encoding]::new($false))
if (Test-Path $statePath -PathType Leaf) {
[System.IO.File]::Replace($tempPath, $statePath, $backupPath, $true)
Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue
} else {
Move-Item -LiteralPath $tempPath -Destination $statePath
}
} finally {
if (Test-Path $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
}
}
# Central move seam for transactional Skill publication. Tests replace this
# function to inject backup/publish failures without relying on ACL behavior.
function Move-SkillPath {
param(
[string]$Source,
[string]$Destination
)
if (Test-SkillPathLexically -Path $Destination) {
throw "Skill move destination already exists: $Destination"
}
$sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
if ($sourceItem.PSIsContainer) {
# Move-Item treats an existing directory as a container and nests the
# source below it. Directory.Move has the exact rename semantics this
# transaction requires: an occupied destination fails without touching
# either path.
[System.IO.Directory]::Move($Source, $Destination)
} else {
# File.Move replaces an occupied dest on Windows. Copy without
# overwrite refuses that dest, then the source is removed only after
# the exclusive copy succeeds.
[System.IO.File]::Copy($Source, $Destination, $false)
try {
Remove-SkillPathLexically -Path $Source
} catch {
$removeErr = $_
try {
Remove-SkillPathLexically -Path $Destination
} catch {
throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_"
}
throw $removeErr
}
}
}
function Test-CrossDeviceMoveError {
param([System.Management.Automation.ErrorRecord]$Record)
$exception = $Record.Exception
while ($null -ne $exception) {
# Win32 ERROR_NOT_SAME_DEVICE is 17 (0x11). Move-Item surfaces it as
# the low word of an IOException HRESULT.
if (($exception.HResult -band 0xffff) -eq 17) { return $true }
$exception = $exception.InnerException
}
return $false
}
function Copy-SkillPathMetadata {
param($SourceItem, [string]$Destination)
(Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes
$nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
if ($nativeWindows) {
Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop
} else {
$mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName)
[System.IO.File]::SetUnixFileMode($Destination, $mode)
}
}
function Get-SkillPathPermissionFingerprint {
param([string]$Path)
$nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
if ($nativeWindows) {
return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl
}
return [string][System.IO.File]::GetUnixFileMode($Path)
}
function Copy-SkillPathLexically {
param([string]$Source, [string]$Destination)
$item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
if ($item.LinkType) {
$itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" }
New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null
return
}
if ($item.PSIsContainer) {
New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null
foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) {
Copy-SkillPathLexically -Source $child.FullName -Destination (Join-Path $Destination $child.Name)
}
Copy-SkillPathMetadata -SourceItem $item -Destination $Destination
return
}
if ($item -isnot [System.IO.FileInfo]) {
throw "不支持复制特殊 Skill 路径 $Source"
}
[System.IO.File]::Copy($Source, $Destination, $false)
Copy-SkillPathMetadata -SourceItem $item -Destination $Destination
}
function Assert-SkillPathCopy {
param([string]$Source, [string]$Destination)
$sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
$destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop
if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or
$sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) {
throw "Skill 路径类型不一致: $Source != $Destination"
}
if ($sourceItem.LinkType) {
if ($sourceItem.LinkType -ne $destinationItem.LinkType -or
($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) {
throw "Skill 链接目标不一致: $Source != $Destination"
}
return
}
if ((Get-SkillPathPermissionFingerprint -Path $Source) -ne
(Get-SkillPathPermissionFingerprint -Path $Destination)) {
throw "Skill 路径权限不一致: $Source != $Destination"
}
if ($sourceItem.PSIsContainer) {
$sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name)
$destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name)
if ($sourceChildren.Count -ne $destinationChildren.Count) {
throw "Skill 目录项数量不一致: $Source != $Destination"
}
for ($i = 0; $i -lt $sourceChildren.Count; $i++) {
if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) {
throw "Skill 目录项不一致: $Source != $Destination"
}
Assert-SkillPathCopy -Source $sourceChildren[$i].FullName -Destination $destinationChildren[$i].FullName
}
return
}
if ($sourceItem.Length -ne $destinationItem.Length) {
throw "Skill 文件大小不一致: $Source != $Destination"
}
$sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash
$destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash
if ($sourceHash -ne $destinationHash) {
throw "Skill 文件内容摘要不一致: $Source != $Destination"
}
}
function Remove-SkillPathLexically {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item.PSIsContainer -and !$item.LinkType) {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
} else {
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
}
}
# Removes a staging directory that may hold unpublished junction/symlink
# children. Every link child must be removed non-recursively first: Windows
# PowerShell 5.1 (the advertised irm|iex surface) can follow reparse points
# during Remove-Item -Recurse and would delete the canonical store's contents
# through the staged link. CI runs pwsh 7, which masks this behavior.
function Remove-LinkStageRoot {
param([string]$StageRoot)
if (!(Test-Path -LiteralPath $StageRoot)) { return $true }
$ok = $true
foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) {
try { Remove-SkillPathLexically -Path $child.FullName } catch { $ok = $false }
}
try {
Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop
} catch {
$ok = $false
}
return $ok
}
# Resolves a path to its physical location, dereferencing junction/symlink
# reparse points at any depth. Resolve-Path alone is lexical and never
# recognizes DWS's own junctions, so every rerun would back them up and
# recreate them. Mirrors EvalSymlinks (Go), realpathSync (npm), and cd -P
# (shell) on the other installer surfaces.
function Get-PhysicalSkillPath {
param([string]$Path, [int]$Depth = 0)
if ($Depth -gt 40) { return $null }
$parent = Split-Path $Path -Parent
if ([string]::IsNullOrWhiteSpace($parent)) { return $Path }
$parentPhysical = Get-PhysicalSkillPath -Path $parent -Depth ($Depth + 1)
if ($null -eq $parentPhysical) { return $null }
$leaf = Split-Path $Path -Leaf
if ([string]::IsNullOrEmpty($leaf)) { return $parentPhysical }
$candidate = Join-Path $parentPhysical $leaf
try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null }
$target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1
if ($item.LinkType -and $target) {
$targetPath = ""
if ([System.IO.Path]::IsPathRooted([string]$target)) {
$targetPath = [string]$target
} else {
$targetPath = Join-Path $parentPhysical ([string]$target)
}
# Resolve the target recursively as well. A custom HOME or canonical
# root can itself sit below another junction, which must compare equal
# to the fully physical path just like EvalSymlinks/realpath do.
return Get-PhysicalSkillPath -Path ([System.IO.Path]::GetFullPath($targetPath)) -Depth ($Depth + 1)
}
return $candidate
}
# Same-volume moves remain atomic. For a cross-volume backup/restore, stage on
# the destination filesystem, copy links lexically, verify, publish, then
# remove the source. Before source removal every failure keeps the original;
# a removal failure deliberately leaves both verified copies and fails loud.
function Move-SkillPathRecoverably {
param([string]$Source, [string]$Destination)
if (Test-SkillPathLexically -Path $Destination) { throw "移动目标已存在: $Destination" }
$destinationParent = Split-Path $Destination -Parent
New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null
try {
Move-SkillPath -Source $Source -Destination $Destination
return
} catch {
if (!(Test-CrossDeviceMoveError -Record $_)) { throw }
}
$stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N"))
$stage = Join-Path $stageRoot "payload"
New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
$published = $false
try {
Copy-SkillPathLexically -Source $Source -Destination $stage
Assert-SkillPathCopy -Source $Source -Destination $stage
Move-SkillPath -Source $stage -Destination $Destination
$published = $true
$publication = [pscustomobject]@{ Path = $Destination; Source = $Source }
try {
Assert-SkillPathCopy -Source $Source -Destination $Destination
if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) {
throw "Skill staging 清理失败: $stageRoot"
}
} catch {
$postErr = $_
try {
Remove-PublishedSkillPathSafely -Record $publication
} catch {
throw "Skill 移动状态不确定:$postErr;撤回目标 $Destination 失败: $_;源 $Source 与目标 $Destination 均保留"
}
throw "Skill 移动失败,目标已撤回,原路径保留 ${Source}: $postErr"
}
try {
Remove-SkillPathLexically -Path $Source
} catch {
throw "Skill 目标已发布但源路径删除失败(源 $Source 与目标 $Destination 均保留): $_"
}
if (Test-SkillPathLexically -Path $Source) {
throw "Skill 目标已发布但源路径仍存在(源 $Source 与目标 $Destination 均保留)"
}
} catch {
$failure = $_
if (Test-Path -LiteralPath $stageRoot) {
if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) {
if (-not $published) {
throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)"
}
}
}
throw $failure
}
}
function Get-Arch {
# Allow manual override via environment variable
if ($env:DWS_ARCH) {
$override = $env:DWS_ARCH.ToLower()
if ($override -eq "amd64" -or $override -eq "arm64") {
return $override
}
Write-Err "Invalid DWS_ARCH value '$env:DWS_ARCH'. Must be 'amd64' or 'arm64'."
}
# Method 1: Try RuntimeInformation (available in .NET Core / PowerShell 6+)
try {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
if ($arch) {
switch ($arch.ToString()) {
"X64" { return "amd64" }
"Arm64" { return "arm64" }
}
}
} catch {}
# Method 2: Check PROCESSOR_ARCHITECTURE environment variable (Windows)
$envArch = $env:PROCESSOR_ARCHITECTURE
if ($envArch) {
switch ($envArch.ToUpper()) {
"AMD64" { return "amd64" }
"ARM64" { return "arm64" }
"X86" {
# 32-bit process on 64-bit OS?
$realArch = $env:PROCESSOR_ARCHITEW6432
if ($realArch) {
switch ($realArch.ToUpper()) {
"AMD64" { return "amd64" }
"ARM64" { return "arm64" }
}
}
Write-Err "32-bit Windows is not supported"
}
}
}
# Method 3: Try WMI query as last resort
try {
$cpu = Get-WmiObject -Class Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1
if ($cpu) {
switch ($cpu.Architecture) {
9 { return "amd64" } # x64
12 { return "arm64" } # ARM64
}
}
} catch {}
Write-Err "Unsupported architecture: Could not detect system architecture. Please set DWS_ARCH environment variable to 'amd64' or 'arm64'."
}
function Invoke-GiteeApi {
param([string]$Uri)
# Gitee's gateway returns sporadic 502/503, so retry a few times before failing.
for ($i = 1; $i -le 4; $i++) {
try {
return Invoke-RestMethod -Uri $Uri -UseBasicParsing
} catch {
if ($i -eq 4) { throw }
Start-Sleep -Seconds 2
}
}
}
function Get-GiteeAssetUrl {
param([string]$Name)
# Resolve a release asset's download URL by name via the Gitee API
# (Gitee attachment URLs carry an unstable numeric id, so never template them).
$rel = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/releases/tags/$Version"
foreach ($a in $rel.assets) {
if ($a.name -eq $Name) { return $a.browser_download_url }
}
return ""
}
function Assert-ReleaseAssetChecksum {
param([string]$AssetPath, [string]$AssetName, [string]$TempDir)
if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
if (-not $checksumUrl) { Write-Err "Could not resolve checksums.txt for $Version." }
$checksumPath = Join-Path $TempDir "checksums.txt"
try {
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing -ErrorAction Stop
} catch {
Write-Err "Could not download checksums.txt for $Version; refusing unverified $AssetName."
}
$expectedLine = Get-Content -LiteralPath $checksumPath | Where-Object {
$_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$"
} | Select-Object -First 1
if (-not $expectedLine) { Write-Err "$AssetName is missing from checksums.txt." }
$expected = ($expectedLine -split '\s+')[0].ToLowerInvariant()
$actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
if ($actual -ne $expected) { Write-Err "SHA256 checksum mismatch for $AssetName. Expected $expected, got $actual." }
Write-Say "✅ SHA256 checksum verified: $AssetName"
}
function Resolve-Source {
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
if ($GiteeRepo -ne "") { return }
if ($env:DWS_NO_FALLBACK -eq "1") { return }
try {
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" -Method Head `
-TimeoutSec 12 -UseBasicParsing -ErrorAction Stop 2>$null | Out-Null
return
} catch {
$script:GiteeRepo = $GiteeFallbackRepo
Write-Say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: $script:GiteeRepo"
}
}
function Resolve-LatestVersion {
if ($Version -eq "latest") {
if ($GiteeRepo -ne "") {
try {
# Gitee's /releases/latest and /releases endpoints are unreliable
# (404 / empty even when releases exist), so resolve the newest
# vN.N.N tag from the git tags endpoint instead.
$tags = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/tags"
$latest = $tags.name |
Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } |
ForEach-Object { [version]($_.TrimStart('v')) } |
Sort-Object | Select-Object -Last 1
if ($latest) { $script:Version = "v$latest"; return }
} catch {}
Write-Err "Could not determine the latest Gitee version. Set `$env:DWS_VERSION explicitly."
return
}
try {
$response = Invoke-WebRequest -Uri $LatestUrl `
-MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing 2>$null
} catch {
if ($_.Exception.Response.Headers.Location) {
$location = $_.Exception.Response.Headers.Location.ToString()
$script:Version = ($location -split "/tag/")[-1].Trim()
return
}
}
# Fallback: parse the redirect from the response
try {
$response = Invoke-WebRequest -Uri $LatestUrl `
-UseBasicParsing -ErrorAction Stop
if ($response.BaseResponse.ResponseUri) {
$script:Version = ($response.BaseResponse.ResponseUri.ToString() -split "/tag/")[-1].Trim()
return
}
if ($response.BaseResponse.RequestMessage.RequestUri) {
$script:Version = ($response.BaseResponse.RequestMessage.RequestUri.ToString() -split "/tag/")[-1].Trim()
return
}
} catch {}
Write-Err "Could not determine the latest version. Set `$env:DWS_VERSION explicitly."
}
}
function Copy-DirRecursive {
param([string]$Source, [string]$Destination)
if (!(Test-Path $Destination)) {
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
}
$count = 0
Get-ChildItem -Path $Source -Force | ForEach-Object {
$destPath = Join-Path $Destination $_.Name
if ($_.PSIsContainer) {
$count += Copy-DirRecursive -Source $_.FullName -Destination $destPath
} else {
Copy-Item -Path $_.FullName -Destination $destPath -Force
$count++
}
}
return $count
}
function Publish-SkillCache {
param([string]$Source, [string]$CacheDir)
$cacheParent = Split-Path $CacheDir -Parent
$cacheName = Split-Path $CacheDir -Leaf
New-Item -ItemType Directory -Path $cacheParent -Force -ErrorAction Stop | Out-Null
$stagedDir = Join-Path $cacheParent ".$cacheName.tmp-$([Guid]::NewGuid().ToString('N'))"
$rollbackDir = ""
$published = $false
New-Item -ItemType Directory -Path $stagedDir -Force -ErrorAction Stop | Out-Null
try {
$count = Copy-DirRecursive -Source $Source -Destination $stagedDir
if (Test-Path $CacheDir) {
$rollbackDir = Join-Path $cacheParent ".$cacheName.old-$([Guid]::NewGuid().ToString('N'))"
Move-Item -Path $CacheDir -Destination $rollbackDir -ErrorAction Stop
}
try {
Move-Item -Path $stagedDir -Destination $CacheDir -ErrorAction Stop
$published = $true
} catch {
$publishError = $_
if ($rollbackDir) {
try {
Move-Item -Path $rollbackDir -Destination $CacheDir -ErrorAction Stop
$rollbackDir = ""
} catch {
throw "Skill 缓存发布失败: $publishError;原缓存恢复也失败,恢复目录: $rollbackDir;错误: $_"
}
}
throw $publishError
}
if ($rollbackDir -and (Test-Path $rollbackDir)) {
Remove-Item -Path $rollbackDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path $rollbackDir) {
Write-Say "⚠️ 新缓存已生效,但旧缓存清理失败: $rollbackDir"
}
$rollbackDir = ""
}
return $count
} finally {
if (!$published -and (Test-Path $stagedDir)) {
Remove-Item -Path $stagedDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
# Get-SkillBackupName encodes the HOME-relative path of a backed-up Skill
# directory ('.codex\skills\dingtalk-chat' → '.codex-skills-dingtalk-chat') so
# copies retired from different Agent roots stay distinguishable inside one
# stamp. Mirrors build/npm/install.js and internal/upgrade/paths.go. Paths
# outside HOME fall back to the bare leaf.
function Get-SkillBackupName {
param([string]$Dir)
$leaf = Split-Path $Dir -Leaf
try {
$full = [System.IO.Path]::GetFullPath($Dir)
$root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/'))
} catch {
return $leaf
}
if ([string]::IsNullOrWhiteSpace($root)) { return $leaf }
foreach ($sep in @('\', '/')) {
$prefix = $root + $sep
if ($full.Length -gt $prefix.Length -and
$full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/'))
if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') }
}
}
return $leaf
}
# SkillBackupKeep bounds $HOME\.dws\skill-backups growth: only the newest
# stamped backup directories are kept, matching skillBackupKeep and
# pruneSkillBackups in internal/upgrade/paths.go.
$SkillBackupKeep = 5
$script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
# Ownership marker: every stamp root DWS creates carries .dws-skill-backup
# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/
# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name
# alone is not ownership proof, so pruning only deletes directories whose
# marker content verifies.
$SkillBackupMarkerFile = ".dws-skill-backup"
$SkillBackupMarkerBody = "dws skill backup v1"
# Write-SkillBackupMarker stamps a freshly created stamp root as DWS-owned.
# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content
# would append a platform newline on Windows PowerShell 5.1).
function Write-SkillBackupMarker {
param([string]$Root)
[System.IO.File]::WriteAllText(
(Join-Path $Root $SkillBackupMarkerFile),
"$SkillBackupMarkerBody`n",
[System.Text.UTF8Encoding]::new($false))
}
# Test-SkillBackupMarker reports whether a stamp root carries the ownership
# marker. The check normalizes CRLF→LF and drops trailing newlines before
# comparing, so this surface accepts every surface's exact-LF bytes (writer
# and checker agree); any other content, or a missing/unreadable marker,
# means foreign data.
function Test-SkillBackupMarker {
param([string]$Dir)
try {
$marker = Join-Path $Dir $SkillBackupMarkerFile
if (![System.IO.File]::Exists($marker)) { return $false }
$body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n")
return ($body -eq $SkillBackupMarkerBody)
} catch {
return $false
}
}
# Removes a whole stamp root child-first without ever following a reparse
# point: link children are deleted non-recursively, real directories are
# recursed the same way, and only an emptied directory is removed. Backup
# trees can contain junctions/symlinks (victims are collected before the
# physical-equality filter), and Windows PowerShell 5.1 can follow reparse
# points during Remove-Item -Recurse — the invariant Remove-LinkStageRoot
# enforces for staging roots, applied at every depth here.
function Remove-SkillBackupTreeLexically {
param([string]$Path)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item.PSIsContainer -and !$item.LinkType) {
foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) {
Remove-SkillBackupTreeLexically -Path $child.FullName
}
}
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
}
# Remove-OldSkillBackups deletes the oldest stamped backup directories once
# more than $SkillBackupKeep remain. Stamps sort lexicographically in
# chronological order, so name order is age order. Roots created during this
# run are never pruned: an in-flight transaction still needs them to roll
# back. Best-effort — a prune failure never fails the install.
function Remove-OldSkillBackups {
$root = Join-Path $HOME ".dws\skill-backups"
if (!(Test-Path -LiteralPath $root -PathType Container)) { return }
# Only directories whose names match the DWS backup stamp format (UTC
# yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership
# marker verifies are candidates; anything else is foreign data —
# preserved and never counted against $SkillBackupKeep.
$dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-SkillBackupMarker -Dir $_.FullName) } |
Sort-Object -Property Name)
$excess = $dirs.Count - $SkillBackupKeep
foreach ($dir in $dirs) {
if ($excess -le 0) { break }
if ($script:SkillBackupRootsThisRun.Contains($dir.FullName)) { continue }
try { Remove-SkillBackupTreeLexically -Path $dir.FullName } catch { }
$excess--
}
}
# Backup-SkillDir moves $Dir into $HOME\.dws\skill-backups\<stamp>\<name>
# instead of destroying it (non-interactive installs cannot confirm, so
# removals must stay reversible). Missing paths are a no-op success. On any
# backup failure the directory is left in place and $false is returned so
# callers skip that target rather than silently deleting data.
function Backup-SkillDir {
param(
[string]$Dir,
[ref]$BackupPath
)
if ($null -ne $BackupPath) { $BackupPath.Value = "" }
if (!(Test-SkillPathLexically -Path $Dir)) { return $true }
$backupRoot = Join-Path $HOME ".dws\skill-backups"
$stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss")
$name = Get-SkillBackupName -Dir $Dir
$targetRoot = Join-Path $backupRoot $stamp
$target = Join-Path $targetRoot $name
$i = 1
# Bump not only when <stamp>\<name> is taken but also when the stamp root
# itself exists without a verified ownership marker: a same-second
# foreign directory must never be stamped DWS-owned and pruned later. A
# marker-verified root from this run's same second stays reusable.
while ((Test-SkillPathLexically -Path $target) -or
((Test-SkillPathLexically -Path $targetRoot) -and !(Test-SkillBackupMarker -Dir $targetRoot))) {
$targetRoot = Join-Path $backupRoot "$stamp-$i"
$target = Join-Path $targetRoot $name
$i++
if ($i -gt 1000) {
Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir"
return $false
}
}
try {
New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null
# Stamp ownership immediately after creating the stamp root and
# before any skill directory moves into it, so an interrupted backup
# can never leave an unmarked (never-prunable) stamp behind.
Write-SkillBackupMarker -Root $targetRoot
} catch {
# The removal stays non-recursive so a pre-existing non-empty root
# (foreign data) is never destroyed; a failed marker write must not
# leave an empty unowned stamp root behind either.
Remove-Item -LiteralPath $targetRoot -Force -ErrorAction SilentlyContinue
Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_"
return $false
}
try {
Move-SkillPathRecoverably -Source $Dir -Destination $target
} catch {
Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_"
return $false
}
if ($null -ne $BackupPath) { $BackupPath.Value = $target }
try {
$script:SkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($targetRoot)) | Out-Null
Remove-OldSkillBackups
} catch {
Write-Say "⚠️ 旧备份清理失败(备份本身已成功): $_"
}
Write-Say " × 已备份并移除 $Dir → $target"
return $true
}
function Get-SkillLinkSignature {
param([string]$Path)
try {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
} catch {
return $null
}
if (!$item.LinkType) { return $null }
$targets = @($item.Target) | ForEach-Object { [string]$_ }
return ([string]$item.LinkType + "`0" + ($targets -join "`0"))
}
function New-PublishedSkillLinkRecord {
param([string]$Path)
$signature = Get-SkillLinkSignature -Path $Path
if ([string]::IsNullOrEmpty($signature)) {
throw "published Skill path is not a link: $Path"
}
return [pscustomobject]@{ Path = $Path; LinkSignature = $signature }
}
function New-PublishedSkillCopyRecord {
param([string]$Path, [string]$Source)
Assert-SkillPathCopy -Source $Source -Destination $Path
return [pscustomobject]@{ Path = $Path; Source = $Source }
}
function Remove-PublishedSkillPathSafely {
param($Record)
$path = [string]$Record.Path
$parent = Split-Path $path -Parent
$quarantine = Join-Path $parent (".dws-link-rollback-" + [guid]::NewGuid().ToString("N"))
# Claim the current directory entry with an exact same-filesystem rename.
# If another process replaced our link, verification below fails and that
# object is moved back instead of ever being recursively removed.
Move-SkillPath -Source $path -Destination $quarantine
try {
if ($Record.LinkSignature) {
if ((Get-SkillLinkSignature -Path $quarantine) -ne [string]$Record.LinkSignature) {
throw "发布链接已被其他进程替换,拒绝删除"
}
} elseif ($Record.Source) {
Assert-SkillPathCopy -Source ([string]$Record.Source) -Destination $quarantine
} else {
throw "发布路径缺少事务身份,拒绝删除"
}
Remove-SkillPathLexically -Path $quarantine
} catch {
$failure = $_
if (Test-SkillPathLexically -Path $quarantine) {
try {
if (Test-SkillPathLexically -Path $path) { throw "原路径已被占用" }
Move-SkillPath -Source $quarantine -Destination $path
} catch {
throw "$failure;并发对象保留于 $quarantine`: $_"
}
}
throw $failure
}
}
function Restore-MultiSkillSet {
param(
[array]$Published,
[array]$Backups
)
$ok = $true
for ($i = $Published.Count - 1; $i -ge 0; $i--) {
$publishedItem = $Published[$i]
$publishedPath = if ($publishedItem -is [string]) { $publishedItem } else { [string]$publishedItem.Path }
try {
if (Test-SkillPathLexically -Path $publishedPath) {
if (!($publishedItem -is [string])) {
Remove-PublishedSkillPathSafely -Record $publishedItem
} else {
Remove-SkillPathLexically -Path $publishedPath
}
}
} catch {
Write-Say "⚠️ 无法移除失败发布目录 $publishedPath`: $_"
$ok = $false
}
}
for ($i = $Backups.Count - 1; $i -ge 0; $i--) {
$item = $Backups[$i]
try {
if (Test-SkillPathLexically -Path $item.Original) {
throw "恢复目标仍存在"
}
New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null
Move-SkillPathRecoverably -Source $item.Backup -Destination $item.Original
} catch {
Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_"
$ok = $false
}
}
return $ok
}
function Test-SkillPathLexically {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
try {
Get-Item -LiteralPath $Path -Force -ErrorAction Stop | Out-Null
return $true
} catch {
$parent = Split-Path $Path -Parent
$leaf = Split-Path $Path -Leaf
if ([string]::IsNullOrWhiteSpace($parent) -or !(Test-Path $parent -PathType Container)) { return $false }
return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq $leaf } | Select-Object -First 1)
}
}
function Resolve-AgentSkillBase {
param([string]$Root, $Agent)
if ($null -eq $Agent.Dir) { return $null }
switch ($Agent.Id) {
"autohand-code" { if ($env:AUTOHAND_HOME) { return (Join-Path $env:AUTOHAND_HOME "skills") } }
"claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return (Join-Path $env:CLAUDE_CONFIG_DIR "skills") } }
"codex" { if ($env:CODEX_HOME) { return (Join-Path $env:CODEX_HOME "skills") } }
"grok" { if ($env:GROK_HOME) { return (Join-Path $env:GROK_HOME "skills") } }
"hermes-agent" { if ($env:HERMES_HOME) { return (Join-Path $env:HERMES_HOME "skills") } }
"mistral-vibe" { if ($env:VIBE_HOME) { return (Join-Path $env:VIBE_HOME "skills") } }
"openclaw" {
foreach ($name in @(".openclaw", ".clawdbot", ".moltbot")) {
$candidate = Join-Path $Root $name
if (Test-Path $candidate -PathType Container) { return (Join-Path $candidate "skills") }
}
}
{ $_ -in @("amp", "replit", "universal") } {
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
return (Join-Path $configHome "agents\skills")
}
{ $_ -in @("crush", "devin", "goose", "opencode") } {
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
$child = switch ($Agent.Id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; default { "opencode\skills" } }
return (Join-Path $configHome $child)
}
"kimchi" {
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
return (Join-Path $configHome "kimchi\harness\skills")
}
}
return (Join-Path $Root $Agent.Dir)
}
function Test-AgentSkillBaseDetected {
param([string]$BaseDir, $Agent)
$parent = Split-Path $BaseDir -Parent
switch ($Agent.Id) {
"kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
"tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
"zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) }
"minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) }
default { return Test-Path $parent -PathType Container }
}
}
function Test-SamePhysicalSkillRoot {
param([string]$Left, [string]$Right)
if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false }
$leftPhysical = Get-PhysicalSkillPath -Path $Left
$rightPhysical = Get-PhysicalSkillPath -Path $Right
if ($null -eq $leftPhysical -or $null -eq $rightPhysical) { return $false }
return $leftPhysical.TrimEnd([char[]]@('\', '/')) -ieq $rightPhysical.TrimEnd([char[]]@('\', '/'))
}
function Move-AgentSkillRootToBackup {
param([string]$Root, [string]$BaseDir)
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $baseDir $SkillName))
foreach ($existing in Get-ChildItem -Path $baseDir -Force -ErrorAction SilentlyContinue) {
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) {
$victims.Add($existing.FullName)
}
}
$backups = @()
try {
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($victim in $victims) {
if (!$seen.Add($victim)) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
throw "Agent Skill 旧副本备份失败: $victim"
}
if ($backupPath) {
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
}
}
return $true
} catch {
Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null
Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_"
return $false
}
}
function Publish-CanonicalSkillLinks {
param([string]$Root, [string]$BaseDir, [string]$Mode, [string[]]$BundleNames = @())
$canonical = Join-Path $Root ".agents\skills"
if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null }
if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true }
$stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N"))
$backups = @()
$published = @()
try {
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
if ($Mode -eq "mono") {
$names = @($SkillName)
} elseif ($BundleNames.Count -gt 0) {
# The canonical store is SHARED: enumerate the link set from the
# installed bundle, never from ~\.agents\skills, or third-party and
# user skills would be republished into every Agent root.
$names = @($BundleNames)
} else {
# No bundle list means we cannot tell DWS skills apart from the
# user's own entries in the shared store. Fail this Agent (the
# caller degrades to a copy install) instead of guessing, matching
# link_canonical_skills_to_base in scripts/install-skills.sh.
throw "multi mode requires the installed bundle skill names"
}
$publishNames = @()
foreach ($name in $names) {
if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue }
$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))
New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget -ErrorAction Stop | Out-Null
$publishNames += $name
}
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $BaseDir $SkillName))
foreach ($existing in Get-ChildItem -Path $BaseDir -Force -ErrorAction SilentlyContinue) {
if ($existing.FullName -eq $stageRoot) { continue }
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) }
}
# Every published name replaces whatever occupies its destination, even
# when that copy predates central ownership metadata; the loop below
# still skips destinations that are already correct links.
foreach ($name in $names) {
$victims.Add((Join-Path $BaseDir $name))
}
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($victim in $victims) {
if (!$seen.Add($victim)) { continue }
if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" }
if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } }
}
foreach ($name in $publishNames) {
$dest = Join-Path $BaseDir $name
Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest
# Only record a publication after the staged junction has occupied
# the exact destination. Rollback also checks this identity before
# deleting, so a concurrent user directory is never removed.
$published += New-PublishedSkillLinkRecord -Path $dest
Write-Say "↪ Skills → $dest"
}
return $true
} catch {
Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null
Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)"
return $false
} finally {
Remove-LinkStageRoot -StageRoot $stageRoot | Out-Null
}
}
function Copy-SkillToDir {
param([string]$SkillSrc, [string]$Dest, [string]$Label)
# Refreshing an existing skill: back it up first; on backup failure keep
# the user's copy and skip this target.
if (!(Backup-SkillDir -Dir $Dest)) {
Write-Say "⚠️ 跳过 $Dest(保留原目录)"
return $false
}
$fileCount = Copy-DirRecursive -Source $SkillSrc -Destination $Dest
Write-Say "✅ Skills → $Label ($fileCount files)"
# List top-level contents for visibility
Get-ChildItem -Path $Dest | ForEach-Object {
if ($_.PSIsContainer) {
$subCount = (Get-ChildItem -Path $_.FullName -Recurse -File).Count
Write-Say " 📁 $($_.Name)/ ($subCount files)"
} else {
Write-Say " 📄 $($_.Name)"
}
}
return $true
}
function Copy-SkillToDirSummary {
param([string]$SkillSrc, [string]$Dest, [string]$Label)
if (!(Backup-SkillDir -Dir $Dest)) {
Write-Say "⚠️ 跳过 $Dest(保留原目录)"
return $false
}
$fileCount = Copy-DirRecursive -Source $SkillSrc -Destination $Dest
Write-Say "✅ Skills → $Label ($fileCount files)"
return $true
}
function Resolve-SourceRoot {
$scriptPath = $PSScriptRoot
if (-not $scriptPath) { return $null }
$candidateRoot = Split-Path $scriptPath -Parent
if ((Test-Path (Join-Path $candidateRoot "go.mod")) -and (Test-Path (Join-Path $candidateRoot "cmd"))) {
return $candidateRoot
}
return $null
}
# ── Banner ───────────────────────────────────────────────────────────────────
function Write-Banner {
Write-Host ""
Write-Say "┌──────────────────────────────────────┐"
Write-Say "│ DWS Installer │"
Write-Say "│ DingTalk Workspace CLI │"
Write-Say "└──────────────────────────────────────┘"
Write-Host ""
}
# ── Skill Mode Resolution ────────────────────────────────────────────────────
#
# Priority (highest first):
# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive)
# 2. Interactive prompt when both stdin and stdout are TTYs (default: multi)
# 3. Fallback: multi (non-TTY without env var, e.g. irm | iex)
function Resolve-SkillMode {
if ($env:DWS_SKILL_MODE) {
$normalized = $env:DWS_SKILL_MODE.ToLower()
if ($normalized -eq "mono" -or $normalized -eq "multi") {
$script:SkillMode = $normalized
Write-Say "Skill mode: $SkillMode (from DWS_SKILL_MODE)"
return
}
Write-Err "Invalid DWS_SKILL_MODE='$($env:DWS_SKILL_MODE)'. Use 'mono' or 'multi'."
}
$isInteractive = $false
try {
$isInteractive = ([Console]::IsInputRedirected -eq $false) -and ([Console]::IsOutputRedirected -eq $false)
} catch {
$isInteractive = $false
}
if ($isInteractive) {
Write-Host ""
Write-Say "Select skill installation mode:"
Write-Say " 1) multi (default) — split each product into its own skill (dingtalk-*)"
Write-Say " 2) mono — install one bundled dws skill (legacy)"
$choice = Read-Host " Choice [1]"
switch ($choice) {
"" { $script:SkillMode = "multi" }
"1" { $script:SkillMode = "multi" }
"multi" { $script:SkillMode = "multi" }
"2" { $script:SkillMode = "mono" }
"mono" { $script:SkillMode = "mono" }
default {
Write-Say "Unrecognized choice '$choice', defaulting to multi."
$script:SkillMode = "multi"
}
}
Write-Say "Skill mode: $SkillMode"
return
}
$script:SkillMode = "multi"
}
# ── Install Binary ───────────────────────────────────────────────────────────
function Install-Binary {
$arch = Get-Arch
Resolve-LatestVersion
$archiveName = "${BinName}-windows-${arch}.zip"
if ($GiteeRepo -ne "") { $downloadUrl = Get-GiteeAssetUrl $archiveName } else { $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName" }
if (-not $downloadUrl) { Write-Err "Could not resolve download URL for $archiveName (version $Version)." }
Write-Say "⬇ Downloading $BinName $Version (windows/$arch)..."
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "dws-install-$PID"
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
try {
$archivePath = Join-Path $tmpDir $archiveName
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
Assert-ReleaseAssetChecksum -AssetPath $archivePath -AssetName $archiveName -TempDir $tmpDir
Write-Say "📦 Extracting..."
Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force
# Create install directory
if (!(Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
# Find the binary
$binFile = Get-ChildItem -Path $tmpDir -Recurse -Filter "${BinName}.exe" | Select-Object -First 1
if ($null -eq $binFile) {
Write-Err "Could not find ${BinName}.exe in the downloaded archive."
}
$destBin = Join-Path $InstallDir "${BinName}.exe"
Copy-Item -Path $binFile.FullName -Destination $destBin -Force
Write-Say "✅ Binary installed:"
Write-Say " → $destBin"
# Check if install dir is in PATH
$userPath = [Environment]::GetEnvironmentVariable("PATH", "User")
if ($userPath -notlike "*$InstallDir*") {
Write-Say ""
Write-Say "⚠️ $InstallDir is not in your PATH."
Write-Say " Adding to user PATH..."
[Environment]::SetEnvironmentVariable("PATH", "$InstallDir;$userPath", "User")
$env:PATH = "$InstallDir;$env:PATH"
Write-Say " ✅ Added to PATH. Restart your terminal for changes to take effect."
}
} finally {
Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ── Install Skills from Local Source ──────────────────────────────────────────
function Install-SkillsLocal {
param([string]$Root)
$skillSrc = Join-Path (Join-Path $Root "skills") "mono"
$multiSrc = Join-Path (Join-Path $Root "skills") "multi"
if ($SkillMode -eq "multi" -and (Test-MultiTreeHasSkills $multiSrc)) {
Write-Say ""
Write-Say "📦 Installing agent skills (multi) from local source: $multiSrc"
if (!(Install-MultiSkillsToHomes -MultiSrc $multiSrc -Root $HOME)) {
throw "multi Skill installation failed"
}
} else {
if ($SkillMode -eq "multi") {
Write-Say "⚠️ multi skill tree not found or empty at $multiSrc; falling back to mono."
}
if (!(Test-Path $skillSrc)) {
Write-Say "⚠️ Local skills directory not found: $skillSrc"
Write-Say " Skipping skills installation."
return
}
Write-Say ""
Write-Say "📦 Installing agent skills from local source: $skillSrc"
if (!(Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME)) {
throw "mono Skill installation failed"
}
}
if (Test-Path $multiSrc) {
Cache-MultiSkills -Source $multiSrc
}
Cache-MonoSkills -Source $skillSrc
}
# Cache-MultiSkills mirrors install.sh cache_multi_skills: copies the multi/
# tree to ~/.dws/skills/multi/ so `dws skill setup --mode multi` can find a
# source without needing the source checkout or a re-download.
function Cache-MultiSkills {
param([string]$Source)
# Never let an empty/corrupt multi\ tree wipe a previously good cache.
if (!(Test-MultiTreeHasSkills $Source)) { return }
$cacheDir = Join-Path $HOME ".dws\skills\multi"
try {
$count = Publish-SkillCache -Source $Source -CacheDir $cacheDir
Write-Say "✅ Cached multi skills → $cacheDir ($count files)"
} catch {
Write-Say "⚠️ Multi Skill 缓存刷新失败,未覆盖原缓存: $cacheDir ($_)"
}
}
function Cache-MonoSkills {
param([string]$Source)
# Only refresh when the new bundle actually carries a mono tree — a
# multi-only bundle must never wipe a previously good mono cache.
if (!(Test-Path (Join-Path $Source "SKILL.md"))) { return }
$cacheDir = Join-Path $HOME ".dws\skills\mono"
try {
Publish-SkillCache -Source $Source -CacheDir $cacheDir | Out-Null
} catch {
Write-Say "⚠️ Mono Skill 缓存刷新失败,未覆盖原缓存: $cacheDir ($_)"
}
}
function Install-MonoToBase {
param(
[string]$SkillSrc,
[string]$BaseDir,
[string]$Label
)
if (!(Test-Path $BaseDir)) {
New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null
}
$stageRoot = Join-Path $BaseDir (".dws-mono-set-" + [guid]::NewGuid().ToString("N"))
$stagedSkill = Join-Path $stageRoot $SkillName
$dest = Join-Path $BaseDir $SkillName
$backups = @()
$published = @()
try {
# Stage the complete mono tree before moving any Agent-visible
# directory, including every mutually-exclusive managed multi Skill.
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
Copy-DirRecursive -Source $SkillSrc -Destination $stagedSkill | Out-Null
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add($dest)
foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -ErrorAction SilentlyContinue) {
if ($existing.FullName -eq $stageRoot) { continue }
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) {
$victims.Add($existing.FullName)
}
}
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($victim in $victims) {
if (!$seen.Add($victim)) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
throw "Skill 备份失败: $victim"
}
if ($backupPath) {
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
}
}
Move-SkillPath -Source $stagedSkill -Destination $dest
$published += New-PublishedSkillCopyRecord -Path $dest -Source $SkillSrc
} catch {
$transactionError = $_
if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) {
Write-Say "⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
}
Write-Say "⚠️ mono Skill 集合发布失败,目标已回滚: $BaseDir ($transactionError)"
return $false
} finally {
if (Test-Path $stageRoot) {
Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
$fileCount = (Get-ChildItem -Path $dest -Recurse -File).Count
Write-Say "✅ Skills → $Label ($fileCount files)"
return $true
}
function Install-SkillsToHomes {
param(
[string]$SkillSrc,
[string]$Root = $HOME
)
$installed = 0
$attempted = 1
$failed = 0
$canonical = Join-Path $Root ".agents\skills"
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ }
if ($installed -eq 0) { return $false }
$seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
$baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
$baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
if (!$seenBases.Add($baseKey)) { continue }
if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue }
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
$attempted++
if ($agent.Universal) {
# Cleanup-only migration: universal Agents read the canonical store
# directly, so nothing is installed here. Leaving an obsolete copy
# behind is a warning, never a reason to fail an install whose
# canonical store and links all succeeded.
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) {
Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir"
}
continue
}
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") {
$installed++
} else {
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) {
Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
$installed++
} else { $failed++ }
}
}
if ($installed -eq 0) {
Write-Say "⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
return $false
}
if ($failed -gt 0) {
Write-Say "⚠️ 有 $failed 个 Agent 目标安装 mono Skill 失败"
return $false
}
Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue
Write-Say "✅ DWS Skills 安装完成"
Write-Say " 统一安装位置:$canonical"
Write-Say " 已自动适配本机上检测到的 Agent"
Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
return $true
}
# Test-MultiTreeHasSkills returns $true only when the multi bundle directory
# contains at least one product skill (a subdirectory with a SKILL.md). An
# empty or corrupt multi\ tree must never select the multi branch: installing
# it would delete existing dws\ + dingtalk-* skills and lay down nothing.
function Test-MultiTreeHasSkills {
param([string]$MultiSrc)
if (!(Test-Path $MultiSrc)) { return $false }
foreach ($dir in Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue) {
if (Test-Path (Join-Path $dir.FullName "SKILL.md")) { return $true }
}
return $false
}
# Install the multi skill bundle (one subdirectory per product skill) into all
# agent homes as sibling directories, mirroring `dws skill setup --mode multi`.
# Mutual exclusion: the mono leftover (<home>\dws) and stale DWS-managed Skills
# not present in the new bundle are removed first.
function Install-MultiSkillsToHomes {
param(
[string]$MultiSrc,
[string]$Root = $HOME
)
$installed = 0
$attempted = 1
$failed = 0
$canonical = Join-Path $Root ".agents\skills"
# The link set must come from the installed bundle, not from the shared
# canonical store (which may also hold user/third-party skills).
$bundleNames = @(Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue | Where-Object {
Test-Path (Join-Path $_.FullName "SKILL.md")
} | ForEach-Object { $_.Name })
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ }
if ($installed -eq 0) { return $false }
$seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
$baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
$baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
if (!$seenBases.Add($baseKey)) { continue }
if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue }
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
$attempted++
if ($agent.Universal) {
# Cleanup-only migration (see Install-SkillsToHomes): a retire
# failure must not fail an otherwise complete install.
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) {
Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir"
}
continue
}
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi" -BundleNames $bundleNames) {
$installed++
} else {
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agent.Dir) {
Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
$installed++
} else { $failed++ }
}
}
if ($installed -eq 0) {
Write-Say "⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
return $false
}
if ($failed -gt 0) {
Write-Say "⚠️ 有 $failed 个 Agent 目标安装 multi Skill 失败"
return $false
}
Write-SkillsState -MultiSrc $MultiSrc
Write-Say "✅ DWS Skills 安装完成"
Write-Say " 统一安装位置:$canonical"
Write-Say " 已自动适配本机上检测到的 Agent"
Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
return $true
}
function Install-MultiToBase {
param(
[string]$MultiSrc,
[string]$BaseDir,
[string]$Root,
[string]$AgentDir
)
if (!(Test-Path $BaseDir)) {
New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null
}
$skillDirs = @(Get-ChildItem -Path $MultiSrc -Directory | Where-Object {
Test-Path (Join-Path $_.FullName "SKILL.md")
})
$stageRoot = Join-Path $BaseDir (".dws-multi-set-" + [guid]::NewGuid().ToString("N"))
$backups = @()
$published = @()
try {
# Stage the complete replacement before moving any Agent-visible
# directory. Copy failures therefore leave the old set untouched.
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
foreach ($skillDir in $skillDirs) {
Copy-DirRecursive -Source $skillDir.FullName -Destination (Join-Path $stageRoot $skillDir.Name) | Out-Null
}
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $BaseDir $SkillName))
# Include stale, proven DWS-managed skills in the same transaction.
foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -ErrorAction SilentlyContinue) {
if ($existing.FullName -eq $stageRoot) { continue }
if ((Test-ManagedMultiSkillDir -Dir $existing.FullName) -and
!(Test-Path (Join-Path (Join-Path $MultiSrc $existing.Name) "SKILL.md"))) {
$victims.Add($existing.FullName)
}
}
if (!(Test-Path (Join-Path (Join-Path $MultiSrc "dws-shared") "SKILL.md"))) {
$victims.Add((Join-Path $BaseDir "dws-shared"))
}
foreach ($skillDir in $skillDirs) {
$victims.Add((Join-Path $BaseDir $skillDir.Name))
}
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($victim in $victims) {
if (!$seen.Add($victim)) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
throw "Skill 备份失败: $victim"
}
if ($backupPath) {
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
}
}
foreach ($skillDir in $skillDirs) {
$dest = Join-Path $BaseDir $skillDir.Name
Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest
$published += New-PublishedSkillCopyRecord -Path $dest -Source $skillDir.FullName
}
} catch {
$transactionError = $_
if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) {
Write-Say "⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
}
Write-Say "⚠️ multi Skill 集合发布失败,目标已回滚: $BaseDir ($transactionError)"
return $false
} finally {
if (Test-Path $stageRoot) {
Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
$count = $skillDirs.Count
if ($Root -eq $HOME) {
$label = "~\$AgentDir\"
} else {
$label = Join-Path $Root $AgentDir
}
Write-Say "✅ Skills → $label ($count product skills)"
return $true
}
# ── Install Binary from Source ───────────────────────────────────────────────
function Install-BinaryFromSource {
param([string]$Root)
if (!(Get-Command go -ErrorAction SilentlyContinue)) {
Write-Err "Missing required command: go"
}
Write-Say "Installing dws from source checkout: $Root"
Write-Say "Install dir: $InstallDir"
if (!(Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
$tmpBin = Join-Path ([System.IO.Path]::GetTempPath()) "dws-build-$PID.exe"
try {
& go build -ldflags="-s -w" -o $tmpBin "$Root/cmd"
$destBin = Join-Path $InstallDir "${BinName}.exe"
Copy-Item -Path $tmpBin -Destination $destBin -Force
Write-Say "✅ Binary installed:"
Write-Say " → $destBin"
} finally {
Remove-Item -Path $tmpBin -Force -ErrorAction SilentlyContinue
}
}
# ── Install Skills from Remote ───────────────────────────────────────────────
function Install-Skills {
Write-Say ""
Write-Say "📦 Installing agent skills from GitHub Releases..."
Resolve-LatestVersion
if ($GiteeRepo -ne "") { $zipUrl = Get-GiteeAssetUrl "dws-skills.zip" } else { $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" }
if (-not $zipUrl) { Write-Err "Could not resolve download URL for dws-skills.zip (version $Version)." }
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "dws-skills-$PID"
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
try {
$zipPath = Join-Path $tmpDir "repo.zip"
try {
Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing
} catch {
Write-Say "⚠️ Release asset download failed. Trying local source..."
$localRoot = Resolve-SourceRoot
if ($localRoot) {
Install-SkillsLocal -Root $localRoot
return
} else {
Write-Err "Cannot download skills from GitHub and no local source checkout found."
}
}
Assert-ReleaseAssetChecksum -AssetPath $zipPath -AssetName "dws-skills.zip" -TempDir $tmpDir
$extractRoot = Join-Path $tmpDir "skills"
Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force
# Prefer the explicit mono/ subtree; fall back to legacy nested or zip root.
$skillSrc = $extractRoot
$monoRoot = Join-Path $extractRoot "mono"
if ((Test-Path $monoRoot) -and (Test-Path (Join-Path $monoRoot "SKILL.md"))) {
$skillSrc = $monoRoot
} elseif (Test-Path (Join-Path $extractRoot "$SkillName\SKILL.md")) {
$skillSrc = Join-Path $extractRoot $SkillName
}
# Multi first: a release may ship only the multi\ tree without the
# root mono copy, so the mono SKILL.md gate must never block a multi
# install. An empty/corrupt multi\ tree (no *\SKILL.md) falls back to
# mono with a warning — installing it would wipe existing skills and
# lay down nothing.
$multiRoot = Join-Path $extractRoot "multi"
if ($SkillMode -eq "multi" -and (Test-MultiTreeHasSkills $multiRoot)) {
if (!(Install-MultiSkillsToHomes -MultiSrc $multiRoot -Root $HOME)) {
throw "multi Skill installation failed"
}
} else {
if ($SkillMode -eq "multi") {
Write-Say "⚠️ multi skill tree not found or empty in release asset; falling back to mono."
}
if (!(Test-Path (Join-Path $skillSrc "SKILL.md"))) {
Write-Say "⚠️ Skills not found in release asset. Trying local source..."
$localRoot = Resolve-SourceRoot
if ($localRoot) {
Install-SkillsLocal -Root $localRoot
return
}
Write-Say "⚠️ No local source found either. Skipping skills installation."
return
}
if (!(Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME)) {
throw "mono Skill installation failed"
}
}
# Cache the multi/ tree (and a mono copy) under ~/.dws/skills so that
# subsequent `dws skill setup --mode multi|mono` can find a source.
if (Test-Path $multiRoot) {
Cache-MultiSkills -Source $multiRoot
}
Cache-MonoSkills -Source $skillSrc
} finally {
Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ── Main ─────────────────────────────────────────────────────────────────────
$SourceRoot = Resolve-SourceRoot
Write-Banner
# Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable).
# Skipped when installing from a local source checkout (no download needed).
if (-not $SourceRoot) { Resolve-Source }
if (!$NoSkills) {
Resolve-SkillMode
}
if ($SourceRoot -and !$SkillsOnly -and ($Version -eq "latest")) {
Install-BinaryFromSource -Root $SourceRoot
if (!$NoSkills) {
Install-SkillsLocal -Root $SourceRoot
}
} elseif ($SkillsOnly) {
Install-Skills
} elseif ($NoSkills) {
Install-Binary
} else {
Install-Binary
Install-Skills
}
Write-Host ""
Write-Say "🎉 Installation complete!"
Write-Say ""
Write-Say "Next steps:"
if (!$SkillsOnly) {
Write-Say " $BinName version # verify installation"
Write-Say " $BinName auth login # authenticate with DingTalk"
}
Write-Say " $BinName --help # explore commands"
Write-Host ""