From f9ee0afeeec6ecb8b26c5e1eb1456b3da6a12b02 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 24 Oct 2026 12:04:29 +0300 Subject: [PATCH 01/10] feat(skills): add cached skills.sh catalog API --- cmd/skills-catalog-api/main.go | 56 ++ .../skills-catalog-api/skills-catalog.caddy | 12 + .../uap-skills-catalog.service | 53 ++ docs/SKILLS_CATALOG_OPERATIONS.md | 198 +++++++ docs/contracts/skills-catalog.openapi.yaml | 188 ++++++ internal/skillscatalog/catalog.go | 63 ++ internal/skillscatalog/catalog_test.go | 539 ++++++++++++++++++ internal/skillscatalog/http.go | 153 +++++ internal/skillscatalog/service.go | 379 ++++++++++++ internal/skillscatalog/skillssh.go | 167 ++++++ 10 files changed, 1808 insertions(+) create mode 100644 cmd/skills-catalog-api/main.go create mode 100644 deploy/skills-catalog-api/skills-catalog.caddy create mode 100644 deploy/skills-catalog-api/uap-skills-catalog.service create mode 100644 docs/SKILLS_CATALOG_OPERATIONS.md create mode 100644 docs/contracts/skills-catalog.openapi.yaml create mode 100644 internal/skillscatalog/catalog.go create mode 100644 internal/skillscatalog/catalog_test.go create mode 100644 internal/skillscatalog/http.go create mode 100644 internal/skillscatalog/service.go create mode 100644 internal/skillscatalog/skillssh.go diff --git a/cmd/skills-catalog-api/main.go b/cmd/skills-catalog-api/main.go new file mode 100644 index 000000000..f3cf28c0a --- /dev/null +++ b/cmd/skills-catalog-api/main.go @@ -0,0 +1,56 @@ +// skills-catalog-api serves a bounded, cached anonymous skills catalog. +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/777genius/plugin-kit-ai/internal/skillscatalog" +) + +func env(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} +func main() { + listen := flag.String("listen", env("SKILLS_CATALOG_LISTEN", "127.0.0.1:13120"), "HTTP listen address") + cacheDir := flag.String("cache-dir", env("SKILLS_CATALOG_CACHE_DIR", ".cache/skills-catalog"), "Private persistent cache directory") + origins := flag.String("origins", env("SKILLS_CATALOG_ORIGINS", "https://universal-agent-plugins.com,https://www.universal-agent-plugins.com"), "Comma-separated exact CORS origins; configure localhost explicitly for development") + flag.Parse() + service, err := skillscatalog.NewService(skillscatalog.NewSkillsSH(), *cacheDir) + if err != nil { + log.Fatal(err) + } + configured := []string{} + for _, origin := range strings.Split(*origins, ",") { + if origin = strings.TrimSpace(origin); origin != "" { + configured = append(configured, origin) + } + } + handler, err := skillscatalog.NewHandler(service, configured) + if err != nil { + log.Fatal(err) + } + server := &http.Server{Addr: *listen, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + }() + log.Printf("Skills catalog listening on %s", *listen) + if err = server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} diff --git a/deploy/skills-catalog-api/skills-catalog.caddy b/deploy/skills-catalog-api/skills-catalog.caddy new file mode 100644 index 000000000..6f77b4b6f --- /dev/null +++ b/deploy/skills-catalog-api/skills-catalog.caddy @@ -0,0 +1,12 @@ +# Imported by /etc/caddy/Caddyfile through /etc/caddy/projects/*.caddy. +# The apex and www website continue to use their existing GitHub Pages hosting. +api.universal-agent-plugins.com { + reverse_proxy 127.0.0.1:13120 { + header_up -Authorization + header_up -Cookie + transport http { + dial_timeout 2s + response_header_timeout 12s + } + } +} diff --git a/deploy/skills-catalog-api/uap-skills-catalog.service b/deploy/skills-catalog-api/uap-skills-catalog.service new file mode 100644 index 000000000..3810ae89a --- /dev/null +++ b/deploy/skills-catalog-api/uap-skills-catalog.service @@ -0,0 +1,53 @@ +[Unit] +Description=Universal Agent Plugins public skills catalog +Wants=network-online.target +After=network-online.target +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=simple +DynamicUser=yes +User=uap-skills-catalog +WorkingDirectory=/srv/platform/projects/universal-agent-plugins/current +ExecStart=/srv/platform/projects/universal-agent-plugins/current/skills-catalog-api +Environment=SKILLS_CATALOG_LISTEN=127.0.0.1:13120 +Environment=SKILLS_CATALOG_CACHE_DIR=/var/lib/uap-skills-catalog +Environment=SKILLS_CATALOG_ORIGINS=https://universal-agent-plugins.com,https://www.universal-agent-plugins.com +Environment=GOMEMLIMIT=192MiB +Environment=GOMAXPROCS=2 +StateDirectory=uap-skills-catalog +StateDirectoryMode=0700 +UMask=0077 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 +KillSignal=SIGTERM + +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +MemoryHigh=192M +MemoryMax=256M +TasksMax=64 +LimitNOFILE=1024 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=uap-skills-catalog + +[Install] +WantedBy=multi-user.target diff --git a/docs/SKILLS_CATALOG_OPERATIONS.md b/docs/SKILLS_CATALOG_OPERATIONS.md new file mode 100644 index 000000000..f33187fc9 --- /dev/null +++ b/docs/SKILLS_CATALOG_OPERATIONS.md @@ -0,0 +1,198 @@ +# Skills catalog operations + +This is a deployment recipe, not evidence that deployment has occurred. The +service is separate from the static GitHub Pages landing site. Only +`api.universal-agent-plugins.com` routes to this service; the apex and `www` +remain on GitHub Pages. The API contract is +[skills-catalog.openapi.yaml](contracts/skills-catalog.openapi.yaml). + +## Destination and owned paths + +Deployment inventory supplied for this service: + +- SSH identity/address: `iliya@37.27.125.57`. +- Hostname: `prod-hel1-01`. +- Machine ID: `b28fc7b17042414386eb9b114046e50c`. +- Binary: `/srv/platform/projects/universal-agent-plugins/releases//skills-catalog-api`. +- Active release: `/srv/platform/projects/universal-agent-plugins/current` symlink. +- Unit: `/etc/systemd/system/uap-skills-catalog.service`. +- Route: `/etc/caddy/projects/skills-catalog.caddy`. +- Private persistent state: `/var/lib/uap-skills-catalog`. + +Verify the configured SSH host key through the trusted host inventory, then +check the hostname and `/etc/machine-id` before remote changes. Determine the +actual architecture with `uname -m`; map `x86_64` to Go `amd64` and `aarch64` to +`arm64`. Stop on an unexpected identity or architecture. No existing UAP +service or release root is assumed. Do not replace another project's files. +Caddy already imports `/etc/caddy/projects/*.caddy`; do not rewrite its shared +configuration or open a new public port. DNS provisioning is separate: the +new API hostname must resolve to this server, including checking any existing +AAAA record. Do not alter the website's DNS records. + +## Build and record the exact artifact + +Build from the reviewed release commit, with the relevant checks passing on +that exact source. Use a job-owned build directory, outside the source tree. +Set `CATALOG_GOARCH` to the architecture verified above. Record the full commit, +Go version, architecture, binary SHA-256, and focused test result with the +release evidence. The hash is specific to the actual binary, not the branch +name or this template. These commands run on the build host: + +```sh +set -eu +CATALOG_SHA=$(git rev-parse HEAD) +CATALOG_GOARCH=amd64 +CATALOG_BUILD_DIR=$(mktemp -d) +go version +go test ./internal/skillscatalog ./cmd/skills-catalog-api +CGO_ENABLED=0 GOOS=linux GOARCH="$CATALOG_GOARCH" go build -trimpath -buildvcs=false -o "$CATALOG_BUILD_DIR/skills-catalog-api" ./cmd/skills-catalog-api +shasum -a 256 "$CATALOG_BUILD_DIR/skills-catalog-api" +file "$CATALOG_BUILD_DIR/skills-catalog-api" +``` + +`CATALOG_GOARCH=amd64` is an example, not an assertion about the destination. +Do not label an artifact with `CATALOG_SHA` if the source has uncommitted edits. +No Node runtime, Docker, database, tokens, or application secrets are needed +on the destination. HTTPS access to `www.skills.sh`, working DNS, and the +system CA trust store are required. Never expose an upstream-origin override +or caller-supplied proxy URL. + +## Stage and activate + +Use a verified transfer into a unique operator-owned staging directory. Compare +its SHA-256 on the destination with the recorded build hash before installation. +Run the following activation steps in a root shell on the verified destination; +`CATALOG_STAGING` must contain the reviewed binary and the two templates from +`deploy/skills-catalog-api/`. Set `CATALOG_SHA` to the full reviewed commit. +Do not overwrite an existing release directory: compare and reuse an identical +artifact or stop to resolve the mismatch. Before running these commands, record +the previous `current` symlink target and save the prior versions of this +service's unit and route in the release evidence directory. Record absent files +and targets explicitly on first install. Check the staged unit's intended diff +before replacing an existing unit. + +```sh +set -eu +CATALOG_ROOT=/srv/platform/projects/universal-agent-plugins +CATALOG_RELEASE="$CATALOG_ROOT/releases/$CATALOG_SHA" +test ! -e "$CATALOG_RELEASE" +install -d -o root -g root -m 0755 "$CATALOG_ROOT" "$CATALOG_ROOT/releases" "$CATALOG_RELEASE" +install -o root -g root -m 0755 "$CATALOG_STAGING/skills-catalog-api" "$CATALOG_RELEASE/skills-catalog-api" +sha256sum "$CATALOG_RELEASE/skills-catalog-api" +``` + +Verify that every parent of the release binary is root owned and not writable +by the service. The binary is installed before switching `current`; unit +verification follows the switch so its executable exists on first install. + +Activate using a fresh temporary symlink name on the same filesystem, then +rename it over `current` atomically. This example uses a release-specific name +that must be absent before creation: + +```sh +set -eu +CATALOG_NEXT="$CATALOG_ROOT/.current-$CATALOG_SHA" +test ! -e "$CATALOG_NEXT" +test ! -L "$CATALOG_NEXT" +ln -s "$CATALOG_RELEASE" "$CATALOG_NEXT" +if test -e "$CATALOG_ROOT/current" && ! test -L "$CATALOG_ROOT/current"; then + exit 1 +fi +mv -Tf "$CATALOG_NEXT" "$CATALOG_ROOT/current" +install -o root -g root -m 0644 "$CATALOG_STAGING/uap-skills-catalog.service" /etc/systemd/system/uap-skills-catalog.service +systemd-analyze verify /etc/systemd/system/uap-skills-catalog.service +systemctl daemon-reload +systemctl enable uap-skills-catalog.service +systemctl restart uap-skills-catalog.service +systemctl is-active uap-skills-catalog.service +curl --retry 5 --retry-delay 1 --retry-connrefused --fail --silent --show-error http://127.0.0.1:13120/healthz +curl --fail --silent --show-error 'http://127.0.0.1:13120/v1/skills?view=popular&page=0' +``` + +If unit verification fails, stop before daemon reload or restart and restore +the recorded prior unit/current target. A health response proves the process is +serving. Before routing public traffic, check that a catalog request produces valid items or a truthful structured 503; +a 503 with no last good cache is not a successful catalog qualification. +The service listens only on loopback; do not add a firewall rule for port 13120. + +## Publish the dedicated route + +After backend checks and DNS verification, install only this route. Validate +the complete existing Caddy configuration, including imports, before reloading: + +```sh +set -eu +install -o root -g root -m 0644 "$CATALOG_STAGING/skills-catalog.caddy" /etc/caddy/projects/skills-catalog.caddy +caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile +systemctl reload caddy +curl --fail --silent --show-error https://api.universal-agent-plugins.com/healthz +curl --fail --silent --show-error --dump-header - -H 'Origin: https://universal-agent-plugins.com' 'https://api.universal-agent-plugins.com/v1/skills?view=popular&page=0' +``` + +Run the reload only if validation succeeds. On validation failure, restore this +route's prior bytes or remove this newly introduced file, then validate again; +do not reload the failed candidate. Verify the system's Caddy service reloads +`/etc/caddy/Caddyfile` rather than a different config. Public acceptance also +checks HTTPS, exact `Access-Control-Allow-Origin`, `cache.status`, `fetchedAt`, +all three views, and a two-character-or-longer search. Search omits a complete +`total` and returns a bounded result set. A disallowed Origin receives 403; +there is no credentialed CORS. Do not log full query responses or client headers +as routine operational telemetry. + +Caddy's [validation command](https://caddyserver.com/docs/command-line#caddy-validate) +checks loading and provisioning, a stronger check than merely adapting the file. + +## Persistence, limits, and normal operations + +The unit uses `DynamicUser` and `StateDirectory=uap-skills-catalog` with mode +0700. Systemd preserves the state directory across stops and restarts and manages +its ownership. With `DynamicUser`, the host may expose it through a symlink into +`/var/lib/private`; do not manually chown, move, replace, or clear it. See the +[systemd directory contract](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#StateDirectory=). + +After one successful browse, record its `fetchedAt`, restart only this service, +and request the same page within its 30-minute freshness window. The timestamp +should remain unchanged and the page should still be fresh. This verifies reuse +of persisted cache without blocking the real upstream or changing shared network +rules. Upstream outage behavior is covered by the focused local tests; do not +fault-inject on the production host. Search freshness lasts ten minutes. Valid +stale data remains usable up to seven days from its last successful fetch; +malformed, timed-out, rate-limited, or failed refreshes do not overwrite it. With +no usable cache, the API returns structured 503 and a retry delay. + +The cache holds at most 128 records with at most 2 MiB per response. Fetches have +bounded concurrency and rate, and upstream 429 retry delays apply. No cron or +refresh daemon is required. The unit's Go memory target is 192 MiB, with a +256 MiB cgroup hard limit, and task/file-descriptor limits. The memory target is +soft; the [cgroup limit](https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html#MemoryMax=) +can terminate the service if exceeded. Check normal browse/search traffic and +journal evidence before changing these limits. + +```sh +set -eu +systemctl status --no-pager uap-skills-catalog.service +journalctl -u uap-skills-catalog.service -n 50 --no-pager +systemctl show uap-skills-catalog.service -p MemoryCurrent -p MemoryPeak -p NRestarts -p ActiveState +``` + +After the bounded restart budget is exhausted, diagnose the failure before using +`systemctl reset-failed uap-skills-catalog.service` and restarting. Keep previous +release binaries and deployment evidence for rollback. Do not run +`systemctl clean` or remove the state directory during a normal update. + +## Rollback this service only + +For an update rollback, verify the prior release's recorded SHA-256, restore +this service's previous unit if it changed, atomically repoint `current` to the +recorded prior release, run `systemctl daemon-reload`, and restart only +`uap-skills-catalog.service`. Check loopback health and catalog behavior. Leave +the persistent cache intact. Restore this service's prior route if changed, +validate the full Caddy configuration, and reload Caddy only on success. + +For first-install removal, remove only +`/etc/caddy/projects/skills-catalog.caddy`, validate the full Caddy configuration, +and reload Caddy only if it succeeds. Then disable and stop only +`uap-skills-catalog.service`. Preserve its binary, unit, state, and evidence until +the rollback is confirmed. Do not remove the release root, cache, shared Caddy +files, other services, website DNS, or GitHub Pages configuration. Removing the +API DNS record is a separate explicit action, not part of this template. diff --git a/docs/contracts/skills-catalog.openapi.yaml b/docs/contracts/skills-catalog.openapi.yaml new file mode 100644 index 000000000..d75b3af9a --- /dev/null +++ b/docs/contracts/skills-catalog.openapi.yaml @@ -0,0 +1,188 @@ +openapi: 3.0.3 +info: + title: Public skills catalog + version: 1.0.0 + description: >- + Anonymous, read-only catalog with a replaceable provider contract. Current + provider is skills.sh. Listings are unverified hints, not installation, + repository-folder, commit, declared-name, trust or security evidence. + Browse is zero-based, fixed at up to 200 upstream results per page. Search + returns at most 100 matches without usable pagination or complete totals. + Fresh TTL is 30 minutes for browse and 10 minutes for search. During upstream + failures, schema-valid cached results may be served for at most seven days + from fetchedAt, including after restart. A valid empty response is a success. +servers: + - url: https://api.universal-agent-plugins.com +paths: + /healthz: + get: + operationId: health + responses: + '200': + description: Process is serving; independent of upstream availability. + content: + application/json: + schema: + type: object + required: [status] + properties: + status: {type: string, enum: [ok]} + /v1/skills: + get: + operationId: listSkills + description: >- + Only listed query parameters are accepted, once each. CORS permits exact + configured origins without credentials. No caller authentication, cookies, + or upstream response headers are forwarded. Search always uses the + canonical popular view and page zero. Global bounded upstream concurrency + and rate limits apply; cache misses can return 503 with Retry-After. + parameters: + - in: query + name: view + schema: {$ref: '#/components/schemas/CatalogView'} + - in: query + name: page + description: Zero-based browse page. Search requires zero. + schema: {type: integer, minimum: 0, maximum: 10000, default: 0} + - in: query + name: q + description: Search text, trimmed; at least two Unicode characters, at most 200 UTF-8 bytes, no control characters. + schema: {type: string, minLength: 2, maxLength: 200} + - in: query + name: limit + description: Search only. Browse always uses the provider's fixed page size. + schema: {type: integer, minimum: 1, maximum: 100, default: 100} + responses: + '200': + description: Schema-valid fresh or stale listing. Inspect cache.status and fetchedAt. + content: + application/json: + schema: {$ref: '#/components/schemas/SkillsPage'} + examples: + browse: + value: + items: + - id: skills-sh:vercel-labs/skills:find-skills + providerId: skills-sh + providerUrl: https://www.skills.sh/vercel-labs/skills/find-skills + title: find-skills + skillNameHint: find-skills + source: + kind: github + repository: vercel-labs/skills + url: https://github.com/vercel-labs/skills + metrics: {installs: 1234, window: all-time} + availability: unverified + total: 1 + hasMore: false + page: 0 + view: popular + cache: {status: fresh, fetchedAt: '2026-10-24T00:00:00Z'} + search: + value: + items: [] + hasMore: false + page: 0 + query: find skills + view: popular + cache: {status: fresh, fetchedAt: '2026-10-24T00:00:00Z'} + warnings: [search_results_limited] + '400': + description: Invalid, duplicate or unknown query parameter. + content: + application/json: + schema: {$ref: '#/components/schemas/CatalogError'} + '403': + description: Browser origin or preflight is not allowed. + content: + application/json: + schema: {$ref: '#/components/schemas/CatalogError'} + '503': + description: Provider unavailable and no cache entry within seven days. + headers: + Retry-After: + description: Minimum seconds before another request is useful. + schema: {type: integer, minimum: 1} + content: + application/json: + schema: {$ref: '#/components/schemas/CatalogError'} + example: + error: {code: provider_unavailable, message: Skills provider is temporarily unavailable} + retryAfter: 30 +components: + schemas: + CatalogView: + type: string + enum: [popular, trending, hot] + default: popular + SkillSource: + type: object + required: [kind, url] + properties: + kind: {type: string, enum: [github, external]} + repository: + type: string + description: Present only for a validated two-segment GitHub owner/repository locator. No folder or revision is inferred. + url: {type: string, format: uri} + SkillMetrics: + type: object + required: [installs, window] + properties: + installs: {type: integer, format: int64, minimum: 0} + window: + type: string + enum: [all-time, 24h, 1h] + description: Provider ranking window; not an independently verified measurement. + SkillListing: + type: object + required: [id, providerId, providerUrl, title, skillNameHint, source, metrics, availability] + properties: + id: {type: string, description: Opaque provider-namespaced identifier.} + providerId: {type: string} + providerUrl: {type: string, format: uri} + title: {type: string} + skillNameHint: + type: string + description: Exact raw upstream name, including Unicode and case, as an unverified CLI selection hint. Never a declared or validated installer name; the listing slug identifies only the provider detail page. + source: {$ref: '#/components/schemas/SkillSource'} + metrics: {$ref: '#/components/schemas/SkillMetrics'} + availability: {type: string, enum: [unverified, unsupported-source]} + CatalogCache: + type: object + required: [status, fetchedAt] + properties: + status: {type: string, enum: [fresh, stale]} + fetchedAt: {type: string, format: date-time, description: Timestamp of the last schema-valid upstream success, never the failed refresh.} + SkillsPage: + type: object + required: [items, hasMore, page, view, cache] + properties: + items: + type: array + maxItems: 200 + items: {$ref: '#/components/schemas/SkillListing'} + total: + type: integer + format: int64 + minimum: 0 + description: Browse provider total only. Omitted for bounded search results. + hasMore: {type: boolean} + page: {type: integer, minimum: 0} + query: {type: string} + view: {$ref: '#/components/schemas/CatalogView'} + cache: {$ref: '#/components/schemas/CatalogCache'} + warnings: + type: array + items: {type: string} + description: Includes search_results_limited, provider_unavailable for stale results, or cache_persistence_unavailable. + CatalogError: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + retryAfter: {type: integer, minimum: 1, description: Seconds before retry, when available.} diff --git a/internal/skillscatalog/catalog.go b/internal/skillscatalog/catalog.go new file mode 100644 index 000000000..fd6785c06 --- /dev/null +++ b/internal/skillscatalog/catalog.go @@ -0,0 +1,63 @@ +// Package skillscatalog owns the public, provider-independent listing contract. +package skillscatalog + +import ( + "context" + "time" +) + +type View string + +const ( + Popular View = "popular" + Trending View = "trending" + Hot View = "hot" +) + +func (v View) Valid() bool { return v == Popular || v == Trending || v == Hot } + +type Source struct { + Kind string `json:"kind"` + Repository string `json:"repository,omitempty"` + URL string `json:"url"` +} +type Metrics struct { + Installs int64 `json:"installs"` + Window string `json:"window"` +} +type SkillListing struct { + ID string `json:"id"` + ProviderID string `json:"providerId"` + ProviderURL string `json:"providerUrl"` + Title string `json:"title"` + SkillNameHint string `json:"skillNameHint"` + Source Source `json:"source"` + Metrics Metrics `json:"metrics"` + Availability string `json:"availability"` +} +type CacheInfo struct { + Status string `json:"status"` + FetchedAt time.Time `json:"fetchedAt"` +} +type Page struct { + Items []SkillListing `json:"items"` + Total *int64 `json:"total,omitempty"` + HasMore bool `json:"hasMore"` + Page int `json:"page"` + Query string `json:"query,omitempty"` + View View `json:"view"` + Cache CacheInfo `json:"cache"` + Warnings []string `json:"warnings,omitempty"` +} + +// Provider returns validated listings. Search returns a bounded result set, not +// a complete census; it must omit Total unless the provider proves completeness. +type Provider interface { + ID() string + Browse(context.Context, View, int) (Page, error) + Search(context.Context, string, int) (Page, error) +} + +type ProviderError struct{ RetryAfter time.Duration } + +func (*ProviderError) Error() string { return "skills provider unavailable" } diff --git a/internal/skillscatalog/catalog_test.go b/internal/skillscatalog/catalog_test.go new file mode 100644 index 000000000..2ddb40d23 --- /dev/null +++ b/internal/skillscatalog/catalog_test.go @@ -0,0 +1,539 @@ +package skillscatalog + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const sampleSkill = `{"source":"vercel-labs/skills","skillId":"find-skills","name":"Find skills","installs":1234,"newField":"allowed"}` + +func browseJSON(skill string) string { + return `{"skills":[` + skill + `],"total":1,"hasMore":false,"page":0}` +} +func upstream(t *testing.T, handler http.HandlerFunc) *SkillsSH { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return newSkillsSH(srv.URL, srv.Client().Transport) +} +func TestProviderNormalizationAndSafeLocators(t *testing.T) { + cases := []struct { + source string + github bool + }{ + {"vercel-labs/skills", true}, {"owner/repo/tree/main", false}, {"https://github.com/owner/repo", false}, {"owner/..", false}, {"owner/repo.git", false}, {"owner/repo#frag", false}, {"evil:payload", false}, + } + for _, tc := range cases { + t.Run(tc.source, func(t *testing.T) { + skill := strings.Replace(sampleSkill, "vercel-labs/skills", tc.source, 1) + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/skills/trending/0" { + t.Errorf("path %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Error("credentials forwarded") + } + _, _ = w.Write([]byte(browseJSON(skill))) + }) + page, err := p.Browse(context.Background(), Trending, 0) + if err != nil { + t.Fatal(err) + } + i := page.Items[0] + if i.Metrics.Window != "24h" || i.Metrics.Installs != 1234 || i.SkillNameHint != "Find skills" { + t.Fatalf("wrong normalized listing: %+v", i) + } + if tc.github { + if i.Source.URL != "https://github.com/vercel-labs/skills" || i.Source.Repository != "vercel-labs/skills" || i.Availability != "unverified" { + t.Fatal(i) + } + } else { + if i.Source.Kind != "external" || i.Source.Repository != "" || i.Availability != "unsupported-source" || !strings.HasPrefix(i.Source.URL, skillsOrigin+"/") { + t.Fatal(i) + } + } + }) + } +} +func TestMalformedAndBoundedUpstreamNeverBecomeEmptySuccess(t *testing.T) { + cases := []string{`{}`, `{"skills":null,"total":0,"hasMore":false,"page":0}`, `{"skills":[],"total":0,"page":0}`, browseJSON(`{"source":"owner/repo","skillId":"x","name":"x"}`), browseJSON(strings.Replace(sampleSkill, `"installs":1234`, `"installs":-1`, 1)), browseJSON(strings.Replace(sampleSkill, `"name":"Find skills"`, `"name":"bad\nname"`, 1)), browseJSON(strings.Replace(sampleSkill, `"source":"vercel-labs/skills"`, `"source":"evil\nsource"`, 1)), strings.Repeat(" ", maxBodyBytes+1)} + for n, body := range cases { + t.Run(string(rune('a'+n)), func(t *testing.T) { + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + _, err = s.Get(context.Background(), Request{View: Popular}) + var pe *ProviderError + if !errors.As(err, &pe) { + t.Fatalf("malformed success: %v", err) + } + }) + } +} +func TestRedirectAnd429Boundaries(t *testing.T) { + var targetHits atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetHits.Add(1) })) + defer target.Close() + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 302) }) + if _, err := p.Browse(context.Background(), Popular, 0); err == nil || targetHits.Load() != 0 { + t.Fatal("followed upstream redirect") + } + var hits atomic.Int32 + p = upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.Header().Set("Retry-After", "120") + w.WriteHeader(429) + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + for n := 0; n < 2; n++ { + _, err = s.Get(context.Background(), Request{View: Popular, Page: n}) + var pe *ProviderError + if !errors.As(err, &pe) || pe.RetryAfter < time.Second*119 { + t.Fatalf("retry not respected: %v", err) + } + } + if hits.Load() != 1 { + t.Fatal("429 cooldown did not apply across keys") + } +} +func TestCacheOutageRestartExpiryAndEmptyResults(t *testing.T) { + var outage atomic.Bool + var empty atomic.Bool + var hits atomic.Int32 + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if outage.Load() { + w.WriteHeader(503) + return + } + body := browseJSON(sampleSkill) + if empty.Load() { + body = `{"skills":[],"total":0,"hasMore":false,"page":0}` + } + _, _ = w.Write([]byte(body)) + }) + dir := t.TempDir() + s, err := NewService(p, dir) + if err != nil { + t.Fatal(err) + } + start := time.Now().UTC() + now := start + s.now = func() time.Time { return now } + s.tokenAt = start + req := Request{View: Popular} + fresh, err := s.Get(context.Background(), req) + if err != nil || fresh.Cache.Status != "fresh" || len(fresh.Items) != 1 { + t.Fatal(fresh, err) + } + outage.Store(true) + now = start.Add(31 * time.Minute) + stale, err := s.Get(context.Background(), req) + if err != nil || stale.Cache.Status != "stale" || !stale.Cache.FetchedAt.Equal(start) || len(stale.Items) != 1 { + t.Fatal(stale, err) + } + prior := hits.Load() + if _, err = s.Get(context.Background(), req); err != nil || hits.Load() != prior { + t.Fatal("failure cooldown missing") + } + restarted, err := NewService(p, dir) + if err != nil { + t.Fatal(err) + } + restarted.now = func() time.Time { return now } + restarted.tokenAt = now + stale, err = restarted.Get(context.Background(), req) + if err != nil || stale.Cache.Status != "stale" || len(stale.Items) != 1 { + t.Fatal(stale, err) + } + now = start.Add(7*24*time.Hour + time.Second) + if _, err = restarted.Get(context.Background(), req); err == nil { + t.Fatal("served cache past seven days") + } + outage.Store(false) + empty.Store(true) + now = now.Add(time.Minute) + result, err := restarted.Get(context.Background(), req) + if err != nil || len(result.Items) != 0 || result.Items == nil || result.Total == nil || *result.Total != 0 || result.Cache.Status != "fresh" { + t.Fatal(result, err) + } + data, err := os.ReadFile(filepath.Join(dir, req.key(p.ID())+".json")) + if err != nil { + t.Fatal(err) + } + var stored entry + if json.Unmarshal(data, &stored) != nil || len(stored.Result.Items) != 0 || !stored.Result.Cache.FetchedAt.Equal(now) { + t.Fatal("valid empty response did not replace old cache") + } +} +func TestInvalidRefreshPreservesLastGoodRecord(t *testing.T) { + var bad atomic.Bool + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + body := browseJSON(sampleSkill) + if bad.Load() { + body = `{"skills":[]}` + } + _, _ = w.Write([]byte(body)) + }) + dir := t.TempDir() + s, err := NewService(p, dir) + if err != nil { + t.Fatal(err) + } + now := time.Now() + s.now = func() time.Time { return now } + s.tokenAt = now + req := Request{View: Popular} + if _, err = s.Get(context.Background(), req); err != nil { + t.Fatal(err) + } + filename := filepath.Join(dir, req.key(p.ID())+".json") + before, _ := os.ReadFile(filename) + bad.Store(true) + now = now.Add(time.Hour) + page, err := s.Get(context.Background(), req) + if err != nil || page.Cache.Status != "stale" { + t.Fatal(page, err) + } + after, _ := os.ReadFile(filename) + if string(before) != string(after) { + t.Fatal("invalid response replaced good persisted cache") + } +} +func TestHTTPValidationSearchAndCORS(t *testing.T) { + var hits atomic.Int32 + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" { + t.Error("credentials forwarded") + } + if strings.HasPrefix(r.URL.Path, "/api/search") { + if r.URL.Query().Get("limit") != "100" || r.URL.Query().Get("q") != "find skills" { + t.Errorf("search query %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"skills":[` + sampleSkill + `],"count":1}`)) + return + } + _, _ = w.Write([]byte(browseJSON(sampleSkill))) + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(s, []string{"https://universal-agent-plugins.com", "http://localhost:3000"}) + if err != nil { + t.Fatal(err) + } + invalid := []string{"view=unknown", "page=-1", "page=10001", "page=1&page=2", "q=x", "q=", "q=find&page=1", "q=find&limit=101", "limit=10", "url=https://evil", "q=%ff", "q=%0atest"} + for _, q := range invalid { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/v1/skills?"+q, nil)) + if rr.Code != 400 { + t.Fatalf("query %s: %d %s", q, rr.Code, rr.Body.String()) + } + } + if hits.Load() != 0 { + t.Fatal("invalid query hit provider") + } + rr := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/skills?q=find+skills&view=hot", nil) + r.Header.Set("Origin", "https://universal-agent-plugins.com") + r.Header.Set("Cookie", "private=secret") + r.Header.Set("Authorization", "Bearer private") + h.ServeHTTP(rr, r) + var page Page + if rr.Code != 200 || json.Unmarshal(rr.Body.Bytes(), &page) != nil || page.Total != nil || page.HasMore || page.Query != "find skills" || page.View != Popular || len(page.Warnings) != 1 { + t.Fatal(rr.Code, rr.Body.String()) + } + if rr.Header().Get("Access-Control-Allow-Origin") != "https://universal-agent-plugins.com" || rr.Header().Get("Access-Control-Allow-Credentials") != "" { + t.Fatal(rr.Header()) + } + for _, origin := range []string{"https://evil.test", "https://universal-agent-plugins.com.evil.test", "null"} { + rr = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/v1/skills", nil) + r.Header.Set("Origin", origin) + h.ServeHTTP(rr, r) + if rr.Code != 403 || rr.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatal(rr.Code, rr.Header()) + } + } + rr = httptest.NewRecorder() + r = httptest.NewRequest("OPTIONS", "/v1/skills", nil) + r.Header.Set("Origin", "http://localhost:3000") + r.Header.Set("Access-Control-Request-Method", "GET") + h.ServeHTTP(rr, r) + if rr.Code != 204 { + t.Fatal(rr.Code) + } + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/healthz", nil)) + if rr.Code != 200 { + t.Fatal(rr.Code) + } + if _, err = NewHandler(s, []string{"https://example.com/path"}); err == nil { + t.Fatal("accepted non-origin CORS value") + } +} +func TestBoundedConcurrentMissesAndCoalescing(t *testing.T) { + var hits atomic.Int32 + entered := make(chan struct{}, 4) + release := make(chan struct{}) + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + entered <- struct{}{} + <-release + _, _ = w.Write([]byte(browseJSON(sampleSkill))) + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for n := 0; n < 4; n++ { + wg.Add(1) + go func(page int) { + defer wg.Done() + _, _ = s.Get(context.Background(), Request{View: Popular, Page: page}) + }(n) + } + for n := 0; n < 4; n++ { + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("miss did not start") + } + } + if _, err = s.Get(context.Background(), Request{View: Hot}); err == nil { + t.Fatal("fifth concurrent miss accepted") + } + wg.Add(1) + go func() { defer wg.Done(); _, _ = s.Get(context.Background(), Request{View: Popular}) }() + close(release) + wg.Wait() + if hits.Load() != 4 { + t.Fatalf("duplicate refresh: %d", hits.Load()) + } +} + +type pageProvider struct{} + +func (pageProvider) ID() string { return "test-provider" } +func (pageProvider) Browse(_ context.Context, v View, n int) (Page, error) { + total := int64(0) + return Page{Items: []SkillListing{}, Total: &total, View: v, Page: n}, nil +} +func (pageProvider) Search(_ context.Context, q string, n int) (Page, error) { + return Page{Items: []SkillListing{}, View: Popular, Query: q}, nil +} +func TestPersistentCacheBoundAndCorruptRecordRejection(t *testing.T) { + dir := t.TempDir() + s, err := NewService(pageProvider{}, dir) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + s.now = func() time.Time { return now } + s.tokenAt = now + for n := 0; n < 130; n++ { + now = now.Add(time.Second) + if _, err = s.Get(context.Background(), Request{View: Popular, Page: n}); err != nil { + t.Fatal(err) + } + } + files, err := os.ReadDir(dir) + if err != nil || len(files) != 128 || len(s.entries) != 128 { + t.Fatalf("unbounded cache: disk %d memory %d err %v", len(files), len(s.entries), err) + } + // Persisted records are loaded with real wall time, so set each test timestamp + // to a past instant before restart while preserving their actual schema. + for _, file := range files { + path := filepath.Join(dir, file.Name()) + data, _ := os.ReadFile(path) + var e entry + _ = json.Unmarshal(data, &e) + e.Result.Cache.FetchedAt = time.Now().Add(-time.Hour) + data, _ = json.Marshal(e) + if err = os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + } + corrupt := filepath.Join(dir, files[0].Name()) + data, _ := os.ReadFile(corrupt) + data = []byte(strings.Replace(string(data), `"hasMore":false,`, "", 1)) + if err = os.WriteFile(corrupt, data, 0600); err != nil { + t.Fatal(err) + } + loaded, err := NewService(pageProvider{}, dir) + if err != nil { + t.Fatal(err) + } + if len(loaded.entries) != 127 { + t.Fatalf("accepted missing required cache field; entries %d", len(loaded.entries)) + } + if _, err = os.Stat(corrupt); !errors.Is(err, os.ErrNotExist) { + t.Fatal("corrupt record retained") + } +} +func TestSearchCacheFreshnessAndCanonicalKeys(t *testing.T) { + s, err := NewService(pageProvider{}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + now := time.Now() + s.now = func() time.Time { return now } + s.tokenAt = now + req := Request{View: Popular, Query: "find", Limit: 100} + first, err := s.Get(context.Background(), req) + if err != nil { + t.Fatal(err) + } + now = now.Add(9 * time.Minute) + cached, err := s.Get(context.Background(), req) + if err != nil || !cached.Cache.FetchedAt.Equal(first.Cache.FetchedAt) { + t.Fatal("search missed fresh cache") + } + now = now.Add(2 * time.Minute) + refreshed, err := s.Get(context.Background(), req) + if err != nil || !refreshed.Cache.FetchedAt.Equal(now) { + t.Fatal("search TTL exceeded ten minutes") + } + other := req + other.Limit = 50 + if other.key("test-provider") == req.key("test-provider") || req.key("another-provider") == req.key("test-provider") { + t.Fatal("cache key collision across request/provider") + } +} + +func TestHTTPUnavailableIsStructuredAndDoesNotLeakUpstream(t *testing.T) { + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Set-Cookie", "upstream-secret=private") + w.Header().Set("X-Internal", "sensitive") + w.WriteHeader(500) + _, _ = w.Write([]byte("secret upstream failure detail")) + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(s, nil) + if err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/v1/skills", nil)) + var envelope errorEnvelope + if rr.Code != 503 || json.Unmarshal(rr.Body.Bytes(), &envelope) != nil || envelope.Error.Code != "provider_unavailable" || envelope.RetryAfter < 1 || rr.Header().Get("Retry-After") == "" { + t.Fatal(rr.Code, rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "secret") || rr.Header().Get("Set-Cookie") != "" || rr.Header().Get("X-Internal") != "" { + t.Fatal("leaked upstream details") + } + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/healthz", nil)) + if rr.Code != 200 { + t.Fatal("upstream outage broke health") + } +} + +func TestTimeoutDoesNotPersistPartialResponse(t *testing.T) { + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }) + dir := t.TempDir() + s, err := NewService(p, dir) + if err != nil { + t.Fatal(err) + } + p.client.Timeout = 20 * time.Millisecond + if _, err = s.Get(context.Background(), Request{View: Popular}); err == nil { + t.Fatal("timeout became successful result") + } + files, err := os.ReadDir(dir) + if err != nil || len(files) != 0 { + t.Fatal("timeout persisted a cache entry", err) + } +} + +func TestExactRawNamesAndDomainProviderLinks(t *testing.T) { + cases := []struct{ source, slug, name, providerURL string }{ + {"pluviobyte/rnskill", "ra", "ra-人话", skillsOrigin + "/pluviobyte/rnskill/ra"}, + {"owner/repo", "ai-video-prompt", "AI_video-prompt", skillsOrigin + "/owner/repo/ai-video-prompt"}, + {"Open.Feishu.Cn", "Agent-Skill", "Agent Skill", skillsOrigin + "/site/open.feishu.cn/agent-skill"}, + {"https://unknown.invalid/path", "slug", "raw name", skillsOrigin + "/"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + installs := int64(1) + skills := []upstreamSkill{{Source: &tc.source, SkillID: &tc.slug, Name: &tc.name, Installs: &installs}} + page, err := normalize(upstreamPage{Skills: &skills}, Popular) + if err != nil { + t.Fatal(err) + } + item := page.Items[0] + if item.SkillNameHint != tc.name || item.ProviderURL != tc.providerURL { + t.Fatalf("name/detail locator changed: %+v", item) + } + total := int64(1) + page.Total = &total + page.Cache = CacheInfo{Status: "fresh", FetchedAt: time.Now()} + if !validPage(page, Request{View: Popular}, "skills-sh") { + t.Fatal("valid raw upstream name rejected by persisted schema") + } + }) + } +} + +func TestSharedRefreshSurvivesFirstCallerCancellation(t *testing.T) { + entered := make(chan struct{}) + release := make(chan struct{}) + var hits atomic.Int32 + p := upstream(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + close(entered) + select { + case <-release: + _, _ = w.Write([]byte(browseJSON(sampleSkill))) + case <-r.Context().Done(): + return + } + }) + s, err := NewService(p, t.TempDir()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + firstDone := make(chan error, 1) + go func() { _, err := s.Get(ctx, Request{View: Popular}); firstDone <- err }() + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("refresh did not start") + } + cancel() + // The first browser may abort while a debounced search/browse request shares + // this refresh. Its cancellation must neither cancel the fetch nor cache a failure. + close(release) + page, err := s.Get(context.Background(), Request{View: Popular}) + if err != nil || len(page.Items) != 1 || page.Cache.Status != "fresh" { + t.Fatalf("caller abort poisoned shared refresh: %+v %v", page, err) + } + if err = <-firstDone; err != nil { + t.Fatalf("shared fetch inherited canceled browser context: %v", err) + } + if hits.Load() != 1 { + t.Fatalf("shared refresh hit upstream %d times", hits.Load()) + } +} diff --git a/internal/skillscatalog/http.go b/internal/skillscatalog/http.go new file mode 100644 index 000000000..3630d8a93 --- /dev/null +++ b/internal/skillscatalog/http.go @@ -0,0 +1,153 @@ +package skillscatalog + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +type errorEnvelope struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + RetryAfter int `json:"retryAfter,omitempty"` +} + +// NewHandler permits only exact configured browser origins; CORS is not an +// authentication boundary. Upstream concurrency/rate controls also apply to +// clients that make anonymous requests without an Origin header. +func NewHandler(service *Service, origins []string) (http.Handler, error) { + allowed := map[string]bool{} + for _, origin := range origins { + u, err := url.Parse(origin) + if err != nil || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return nil, errors.New("invalid allowed origin") + } + allowed[origin] = true + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "no-store") + // Health remains available even during upstream failures or CORS rejection. + if r.URL.Path == "/healthz" && r.Method == http.MethodGet { + writeJSON(w, 200, map[string]string{"status": "ok"}) + return + } + if r.URL.Path != "/v1/skills" { + writeError(w, 404, "not_found", "Endpoint not found", 0) + return + } + w.Header().Add("Vary", "Origin") + origin := r.Header.Get("Origin") + if origin != "" { + if !allowed[origin] { + writeError(w, 403, "origin_not_allowed", "Origin is not allowed", 0) + return + } + w.Header().Set("Access-Control-Allow-Origin", origin) + } + if r.Method == http.MethodOptions { + if origin == "" || r.Header.Get("Access-Control-Request-Method") != "GET" || r.Header.Get("Access-Control-Request-Headers") != "" { + writeError(w, 403, "invalid_preflight", "Only anonymous GET is allowed", 0) + return + } + w.Header().Set("Access-Control-Allow-Methods", "GET") + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET, OPTIONS") + writeError(w, 405, "method_not_allowed", "Only GET is allowed", 0) + return + } + req, err := parseRequest(r.URL.RawQuery) + if err != nil { + writeError(w, 400, "invalid_request", err.Error(), 0) + return + } + page, err := service.Get(r.Context(), req) + if err != nil { + retry := time.Second * 30 + var pe *ProviderError + if errors.As(err, &pe) && pe.RetryAfter > 0 { + retry = pe.RetryAfter + } + seconds := max(1, int((retry+time.Second-1)/time.Second)) + w.Header().Set("Retry-After", strconv.Itoa(seconds)) + writeError(w, 503, "provider_unavailable", "Skills provider is temporarily unavailable", seconds) + return + } + writeJSON(w, 200, page) + }), nil +} +func parseRequest(raw string) (Request, error) { + bad := func(message string) (Request, error) { return Request{}, errors.New(message) } + if len(raw) > 2048 { + return bad("Query string is too long") + } + q, err := url.ParseQuery(raw) + if err != nil { + return bad("Invalid query string") + } + for k, v := range q { + if len(v) != 1 { + return bad("Duplicate query parameters are not allowed") + } + if k != "view" && k != "page" && k != "q" && k != "limit" { + return bad("Unknown query parameter") + } + } + r := Request{View: Popular} + if q.Has("view") { + r.View = View(q.Get("view")) + if !r.View.Valid() { + return bad("view must be popular, trending or hot") + } + } + if q.Has("page") { + r.Page, err = strconv.Atoi(q.Get("page")) + if err != nil || r.Page < 0 || r.Page > 10000 { + return bad("page must be between 0 and 10000") + } + } + if q.Has("q") { + if !safeText(q.Get("q"), 200) { + return bad("q contains unsupported characters") + } + r.Query = strings.TrimSpace(q.Get("q")) + if !utf8.ValidString(r.Query) || utf8.RuneCountInString(r.Query) < 2 || !safeText(r.Query, 200) { + return bad("q must contain 2 or more characters, up to 200 bytes") + } + if r.Page != 0 { + return bad("Search supports only page 0") + } + r.View = Popular + r.Limit = 100 + if q.Has("limit") { + r.Limit, err = strconv.Atoi(q.Get("limit")) + if err != nil || r.Limit < 1 || r.Limit > 100 { + return bad("limit must be between 1 and 100") + } + } + } else if q.Has("limit") { + return bad("limit is only supported with q") + } + return r, nil +} +func writeJSON(w http.ResponseWriter, status int, value any) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} +func writeError(w http.ResponseWriter, status int, code, message string, retry int) { + e := errorEnvelope{RetryAfter: retry} + e.Error.Code = code + e.Error.Message = message + writeJSON(w, status, e) +} diff --git a/internal/skillscatalog/service.go b/internal/skillscatalog/service.go new file mode 100644 index 000000000..9a0919e5f --- /dev/null +++ b/internal/skillscatalog/service.go @@ -0,0 +1,379 @@ +package skillscatalog + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +const maxEntries = 128 +const maxStale = 7 * 24 * time.Hour + +type Request struct { + View View `json:"view"` + Page int `json:"page"` + Query string `json:"query"` + Limit int `json:"limit"` +} + +func (r Request) key(provider string) string { + operation := "browse" + if r.Query != "" { + operation = "search" + } + b, _ := json.Marshal([]any{provider, operation, r.View, r.Page, r.Query, r.Limit}) + digest := sha256.Sum256(b) + return hex.EncodeToString(digest[:]) +} + +type entry struct { + Key string `json:"key"` + Request Request `json:"request"` + Result Page `json:"result"` +} +type failure struct{ until time.Time } +type Service struct { + provider Provider + dir string + now func() time.Time + mu sync.Mutex + entries map[string]entry + failures map[string]failure + pending map[string]chan struct{} + slots chan struct{} + tokens float64 + tokenAt time.Time + providerRetry time.Time +} + +// NewService loads only bounded, validated cache records belonging to this +// provider. A cache directory is private service state, not a content endpoint. +func NewService(provider Provider, dir string) (*Service, error) { + if provider == nil || !safeText(provider.ID(), 100) { + return nil, fmt.Errorf("provider required") + } + if dir == "" { + return nil, fmt.Errorf("cache directory required") + } + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + s := &Service{provider: provider, dir: dir, now: time.Now, entries: map[string]entry{}, failures: map[string]failure{}, pending: map[string]chan struct{}{}, slots: make(chan struct{}, 4), tokens: 4, tokenAt: time.Now()} + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + // This directory contains only our hex-key records; other files are untouched. + for _, file := range files { + name := file.Name() + if len(name) != 69 || !strings.HasSuffix(name, ".json") { + continue + } + if _, err := hex.DecodeString(name[:64]); err != nil { + continue + } + path := filepath.Join(dir, name) + info, err := file.Info() + if err != nil || !info.Mode().IsRegular() { + continue + } + if info.Size() > maxBodyBytes { + _ = os.Remove(path) + continue + } + b, err := os.ReadFile(path) + if err != nil { + continue + } + var e entry + if !validStoredJSON(b) || json.Unmarshal(b, &e) != nil || e.Key != name[:64] || e.Key != e.Request.key(provider.ID()) || !validRequest(e.Request) || !validPage(e.Result, e.Request, provider.ID()) || !s.usable(e.Result, s.now()) { + _ = os.Remove(path) + continue + } + s.entries[e.Key] = e + } + s.trimLocked() + return s, nil +} +func validRequest(r Request) bool { + if !r.View.Valid() || r.Page < 0 || r.Page > 10000 { + return false + } + if r.Query == "" { + return r.Limit == 0 + } + return r.Page == 0 && r.View == Popular && len([]rune(r.Query)) >= 2 && len(r.Query) <= 200 && safeText(r.Query, 200) && r.Limit > 0 && r.Limit <= 100 +} +func validPage(p Page, r Request, provider string) bool { + if p.Items == nil || p.View != r.View || p.Page != r.Page || p.Query != r.Query || p.Cache.FetchedAt.IsZero() || (p.Cache.Status != "fresh" && p.Cache.Status != "stale") || (p.Total != nil && *p.Total < 0) { + return false + } + maxItems := 200 + if r.Query != "" { + maxItems = r.Limit + if p.Total != nil || p.HasMore { + return false + } + } + if len(p.Items) > maxItems || (r.Query == "" && (p.Total == nil || *p.Total < int64(len(p.Items)))) { + return false + } + seen := map[string]bool{} + for _, i := range p.Items { + if i.ProviderID != provider || !safeText(i.ID, 1024) || !strings.HasPrefix(i.ID, provider+":") || seen[i.ID] || !safeText(i.Title, 512) || !safeText(i.SkillNameHint, 512) || i.Metrics.Installs < 0 { + return false + } + seen[i.ID] = true + if i.Metrics.Window != "all-time" && i.Metrics.Window != "24h" && i.Metrics.Window != "1h" { + return false + } + u, err := url.Parse(i.ProviderURL) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || !safeText(i.ProviderURL, 2048) { + return false + } + switch i.Source.Kind { + case "github": + if !repository(i.Source.Repository) || i.Source.URL != "https://github.com/"+i.Source.Repository || i.Availability != "unverified" { + return false + } + case "external": + if i.Source.Repository != "" || !safeHTTPSURL(i.Source.URL) || i.Availability != "unsupported-source" { + return false + } + default: + return false + } + } + return true +} +func (s *Service) usable(p Page, now time.Time) bool { + age := now.Sub(p.Cache.FetchedAt) + return age >= 0 && age <= maxStale +} +func (s *Service) Get(ctx context.Context, r Request) (Page, error) { + if !validRequest(r) { + return Page{}, fmt.Errorf("invalid catalog request") + } + key := r.key(s.provider.ID()) + for { + s.mu.Lock() + now := s.now() + e, exists := s.entries[key] + ttl := 30 * time.Minute + if r.Query != "" { + ttl = 10 * time.Minute + } + if exists && s.usable(e.Result, now) && now.Sub(e.Result.Cache.FetchedAt) < ttl { + p := e.Result + p.Cache.Status = "fresh" + s.mu.Unlock() + return p, nil + } + if waiting, ok := s.pending[key]; ok { + s.mu.Unlock() + select { + case <-waiting: + continue + case <-ctx.Done(): + return Page{}, &ProviderError{} + } + } + unavailable := func(retry time.Duration) (Page, error) { + s.mu.Unlock() + if exists && s.usable(e.Result, now) { + p := e.Result + p.Cache.Status = "stale" + p.Warnings = append(append([]string{}, p.Warnings...), "provider_unavailable") + return p, nil + } + return Page{}, &ProviderError{RetryAfter: retry} + } + until := s.failures[key].until + if s.providerRetry.After(until) { + until = s.providerRetry + } + if now.Before(until) { + return unavailable(until.Sub(now)) + } + s.tokens = min(4, s.tokens+now.Sub(s.tokenAt).Seconds()*2) + s.tokenAt = now + if s.tokens < 1 { + return unavailable(time.Second) + } + select { + case s.slots <- struct{}{}: + default: + return unavailable(time.Second) + } + s.tokens-- + done := make(chan struct{}) + s.pending[key] = done + s.mu.Unlock() + // A refresh is shared across callers and bounded independently of the + // initiating browser, whose abort must not poison this key for everyone. + fetchCtx, cancel := context.WithTimeout(context.Background(), 9*time.Second) + var result Page + var err error + if r.Query == "" { + result, err = s.provider.Browse(fetchCtx, r.View, r.Page) + } else { + result, err = s.provider.Search(fetchCtx, r.Query, r.Limit) + } + cancel() + s.mu.Lock() + now = s.now() + result.Cache = CacheInfo{Status: "fresh", FetchedAt: now} + if err == nil && !validPage(result, r, s.provider.ID()) { + err = &ProviderError{} + } + if err == nil { + s.entries[key] = entry{Key: key, Request: r, Result: result} + delete(s.failures, key) + // Persistence failure never replaces a good on-disk record with bad bytes. + if !s.persist(s.entries[key]) { + result.Warnings = append(result.Warnings, "cache_persistence_unavailable") + } + s.trimLocked() + } else { + retry := 30 * time.Second + var providerErr *ProviderError + if errors.As(err, &providerErr) && providerErr.RetryAfter > 0 { + retry = min(providerErr.RetryAfter, time.Duration(1<<63-1)-time.Second) + s.providerRetry = now.Add(retry) + } + if len(s.failures) >= maxEntries { + for k, f := range s.failures { + if !now.Before(f.until) { + delete(s.failures, k) + } + } + if len(s.failures) >= maxEntries { + for k := range s.failures { + delete(s.failures, k) + break + } + } + } + s.failures[key] = failure{until: now.Add(retry)} + err = &ProviderError{RetryAfter: retry} + } + delete(s.pending, key) + close(done) + <-s.slots + s.mu.Unlock() + if err == nil { + return result, nil + } + if exists && s.usable(e.Result, now) { + p := e.Result + p.Cache.Status = "stale" + p.Warnings = append(append([]string{}, p.Warnings...), "provider_unavailable") + return p, nil + } + return Page{}, err + } +} +func (s *Service) persist(e entry) bool { + b, err := json.Marshal(e) + if err != nil || len(b) > maxBodyBytes { + return false + } + f, err := os.CreateTemp(s.dir, ".catalog-*") + if err != nil { + return false + } + name := f.Name() + defer os.Remove(name) + if _, err = f.Write(b); err != nil { + f.Close() + return false + } + if err = f.Sync(); err != nil { + f.Close() + return false + } + if f.Close() != nil { + return false + } + return os.Rename(name, filepath.Join(s.dir, e.Key+".json")) == nil +} +func (s *Service) trimLocked() { + if len(s.entries) <= maxEntries { + return + } + keys := make([]string, 0, len(s.entries)) + for k := range s.entries { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + return s.entries[keys[i]].Result.Cache.FetchedAt.Before(s.entries[keys[j]].Result.Cache.FetchedAt) + }) + for _, k := range keys[:len(keys)-maxEntries] { + delete(s.entries, k) + _ = os.Remove(filepath.Join(s.dir, k+".json")) + } +} + +// Required presence is checked before decoding disk records: Go zero values +// alone cannot distinguish a missing false/zero field from a valid value. +func requiredObject(raw json.RawMessage, names ...string) (map[string]json.RawMessage, bool) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return nil, false + } + for _, name := range names { + value, ok := fields[name] + if !ok || string(value) == "null" { + return nil, false + } + } + return fields, true +} +func validStoredJSON(b []byte) bool { + root, ok := requiredObject(b, "key", "request", "result") + if !ok { + return false + } + if _, ok = requiredObject(root["request"], "view", "page", "query", "limit"); !ok { + return false + } + page, ok := requiredObject(root["result"], "items", "hasMore", "page", "view", "cache") + if !ok { + return false + } + if _, ok = requiredObject(page["cache"], "status", "fetchedAt"); !ok { + return false + } + var items []json.RawMessage + if json.Unmarshal(page["items"], &items) != nil { + return false + } + for _, raw := range items { + item, ok := requiredObject(raw, "id", "providerId", "providerUrl", "title", "skillNameHint", "source", "metrics", "availability") + if !ok { + return false + } + if _, ok = requiredObject(item["source"], "kind", "url"); !ok { + return false + } + if _, ok = requiredObject(item["metrics"], "installs", "window"); !ok { + return false + } + } + return true +} + +func safeHTTPSURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && u.Scheme == "https" && u.Host != "" && u.User == nil && safeText(raw, 2048) +} diff --git a/internal/skillscatalog/skillssh.go b/internal/skillscatalog/skillssh.go new file mode 100644 index 000000000..8e39be90b --- /dev/null +++ b/internal/skillscatalog/skillssh.go @@ -0,0 +1,167 @@ +package skillscatalog + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +const maxBodyBytes = 2 << 20 +const skillsOrigin = "https://www.skills.sh" + +var sourceDomain = regexp.MustCompile(`(?i)^[a-z0-9]([a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$`) +var githubOwner = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`) +var githubRepository = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,100}$`) + +type SkillsSH struct { + client *http.Client + origin string +} + +// NewSkillsSH pins the anonymous upstream origin. Redirects are disabled so +// neither a redirect nor caller headers can turn this into an arbitrary proxy. +func NewSkillsSH() *SkillsSH { return newSkillsSH(skillsOrigin, http.DefaultTransport) } +func newSkillsSH(origin string, transport http.RoundTripper) *SkillsSH { + return &SkillsSH{origin: origin, client: &http.Client{Timeout: 8 * time.Second, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} +func (*SkillsSH) ID() string { return "skills-sh" } + +type upstreamSkill struct { + Source *string `json:"source"` + SkillID *string `json:"skillId"` + Name *string `json:"name"` + Installs *int64 `json:"installs"` +} +type upstreamPage struct { + Skills *[]upstreamSkill `json:"skills"` + Total *int64 `json:"total"` + HasMore *bool `json:"hasMore"` + Page *int `json:"page"` + Count *int64 `json:"count"` +} + +func (p *SkillsSH) Browse(ctx context.Context, view View, page int) (Page, error) { + route := map[View]string{Popular: "all-time", Trending: "trending", Hot: "hot"}[view] + if route == "" || page < 0 { + return Page{}, &ProviderError{} + } + raw, err := p.fetch(ctx, "/api/skills/"+route+"/"+strconv.Itoa(page)) + if err != nil { + return Page{}, err + } + if raw.Total == nil || *raw.Total < 0 || raw.HasMore == nil || raw.Page == nil || *raw.Page != page { + return Page{}, &ProviderError{} + } + result, err := normalize(raw, view) + if err != nil { + return Page{}, err + } + if int64(len(result.Items)) > *raw.Total || len(result.Items) > 200 { + return Page{}, &ProviderError{} + } + result.Total, result.HasMore, result.Page = raw.Total, *raw.HasMore, page + return result, nil +} +func (p *SkillsSH) Search(ctx context.Context, q string, limit int) (Page, error) { + raw, err := p.fetch(ctx, "/api/search?"+url.Values{"q": {q}, "limit": {strconv.Itoa(limit)}}.Encode()) + if err != nil { + return Page{}, err + } + if raw.Count == nil || *raw.Count < 0 || raw.Skills == nil || int64(len(*raw.Skills)) > *raw.Count || len(*raw.Skills) > limit { + return Page{}, &ProviderError{} + } + result, err := normalize(raw, Popular) + result.Query = q + result.Warnings = []string{"search_results_limited"} + return result, err +} +func (p *SkillsSH) fetch(ctx context.Context, path string) (upstreamPage, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.origin+path, nil) + if err != nil { + return upstreamPage{}, &ProviderError{} + } + req.Header.Set("Accept", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return upstreamPage{}, &ProviderError{} + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + retry := time.Duration(0) + if resp.StatusCode == 429 { + retry = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) + } + return upstreamPage{}, &ProviderError{RetryAfter: retry} + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1)) + if err != nil || len(data) > maxBodyBytes || !utf8.Valid(data) { + return upstreamPage{}, &ProviderError{} + } + var raw upstreamPage + if json.Unmarshal(data, &raw) != nil || raw.Skills == nil { + return upstreamPage{}, &ProviderError{} + } + return raw, nil +} +func parseRetryAfter(s string, now time.Time) time.Duration { + if seconds, err := strconv.Atoi(s); err == nil && seconds > 0 { + return time.Duration(min(seconds, int((time.Duration(1<<63-1)-time.Second)/time.Second))) * time.Second + } + if date, err := http.ParseTime(s); err == nil && date.After(now) { + return date.Sub(now) + } + return 30 * time.Second +} +func safeText(s string, maxLen int) bool { + return strings.TrimSpace(s) != "" && len(s) <= maxLen && !strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) }) +} +func repository(source string) bool { + parts := strings.Split(source, "/") + if len(parts) != 2 { + return false + } + return githubOwner.MatchString(parts[0]) && githubRepository.MatchString(parts[1]) && parts[1] != "." && parts[1] != ".." && !strings.HasSuffix(parts[1], ".git") +} +func normalize(raw upstreamPage, view View) (Page, error) { + result := Page{Items: make([]SkillListing, 0), View: view} + if raw.Skills == nil { + return Page{}, &ProviderError{} + } + window := map[View]string{Popular: "all-time", Trending: "24h", Hot: "1h"}[view] + seen := map[string]bool{} + for _, s := range *raw.Skills { + if s.Source == nil || s.SkillID == nil || s.Name == nil || s.Installs == nil || *s.Installs < 0 || !safeText(*s.Source, 256) || !safeText(*s.SkillID, 256) || !safeText(*s.Name, 512) { + return Page{}, &ProviderError{} + } + source, skillID := *s.Source, *s.SkillID + // The provider slug identifies the listing page; the raw name is the + // CLI selection hint. Preserve it exactly, leaving admissibility to CLI. + hint := *s.Name + providerURL := skillsOrigin + "/" + if repository(source) { + providerURL = skillsOrigin + "/" + source + "/" + url.PathEscape(skillID) + } else if !strings.Contains(source, "/") && sourceDomain.MatchString(source) { + providerURL = skillsOrigin + "/site/" + strings.ToLower(source) + "/" + url.PathEscape(strings.ToLower(skillID)) + } + item := SkillListing{ID: fmt.Sprintf("skills-sh:%s:%s", source, skillID), ProviderID: "skills-sh", ProviderURL: providerURL, Title: *s.Name, SkillNameHint: hint, Source: Source{Kind: "external", URL: providerURL}, Metrics: Metrics{Installs: *s.Installs, Window: window}, Availability: "unsupported-source"} + if repository(source) { + item.Source = Source{Kind: "github", Repository: source, URL: "https://github.com/" + source} + item.Availability = "unverified" + } + if seen[item.ID] { + return Page{}, &ProviderError{} + } + seen[item.ID] = true + result.Items = append(result.Items, item) + } + return result, nil +} From 5cc823460c8a1b34753f3e18bbc558e766917562 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 24 Oct 2026 12:15:36 +0300 Subject: [PATCH 02/10] feat(landing): add unified Skills discovery and installation catalog --- docs/SKILLS_CATALOG_OPERATIONS.md | 11 + .../landing-pages-plugins-index-vue.md | 24 ++ landing/DESIGN.md | 32 +++ landing/PRODUCT.md | 36 +++ landing/assets/styles/registry.scss | 121 +++++++++ .../components/registry/AppMultiSelect.vue | 9 +- landing/components/registry/CatalogCard.vue | 9 + .../registry/CatalogSearchField.vue | 30 +++ landing/components/registry/PluginCatalog.vue | 28 +- .../components/registry/RegistryDirectory.vue | 2 +- .../registry/RegistryPluginCard.vue | 7 +- landing/components/registry/SkillCard.vue | 134 ++++++++++ landing/components/registry/SkillsCatalog.vue | 179 +++++++++++++ .../components/registry/UnifiedCatalog.vue | 52 ++++ landing/composables/useSkillsCatalog.ts | 119 ++++++++ landing/data/skillClients.ts | 9 + landing/locales/ar.json | 66 ++++- landing/locales/en.json | 66 ++++- landing/locales/es.json | 66 ++++- landing/locales/fr.json | 66 ++++- landing/locales/hi.json | 66 ++++- landing/locales/pt.json | 66 ++++- landing/locales/ru.json | 66 ++++- landing/locales/uk.json | 66 ++++- landing/locales/zh.json | 66 ++++- landing/nuxt.config.ts | 3 + landing/pages/plugins/index.vue | 36 ++- .../tests/browser/community-install.spec.ts | 2 +- landing/tests/browser/hero-orbit.spec.ts | 4 +- landing/tests/browser/landing.spec.ts | 2 +- landing/tests/browser/skills-catalog.spec.ts | 107 ++++++++ landing/tests/registry-i18n.test.ts | 2 +- landing/tests/skills-catalog.test.ts | 253 ++++++++++++++++++ landing/types/skillsCatalog.generated.ts | 210 +++++++++++++++ landing/types/skillsCatalog.ts | 6 + landing/utils/commands.ts | 9 +- landing/utils/skillsCatalog.ts | 82 ++++++ 37 files changed, 1973 insertions(+), 139 deletions(-) create mode 100644 landing/.impeccable/surfaces/landing-pages-plugins-index-vue.md create mode 100644 landing/DESIGN.md create mode 100644 landing/PRODUCT.md create mode 100644 landing/components/registry/CatalogCard.vue create mode 100644 landing/components/registry/CatalogSearchField.vue create mode 100644 landing/components/registry/SkillCard.vue create mode 100644 landing/components/registry/SkillsCatalog.vue create mode 100644 landing/components/registry/UnifiedCatalog.vue create mode 100644 landing/composables/useSkillsCatalog.ts create mode 100644 landing/data/skillClients.ts create mode 100644 landing/tests/browser/skills-catalog.spec.ts create mode 100644 landing/tests/skills-catalog.test.ts create mode 100644 landing/types/skillsCatalog.generated.ts create mode 100644 landing/types/skillsCatalog.ts create mode 100644 landing/utils/skillsCatalog.ts diff --git a/docs/SKILLS_CATALOG_OPERATIONS.md b/docs/SKILLS_CATALOG_OPERATIONS.md index f33187fc9..0a73db9a2 100644 --- a/docs/SKILLS_CATALOG_OPERATIONS.md +++ b/docs/SKILLS_CATALOG_OPERATIONS.md @@ -6,6 +6,17 @@ service is separate from the static GitHub Pages landing site. Only remain on GitHub Pages. The API contract is [skills-catalog.openapi.yaml](contracts/skills-catalog.openapi.yaml). +Regenerate the landing DTOs after changing the contract: + +```sh +npm exec --yes --package=openapi-typescript@7.13.0 -- openapi-typescript docs/contracts/skills-catalog.openapi.yaml -o landing/types/skillsCatalog.generated.ts +``` + +The generator is pinned for reproducibility and is a development tool only. +The provider adapter uses anonymous skills.sh website/CLI endpoints, which are +undocumented and may change. Its interface and cached last good results contain +that dependency; the authenticated `/api/v1` service is not used. + ## Destination and owned paths Deployment inventory supplied for this service: diff --git a/landing/.impeccable/surfaces/landing-pages-plugins-index-vue.md b/landing/.impeccable/surfaces/landing-pages-plugins-index-vue.md new file mode 100644 index 000000000..c8f6f56d9 --- /dev/null +++ b/landing/.impeccable/surfaces/landing-pages-plugins-index-vue.md @@ -0,0 +1,24 @@ +--- +version: 1 +slug: "landing-pages-plugins-index-vue" +primary_target: "landing/pages/plugins/index.vue" +related_targets: ["landing/components/registry/PluginCatalog.vue"] +--- + +# Catalog discovery extension + +Mode: Operate. Target: existing home catalog and /plugins/. + +## Direction contract + +THESIS: Find an agent capability, then choose how to install it. Plugins and Skills are distinct modes of the same catalog; installation controls appear only after intent. + +OWN-WORLD: Preserve the incumbent registry typography, light/dark surface tokens, accent, control geometry and existing agent multiselect. Extend shared components rather than copying card or input styles. No new decorative hero. + +STORY: A visitor switches to Skills, searches or browses real skills.sh entries, inspects a GitHub source, clicks Install, chooses agents and copies the exact command. The CLI resolves and reviews actual source contents. + +FIRST VIEWPORT: Two prominent URL-addressable Plugins/Skills tabs above the existing full-width search and compact view selector; real result cards below. No empty basket, agent panel or terminal instructions competing with browsing. Selected installation opens within its card using the existing controls. + +FORM: Precisely specified extension of an established catalog. No new visual-world or concept-seed round is applicable. Desktop-first; keyboard access, visible focus, honest loading, stale, empty and error states. + +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance diff --git a/landing/DESIGN.md b/landing/DESIGN.md new file mode 100644 index 000000000..7d6df6fdd --- /dev/null +++ b/landing/DESIGN.md @@ -0,0 +1,32 @@ +# Catalog design + +The Plugins and Skills modes extend the existing registry visual system: Inter +for interface copy, JetBrains Mono for commands, shared light/dark surface and +foreground tokens, and the existing cyan/purple accents. This change does not +introduce a separate brand or asset set. + +Both modes use `CatalogSearchField` and `CatalogCard`. Skills installation uses +the existing `AppMultiSelect`, persisted installation preferences, and +`CommandSnippet`. Skills remain a separate data model from reviewed packages. +The mode is reflected in `?tab=skills`; search and sorting survive navigation. + +The directory's page heading already introduces Skills, so its catalog heading +is available to assistive technology without repeating visible introductory +copy. On the home page the catalog keeps its own visible heading. Collapsed +Skill cards are compact; agent selection and commands appear after Install. +Agent choices come from the generated client capability manifest. + +Desktop is the acceptance target. Review includes real skills.sh data, search, +installation choices, copy, navigation, pagination, and outage/empty states. +The source and provider links identify provenance without claiming compatibility +or review. The CLI validates the repository and exact declared Skill name. + +Impeccable's final detector identified two inherited patterns in unrelated +registry styles (gradient text and a workflow accent stripe). They are outside +this catalog extension. The independent finish review requested removal of +duplicate introductory copy and excess collapsed-card spacing; both are fixed. +No raster assets were added; raster provenance is therefore not applicable. + +The React CLI picker remains unchanged. A later migration can share these Vue +components through a small static entry point while retaining its local server +and installation contract. diff --git a/landing/PRODUCT.md b/landing/PRODUCT.md new file mode 100644 index 000000000..e7ba7207f --- /dev/null +++ b/landing/PRODUCT.md @@ -0,0 +1,36 @@ +# Product + + + +## Platform + +web + +## Users + +People using coding agents who want to find a useful plugin or Agent Skill and install it without learning repository layouts or installer internals. + +## Product Purpose + +Universal Agent Plugins offers one catalog and installer journey for plugins and Skills. Success is finding an item, choosing relevant agents when installing, and running the exact command shown. + +## Operating Context + +The public Nuxt site is generated statically. The CLI installs public GitHub Skills through an existing scanner, explicit review and a saved, versioned environment. The Skills discovery API is a separate service on the owner's Hetzner production host. The owner supplied universal-agent-plugins.com; the proposed API subdomain is api.universal-agent-plugins.com. + +## Capabilities and Constraints + +- Plugins and Skills are distinct catalog modes with URL-addressable selection. +- Reuse the site's existing controls, agent selector, command presentation and visual language. +- Skills discovery initially uses skills.sh through a replaceable provider contract and last-known-good caching. +- Show installation choices after the user chooses to install an item. Do not put implementation details into the visitor flow. +- Preserve GitHub source identity; do not invent a skill folder from a registry slug. The CLI checks the selected source before installing. +- Desktop is the current priority. Replacing the React CLI picker with shared landing components is a future phase, not this catalog extension. +- Preserve plugin installation behavior, existing availability boundaries and supported-client metadata. + +## Product Principles + +- Let people browse before asking where to install. +- Keep equivalent controls and actions consistent across plugins and Skills. +- Preserve a useful cached catalog during upstream outages and show its age honestly. +- Use real source metadata and real installation commands; do not imply that a listing establishes compatibility or verification. diff --git a/landing/assets/styles/registry.scss b/landing/assets/styles/registry.scss index a921455b9..db28c9499 100644 --- a/landing/assets/styles/registry.scss +++ b/landing/assets/styles/registry.scss @@ -3167,3 +3167,124 @@ img { transform: none; } } + +/* Catalog modes share the existing control and card vocabulary. */ +.catalog-modes { + display: flex; + width: fit-content; + gap: 4px; + padding: 5px; + margin-bottom: 32px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface-solid); +} +.catalog-mode { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-width: 160px; + min-height: 46px; + padding: 10px 22px; + border-radius: 10px; + color: var(--muted); + font-size: 0.95rem; + font-weight: 700; + text-decoration: none; + transition: + background 0.18s ease, + color 0.18s ease; +} +.catalog-mode svg { + width: 19px; + height: 19px; +} +.catalog-mode:hover { + color: var(--text); + background: var(--surface-raised); +} +.catalog-mode--active { + color: var(--text); + background: color-mix(in srgb, var(--primary) 19%, var(--surface)); +} +.catalog-mode:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 3px; +} +.skills-catalog__controls { + grid-template-columns: minmax(240px, 1fr) minmax(210px, 0.4fr); +} +.skills-catalog__disclosure { + max-width: 75ch; + margin: 5px 0 0; + color: var(--muted); + font-size: 0.72rem; + line-height: 1.6; +} +.skill-card { + min-height: 180px; +} +.skill-card .plugin-card__identity-copy { + width: 100%; +} +.skill-card h3 { + text-transform: none; +} +.skill-card__metric { + margin: 20px 0 12px; + color: var(--text); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; +} +.skill-card__links { + display: flex; + flex-wrap: wrap; + gap: 8px 18px; + margin-bottom: 24px; + font-size: 0.73rem; +} +.skill-card__links a { + color: var(--muted); + text-underline-offset: 3px; +} +.skill-card__links a:hover, +.skill-card__links a:focus-visible { + color: var(--cyan); +} +.skill-card .app-multiselect__trigger { + width: 100%; +} +.skill-card--skeleton { + gap: 18px; + justify-content: center; + pointer-events: none; +} +.skill-card--skeleton div { + height: 14px; + width: 65%; + border-radius: 5px; + background: var(--surface-raised); +} +.skill-card--skeleton div:first-child { + width: 80%; + height: 20px; +} +.skill-card--skeleton div:last-child { + width: 100%; + height: 38px; + margin-top: 25px; +} +@media (max-width: 640px) { + .catalog-modes { + width: 100%; + } + .catalog-mode { + flex: 1; + min-width: 0; + padding-inline: 16px; + } + .skills-catalog__controls { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/landing/components/registry/AppMultiSelect.vue b/landing/components/registry/AppMultiSelect.vue index 51ab07859..456f24a28 100644 --- a/landing/components/registry/AppMultiSelect.vue +++ b/landing/components/registry/AppMultiSelect.vue @@ -22,6 +22,7 @@ const props = defineProps<{ label: string; autoOption?: AutoDetectOption; autoSelected?: boolean; + automaticHelp?: string; }>(); const emit = defineEmits<{ @@ -107,7 +108,7 @@ function toggle(value: string) { {{ option.label }}{{ option.description }}

