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
6 changes: 1 addition & 5 deletions packages/protocol/src/test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ export const fixtures = {
audit: {
skillLivePass: {
id: 'antfu/skills/vue',
installs: 1234,
formatted: '1.2k',
audits: [
{ provider: 'skills.sh', slug: 'static', status: 'pass' },
{ provider: 'skills.sh', slug: 'license', status: 'pass' },
Expand All @@ -40,8 +38,6 @@ export const fixtures = {
},
skillLiveWarn: {
id: 'antfu/skills/motion-v',
installs: 42,
formatted: '42',
audits: [
{ provider: 'skills.sh', slug: 'static', status: 'pass' },
{ provider: 'skills.sh', slug: 'deps', status: 'warn', summary: 'wildcard import', riskLevel: 'medium', categories: ['imports'] },
Expand Down Expand Up @@ -177,7 +173,7 @@ export const fixtures = {
repo: 'skills',
name: 'vue',
displayName: 'Vue',
installs: 1234,
stars: 1234,
branch: 'main',
skillPath: 'vue/SKILL.md',
raw: '# Vue\n\nUse <script setup>.',
Expand Down
4 changes: 1 addition & 3 deletions packages/protocol/src/wire/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,10 @@ export const AuditEntrySchema = z.object({

export const SkillLiveResponseSchema = z.object({
id: z.string(),
installs: z.number().nullable(),
formatted: z.string().nullable(),
audits: z.array(AuditEntrySchema),
source: z.literal('skills.sh'),
fetchedAt: z.string(),
})
}).strict()

export type AuditEntry = z.infer<typeof AuditEntrySchema>
export type SkillLiveResponse = z.infer<typeof SkillLiveResponseSchema>
12 changes: 6 additions & 6 deletions packages/protocol/src/wire/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ export const SkillDetailResponseSchema = z.object({
repo: z.string(),
name: z.string(),
displayName: z.string(),
installs: z.number(),
branch: z.string().optional(),
skillPath: z.string().nullable().optional(),
raw: z.string().nullable().optional(),
pushedAt: z.string().nullable().optional(),
})
stars: z.number(),
branch: z.string(),
skillPath: z.string().nullable(),
raw: z.string().nullable(),
pushedAt: z.string().nullable(),
}).passthrough()

export type SkillsResolveInput = z.infer<typeof SkillsResolveInputSchema>
export type SkillsResolveEntry = z.infer<typeof SkillsResolveEntrySchema>
Expand Down
70 changes: 46 additions & 24 deletions src/auth/client.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,90 @@
/**
* `withAuth(fetcher)` β€” wraps an ofetch-like call with the current session.
* `withAuth(baseUrl)` wraps an ofetch-like call with the current session.
* Adds `Authorization: Bearer …`, refreshes on 401, re-reads the marker file
* before refreshing so concurrent CLI invocations can share a rotated token.
*
* Refresh is never preemptive. SKILLD_TOKEN env scheme is treated as hard
* expiry: a 401 propagates instead of triggering refresh.
*/

import type { StorageScheme, StoredSession } from './store.ts'
import type { TokenResponse } from './types.ts'
import { ofetch } from 'ofetch'
import { getRegistryBase } from '../registry/client.ts'
import { loadSession, saveSession } from './store.ts'

export interface AuthedFetcher {
<T>(url: string, init?: Parameters<typeof ofetch<T>>[1]): Promise<T>
}

async function refreshSession(refreshToken: string): Promise<TokenResponse | null> {
const base = getRegistryBase()
return ofetch<TokenResponse>(`${base}/cli/oauth/refresh`, {
method: 'POST',
body: { refresh_token: refreshToken },
}).catch(() => null)
interface AuthenticatedFetchDependencies {
baseUrl: string
fetch: AuthedFetcher
loadSession: () => Promise<StoredSession | null>
saveSession: (session: Parameters<typeof saveSession>[0]) => Promise<StorageScheme>
}

export function withAuth(): AuthedFetcher {
type FetchAttempt<T>
= | { _tag: 'Ok', value: T }
| { _tag: 'Err', error: unknown }

function isAuthFailure(error: unknown): boolean {
if (typeof error !== 'object' || error === null || !('statusCode' in error))
return false
const statusCode = (error as { statusCode?: unknown }).statusCode
return statusCode === 401 || statusCode === 403
}

export function createAuthenticatedFetch(deps: AuthenticatedFetchDependencies): AuthedFetcher {
return async <T>(url: string, init?: Parameters<typeof ofetch<T>>[1]): Promise<T> => {
const session = await loadSession()
const session = await deps.loadSession()
if (!session)
throw new Error('auth required')

const send = (token: string): Promise<T> => ofetch<T>(url, {
const send = (token: string): Promise<T> => deps.fetch<T>(url, {
...init,
headers: { ...(init?.headers as any), Authorization: `Bearer ${token}` },
})

const fail401Codes = new Set([401, 403])

const firstAttempt = await send(session.accessToken).catch((err: { statusCode?: number } & Error) => err)
if (!(firstAttempt instanceof Error) || !fail401Codes.has((firstAttempt as { statusCode?: number }).statusCode ?? 0))
return firstAttempt as T
const firstAttempt: FetchAttempt<T> = await send(session.accessToken)
.then(value => ({ _tag: 'Ok' as const, value }))
.catch(error => ({ _tag: 'Err' as const, error }))
if (firstAttempt._tag === 'Ok')
return firstAttempt.value
if (!isAuthFailure(firstAttempt.error))
throw firstAttempt.error

if (session.scheme === 'env' || !session.refreshToken)
throw firstAttempt
throw firstAttempt.error

// Re-read marker; another process may have already rotated.
const fresh = await loadSession()
const fresh = await deps.loadSession()
const candidateRefresh = fresh?.refreshToken ?? session.refreshToken
if (fresh && fresh.accessToken !== session.accessToken) {
if (fresh && fresh.accessToken !== session.accessToken)
return send(fresh.accessToken)
}

const rotated = await refreshSession(candidateRefresh)
if (!rotated)
throw firstAttempt
const rotated = await deps.fetch<TokenResponse>(`${deps.baseUrl}/cli/oauth/refresh`, {
method: 'POST',
body: { refresh_token: candidateRefresh },
})

await saveSession({
await deps.saveSession({
login: rotated.login,
accessToken: rotated.accessToken,
refreshToken: rotated.refreshToken,
expiresAt: rotated.expiresAt,
host: session.host,
tokens: { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken },
})

return send(rotated.accessToken)
}
}

export function withAuth(baseUrl: string): AuthedFetcher {
return createAuthenticatedFetch({
baseUrl,
fetch: ofetch,
loadSession,
saveSession,
})
}
2 changes: 1 addition & 1 deletion src/auth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* file. Use it in CI when keychain access isn't available.
*/

import type { AuthSession } from '../registry/client.ts'
import type { AuthSession } from 'skilld-protocol/wire'
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname } from 'pathe'
import { AUTH_PATH, CACHE_DIR } from '../core/paths.ts'
Expand Down
5 changes: 4 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function deprecatedForwarder(

// ── Subcommands (lazy-loaded) ──

const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'cache', 'validate', 'assemble', 'setup', 'prepare', 'author', 'publish', 'upload', 'login', 'logout', 'whoami', 'pull']
const SUBCOMMAND_NAMES = ['add', 'eject', 'update', 'changes', 'watch', 'unwatch', 'info', 'list', 'config', 'remove', 'install', 'uninstall', 'search', 'cache', 'validate', 'assemble', 'setup', 'prepare', 'author', 'publish', 'upload', 'login', 'logout', 'whoami', 'pull']

// ── Main command ──

Expand All @@ -76,6 +76,9 @@ const main = defineCommand({
subCommands: {
add: () => import('./commands/sync/add.ts').then(m => m.addCommandDef),
update: () => import('./commands/sync/update.ts').then(m => m.updateCommandDef),
changes: () => import('./commands/changes.ts').then(m => m.changesCommandDef),
watch: () => import('./commands/watch.ts').then(m => m.watchCommandDef),
unwatch: () => import('./commands/watch.ts').then(m => m.unwatchCommandDef),
info: () => infoCommandDef,
list: () => import('./commands/list.ts').then(m => m.listCommandDef),
config: () => configCommandDef,
Expand Down
22 changes: 12 additions & 10 deletions src/cli/digest-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,7 @@ function relative(iso: string, now = Date.now()): string {
return RELATIVE_FORMATTER.format(Math.round(hours / 24), 'day')
}

export function renderDigest(entries: ChangeEntry[]): void {
if (entries.length === 0) {
p.log.success('No new updates since last digest.')
return
}

export function formatDigestLines(entries: ChangeEntry[], now = Date.now()): string[] {
const byRepo = new Map<string, ChangeEntry[]>()
for (const entry of entries) {
const list = byRepo.get(entry.repo) ?? []
Expand All @@ -40,14 +35,21 @@ export function renderDigest(entries: ChangeEntry[]): void {
for (const [repo, items] of byRepo) {
lines.push(styleText('cyan', repo))
for (const item of items) {
const when = styleText('gray', relative(item.at))
const when = styleText('gray', relative(item.at, now))
lines.push(` ${styleText('green', 'β€’')} ${item.skill} ${when}`)
if (item.summary)
lines.push(` ${styleText('gray', item.summary)}`)
lines.push(` ${styleText('gray', `https://skilld.dev/gh/${item.repo}/${encodeURIComponent(item.skill)}`)}`)
}
}
lines.push('')
lines.push(styleText('gray', 'See full activity at https://skilld.dev/me/activity'))
return lines
}

export function renderDigest(entries: ChangeEntry[]): void {
if (entries.length === 0) {
p.log.success('No new updates since last digest.')
return
}

p.log.message(lines.join('\n'))
p.log.message(formatDigestLines(entries).join('\n'))
}
19 changes: 19 additions & 0 deletions src/commands/changes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as p from '@clack/prompts'
import { defineCommand } from 'citty'
import { renderChangesDigest } from './sync/changes-digest.ts'

export const changesCommandDef = defineCommand({
meta: { name: 'changes', description: 'Show watched skill changes' },
async run() {
p.intro('skilld changes')
const result = await renderChangesDigest(true).catch((error) => {
p.log.error(`Failed to load changes: ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
return null
})
if (result === 'auth-required') {
p.log.error('Not logged in. Run `skilld login` first.')
process.exitCode = 1
}
},
})
67 changes: 22 additions & 45 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { loadSession } from '../auth/store.ts'
import { autoResolveAgent } from '../cli/agent-prompt.ts'
import { sharedArgs } from '../cli/args.ts'
import { createRegistryClient } from '../registry/client.ts'
import { manifestToSources } from '../registry/collections.ts'
import { track } from '../telemetry.ts'
import { installSkills } from './sync/install-many.ts'

Expand All @@ -25,46 +26,6 @@ function manifestItemKey(item: CollectionManifestItem): string {
return item.package ?? `${item.kind}:unknown`
}

/**
* Convert selected manifest items into `installSkills` inputs. Multiple gh
* items in the same repo collapse to one `git` source carrying the union of
* picked skill names as `skillFilter`, so a repo with N skills installs in
* one `syncGitSkills` call instead of N redundant ones.
*/
function manifestToSources(items: CollectionManifestItem[]): Array<{ source: SkillSource, skillFilter?: string }> {
const npm: Array<{ source: SkillSource, skillFilter?: string }> = []
const crate: Array<{ source: SkillSource, skillFilter?: string }> = []
const ghByRepo = new Map<string, { owner: string, repo: string, names: string[] }>()

for (const item of items) {
if (item.kind === 'npm' && item.package) {
npm.push({ source: { type: 'npm', package: item.package } })
continue
}
if (item.kind === 'crate' && item.package) {
crate.push({ source: { type: 'crate', package: item.package } })
continue
}
if (item.kind === 'gh' && item.owner && item.repo) {
const key = `${item.owner}/${item.repo}`
const group = ghByRepo.get(key) ?? { owner: item.owner, repo: item.repo, names: [] }
if (item.name && !group.names.includes(item.name))
group.names.push(item.name)
ghByRepo.set(key, group)
}
}

const gh: Array<{ source: SkillSource, skillFilter?: string }> = []
for (const group of ghByRepo.values()) {
gh.push({
source: { type: 'git', source: { type: 'github', owner: group.owner, repo: group.repo } },
skillFilter: group.names.length ? group.names.join(',') : undefined,
})
}

return [...gh, ...npm, ...crate]
}

function badgeFor(status: AuditStatus, result: AuditResult): string {
switch (status) {
case 'pass':
Expand Down Expand Up @@ -126,15 +87,28 @@ export const pullCommandDef = defineCommand({
return
}

const client = createRegistryClient({ session })
const collections = await client.my.collections()
const client = createRegistryClient()
const collections = await client.my.collections().catch((error) => {
p.log.error(`Failed to load collections: ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
return null
})
if (!collections)
return
const picked = await pickCollection(collections, args.collection)
if (!picked)
return

const manifest = await client.fetchCollection(session.login, picked.slug) as CollectionManifest | null
let manifestFailed = false
const manifest = await client.fetchCollection(session.login, picked.slug).catch((error) => {
manifestFailed = true
p.log.error(`Failed to load @${session.login}/${picked.slug}: ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
return null
}) as CollectionManifest | null
if (!manifest) {
p.log.error(`Failed to load collection manifest for @${session.login}/${picked.slug}.`)
if (!manifestFailed)
p.log.error(`Collection @${session.login}/${picked.slug} was not found.`)
process.exitCode = 1
return
}
Expand All @@ -156,7 +130,10 @@ export const pullCommandDef = defineCommand({
auditByKey.set(manifestItemKey(item), { status: 'unaudited', audits: [] })
return
}
const result = await client.audit({ owner: item.owner, repo: item.repo, name: item.name })
const result = await client.audit({ owner: item.owner, repo: item.repo, name: item.name }).catch((error) => {
p.log.warn(`Audit unavailable for ${item.owner}/${item.repo}/${item.name}: ${error instanceof Error ? error.message : String(error)}`)
return { status: 'unaudited' as const, audits: [] }
})
auditCache.set(`${item.owner}/${item.repo}/${item.name}`, result)
auditByKey.set(manifestItemKey(item), result)
}))
Expand Down
Loading