Skip to content

Commit 8dbbc34

Browse files
committed
feat!: connect CLI installs to skilld.dev
1 parent 5687ca7 commit 8dbbc34

24 files changed

Lines changed: 719 additions & 235 deletions

packages/protocol/src/test-fixtures.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ export const fixtures = {
2929
audit: {
3030
skillLivePass: {
3131
id: 'antfu/skills/vue',
32-
installs: 1234,
33-
formatted: '1.2k',
3432
audits: [
3533
{ provider: 'skills.sh', slug: 'static', status: 'pass' },
3634
{ provider: 'skills.sh', slug: 'license', status: 'pass' },
@@ -40,8 +38,6 @@ export const fixtures = {
4038
},
4139
skillLiveWarn: {
4240
id: 'antfu/skills/motion-v',
43-
installs: 42,
44-
formatted: '42',
4541
audits: [
4642
{ provider: 'skills.sh', slug: 'static', status: 'pass' },
4743
{ provider: 'skills.sh', slug: 'deps', status: 'warn', summary: 'wildcard import', riskLevel: 'medium', categories: ['imports'] },
@@ -177,7 +173,7 @@ export const fixtures = {
177173
repo: 'skills',
178174
name: 'vue',
179175
displayName: 'Vue',
180-
installs: 1234,
176+
stars: 1234,
181177
branch: 'main',
182178
skillPath: 'vue/SKILL.md',
183179
raw: '# Vue\n\nUse <script setup>.',

packages/protocol/src/wire/audit.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,10 @@ export const AuditEntrySchema = z.object({
2626

2727
export const SkillLiveResponseSchema = z.object({
2828
id: z.string(),
29-
installs: z.number().nullable(),
30-
formatted: z.string().nullable(),
3129
audits: z.array(AuditEntrySchema),
3230
source: z.literal('skills.sh'),
3331
fetchedAt: z.string(),
34-
})
32+
}).strict()
3533

3634
export type AuditEntry = z.infer<typeof AuditEntrySchema>
3735
export type SkillLiveResponse = z.infer<typeof SkillLiveResponseSchema>

packages/protocol/src/wire/skills.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ export const SkillDetailResponseSchema = z.object({
3131
repo: z.string(),
3232
name: z.string(),
3333
displayName: z.string(),
34-
installs: z.number(),
35-
branch: z.string().optional(),
36-
skillPath: z.string().nullable().optional(),
37-
raw: z.string().nullable().optional(),
38-
pushedAt: z.string().nullable().optional(),
39-
})
34+
stars: z.number(),
35+
branch: z.string(),
36+
skillPath: z.string().nullable(),
37+
raw: z.string().nullable(),
38+
pushedAt: z.string().nullable(),
39+
}).passthrough()
4040

4141
export type SkillsResolveInput = z.infer<typeof SkillsResolveInputSchema>
4242
export type SkillsResolveEntry = z.infer<typeof SkillsResolveEntrySchema>

src/auth/client.ts

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,90 @@
11
/**
2-
* `withAuth(fetcher)` — wraps an ofetch-like call with the current session.
2+
* `withAuth(baseUrl)` wraps an ofetch-like call with the current session.
33
* Adds `Authorization: Bearer …`, refreshes on 401, re-reads the marker file
44
* before refreshing so concurrent CLI invocations can share a rotated token.
55
*
66
* Refresh is never preemptive. SKILLD_TOKEN env scheme is treated as hard
77
* expiry: a 401 propagates instead of triggering refresh.
88
*/
99

10+
import type { StorageScheme, StoredSession } from './store.ts'
1011
import type { TokenResponse } from './types.ts'
1112
import { ofetch } from 'ofetch'
12-
import { getRegistryBase } from '../registry/client.ts'
1313
import { loadSession, saveSession } from './store.ts'
1414

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

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

27-
export function withAuth(): AuthedFetcher {
26+
type FetchAttempt<T>
27+
= | { _tag: 'Ok', value: T }
28+
| { _tag: 'Err', error: unknown }
29+
30+
function isAuthFailure(error: unknown): boolean {
31+
if (typeof error !== 'object' || error === null || !('statusCode' in error))
32+
return false
33+
const statusCode = (error as { statusCode?: unknown }).statusCode
34+
return statusCode === 401 || statusCode === 403
35+
}
36+
37+
export function createAuthenticatedFetch(deps: AuthenticatedFetchDependencies): AuthedFetcher {
2838
return async <T>(url: string, init?: Parameters<typeof ofetch<T>>[1]): Promise<T> => {
29-
const session = await loadSession()
39+
const session = await deps.loadSession()
3040
if (!session)
3141
throw new Error('auth required')
3242

33-
const send = (token: string): Promise<T> => ofetch<T>(url, {
43+
const send = (token: string): Promise<T> => deps.fetch<T>(url, {
3444
...init,
3545
headers: { ...(init?.headers as any), Authorization: `Bearer ${token}` },
3646
})
3747

38-
const fail401Codes = new Set([401, 403])
39-
40-
const firstAttempt = await send(session.accessToken).catch((err: { statusCode?: number } & Error) => err)
41-
if (!(firstAttempt instanceof Error) || !fail401Codes.has((firstAttempt as { statusCode?: number }).statusCode ?? 0))
42-
return firstAttempt as T
48+
const firstAttempt: FetchAttempt<T> = await send(session.accessToken)
49+
.then(value => ({ _tag: 'Ok' as const, value }))
50+
.catch(error => ({ _tag: 'Err' as const, error }))
51+
if (firstAttempt._tag === 'Ok')
52+
return firstAttempt.value
53+
if (!isAuthFailure(firstAttempt.error))
54+
throw firstAttempt.error
4355

4456
if (session.scheme === 'env' || !session.refreshToken)
45-
throw firstAttempt
57+
throw firstAttempt.error
4658

4759
// Re-read marker; another process may have already rotated.
48-
const fresh = await loadSession()
60+
const fresh = await deps.loadSession()
4961
const candidateRefresh = fresh?.refreshToken ?? session.refreshToken
50-
if (fresh && fresh.accessToken !== session.accessToken) {
62+
if (fresh && fresh.accessToken !== session.accessToken)
5163
return send(fresh.accessToken)
52-
}
5364

54-
const rotated = await refreshSession(candidateRefresh)
55-
if (!rotated)
56-
throw firstAttempt
65+
const rotated = await deps.fetch<TokenResponse>(`${deps.baseUrl}/cli/oauth/refresh`, {
66+
method: 'POST',
67+
body: { refresh_token: candidateRefresh },
68+
})
5769

58-
await saveSession({
70+
await deps.saveSession({
5971
login: rotated.login,
6072
accessToken: rotated.accessToken,
6173
refreshToken: rotated.refreshToken,
6274
expiresAt: rotated.expiresAt,
75+
host: session.host,
6376
tokens: { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken },
6477
})
6578

6679
return send(rotated.accessToken)
6780
}
6881
}
82+
83+
export function withAuth(baseUrl: string): AuthedFetcher {
84+
return createAuthenticatedFetch({
85+
baseUrl,
86+
fetch: ofetch,
87+
loadSession,
88+
saveSession,
89+
})
90+
}

src/auth/store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* file. Use it in CI when keychain access isn't available.
77
*/
88

9-
import type { AuthSession } from '../registry/client.ts'
9+
import type { AuthSession } from 'skilld-protocol/wire'
1010
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
1111
import { dirname } from 'pathe'
1212
import { AUTH_PATH, CACHE_DIR } from '../core/paths.ts'

src/cli.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function deprecatedForwarder(
5959

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

62-
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']
62+
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']
6363

6464
// ── Main command ──
6565

@@ -75,6 +75,9 @@ const main = defineCommand({
7575
subCommands: {
7676
add: () => import('./commands/sync/add.ts').then(m => m.addCommandDef),
7777
update: () => import('./commands/sync/update.ts').then(m => m.updateCommandDef),
78+
changes: () => import('./commands/changes.ts').then(m => m.changesCommandDef),
79+
watch: () => import('./commands/watch.ts').then(m => m.watchCommandDef),
80+
unwatch: () => import('./commands/watch.ts').then(m => m.unwatchCommandDef),
7881
info: () => infoCommandDef,
7982
list: () => import('./commands/list.ts').then(m => m.listCommandDef),
8083
config: () => configCommandDef,

src/cli/digest-render.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,7 @@ function relative(iso: string, now = Date.now()): string {
2323
return RELATIVE_FORMATTER.format(Math.round(hours / 24), 'day')
2424
}
2525

26-
export function renderDigest(entries: ChangeEntry[]): void {
27-
if (entries.length === 0) {
28-
p.log.success('No new updates since last digest.')
29-
return
30-
}
31-
26+
export function formatDigestLines(entries: ChangeEntry[], now = Date.now()): string[] {
3227
const byRepo = new Map<string, ChangeEntry[]>()
3328
for (const entry of entries) {
3429
const list = byRepo.get(entry.repo) ?? []
@@ -40,14 +35,21 @@ export function renderDigest(entries: ChangeEntry[]): void {
4035
for (const [repo, items] of byRepo) {
4136
lines.push(styleText('cyan', repo))
4237
for (const item of items) {
43-
const when = styleText('gray', relative(item.at))
38+
const when = styleText('gray', relative(item.at, now))
4439
lines.push(` ${styleText('green', '•')} ${item.skill} ${when}`)
4540
if (item.summary)
4641
lines.push(` ${styleText('gray', item.summary)}`)
42+
lines.push(` ${styleText('gray', `https://skilld.dev/gh/${item.repo}/${encodeURIComponent(item.skill)}`)}`)
4743
}
4844
}
49-
lines.push('')
50-
lines.push(styleText('gray', 'See full activity at https://skilld.dev/me/activity'))
45+
return lines
46+
}
47+
48+
export function renderDigest(entries: ChangeEntry[]): void {
49+
if (entries.length === 0) {
50+
p.log.success('No new updates since last digest.')
51+
return
52+
}
5153

52-
p.log.message(lines.join('\n'))
54+
p.log.message(formatDigestLines(entries).join('\n'))
5355
}

src/commands/changes.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as p from '@clack/prompts'
2+
import { defineCommand } from 'citty'
3+
import { renderChangesDigest } from './sync/changes-digest.ts'
4+
5+
export const changesCommandDef = defineCommand({
6+
meta: { name: 'changes', description: 'Show watched skill changes' },
7+
async run() {
8+
p.intro('skilld changes')
9+
const result = await renderChangesDigest(true).catch((error) => {
10+
p.log.error(`Failed to load changes: ${error instanceof Error ? error.message : String(error)}`)
11+
process.exitCode = 1
12+
return null
13+
})
14+
if (result === 'auth-required') {
15+
p.log.error('Not logged in. Run `skilld login` first.')
16+
process.exitCode = 1
17+
}
18+
},
19+
})

src/commands/pull.ts

Lines changed: 22 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { loadSession } from '../auth/store.ts'
1616
import { autoResolveAgent } from '../cli/agent-prompt.ts'
1717
import { sharedArgs } from '../cli/args.ts'
1818
import { createRegistryClient } from '../registry/client.ts'
19+
import { manifestToSources } from '../registry/collections.ts'
1920
import { track } from '../telemetry.ts'
2021
import { installSkills } from './sync/install-many.ts'
2122

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

28-
/**
29-
* Convert selected manifest items into `installSkills` inputs. Multiple gh
30-
* items in the same repo collapse to one `git` source carrying the union of
31-
* picked skill names as `skillFilter`, so a repo with N skills installs in
32-
* one `syncGitSkills` call instead of N redundant ones.
33-
*/
34-
function manifestToSources(items: CollectionManifestItem[]): Array<{ source: SkillSource, skillFilter?: string }> {
35-
const npm: Array<{ source: SkillSource, skillFilter?: string }> = []
36-
const crate: Array<{ source: SkillSource, skillFilter?: string }> = []
37-
const ghByRepo = new Map<string, { owner: string, repo: string, names: string[] }>()
38-
39-
for (const item of items) {
40-
if (item.kind === 'npm' && item.package) {
41-
npm.push({ source: { type: 'npm', package: item.package } })
42-
continue
43-
}
44-
if (item.kind === 'crate' && item.package) {
45-
crate.push({ source: { type: 'crate', package: item.package } })
46-
continue
47-
}
48-
if (item.kind === 'gh' && item.owner && item.repo) {
49-
const key = `${item.owner}/${item.repo}`
50-
const group = ghByRepo.get(key) ?? { owner: item.owner, repo: item.repo, names: [] }
51-
if (item.name && !group.names.includes(item.name))
52-
group.names.push(item.name)
53-
ghByRepo.set(key, group)
54-
}
55-
}
56-
57-
const gh: Array<{ source: SkillSource, skillFilter?: string }> = []
58-
for (const group of ghByRepo.values()) {
59-
gh.push({
60-
source: { type: 'git', source: { type: 'github', owner: group.owner, repo: group.repo } },
61-
skillFilter: group.names.length ? group.names.join(',') : undefined,
62-
})
63-
}
64-
65-
return [...gh, ...npm, ...crate]
66-
}
67-
6829
function badgeFor(status: AuditStatus, result: AuditResult): string {
6930
switch (status) {
7031
case 'pass':
@@ -126,15 +87,28 @@ export const pullCommandDef = defineCommand({
12687
return
12788
}
12889

129-
const client = createRegistryClient({ session })
130-
const collections = await client.my.collections()
90+
const client = createRegistryClient()
91+
const collections = await client.my.collections().catch((error) => {
92+
p.log.error(`Failed to load collections: ${error instanceof Error ? error.message : String(error)}`)
93+
process.exitCode = 1
94+
return null
95+
})
96+
if (!collections)
97+
return
13198
const picked = await pickCollection(collections, args.collection)
13299
if (!picked)
133100
return
134101

135-
const manifest = await client.fetchCollection(session.login, picked.slug) as CollectionManifest | null
102+
let manifestFailed = false
103+
const manifest = await client.fetchCollection(session.login, picked.slug).catch((error) => {
104+
manifestFailed = true
105+
p.log.error(`Failed to load @${session.login}/${picked.slug}: ${error instanceof Error ? error.message : String(error)}`)
106+
process.exitCode = 1
107+
return null
108+
}) as CollectionManifest | null
136109
if (!manifest) {
137-
p.log.error(`Failed to load collection manifest for @${session.login}/${picked.slug}.`)
110+
if (!manifestFailed)
111+
p.log.error(`Collection @${session.login}/${picked.slug} was not found.`)
138112
process.exitCode = 1
139113
return
140114
}
@@ -156,7 +130,10 @@ export const pullCommandDef = defineCommand({
156130
auditByKey.set(manifestItemKey(item), { status: 'unaudited', audits: [] })
157131
return
158132
}
159-
const result = await client.audit({ owner: item.owner, repo: item.repo, name: item.name })
133+
const result = await client.audit({ owner: item.owner, repo: item.repo, name: item.name }).catch((error) => {
134+
p.log.warn(`Audit unavailable for ${item.owner}/${item.repo}/${item.name}: ${error instanceof Error ? error.message : String(error)}`)
135+
return { status: 'unaudited' as const, audits: [] }
136+
})
160137
auditCache.set(`${item.owner}/${item.repo}/${item.name}`, result)
161138
auditByKey.set(manifestItemKey(item), result)
162139
}))

0 commit comments

Comments
 (0)