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 -[![NuGet](https://img.shields.io/nuget/v/Netclaw.SkillClient)](https://www.nuget.org/packages/Netclaw.SkillClient) +[![NuGet - Client](https://img.shields.io/nuget/v/Netclaw.SkillClient?label=Netclaw.SkillClient)](https://www.nuget.org/packages/Netclaw.SkillClient) +[![NuGet - CLI](https://img.shields.io/nuget/v/Netclaw.SkillServer.Cli?label=skillserver%20CLI)](https://www.nuget.org/packages/Netclaw.SkillServer.Cli) [![GitHub Container](https://ghcr-badge.egpl.dev/netclaw-dev/skillserver/latest_tag?label=container)](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