{{ - autoSelected ? t('registryUi.multi.automaticHelp') : t('registryUi.multi.manualHelp') + autoSelected + ? (automaticHelp ?? t('registryUi.multi.automaticHelp')) + : t('registryUi.multi.manualHelp') }}

diff --git a/landing/components/registry/CatalogCard.vue b/landing/components/registry/CatalogCard.vue new file mode 100644 index 000000000..71154dabd --- /dev/null +++ b/landing/components/registry/CatalogCard.vue @@ -0,0 +1,9 @@ + + + diff --git a/landing/components/registry/CatalogSearchField.vue b/landing/components/registry/CatalogSearchField.vue new file mode 100644 index 000000000..5d99766cd --- /dev/null +++ b/landing/components/registry/CatalogSearchField.vue @@ -0,0 +1,30 @@ + + + diff --git a/landing/components/registry/PluginCatalog.vue b/landing/components/registry/PluginCatalog.vue index 15908d740..9dc06fbdc 100644 --- a/landing/components/registry/PluginCatalog.vue +++ b/landing/components/registry/PluginCatalog.vue @@ -260,28 +260,12 @@ watch([query, category, component, source, trust, client, authentication, owner]

{{ intro ?? t('registryUi.catalog.searchByCapabilityComponentOrSource') }}