diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml
index fb81dda..c9fee88 100644
--- a/.github/workflows/pr_validation.yml
+++ b/.github/workflows/pr_validation.yml
@@ -98,6 +98,74 @@ jobs:
- name: "slopwatch analyze"
run: dotnet slopwatch analyze
+ script-lint:
+ name: Script Lint
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+
+ - name: "ShellCheck (bash)"
+ run: shellcheck scripts/install-skillserver.sh
+
+ - name: "Syntax check (PowerShell)"
+ shell: pwsh
+ run: |
+ $errors = $null
+ [System.Management.Automation.PSParser]::Tokenize(
+ (Get-Content -Raw scripts/install-skillserver.ps1), [ref]$errors) | Out-Null
+ if ($errors.Count -gt 0) {
+ $errors | ForEach-Object { Write-Error $_.Message }
+ exit 1
+ }
+ Write-Host "PowerShell syntax OK"
+
+ cli-publish:
+ name: CLI Publish Dry Run
+ runs-on: ubuntu-latest
+ needs: test
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+ with:
+ lfs: true
+ fetch-depth: 0
+
+ - name: "Install .NET SDK"
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7
+ with:
+ global-json-file: "./global.json"
+
+ - name: "Publish CLI (trimmed, linux-x64)"
+ run: |
+ dotnet publish src/Netclaw.SkillServer.Cli/Netclaw.SkillServer.Cli.csproj \
+ -c Release \
+ -r linux-x64 \
+ --self-contained \
+ /p:PublishSingleFile=true \
+ /p:TrimmerSingleWarn=false \
+ -o ./publish/linux-x64
+
+ - name: "Check trim warnings"
+ run: |
+ dotnet publish src/Netclaw.SkillServer.Cli/Netclaw.SkillServer.Cli.csproj \
+ -c Release \
+ -r linux-x64 \
+ --self-contained \
+ /p:PublishSingleFile=true \
+ /p:TrimmerSingleWarn=false \
+ /p:TreatWarningsAsErrors=true \
+ /warnAsError:IL2026 /warnAsError:IL2057 \
+ /warnAsError:IL2067 /warnAsError:IL2075 \
+ /warnAsError:IL2096 /warnAsError:IL3050
+
+ - name: "Smoke test"
+ run: |
+ ./publish/linux-x64/skillserver --version
+ ./publish/linux-x64/skillserver --help
+
docker:
name: Docker Build
runs-on: ubuntu-latest
diff --git a/.github/workflows/publish_container.yml b/.github/workflows/publish_container.yml
deleted file mode 100644
index ea0a82f..0000000
--- a/.github/workflows/publish_container.yml
+++ /dev/null
@@ -1,81 +0,0 @@
-name: Publish Container
-
-on:
- push:
- tags:
- - '*'
-
-permissions:
- contents: read
- packages: write
-
-env:
- REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository_owner }}/skillserver
-
-jobs:
- build-container:
- name: build-${{ matrix.arch }}
- runs-on: ubuntu-latest
- strategy:
- matrix:
- include:
- - arch: x64
- rid: linux-x64
- - arch: arm64
- rid: linux-arm64
-
- steps:
- - name: "Checkout"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- with:
- lfs: true
- fetch-depth: 0
-
- - name: "Install .NET SDK"
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7
- with:
- global-json-file: "./global.json"
-
- - name: "Login to GitHub Container Registry"
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: "Build and Push Container"
- run: |
- dotnet publish src/SkillServer/SkillServer.csproj \
- -c Release \
- /t:PublishContainer \
- /p:ContainerRegistry=${{ env.REGISTRY }} \
- /p:ContainerRepository=${{ env.IMAGE_NAME }} \
- /p:ContainerRuntimeIdentifier=${{ matrix.rid }} \
- /p:ContainerImageTags='"${{ github.ref_name }}-${{ matrix.arch }}"'
-
- create-manifest:
- name: create-manifest
- runs-on: ubuntu-latest
- needs: build-container
-
- steps:
- - name: "Login to GitHub Container Registry"
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: "Create and Push Multi-Arch Manifest"
- run: |
- docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} \
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-x64 \
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-arm64
-
- docker manifest create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-x64 \
- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-arm64
-
- docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
- docker manifest push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
diff --git a/.github/workflows/publish_nuget.yml b/.github/workflows/publish_nuget.yml
deleted file mode 100644
index 582ce23..0000000
--- a/.github/workflows/publish_nuget.yml
+++ /dev/null
@@ -1,74 +0,0 @@
-name: Publish NuGet
-
-on:
- push:
- tags:
- - '*'
-
-permissions:
- contents: write
- id-token: write
-
-jobs:
- publish-nuget:
-
- name: publish-nuget
- environment: nuget
- runs-on: ${{ matrix.os }}
- strategy:
- matrix:
- os: [ubuntu-latest]
-
- steps:
- - name: "Checkout"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- with:
- lfs: true
- fetch-depth: 0
-
- - name: "Install .NET SDK"
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7
- with:
- global-json-file: "./global.json"
-
- - name: "Restore .NET tools"
- run: dotnet tool restore
-
- - name: "Update release notes"
- shell: pwsh
- run: |
- ./build.ps1
-
- - name: Create Packages
- run: dotnet pack /p:PackageVersion=${{ github.ref_name }} -c Release -o ./output
-
- - name: NuGet login (OIDC → temp API key)
- uses: NuGet/login@v1
- id: nuget-login
- with:
- user: ${{ secrets.NUGET_USER }}
-
- - name: Push Packages
- run: |
- dotnet nuget push "output/*.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
- dotnet nuget push "output/*.snupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
-
- - name: "Extract latest release notes"
- shell: pwsh
- run: |
- $content = Get-Content RELEASE_NOTES.md -Raw
- # Match from the first #### heading to just before the second one
- if ($content -match '(?s)(####.+?)(?=\n####|\z)') {
- $Matches[1].Trim() | Set-Content RELEASE_NOTES_LATEST.md
- } else {
- $content | Set-Content RELEASE_NOTES_LATEST.md
- }
-
- - name: Create GitHub Release
- run: |
- gh release create "${{ github.ref_name }}" \
- --title "SkillServer ${{ github.ref_name }}" \
- --notes-file RELEASE_NOTES_LATEST.md \
- output/*.nupkg output/*.snupkg
- env:
- GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ab8b52b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,280 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - '*'
+
+permissions:
+ contents: write
+ packages: write
+ id-token: write
+
+env:
+ CONTAINER_REGISTRY: ghcr.io
+ CONTAINER_IMAGE: ${{ github.repository_owner }}/skillserver
+
+jobs:
+ # ─── Stage 1: Build everything in parallel ───────────────────────────
+
+ nuget-pack:
+ name: NuGet Pack
+ runs-on: ubuntu-latest
+ environment: nuget
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+ with:
+ lfs: true
+ fetch-depth: 0
+
+ - name: "Install .NET SDK"
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7
+ with:
+ global-json-file: "./global.json"
+
+ - name: "Restore .NET tools"
+ run: dotnet tool restore
+
+ - name: "Update release notes"
+ shell: pwsh
+ run: ./build.ps1
+
+ - name: "Pack"
+ run: dotnet pack /p:PackageVersion=${{ github.ref_name }} -c Release -o ./output
+
+ - name: "Upload artifacts"
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: nuget-packages
+ path: |
+ output/*.nupkg
+ output/*.snupkg
+ retention-days: 7
+
+ cli-binaries:
+ name: CLI-${{ matrix.rid }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: linux-x64
+ os: ubuntu-latest
+ archive: tar
+ - rid: linux-arm64
+ os: ubuntu-latest
+ archive: tar
+ - rid: osx-arm64
+ os: macos-latest
+ archive: tar
+ - rid: win-x64
+ os: windows-latest
+ archive: zip
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+ with:
+ lfs: true
+ fetch-depth: 0
+
+ - name: "Install .NET SDK"
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7
+ with:
+ global-json-file: "./global.json"
+
+ - name: "Restore .NET tools"
+ run: dotnet tool restore
+
+ - name: "Update release notes"
+ shell: pwsh
+ run: ./build.ps1
+
+ - name: "Set version"
+ shell: bash
+ run: |
+ VERSION=${{ github.ref_name }}
+ VERSION=${VERSION#v}
+ echo "VERSION=$VERSION" >> $GITHUB_ENV
+ ASSEMBLY_VERSION=$(echo $VERSION | cut -d'-' -f1)
+ echo "ASSEMBLY_VERSION=$ASSEMBLY_VERSION" >> $GITHUB_ENV
+
+ - name: "Publish CLI"
+ shell: bash
+ run: |
+ dotnet publish src/Netclaw.SkillServer.Cli/Netclaw.SkillServer.Cli.csproj \
+ -c Release \
+ -r ${{ matrix.rid }} \
+ --self-contained \
+ /p:PublishSingleFile=true \
+ /p:Version=${{ env.VERSION }} \
+ /p:AssemblyVersion=${{ env.ASSEMBLY_VERSION }} \
+ /p:FileVersion=${{ env.ASSEMBLY_VERSION }} \
+ -o ./publish
+
+ - name: "Smoke test"
+ if: matrix.rid != 'linux-arm64'
+ shell: bash
+ run: |
+ if [[ "${{ matrix.rid }}" == win-* ]]; then
+ ./publish/skillserver.exe --version
+ ./publish/skillserver.exe --help
+ else
+ ./publish/skillserver --version
+ ./publish/skillserver --help
+ fi
+
+ - name: "Package (tar.gz)"
+ if: matrix.archive == 'tar'
+ shell: bash
+ run: |
+ cd publish
+ tar -czf ../skillserver-${{ env.VERSION }}-${{ matrix.rid }}.tar.gz *
+ cd ..
+ sha256sum skillserver-${{ env.VERSION }}-${{ matrix.rid }}.tar.gz \
+ > skillserver-${{ env.VERSION }}-${{ matrix.rid }}.tar.gz.sha256
+
+ - name: "Package (zip)"
+ if: matrix.archive == 'zip'
+ shell: pwsh
+ run: |
+ Compress-Archive -Path publish/* -DestinationPath skillserver-${{ env.VERSION }}-${{ matrix.rid }}.zip
+ $hash = (Get-FileHash -Algorithm SHA256 skillserver-${{ env.VERSION }}-${{ matrix.rid }}.zip).Hash.ToLower()
+ "$hash skillserver-${{ env.VERSION }}-${{ matrix.rid }}.zip" | Set-Content skillserver-${{ env.VERSION }}-${{ matrix.rid }}.zip.sha256
+
+ - name: "Upload artifacts"
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: cli-${{ matrix.rid }}
+ path: |
+ skillserver-${{ env.VERSION }}-${{ matrix.rid }}.*
+ retention-days: 7
+
+ container-images:
+ name: Container-${{ matrix.arch }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ rid: linux-x64
+ - arch: arm64
+ rid: linux-arm64
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+ with:
+ lfs: true
+ fetch-depth: 0
+
+ - name: "Install .NET SDK"
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7
+ with:
+ global-json-file: "./global.json"
+
+ - name: "Login to GitHub Container Registry"
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121
+ with:
+ registry: ${{ env.CONTAINER_REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: "Build and Push Container"
+ run: |
+ dotnet publish src/SkillServer/SkillServer.csproj \
+ -c Release \
+ /t:PublishContainer \
+ /p:ContainerRegistry=${{ env.CONTAINER_REGISTRY }} \
+ /p:ContainerRepository=${{ env.CONTAINER_IMAGE }} \
+ /p:ContainerRuntimeIdentifier=${{ matrix.rid }} \
+ /p:ContainerImageTags='"${{ github.ref_name }}-${{ matrix.arch }}"'
+
+ # ─── Stage 2: Publish everything once all builds succeed ─────────────
+
+ publish-release:
+ name: Publish Release
+ runs-on: ubuntu-latest
+ needs: [nuget-pack, cli-binaries, container-images]
+ environment: nuget
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+ with:
+ fetch-depth: 0
+
+ - name: "Install .NET SDK"
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7
+ with:
+ global-json-file: "./global.json"
+
+ - name: "Download NuGet packages"
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
+ with:
+ name: nuget-packages
+ path: ./nuget
+
+ - name: "Download CLI binaries"
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
+ with:
+ pattern: cli-*
+ merge-multiple: true
+ path: ./cli
+
+ - name: "NuGet login (OIDC)"
+ uses: NuGet/login@v1
+ id: nuget-login
+ with:
+ user: ${{ secrets.NUGET_USER }}
+
+ - name: "Push NuGet packages"
+ run: |
+ dotnet nuget push "nuget/*.nupkg" \
+ --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} \
+ --source https://api.nuget.org/v3/index.json \
+ --skip-duplicate
+ dotnet nuget push "nuget/*.snupkg" \
+ --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} \
+ --source https://api.nuget.org/v3/index.json \
+ --skip-duplicate
+
+ - name: "Create Docker multi-arch manifest"
+ run: |
+ echo "${{ secrets.GITHUB_TOKEN }}" | \
+ docker login ${{ env.CONTAINER_REGISTRY }} -u ${{ github.actor }} --password-stdin
+
+ docker manifest create \
+ ${{ env.CONTAINER_REGISTRY }}/${{ env.CONTAINER_IMAGE }}:${{ github.ref_name }} \
+ ${{ env.CONTAINER_REGISTRY }}/${{ env.CONTAINER_IMAGE }}:${{ github.ref_name }}-x64 \
+ ${{ env.CONTAINER_REGISTRY }}/${{ env.CONTAINER_IMAGE }}:${{ github.ref_name }}-arm64
+
+ docker manifest create \
+ ${{ env.CONTAINER_REGISTRY }}/${{ env.CONTAINER_IMAGE }}:latest \
+ ${{ env.CONTAINER_REGISTRY }}/${{ env.CONTAINER_IMAGE }}:${{ github.ref_name }}-x64 \
+ ${{ env.CONTAINER_REGISTRY }}/${{ env.CONTAINER_IMAGE }}:${{ github.ref_name }}-arm64
+
+ docker manifest push ${{ env.CONTAINER_REGISTRY }}/${{ env.CONTAINER_IMAGE }}:${{ github.ref_name }}
+ docker manifest push ${{ env.CONTAINER_REGISTRY }}/${{ env.CONTAINER_IMAGE }}:latest
+
+ - name: "Extract release notes"
+ shell: pwsh
+ run: |
+ $content = Get-Content RELEASE_NOTES.md -Raw
+ if ($content -match '(?s)(####.+?)(?=\n####|\z)') {
+ $Matches[1].Trim() | Set-Content RELEASE_NOTES_LATEST.md
+ } else {
+ $content | Set-Content RELEASE_NOTES_LATEST.md
+ }
+
+ - name: "Create GitHub Release"
+ run: |
+ gh release create "${{ github.ref_name }}" \
+ --title "SkillServer ${{ github.ref_name }}" \
+ --notes-file RELEASE_NOTES_LATEST.md \
+ nuget/*.nupkg nuget/*.snupkg cli/*
+ env:
+ GH_TOKEN: ${{ github.token }}
diff --git a/README.md b/README.md
index d72ffe1..59cc7a9 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
# SkillServer
-[](https://www.nuget.org/packages/Netclaw.SkillClient)
+[](https://www.nuget.org/packages/Netclaw.SkillClient)
+[](https://www.nuget.org/packages/Netclaw.SkillServer.Cli)
[](https://ghcr.io/netclaw-dev/skillserver)
A self-hosted skill server for managing AI agent skills internally within organizations. Similar to self-hosted package registries (BaGet for NuGet, Verdaccio for npm, Docker Registry), SkillServer enables companies to:
@@ -10,6 +11,14 @@ A self-hosted skill server for managing AI agent skills internally within organi
- Version skills with full history
- Integrate with NetClaw CLI and other AgentSkills.io-compatible agents
+This repository contains three components:
+
+| Component | Description | Install |
+|-----------|-------------|---------|
+| **SkillServer** | Self-hosted skill registry (web server) | `docker pull ghcr.io/netclaw-dev/skillserver` |
+| **skillserver CLI** | Command-line tool for publishing and managing skills | `dotnet tool install -g Netclaw.SkillServer.Cli` |
+| **Netclaw.SkillClient** | Typed .NET client library | `dotnet add package Netclaw.SkillClient` |
+
## Standards Support
SkillServer implements two complementary standards:
@@ -90,19 +99,43 @@ Configuration is via environment variables or `appsettings.json`:
|----------|-------------|
| `GET /health` | Health check |
-## Uploading Skills
+## CLI Tool
-Upload a SKILL.md file:
+The `skillserver` CLI is the recommended way to publish and manage skills.
+
+### Install
```bash
-curl -X POST http://localhost:8080/skills \
- -H "Authorization: Bearer sk-your-api-key" \
- -F "name=my-skill" \
- -F "version=1.0.0" \
- -F "category=internal" \
- -F "file=@SKILL.md"
+# .NET global tool
+dotnet tool install --global Netclaw.SkillServer.Cli
+
+# Or standalone binary (Linux/macOS)
+curl -fsSL https://raw.githubusercontent.com/netclaw-dev/skill-server/dev/scripts/install-skillserver.sh | bash
+
+# Or standalone binary (Windows PowerShell)
+iwr -useb https://raw.githubusercontent.com/netclaw-dev/skill-server/dev/scripts/install-skillserver.ps1 | iex
```
+### Usage
+
+```bash
+# Configure
+skillserver config init
+
+# Publish a skill
+skillserver publish ./my-skill
+
+# Batch publish
+skillserver publish-all ./skills
+
+# List, search, verify, delete
+skillserver list --search kubernetes
+skillserver verify ./my-skill
+skillserver delete my-skill 1.0.0 --yes
+```
+
+See the [CLI README](src/Netclaw.SkillServer.Cli/README.md) for the full command reference.
+
## Client Library
The `Netclaw.SkillClient` NuGet package provides a typed .NET client for SkillServer.
diff --git a/SkillServer.slnx b/SkillServer.slnx
index ade5f1c..fc7f945 100644
--- a/SkillServer.slnx
+++ b/SkillServer.slnx
@@ -9,11 +9,13 @@
+
+
diff --git a/scripts/install-skillserver.ps1 b/scripts/install-skillserver.ps1
new file mode 100644
index 0000000..0b0cd65
--- /dev/null
+++ b/scripts/install-skillserver.ps1
@@ -0,0 +1,114 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+ Install the skillserver CLI on Windows.
+.DESCRIPTION
+ Downloads and installs the skillserver CLI binary from GitHub Releases.
+ No administrator permissions required - installs to your home directory.
+.PARAMETER Version
+ Version to install. Defaults to "latest".
+.PARAMETER InstallDir
+ Installation directory. Defaults to $env:LOCALAPPDATA\Programs\skillserver.
+.EXAMPLE
+ iwr -useb https://raw.githubusercontent.com/netclaw-dev/skill-server/dev/scripts/install-skillserver.ps1 | iex
+.EXAMPLE
+ .\install-skillserver.ps1 -Version 0.2.1
+#>
+param(
+ [string]$Version = "latest",
+ [string]$InstallDir = ""
+)
+
+$ErrorActionPreference = "Stop"
+$Repo = "netclaw-dev/skill-server"
+$BinaryName = "skillserver"
+$Rid = "win-x64"
+
+if (-not $InstallDir) {
+ $InstallDir = Join-Path $env:LOCALAPPDATA "Programs" $BinaryName
+}
+
+function Resolve-LatestVersion {
+ if ($script:Version -eq "latest") {
+ try {
+ $response = Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" `
+ -MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing
+ if ($response.StatusCode -eq 302) {
+ $redirectUrl = $response.Headers.Location
+ } else {
+ $redirectUrl = $response.BaseResponse.ResponseUri.AbsoluteUri
+ }
+ } catch {
+ $redirectUrl = $_.Exception.Response.Headers.Location.AbsoluteUri
+ }
+ $script:Version = ($redirectUrl -split '/')[-1]
+ if (-not $script:Version) {
+ throw "Could not determine latest version"
+ }
+ }
+ $script:Version = $script:Version.TrimStart('v')
+}
+
+function Main {
+ Write-Host "Installing $BinaryName..."
+
+ Resolve-LatestVersion
+
+ $archiveName = "$BinaryName-$Version-$Rid.zip"
+ $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName"
+ $checksumUrl = "$downloadUrl.sha256"
+
+ Write-Host " Platform: $Rid"
+ Write-Host " Version: $Version"
+ Write-Host " Directory: $InstallDir"
+ Write-Host ""
+
+ $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())
+ New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
+
+ try {
+ Write-Host " Downloading $archiveName..."
+ try {
+ Invoke-WebRequest -Uri $downloadUrl -OutFile (Join-Path $tmpDir $archiveName) -UseBasicParsing
+ } catch {
+ Write-Error "Failed to download $archiveName from $downloadUrl"
+ return
+ }
+
+ Write-Host " Verifying checksum..."
+ try {
+ Invoke-WebRequest -Uri $checksumUrl -OutFile (Join-Path $tmpDir "$archiveName.sha256") -UseBasicParsing
+ $expectedLine = Get-Content (Join-Path $tmpDir "$archiveName.sha256") -Raw
+ $expected = ($expectedLine -split '\s+')[0].ToLower()
+ $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $tmpDir $archiveName)).Hash.ToLower()
+ if ($expected -ne $actual) {
+ throw "Checksum mismatch: expected $expected, got $actual"
+ }
+ } catch [System.Net.WebException] {
+ Write-Warning "Checksum file not available, skipping verification"
+ }
+
+ Write-Host " Extracting..."
+ if (-not (Test-Path $InstallDir)) {
+ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
+ }
+ Expand-Archive -Path (Join-Path $tmpDir $archiveName) -DestinationPath $InstallDir -Force
+
+ $userPath = [Environment]::GetEnvironmentVariable("PATH", "User")
+ if ($userPath -notlike "*$InstallDir*") {
+ [Environment]::SetEnvironmentVariable("PATH", "$userPath;$InstallDir", "User")
+ $env:PATH = "$env:PATH;$InstallDir"
+ Write-Host " Added $InstallDir to user PATH"
+ }
+
+ Write-Host ""
+ Write-Host " Installed $BinaryName $Version to $InstallDir\$BinaryName.exe"
+ Write-Host ""
+ Write-Host " Run '$BinaryName --version' to verify."
+ Write-Host " You may need to restart your terminal for PATH changes to take effect."
+ } finally {
+ Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+}
+
+Main
diff --git a/scripts/install-skillserver.sh b/scripts/install-skillserver.sh
new file mode 100755
index 0000000..639f78e
--- /dev/null
+++ b/scripts/install-skillserver.sh
@@ -0,0 +1,132 @@
+#!/usr/bin/env bash
+#
+# Usage:
+# curl -fsSL https://raw.githubusercontent.com/netclaw-dev/skill-server/dev/scripts/install-skillserver.sh | bash
+# ./install-skillserver.sh
+# ./install-skillserver.sh 0.2.1
+# INSTALL_DIR=/custom/path ./install-skillserver.sh
+#
+set -euo pipefail
+
+REPO="netclaw-dev/skill-server"
+BINARY_NAME="skillserver"
+INSTALL_DIR="${INSTALL_DIR:-${HOME}/.local/bin}"
+VERSION="${1:-latest}"
+
+detect_platform() {
+ local os arch
+ os="$(uname -s)"
+ arch="$(uname -m)"
+
+ case "$os" in
+ Linux) os="linux" ;;
+ Darwin) os="osx" ;;
+ *)
+ echo "Error: Unsupported OS: $os" >&2
+ exit 1
+ ;;
+ esac
+
+ case "$arch" in
+ x86_64|amd64) arch="x64" ;;
+ aarch64|arm64) arch="arm64" ;;
+ *)
+ echo "Error: Unsupported architecture: $arch" >&2
+ exit 1
+ ;;
+ esac
+
+ echo "${os}-${arch}"
+}
+
+resolve_version() {
+ if [ "$VERSION" = "latest" ]; then
+ VERSION=$(curl -sI "https://github.com/${REPO}/releases/latest" \
+ | grep -i "^location:" \
+ | sed 's|.*/||' \
+ | tr -d '\r\n')
+
+ if [ -z "$VERSION" ]; then
+ echo "Error: Could not determine latest version" >&2
+ exit 1
+ fi
+ fi
+ VERSION="${VERSION#v}"
+}
+
+main() {
+ local rid archive_name download_url checksum_url tmp_dir
+
+ echo "Installing ${BINARY_NAME}..."
+
+ rid=$(detect_platform)
+ resolve_version
+
+ archive_name="${BINARY_NAME}-${VERSION}-${rid}.tar.gz"
+ download_url="https://github.com/${REPO}/releases/download/${VERSION}/${archive_name}"
+ checksum_url="${download_url}.sha256"
+
+ tmp_dir=$(mktemp -d)
+ trap 'rm -rf "$tmp_dir"' EXIT
+
+ echo " Platform: ${rid}"
+ echo " Version: ${VERSION}"
+ echo " Directory: ${INSTALL_DIR}"
+ echo ""
+
+ echo " Downloading ${archive_name}..."
+ if ! curl -fsSL -o "${tmp_dir}/${archive_name}" "$download_url"; then
+ echo "Error: Failed to download ${archive_name}" >&2
+ echo " URL: ${download_url}" >&2
+ echo " Available platforms: linux-x64, linux-arm64, osx-arm64" >&2
+ exit 1
+ fi
+
+ echo " Verifying checksum..."
+ if curl -fsSL -o "${tmp_dir}/${archive_name}.sha256" "$checksum_url" 2>/dev/null; then
+ cd "$tmp_dir"
+ if command -v sha256sum > /dev/null 2>&1; then
+ sha256sum -c "${archive_name}.sha256" --quiet
+ elif command -v shasum > /dev/null 2>&1; then
+ expected=$(awk '{print $1}' "${archive_name}.sha256")
+ actual=$(shasum -a 256 "${archive_name}" | awk '{print $1}')
+ if [ "$expected" != "$actual" ]; then
+ echo "Error: Checksum mismatch" >&2
+ exit 1
+ fi
+ fi
+ cd - > /dev/null
+ else
+ echo " Warning: Checksum file not available, skipping verification" >&2
+ fi
+
+ echo " Extracting..."
+ tar -xzf "${tmp_dir}/${archive_name}" -C "$tmp_dir"
+
+ mkdir -p "${INSTALL_DIR}"
+ cp "${tmp_dir}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
+ chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
+
+ echo ""
+ echo " Installed ${BINARY_NAME} ${VERSION} to ${INSTALL_DIR}/${BINARY_NAME}"
+
+ case ":${PATH}:" in
+ *":${INSTALL_DIR}:"*) ;;
+ *)
+ echo ""
+ echo " Warning: ${INSTALL_DIR} is not in your PATH."
+ echo " Add it by running:"
+ echo ""
+ if [ -n "${ZSH_VERSION:-}" ] || [ -f "${HOME}/.zshrc" ]; then
+ echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.zshrc && source ~/.zshrc"
+ else
+ echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc && source ~/.bashrc"
+ fi
+ ;;
+ esac
+
+ echo ""
+ echo " Run '${BINARY_NAME} --version' to verify."
+}
+
+main
diff --git a/src/Netclaw.SkillClient/Models.cs b/src/Netclaw.SkillClient/Models.cs
index c2e4a7b..bba8800 100644
--- a/src/Netclaw.SkillClient/Models.cs
+++ b/src/Netclaw.SkillClient/Models.cs
@@ -199,6 +199,24 @@ public sealed record ApiKeySummary
public DateTimeOffset? ExpiresAt { get; init; }
}
+public sealed record CreateApiKeyRequest
+{
+ [JsonPropertyName("label")]
+ public required string Label { get; init; }
+
+ [JsonPropertyName("expiresAt")]
+ public DateTimeOffset? ExpiresAt { get; init; }
+}
+
+public sealed record ErrorResponse
+{
+ [JsonPropertyName("error")]
+ public string Error { get; init; } = "";
+
+ [JsonPropertyName("message")]
+ public string Message { get; init; } = "";
+}
+
///
/// JSON serialization context for AOT support.
///
@@ -212,5 +230,7 @@ public sealed record ApiKeySummary
[JsonSerializable(typeof(IReadOnlyList))]
[JsonSerializable(typeof(IReadOnlyList))]
[JsonSerializable(typeof(IReadOnlyList))]
+[JsonSerializable(typeof(CreateApiKeyRequest))]
+[JsonSerializable(typeof(ErrorResponse))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
-public partial class SkillServerClientJsonContext : JsonSerializerContext;
+public sealed partial class SkillServerClientJsonContext : JsonSerializerContext;
diff --git a/src/Netclaw.SkillClient/SkillServerClient.cs b/src/Netclaw.SkillClient/SkillServerClient.cs
index 750df1c..b3e3fdc 100644
--- a/src/Netclaw.SkillClient/SkillServerClient.cs
+++ b/src/Netclaw.SkillClient/SkillServerClient.cs
@@ -3,6 +3,7 @@
// Copyright (C) 2026 - 2026 Petabridge, LLC
//
// -----------------------------------------------------------------------
+using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
@@ -194,6 +195,40 @@ public async Task> CheckUpdatesAsync(
public async Task UploadSkillAsync(
string name, string version, Stream skillMdContent, string? category = null,
CancellationToken ct = default)
+ {
+ return await UploadSkillWithResourcesAsync(name, version, skillMdContent, [], category, ct);
+ }
+
+ public async Task UploadSkillWithResourcesAsync(
+ string name, string version, Stream skillMdContent,
+ IReadOnlyList<(string RelativePath, Stream Content)> resources,
+ string? category = null, CancellationToken ct = default)
+ {
+ using var response = await PostSkillAsync(name, version, skillMdContent, resources, category, ct);
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync(
+ SkillServerClientJsonContext.Default.SkillUploadResponse, ct))!;
+ }
+
+ public async Task UploadSkillIfNotExistsAsync(
+ string name, string version, Stream skillMdContent,
+ IReadOnlyList<(string RelativePath, Stream Content)> resources,
+ string? category = null, CancellationToken ct = default)
+ {
+ using var response = await PostSkillAsync(name, version, skillMdContent, resources, category, ct);
+
+ if (response.StatusCode == HttpStatusCode.Conflict)
+ return null;
+
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync(
+ SkillServerClientJsonContext.Default.SkillUploadResponse, ct))!;
+ }
+
+ private async Task PostSkillAsync(
+ string name, string version, Stream skillMdContent,
+ IReadOnlyList<(string RelativePath, Stream Content)> resources,
+ string? category, CancellationToken ct)
{
using var content = new MultipartFormDataContent();
content.Add(new StringContent(name), "name");
@@ -202,10 +237,10 @@ public async Task UploadSkillAsync(
content.Add(new StringContent(category), "category");
content.Add(new StreamContent(skillMdContent), "file", "SKILL.md");
- var response = await _httpClient.PostAsync("skills", content, ct);
- response.EnsureSuccessStatusCode();
- return (await response.Content.ReadFromJsonAsync(
- SkillServerClientJsonContext.Default.SkillUploadResponse, ct))!;
+ foreach (var (relativePath, resourceStream) in resources)
+ content.Add(new StreamContent(resourceStream), "resources", relativePath);
+
+ return await _httpClient.PostAsync("skills", content, ct);
}
public async Task DeleteVersionAsync(string name, string version, CancellationToken ct = default)
@@ -215,6 +250,30 @@ public async Task DeleteVersionAsync(string name, string version, CancellationTo
response.EnsureSuccessStatusCode();
}
+ public async Task CreateApiKeyAsync(
+ string label, DateTimeOffset? expiresAt = null, CancellationToken ct = default)
+ {
+ var request = new CreateApiKeyRequest { Label = label, ExpiresAt = expiresAt };
+ var response = await _httpClient.PostAsJsonAsync("api-keys",
+ request, SkillServerClientJsonContext.Default.CreateApiKeyRequest, ct);
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync(
+ SkillServerClientJsonContext.Default.CreateApiKeyResponse, ct))!;
+ }
+
+ public async Task> ListApiKeysAsync(CancellationToken ct = default)
+ {
+ var result = await _httpClient.GetFromJsonAsync("api-keys",
+ SkillServerClientJsonContext.Default.IReadOnlyListApiKeySummary, ct);
+ return result ?? [];
+ }
+
+ public async Task DeleteApiKeyAsync(long id, CancellationToken ct = default)
+ {
+ var response = await _httpClient.DeleteAsync($"api-keys/{id}", ct);
+ response.EnsureSuccessStatusCode();
+ }
+
public void Dispose()
{
if (_ownsHttpClient)
diff --git a/src/Netclaw.SkillServer.Cli/CliArgsParser.cs b/src/Netclaw.SkillServer.Cli/CliArgsParser.cs
new file mode 100644
index 0000000..2b4c414
--- /dev/null
+++ b/src/Netclaw.SkillServer.Cli/CliArgsParser.cs
@@ -0,0 +1,169 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+
+namespace Netclaw.SkillServer.Cli;
+
+internal sealed class ParsedArgs
+{
+ public string Command { get; init; } = "";
+ public string SubCommand { get; init; } = "";
+ public IReadOnlyList Positional { get; init; } = [];
+ public string? ServerUrl { get; init; }
+ public string? ApiKey { get; init; }
+ public string? OutputFormat { get; init; }
+ public bool Verbose { get; init; }
+ public bool Help { get; init; }
+ public bool Version { get; init; }
+ public bool Force { get; init; }
+ public bool DryRun { get; init; }
+ public bool Yes { get; init; }
+ public string? VersionOverride { get; init; }
+ public string? Search { get; init; }
+ public int? Skip { get; init; }
+ public int? Take { get; init; }
+ public string? Label { get; init; }
+ public string? ExpiresAt { get; init; }
+ public string? Key { get; init; }
+ public string? Value { get; init; }
+}
+
+internal static class CliArgsParser
+{
+ public static ParsedArgs Parse(string[] args)
+ {
+ var positional = new List();
+ string? serverUrl = null, apiKey = null, outputFormat = null;
+ string? versionOverride = null, search = null, label = null, expiresAt = null;
+ string? configKey = null, configValue = null;
+ int? skip = null, take = null;
+ bool verbose = false, help = false, version = false, force = false, dryRun = false, yes = false;
+
+ var command = "";
+ var subCommand = "";
+ var commandParsed = false;
+
+ for (var i = 0; i < args.Length; i++)
+ {
+ var arg = args[i];
+
+ if (!commandParsed && !arg.StartsWith('-'))
+ {
+ if (command == "")
+ {
+ command = arg.ToLowerInvariant();
+
+ if (command is "api-key" or "config" or "publish-all")
+ {
+ if (command is "api-key" or "config" && i + 1 < args.Length && !args[i + 1].StartsWith('-'))
+ {
+ subCommand = args[++i].ToLowerInvariant();
+ }
+
+ commandParsed = true;
+ }
+ else
+ {
+ commandParsed = true;
+ }
+
+ continue;
+ }
+ }
+
+ switch (arg)
+ {
+ case "--server-url" when i + 1 < args.Length:
+ serverUrl = args[++i];
+ break;
+ case "--api-key" when i + 1 < args.Length:
+ apiKey = args[++i];
+ break;
+ case "--output" when i + 1 < args.Length:
+ outputFormat = args[++i].ToLowerInvariant();
+ break;
+ case "--version" when command == "":
+ version = true;
+ break;
+ case "--version" when i + 1 < args.Length:
+ versionOverride = args[++i];
+ break;
+ case "--search" when i + 1 < args.Length:
+ search = args[++i];
+ break;
+ case "--skip" when i + 1 < args.Length:
+ if (int.TryParse(args[++i], out var s)) skip = s;
+ break;
+ case "--take" when i + 1 < args.Length:
+ if (int.TryParse(args[++i], out var t)) take = t;
+ break;
+ case "--label" when i + 1 < args.Length:
+ label = args[++i];
+ break;
+ case "--expires-at" when i + 1 < args.Length:
+ expiresAt = args[++i];
+ break;
+ case "--verbose" or "-v":
+ verbose = true;
+ break;
+ case "--help" or "-h":
+ help = true;
+ break;
+ case "--force" or "-f":
+ force = true;
+ break;
+ case "--dry-run":
+ dryRun = true;
+ break;
+ case "--yes" or "-y":
+ yes = true;
+ break;
+ case "-V":
+ version = true;
+ break;
+ default:
+ if (!arg.StartsWith('-'))
+ {
+ if (command == "config" && subCommand == "set")
+ {
+ if (configKey is null)
+ configKey = arg;
+ else
+ configValue ??= arg;
+ }
+ else
+ {
+ positional.Add(arg);
+ }
+ }
+ break;
+ }
+ }
+
+ return new ParsedArgs
+ {
+ Command = command,
+ SubCommand = subCommand,
+ Positional = positional,
+ ServerUrl = serverUrl,
+ ApiKey = apiKey,
+ OutputFormat = outputFormat,
+ Verbose = verbose,
+ Help = help,
+ Version = version,
+ Force = force,
+ DryRun = dryRun,
+ Yes = yes,
+ VersionOverride = versionOverride,
+ Search = search,
+ Skip = skip,
+ Take = take,
+ Label = label,
+ ExpiresAt = expiresAt,
+ Key = configKey,
+ Value = configValue
+ };
+ }
+}
diff --git a/src/Netclaw.SkillServer.Cli/Commands/ApiKeyCommand.cs b/src/Netclaw.SkillServer.Cli/Commands/ApiKeyCommand.cs
new file mode 100644
index 0000000..836dc30
--- /dev/null
+++ b/src/Netclaw.SkillServer.Cli/Commands/ApiKeyCommand.cs
@@ -0,0 +1,153 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+
+using System.Text.Json;
+using Netclaw.SkillClient;
+using Netclaw.SkillServer.Cli.Json;
+using Netclaw.SkillServer.Cli.Output;
+
+namespace Netclaw.SkillServer.Cli.Commands;
+
+internal static class ApiKeyCommand
+{
+ public static async Task ExecuteAsync(ParsedArgs args, SkillServerClient client)
+ {
+ if (args.Help)
+ {
+ PrintHelp();
+ return 0;
+ }
+
+ return args.SubCommand switch
+ {
+ "create" => await Create(args, client),
+ "list" => await List(args, client),
+ "delete" => await Delete(args, client),
+ _ => ShowSubcommandHelp()
+ };
+ }
+
+ private static async Task Create(ParsedArgs args, SkillServerClient client)
+ {
+ var label = args.Label;
+ if (string.IsNullOrWhiteSpace(label))
+ {
+ ConsoleOutput.WriteError("Error: --label is required.");
+ return 1;
+ }
+
+ DateTimeOffset? expiresAt = null;
+ if (!string.IsNullOrWhiteSpace(args.ExpiresAt))
+ {
+ if (DateTimeOffset.TryParse(args.ExpiresAt, out var parsed))
+ expiresAt = parsed;
+ else
+ {
+ ConsoleOutput.WriteError($"Error: Invalid date format for --expires-at: '{args.ExpiresAt}'");
+ return 1;
+ }
+ }
+
+ try
+ {
+ var response = await client.CreateApiKeyAsync(label, expiresAt);
+ ConsoleOutput.WriteSuccess($"Created API key: {response.Key}");
+ ConsoleOutput.WriteInfo($"Label: {response.Label}");
+ ConsoleOutput.WriteInfo($"ID: {response.Id}");
+ return 0;
+ }
+ catch (HttpRequestException ex)
+ {
+ return ConsoleOutput.HandleHttpError(ex);
+ }
+ }
+
+ private static async Task List(ParsedArgs args, SkillServerClient client)
+ {
+ try
+ {
+ var keys = await client.ListApiKeysAsync();
+
+ if (args.OutputFormat == "json")
+ {
+ var json = JsonSerializer.Serialize(keys,
+ CliJsonContext.Default.IReadOnlyListApiKeySummary);
+ Console.WriteLine(json);
+ return 0;
+ }
+
+ if (keys.Count == 0)
+ {
+ ConsoleOutput.WriteInfo("No API keys found.");
+ return 0;
+ }
+
+ var headers = new[] { "ID", "LABEL", "CREATED", "EXPIRES" };
+ var rows = keys.Select(k => new[]
+ {
+ k.Id.ToString(),
+ k.Label,
+ k.CreatedAt.ToString("yyyy-MM-dd"),
+ k.ExpiresAt?.ToString("yyyy-MM-dd") ?? "never"
+ }).ToList();
+
+ ConsoleOutput.WriteTable(headers, rows);
+ return 0;
+ }
+ catch (HttpRequestException ex)
+ {
+ return ConsoleOutput.HandleHttpError(ex);
+ }
+ }
+
+ private static async Task Delete(ParsedArgs args, SkillServerClient client)
+ {
+ if (args.Positional.Count == 0)
+ {
+ ConsoleOutput.WriteError("Usage: skillserver api-key delete ");
+ return 1;
+ }
+
+ if (!long.TryParse(args.Positional[0], out var id))
+ {
+ ConsoleOutput.WriteError($"Error: Invalid API key ID: '{args.Positional[0]}'");
+ return 1;
+ }
+
+ try
+ {
+ await client.DeleteApiKeyAsync(id);
+ ConsoleOutput.WriteSuccess($"Deleted API key {id}");
+ return 0;
+ }
+ catch (HttpRequestException ex)
+ {
+ return ConsoleOutput.HandleHttpError(ex);
+ }
+ }
+
+ private static int ShowSubcommandHelp()
+ {
+ PrintHelp();
+ return 1;
+ }
+
+ private static void PrintHelp()
+ {
+ Console.WriteLine("Usage: skillserver api-key [options]");
+ Console.WriteLine();
+ Console.WriteLine("Manage server API keys.");
+ Console.WriteLine();
+ Console.WriteLine("Subcommands:");
+ Console.WriteLine(" create Create a new API key");
+ Console.WriteLine(" list List all API keys");
+ Console.WriteLine(" delete Delete an API key");
+ Console.WriteLine();
+ Console.WriteLine("Create options:");
+ Console.WriteLine(" --label