From ce6b457362d1708b3030892b57e2e45f0183df0c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 19:54:27 +1100 Subject: [PATCH] feat(skillgen): support monorepo packages and polish the opt-in page Harlan's account page refused most of his repositories. They are monorepos: the root package.json is private or names the monorepo, and the published package keeps its Skill under packages//skills/. One rule in workers/skill-harness/src/package-skills.ts now finds every package with a Skill at the root or under packages/, and the site imports the same rule. A tag that several packages share splits into one job per package, each with its own branch and draft, since nuxt-seo releases two packages with Skills at once. The account page checks every repository when it loads, so a blocked one shows its reason before any click. Ready rows name the packages Skillgen updates, and "Turn on all" switches on every ready repository. useFetch data is shallow in Nuxt 4, so rows now update by replacing the list. --- COPY.md | 1 + docs/runbooks/skillgen.md | 11 +- .../app/components/_SkillgenRepositories.vue | 165 +++++++++++++----- .../server/api/me/skillgen/index.get.ts | 9 +- layers/identity/server/presenters/skillgen.ts | 6 +- layers/identity/server/utils/skillgen.ts | 146 +++++++++++----- layers/identity/shared/contracts/skillgen.ts | 10 +- layers/marketing/app/pages/skillgen.vue | 8 +- test/unit/skillgen-opt-in.test.ts | 90 ++++++---- test/unit/skillgen-repositories.test.ts | 41 +++-- workers/skill-harness/src/github-client.ts | 80 +++++++-- workers/skill-harness/src/github-events.ts | 3 +- workers/skill-harness/src/github-jobs.ts | 16 +- workers/skill-harness/src/package-skills.ts | 57 ++++++ workers/skill-harness/test/github-app.test.ts | 86 ++++++++- 15 files changed, 553 insertions(+), 176 deletions(-) create mode 100644 workers/skill-harness/src/package-skills.ts diff --git a/COPY.md b/COPY.md index e82cb92b..0533b78b 100644 --- a/COPY.md +++ b/COPY.md @@ -46,6 +46,7 @@ These exact strings. Do not paraphrase them per page. | 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. A "Make a skill" link above the H1 returns to `/make-skill` | | 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 | +| Skillgen groups | Ready · Needs a change · Turn on all (n) · n of n on | guidance only: the two groups on `/me?view=skillgen`. Ready rows say "Updates on each new tag." when on and "Can update when you turn it on." when off. Needs a change rows show the refusal sentence from `skillgenRefusal` | | 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/docs/runbooks/skillgen.md b/docs/runbooks/skillgen.md index 2f0ef2ef..c567c1af 100644 --- a/docs/runbooks/skillgen.md +++ b/docs/runbooks/skillgen.md @@ -11,7 +11,9 @@ Maintainers set it up from [/skillgen](https://skilld.dev/skillgen). 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. +Turning it on needs admin or maintain access, a public repository, and a published package with a Skill. +`workers/skill-harness/src/package-skills.ts` holds the package rule. The site and the Worker both import it. +A package sits at the root or under `packages//`, with its Skill in `skills//SKILL.md` beside its `package.json`. 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. @@ -52,8 +54,11 @@ New tag creation starts another job automatically. ## Source and publication checks -Each job reads the root `package.json` from the exact tag commit. -The tag must equal its package version, with an optional `v` prefix. +Each job lists the packages with a Skill on the default branch. +It reads each one's `package.json` from the exact tag commit, and skips private packages. +The tag must equal a package version, as `1.2.3`, `v1.2.3`, or `name@1.2.3`. +If several packages match one tag, the job splits into one job per package. +Each split job opens its own draft on a branch named after the package directory. If the package publication follows the tag, the job waits for it. The whole job has a thirty minute deadline. diff --git a/layers/identity/app/components/_SkillgenRepositories.vue b/layers/identity/app/components/_SkillgenRepositories.vue index 9319411d..396f994b 100644 --- a/layers/identity/app/components/_SkillgenRepositories.vue +++ b/layers/identity/app/components/_SkillgenRepositories.vue @@ -6,37 +6,69 @@ import type { SkillgenOptInResult, SkillgenRepositoryEntry } from '../../shared/ * that holds a Skill. The skill-harness Worker queues jobs only for * repositories switched on here, so installing the App alone runs nothing. * - * The list calls GitHub, so it loads in the browser after the page renders. + * Each row carries what the Worker would find, so a repository it cannot run + * on shows its reason up front instead of after a click. The list reads + * GitHub, so it loads in the browser after the page renders. */ const INSTALL_URL = 'https://github.com/apps/skilld-skillgen/installations/new' const { data, error, status, refresh } = useFetch<{ items: SkillgenRepositoryEntry[] }>('/api/me/skillgen', { lazy: true, server: false }) -type RowState = { _tag: 'Idle' } | { _tag: 'Saving' } | { _tag: 'Refused', message: string } +type RowState = { _tag: 'Idle' } | { _tag: 'Saving' } | { _tag: 'Failed', message: string } const rowStates = ref>({}) const key = (item: SkillgenRepositoryEntry) => `${item.owner}/${item.repo}` +// A repository already on stays in the ready list even if it stopped qualifying, so it can be turned off. +const ready = computed(() => data.value?.items.filter(item => item.eligibility._tag === 'Eligible' || item.optedIn) ?? []) +const blocked = computed(() => data.value?.items.filter(item => item.eligibility._tag === 'Ineligible' && !item.optedIn) ?? []) +const waiting = computed(() => ready.value.filter(item => !item.optedIn && item.eligibility._tag === 'Eligible')) +const onCount = computed(() => ready.value.filter(item => item.optedIn).length) +const turningOnAll = ref(false) + +/** `useFetch` data is shallow, so a row changes by replacing the list. */ +function update(item: SkillgenRepositoryEntry, patch: Partial): void { + if (data.value) + data.value = { items: data.value.items.map(row => key(row) === key(item) ? { ...row, ...patch } : row) } +} + async function setOptIn(item: SkillgenRepositoryEntry, optedIn: boolean): Promise { rowStates.value[key(item)] = { _tag: 'Saving' } await $fetch('/api/me/skillgen', { method: 'PUT', body: { owner: item.owner, repo: item.repo, optedIn } }) .then((result) => { if (result._tag === 'Refused') { - rowStates.value[key(item)] = { _tag: 'Refused', message: result.message } + // The repository changed since the list loaded, so it moves to the blocked group with the new reason. + update(item, { eligibility: { _tag: 'Ineligible', message: result.message } }) + rowStates.value[key(item)] = { _tag: 'Idle' } return } - item.optedIn = result.optedIn + update(item, { optedIn: result.optedIn }) rowStates.value[key(item)] = { _tag: 'Idle' } }) .catch((cause: unknown) => { // An expected refusal arrives as a value, so this is a network or server failure. console.warn('[skillgen] Saving the switch failed:', cause) - rowStates.value[key(item)] = { _tag: 'Refused', message: 'Could not save. Try again.' } + rowStates.value[key(item)] = { _tag: 'Failed', message: 'Could not save. Try again.' } }) } -function refusedMessage(item: SkillgenRepositoryEntry): string | null { +/** Turns on every ready repository that is off, one at a time, so each keeps its own result. */ +async function turnOnAll(): Promise { + turningOnAll.value = true + for (const item of [...waiting.value]) + await setOptIn(item, true) + turningOnAll.value = false +} + +/** Joins package names as `a`, `a and b`, or `a, b and c`. */ +function listSeparator(index: number, length: number): string { + if (index >= length - 1) + return '' + return index === length - 2 ? ' and ' : ', ' +} + +function failure(item: SkillgenRepositoryEntry): string | null { const state = rowStates.value[key(item)] - return state?._tag === 'Refused' ? state.message : null + return state?._tag === 'Failed' ? state.message : null } const signInAgain = computed(() => error.value?.statusCode === 401) @@ -47,18 +79,16 @@ const signInAgain = computed(() => error.value?.statusCode === 401)

Skillgen repositories

-

- Skillgen opens a draft pull request after each release tag. It runs only on the repositories you turn on here. +

+ Skillgen opens a draft pull request after each release tag. It runs only on the repositories you turn on here. Then + install the App + on the same repositories. How Skillgen works

-

- Then install the App on the same repositories. - Install Skillgen -

-