From e8a9ad0d8863b432a39e653b18d583cd80b41de4 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 13 May 2026 16:21:11 +1000 Subject: [PATCH 1/6] chore: progress --- build.config.ts | 4 +- package.json | 11 +- pnpm-lock.yaml | 137 ++++++++++++++++++ src/cli.ts | 4 + src/commands/sync-git.ts | 18 ++- src/commands/sync/add.ts | 127 +++------------- src/commands/sync/registry.ts | 15 +- src/commands/sync/update.ts | 21 ++- src/core/paths.ts | 3 + src/index.ts | 56 -------- src/registry/client.ts | 262 ++++++++++++++++++++++++++++------ src/telemetry.ts | 95 ++++++------ 12 files changed, 481 insertions(+), 272 deletions(-) delete mode 100644 src/index.ts diff --git a/build.config.ts b/build.config.ts index f5d02ce9..17a2c0a8 100644 --- a/build.config.ts +++ b/build.config.ts @@ -5,12 +5,14 @@ export default defineBuildConfig({ { type: 'bundle', input: [ - './src/index.ts', './src/cli-entry.ts', './src/cli.ts', './src/prepare.ts', './src/retriv/worker.ts', ], + rolldown: { + external: ['@napi-rs/keyring', /@napi-rs\/keyring-/], + }, }, ], }) diff --git a/package.json b/package.json index a67c3032..7623a3d6 100644 --- a/package.json +++ b/package.json @@ -31,14 +31,6 @@ "cursor", "codex" ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, - "main": "./dist/index.mjs", - "types": "./dist/index.d.mts", "bin": { "skilld": "./dist/cli-entry.mjs" }, @@ -85,6 +77,9 @@ "typescript": "catalog:", "unagent": "catalog:" }, + "optionalDependencies": { + "@napi-rs/keyring": "^1.1.6" + }, "devDependencies": { "@antfu/eslint-config": "catalog:dev-lint", "@types/node": "catalog:dev-build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03e39daf..86b300a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,10 @@ importers: vitest: specifier: catalog:dev-test version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(vite@7.3.1(@types/node@25.7.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + optionalDependencies: + '@napi-rs/keyring': + specifier: ^1.1.6 + version: 1.3.0 packages: @@ -1220,6 +1224,87 @@ packages: '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -5117,6 +5202,58 @@ snapshots: - bufferutil - utf-8-validate + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 diff --git a/src/cli.ts b/src/cli.ts index c81bf66b..a5699463 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -85,6 +85,10 @@ const main = defineCommand({ search: () => import('./commands/search.ts').then(m => m.searchCommandDef), cache: () => import('./commands/cache.ts').then(m => m.cacheCommandDef), setup: () => import('./commands/wizard.ts').then(m => m.setupCommandDef), + login: () => import('./commands/login.ts').then(m => m.loginCommandDef), + logout: () => import('./commands/logout.ts').then(m => m.logoutCommandDef), + whoami: () => import('./commands/whoami.ts').then(m => m.whoamiCommandDef), + pull: () => import('./commands/pull.ts').then(m => m.pullCommandDef), // Author group (nested subcommands) author: () => import('./commands/author.ts').then(m => m.authorGroupDef), // Deprecated forwarders (old top-level commands → skilld author ) diff --git a/src/commands/sync-git.ts b/src/commands/sync-git.ts index cfd8ceb4..f3b756e2 100644 --- a/src/commands/sync-git.ts +++ b/src/commands/sync-git.ts @@ -137,11 +137,10 @@ export async function syncGitSkills(opts: GitSyncOptions): Promise { if (source.type !== 'local' && source.owner && source.repo) { track({ event: 'install', - source: `${source.owner}/${source.repo}`, - skills: selected.map(s => s.name).join(','), - agents: agent, - ...(isGlobal && { global: '1' as const }), - sourceType: source.type, + surface: 'cli:add', + sourceKind: 'gh', + slug: `${source.owner}/${source.repo}`, + agent, }) } @@ -190,11 +189,10 @@ async function syncGitHubRepo(opts: GitSyncOptions): Promise { track({ event: 'install', - source: spec, - skills: state.skillDirName, - agents: agent, - ...(isGlobal && { global: '1' as const }), - sourceType: 'github-generated', + surface: 'cli:add', + sourceKind: 'gh', + slug: spec, + agent, }) p.outro(`Synced ${spec} to ${relative(cwd, state.skillDir)}`) diff --git a/src/commands/sync/add.ts b/src/commands/sync/add.ts index 7849f5ea..60463cf4 100644 --- a/src/commands/sync/add.ts +++ b/src/commands/sync/add.ts @@ -1,38 +1,35 @@ import type { AgentType, OptimizeModel } from '../../agent/index.ts' -import type { GitSkillSource } from '../../sources/git-skills.ts' -import { styleText } from 'node:util' -import * as p from '@clack/prompts' import { defineCommand } from 'citty' import { promptForAgent, resolveAgent } from '../../cli/agent-prompt.ts' import { sharedArgs } from '../../cli/args.ts' -import { introLine } from '../../cli/intro.ts' import { hasCompletedWizard } from '../../core/config.ts' import { parseSkillInput } from '../../core/prefix.ts' import { COMMA_OR_WHITESPACE_RE } from '../../core/regex.ts' -import { getProjectState } from '../../core/skills.ts' -import { syncGitSkills } from '../sync-git.ts' -import { syncCommand } from '../sync.ts' import { runWizard } from '../wizard.ts' +import { installSkills } from './install-many.ts' import { exportPortablePrompts } from './portable.ts' export const addCommandDef = defineCommand({ meta: { name: 'add', description: 'Install skills (npm:, crate:, gh:, @)' }, args: { - package: { + 'package': { type: 'positional', description: 'Package(s) to sync (space/comma-separated; npm:, crate:, or owner/repo)', required: true, }, - skill: { + 'skill': { type: 'string', alias: 's', description: 'Select specific skills from a git repo (comma-separated)', valueHint: 'name', }, + 'allow-unsafe': { + type: 'boolean', + description: 'Install skills that fail the upstream audit gate', + }, ...sharedArgs, }, async run({ args }) { - const cwd = process.cwd() let agent: AgentType | 'none' | null = resolveAgent(args.agent) if (!agent) { agent = await promptForAgent() @@ -56,103 +53,17 @@ export const addCommandDef = defineCommand({ if (!hasCompletedWizard()) await runWizard({ agent }) - const parsedSources = rawInputs.map(parseSkillInput) - const gitSources: GitSkillSource[] = [] - const npmEntries: Array<{ name: string, spec: string }> = [] - const crateSpecs: string[] = [] - const unsupported: string[] = [] - - for (const source of parsedSources) { - switch (source.type) { - case 'git': - gitSources.push(source.source) - break - case 'npm': - npmEntries.push({ name: source.package, spec: source.tag ? `${source.package}@${source.tag}` : source.package }) - break - case 'crate': - crateSpecs.push(source.version ? `crate:${source.package}@${source.version}` : `crate:${source.package}`) - break - case 'bare': - p.log.warn(`Bare names are deprecated. Use ${styleText('cyan', `npm:${source.package}`)} instead.`) - npmEntries.push({ name: source.package, spec: source.tag ? `${source.package}@${source.tag}` : source.package }) - break - case 'curator': - unsupported.push(`@${source.handle} (curator)`) - break - case 'collection': - unsupported.push(`@${source.handle}/${source.name} (collection)`) - break - default: { - const _exhaustive: never = source - throw new Error(`Unhandled SkillSource type: ${JSON.stringify(_exhaustive)}`) - } - } - } - - if (unsupported.length > 0) { - p.log.error(`Curator and collection installs are not yet available:\n ${unsupported.join('\n ')}\n\nFollow https://skilld.dev for launch updates.`) - process.exitCode = 1 - if (gitSources.length === 0 && npmEntries.length === 0 && crateSpecs.length === 0) - return - } - - if (gitSources.length > 0) { - for (const source of gitSources) { - const skillFilter = args.skill ? args.skill.split(COMMA_OR_WHITESPACE_RE).map((s: string) => s.trim()).filter(Boolean) : undefined - await syncGitSkills({ source, global: args.global, agent, yes: args.yes, model: args.model as OptimizeModel | undefined, force: args.force, debug: args.debug, skillFilter }) - } - } - - if (npmEntries.length > 0) { - const { syncRegistrySkill } = await import('./registry.ts') - const seen = new Set() - const dedupedEntries = npmEntries.filter((e) => { - if (seen.has(e.name)) - return false - seen.add(e.name) - return true - }) - - const fallbackPackages: string[] = [] - for (const entry of dedupedEntries) { - const result = await syncRegistrySkill({ packageName: entry.name, agent, cwd }) - if (result) { - p.log.success(`Installed ${styleText('cyan', result.name)} from registry`) - } - else { - fallbackPackages.push(entry.spec) - } - } - - if (fallbackPackages.length > 0) { - const state = await getProjectState(cwd) - p.intro(introLine({ state, agentId: agent || undefined })) - await syncCommand(state, { - packages: [...fallbackPackages, ...crateSpecs], - global: args.global, - agent, - model: args.model as OptimizeModel | undefined, - yes: args.yes, - force: args.force, - debug: args.debug, - }) - return - } - } - - if (crateSpecs.length > 0) { - const state = await getProjectState(cwd) - p.intro(introLine({ state, agentId: agent || undefined })) - await syncCommand(state, { - packages: crateSpecs, - global: args.global, - agent, - model: args.model as OptimizeModel | undefined, - yes: args.yes, - force: args.force, - debug: args.debug, - }) - } + const items = rawInputs.map(parseSkillInput) + await installSkills(items, { + agent, + surface: 'cli:add', + global: args.global, + yes: args.yes, + force: args.force, + debug: args.debug, + model: args.model as OptimizeModel | undefined, + skillFilter: args.skill, + allowUnsafe: args['allow-unsafe'], + }) }, }) diff --git a/src/commands/sync/registry.ts b/src/commands/sync/registry.ts index 0f7bcfae..86040205 100644 --- a/src/commands/sync/registry.ts +++ b/src/commands/sync/registry.ts @@ -5,24 +5,29 @@ import type { AgentType } from '../../agent/index.ts' import type { RegistrySkill } from '../../registry/client.ts' +import type { TelemetrySurface } from '../../telemetry.ts' import { mkdirSync } from 'node:fs' import { join } from 'pathe' import { writeSkillMd } from '../../agent/prompts/skill.ts' import { installSkill, resolveBaseDir } from '../../agent/skill-installer.ts' import { SHARED_SKILLS_DIR } from '../../core/paths.ts' import { fetchRegistrySkill } from '../../registry/client.ts' +import { track } from '../../telemetry.ts' export interface SyncRegistryOptions { packageName: string agent: AgentType global?: boolean cwd?: string + /** Skip resolve when caller has already fetched the skill (e.g. after audit gate). */ + prefetched?: RegistrySkill + surface?: TelemetrySurface } export async function syncRegistrySkill(opts: SyncRegistryOptions): Promise { const { packageName, agent, cwd = process.cwd() } = opts - const skill = await fetchRegistrySkill(packageName) + const skill = opts.prefetched ?? await fetchRegistrySkill(packageName) if (!skill) return null @@ -49,5 +54,13 @@ export async function syncRegistrySkill(opts: SyncRegistryOptions): Promise { + const session = await loadSession() + if (!session || session.scheme === 'env') + return + const marker = peekMarker() + const client = createRegistryClient({ session }) + const changes = await client.my.changes({ since: marker?.lastDigestAt }).catch(() => []) + if (changes.length === 0) + return + renderDigest(changes) + updateMarker({ lastDigestAt: new Date().toISOString() }) +} + export const updateCommandDef = defineCommand({ meta: { name: 'update', description: 'Update outdated skills' }, args: { @@ -116,7 +132,7 @@ export const updateCommandDef = defineCommand({ ...state.outdated.map(s => s.packageName || s.name), ...crateSpecs, ] - return syncCommand(state, { + await syncCommand(state, { packages, global: args.global, agent, @@ -126,5 +142,8 @@ export const updateCommandDef = defineCommand({ debug: args.debug, mode: 'update', }) + + if (!silent) + await renderChangesDigest() }, }) diff --git a/src/core/paths.ts b/src/core/paths.ts index bc0fd9a8..35970826 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -53,6 +53,9 @@ export const CONFIG_PATH: string = join(CACHE_DIR, CONFIG_FILENAME) /** pi-ai auth credentials */ export const PI_AI_AUTH_PATH: string = join(CACHE_DIR, 'pi-ai-auth.json') +/** CLI auth marker (`~/.skilld/auth.json`, 0600). Stores tokens directly only when keychain unavailable. */ +export const AUTH_PATH: string = join(CACHE_DIR, 'auth.json') + // ── Helpers ── /** Returns the shared skills directory path if `.skills/` exists at project root, else null */ diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index fe557c87..00000000 --- a/src/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * skilld - Package documentation for agentic use - * - * Main entry point re-exports cache and retriv modules. - */ - -// Cache management -export { - CACHE_DIR, - clearAllCachedPackages, - clearCachedPackage, - createReferenceCache, - createRepoCache, - ensureCacheDir, - getCacheDir, - getCacheKey, - getVersionKey, - listCachedPackages, - REFERENCES_DIR, -} from './cache/index.ts' -export type { CacheConfig, CachedDoc, CachedPackage, ReferenceCache, RepoCache } from './cache/index.ts' - -// Search -export { - createIndex, - search, - searchSnippets, -} from './retriv/index.ts' -export type { - Document, - IndexConfig, - SearchFilter, - SearchOptions, - SearchResult, - SearchSnippet, -} from './retriv/index.ts' - -// Doc resolver -export { - downloadLlmsDocs, - fetchLlmsTxt, - fetchNpmPackage, - fetchReadmeContent, - normalizeLlmsLinks, - parseMarkdownLinks, - readLocalDependencies, - resolvePackageDocs, -} from './sources/index.ts' -export type { - FetchedDoc, - LlmsContent, - LlmsLink, - LocalDependency, - NpmPackageInfo, - ResolvedPackage, -} from './sources/index.ts' diff --git a/src/registry/client.ts b/src/registry/client.ts index 79cb4e41..4f96ae55 100644 --- a/src/registry/client.ts +++ b/src/registry/client.ts @@ -21,27 +21,16 @@ export function getRegistryBase(): string { } export interface RegistrySkill { - /** Skill directory name (matches what lands in .claude/skills//) */ name: string - /** npm package name used to look up this skill */ packageName: string - /** Raw SKILL.md content (frontmatter + body) */ content: string - /** Source repo owner */ owner: string - /** Full "owner/repo" identifier */ repo: string - /** Human-readable display name from the registry */ displayName?: string - /** Install count reported by the registry */ installs?: number - /** True when the source repo is on the official owners list */ official?: boolean - /** Default branch the SKILL.md was fetched from */ branch?: string - /** Path to SKILL.md within the source repo */ skillPath?: string - /** ISO timestamp of the source repo's last push — used for staleness */ updatedAt?: string } @@ -64,46 +53,237 @@ interface SkillDetailResponse { } export interface FetchRegistrySkillOptions { - /** Narrow the resolve to a specific owner when multiple skills share a name */ owner?: string } +export type AuditStatus = 'pass' | 'warn' | 'fail' | 'unaudited' + +export interface AuditEntry { + category: string + status: 'pass' | 'warn' | 'fail' + summary?: string +} + +export interface AuditResult { + status: AuditStatus + riskLevel?: 'low' | 'medium' | 'high' + summary?: string + audits: AuditEntry[] +} + +interface AuditApiResponse { + riskLevel?: 'low' | 'medium' | 'high' + summary?: string + audits?: AuditEntry[] +} + +export interface AuthSession { + accessToken: string + refreshToken?: string + login: string + expiresAt?: number + host?: string +} + +export interface RegistryClient { + resolveSkill: (packageName: string, opts?: FetchRegistrySkillOptions) => Promise + fetchSkillDetail: (owner: string, repo: string, name: string) => Promise + audit: (params: { owner: string, repo: string, name: string }) => Promise + fetchCollection: (login: string, slug: string) => Promise + fetchCurator: (login: string) => Promise + my: { + collections: () => Promise + subscriptions: () => Promise + changes: (params: { since?: string }) => Promise + installs: (payload: InstallEventPayload) => Promise + } +} + +export interface CollectionManifestItem { + kind: 'npm' | 'gh' | 'crate' + /** For kind='npm': the package name. For kind='gh': owner/repo. For kind='crate': crate name. */ + package?: string + owner?: string + repo?: string +} + +export interface CollectionManifest { + name: string + preamble?: string + items: CollectionManifestItem[] +} + +export interface CollectionSummary { + slug: string + name: string + itemCount: number +} + +export interface CuratorPayload { + login: string + collections: CollectionSummary[] +} + +export interface SubscriptionSummary { + login: string + slug: string +} + +export interface ChangeEntry { + repo: string + skill: string + at: string + summary?: string +} + +export interface InstallEventPayload { + slug: string + sourceKind: 'npm' | 'gh' | 'crate' | 'collection' + surface: string + agent?: string +} + +export type GateDecision = 'install' | 'skip' | 'prompt' + +export interface GateOptions { + allowUnsafe?: boolean + yes?: boolean + /** Source kind drives unaudited behaviour: gh → prompt, npm/crate → silent install */ + sourceKind: 'npm' | 'gh' | 'crate' | 'collection' +} + /** - * Fetch a curated package skill from the registry. - * Returns null if no curated skill exists, the SKILL.md can't be loaded, or the API is unreachable. + * Pure gating rule from an audit result. Caller is responsible for the prompt + * itself when the decision is `'prompt'`. */ -export async function fetchRegistrySkill( - packageName: string, - opts: FetchRegistrySkillOptions = {}, -): Promise { - const base = getRegistryBase() +export function gateInstall(result: AuditResult, opts: GateOptions): GateDecision { + switch (result.status) { + case 'pass': + return 'install' + case 'warn': + return 'install' + case 'fail': + return opts.allowUnsafe ? 'install' : 'skip' + case 'unaudited': + if (opts.sourceKind !== 'gh') + return 'install' + return opts.yes ? 'install' : 'prompt' + } +} - const resolved = await ofetch>(`${base}/skills/resolve`, { - method: 'POST', - body: { items: [{ packageName, owner: opts.owner }] }, - }).catch(() => null) +export function aggregateAuditStatus(audits: AuditEntry[]): AuditStatus { + if (audits.length === 0) + return 'unaudited' + if (audits.some(a => a.status === 'fail')) + return 'fail' + if (audits.some(a => a.status === 'warn')) + return 'warn' + return 'pass' +} - const hit = resolved?.[packageName] - if (!hit) - return null +export function createRegistryClient(opts: { session?: AuthSession, baseUrl?: string } = {}): RegistryClient { + const base = (opts.baseUrl ?? getRegistryBase()).replace(TRAILING_SLASH_RE, '') + const headers = opts.session ? { Authorization: `Bearer ${opts.session.accessToken}` } : undefined - const slug = `${hit.owner}/${hit.repo}/${packageName}` - const detail = await ofetch(`${base}/skills/${slug}`).catch(() => null) + const fetcher = (url: string, init?: Parameters>[1]): Promise => { + if (!headers && !init) + return ofetch(url) + if (!headers) + return ofetch(url, init) + return ofetch(url, { ...init, headers: { ...headers, ...(init?.headers as any) } }) + } - if (!detail?.raw) - return null + const requireSession = (): void => { + if (!opts.session) + throw new Error('auth required') + } return { - name: detail.name, - packageName, - content: detail.raw, - owner: detail.owner, - repo: `${detail.owner}/${detail.repo}`, - displayName: detail.displayName, - installs: detail.installs, - official: hit.official, - branch: detail.branch, - skillPath: detail.skillPath ?? undefined, - updatedAt: detail.pushedAt ?? undefined, + async resolveSkill(packageName, fetchOpts = {}) { + const resolved = await fetcher>(`${base}/skills/resolve`, { + method: 'POST', + body: { items: [{ packageName, owner: fetchOpts.owner }] }, + }).catch(() => null) + + const hit = resolved?.[packageName] + if (!hit) + return null + + const detail = await fetcher(`${base}/skills/${hit.owner}/${hit.repo}/${packageName}`).catch(() => null) + if (!detail?.raw) + return null + + return { + name: detail.name, + packageName, + content: detail.raw, + owner: detail.owner, + repo: `${detail.owner}/${detail.repo}`, + displayName: detail.displayName, + installs: detail.installs, + official: hit.official, + branch: detail.branch, + skillPath: detail.skillPath ?? undefined, + updatedAt: detail.pushedAt ?? undefined, + } + }, + + async fetchSkillDetail(owner, repo, name) { + return fetcher(`${base}/skills/${owner}/${repo}/${name}`).catch(() => null) + }, + + async audit({ owner, repo, name }) { + const res = await fetcher(`${base}/skill-live/${owner}/${repo}/${name}`).catch(() => null) + if (!res) + return { status: 'unaudited', audits: [] } + const audits = res.audits ?? [] + return { + status: aggregateAuditStatus(audits), + riskLevel: res.riskLevel, + summary: res.summary, + audits, + } + }, + + async fetchCollection(login, slug) { + return fetcher(`${base}/collections/by-author/${login}/${slug}/manifest`).catch(() => null) + }, + + async fetchCurator(login) { + return fetcher(`${base}/curators/${login}`).catch(() => null) + }, + + my: { + async collections() { + requireSession() + return fetcher(`${base}/me/collections`).catch(() => []) + }, + async subscriptions() { + requireSession() + return fetcher(`${base}/me/subscriptions`).catch(() => []) + }, + async changes({ since }) { + requireSession() + const qs = since ? `?since=${encodeURIComponent(since)}` : '' + return fetcher(`${base}/cli/changes${qs}`).catch(() => []) + }, + async installs(payload) { + requireSession() + await fetcher(`${base}/me/installs`, { method: 'POST', body: payload }).catch(() => {}) + }, + }, } } + +/** + * Fetch a curated package skill from the registry. + * Returns null if no curated skill exists, the SKILL.md can't be loaded, or the API is unreachable. + * + * Thin wrapper over `createRegistryClient().resolveSkill` for back-compat. + */ +export async function fetchRegistrySkill( + packageName: string, + opts: FetchRegistrySkillOptions = {}, +): Promise { + return createRegistryClient().resolveSkill(packageName, opts) +} diff --git a/src/telemetry.ts b/src/telemetry.ts index fd5b1989..f9a9a799 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -1,63 +1,66 @@ /** - * Anonymous telemetry — fire-and-forget GET to add-skill.vercel.sh/t + * Anonymous telemetry → `POST /events/cli`. Fire-and-forget; never + * throws into a caller, never blocks shutdown. * - * Opt-out: set DISABLE_TELEMETRY=1 or DO_NOT_TRACK=1 - * Auto-disabled in CI environments. + * Opt-out: `SKILLD_TELEMETRY=0`, `DISABLE_TELEMETRY=1`, or `DO_NOT_TRACK=1`. + * Auto-disabled in CI. */ import { isCI } from 'std-env' +import { getRegistryBase } from './registry/client.ts' +import { version } from './version.ts' -const TELEMETRY_URL = 'https://add-skill.vercel.sh/t' -const SKILLS_VERSION = '1.3.9' - -interface InstallTelemetryData { - event: 'install' - source: string - skills: string - agents: string - global?: '1' - skillFiles?: string - sourceType?: string -} +export type TelemetryEvent + = | 'install' + | 'install-failed' + | 'update' + | 'audit-warn' + | 'audit-fail' + | 'audit-blocked' + | 'auth-flow' + | 'pull-checklist' -interface RemoveTelemetryData { - event: 'remove' - source?: string - skills: string - agents: string - global?: '1' - sourceType?: string -} +export type TelemetrySurface + = | 'cli:add' + | 'cli:pull' + | 'cli:prepare' + | 'cli:update' + | 'cli:wizard' + | 'cli:auth' -type TelemetryData - = | InstallTelemetryData - | RemoveTelemetryData +export interface TelemetryPayload { + event: TelemetryEvent + surface: TelemetrySurface + sourceKind?: 'npm' | 'gh' | 'crate' | 'collection' | 'curator' + slug?: string + agent?: string + durationMs?: number + userId?: number + /** auth-flow only */ + flow?: 'pkce' | 'device' | 'oidc' +} function isEnabled(): boolean { - return !process.env.DISABLE_TELEMETRY && !process.env.DO_NOT_TRACK + if (process.env.SKILLD_TELEMETRY === '0') + return false + if (process.env.DISABLE_TELEMETRY || process.env.DO_NOT_TRACK) + return false + return true } -export function track(data: TelemetryData): void { +export function track(payload: TelemetryPayload): void { if (!isEnabled()) return - try { - const params = new URLSearchParams() - - params.set('v', SKILLS_VERSION) - - if (isCI) - params.set('ci', '1') - - for (const [key, value] of Object.entries(data)) { - if (value !== undefined && value !== null) - params.set(key, String(value)) - } - - // Fire and forget - fetch(`${TELEMETRY_URL}?${params.toString()}`).catch(() => {}) - } - catch { - // Telemetry should never break the CLI + const body = { + ...payload, + cliVersion: version, + ...(isCI && { ci: true }), } + + fetch(`${getRegistryBase()}/events/cli`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}) } From 828505e19fba6ce90ff79e7c46c331a946bd3a73 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 13 May 2026 16:21:18 +1000 Subject: [PATCH 2/6] chore: progress --- src/auth/client.ts | 68 +++++++++ src/auth/device-flow.ts | 61 ++++++++ src/auth/oidc.ts | 34 +++++ src/auth/pkce-flow.ts | 139 ++++++++++++++++++ src/auth/store.ts | 175 +++++++++++++++++++++++ src/auth/types.ts | 8 ++ src/cli/digest-render.ts | 53 +++++++ src/commands/login.ts | 86 +++++++++++ src/commands/logout.ts | 29 ++++ src/commands/pull.ts | 196 +++++++++++++++++++++++++ src/commands/sync/install-many.ts | 229 ++++++++++++++++++++++++++++++ src/commands/whoami.ts | 20 +++ 12 files changed, 1098 insertions(+) create mode 100644 src/auth/client.ts create mode 100644 src/auth/device-flow.ts create mode 100644 src/auth/oidc.ts create mode 100644 src/auth/pkce-flow.ts create mode 100644 src/auth/store.ts create mode 100644 src/auth/types.ts create mode 100644 src/cli/digest-render.ts create mode 100644 src/commands/login.ts create mode 100644 src/commands/logout.ts create mode 100644 src/commands/pull.ts create mode 100644 src/commands/sync/install-many.ts create mode 100644 src/commands/whoami.ts diff --git a/src/auth/client.ts b/src/auth/client.ts new file mode 100644 index 00000000..615cf883 --- /dev/null +++ b/src/auth/client.ts @@ -0,0 +1,68 @@ +/** + * `withAuth(fetcher)` — 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 { TokenResponse } from './types.ts' +import { ofetch } from 'ofetch' +import { getRegistryBase } from '../registry/client.ts' +import { loadSession, saveSession } from './store.ts' + +export interface AuthedFetcher { + (url: string, init?: Parameters>[1]): Promise +} + +async function refreshSession(refreshToken: string): Promise { + const base = getRegistryBase() + return ofetch(`${base}/cli/oauth/refresh`, { + method: 'POST', + body: { refresh_token: refreshToken }, + }).catch(() => null) +} + +export function withAuth(): AuthedFetcher { + return async (url: string, init?: Parameters>[1]): Promise => { + const session = await loadSession() + if (!session) + throw new Error('auth required') + + const send = (token: string): Promise => ofetch(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 + + if (session.scheme === 'env' || !session.refreshToken) + throw firstAttempt + + // Re-read marker; another process may have already rotated. + const fresh = await loadSession() + const candidateRefresh = fresh?.refreshToken ?? session.refreshToken + if (fresh && fresh.accessToken !== session.accessToken) { + return send(fresh.accessToken) + } + + const rotated = await refreshSession(candidateRefresh) + if (!rotated) + throw firstAttempt + + await saveSession({ + login: rotated.login, + accessToken: rotated.accessToken, + refreshToken: rotated.refreshToken, + expiresAt: rotated.expiresAt, + tokens: { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken }, + }) + + return send(rotated.accessToken) + } +} diff --git a/src/auth/device-flow.ts b/src/auth/device-flow.ts new file mode 100644 index 00000000..5cf0b352 --- /dev/null +++ b/src/auth/device-flow.ts @@ -0,0 +1,61 @@ +/** + * RFC 8628 device flow. Used when --device is passed, no browser is detected, + * or PKCE bind fails. + */ + +import type { TokenResponse } from './types.ts' +import { ofetch } from 'ofetch' + +export interface DeviceStartResponse { + device_code: string + user_code: string + verification_uri: string + interval: number + expires_in: number +} + +export interface DeviceFlowOptions { + registryBase: string + cliVersion: string + machineHint?: string + /** Hook called once the user_code is known so the CLI can prompt the user. */ + onUserCode: (info: { userCode: string, verificationUri: string }) => void + /** Override polling interval for tests. */ + intervalMs?: number +} + +interface PollResponse { + status: 'pending' | 'authorized' | 'expired' | 'denied' + tokens?: TokenResponse +} + +export async function runDeviceFlow(opts: DeviceFlowOptions): Promise { + const start = await ofetch(`${opts.registryBase}/cli/device/start`, { + method: 'POST', + body: { cli_version: opts.cliVersion, machine_hint: opts.machineHint }, + }) + + opts.onUserCode({ userCode: start.user_code, verificationUri: start.verification_uri }) + + const deadline = Date.now() + start.expires_in * 1000 + const interval = opts.intervalMs ?? start.interval * 1000 + + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, interval)) + const poll = await ofetch(`${opts.registryBase}/cli/device/poll`, { + method: 'POST', + body: { device_code: start.device_code }, + }).catch(() => null) + + if (!poll || poll.status === 'pending') + continue + if (poll.status === 'expired') + throw new Error('Device code expired before authorization') + if (poll.status === 'denied') + throw new Error('Device authorization denied') + if (poll.status === 'authorized' && poll.tokens) + return poll.tokens + } + + throw new Error('Device authorization timed out') +} diff --git a/src/auth/oidc.ts b/src/auth/oidc.ts new file mode 100644 index 00000000..963e3336 --- /dev/null +++ b/src/auth/oidc.ts @@ -0,0 +1,34 @@ +/** + * GitHub Actions OIDC exchange. Auto-detected via `ACTIONS_ID_TOKEN_REQUEST_TOKEN`; + * fetches a short-lived JWT against `audience=skilld.dev` and trades it for a + * session token. No browser, no prompt, no refresh. + */ + +import type { TokenResponse } from './types.ts' +import { ofetch } from 'ofetch' + +interface GhaOidcResponse { + value: string + count?: number +} + +export function isGhaOidcAvailable(): boolean { + return !!(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && process.env.ACTIONS_ID_TOKEN_REQUEST_URL) +} + +export async function runOidcExchange(opts: { registryBase: string, audience?: string }): Promise { + const token = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN + const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL + if (!token || !url) + throw new Error('Not running in GitHub Actions with id-token: write permission') + + const audience = opts.audience ?? 'skilld.dev' + const idToken = await ofetch(`${url}&audience=${encodeURIComponent(audience)}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + + return ofetch(`${opts.registryBase}/cli/oidc/exchange`, { + method: 'POST', + body: { id_token: idToken.value }, + }) +} diff --git a/src/auth/pkce-flow.ts b/src/auth/pkce-flow.ts new file mode 100644 index 00000000..a8d34da9 --- /dev/null +++ b/src/auth/pkce-flow.ts @@ -0,0 +1,139 @@ +/** + * RFC 7636 PKCE loopback flow. + * + * Binds `127.0.0.1:` and `[::1]:` simultaneously so the browser + * can hit either; opens the system browser to the verification URL; serves a + * single GET callback that captures the auth code, then exchanges it for + * tokens against `/api/cli/oauth/token`. + */ + +import type { AddressInfo } from 'node:net' +import type { TokenResponse } from './types.ts' +import { spawn } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' +import { createServer } from 'node:http' +import { ofetch } from 'ofetch' + +const SUCCESS_HTML = `skilld — signed in + +

Signed in to skilld

You can close this tab and return to the CLI.

` + +function ERROR_HTML(msg: string) { + return `skilld — error + +

Sign-in failed

${msg}

` +} + +export interface PkceFlowOptions { + registryBase: string + cliVersion: string + openBrowser?: (url: string) => Promise | void + timeoutMs?: number +} + +const PLUS_RE = /\+/g +const SLASH_RE = /\//g +const EQ_RE = /=+$/ +const TRAILING_SLASH_RE = /\/$/ +const TRAILING_API_RE = /\/api$/ + +function base64url(buf: Buffer): string { + return buf.toString('base64').replace(PLUS_RE, '-').replace(SLASH_RE, '_').replace(EQ_RE, '') +} + +function generateVerifier(): string { + return base64url(randomBytes(32)) +} + +function challengeFromVerifier(verifier: string): string { + return base64url(createHash('sha256').update(verifier).digest()) +} + +export async function runPkceFlow(opts: PkceFlowOptions): Promise { + const verifier = generateVerifier() + const challenge = challengeFromVerifier(verifier) + const state = base64url(randomBytes(16)) + + const { port, server, gotCode } = await bindLoopback(state) + const verificationUrl = new URL(`${opts.registryBase.replace(TRAILING_SLASH_RE, '').replace(TRAILING_API_RE, '')}/cli/authorize`) + verificationUrl.searchParams.set('challenge', challenge) + verificationUrl.searchParams.set('port', String(port)) + verificationUrl.searchParams.set('state', state) + verificationUrl.searchParams.set('v', opts.cliVersion) + + await (opts.openBrowser ?? defaultOpenBrowser)(verificationUrl.toString()) + + try { + const code = await Promise.race([ + gotCode, + new Promise((_, reject) => setTimeout(() => reject(new Error('PKCE flow timed out')), opts.timeoutMs ?? 5 * 60_000)), + ]) + + return await ofetch(`${opts.registryBase}/cli/oauth/token`, { + method: 'POST', + body: { code, code_verifier: verifier, redirect_uri: `http://127.0.0.1:${port}/` }, + }) + } + finally { + server.close() + } +} + +interface LoopbackBinding { + port: number + server: { close: () => void } + gotCode: Promise +} + +async function bindLoopback(expectedState: string): Promise { + let resolveCode!: (code: string) => void + let rejectCode!: (err: Error) => void + const gotCode = new Promise((res, rej) => { + resolveCode = res + rejectCode = rej + }) + + const handler = (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): void => { + const url = new URL(req.url ?? '/', 'http://localhost') + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + if (!code || state !== expectedState) { + res.writeHead(400, { 'content-type': 'text/html' }).end(ERROR_HTML('Missing or invalid state parameter.')) + rejectCode(new Error('PKCE callback missing code or state mismatch')) + return + } + res.writeHead(200, { 'content-type': 'text/html' }).end(SUCCESS_HTML) + resolveCode(code) + } + + const v4 = createServer(handler) + const v6 = createServer(handler) + + await new Promise((resolve, reject) => { + v4.once('error', reject).listen(0, '127.0.0.1', () => resolve()) + }) + const port = (v4.address() as AddressInfo).port + await new Promise((resolve) => { + v6.once('error', () => resolve()).listen(port, '::1', () => resolve()) + }) + + const close = (): void => { + v4.close() + v6.close() + } + + return { + port, + server: { close }, + gotCode, + } +} + +function defaultOpenBrowser(url: string): void { + const cmd = process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'start' + : 'xdg-open' + spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref() +} diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 00000000..b788a3dc --- /dev/null +++ b/src/auth/store.ts @@ -0,0 +1,175 @@ +/** + * Auth credential store. Prefers OS keychain via the optional `@napi-rs/keyring` + * dependency; falls back to a 0600 JSON file at `~/.skilld/auth.json`. + * + * `SKILLD_TOKEN` env var trumps everything: hard expiry, no refresh, no marker + * file. Use it in CI when keychain access isn't available. + */ + +import type { AuthSession } from '../registry/client.ts' +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname } from 'pathe' +import { AUTH_PATH, CACHE_DIR } from '../core/paths.ts' + +const KEYRING_SERVICE = 'skilld' +const DEFAULT_HOST = 'https://skilld.dev' + +export type StorageScheme = 'keychain' | 'file' | 'env' + +export interface StoredTokens { + accessToken: string + refreshToken?: string +} + +export interface AuthMarker { + scheme: StorageScheme + login: string + expiresAt?: number + host: string + /** Present only when scheme = 'file' */ + tokens?: StoredTokens + /** ISO timestamp of the last in-terminal digest the user saw. */ + lastDigestAt?: string +} + +export interface StoredSession extends AuthSession { + scheme: StorageScheme +} + +interface KeyringEntry { + new(service: string, account: string): { + getPassword: () => string | null + setPassword: (value: string) => void + deletePassword: () => boolean + } +} + +let keyringPromise: Promise | null = null + +async function loadKeyring(): Promise { + if (!keyringPromise) { + keyringPromise = (import('@napi-rs/keyring' as any) as Promise<{ Entry: KeyringEntry }>) + .then(m => m.Entry) + .catch(() => null) + } + return keyringPromise +} + +function readMarker(): AuthMarker | null { + if (!existsSync(AUTH_PATH)) + return null + const raw = readFileSync(AUTH_PATH, 'utf8').trim() + if (!raw) + return null + return JSON.parse(raw) as AuthMarker +} + +function writeMarker(marker: AuthMarker): void { + if (!existsSync(CACHE_DIR)) + mkdirSync(dirname(AUTH_PATH), { recursive: true }) + writeFileSync(AUTH_PATH, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 }) + chmodSync(AUTH_PATH, 0o600) +} + +export async function saveSession(session: AuthSession & { tokens: StoredTokens }): Promise { + const Keyring = await loadKeyring() + const host = session.host ?? DEFAULT_HOST + + if (Keyring) { + new Keyring(KEYRING_SERVICE, `access:${session.login}`).setPassword(session.tokens.accessToken) + if (session.tokens.refreshToken) + new Keyring(KEYRING_SERVICE, `refresh:${session.login}`).setPassword(session.tokens.refreshToken) + + writeMarker({ + scheme: 'keychain', + login: session.login, + expiresAt: session.expiresAt, + host, + }) + return 'keychain' + } + + writeMarker({ + scheme: 'file', + login: session.login, + expiresAt: session.expiresAt, + host, + tokens: session.tokens, + }) + return 'file' +} + +export async function loadSession(): Promise { + const envToken = process.env.SKILLD_TOKEN + if (envToken) { + return { + scheme: 'env', + accessToken: envToken, + login: process.env.SKILLD_LOGIN ?? 'ci', + host: DEFAULT_HOST, + } + } + + const marker = readMarker() + if (!marker) + return null + + if (marker.scheme === 'file') { + if (!marker.tokens?.accessToken) + return null + return { + scheme: 'file', + accessToken: marker.tokens.accessToken, + refreshToken: marker.tokens.refreshToken, + login: marker.login, + expiresAt: marker.expiresAt, + host: marker.host, + } + } + + const Keyring = await loadKeyring() + if (!Keyring) + return null + + const accessToken = new Keyring(KEYRING_SERVICE, `access:${marker.login}`).getPassword() + if (!accessToken) + return null + const refreshToken = new Keyring(KEYRING_SERVICE, `refresh:${marker.login}`).getPassword() ?? undefined + + return { + scheme: 'keychain', + accessToken, + refreshToken, + login: marker.login, + expiresAt: marker.expiresAt, + host: marker.host, + } +} + +export async function clearSession(): Promise { + const marker = readMarker() + if (marker) { + const Keyring = await loadKeyring() + if (Keyring && marker.scheme === 'keychain') { + new Keyring(KEYRING_SERVICE, `access:${marker.login}`).deletePassword() + new Keyring(KEYRING_SERVICE, `refresh:${marker.login}`).deletePassword() + } + rmSync(AUTH_PATH, { force: true }) + } +} + +export function updateMarker(patch: Partial): void { + const current = readMarker() + if (!current) + return + writeMarker({ ...current, ...patch }) +} + +export function peekMarker(): AuthMarker | null { + try { + return readMarker() + } + catch { + return null + } +} diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 00000000..71a5ee01 --- /dev/null +++ b/src/auth/types.ts @@ -0,0 +1,8 @@ +/** Token payload returned by `POST /api/cli/oauth/token` and `…/refresh`. */ +export interface TokenResponse { + accessToken: string + refreshToken?: string + expiresAt: number + login: string + scopes?: string +} diff --git a/src/cli/digest-render.ts b/src/cli/digest-render.ts new file mode 100644 index 00000000..4e80e542 --- /dev/null +++ b/src/cli/digest-render.ts @@ -0,0 +1,53 @@ +/** + * Render the in-terminal change digest from `/api/cli/changes`. + * + * Groups entries by repo, shows the skill name + AI summary if present, and + * prints a link back to the web view. Format mirrors the email digest minus + * the HTML wrapper. + */ + +import type { ChangeEntry } from '../registry/client.ts' +import { styleText } from 'node:util' +import * as p from '@clack/prompts' + +const RELATIVE_FORMATTER = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) + +function relative(iso: string, now = Date.now()): string { + const delta = (new Date(iso).getTime() - now) / 1000 + const minutes = delta / 60 + if (Math.abs(minutes) < 60) + return RELATIVE_FORMATTER.format(Math.round(minutes), 'minute') + const hours = minutes / 60 + if (Math.abs(hours) < 24) + return RELATIVE_FORMATTER.format(Math.round(hours), 'hour') + 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 + } + + const byRepo = new Map() + for (const entry of entries) { + const list = byRepo.get(entry.repo) ?? [] + list.push(entry) + byRepo.set(entry.repo, list) + } + + const lines: string[] = [] + for (const [repo, items] of byRepo) { + lines.push(styleText('cyan', repo)) + for (const item of items) { + const when = styleText('gray', relative(item.at)) + lines.push(` ${styleText('green', '•')} ${item.skill} ${when}`) + if (item.summary) + lines.push(` ${styleText('gray', item.summary)}`) + } + } + lines.push('') + lines.push(styleText('gray', 'See full activity at https://skilld.dev/me/activity')) + + p.log.message(lines.join('\n')) +} diff --git a/src/commands/login.ts b/src/commands/login.ts new file mode 100644 index 00000000..c6b5d8db --- /dev/null +++ b/src/commands/login.ts @@ -0,0 +1,86 @@ +/** + * `skilld login` — authenticate with skilld.dev. + * + * Picks a flow based on env: + * - `ACTIONS_ID_TOKEN_REQUEST_TOKEN` set → GHA OIDC exchange. + * - `--device` or no `DISPLAY`/`BROWSER` env → RFC 8628 device flow. + * - Otherwise → PKCE loopback. + */ + +import { styleText } from 'node:util' +import * as p from '@clack/prompts' +import { defineCommand } from 'citty' +import { runDeviceFlow } from '../auth/device-flow.ts' +import { isGhaOidcAvailable, runOidcExchange } from '../auth/oidc.ts' +import { runPkceFlow } from '../auth/pkce-flow.ts' +import { saveSession } from '../auth/store.ts' +import { getRegistryBase } from '../registry/client.ts' +import { track } from '../telemetry.ts' +import { version } from '../version.ts' + +function shouldUseDevice(force: boolean): boolean { + if (force) + return true + if (process.env.BROWSER) + return false + if (process.platform === 'darwin' || process.platform === 'win32') + return false + return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY +} + +export const loginCommandDef = defineCommand({ + meta: { name: 'login', description: 'Authenticate with skilld.dev' }, + args: { + device: { type: 'boolean', description: 'Use RFC 8628 device flow' }, + }, + async run({ args }) { + const registryBase = getRegistryBase() + + if (isGhaOidcAvailable()) { + const spin = p.spinner() + spin.start('Exchanging GitHub Actions OIDC token') + const tokens = await runOidcExchange({ registryBase }) + spin.stop(`Authenticated as @${tokens.login} (oidc)`) + await saveSession({ + login: tokens.login, + accessToken: tokens.accessToken, + expiresAt: tokens.expiresAt, + tokens: { accessToken: tokens.accessToken }, + }) + track({ event: 'auth-flow', surface: 'cli:auth', flow: 'oidc' }) + return + } + + if (shouldUseDevice(!!args.device)) { + const tokens = await runDeviceFlow({ + registryBase, + cliVersion: version, + onUserCode: ({ userCode, verificationUri }) => { + p.log.info(`Visit ${styleText('cyan', verificationUri)} and enter ${styleText('bold', userCode)}`) + }, + }) + await saveSession({ + login: tokens.login, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + tokens: { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }, + }) + track({ event: 'auth-flow', surface: 'cli:auth', flow: 'device' }) + p.log.success(`Logged in as @${tokens.login}`) + return + } + + p.log.info('Opening browser to authenticate…') + const tokens = await runPkceFlow({ registryBase, cliVersion: version }) + await saveSession({ + login: tokens.login, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + tokens: { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }, + }) + track({ event: 'auth-flow', surface: 'cli:auth', flow: 'pkce' }) + p.log.success(`Logged in as @${tokens.login}`) + }, +}) diff --git a/src/commands/logout.ts b/src/commands/logout.ts new file mode 100644 index 00000000..d982f2fd --- /dev/null +++ b/src/commands/logout.ts @@ -0,0 +1,29 @@ +/** + * `skilld logout` — revoke the active session server-side and clear local credentials. + * Local state is cleared even if the server revoke fails. + */ + +import * as p from '@clack/prompts' +import { defineCommand } from 'citty' +import { ofetch } from 'ofetch' +import { clearSession, loadSession } from '../auth/store.ts' +import { getRegistryBase } from '../registry/client.ts' + +export const logoutCommandDef = defineCommand({ + meta: { name: 'logout', description: 'Sign out of skilld.dev' }, + async run() { + const session = await loadSession() + if (!session) { + p.log.info('Not logged in.') + return + } + + await ofetch(`${getRegistryBase()}/cli/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${session.accessToken}` }, + }).catch(() => {}) + + await clearSession() + p.log.success('Logged out.') + }, +}) diff --git a/src/commands/pull.ts b/src/commands/pull.ts new file mode 100644 index 00000000..350269c1 --- /dev/null +++ b/src/commands/pull.ts @@ -0,0 +1,196 @@ +/** + * `skilld pull` — install skills from one of the user's collections. + * + * Headline authed command. Pulls the user's collections from skilld.dev, + * optionally lets them pick one, fans out audit checks in parallel, and + * presents a multiselect checklist with audit-status badges before handing + * the selection to `installSkills` (surface = `cli:pull`). + */ + +import type { SkillSource } from '../core/prefix.ts' +import type { AuditResult, AuditStatus, CollectionManifest, CollectionManifestItem, CollectionSummary } from '../registry/client.ts' +import { styleText } from 'node:util' +import * as p from '@clack/prompts' +import { defineCommand } from 'citty' +import { loadSession } from '../auth/store.ts' +import { promptForAgent, resolveAgent } from '../cli/agent-prompt.ts' +import { sharedArgs } from '../cli/args.ts' +import { createRegistryClient } from '../registry/client.ts' +import { track } from '../telemetry.ts' +import { installSkills } from './sync/install-many.ts' + +function manifestToSource(item: CollectionManifestItem): SkillSource | null { + if (item.kind === 'npm' && item.package) + return { type: 'npm', package: item.package } + if (item.kind === 'crate' && item.package) + return { type: 'crate', package: item.package } + if (item.kind === 'gh' && item.owner && item.repo) + return { type: 'git', source: { type: 'github', owner: item.owner, repo: item.repo } } + return null +} + +function manifestItemKey(item: CollectionManifestItem): string { + if (item.kind === 'gh') + return `${item.owner}/${item.repo}` + return item.package ?? `${item.kind}:unknown` +} + +function badgeFor(status: AuditStatus, result: AuditResult): string { + switch (status) { + case 'pass': + return styleText('green', '✓ audited') + case 'warn': + return styleText('yellow', `⚠ warn${result.summary ? `: ${result.summary}` : ''}`) + case 'fail': + return styleText('red', `✗ fail${result.summary ? `: ${result.summary}` : ''}`) + case 'unaudited': + return styleText('gray', '? unaudited') + } +} + +async function pickCollection(collections: CollectionSummary[], slug?: string): Promise { + if (slug) { + const match = collections.find(c => c.slug === slug) + if (!match) { + p.log.error(`No collection with slug "${slug}". Available: ${collections.map(c => c.slug).join(', ')}`) + return null + } + return match + } + if (collections.length === 0) { + p.log.warn('You have no collections on skilld.dev. Create one at https://skilld.dev/me/collections') + return null + } + if (collections.length === 1) + return collections[0]! + + const choice = await p.select({ + message: 'Pick a collection', + options: collections.map(c => ({ label: c.name, value: c.slug, hint: `${c.itemCount} skills` })), + }) + if (p.isCancel(choice)) + return null + return collections.find(c => c.slug === choice) ?? null +} + +export const pullCommandDef = defineCommand({ + meta: { name: 'pull', description: 'Install skills from one of your collections' }, + args: { + 'collection': { type: 'string', description: 'Collection slug to pull', valueHint: 'slug' }, + 'all': { type: 'boolean', description: 'Install every item without prompting' }, + 'allow-unsafe': { type: 'boolean', description: 'Install items that fail the audit gate' }, + ...sharedArgs, + }, + async run({ args }) { + let agent = resolveAgent(args.agent) + if (!agent) + agent = await promptForAgent() + if (!agent || agent === 'none') { + p.log.error('`skilld pull` requires an agent target.') + return + } + + const session = await loadSession() + if (!session) { + p.log.error('Not logged in. Run `skilld login` first.') + process.exitCode = 1 + return + } + + const client = createRegistryClient({ session }) + const collections = await client.my.collections() + const picked = await pickCollection(collections, args.collection) + if (!picked) + return + + const manifest = await client.fetchCollection(session.login, picked.slug) as CollectionManifest | null + if (!manifest) { + p.log.error(`Failed to load collection manifest for @${session.login}/${picked.slug}.`) + process.exitCode = 1 + return + } + if (manifest.items.length === 0) { + p.log.warn('Collection is empty.') + return + } + + const auditCache = new Map() + const auditByKey = new Map() + + const spin = p.spinner() + spin.start(`Auditing ${manifest.items.length} items`) + await Promise.all(manifest.items.map(async (item) => { + if (item.kind === 'crate') { + auditByKey.set(manifestItemKey(item), { status: 'unaudited', audits: [] }) + return + } + const owner = item.owner ?? (item.package?.split('/')[0]) + const repo = item.repo ?? (item.package?.split('/')[1] ?? item.package ?? '') + const name = item.package ?? `${item.owner}/${item.repo}` + if (!owner || !repo || !name) { + auditByKey.set(manifestItemKey(item), { status: 'unaudited', audits: [] }) + return + } + const result = await client.audit({ owner, repo, name }) + auditCache.set(`${owner}/${repo}/${name}`, result) + auditByKey.set(manifestItemKey(item), result) + })) + spin.stop(`Audited ${manifest.items.length} items`) + + track({ + event: 'pull-checklist', + surface: 'cli:pull', + sourceKind: 'collection', + slug: `${session.login}/${picked.slug}`, + agent, + }) + + let selected: CollectionManifestItem[] + if (args.all || args.yes) { + selected = manifest.items.filter((item) => { + const audit = auditByKey.get(manifestItemKey(item)) + return args['allow-unsafe'] || audit?.status !== 'fail' + }) + } + else { + const choice = await p.multiselect({ + message: `Select skills from ${manifest.name}`, + required: false, + initialValues: manifest.items + .filter(item => auditByKey.get(manifestItemKey(item))?.status !== 'fail') + .map(manifestItemKey), + options: manifest.items.map((item) => { + const key = manifestItemKey(item) + const audit = auditByKey.get(key) ?? { status: 'unaudited' as const, audits: [] } + return { + label: `${key} ${styleText('gray', `(${item.kind})`)}`, + value: key, + hint: badgeFor(audit.status, audit), + } + }), + }) + if (p.isCancel(choice)) + return + const chosen = new Set(choice as string[]) + selected = manifest.items.filter(item => chosen.has(manifestItemKey(item))) + } + + const items = selected.map(manifestToSource).filter((s): s is SkillSource => s !== null) + if (items.length === 0) { + p.log.info('Nothing to install.') + return + } + + const summary = await installSkills(items, { + agent, + surface: 'cli:pull', + yes: args.yes, + force: args.force, + debug: args.debug, + allowUnsafe: args['allow-unsafe'], + auditCache, + }) + + p.outro(`${summary.installed} installed · ${summary.skipped} skipped · ${summary.failed} failed`) + }, +}) diff --git a/src/commands/sync/install-many.ts b/src/commands/sync/install-many.ts new file mode 100644 index 00000000..5a4bda0e --- /dev/null +++ b/src/commands/sync/install-many.ts @@ -0,0 +1,229 @@ +/** + * Install many skills from a parsed source list. Routes each `SkillSource` + * to the right pipeline (git, npm registry → npm doc fallback, crate) and + * collects per-item outcomes for telemetry and `pull` summaries. + */ + +import type { AgentType, OptimizeModel } from '../../agent/index.ts' +import type { SkillSource } from '../../core/prefix.ts' +import type { AuditResult, RegistryClient } from '../../registry/client.ts' +import type { GitSkillSource } from '../../sources/git-skills.ts' +import { styleText } from 'node:util' +import * as p from '@clack/prompts' +import { introLine } from '../../cli/intro.ts' +import { COMMA_OR_WHITESPACE_RE } from '../../core/regex.ts' +import { getProjectState } from '../../core/skills.ts' +import { createRegistryClient, gateInstall } from '../../registry/client.ts' +import { track } from '../../telemetry.ts' +import { syncGitSkills } from '../sync-git.ts' +import { syncCommand } from '../sync.ts' +import { syncRegistrySkill } from './registry.ts' + +export type InstallSurface = 'cli:add' | 'cli:pull' | 'cli:prepare' | 'cli:update' | 'cli:wizard' + +export interface InstallOpts { + agent: AgentType + surface: InstallSurface + global?: boolean + yes?: boolean + force?: boolean + debug?: boolean + model?: OptimizeModel + skillFilter?: string + /** Allow installs that fail the upstream audit gate (Step 3 wiring). */ + allowUnsafe?: boolean + /** Caller-supplied audit cache; pull populates this with pre-fetched results. */ + auditCache?: Map +} + +export interface InstallSummary { + installed: number + skipped: number + failed: number +} + +const RECEIPTS_URL = 'https://skilld.dev/gh' + +async function getAudit( + client: RegistryClient, + cache: Map, + owner: string, + repo: string, + name: string, +): Promise { + const key = `${owner}/${repo}/${name}` + const cached = cache.get(key) + if (cached) + return cached + const result = await client.audit({ owner, repo, name }) + cache.set(key, result) + return result +} + +function logAuditWarn(slug: string, result: AuditResult): void { + const parts = [ + result.riskLevel && `risk: ${result.riskLevel}`, + result.summary, + result.audits.filter(a => a.status === 'warn').map(a => a.category).join(','), + ].filter(Boolean).join(' · ') + p.log.warn(`${styleText('yellow', '⚠')} ${slug} ${styleText('gray', parts)}`) +} + +function logAuditFail(slug: string, result: AuditResult, owner: string, repo: string, name: string): void { + const detail = result.audits.filter(a => a.status === 'fail').map(a => a.summary || a.category).join('; ') + p.log.error(`${styleText('red', '✗')} ${slug} blocked: ${detail || 'audit failed'}\n Receipts: ${RECEIPTS_URL}/${owner}/${repo}/${name}`) +} + +export async function installSkills(items: SkillSource[], opts: InstallOpts): Promise { + const cwd = process.cwd() + const summary: InstallSummary = { installed: 0, skipped: 0, failed: 0 } + const client = createRegistryClient() + const auditCache = opts.auditCache ?? new Map() + + const gitSources: GitSkillSource[] = [] + const npmEntries: Array<{ name: string, spec: string }> = [] + const crateSpecs: string[] = [] + const unsupported: string[] = [] + + for (const source of items) { + switch (source.type) { + case 'git': + gitSources.push(source.source) + break + case 'npm': + npmEntries.push({ name: source.package, spec: source.tag ? `${source.package}@${source.tag}` : source.package }) + break + case 'crate': + crateSpecs.push(source.version ? `crate:${source.package}@${source.version}` : `crate:${source.package}`) + break + case 'bare': + p.log.warn(`Bare names are deprecated. Use ${styleText('cyan', `npm:${source.package}`)} instead.`) + npmEntries.push({ name: source.package, spec: source.tag ? `${source.package}@${source.tag}` : source.package }) + break + case 'curator': + unsupported.push(`@${source.handle} (curator)`) + break + case 'collection': + unsupported.push(`@${source.handle}/${source.name} (collection)`) + break + default: { + const _exhaustive: never = source + throw new Error(`Unhandled SkillSource type: ${JSON.stringify(_exhaustive)}`) + } + } + } + + if (unsupported.length > 0) { + p.log.error(`Curator and collection installs are not yet available:\n ${unsupported.join('\n ')}\n\nFollow https://skilld.dev for launch updates.`) + summary.skipped += unsupported.length + process.exitCode = 1 + if (gitSources.length === 0 && npmEntries.length === 0 && crateSpecs.length === 0) + return summary + } + + for (const source of gitSources) { + const skillFilter = opts.skillFilter + ? opts.skillFilter.split(COMMA_OR_WHITESPACE_RE).map(s => s.trim()).filter(Boolean) + : undefined + await syncGitSkills({ + source, + global: !!opts.global, + agent: opts.agent, + yes: !!opts.yes, + model: opts.model, + force: opts.force, + debug: opts.debug, + skillFilter, + }) + .then(() => { summary.installed += 1 }) + .catch((err) => { + summary.failed += 1 + p.log.error(`Failed to install ${source.type === 'local' ? source.localPath : `${source.owner}/${source.repo}`}: ${err instanceof Error ? err.message : String(err)}`) + }) + } + + if (npmEntries.length > 0) { + const seen = new Set() + const dedupedEntries = npmEntries.filter((e) => { + if (seen.has(e.name)) + return false + seen.add(e.name) + return true + }) + + const fallbackPackages: string[] = [] + for (const entry of dedupedEntries) { + const resolved = await client.resolveSkill(entry.name).catch(() => null) + if (!resolved) { + fallbackPackages.push(entry.spec) + continue + } + + const [auditOwner, auditRepo] = resolved.repo.split('/') + const audit = await getAudit(client, auditCache, auditOwner!, auditRepo!, resolved.name) + const decision = gateInstall(audit, { allowUnsafe: opts.allowUnsafe, yes: opts.yes, sourceKind: 'npm' }) + + const slug = `${resolved.repo}/${resolved.name}` + if (audit.status === 'warn') { + logAuditWarn(slug, audit) + track({ event: 'audit-warn', surface: opts.surface, sourceKind: 'npm', slug, agent: opts.agent }) + } + if (audit.status === 'fail') { + logAuditFail(slug, audit, auditOwner!, auditRepo!, resolved.name) + track({ event: 'audit-fail', surface: opts.surface, sourceKind: 'npm', slug, agent: opts.agent }) + } + if (decision === 'skip') { + track({ event: 'audit-blocked', surface: opts.surface, sourceKind: 'npm', slug, agent: opts.agent }) + summary.skipped += 1 + continue + } + + const result = await syncRegistrySkill({ packageName: entry.name, agent: opts.agent, cwd, prefetched: resolved, surface: opts.surface }) + .catch((err) => { + summary.failed += 1 + p.log.error(`Failed to install ${entry.name}: ${err instanceof Error ? err.message : String(err)}`) + return null + }) + if (result) { + p.log.success(`Installed ${styleText('cyan', result.name)} from registry`) + summary.installed += 1 + } + else if (result === null) { + fallbackPackages.push(entry.spec) + } + } + + if (fallbackPackages.length > 0) { + const state = await getProjectState(cwd) + p.intro(introLine({ state, agentId: opts.agent })) + await syncCommand(state, { + packages: [...fallbackPackages, ...crateSpecs], + global: !!opts.global, + agent: opts.agent, + model: opts.model, + yes: !!opts.yes, + force: opts.force, + debug: opts.debug, + }) + summary.installed += fallbackPackages.length + crateSpecs.length + return summary + } + } + + if (crateSpecs.length > 0) { + const state = await getProjectState(cwd) + p.intro(introLine({ state, agentId: opts.agent })) + await syncCommand(state, { + packages: crateSpecs, + global: !!opts.global, + agent: opts.agent, + model: opts.model, + yes: !!opts.yes, + force: opts.force, + debug: opts.debug, + }) + summary.installed += crateSpecs.length + } + + return summary +} diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts new file mode 100644 index 00000000..1329e633 --- /dev/null +++ b/src/commands/whoami.ts @@ -0,0 +1,20 @@ +/** + * `skilld whoami` — print the active login + storage scheme. + */ + +import { styleText } from 'node:util' +import * as p from '@clack/prompts' +import { defineCommand } from 'citty' +import { loadSession } from '../auth/store.ts' + +export const whoamiCommandDef = defineCommand({ + meta: { name: 'whoami', description: 'Show the active skilld.dev session' }, + async run() { + const session = await loadSession() + if (!session) { + p.log.info('Not logged in. Run `skilld login` to authenticate.') + return + } + p.log.message(`Logged in as ${styleText('cyan', `@${session.login}`)} ${styleText('gray', `(${session.scheme})`)}`) + }, +}) From 6e7f555ce8c9e6eabb470eac8366be9b4bc9e828 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 13 May 2026 16:21:27 +1000 Subject: [PATCH 3/6] chore: progress --- test/unit/audit-aggregation.test.ts | 135 +++++++++++++++++++++++++ test/unit/auth-store.test.ts | 148 ++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 test/unit/audit-aggregation.test.ts create mode 100644 test/unit/auth-store.test.ts diff --git a/test/unit/audit-aggregation.test.ts b/test/unit/audit-aggregation.test.ts new file mode 100644 index 00000000..98b17ede --- /dev/null +++ b/test/unit/audit-aggregation.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('ofetch', () => ({ + ofetch: vi.fn(), +})) + +describe('aggregateAuditStatus', () => { + it('returns unaudited for empty audit list', async () => { + const { aggregateAuditStatus } = await import('../../src/registry/client') + expect(aggregateAuditStatus([])).toBe('unaudited') + }) + + it('returns pass when all entries pass', async () => { + const { aggregateAuditStatus } = await import('../../src/registry/client') + expect(aggregateAuditStatus([ + { category: 'static', status: 'pass' }, + { category: 'deps', status: 'pass' }, + ])).toBe('pass') + }) + + it('returns warn when any warn but no fail', async () => { + const { aggregateAuditStatus } = await import('../../src/registry/client') + expect(aggregateAuditStatus([ + { category: 'static', status: 'pass' }, + { category: 'deps', status: 'warn' }, + ])).toBe('warn') + }) + + it('returns fail when any fail, regardless of warns', async () => { + const { aggregateAuditStatus } = await import('../../src/registry/client') + expect(aggregateAuditStatus([ + { category: 'static', status: 'fail' }, + { category: 'deps', status: 'warn' }, + { category: 'license', status: 'pass' }, + ])).toBe('fail') + }) +}) + +describe('createRegistryClient.audit', () => { + it('returns unaudited on network error', async () => { + const { ofetch } = await import('ofetch') + vi.mocked(ofetch).mockReset().mockRejectedValueOnce(new Error('network')) + const { createRegistryClient } = await import('../../src/registry/client') + const client = createRegistryClient() + const res = await client.audit({ owner: 'foo', repo: 'bar', name: 'baz' }) + expect(res).toEqual({ status: 'unaudited', audits: [] }) + }) + + it('aggregates server response with audits + riskLevel + summary', async () => { + const { ofetch } = await import('ofetch') + vi.mocked(ofetch).mockReset().mockResolvedValueOnce({ + riskLevel: 'medium', + summary: 'large asset tree', + audits: [ + { category: 'static', status: 'pass' }, + { category: 'deps', status: 'warn', summary: 'wildcard import' }, + ], + }) + const { createRegistryClient } = await import('../../src/registry/client') + const client = createRegistryClient() + const res = await client.audit({ owner: 'foo', repo: 'bar', name: 'baz' }) + expect(res.status).toBe('warn') + expect(res.riskLevel).toBe('medium') + expect(res.audits).toHaveLength(2) + }) + + it('treats missing audits array as unaudited', async () => { + const { ofetch } = await import('ofetch') + vi.mocked(ofetch).mockReset().mockResolvedValueOnce({}) + const { createRegistryClient } = await import('../../src/registry/client') + const client = createRegistryClient() + const res = await client.audit({ owner: 'foo', repo: 'bar', name: 'baz' }) + expect(res.status).toBe('unaudited') + }) +}) + +describe('gateInstall', () => { + it('pass → install', async () => { + const { gateInstall } = await import('../../src/registry/client') + expect(gateInstall({ status: 'pass', audits: [] }, { sourceKind: 'npm' })).toBe('install') + }) + + it('warn → install', async () => { + const { gateInstall } = await import('../../src/registry/client') + expect(gateInstall({ status: 'warn', audits: [] }, { sourceKind: 'npm' })).toBe('install') + }) + + it('fail → skip without --allow-unsafe', async () => { + const { gateInstall } = await import('../../src/registry/client') + expect(gateInstall({ status: 'fail', audits: [] }, { sourceKind: 'npm' })).toBe('skip') + }) + + it('fail + allowUnsafe → install', async () => { + const { gateInstall } = await import('../../src/registry/client') + expect(gateInstall({ status: 'fail', audits: [] }, { sourceKind: 'npm', allowUnsafe: true })).toBe('install') + }) + + it('unaudited npm → install silently', async () => { + const { gateInstall } = await import('../../src/registry/client') + expect(gateInstall({ status: 'unaudited', audits: [] }, { sourceKind: 'npm' })).toBe('install') + }) + + it('unaudited gh + no --yes → prompt', async () => { + const { gateInstall } = await import('../../src/registry/client') + expect(gateInstall({ status: 'unaudited', audits: [] }, { sourceKind: 'gh' })).toBe('prompt') + }) + + it('unaudited gh + --yes → install', async () => { + const { gateInstall } = await import('../../src/registry/client') + expect(gateInstall({ status: 'unaudited', audits: [] }, { sourceKind: 'gh', yes: true })).toBe('install') + }) +}) + +describe('createRegistryClient.my requires session', () => { + it('throws auth required without a session', async () => { + const { createRegistryClient } = await import('../../src/registry/client') + const client = createRegistryClient() + await expect(client.my.collections()).rejects.toThrow('auth required') + await expect(client.my.subscriptions()).rejects.toThrow('auth required') + await expect(client.my.changes({})).rejects.toThrow('auth required') + await expect(client.my.installs({ slug: 'x', sourceKind: 'npm', surface: 'cli:add' })).rejects.toThrow('auth required') + }) + + it('passes Bearer header when session present', async () => { + const { ofetch } = await import('ofetch') + vi.mocked(ofetch).mockReset().mockResolvedValueOnce([]) + const { createRegistryClient } = await import('../../src/registry/client') + const client = createRegistryClient({ session: { accessToken: 'tok', login: 'me' } }) + await client.my.collections() + expect(ofetch).toHaveBeenCalledWith( + expect.stringContaining('/me/collections'), + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer tok' }) }), + ) + }) +}) diff --git a/test/unit/auth-store.test.ts b/test/unit/auth-store.test.ts new file mode 100644 index 00000000..a48c8e4b --- /dev/null +++ b/test/unit/auth-store.test.ts @@ -0,0 +1,148 @@ +import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + chmodSync: vi.fn(), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + } +}) + +interface FakePasswordEntry { + service: string + account: string +} + +const passwords = new Map() + +function fakeKey(svc: string, acct: string): string { + return `${svc}:${acct}` +} + +class FakeEntry { + constructor(public service: string, public account: string) {} + getPassword(): string | null { + return passwords.get(fakeKey(this.service, this.account)) ?? null + } + + setPassword(value: string): void { + passwords.set(fakeKey(this.service, this.account), value) + } + + deletePassword(): boolean { + return passwords.delete(fakeKey(this.service, this.account)) + } +} + +void FakeEntry as unknown as { new(...args: any[]): FakePasswordEntry } + +describe('auth store', () => { + beforeEach(() => { + vi.resetModules() + vi.resetAllMocks() + passwords.clear() + delete process.env.SKILLD_TOKEN + delete process.env.SKILLD_LOGIN + }) + + it('sKILLD_TOKEN env var trumps the marker', async () => { + process.env.SKILLD_TOKEN = 'ci-token' + process.env.SKILLD_LOGIN = 'ci-user' + const { loadSession } = await import('../../src/auth/store') + const session = await loadSession() + expect(session).toMatchObject({ scheme: 'env', accessToken: 'ci-token', login: 'ci-user' }) + expect(existsSync).not.toHaveBeenCalled() + }) + + it('returns null when no marker exists and no env var', async () => { + vi.mocked(existsSync).mockReturnValue(false) + const { loadSession } = await import('../../src/auth/store') + expect(await loadSession()).toBeNull() + }) + + it('falls back to file scheme when keychain is missing', async () => { + vi.doMock('@napi-rs/keyring', () => { + throw new Error('not installed') + }) + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockImplementation(() => '') + + const { saveSession, loadSession } = await import('../../src/auth/store') + let written = '' + vi.mocked(writeFileSync).mockImplementation((_p, data) => { + written = String(data) + }) + + const scheme = await saveSession({ + login: 'harlan', + accessToken: 'a', + tokens: { accessToken: 'a', refreshToken: 'r' }, + expiresAt: 123, + }) + expect(scheme).toBe('file') + expect(written).toContain('"scheme": "file"') + expect(written).toContain('"refreshToken": "r"') + + vi.mocked(readFileSync).mockReturnValue(written) + const loaded = await loadSession() + expect(loaded).toMatchObject({ scheme: 'file', accessToken: 'a', refreshToken: 'r', login: 'harlan' }) + }) + + it('stores tokens in keychain when available, marker omits tokens', async () => { + vi.doMock('@napi-rs/keyring', () => ({ Entry: FakeEntry })) + vi.mocked(existsSync).mockReturnValue(true) + let written = '' + vi.mocked(writeFileSync).mockImplementation((_p, data) => { + written = String(data) + }) + + const { saveSession, loadSession } = await import('../../src/auth/store') + const scheme = await saveSession({ + login: 'harlan', + accessToken: 'a', + tokens: { accessToken: 'a', refreshToken: 'r' }, + expiresAt: 123, + }) + expect(scheme).toBe('keychain') + expect(written).toContain('"scheme": "keychain"') + expect(written).not.toContain('"tokens"') + expect(chmodSync).toHaveBeenCalled() + + vi.mocked(readFileSync).mockReturnValue(written) + const loaded = await loadSession() + expect(loaded).toMatchObject({ scheme: 'keychain', accessToken: 'a', refreshToken: 'r', login: 'harlan' }) + }) + + it('throws on a corrupt marker file', async () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue('not-json{') + const { loadSession } = await import('../../src/auth/store') + await expect(loadSession()).rejects.toThrow() + }) + + it('clearSession removes the marker and keychain entries', async () => { + vi.doMock('@napi-rs/keyring', () => ({ Entry: FakeEntry })) + passwords.set('skilld:access:harlan', 'a') + passwords.set('skilld:refresh:harlan', 'r') + + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + scheme: 'keychain', + login: 'harlan', + host: 'https://skilld.dev', + })) + + const { clearSession } = await import('../../src/auth/store') + await clearSession() + + expect(passwords.has('skilld:access:harlan')).toBe(false) + expect(passwords.has('skilld:refresh:harlan')).toBe(false) + expect(rmSync).toHaveBeenCalled() + }) +}) From 03d766b694d3610b5c254714899496a7c153978d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 13 May 2026 23:28:51 +1000 Subject: [PATCH 4/6] chore: progress --- package.json | 1 + packages/protocol/README.md | 21 ++ packages/protocol/build.config.ts | 15 + packages/protocol/eslint.config.mjs | 8 + packages/protocol/package.json | 60 +++ packages/protocol/src/constants.ts | 63 ++++ packages/protocol/src/test-fixtures.ts | 215 +++++++++++ packages/protocol/src/wire.ts | 15 + packages/protocol/src/wire/audit.ts | 37 ++ packages/protocol/src/wire/auth.ts | 55 +++ packages/protocol/src/wire/collections.ts | 76 ++++ packages/protocol/src/wire/device.ts | 36 ++ packages/protocol/src/wire/skills.ts | 44 +++ packages/protocol/src/wire/telemetry.ts | 25 ++ packages/protocol/test/constants.test.ts | 42 +++ packages/protocol/test/fixtures.test.ts | 58 +++ packages/protocol/tsconfig.json | 23 ++ packages/protocol/vitest.config.ts | 8 + pnpm-lock.yaml | 429 +++++++++++++++++++++- pnpm-workspace.yaml | 4 + src/auth/device-flow.ts | 17 +- src/auth/pkce-flow.ts | 4 + src/auth/store.ts | 3 +- src/auth/types.ts | 9 +- src/cli.ts | 2 +- src/cli/agent-prompt.ts | 36 ++ src/commands/login.ts | 7 +- src/commands/pull.ts | 88 +++-- src/commands/sync/add.ts | 20 +- src/commands/sync/install-many.ts | 16 +- src/commands/sync/update.ts | 29 +- src/core/prefix.ts | 2 +- src/registry/client.ts | 108 ++---- src/telemetry.ts | 41 +-- test/unit/audit-aggregation.test.ts | 18 +- test/unit/protocol-fixtures.test.ts | 58 +++ test/unit/retriv.test.ts | 18 + 37 files changed, 1491 insertions(+), 220 deletions(-) create mode 100644 packages/protocol/README.md create mode 100644 packages/protocol/build.config.ts create mode 100644 packages/protocol/eslint.config.mjs create mode 100644 packages/protocol/package.json create mode 100644 packages/protocol/src/constants.ts create mode 100644 packages/protocol/src/test-fixtures.ts create mode 100644 packages/protocol/src/wire.ts create mode 100644 packages/protocol/src/wire/audit.ts create mode 100644 packages/protocol/src/wire/auth.ts create mode 100644 packages/protocol/src/wire/collections.ts create mode 100644 packages/protocol/src/wire/device.ts create mode 100644 packages/protocol/src/wire/skills.ts create mode 100644 packages/protocol/src/wire/telemetry.ts create mode 100644 packages/protocol/test/constants.test.ts create mode 100644 packages/protocol/test/fixtures.test.ts create mode 100644 packages/protocol/tsconfig.json create mode 100644 packages/protocol/vitest.config.ts create mode 100644 test/unit/protocol-fixtures.test.ts create mode 100644 test/unit/retriv.test.ts diff --git a/package.json b/package.json index 7623a3d6..25995f16 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "pathe": "catalog:", "retriv": "catalog:", "semver": "catalog:", + "skilld-protocol": "workspace:*", "sqlite-vec": "catalog:deps", "std-env": "catalog:", "typebox": "catalog:", diff --git a/packages/protocol/README.md b/packages/protocol/README.md new file mode 100644 index 00000000..20552f25 --- /dev/null +++ b/packages/protocol/README.md @@ -0,0 +1,21 @@ +# skilld-protocol + +Wire shapes and constants shared between the [skilld CLI](https://github.com/skilld-dev/skilld) and [skilld.dev](https://skilld.dev). The single source of truth for everything that crosses that boundary: telemetry, audit, auth, device flow, collection manifests. + +## Install + +```sh +pnpm add skilld-protocol +``` + +ESM-only. Node ≥18. One peer-free dep: `zod` v4. + +## Subpaths + +- `skilld-protocol/wire` — every endpoint shape as a zod schema (suffix `Schema`) and the matching inferred TS type (no suffix). Use `import { FooSchema }` for runtime validation; `import type { Foo }` for the type. +- `skilld-protocol/constants` — readonly tuples backing the closed enums plus their inferred unions. +- `skilld-protocol/test-fixtures` — canonical payloads each consumer round-trips through their schema on CI. + +## Repo + +This package lives inside the [skilld CLI](https://github.com/skilld-dev/skilld) monorepo at `packages/protocol`. The CLI consumes it as a workspace dep; skilld.dev consumes the published npm version. diff --git a/packages/protocol/build.config.ts b/packages/protocol/build.config.ts new file mode 100644 index 00000000..03c4d9de --- /dev/null +++ b/packages/protocol/build.config.ts @@ -0,0 +1,15 @@ +import { defineBuildConfig } from 'obuild/config' + +export default defineBuildConfig({ + entries: [ + { + type: 'bundle', + input: [ + './src/wire.ts', + './src/constants.ts', + './src/test-fixtures.ts', + ], + outDir: './dist', + }, + ], +}) diff --git a/packages/protocol/eslint.config.mjs b/packages/protocol/eslint.config.mjs new file mode 100644 index 00000000..61eac19e --- /dev/null +++ b/packages/protocol/eslint.config.mjs @@ -0,0 +1,8 @@ +import antfu from '@antfu/eslint-config' + +export default antfu({ + type: 'lib', + typescript: true, + stylistic: true, + ignores: ['dist', 'node_modules'], +}) diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 00000000..65dced40 --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,60 @@ +{ + "name": "skilld-protocol", + "type": "module", + "version": "0.2.2", + "description": "Wire shapes and constants shared between the skilld CLI and skilld.dev", + "author": { + "name": "Harlan Wilton", + "email": "harlan@harlanzw.com", + "url": "https://harlanzw.com/" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/skilld-dev/skilld.git", + "directory": "packages/protocol" + }, + "keywords": ["skilld", "protocol", "zod", "schema"], + "sideEffects": false, + "exports": { + "./wire": { + "types": "./dist/wire.d.mts", + "import": "./dist/wire.mjs" + }, + "./constants": { + "types": "./dist/constants.d.mts", + "import": "./dist/constants.mjs" + }, + "./test-fixtures": { + "types": "./dist/test-fixtures.d.mts", + "import": "./dist/test-fixtures.mjs" + } + }, + "files": ["dist"], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "obuild", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run", + "publint": "publint", + "attw": "pnpm pack && attw skilld-protocol-*.tgz --ignore-rules no-resolution cjs-resolves-to-esm && rm skilld-protocol-*.tgz", + "prepack": "pnpm build" + }, + "dependencies": { + "zod": "catalog:" + }, + "devDependencies": { + "@antfu/eslint-config": "catalog:dev-lint", + "@arethetypeswrong/cli": "catalog:", + "@types/node": "catalog:dev-build", + "obuild": "catalog:dev-build", + "publint": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:dev-test" + } +} diff --git a/packages/protocol/src/constants.ts b/packages/protocol/src/constants.ts new file mode 100644 index 00000000..031a5a96 --- /dev/null +++ b/packages/protocol/src/constants.ts @@ -0,0 +1,63 @@ +/** + * Canonical readonly tuples for closed enums and their inferred TS unions. + * + * The wire schemas import these tuples for `z.enum(...)`. The TS unions are + * exported for tooling (autocomplete, exhaustive switches). Telemetry surface + * is intentionally open on the wire (`z.string().min(1).max(32)` in + * `wire/telemetry.ts`) so the CLI can ship new surfaces without a coordinated + * protocol bump; the closed tuple here is the *currently canonical* list. + */ + +export const TELEMETRY_EVENTS = [ + 'install', + 'install-failed', + 'update', + 'audit-warn', + 'audit-fail', + 'audit-blocked', + 'auth-flow', + 'pull-checklist', +] as const + +export const TELEMETRY_SURFACES = [ + 'cli:add', + 'cli:pull', + 'cli:prepare', + 'cli:update', + 'cli:wizard', + 'cli:auth', +] as const + +export const SOURCE_KINDS = [ + 'npm', + 'gh', + 'crate', + 'collection', + 'curator', +] as const + +export const AUDIT_STATUSES = [ + 'pass', + 'warn', + 'fail', + 'unaudited', +] as const + +export const AUDIT_ENTRY_STATUSES = [ + 'pass', + 'warn', + 'fail', +] as const + +export const AUTH_FLOWS = [ + 'pkce', + 'device', + 'oidc', +] as const + +export type TelemetryEvent = typeof TELEMETRY_EVENTS[number] +export type TelemetrySurface = typeof TELEMETRY_SURFACES[number] +export type SourceKind = typeof SOURCE_KINDS[number] +export type AuditStatus = typeof AUDIT_STATUSES[number] +export type AuditEntryStatus = typeof AUDIT_ENTRY_STATUSES[number] +export type AuthFlow = typeof AUTH_FLOWS[number] diff --git a/packages/protocol/src/test-fixtures.ts b/packages/protocol/src/test-fixtures.ts new file mode 100644 index 00000000..611bf514 --- /dev/null +++ b/packages/protocol/src/test-fixtures.ts @@ -0,0 +1,215 @@ +/** + * Canonical payloads each consumer round-trips through the wire schemas on CI. + * A breaking schema change invalidates a fixture → both consumer test suites + * go red on the bump. Drift detector at zero conditional-logic cost. + */ + +import type { + AuditEntry, + ChangeEntry, + CliEventInput, + CollectionManifest, + CollectionSummary, + DevicePollInput, + DevicePollResponse, + DeviceStartInput, + DeviceStartResponse, + DigestResponse, + OauthRefreshInput, + OauthTokenInput, + OidcExchangeInput, + SkillDetailResponse, + SkillLiveResponse, + SkillsResolveInput, + SkillsResolveResponse, + TokenResponse, +} from './wire.ts' + +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' }, + ], + source: 'skills.sh', + fetchedAt: '2026-05-13T00:00:00.000Z', + }, + 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'] }, + ], + source: 'skills.sh', + fetchedAt: '2026-05-13T00:00:00.000Z', + }, + entryFail: { + provider: 'skills.sh', + slug: 'static', + status: 'fail', + summary: 'detected eval()', + auditedAt: '2026-05-12T18:00:00.000Z', + }, + }, + auth: { + tokenResponse: { + accessToken: 'eyJhbGc...stub', + refreshToken: 'r-32-bytes-base64url', + expiresAt: 1_715_600_000, + login: 'harlanzw', + scopes: 'cli', + }, + tokenResponseOidc: { + accessToken: 'eyJhbGc...oidc', + expiresAt: 1_715_600_000, + login: 'harlanzw', + }, + oauthTokenInput: { + code: 'auth-code-1234567890abcdef', + code_verifier: 'verifier-32-bytes-or-more-padding-here', + redirect_uri: 'http://127.0.0.1:50123/', + }, + oauthRefreshInput: { + refresh_token: 'r-32-bytes-base64url', + }, + oidcExchangeInput: { + id_token: `${'a'.repeat(64)}.payload.signature`, + }, + }, + device: { + startInput: { + cli_version: '2.0.0', + machine_hint: 'darwin-arm64', + }, + pollInput: { + device_code: 'dc-128-bytes-base64url-string', + }, + startResponse: { + device_code: 'dc-128-bytes', + user_code: 'WDJB-MJHT', + verification_uri: 'https://skilld.dev/cli/authorize', + interval: 5, + expires_in: 600, + }, + pollPending: { status: 'pending' }, + pollAuthorized: { + status: 'authorized', + tokens: { + accessToken: 'eyJhbGc...stub', + refreshToken: 'r-32-bytes-base64url', + expiresAt: 1_715_600_000, + login: 'harlanzw', + }, + }, + }, + telemetry: { + installEvent: { + event: 'install', + surface: 'cli:add', + sourceKind: 'npm', + slug: 'vue', + cliVersion: '2.0.0', + agent: 'claude-code', + }, + auditFailEvent: { + event: 'audit-fail', + surface: 'cli:pull', + sourceKind: 'gh', + slug: 'antfu/skills', + cliVersion: '2.0.0', + }, + authFlowEvent: { + event: 'auth-flow', + surface: 'cli:auth', + cliVersion: '2.0.0', + flow: 'pkce', + }, + }, + collections: { + manifest: { + name: 'My Vue stack', + preamble: 'Tools I reach for on every Vue project.', + items: [ + { kind: 'npm', package: 'vue' }, + { kind: 'npm', package: 'motion-v' }, + { kind: 'gh', owner: 'antfu', repo: 'skills' }, + ], + }, + summary: { + slug: 'vue-stack', + name: 'My Vue stack', + itemCount: 3, + }, + change: { + repo: 'antfu/skills', + skill: 'vue', + at: '2026-05-13T00:00:00.000Z', + summary: 'Added Pinia composables.', + }, + digestResponse: { + user: { id: 2, login: 'harlan-zw' }, + windowStart: 1_715_500_000, + windowEnd: 1_715_600_000, + entries: [ + { repo: 'antfu/skills', skill: 'vue', at: '2026-05-13T00:00:00.000Z', summary: 'Added Pinia composables.' }, + ], + }, + }, + skills: { + resolveInput: { + items: [ + { packageName: 'vue' }, + { packageName: 'motion-v', owner: 'antfu' }, + ], + }, + resolveResponse: { + 'vue': { owner: 'antfu', repo: 'skills', official: true }, + 'motion-v': { owner: 'antfu', repo: 'skills', official: false }, + }, + detail: { + owner: 'antfu', + repo: 'skills', + name: 'vue', + displayName: 'Vue', + installs: 1234, + branch: 'main', + skillPath: 'vue/SKILL.md', + raw: '# Vue\n\nUse