diff --git a/COPY.md b/COPY.md index 71022852..c3668d2a 100644 --- a/COPY.md +++ b/COPY.md @@ -37,7 +37,15 @@ These exact strings. Do not paraphrase them per page. | Home section: trending | Trending skills this week. | guidance only: the homepage trending section heading | | Home section: changes | Keep up with skill changes. | guidance only: the homepage section heading for watching and the digest | | Home section: authoring | Write a skill for your project. | guidance only: the homepage section heading for making a Skill | -| Nav: CLI | CLI | guidance only: the header nav and the mobile menu item that link `/cli` | +| Nav: developers | Developers | guidance only: the header menu trigger, and the group label in the mobile menu. The menu holds the five cards below | +| Nav: CLI | CLI · Search, run, install, and keep Skills current. · Install the CLI | guidance only: the first card in the Developers menu: name, line, action. Links `/cli`. The mobile menu shows the name only. The line is the CLI line and the action is the CLI install label, so change those first | +| Nav: MCP server | MCP server · For ChatGPT, Claude, and any app that speaks MCP. · Add the MCP server | guidance only: a Developers menu card. Links `/developers?setup=mcp`. The line is the MCP setup card on `/developers`, and the action is its section heading | +| Nav: SDK | SDK · For your own code, with the TypeScript SDK or plain HTTP. · Call the skilld API | guidance only: a Developers menu card. Links `/developers?setup=api`. The line is the API setup card on `/developers`, and the action is its section heading | +| Nav: make | Make a skill · Guides and three authoring Skills to write and review your own. · Get the steps | guidance only: a Developers menu card. Links `/make-skill`. The line is the Step 05 description | +| Nav: Skillgen | Skillgen · Keeps your package skill current with a draft pull request after each release. · Set up Skillgen | guidance only: the last Developers menu card. Links `/skillgen` | +| Nav: setup guides | All setup guides | guidance only: the link under the Developers menu cards. Links `/developers` | +| Skillgen lead | Keep your package skill current. After each release tag, Skillgen opens a draft pull request that updates the Skill in your repository. You review it and decide what merges. | guidance only: the `/skillgen` line under the H1. Its first two sentences are the page's meta description | +| Skillgen opt-in | Skillgen opens a draft pull request after each release tag. It runs only on the repositories you turn on here. | guidance only: the intro of the Skillgen view on `/me?view=skillgen`. The `/skillgen` step 02 says the same in its own words: Skillgen never runs on a repository you did not turn on | | CLI H1 | The skilld CLI | guidance only: the `/cli` H1 and its OG title | | CLI line | Search, run, install, and keep Skills current. | guidance only: the `/cli` line under the H1. It is the CLI's own `--help` line, so change it in the CLI first | | CLI intro | Give your Agent Skills that real maintainers write. A run reads the current source every time, and one command updates the Skills you install. Install the skilld Skill once, and your Agent searches and loads Skills on its own. | guidance only: the `/cli` intro under the line | diff --git a/DESIGN.md b/DESIGN.md index 1b3b194d..21d3d7d1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -78,7 +78,7 @@ Practically: - One accent color used sparingly (10% of surface area, max) - UI chrome in monospace at small sizes; content in the sans-serif - Borders, not shadows, define boundaries -- No glow, no gradient, no blur, no decorative flourish in UI chrome. The textures in "Brand System" are the only decoration, and each has one job +- No glow, no gradient, no blur, no decorative flourish in UI chrome. The textures in "Brand System" are the only decoration, and each has one job. The cards in the header's Developers menu are the one exception: each carries one atmosphere layer - Compact components: tight padding, defined shapes, minimal radius - Whitespace is intentional structure, not generous filler @@ -460,3 +460,4 @@ These are editorial sets, not necessarily database collections yet. If a set has - **Trending shares the /skills shell**: `/skills/trending` uses the full-width layout from `/skills`. Its heading replaces `CompactPageHeader` and lines up with the results column. A sticky 14rem sidebar holds the range links, then the weekly CTA and the skilld install. Nothing sits right of the board, so the board takes every column the sidebar leaves. Rows switch on their own width, and the post carousel shows two cards where it has room. On narrow screens the ranges are a compact row above the board, and the CTA stays after the fifth row. Harlan asked for this on 2026-10-06. It supersedes the 2026-10-01 call that dropped the 9/3 sidebar for taking a quarter of the board's width: that sidebar sat right of a 64rem board, and this one sits left of a board that runs the full page width. - **Trending rows scan in one pass**: the rank is a plain mono number, and the first three are a step darker. Each metadata line runs source, stars, then the signal that ranked the row, and an empty slot renders nothing. Descriptions stop at two lines, the run chip is as wide as its command, and the post carousel shows whole cards only. The sidebar invitation drops the email preview and the card. Its button is the rail's one solid rose element, so the install chip beside it inks its dot. Harlan asked for this polish on 2026-10-06. - **A six-step lifecycle band replaces the four tiles**: under the hero, one band maps the whole product in its real order: find, run, install or fork, keep current, make, build on. The site and the CLI share one line. Each step is one link: a mono `01` marker and step name, then one sans line that opens with its pitch line where COPY.md has one. A line of stone dots joins the steps. Run, the default, carries the band's one rose dot, so no step lights rose on hover. Six columns from 1024px; below that a vertical list with the dots down the left. Keep the band no taller than the tiles it replaced: the hero and the band must fit a 1440×900 viewport. Harlan approved it on 2026-10-06. +- **A Developers menu groups the build surfaces**: the desktop header keeps Trending Skills and Find Skills as links, and folds CLI, MCP server, SDK, Make a skill and Skillgen into one Developers menu, as nuxtseo.com does. The trigger is a button, so a keyboard opens the panel without leaving the page; the panel's last link reaches `/developers`. Each item is a card with a lucide icon tile and one `.editorial-atmosphere` layer. The three ways to connect share the top row, and the two authoring cards share the row below. CLI takes rose because it is the recommended path; the others take ember or stone. The mobile menu lists the same five items as a plain group. Harlan asked for this on 2026-10-06. diff --git a/GLOSSARY.md b/GLOSSARY.md index 57698f66..dd1d6df6 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -28,6 +28,7 @@ Shared terms match the skilld CLI glossary (`skilld-dev/skilld/GLOSSARY.md`). Th | skilld token | `cli_tokens`, `layers/identity` | Author 1—N token | "skilld token" | | provenance | Skill detail, cards | Skill 1—1 author and source link | "written by", "source" | | behavior | `skilld-protocol/behaviors`, Skill page | Skill 1—N behavior | "Skill behaviors" | +| Skillgen | `workers/skill-harness`, `/skillgen` | Repository 1—N draft pull request | "Skillgen" | | task search | search box, `layers/registry` | sentence 1—N Skill | "Find skills for this task" | Collisions @@ -241,6 +242,16 @@ Existing route paths, table names, and protocol fields keep their identifiers. **Casing:** lowercase in prose and UI. +### Skillgen + +**Is:** the `skilld-skillgen` GitHub App. After each release tag, it opens a draft pull request that updates a package skill. The maintainer reviews it and decides what merges. + +**Use for:** the App and its per-repository switch. A maintainer turns Skillgen on for each repository on their account page, and it runs nowhere else. It supports npm packages only. + +**Never:** bot (in UI copy), skill generator, auto-update. + +**Casing:** `Skillgen` in prose and UI. `skilld-skillgen` only where GitHub shows the App's name. + ### guide skill **Is:** a curation tag for a Skill not tied to a package. Distributed as a git Skill, tagged for filtering in browse views. diff --git a/app/app.vue b/app/app.vue index 2700d6a8..64b9c320 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,5 +1,7 @@ + + + + diff --git a/app/utils/developer-menu.ts b/app/utils/developer-menu.ts new file mode 100644 index 00000000..060157d9 --- /dev/null +++ b/app/utils/developer-menu.ts @@ -0,0 +1,65 @@ +/** + * The header's Developers menu. Desktop shows each item as a card; the mobile + * menu lists the same items. COPY.md owns every string here. + */ +export interface DeveloperMenuItem { + label: string + description: string + /** The card's call to action. */ + action: string + icon: string + to: string + /** The card's `.editorial-atmosphere` palette. Rose marks the recommended path. */ + palette: 'rose' | 'ember' | 'stone' + /** `connect` cards share the top row; `author` cards share the row below. */ + group: 'connect' | 'author' +} + +export const developerMenuItems: DeveloperMenuItem[] = [ + { + label: 'CLI', + description: 'Search, run, install, and keep Skills current.', + action: 'Install the CLI', + icon: 'i-lucide-square-terminal', + to: '/cli', + palette: 'rose', + group: 'connect', + }, + { + label: 'MCP server', + description: 'For ChatGPT, Claude, and any app that speaks MCP.', + action: 'Add the MCP server', + icon: 'i-lucide-plug', + to: '/developers?setup=mcp', + palette: 'ember', + group: 'connect', + }, + { + label: 'SDK', + description: 'For your own code, with the TypeScript SDK or plain HTTP.', + action: 'Call the skilld API', + icon: 'i-lucide-braces', + to: '/developers?setup=api', + palette: 'stone', + group: 'connect', + }, + { + label: 'Make a skill', + // generate-package-skill, generate-project-skill and review-skill. + description: 'Guides and three authoring Skills to write and review your own.', + action: 'Get the steps', + icon: 'i-lucide-pen-line', + to: '/make-skill', + palette: 'stone', + group: 'author', + }, + { + label: 'Skillgen', + description: 'Keeps your package skill current with a draft pull request after each release.', + action: 'Set up Skillgen', + icon: 'i-lucide-git-pull-request-draft', + to: '/skillgen', + palette: 'ember', + group: 'author', + }, +] diff --git a/docs/runbooks/skill-github-app-pilot.md b/docs/runbooks/skillgen.md similarity index 70% rename from docs/runbooks/skill-github-app-pilot.md rename to docs/runbooks/skillgen.md index 6b941296..2f0ef2ef 100644 --- a/docs/runbooks/skill-github-app-pilot.md +++ b/docs/runbooks/skillgen.md @@ -1,10 +1,24 @@ -# GitHub App pilot +# Skillgen GitHub App -The pilot maintains an existing package skill through draft pull requests. -It accepts public repositories listed in `GITHUB_PILOT_REPOSITORIES`. -The first repository is `harlan-zw/nuxt-skew-protection`. +Skillgen is the `skilld-skillgen` GitHub App. +It maintains an existing package skill through draft pull requests. +It runs only on a public repository whose maintainer turned it on at `/me?view=skillgen`. +Maintainers set it up from [/skillgen](https://skilld.dev/skillgen). + +## Opt-in + +Installing the App alone queues nothing. +A signed-in maintainer turns Skillgen on for each repository, and `skillgen_repositories` records it. +Turning it on needs admin or maintain access, a public repository, a root `package.json`, and the Skill where the Worker reads it. +Skillgen supports npm packages only. + +Before the Worker queues a job, it asks `POST /api/internal/skillgen/opt-ins` which repositories in the event opted in. +If the site does not answer, the webhook returns 503 and queues nothing. Redeliver the event from the App settings. + +The site and the Worker share one secret. +Set `NUXT_SKILLGEN_TOKEN` on the site Worker and `SKILLGEN_SITE_TOKEN` on `skilld-harness-proof` to the same value. ## Register the App @@ -20,7 +34,6 @@ Open the printed local URL in your personal GitHub browser. The helper creates a public App owned by `skilld-dev`. Use a GitHub account allowed to register Apps for that organization. Other accounts can install the App on selected repositories. -The Worker still processes only its configured pilot repositories. It saves the converted App secrets with mode `600`. It converts the signing key to PKCS8 before saving it. It rejects a callback with invalid state or a missing registration cookie. @@ -33,7 +46,7 @@ Pass the file through stdin. Never print its contents. Also configure the model credential and `PROOF_TOKEN` described in the [Harness runbook](./skill-harness-proof.md). Deploy through the existing Actions workflow with `target=harness-proof`. -Use the saved installation URL. Select only the pilot repository. +Use the saved installation URL. Select only the package repository. Installation starts a job for its latest tag. New tag creation starts another job automatically. @@ -51,7 +64,7 @@ It does not independently verify Sigstore signatures. The baseline comes from an exact default branch commit. Supported locations are `skills//SKILL.md` and the root `SKILL.md`. The baseline can include Markdown files under `references/`. -It must fit the Harness limits and the pilot's 64 KiB baseline limit. +It must fit the Harness limits and the App's 64 KiB baseline limit. The App mints a single-repository installation token for each GitHub phase. The container receives no GitHub or model credential. @@ -81,5 +94,7 @@ Duplicate deliveries reuse a job. Different tags resolving to the same commit reuse the recorded target. Generation failures persist as job outcomes. They produce no pull request. -This pilot has one global queue and one active container. +The App has one global queue and one active container. +Jobs run one at a time, and the thirty minute deadline includes time in the queue. +If 64 jobs are queued, the webhook answers 503. GitHub does not redeliver it. Organization keys and optional shared capacity require separate onboarding and budgets. diff --git a/layers/identity/app/components/_SkillgenRepositories.vue b/layers/identity/app/components/_SkillgenRepositories.vue new file mode 100644 index 00000000..9319411d --- /dev/null +++ b/layers/identity/app/components/_SkillgenRepositories.vue @@ -0,0 +1,103 @@ + + + diff --git a/layers/identity/app/layouts/account.vue b/layers/identity/app/layouts/account.vue index c05c48ce..82e55406 100644 --- a/layers/identity/app/layouts/account.vue +++ b/layers/identity/app/layouts/account.vue @@ -12,10 +12,11 @@ function restoreNavigationFocus(event: Event) { } const navigation = computed(() => [ - { label: 'Your skills', icon: 'i-lucide-heart', to: '/me', active: route.path === '/me' && !['email', 'repositories', 'account'].includes(String(route.query.view)) }, + { label: 'Your skills', icon: 'i-lucide-heart', to: '/me', active: route.path === '/me' && !['email', 'repositories', 'skillgen', 'account'].includes(String(route.query.view)) }, { label: 'Repository coverage', icon: 'i-lucide-git-fork', to: '/me?view=repositories', active: route.path === '/me' && route.query.view === 'repositories' }, { label: 'Email updates', icon: 'i-lucide-mail', to: '/me?view=email', active: route.path === '/me' && route.query.view === 'email' }, { label: 'Set up your agent', icon: 'i-lucide-plug', to: '/developers', active: route.path === '/developers' }, + { label: 'Skillgen', icon: 'i-lucide-git-pull-request-draft', to: '/me?view=skillgen', active: route.path === '/me' && route.query.view === 'skillgen' }, { label: 'Devices and tokens', icon: 'i-lucide-terminal', to: '/me/devices', active: route.path.startsWith('/me/devices') || route.path.startsWith('/me/cli-tokens') }, { label: 'Account', icon: 'i-lucide-settings', to: '/me?view=account', active: route.path === '/me' && route.query.view === 'account' }, ]) diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue index 4ce8ad89..2ea558d6 100644 --- a/layers/identity/app/pages/me/index.vue +++ b/layers/identity/app/pages/me/index.vue @@ -9,6 +9,7 @@ import type { } from '../../../shared/contracts/account' import type { StarsSyncResponse } from '../../utils/sync-starred-repos' import { accountDeletionConfirmed } from '../../../shared/contracts/account' +import SkillgenRepositories from '../../components/_SkillgenRepositories.vue' import { identityAccountQueries, identityAccountQueryOptions } from '../../queries/account' import { syncStarredRepos } from '../../utils/sync-starred-repos' @@ -166,9 +167,9 @@ function sourceDescription(source: string): string { const route = useRoute() const view = computed(() => { const value = route.query.view - return value === 'email' || value === 'repositories' || value === 'account' ? value : 'skills' + return value === 'email' || value === 'repositories' || value === 'skillgen' || value === 'account' ? value : 'skills' }) -const viewTitle = computed(() => ({ skills: 'Your skills', email: 'Email updates', repositories: 'Repository coverage', account: 'Account' })[view.value]) +const viewTitle = computed(() => ({ skills: 'Your skills', email: 'Email updates', repositories: 'Repository coverage', skillgen: 'Skillgen', account: 'Account' })[view.value]) useSeoMeta({ title: viewTitle, robots: 'noindex' }) const toast = useToast() async function clearWelcomeQuery() { @@ -487,6 +488,8 @@ async function deleteAccount() { + +

