Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions COPY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package> on each new tag." when on and "Can update <package> 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 |
Expand Down
11 changes: 8 additions & 3 deletions docs/runbooks/skillgen.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/`, with its Skill in `skills/<dir>/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.
Expand Down Expand Up @@ -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.

Expand Down
165 changes: 126 additions & 39 deletions layers/identity/app/components/_SkillgenRepositories.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, RowState>>({})
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<SkillgenRepositoryEntry>): 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<void> {
rowStates.value[key(item)] = { _tag: 'Saving' }
await $fetch<SkillgenOptInResult>('/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<void> {
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)
Expand All @@ -47,18 +79,16 @@ const signInAgain = computed(() => error.value?.statusCode === 401)
<h2 id="skillgen-heading" class="sr-only">
Skillgen repositories
</h2>
<p class="text-sm leading-relaxed text-muted">
Skillgen opens a draft pull request after each release tag. It runs only on the repositories you turn on here.
<p class="max-w-2xl text-sm leading-relaxed text-muted">
Skillgen opens a draft pull request after each release tag. It runs only on the repositories you turn on here. Then
<a :href="INSTALL_URL" target="_blank" rel="noopener" class="text-default underline underline-offset-2 hover:text-primary">install the App</a>
on the same repositories.
<NuxtLink to="/skillgen" class="text-default underline underline-offset-2 hover:text-primary">
How Skillgen works
</NuxtLink>
</p>
<p class="mt-2 text-sm leading-relaxed text-muted">
Then install the App on the same repositories.
<a :href="INSTALL_URL" target="_blank" rel="noopener" class="text-default underline underline-offset-2 hover:text-primary">Install Skillgen</a>
</p>

<div v-if="error" class="mt-5" role="alert">
<div v-if="error" class="mt-6" role="alert">
<p class="text-sm text-error">
{{ signInAgain ? 'Your GitHub access ended. Sign in with GitHub again.' : 'Could not load your repositories from GitHub. Try again.' }}
</p>
Expand All @@ -71,33 +101,90 @@ const signInAgain = computed(() => error.value?.statusCode === 401)
/>
<UButton v-else label="Retry" color="neutral" variant="outline" class="mt-3 min-h-11" :loading="status === 'pending'" @click="refresh()" />
</div>
<p v-else-if="status !== 'success'" class="mt-5 text-sm text-muted" role="status">
Loading your repositories from GitHub
<p v-else-if="status !== 'success'" class="mt-6 text-sm text-muted" role="status">
Checking your repositories on GitHub
</p>
<div v-else-if="data?.items.length" class="mt-5 border-y border-default">
<ul class="list-none p-0">
<li
v-for="item in data.items"
:key="key(item)"
class="border-b border-default py-4 last:border-b-0"
>
<USwitch
:model-value="item.optedIn"
:loading="rowStates[key(item)]?._tag === 'Saving'"
:disabled="rowStates[key(item)]?._tag === 'Saving'"
:label="key(item)"
:description="item.optedIn ? 'Skillgen runs on each new tag.' : 'Skillgen does not run here.'"
:ui="{ label: 'font-mono text-sm' }"
@update:model-value="value => setOptIn(item, value)"
/>
<p v-if="refusedMessage(item)" class="mt-2 text-sm text-error" role="alert">
{{ refusedMessage(item) }}
</p>
</li>
</ul>
</div>
<p v-else class="mt-5 border-y border-default py-4 text-sm text-muted">
<p v-else-if="!data?.items.length" class="mt-6 border-y border-default py-4 text-sm text-muted">
None of the public repositories you maintain has a Skill on skilld yet.
</p>
<template v-else>
<div v-if="ready.length" class="mt-8">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-default pb-3">
<h3 class="section-label">
Ready
</h3>
<div class="flex items-center gap-3">
<span class="data-label">{{ onCount }} of {{ ready.length }} on</span>
<UButton
v-if="waiting.length"
:label="`Turn on all (${waiting.length})`"
color="neutral"
variant="outline"
size="sm"
class="min-h-11"
:loading="turningOnAll"
@click="turnOnAll"
/>
</div>
</div>
<ul class="list-none p-0">
<li
v-for="item in ready"
:key="key(item)"
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 border-b border-default py-4"
>
<div class="min-w-0">
<NuxtLink :to="`/gh/${item.owner}/${item.repo}`" class="break-words font-mono text-sm text-default hover:text-primary">
{{ key(item) }}
</NuxtLink>
<p v-if="item.eligibility._tag === 'Eligible'" class="mt-1 text-sm leading-relaxed text-muted">
{{ item.optedIn ? 'Updates' : 'Can update' }}
<template v-for="(name, index) in item.eligibility.packages" :key="name">
<code class="font-mono text-xs text-default">{{ name }}</code>{{ listSeparator(index, item.eligibility.packages.length) }}
</template>
{{ item.optedIn ? 'on each new tag.' : 'when you turn it on.' }}
</p>
<p v-else class="mt-1 text-sm leading-relaxed text-muted">
{{ item.eligibility.message }} Turn it off, or fix the repository.
</p>
</div>
<USwitch
:model-value="item.optedIn"
:loading="rowStates[key(item)]?._tag === 'Saving'"
:disabled="rowStates[key(item)]?._tag === 'Saving' || (!item.optedIn && item.eligibility._tag !== 'Eligible')"
:aria-label="`Skillgen for ${key(item)}`"
@update:model-value="value => setOptIn(item, value)"
/>
<p v-if="failure(item)" class="col-span-2 mt-2 text-sm text-error" role="alert">
{{ failure(item) }}
</p>
</li>
</ul>
</div>

<div v-if="blocked.length" class="mt-10">
<div class="flex items-center justify-between gap-3 border-b border-default pb-3">
<h3 class="section-label">
Needs a change
</h3>
<span class="data-label">{{ blocked.length }}</span>
</div>
<ul class="list-none p-0">
<li
v-for="item in blocked"
:key="key(item)"
class="border-b border-default py-4"
>
<NuxtLink :to="`/gh/${item.owner}/${item.repo}`" class="break-words font-mono text-sm text-default hover:text-primary">
{{ key(item) }}
</NuxtLink>
<p class="mt-1 flex gap-2 text-sm leading-relaxed text-muted">
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0 text-warning" aria-hidden="true" />
<span class="min-w-0">{{ item.eligibility._tag === 'Ineligible' ? item.eligibility.message : '' }}</span>
</p>
</li>
</ul>
</div>
</template>
</section>
</template>
9 changes: 5 additions & 4 deletions layers/identity/server/api/me/skillgen/index.get.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
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 { githubUserGet, inspectSkillgenRows, 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.
* the registry, each with its Skillgen opt-in and whether Skillgen can run there.
*/
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))
const github = githubUserGet(token)
const listed = await listMaintainedRepositories(github)
if (listed._tag === 'GithubUnavailable')
throw createError(skillgenRefusal(listed))
return skillgenCandidates(platform.db, listed.repositories)
return inspectSkillgenRows(github, await skillgenCandidates(platform.db, listed.repositories))
},
presenter: skillgenRepositoriesPresenter,
})
6 changes: 3 additions & 3 deletions layers/identity/server/presenters/skillgen.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { SkillgenOptInResult, SkillgenRepositoryEntry } from '../../shared/contracts/skillgen'
import type { SkillgenRepositoryRow } from '../utils/skillgen'
import type { SkillgenInspectedRow } 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 skillgenRepositoriesPresenter(rows: SkillgenInspectedRow[]): { items: SkillgenRepositoryEntry[] } {
return { items: rows.map(row => ({ owner: row.owner, repo: row.repo, optedIn: row.optedIn, eligibility: row.eligibility })) }
}

export function skillgenOptInPresenter(result: SkillgenOptInResult): SkillgenOptInResult {
Expand Down
Loading