diff --git a/src/cmd/learn.js b/src/cmd/learn.js index 1f65b76..f08b5e2 100644 --- a/src/cmd/learn.js +++ b/src/cmd/learn.js @@ -2,32 +2,239 @@ // Copyright (c) 2026 sol pbc import { spawnSync } from 'node:child_process'; -import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir, tmpdir } from 'node:os'; import { requireDid } from '../lib/config.js'; import { SKILL_COLLECTION } from '../lib/constants.js'; import { restoreAgent } from '../lib/oauth.js'; -import { readFollowing, readLog, appendLog } from '../lib/vit-dir.js'; +import { readFollowing, readLog, appendLog, vitDir } from '../lib/vit-dir.js'; import { requireAgent, detectCodingAgent } from '../lib/agent.js'; import { shouldBypassVet } from '../lib/trust-gate.js'; -import { isSkillRef, nameFromSkillRef, isValidSkillRef } from '../lib/skill-ref.js'; +import { isSkillRef, nameFromSkillRef, isValidSkillRef, isValidSkillName } from '../lib/skill-ref.js'; import { mark, name } from '../lib/brand.js'; -import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; +import { resolvePds, resolveHandle, listRecordsFromPds, batchQuery } from '../lib/pds.js'; import { loadConfig } from '../lib/config.js'; import { jsonOk, jsonError } from '../lib/json-output.js'; +async function installSkill({ match, skillName, isGlobal, opts, ref }) { + const { verbose } = opts; + const vlog = opts.json ? (...a) => console.error(...a) : console.log; + const record = match.value; + + const tempDir = mkdtempSync(join(tmpdir(), 'vit-learn-')); + try { + writeFileSync(join(tempDir, 'SKILL.md'), record.text); + if (verbose) vlog('[verbose] wrote SKILL.md to temp dir'); + + if (record.resources && record.resources.length > 0) { + const authorDid = match.uri.split('/')[2]; + const pds = await resolvePds(authorDid); + + for (const resource of record.resources) { + const resourcePath = join(tempDir, resource.path); + mkdirSync(dirname(resourcePath), { recursive: true }); + + try { + const blobCid = resource.blob?.ref?.$link || resource.blob?.cid; + if (blobCid) { + const blobUrl = new URL('/xrpc/com.atproto.sync.getBlob', pds); + blobUrl.searchParams.set('did', authorDid); + blobUrl.searchParams.set('cid', blobCid); + const blobRes = await fetch(blobUrl); + if (!blobRes.ok) throw new Error(`blob fetch failed: ${blobRes.status}`); + const blobData = Buffer.from(await blobRes.arrayBuffer()); + writeFileSync(resourcePath, blobData); + if (verbose) vlog(`[verbose] wrote resource: ${resource.path}`); + } + } catch (err) { + console.error(`warning: failed to download resource ${resource.path}: ${err.message}`); + } + } + } + + const addArgs = ['skills', 'add', tempDir, '-a', 'claude-code', '-y']; + if (isGlobal) addArgs.push('-g'); + const addResult = spawnSync('npx', addArgs, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (addResult.status !== 0) { + const errText = (addResult.stderr || addResult.stdout || '').trim(); + throw new Error(`skill install failed: ${errText || 'unknown error'}`); + } + if (verbose) vlog('[verbose] installed via npx skills add'); + } finally { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + } + + const installDir = isGlobal + ? join(homedir(), '.claude', 'skills', skillName) + : join(process.cwd(), '.claude', 'skills', skillName); + + if (existsSync(vitDir())) { + try { + appendLog('learned.jsonl', { + ref, + name: skillName, + uri: match.uri, + cid: match.cid, + installedTo: installDir, + scope: isGlobal ? 'user' : 'project', + learnedAt: new Date().toISOString(), + version: record.version || null, + }); + } catch (logErr) { + console.error('warning: failed to write learned.jsonl:', logErr.message); + } + } + + const scope = isGlobal ? 'user' : 'project'; + if (opts.json) { + jsonOk({ ref, name: skillName, installedTo: installDir, scope, version: record.version || null }); + return; + } + console.log(`${mark} learned: ${ref} (${scope})`); + console.log(`installed to: ${installDir}`); + if (record.version) console.log(`version: ${record.version}`); +} + +async function learnFromHandle(ref, opts) { + const { verbose } = opts; + const vlog = opts.json ? (...a) => console.error(...a) : console.log; + + let raw = ref.slice(1); + let projectLocal = false; + if (raw.endsWith('.')) { + projectLocal = true; + raw = raw.slice(0, -1); + } + + const slashIdx = raw.indexOf('/'); + if (slashIdx === -1 || slashIdx === 0 || slashIdx === raw.length - 1) { + if (opts.json) { + jsonError('invalid ref', 'expected format: @handle/skill-name'); + return; + } + console.error('invalid ref. expected format: @handle/skill-name'); + process.exitCode = 1; + return; + } + + const handle = raw.slice(0, slashIdx); + const skillName = raw.slice(slashIdx + 1); + + if (!handle.includes('.')) { + if (opts.json) { + jsonError('invalid handle', 'handle must be a domain name (e.g. alice.bsky.social)'); + return; + } + console.error('invalid handle. must be a domain name (e.g. alice.bsky.social)'); + process.exitCode = 1; + return; + } + + if (!isValidSkillName(skillName)) { + if (opts.json) { + jsonError('invalid skill name', 'lowercase letters, numbers, hyphens only'); + return; + } + console.error('invalid skill name. lowercase letters, numbers, hyphens only.'); + console.error('no leading hyphen, no consecutive hyphens, max 64 chars.'); + process.exitCode = 1; + return; + } + + if (verbose) vlog(`[verbose] handle: ${handle}, skill: ${skillName}`); + + const did = await resolveHandle(handle); + if (verbose) vlog(`[verbose] resolved DID: ${did}`); + + const pds = await resolvePds(did); + if (verbose) vlog(`[verbose] resolved PDS: ${pds}`); + + const { records } = await listRecordsFromPds(pds, did, SKILL_COLLECTION, 50); + + let match = null; + for (const rec of records) { + if (rec.value.name === skillName) { + if (!match || (rec.value.createdAt || '') > (match.value.createdAt || '')) { + match = rec; + } + } + } + + if (!match) { + const msg = `no skill '${skillName}' found from @${handle}`; + if (opts.json) { + jsonError(msg); + return; + } + console.error(msg); + process.exitCode = 1; + return; + } + + if (verbose) vlog(`[verbose] found skill: ${match.value.name} from ${match.uri}`); + + if (opts.dryRun) { + const record = match.value; + if (opts.json) { + jsonOk({ + name: record.name, + author: handle, + did, + description: record.description || null, + version: record.version || null, + tags: record.tags || [], + resources: (record.resources || []).map(r => r.path), + text: record.text, + }); + return; + } + console.log(`name: ${record.name}`); + console.log(`author: @${handle} (${did})`); + if (record.description) console.log(`description: ${record.description}`); + if (record.version) console.log(`version: ${record.version}`); + if (record.tags?.length) console.log(`tags: ${record.tags.join(', ')}`); + if (record.resources?.length) console.log(`resources: ${record.resources.map(r => r.path).join(', ')}`); + console.log(''); + console.log('--- SKILL.md ---'); + console.log(record.text); + return; + } + + const isGlobal = !(projectLocal || opts.project); + await installSkill({ match, skillName, isGlobal, opts, ref }); +} + export default function register(program) { program .command('learn') - .argument('', 'Skill reference (e.g. skill-agent-test-patterns)') - .description('Install a skill from the network into your skill directory') - .option('--did ', 'DID to use') - .option('--user', 'Install to user-wide ~/.claude/skills/ (requires vet)') + .argument('', 'Skill reference: @handle/name or skill-{name}') + .description('Install a skill from the network') + .option('--did ', 'DID to use (skill-{name} path only)') + .option('--user', 'Install to user-wide ~/.claude/skills/ (skill-{name} path, requires vet)') + .option('--project', 'Install to project .claude/skills/ (@handle/ path)') + .option('--dry-run', 'Show skill contents without installing') .option('--json', 'Output as JSON') .option('-v, --verbose', 'Show step-by-step details') + .addHelpText('after', ` +Examples: + vit learn @solpbc.org/using-vit Install from publisher (user-wide) + vit learn @solpbc.org/using-vit. Install from publisher (project-local) + vit learn @solpbc.org/using-vit --project Same as trailing dot + vit learn @solpbc.org/using-vit --dry-run Inspect without installing + vit learn skill-agent-test-patterns Install from followed accounts (project-local) + vit learn skill-agent-test-patterns --user Install from followed (user-wide, requires vet) +`) .action(async (ref, opts) => { try { + if (ref.startsWith('@')) { + await learnFromHandle(ref, opts); + return; + } + const gate = requireAgent(); if (!gate.ok) { if (opts.json) { @@ -170,85 +377,32 @@ export default function register(program) { const record = match.value; if (verbose) vlog(`[verbose] found skill: ${record.name} from ${match.uri}`); - // Install via skills CLI - const tempDir = mkdtempSync(join(tmpdir(), 'vit-learn-')); - try { - writeFileSync(join(tempDir, 'SKILL.md'), record.text); - if (verbose) vlog('[verbose] wrote SKILL.md to temp dir'); - - // Download resource blobs to temp dir - if (record.resources && record.resources.length > 0) { - const authorDid = match.uri.split('/')[2]; - const pds = await resolvePds(authorDid); - - for (const resource of record.resources) { - const resourcePath = join(tempDir, resource.path); - mkdirSync(dirname(resourcePath), { recursive: true }); - - try { - // Download blob from PDS - const blobCid = resource.blob?.ref?.$link || resource.blob?.cid; - if (blobCid) { - const blobUrl = new URL('/xrpc/com.atproto.sync.getBlob', pds); - blobUrl.searchParams.set('did', authorDid); - blobUrl.searchParams.set('cid', blobCid); - const blobRes = await fetch(blobUrl); - if (!blobRes.ok) throw new Error(`blob fetch failed: ${blobRes.status}`); - const blobData = Buffer.from(await blobRes.arrayBuffer()); - writeFileSync(resourcePath, blobData); - if (verbose) vlog(`[verbose] wrote resource: ${resource.path}`); - } - } catch (err) { - console.error(`warning: failed to download resource ${resource.path}: ${err.message}`); - } - } - } - - // Delegate to skills CLI - const addArgs = ['skills', 'add', tempDir, '-a', 'claude-code', '-y']; - if (isUserInstall) addArgs.push('-g'); - const addResult = spawnSync('npx', addArgs, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - if (addResult.status !== 0) { - const errText = (addResult.stderr || addResult.stdout || '').trim(); - throw new Error(`skill install failed: ${errText || 'unknown error'}`); + if (opts.dryRun) { + if (opts.json) { + jsonOk({ + name: record.name, + author: match.uri.split('/')[2], + description: record.description || null, + version: record.version || null, + tags: record.tags || [], + resources: (record.resources || []).map(r => r.path), + text: record.text, + }); + return; } - if (verbose) vlog('[verbose] installed via npx skills add'); - } finally { - try { rmSync(tempDir, { recursive: true, force: true }); } catch {} - } - - // Determine install path for logging - const installDir = isUserInstall - ? join(homedir(), '.claude', 'skills', skillName) - : join(process.cwd(), '.claude', 'skills', skillName); - - // Log to learned.jsonl - try { - appendLog('learned.jsonl', { - ref, - name: skillName, - uri: match.uri, - cid: match.cid, - installedTo: installDir, - scope: isUserInstall ? 'user' : 'project', - learnedAt: new Date().toISOString(), - version: record.version || null, - }); - } catch (logErr) { - console.error('warning: failed to write learned.jsonl:', logErr.message); - } - - const scope = isUserInstall ? 'user' : 'project'; - if (opts.json) { - jsonOk({ ref, name: skillName, installedTo: installDir, scope, version: record.version || null }); + console.log(`name: ${record.name}`); + console.log(`author: ${match.uri.split('/')[2]}`); + if (record.description) console.log(`description: ${record.description}`); + if (record.version) console.log(`version: ${record.version}`); + if (record.tags?.length) console.log(`tags: ${record.tags.join(', ')}`); + if (record.resources?.length) console.log(`resources: ${record.resources.map(r => r.path).join(', ')}`); + console.log(''); + console.log('--- SKILL.md ---'); + console.log(record.text); return; } - console.log(`${mark} learned: ${ref} (${scope})`); - console.log(`installed to: ${installDir}`); - if (record.version) console.log(`version: ${record.version}`); + + await installSkill({ match, skillName, isGlobal: !!opts.user, opts, ref }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (opts.json) { diff --git a/src/lib/pds.js b/src/lib/pds.js index e22cad2..1fe8749 100644 --- a/src/lib/pds.js +++ b/src/lib/pds.js @@ -61,6 +61,14 @@ export async function resolveHandleFromDid(did) { } } +export async function resolveHandle(handle) { + const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`could not resolve handle: ${handle}`); + const data = await res.json(); + return data.did; +} + export async function batchQuery(items, fn, { batchSize = 10, verbose = false } = {}) { if (verbose) console.log(`[verbose] querying ${items.length} accounts in batches of ${batchSize}`); const results = []; diff --git a/test/learn.test.js b/test/learn.test.js index 8e5f0c6..afc34a4 100644 --- a/test/learn.test.js +++ b/test/learn.test.js @@ -112,4 +112,66 @@ describe('vit learn', () => { rmSync(tmp, { recursive: true, force: true }); }); }); + + describe('vit learn @handle/', () => { + test('parses @handle/name format', () => { + const r = run('learn @test.example/my-skill', '/tmp'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).not.toContain('invalid skill ref'); + }); + + test('rejects @handle/ with no skill name', () => { + const r = run('learn @test.example/', '/tmp'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toContain('invalid ref'); + }); + + test('rejects @/name with no handle', () => { + const r = run('learn @/my-skill', '/tmp'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toContain('invalid ref'); + }); + + test('rejects handle without dot', () => { + const r = run('learn @localhost/my-skill', '/tmp'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toContain('invalid handle'); + }); + + test('rejects invalid skill name', () => { + const r = run('learn @test.example/Bad-Name', '/tmp'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).toContain('invalid skill name'); + }); + + test('trailing dot sets project-local', () => { + const r = run('learn @test.example/my-skill.', '/tmp'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).not.toContain('invalid skill name'); + }); + + test('@handle/ path does NOT require agent env', () => { + const r = run('learn @test.example/my-skill', '/tmp', { CLAUDECODE: '', GEMINI_CLI: '', CODEX_CI: '' }); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).not.toContain('should be run by a coding agent'); + }); + + test('@handle/ path does NOT require .vit/ dir', () => { + const r = run('learn @test.example/my-skill', '/tmp'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).not.toContain('not yet vetted'); + }); + + test('--project flag accepted', () => { + const r = run('learn @test.example/my-skill --project', '/tmp'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).not.toContain('unknown option'); + }); + + test('--dry-run flag accepted', () => { + const r = run('learn @test.example/my-skill --dry-run', '/tmp'); + expect(r.exitCode).not.toBe(0); + expect(r.stderr).not.toContain('unknown option'); + }); + }); }); diff --git a/test/pds.test.js b/test/pds.test.js index 8c00a56..a867014 100644 --- a/test/pds.test.js +++ b/test/pds.test.js @@ -2,7 +2,7 @@ // Copyright (c) 2026 sol pbc import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { resolvePds, resolveHandleFromDid, listRecordsFromPds, batchQuery } from '../src/lib/pds.js'; +import { resolvePds, resolveHandleFromDid, resolveHandle, listRecordsFromPds, batchQuery } from '../src/lib/pds.js'; function jsonResponse(data, { ok = true, status = 200, statusText = 'OK' } = {}) { return { @@ -128,6 +128,25 @@ describe('pds', () => { }); }); + describe('resolveHandle', () => { + test('resolves handle to DID', async () => { + let fetchedUrl; + global.fetch = async (input) => { + fetchedUrl = String(input); + return jsonResponse({ did: 'did:plc:test123' }); + }; + + await expect(resolveHandle('test.example')).resolves.toBe('did:plc:test123'); + expect(fetchedUrl).toBe('https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=test.example'); + }); + + test('throws on non-ok response', async () => { + global.fetch = async () => jsonResponse({}, { ok: false, status: 404, statusText: 'Not Found' }); + + await expect(resolveHandle('nonexistent.test')).rejects.toThrow('could not resolve handle: nonexistent.test'); + }); + }); + describe('listRecordsFromPds', () => { test('returns a single page of records', async () => { const seenUrls = [];