{{ watchedRepositoryCount }} {{ watchedRepositoryCount === 1 ? 'repository supports' : 'repositories support' }} your skill updates. diff --git a/layers/identity/server/api/internal/skillgen/opt-ins.post.ts b/layers/identity/server/api/internal/skillgen/opt-ins.post.ts new file mode 100644 index 00000000..dcb0652b --- /dev/null +++ b/layers/identity/server/api/internal/skillgen/opt-ins.post.ts @@ -0,0 +1,16 @@ +import { defineApiHandler } from '#shared/server/handler' +import { skillgenService } from '../../../policies/skillgen-service' +import { skillgenOptInsPresenter } from '../../../presenters/skillgen' +import { SkillgenOptInsBody } from '../../../schemas/skillgen' +import { optedInSkillgenRepositories } from '../../../utils/skillgen' + +/** The skill-harness Worker asks which repositories in one GitHub event opted in to Skillgen. */ +export default defineApiHandler({ + schema: SkillgenOptInsBody, + policy: [skillgenService], + handler: async ({ event, body, platform }) => { + setHeader(event, 'cache-control', 'no-store') + return optedInSkillgenRepositories(platform.db, body.repositories) + }, + presenter: skillgenOptInsPresenter, +}) diff --git a/layers/identity/server/api/me/skillgen/index.get.ts b/layers/identity/server/api/me/skillgen/index.get.ts new file mode 100644 index 00000000..27125951 --- /dev/null +++ b/layers/identity/server/api/me/skillgen/index.get.ts @@ -0,0 +1,22 @@ +import { defineApiHandler } from '#shared/server/handler' +import { authenticated } from '../../../policies/authenticated' +import { skillgenRepositoriesPresenter } from '../../../presenters/skillgen' +import { githubUserGet, listMaintainedRepositories, skillgenCandidates, skillgenRefusal } from '../../../utils/skillgen' +import { requireGithubUserToken, requireUserRow } from '../../../utils/users' + +/** + * Lists the public repositories the account maintains that hold a Skill in + * the registry, each with its Skillgen opt-in. + */ +export default defineApiHandler({ + policy: [authenticated], + handler: async ({ event, platform }) => { + const u = await requireUserRow(event) + const token = await requireGithubUserToken(platform.db, u.id, useRuntimeConfig(event).tokenKey as string) + const listed = await listMaintainedRepositories(githubUserGet(token)) + if (listed._tag === 'GithubUnavailable') + throw createError(skillgenRefusal(listed)) + return skillgenCandidates(platform.db, listed.repositories) + }, + presenter: skillgenRepositoriesPresenter, +}) diff --git a/layers/identity/server/api/me/skillgen/index.put.ts b/layers/identity/server/api/me/skillgen/index.put.ts new file mode 100644 index 00000000..129cbe7b --- /dev/null +++ b/layers/identity/server/api/me/skillgen/index.put.ts @@ -0,0 +1,47 @@ +import type { SkillgenOptInResult } from '../../../../shared/contracts/skillgen' +import { defineApiHandler } from '#shared/server/handler' +import { authenticated } from '../../../policies/authenticated' +import { browserSession } from '../../../policies/browser-session' +import { skillgenOptInPresenter } from '../../../presenters/skillgen' +import { SkillgenRepositoryPutBody } from '../../../schemas/skillgen' +import { + checkSkillgenEligibility, + githubUserGet, + optInSkillgenRepository, + optOutSkillgenRepository, + skillgenOptInUser, + skillgenRefusal, +} from '../../../utils/skillgen' +import { requireGithubUserToken, requireUserRow } from '../../../utils/users' + +/** + * Turns Skillgen on or off for one repository. + * + * On needs admin or maintain access and a repository the Worker can run on. + * Off needs the account that turned it on, or current admin or maintain access. + */ +export default defineApiHandler({ + schema: SkillgenRepositoryPutBody, + policy: [authenticated, browserSession], + handler: async ({ event, body, platform }): Promise => { + const u = await requireUserRow(event) + const repository = { owner: body.owner, repo: body.repo } + if (!body.optedIn && await skillgenOptInUser(platform.db, repository) === u.id) { + await optOutSkillgenRepository(platform.db, repository) + return { _tag: 'Saved', ...repository, optedIn: false } + } + const token = await requireGithubUserToken(platform.db, u.id, useRuntimeConfig(event).tokenKey as string) + const eligibility = await checkSkillgenEligibility(githubUserGet(token), repository) + const maintains = eligibility._tag !== 'NotFound' && eligibility._tag !== 'NotMaintainer' && eligibility._tag !== 'GithubUnavailable' + if (!body.optedIn && maintains) { + await optOutSkillgenRepository(platform.db, repository) + return { _tag: 'Saved', ...repository, optedIn: false } + } + if (eligibility._tag !== 'Eligible') + return { _tag: 'Refused', message: skillgenRefusal(eligibility).message } + const canonical = { owner: eligibility.owner, repo: eligibility.repo } + await optInSkillgenRepository(platform.db, u.id, canonical, Date.now()) + return { _tag: 'Saved', ...canonical, optedIn: true } + }, + presenter: skillgenOptInPresenter, +}) diff --git a/layers/identity/server/policies/skillgen-service.ts b/layers/identity/server/policies/skillgen-service.ts new file mode 100644 index 00000000..fbe128e0 --- /dev/null +++ b/layers/identity/server/policies/skillgen-service.ts @@ -0,0 +1,17 @@ +import type { Policy } from '#shared/server/handler' +import { createHash, timingSafeEqual } from 'node:crypto' +import { getHeader } from 'h3' + +/** + * The request carries the skill-harness Worker's `NUXT_SKILLGEN_TOKEN`. + * Digests compare in constant time, so the check leaks nothing through timing. + */ +export const skillgenService: Policy = ({ event }) => { + const secret = useRuntimeConfig(event).skillgenToken + const authorization = getHeader(event, 'authorization') + if (!authorization?.startsWith('Bearer ') || typeof secret !== 'string' || !secret) + return false + const provided = createHash('sha256').update(authorization.slice(7)).digest() + const expected = createHash('sha256').update(secret).digest() + return timingSafeEqual(provided, expected) +} diff --git a/layers/identity/server/presenters/skillgen.ts b/layers/identity/server/presenters/skillgen.ts new file mode 100644 index 00000000..a413a884 --- /dev/null +++ b/layers/identity/server/presenters/skillgen.ts @@ -0,0 +1,17 @@ +import type { SkillgenOptInResult, SkillgenRepositoryEntry } from '../../shared/contracts/skillgen' +import type { SkillgenRepositoryRow } from '../utils/skillgen' + +export function skillgenRepositoriesPresenter(rows: SkillgenRepositoryRow[]): { items: SkillgenRepositoryEntry[] } { + return { items: rows.map(row => ({ owner: row.owner, repo: row.repo, optedIn: row.optedIn })) } +} + +export function skillgenOptInPresenter(result: SkillgenOptInResult): SkillgenOptInResult { + return result._tag === 'Saved' + ? { _tag: 'Saved', owner: result.owner, repo: result.repo, optedIn: result.optedIn } + : { _tag: 'Refused', message: result.message } +} + +/** The Worker reads `repositories`: lowercased `owner/repo` names that opted in. */ +export function skillgenOptInsPresenter(fullNames: string[]): { repositories: string[] } { + return { repositories: fullNames } +} diff --git a/layers/identity/server/schemas/skillgen.ts b/layers/identity/server/schemas/skillgen.ts new file mode 100644 index 00000000..66b8108e --- /dev/null +++ b/layers/identity/server/schemas/skillgen.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +const githubName = z.string().regex(/^[\w.-]{1,100}$/) + +export const SkillgenRepositoryPutBody = z.object({ + owner: githubName, + repo: githubName, + optedIn: z.boolean(), +}) + +export type SkillgenRepositoryPutBody = z.infer + +/** The skill-harness Worker asks about one GitHub event's repositories at a time. */ +export const SkillgenOptInsBody = z.object({ + repositories: z.array(z.string().regex(/^[\w.-]{1,100}\/[\w.-]{1,100}$/)).max(100), +}) diff --git a/layers/identity/server/utils/account-deletion.ts b/layers/identity/server/utils/account-deletion.ts index 102fa64b..06e82f0c 100644 --- a/layers/identity/server/utils/account-deletion.ts +++ b/layers/identity/server/utils/account-deletion.ts @@ -40,6 +40,7 @@ const ACCOUNT_ROW_DELETES = [ WHERE installation_id IN (SELECT installation_id FROM github_app_installations WHERE account_id = ?1)`, `DELETE FROM github_app_installations WHERE account_id = ?1`, `DELETE FROM github_app_user_authorizations WHERE account_id = ?1`, + `DELETE FROM skillgen_repositories WHERE user_id = ?1`, `DELETE FROM users WHERE id = ?1`, ] as const diff --git a/layers/identity/server/utils/skillgen.ts b/layers/identity/server/utils/skillgen.ts new file mode 100644 index 00000000..875ed430 --- /dev/null +++ b/layers/identity/server/utils/skillgen.ts @@ -0,0 +1,203 @@ +/// +import { z } from 'zod' + +/** + * Skillgen opt-in. A maintainer turns Skillgen on per repository, and the + * skill-harness Worker queues jobs only for those. Eligibility mirrors the + * Worker's own checks: a public npm package whose Skill sits where the Worker + * reads it. Skillgen supports npm only, so a root `package.json` is required. + */ + +export interface SkillgenRepository { owner: string, repo: string } + +export type SkillgenEligibility + = | ({ _tag: 'Eligible' } & SkillgenRepository) + | { _tag: 'NotFound' } + | { _tag: 'NotPublic' } + | { _tag: 'NotMaintainer' } + | { _tag: 'NoPackageJson' } + | { _tag: 'NoSkill', expected: string } + | { _tag: 'GithubUnavailable', status: number } + +/** Reads one GitHub REST path as the signed-in account. */ +export type GithubGet = (path: string) => Promise + +export function githubUserGet(token: string, fetcher: typeof fetch = fetch): GithubGet { + return path => fetcher(`https://api.github.com${path}`, { + headers: { + 'accept': 'application/vnd.github+json', + 'authorization': `Bearer ${token}`, + 'user-agent': 'skilld.dev', + 'x-github-api-version': '2022-11-28', + }, + }) +} + +const repositorySchema = z.object({ + full_name: z.string(), + private: z.boolean(), + archived: z.boolean().optional(), + permissions: z.object({ admin: z.boolean().optional(), maintain: z.boolean().optional() }).optional(), +}) +const contentSchema = z.object({ content: z.string(), encoding: z.literal('base64') }) +const packageSchema = z.object({ name: z.string().min(1) }) + +function canMaintain(repository: z.infer): boolean { + return !!(repository.permissions?.admin || repository.permissions?.maintain) +} + +function splitFullName(fullName: string): SkillgenRepository { + const [owner = '', repo = ''] = fullName.split('/') + return { owner, repo } +} + +async function packageName(github: GithubGet, prefix: string): Promise { + const response = await github(`${prefix}/contents/package.json`) + if (response.status === 404) + return null + if (!response.ok) + return { status: response.status } + const content = contentSchema.safeParse(await response.json()) + if (!content.success) + return null + const parsed = packageSchema.safeParse(parseJsonText(atob(content.data.content.replaceAll('\n', '')))) + return parsed.success ? parsed.data.name : null +} + +/** A `package.json` that is not JSON counts as missing, so it returns undefined instead of throwing. */ +function parseJsonText(text: string): unknown { + try { + return JSON.parse(text) + } + catch { + return undefined + } +} + +/** + * Checks one repository the way the Worker will. The account must hold admin + * or maintain rights, because opting in lets an App write to the repository. + */ +export async function checkSkillgenEligibility(github: GithubGet, ref: SkillgenRepository): Promise { + const prefix = `/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}` + const response = await github(prefix) + if (response.status === 404) + return { _tag: 'NotFound' } + if (!response.ok) + return { _tag: 'GithubUnavailable', status: response.status } + const repository = repositorySchema.parse(await response.json()) + if (repository.private) + return { _tag: 'NotPublic' } + if (!canMaintain(repository)) + return { _tag: 'NotMaintainer' } + + const name = await packageName(github, prefix) + if (name === null) + return { _tag: 'NoPackageJson' } + if (typeof name === 'object') + return { _tag: 'GithubUnavailable', status: name.status } + + // The Worker reads `skills//SKILL.md`, then a root `SKILL.md`. + const expected = `skills/${name.split('/').at(-1)}/SKILL.md` + for (const path of [expected, 'SKILL.md']) { + const skill = await github(`${prefix}/contents/${path}`) + if (skill.ok) + return { _tag: 'Eligible', ...splitFullName(repository.full_name) } + if (skill.status !== 404) + return { _tag: 'GithubUnavailable', status: skill.status } + } + return { _tag: 'NoSkill', expected } +} + +const MAX_REPOSITORY_PAGES = 3 + +/** Public repositories the account can maintain, newest push first. At most 300. */ +export async function listMaintainedRepositories(github: GithubGet): Promise<{ _tag: 'Ok', repositories: SkillgenRepository[] } | { _tag: 'GithubUnavailable', status: number }> { + const repositories: SkillgenRepository[] = [] + for (let page = 1; page <= MAX_REPOSITORY_PAGES; page++) { + const response = await github(`/user/repos?visibility=public&affiliation=owner,collaborator,organization_member&sort=pushed&per_page=100&page=${page}`) + if (!response.ok) + return { _tag: 'GithubUnavailable', status: response.status } + const items = z.array(repositorySchema).parse(await response.json()) + for (const item of items) { + if (!item.private && !item.archived && canMaintain(item)) + repositories.push(splitFullName(item.full_name)) + } + if (items.length < 100) + break + } + return { _tag: 'Ok', repositories } +} + +export interface SkillgenRepositoryRow extends SkillgenRepository { optedIn: boolean } + +/** + * Keeps the repositories that hold at least one Skill in the registry, and + * marks the ones already opted in. Uses the registry's casing. + */ +export async function skillgenCandidates(db: D1Database, repositories: SkillgenRepository[]): Promise { + if (!repositories.length) + return [] + const { results } = await db.prepare( + `SELECT r.owner AS owner, r.repo AS repo, o.owner IS NOT NULL AS opted_in + FROM json_each(?1) AS j + JOIN repos r ON r.owner = json_extract(j.value, '$[0]') COLLATE NOCASE + AND r.repo = json_extract(j.value, '$[1]') COLLATE NOCASE + LEFT JOIN skillgen_repositories o ON o.owner = r.owner AND o.repo = r.repo + WHERE EXISTS (SELECT 1 FROM skills s WHERE s.owner = r.owner AND s.repo = r.repo) + ORDER BY j.key`, + ).bind(JSON.stringify(repositories.map(item => [item.owner, item.repo]))).all<{ owner: string, repo: string, opted_in: number }>() + return results.map(row => ({ owner: row.owner, repo: row.repo, optedIn: row.opted_in === 1 })) +} + +export async function optInSkillgenRepository(db: D1Database, userId: number, repository: SkillgenRepository, now: number): Promise { + await db.prepare( + `INSERT INTO skillgen_repositories (owner, repo, user_id, opted_in_at) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (owner, repo) DO UPDATE SET user_id = excluded.user_id, opted_in_at = excluded.opted_in_at`, + ).bind(repository.owner, repository.repo, userId, now).run() +} + +export async function optOutSkillgenRepository(db: D1Database, repository: SkillgenRepository): Promise { + await db.prepare(`DELETE FROM skillgen_repositories WHERE owner = ?1 AND repo = ?2`).bind(repository.owner, repository.repo).run() +} + +/** The subset of `owner/repo` names that opted in, lowercased. The Worker asks with at most 100. */ +export async function optedInSkillgenRepositories(db: D1Database, fullNames: string[]): Promise { + if (!fullNames.length) + return [] + const { results } = await db.prepare( + `SELECT lower(owner || '/' || repo) AS full_name FROM skillgen_repositories + WHERE lower(owner || '/' || repo) IN (SELECT lower(value) FROM json_each(?1))`, + ).bind(JSON.stringify(fullNames)).all<{ full_name: string }>() + return results.map(row => row.full_name) +} + +/** The account that opted the repository in, or null when it is off. */ +export async function skillgenOptInUser(db: D1Database, repository: SkillgenRepository): Promise { + const row = await db.prepare(`SELECT user_id FROM skillgen_repositories WHERE owner = ?1 AND repo = ?2`) + .bind(repository.owner, repository.repo) + .first<{ user_id: number }>() + return row?.user_id ?? null +} + +type Ineligible = Exclude + +/** The HTTP status and the sentence the account page shows for a refused opt-in. */ +export function skillgenRefusal(result: Ineligible): { statusCode: number, message: string } { + switch (result._tag) { + case 'NotFound': + return { statusCode: 404, message: 'GitHub has no public repository with that name.' } + case 'NotPublic': + return { statusCode: 422, message: 'Skillgen runs only on public repositories.' } + case 'NotMaintainer': + return { statusCode: 403, message: 'You need admin or maintain access to this repository.' } + case 'NoPackageJson': + return { statusCode: 422, message: 'Skillgen supports npm packages only. Add a package.json with a name at the repository root.' } + case 'NoSkill': + return { statusCode: 422, message: `Skillgen updates an existing Skill. Add it at ${result.expected} or as SKILL.md at the root.` } + case 'GithubUnavailable': + return result.status === 401 + ? { statusCode: 401, message: 'Your GitHub access ended. Sign in with GitHub again.' } + : { statusCode: 502, message: 'GitHub did not answer. Try again.' } + } +} diff --git a/layers/identity/shared/contracts/skillgen.ts b/layers/identity/shared/contracts/skillgen.ts new file mode 100644 index 00000000..a9e546ba --- /dev/null +++ b/layers/identity/shared/contracts/skillgen.ts @@ -0,0 +1,11 @@ +/** One repository on the account's Skillgen page, with its opt-in. */ +export interface SkillgenRepositoryEntry { + owner: string + repo: string + optedIn: boolean +} + +/** A refusal is an expected answer, so it arrives as a value with the sentence to show. */ +export type SkillgenOptInResult + = | ({ _tag: 'Saved' } & SkillgenRepositoryEntry) + | { _tag: 'Refused', message: string } diff --git a/layers/marketing/app/pages/skillgen.vue b/layers/marketing/app/pages/skillgen.vue new file mode 100644 index 00000000..33606177 --- /dev/null +++ b/layers/marketing/app/pages/skillgen.vue @@ -0,0 +1,134 @@ + + + diff --git a/layers/marketing/app/utils/page-admissions.ts b/layers/marketing/app/utils/page-admissions.ts index 468fb144..c5b2c0c7 100644 --- a/layers/marketing/app/utils/page-admissions.ts +++ b/layers/marketing/app/utils/page-admissions.ts @@ -43,6 +43,7 @@ export const FREEZE_AUDIT_PATHS: readonly string[] = [ '/docs/cli', '/cli', '/developers', + '/skillgen', '/privacy', '/learn/private-repositories', '/learn/create-agent-skills', diff --git a/migrations/0140_skillgen_repositories.sql b/migrations/0140_skillgen_repositories.sql new file mode 100644 index 00000000..ecf06ab7 --- /dev/null +++ b/migrations/0140_skillgen_repositories.sql @@ -0,0 +1,16 @@ +-- Skillgen runs only on repositories a maintainer opted in on skilld.dev. +-- Installing the skilld-skillgen GitHub App alone starts nothing. The +-- skill-harness Worker reads this table through +-- /api/internal/skillgen/opt-ins before it queues a job. +-- +-- Owner and repository keep GitHub's casing and compare without case, as +-- GitHub does. Account deletion removes the account's rows. +CREATE TABLE IF NOT EXISTS skillgen_repositories ( + owner TEXT NOT NULL COLLATE NOCASE, + repo TEXT NOT NULL COLLATE NOCASE, + user_id INTEGER NOT NULL, + opted_in_at INTEGER NOT NULL, + PRIMARY KEY (owner, repo) +); + +CREATE INDEX IF NOT EXISTS idx_skillgen_repositories_user ON skillgen_repositories(user_id); diff --git a/nuxt.config.ts b/nuxt.config.ts index 26504157..50c125e1 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -409,6 +409,8 @@ export default defineNuxtConfig({ adminSecret: '', tokenKey: '', checkinToken: '', + // Shared with the skill-harness Worker as SKILLGEN_SITE_TOKEN. It reads Skillgen opt-ins. + skillgenToken: '', publicSiteUrl: 'https://skilld.dev', // Task search, the search box's opt-in model answer. Set the Worker // variable NUXT_TASK_SEARCH_ENABLED=false to switch it off without a diff --git a/package.json b/package.json index 1eedaca4..8389cb41 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "nuxt-auth-utils": "catalog:", "nuxt-og-image": "catalog:", "rangi": "catalog:", + "reka-ui": "catalog:", "skilld-protocol": "catalog:", "skilld-sdk": "catalog:", "tailwindcss": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91d683a1..9ad28b1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,6 +338,9 @@ catalogs: rangi: specifier: ^2.2.0 version: 2.2.0 + reka-ui: + specifier: 2.10.4 + version: 2.10.4 skilld-protocol: specifier: ^2.5.0 version: 2.5.0 @@ -496,6 +499,9 @@ importers: rangi: specifier: 'catalog:' version: 2.2.0 + reka-ui: + specifier: 'catalog:' + version: 2.10.4(vue@3.5.43(typescript@6.0.3)) skilld-protocol: specifier: 'catalog:' version: 2.5.0(patch_hash=40578b24b5375959d96e2286d7fdcf6424e9827d7c72c7705df3352dce1c2aac) @@ -2333,15 +2339,9 @@ packages: cpu: [x64] os: [win32] - '@internationalized/date@3.12.3': - resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==} - '@internationalized/date@3.12.4': resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} - '@internationalized/number@3.6.7': - resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} - '@internationalized/number@3.6.8': resolution: {integrity: sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==} @@ -4052,20 +4052,12 @@ packages: '@tanstack/virtual-core@3.17.11': resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==} - '@tanstack/virtual-core@3.17.7': - resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} - '@tanstack/vue-table@8.21.3': resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==} engines: {node: '>=12'} peerDependencies: vue: '>=3.2' - '@tanstack/vue-virtual@3.13.35': - resolution: {integrity: sha512-lOfSPvgPdlaH6Qy+CyIc3XpycitaSQ9GECndGpTuDiu+uDA1am+90yWXwzDSd/20ZM196ggWJLS+Qb6WjVd/OA==} - peerDependencies: - vue: ^2.7.0 || ^3.0.0 - '@tanstack/vue-virtual@3.13.39': resolution: {integrity: sha512-AG8sI+6nfrDvN6ixuHJL9LaJTf3//0V5VITZYW3UOaIuSVexnEzsbMu4LH/JhMfRTxwhUXPnLqkl1DlzkZbRtg==} peerDependencies: @@ -8219,11 +8211,6 @@ packages: resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} hasBin: true - reka-ui@2.10.3: - resolution: {integrity: sha512-nJGZbwcha8AcP2wbnjodfzsciTKBqp1mIzepC9Pi2xDI/YK3++ej3vpJOSPyxqrkq1Oorj4K+BdJkbVCV6x6ag==} - peerDependencies: - vue: '>= 3.4.0' - reka-ui@2.10.4: resolution: {integrity: sha512-kbS5GAbkHkYj0EVKAg5ZPEPndhBjeUFSa1Aq5m5ftQwyHnB1v5tw8X6fcwfKH/ry3StPXblMWM1DW82MVS71cw==} peerDependencies: @@ -9069,6 +9056,7 @@ packages: vaul-vue@0.4.1: resolution: {integrity: sha512-A6jOWOZX5yvyo1qMn7IveoWN91mJI5L3BUKsIwkg6qrTGgHs1Sb1JF/vyLJgnbN1rH4OOOxFbtqL9A46bOyGUQ==} peerDependencies: + reka-ui: ^2.0.0 vue: ^3.3.0 verkit@0.3.2: @@ -11040,18 +11028,10 @@ snapshots: '@img/sharp-win32-x64@0.35.4': optional: true - '@internationalized/date@3.12.3': - dependencies: - '@swc/helpers': 0.5.23 - '@internationalized/date@3.12.4': dependencies: '@swc/helpers': 0.5.23 - '@internationalized/number@3.6.7': - dependencies: - '@swc/helpers': 0.5.23 - '@internationalized/number@3.6.8': dependencies: '@swc/helpers': 0.5.23 @@ -12147,7 +12127,7 @@ snapshots: unplugin: 3.4.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) unplugin-auto-import: 21.1.0(@nuxt/kit@4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))))(@vueuse/core@14.4.0(vue@3.5.43(typescript@6.0.3)))(esbuild@0.28.2)(oxc-parser@0.143.0)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1)) unplugin-vue-components: 32.1.0(@nuxt/kit@4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.4)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))))(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.4)(vite@8.2.1(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.13)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3)) - vaul-vue: 0.4.1(vue@3.5.43(typescript@6.0.3)) + vaul-vue: 0.4.1(reka-ui@2.10.4(vue@3.5.43(typescript@6.0.3)))(vue@3.5.43(typescript@6.0.3)) vue-component-type-helpers: 3.3.11 optionalDependencies: valibot: 1.4.2(typescript@6.0.3) @@ -13354,18 +13334,11 @@ snapshots: '@tanstack/virtual-core@3.17.11': {} - '@tanstack/virtual-core@3.17.7': {} - '@tanstack/vue-table@8.21.3(vue@3.5.43(typescript@6.0.3))': dependencies: '@tanstack/table-core': 8.21.3 vue: 3.5.43(typescript@6.0.3) - '@tanstack/vue-virtual@3.13.35(vue@3.5.43(typescript@6.0.3))': - dependencies: - '@tanstack/virtual-core': 3.17.7 - vue: 3.5.43(typescript@6.0.3) - '@tanstack/vue-virtual@3.13.39(vue@3.5.43(typescript@6.0.3))': dependencies: '@tanstack/virtual-core': 3.17.11 @@ -18371,29 +18344,13 @@ snapshots: dependencies: jsesc: 3.1.0 - reka-ui@2.10.3(vue@3.5.43(typescript@6.0.3)): - dependencies: - '@floating-ui/dom': 1.8.0 - '@floating-ui/vue': 1.1.11(vue@3.5.43(typescript@6.0.3)) - '@internationalized/date': 3.12.3 - '@internationalized/number': 3.6.7 - '@tanstack/vue-virtual': 3.13.35(vue@3.5.43(typescript@6.0.3)) - '@vueuse/core': 14.4.0(vue@3.5.43(typescript@6.0.3)) - '@vueuse/shared': 14.4.0(vue@3.5.43(typescript@6.0.3)) - aria-hidden: 1.2.6 - defu: 6.1.7 - ohash: 2.0.12 - vue: 3.5.43(typescript@6.0.3) - transitivePeerDependencies: - - '@vue/composition-api' - reka-ui@2.10.4(vue@3.5.43(typescript@6.0.3)): dependencies: '@floating-ui/dom': 1.8.0 '@floating-ui/vue': 1.1.11(vue@3.5.43(typescript@6.0.3)) - '@internationalized/date': 3.12.3 - '@internationalized/number': 3.6.7 - '@tanstack/vue-virtual': 3.13.35(vue@3.5.43(typescript@6.0.3)) + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.8 + '@tanstack/vue-virtual': 3.13.39(vue@3.5.43(typescript@6.0.3)) '@vueuse/core': 14.4.0(vue@3.5.43(typescript@6.0.3)) '@vueuse/shared': 14.4.0(vue@3.5.43(typescript@6.0.3)) aria-hidden: 1.2.6 @@ -19351,10 +19308,10 @@ snapshots: vary@1.1.2: {} - vaul-vue@0.4.1(vue@3.5.43(typescript@6.0.3)): + vaul-vue@0.4.1(reka-ui@2.10.4(vue@3.5.43(typescript@6.0.3)))(vue@3.5.43(typescript@6.0.3)): dependencies: '@vueuse/core': 10.11.1(vue@3.5.43(typescript@6.0.3)) - reka-ui: 2.10.3(vue@3.5.43(typescript@6.0.3)) + reka-ui: 2.10.4(vue@3.5.43(typescript@6.0.3)) vue: 3.5.43(typescript@6.0.3) transitivePeerDependencies: - '@vue/composition-api' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6946d922..7f4c7e44 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -238,6 +238,9 @@ catalog: nuxt-og-image: ^6.9.1 nuxt-skew-protection: ^1.5.4 rangi: ^2.2.0 + # The header menu imports reka-ui directly. Keep it equal to the version + # @nuxt/ui pins, or the client ships two copies. + reka-ui: 2.10.4 skilld-protocol: ^2.5.0 skilld-sdk: 0.1.2 tailwindcss: ^4.3.3 diff --git a/scripts/skill-github-app-register.ts b/scripts/skill-github-app-register.ts index e83eec2e..713bae3f 100644 --- a/scripts/skill-github-app-register.ts +++ b/scripts/skill-github-app-register.ts @@ -44,7 +44,7 @@ async function handle(request: IncomingMessage, response: ServerResponse): Promi } if (url.pathname === `/setup/${state}`) { response.setHeader('Set-Cookie', `skilld_app_setup=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600`) - response.end(`

Register skilld under skilld-dev

This App reads package source and proposes draft Skill pull requests.

Other accounts can install it. The pilot processes only its configured repositories.

It requests Contents write and Pull requests write. Select one repository during installation.

`) + response.end(`

Register skilld under skilld-dev

This App reads package source and proposes draft Skill pull requests.

Other accounts can install it on their public package repositories.

It requests Contents write and Pull requests write. Select one repository during installation.

`) return } const supplied = Buffer.from(url.searchParams.get('state') ?? '') diff --git a/test/a11y.spec.ts b/test/a11y.spec.ts index a89c2477..cbaf7342 100644 --- a/test/a11y.spec.ts +++ b/test/a11y.spec.ts @@ -548,6 +548,42 @@ describe('accessibility: components', () => { wrapper.unmount() }) + it('headerNavigation opens the Developers panel with no violations', async () => { + const container = createIsolatedContainer() + const wrapper = await mountSuspended( + await loadComponent('_HeaderNavigation'), + { attachTo: container }, + ) + const trigger = wrapper.get('#header-developers-trigger') + expect(trigger.attributes('aria-expanded')).toBe('false') + + await trigger.trigger('click') + await nextTick() + + expect(trigger.attributes('aria-expanded')).toBe('true') + // reka-ui renders an aria-hidden focus proxy after an open trigger. It + // moves focus into the panel as soon as it gets focus, and Shift+Tab skips + // it, so focus never rests there. Only that span leaves the scan. + const results = await axe.run( + { include: [container], exclude: [['[data-navigation-menu-trigger] + span[aria-hidden="true"]']] }, + AXE_OPTIONS, + ) + expect(results.violations, formatViolations(results)).toHaveLength(0) + + // The panel's label points at the fixed trigger id that replaces reka-ui's. + const panel = container.querySelector('[aria-labelledby]')! + expect(container.querySelector(`#${panel.getAttribute('aria-labelledby')}`)).toBe(trigger.element) + expect([...panel.querySelectorAll('a')].map(link => link.getAttribute('href'))).toEqual([ + '/cli', + '/developers?setup=mcp', + '/developers?setup=api', + '/make-skill', + '/skillgen', + '/developers', + ]) + wrapper.unmount() + }) + it('runChip compact has no violations', async () => { const container = createIsolatedContainer() const wrapper = await mountSuspended( diff --git a/test/unit/account-deletion.test.ts b/test/unit/account-deletion.test.ts index 3c6273de..66827b02 100644 --- a/test/unit/account-deletion.test.ts +++ b/test/unit/account-deletion.test.ts @@ -177,6 +177,7 @@ function seedAccount(raw: DatabaseSync, account: Account, githubToken: string | NOW, NOW, ) + run(`INSERT INTO skillgen_repositories (owner, repo, user_id, opted_in_at) VALUES (?, 'package', ?, ?)`, login, id, NOW) run( `INSERT INTO artifact_resolutions ( id, request_fingerprint, state, requested_owner, requested_repository, selector_type, diff --git a/test/unit/skillgen-opt-in.test.ts b/test/unit/skillgen-opt-in.test.ts new file mode 100644 index 00000000..968f377a --- /dev/null +++ b/test/unit/skillgen-opt-in.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment node +import type { SqliteD1 } from './helpers/d1-sqlite' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + checkSkillgenEligibility, + optedInSkillgenRepositories, + optInSkillgenRepository, + optOutSkillgenRepository, + skillgenCandidates, +} from '../../layers/identity/server/utils/skillgen' +import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' + +const ref = { owner: 'harlan-zw', repo: 'nuxt-skew-protection' } +const prefix = '/repos/harlan-zw/nuxt-skew-protection' + +function file(text: string): Response { + return Response.json({ encoding: 'base64', content: btoa(text) }) +} + +/** A GitHub stub: each path answers its listed response, anything else 404s. */ +function github(routes: Record Response>) { + return async (path: string) => routes[path]?.() ?? new Response(null, { status: 404 }) +} + +const maintained = () => Response.json({ full_name: 'Harlan-Zw/nuxt-skew-protection', private: false, permissions: { admin: false, maintain: true } }) + +describe('skillgen eligibility', () => { + it('accepts a maintained npm package whose Skill sits under its unscoped name', async () => { + const result = await checkSkillgenEligibility(github({ + [prefix]: maintained, + [`${prefix}/contents/package.json`]: () => file(JSON.stringify({ name: '@harlan-zw/nuxt-skew-protection' })), + [`${prefix}/contents/skills/nuxt-skew-protection/SKILL.md`]: () => file('---\nname: x\n---'), + }), ref) + // GitHub's casing is stored, so later lookups match what the Worker sends. + expect(result).toEqual({ _tag: 'Eligible', owner: 'Harlan-Zw', repo: 'nuxt-skew-protection' }) + }) + + it('accepts a root SKILL.md', async () => { + const result = await checkSkillgenEligibility(github({ + [prefix]: maintained, + [`${prefix}/contents/package.json`]: () => file(JSON.stringify({ name: 'nuxt-skew-protection' })), + [`${prefix}/contents/SKILL.md`]: () => file('---\nname: x\n---'), + }), ref) + expect(result._tag).toBe('Eligible') + }) + + it('refuses a repository without a root package.json, since only npm is supported', async () => { + expect(await checkSkillgenEligibility(github({ [prefix]: maintained }), ref)).toEqual({ _tag: 'NoPackageJson' }) + expect(await checkSkillgenEligibility(github({ + [prefix]: maintained, + [`${prefix}/contents/package.json`]: () => file('not json'), + }), ref)).toEqual({ _tag: 'NoPackageJson' }) + }) + + it('names the Skill path it expected when none exists', async () => { + expect(await checkSkillgenEligibility(github({ + [prefix]: maintained, + [`${prefix}/contents/package.json`]: () => file(JSON.stringify({ name: '@scope/widget' })), + }), ref)).toEqual({ _tag: 'NoSkill', expected: 'skills/widget/SKILL.md' }) + }) + + it('refuses an account with only push access, and a private repository', async () => { + expect(await checkSkillgenEligibility(github({ + [prefix]: () => Response.json({ full_name: 'harlan-zw/nuxt-skew-protection', private: false, permissions: { admin: false, maintain: false, push: true } }), + }), ref)).toEqual({ _tag: 'NotMaintainer' }) + expect(await checkSkillgenEligibility(github({ + [prefix]: () => Response.json({ full_name: 'harlan-zw/nuxt-skew-protection', private: true, permissions: { admin: true } }), + }), ref)).toEqual({ _tag: 'NotPublic' }) + }) + + it('reports a GitHub failure instead of a refusal', async () => { + expect(await checkSkillgenEligibility(github({ [prefix]: () => new Response(null, { status: 502 }) }), ref)) + .toEqual({ _tag: 'GithubUnavailable', status: 502 }) + }) +}) + +describe('skillgen opt-ins', () => { + let fixture: SqliteD1 + + beforeEach(() => { + fixture = createSqliteD1(allMigrations()) + fixture.raw.exec(` + INSERT INTO repos (owner, repo) VALUES ('Harlan-Zw', 'nuxt-skew-protection'), ('harlan-zw', 'no-skills'); + INSERT INTO skills (name, owner, repo, display_name, slug) VALUES ('skew', 'Harlan-Zw', 'nuxt-skew-protection', 'Skew', 'skew'); + `) + }) + + afterEach(() => fixture.close()) + + it('lists only repositories with a registry Skill, matched without case', async () => { + const rows = await skillgenCandidates(fixture.db, [ + { owner: 'harlan-zw', repo: 'NUXT-SKEW-PROTECTION' }, + { owner: 'harlan-zw', repo: 'no-skills' }, + { owner: 'harlan-zw', repo: 'not-in-registry' }, + ]) + expect(rows).toEqual([{ owner: 'Harlan-Zw', repo: 'nuxt-skew-protection', optedIn: false }]) + }) + + it('answers the Worker with the opted-in subset until the repository opts out', async () => { + await optInSkillgenRepository(fixture.db, 7, { owner: 'Harlan-Zw', repo: 'nuxt-skew-protection' }, 1) + + expect(await optedInSkillgenRepositories(fixture.db, ['harlan-zw/nuxt-skew-protection', 'someone/else'])) + .toEqual(['harlan-zw/nuxt-skew-protection']) + expect(await skillgenCandidates(fixture.db, [ref])).toEqual([{ owner: 'Harlan-Zw', repo: 'nuxt-skew-protection', optedIn: true }]) + + await optOutSkillgenRepository(fixture.db, { owner: 'harlan-zw', repo: 'NUXT-skew-protection' }) + expect(await optedInSkillgenRepositories(fixture.db, ['harlan-zw/nuxt-skew-protection'])).toEqual([]) + }) +}) diff --git a/test/unit/skillgen-repositories.test.ts b/test/unit/skillgen-repositories.test.ts new file mode 100644 index 00000000..cd5fba36 --- /dev/null +++ b/test/unit/skillgen-repositories.test.ts @@ -0,0 +1,54 @@ +import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' +import { flushPromises } from '@vue/test-utils' +import { readBody } from 'h3' +import { describe, expect, it, vi } from 'vitest' + +const saved: unknown[] = [] + +registerEndpoint('/api/me/skillgen', { + method: 'GET', + handler: () => ({ + items: [ + { owner: 'harlan-zw', repo: 'nuxt-skew-protection', optedIn: false }, + { owner: 'harlan-zw', repo: 'monorepo', optedIn: false }, + ], + }), +}) + +registerEndpoint('/api/me/skillgen', { + method: 'PUT', + handler: async (event) => { + const body = await readBody<{ owner: string, repo: string, optedIn: boolean }>(event) + saved.push(body) + if (body.repo === 'monorepo') + return { _tag: 'Refused', message: 'Skillgen supports npm packages only. Add a package.json with a name at the repository root.' } + return { _tag: 'Saved', ...body } + }, +}) + +describe('skillgen repository switches', () => { + it('turns one repository on and shows why another was refused', async () => { + const wrapper = await mountSuspended( + await import('../../layers/identity/app/components/_SkillgenRepositories.vue').then(module => module.default), + ) + await flushPromises() + + const switches = wrapper.findAll('button[role="switch"]') + expect(switches.map(control => control.attributes('aria-checked'))).toEqual(['false', 'false']) + expect(wrapper.text()).toContain('harlan-zw/nuxt-skew-protection') + + await switches[0]!.trigger('click') + await flushPromises() + await switches[1]!.trigger('click') + await flushPromises() + + expect(saved).toEqual([ + { owner: 'harlan-zw', repo: 'nuxt-skew-protection', optedIn: true }, + { owner: 'harlan-zw', repo: 'monorepo', optedIn: true }, + ]) + await vi.waitFor(() => expect(wrapper.find('[role="alert"]').exists()).toBe(true)) + const after = wrapper.findAll('button[role="switch"]') + expect(after.map(control => control.attributes('aria-checked'))).toEqual(['true', 'false']) + expect(wrapper.get('[role="alert"]').text()).toBe('Skillgen supports npm packages only. Add a package.json with a name at the repository root.') + }) +}) diff --git a/workers/skill-harness/.dev.vars.example b/workers/skill-harness/.dev.vars.example index b3474215..08d14ad2 100644 --- a/workers/skill-harness/.dev.vars.example +++ b/workers/skill-harness/.dev.vars.example @@ -4,3 +4,5 @@ ANTHROPIC_API_KEY=example GITHUB_APP_ID=example GITHUB_APP_PRIVATE_KEY_PKCS8=example GITHUB_APP_WEBHOOK_SECRET=example +# Shared with the site as NUXT_SKILLGEN_TOKEN. Reads Skillgen opt-ins. +SKILLGEN_SITE_TOKEN=example diff --git a/workers/skill-harness/src/github-routes.ts b/workers/skill-harness/src/github-routes.ts index dae37752..271d0ecb 100644 --- a/workers/skill-harness/src/github-routes.ts +++ b/workers/skill-harness/src/github-routes.ts @@ -1,8 +1,9 @@ import { MAX_REQUEST_BYTES, parseJson, readBoundedBody } from './contracts' import { parseGithubEvent, verifyGithubSignature } from './github-events' +import { readSkillgenOptIns } from './skillgen-opt-ins' -export async function githubWebhook(request: Request, env: HarnessEnv): Promise { - if (!env.GITHUB_APP_WEBHOOK_SECRET || !env.GITHUB_APP_ID || !env.GITHUB_APP_PRIVATE_KEY_PKCS8) +export async function githubWebhook(request: Request, env: HarnessEnv, fetcher: typeof fetch = fetch): Promise { + if (!env.GITHUB_APP_WEBHOOK_SECRET || !env.GITHUB_APP_ID || !env.GITHUB_APP_PRIVATE_KEY_PKCS8 || !env.SKILLGEN_SITE_TOKEN) return Response.json({ code: 'APP_UNCONFIGURED' }, { status: 503 }) const body = await readBoundedBody(request, MAX_REQUEST_BYTES) if (body === undefined) @@ -17,10 +18,17 @@ export async function githubWebhook(request: Request, env: HarnessEnv): Promise< return Response.json({ code: 'INVALID_EVENT' }, { status: 400 }) if (event._tag === 'Ignored') return Response.json({ accepted: true, jobs: [] }, { status: 202 }) - const allowed = new Set(env.GITHUB_PILOT_REPOSITORIES.split(',').map(value => value.trim().toLowerCase())) + const optIns = await readSkillgenOptIns({ + siteUrl: env.SKILLD_SITE_URL, + token: env.SKILLGEN_SITE_TOKEN, + repositories: event.tags.map(tag => `${tag.owner}/${tag.name}`), + fetch: fetcher, + }) + if (optIns._tag === 'Unavailable') + return Response.json({ code: 'OPT_IN_UNAVAILABLE' }, { status: 503 }) const jobs = [] for (const tag of event.tags) { - if (!allowed.has(`${tag.owner}/${tag.name}`.toLowerCase())) + if (!optIns.repositories.has(`${tag.owner}/${tag.name}`.toLowerCase())) continue const job = await env.GITHUB_JOBS.getByName('github-app').enqueue(tag) if (job._tag === 'Busy') diff --git a/workers/skill-harness/src/skillgen-opt-ins.ts b/workers/skill-harness/src/skillgen-opt-ins.ts new file mode 100644 index 00000000..a0f15677 --- /dev/null +++ b/workers/skill-harness/src/skillgen-opt-ins.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +const answerSchema = z.object({ repositories: z.array(z.string()) }) + +/** skilld.dev answers at most this many repositories per request. */ +const BATCH = 100 + +/** + * Asks skilld.dev which repositories opted in to Skillgen. Maintainers opt in + * per repository on their account page, so an App install alone queues + * nothing. Any failed answer is `Unavailable`, and the caller queues nothing. + */ +export async function readSkillgenOptIns(input: { + siteUrl: string + token: string + repositories: string[] + fetch: typeof fetch +}): Promise<{ _tag: 'Ok', repositories: Set } | { _tag: 'Unavailable', status: number }> { + const optedIn = new Set() + for (let start = 0; start < input.repositories.length; start += BATCH) { + const response = await input.fetch(new URL('/api/internal/skillgen/opt-ins', input.siteUrl), { + method: 'POST', + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + headers: { 'authorization': `Bearer ${input.token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ repositories: input.repositories.slice(start, start + BATCH) }), + }) + if (!response.ok) + return { _tag: 'Unavailable', status: response.status } + const answer = answerSchema.safeParse(await response.json()) + if (!answer.success) + return { _tag: 'Unavailable', status: response.status } + for (const name of answer.data.repositories) + optedIn.add(name.toLowerCase()) + } + return { _tag: 'Ok', repositories: optedIn } +} diff --git a/workers/skill-harness/test/github-app.test.ts b/workers/skill-harness/test/github-app.test.ts index c90177d4..01bf0c18 100644 --- a/workers/skill-harness/test/github-app.test.ts +++ b/workers/skill-harness/test/github-app.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { githubInstallationClient, prepareTag, publishSkill } from '../src/github-client' import { parseGithubEvent, verifyGithubSignature } from '../src/github-events' +import { githubWebhook } from '../src/github-routes' const repository = { id: 10, name: 'package', private: false, owner: { login: 'harlan-zw' } } const installation = { id: 20 } @@ -153,3 +154,64 @@ describe('tag preparation', () => { expect(await prepareTag(request, api, fetcher)).toEqual({ _tag: 'Skipped', reason: 'PACKAGE_TAG_PROVENANCE_MISMATCH' }) }) }) + +describe('gitHub webhook', () => { + async function sign(secret: string, body: string): Promise { + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + const digest = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body))) + return `sha256=${Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')}` + } + + function setup(site: (request: Request) => Response) { + const enqueued: unknown[] = [] + const siteRequests: Request[] = [] + const env = { + GITHUB_APP_ID: '123', + GITHUB_APP_PRIVATE_KEY_PKCS8: 'key', + GITHUB_APP_WEBHOOK_SECRET: 'secret', + SKILLD_SITE_URL: 'https://skilld.dev', + SKILLGEN_SITE_TOKEN: 'site-token', + GITHUB_JOBS: { getByName: () => ({ enqueue: async (tag: unknown) => { + enqueued.push(tag) + return { _tag: 'Accepted', id: `job-${enqueued.length}` } + } }) }, + } as unknown as HarnessEnv + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + siteRequests.push(request.clone()) + return site(request) + }) as typeof fetch + async function deliver(event: string, payload: unknown): Promise { + const body = JSON.stringify(payload) + return githubWebhook(new Request('https://example.com/github/webhook', { + method: 'POST', + body, + headers: { 'x-github-event': event, 'x-hub-signature-256': await sign('secret', body) }, + }), env, fetcher) + } + return { enqueued, siteRequests, deliver } + } + + const compact = (name: string, id: number) => ({ id, name, full_name: `Maintainer/${name}`, private: false }) + + it('queues only the repositories their maintainers opted in on skilld.dev', async () => { + const { enqueued, siteRequests, deliver } = setup(() => Response.json({ repositories: ['maintainer/opted'] })) + const response = await deliver('installation', { action: 'created', installation, repositories: [compact('opted', 1), compact('silent', 2)] }) + + expect(response.status).toBe(202) + expect(await response.json()).toEqual({ accepted: true, jobs: ['job-1'] }) + expect(enqueued).toEqual([{ owner: 'Maintainer', name: 'opted', repositoryId: 1, installationId: 20, tag: '@latest' }]) + expect(siteRequests[0]!.url).toBe('https://skilld.dev/api/internal/skillgen/opt-ins') + expect(siteRequests[0]!.headers.get('authorization')).toBe('Bearer site-token') + expect(await siteRequests[0]!.json()).toEqual({ repositories: ['Maintainer/opted', 'Maintainer/silent'] }) + }) + + it('queues nothing when skilld.dev cannot answer', async () => { + const { enqueued, deliver } = setup(() => new Response(null, { status: 500 })) + const response = await deliver('create', { ref_type: 'tag', ref: 'v2.0.0', repository, installation }) + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ code: 'OPT_IN_UNAVAILABLE' }) + expect(enqueued).toEqual([]) + }) +}) diff --git a/workers/skill-harness/worker-configuration.d.ts b/workers/skill-harness/worker-configuration.d.ts index a6674211..7f3c9795 100644 --- a/workers/skill-harness/worker-configuration.d.ts +++ b/workers/skill-harness/worker-configuration.d.ts @@ -1,15 +1,16 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --include-runtime=false --env-interface=HarnessEnv` (hash: 7bba7404c650029742df549212cbb06c) +// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --include-runtime=false --env-interface=HarnessEnv` (hash: 388b9853d3dcec863631966608d9909d) interface __BaseEnv_HarnessEnv { PROVIDER: "anthropic"; MODEL: "claude-sonnet-4-6"; - GITHUB_PILOT_REPOSITORIES: "harlan-zw/nuxt-skew-protection"; + SKILLD_SITE_URL: "https://skilld.dev"; GOOGLE_GENERATIVE_AI_API_KEY: string; PROOF_TOKEN: string; ANTHROPIC_API_KEY: string; GITHUB_APP_ID: string; GITHUB_APP_PRIVATE_KEY_PKCS8: string; GITHUB_APP_WEBHOOK_SECRET: string; + SKILLGEN_SITE_TOKEN: string; SANDBOX: DurableObjectNamespace; GITHUB_JOBS: DurableObjectNamespace; } @@ -25,5 +26,5 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } diff --git a/workers/skill-harness/wrangler.jsonc b/workers/skill-harness/wrangler.jsonc index 7334da9c..cab7a0e2 100644 --- a/workers/skill-harness/wrangler.jsonc +++ b/workers/skill-harness/wrangler.jsonc @@ -6,7 +6,7 @@ "compatibility_flags": ["nodejs_compat"], "workers_dev": true, "observability": { "enabled": true }, - "vars": { "PROVIDER": "anthropic", "MODEL": "claude-sonnet-4-6", "GITHUB_PILOT_REPOSITORIES": "harlan-zw/nuxt-skew-protection" }, + "vars": { "PROVIDER": "anthropic", "MODEL": "claude-sonnet-4-6", "SKILLD_SITE_URL": "https://skilld.dev" }, "containers": [{ "class_name": "SkillSandbox", "scheduling_policy": "durable_object",