diff --git a/assistant/src/daemon/handlers/skills.ts b/assistant/src/daemon/handlers/skills.ts index 7752b2e896f..f991e7344aa 100644 --- a/assistant/src/daemon/handlers/skills.ts +++ b/assistant/src/daemon/handlers/skills.ts @@ -423,7 +423,7 @@ export async function handleSkillsSearch( ctx: HandlerContext, ): Promise { try { - // Search vellum-skills catalog (remote with bundled fallback) + // Search vellum-skills catalog (platform API with bundled fallback) const catalogEntries = await listCatalogEntries(); const query = (msg.query ?? '').toLowerCase(); const matchingCatalog = catalogEntries.filter((e) => { diff --git a/assistant/src/skills/vellum-catalog-remote.ts b/assistant/src/skills/vellum-catalog-remote.ts index 9571339ac15..877ab298ff6 100644 --- a/assistant/src/skills/vellum-catalog-remote.ts +++ b/assistant/src/skills/vellum-catalog-remote.ts @@ -1,13 +1,14 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { gunzipSync } from 'node:zlib'; import type { CatalogEntry } from '../tools/skills/vellum-catalog.js'; import { getLogger } from '../util/logger.js'; +import { readPlatformToken } from '../util/platform.js'; const log = getLogger('vellum-catalog-remote'); -const GITHUB_RAW_BASE = - 'https://raw.githubusercontent.com/vellum-ai/vellum-assistant/main/assistant/src/config/vellum-skills'; +const PLATFORM_URL = process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? 'https://assistant.vellum.ai'; const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour @@ -43,7 +44,20 @@ function getBundledSkillContent(skillId: string): string | null { } } -/** Fetch catalog entries (cached, async). Falls back to bundled copy. */ +/** Build request headers, including platform token when available. */ +function buildPlatformHeaders(): Record { + const headers: Record = {}; + const token = readPlatformToken(); + if (token) { + headers['X-Session-Token'] = token; + } + return headers; +} + +/** + * Fetch catalog entries from the platform API. Falls back to bundled copy. + * Reads the platform token from ~/.vellum/platform-token automatically. + */ export async function fetchCatalogEntries(): Promise { const now = Date.now(); if (cachedEntries && now - cacheTimestamp < CACHE_TTL_MS) { @@ -51,8 +65,9 @@ export async function fetchCatalogEntries(): Promise { } try { - const url = `${GITHUB_RAW_BASE}/catalog.json`; + const url = `${PLATFORM_URL}/v1/skills/`; const response = await fetch(url, { + headers: buildPlatformHeaders(), signal: AbortSignal.timeout(5000), }); @@ -63,14 +78,14 @@ export async function fetchCatalogEntries(): Promise { const manifest: CatalogManifest = await response.json(); const skills = manifest.skills; if (!Array.isArray(skills) || skills.length === 0) { - throw new Error('Remote catalog has invalid or empty skills array'); + throw new Error('Platform catalog has invalid or empty skills array'); } cachedEntries = skills; cacheTimestamp = now; - log.info({ count: cachedEntries.length }, 'Fetched remote vellum-skills catalog'); + log.info({ count: cachedEntries.length }, 'Fetched vellum-skills catalog from platform API'); return cachedEntries; } catch (err) { - log.warn({ err }, 'Failed to fetch remote catalog, falling back to bundled copy'); + log.warn({ err }, 'Failed to fetch catalog from platform API, falling back to bundled copy'); const bundled = loadBundledCatalog(); // Cache the bundled result too so we don't re-fetch on every call during outage cachedEntries = bundled; @@ -79,28 +94,72 @@ export async function fetchCatalogEntries(): Promise { } } -/** Fetch a skill's SKILL.md content from GitHub. Falls back to bundled copy. */ +/** + * Extract SKILL.md content from a tar archive (uncompressed). + * Tar format: 512-byte header blocks followed by file data blocks. + */ +function extractSkillMdFromTar(tarBuffer: Buffer): string | null { + let offset = 0; + while (offset + 512 <= tarBuffer.length) { + const header = tarBuffer.subarray(offset, offset + 512); + + // Check for end-of-archive (two consecutive zero blocks) + if (header.every((b) => b === 0)) break; + + // Extract filename (bytes 0-99, null-terminated) + const nameEnd = header.indexOf(0, 0); + const name = header.subarray(0, Math.min(nameEnd >= 0 ? nameEnd : 100, 100)).toString('utf-8'); + + // Extract file size (bytes 124-135, octal string) + const sizeStr = header.subarray(124, 136).toString('utf-8').trim(); + const size = parseInt(sizeStr, 8) || 0; + + offset += 512; // move past header + + if (name.endsWith('SKILL.md') || name === 'SKILL.md') { + return tarBuffer.subarray(offset, offset + size).toString('utf-8'); + } + + // Skip to next header (data blocks are padded to 512 bytes) + offset += Math.ceil(size / 512) * 512; + } + return null; +} + +/** + * Fetch a skill's SKILL.md content from the platform tar API. + * GET /v1/skills/{skill_id}/ returns a tar.gz archive containing all skill files. + * Falls back to bundled copy on failure. + */ export async function fetchSkillContent(skillId: string): Promise { try { - const url = `${GITHUB_RAW_BASE}/${encodeURIComponent(skillId)}/SKILL.md`; + const url = `${PLATFORM_URL}/v1/skills/${encodeURIComponent(skillId)}/`; const response = await fetch(url, { - signal: AbortSignal.timeout(10000), + headers: buildPlatformHeaders(), + signal: AbortSignal.timeout(15000), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const content = await response.text(); - log.info({ skillId }, 'Fetched remote SKILL.md'); - return content; + const gzipBuffer = Buffer.from(await response.arrayBuffer()); + const tarBuffer = gunzipSync(gzipBuffer); + const skillMd = extractSkillMdFromTar(tarBuffer); + + if (skillMd) { + return skillMd; + } + + log.warn({ skillId }, 'SKILL.md not found in platform tar archive, falling back to bundled'); } catch (err) { - log.warn({ err, skillId }, 'Failed to fetch remote SKILL.md, falling back to bundled copy'); - return getBundledSkillContent(skillId); + log.warn({ err, skillId }, 'Failed to fetch skill content from platform API, falling back to bundled'); } + + return getBundledSkillContent(skillId); } -/** Check if a skill ID exists in the remote catalog. */ +/** Check if a skill ID exists in the catalog. */ export async function checkVellumSkill(skillId: string): Promise { const entries = await fetchCatalogEntries(); return entries.some((e) => e.id === skillId); diff --git a/assistant/src/util/platform.ts b/assistant/src/util/platform.ts index 30066d72bfa..68f32b6e30c 100644 --- a/assistant/src/util/platform.ts +++ b/assistant/src/util/platform.ts @@ -207,6 +207,27 @@ export function getHttpTokenPath(): string { return join(getRootDir(), 'http-token'); } +/** + * Returns the path to the platform API token file (~/.vellum/platform-token). + * This token is the X-Session-Token used to authenticate with the Vellum + * Platform API (e.g. assistant.vellum.ai). + */ +export function getPlatformTokenPath(): string { + return join(getRootDir(), 'platform-token'); +} + +/** + * Read the platform API token from disk. Returns null if the file + * doesn't exist or can't be read. + */ +export function readPlatformToken(): string | null { + try { + return readFileSync(getPlatformTokenPath(), 'utf-8').trim(); + } catch { + return null; + } +} + /** * Read the daemon session token from disk. Returns null if the file * doesn't exist or can't be read (daemon not running). diff --git a/clients/macos/vellum-assistant/Features/MainWindow/Panels/AgentPanel.swift b/clients/macos/vellum-assistant/Features/MainWindow/Panels/AgentPanel.swift index d5a86f3c7a1..a62e02ab8e8 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/Panels/AgentPanel.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/Panels/AgentPanel.swift @@ -141,19 +141,23 @@ struct AgentPanelContent: View { // MARK: - Available Skills Tab /// ClaWHub skills filtered to exclude already-installed ones, with local search and sort. + /// When filtering to Vellum-only, installed Vellum skills are kept so the catalog is always visible. private var availableClawhubSkills: [ClawhubSkillItem] { let installedNames = Set(skillsManager.skills.map(\.name)) - var filtered = skillsManager.searchResults - .filter { !installedNames.contains($0.name) } - // Source filter + // Source filter — applied before installed-name exclusion so we can + // relax the exclusion for Vellum-only view. + var filtered: [ClawhubSkillItem] switch skillSourceFilter { case .all: - break + filtered = skillsManager.searchResults + .filter { !installedNames.contains($0.name) } case .vellum: - filtered = filtered.filter { $0.isVellum } + // Show all Vellum catalog skills even if already installed + filtered = skillsManager.searchResults.filter { $0.isVellum } case .community: - filtered = filtered.filter { !$0.isVellum } + filtered = skillsManager.searchResults + .filter { !$0.isVellum && !installedNames.contains($0.name) } } // Local fuzzy filter by name/description @@ -269,7 +273,7 @@ struct AgentPanelContent: View { title: hasActiveSearch ? "No matches in Available" : "No results", subtitle: hasActiveSearch ? "No available skills matched \"\(globalSkillSearchQuery)\"" - : "No \(skillSourceFilter.rawValue.lowercased()) skills found", + : "No \(skillSourceFilter.rawValue) skills found", icon: "magnifyingglass" ) @@ -287,24 +291,26 @@ struct AgentPanelContent: View { .frame(minHeight: 100) } - // Community disclaimer - VStack(spacing: VSpacing.sm) { - HStack(spacing: VSpacing.sm) { - Image(systemName: "exclamationmark.shield.fill") - .font(.system(size: 10)) - .foregroundColor(Amber._500) - Text("Community skills are not verified by Vellum. Review before installing.") - .font(VFont.caption) - .foregroundColor(VColor.textMuted) - } + // Community disclaimer — hidden when filtering to Vellum-only + if skillSourceFilter != .vellum { + VStack(spacing: VSpacing.sm) { + HStack(spacing: VSpacing.sm) { + Image(systemName: "exclamationmark.shield.fill") + .font(.system(size: 10)) + .foregroundColor(Amber._500) + Text("Community skills are not verified by Vellum. Review before installing.") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + } - HStack(spacing: VSpacing.sm) { - Image(systemName: "sparkles") - .font(.system(size: 10)) - .foregroundColor(VColor.accent) - Text("Browse more on ClawhHub") - .font(VFont.caption) - .foregroundColor(VColor.textMuted) + HStack(spacing: VSpacing.sm) { + Image(systemName: "sparkles") + .font(.system(size: 10)) + .foregroundColor(VColor.accent) + Text("Browse more on ClawhHub") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + } } } } @@ -352,6 +358,8 @@ struct AgentPanelContent: View { } private func clawhubSkillCard(_ skill: ClawhubSkillItem) -> some View { + let installedNames = Set(skillsManager.skills.map(\.name)) + let isAlreadyInstalled = installedNames.contains(skill.name) let isInstalling = installingSlug == skill.slug let isNew = !skill.isVellum && skill.createdAt > 0 && Date().timeIntervalSince( Date(timeIntervalSince1970: Double(skill.createdAt) / 1000) @@ -392,24 +400,30 @@ struct AgentPanelContent: View { Spacer() - VButton( - label: isInstalling ? "Installing..." : "Install", - icon: isInstalling ? nil : "arrow.down.circle.fill", - style: .primary, - isDisabled: installingSlug != nil - ) { - guard installingSlug == nil else { return } - let attemptId = UUID() - installingSlug = skill.slug - installAttemptId = attemptId - skillsManager.installSkill(slug: skill.slug) - installTimeoutTask?.cancel() - installTimeoutTask = Task { - try? await Task.sleep(nanoseconds: 10_000_000_000) - guard !Task.isCancelled else { return } - if installingSlug == skill.slug && installAttemptId == attemptId { - installingSlug = nil - installAttemptId = nil + if isAlreadyInstalled { + Text("Installed") + .font(VFont.caption) + .foregroundColor(VColor.success) + } else { + VButton( + label: isInstalling ? "Installing..." : "Install", + icon: isInstalling ? nil : "arrow.down.circle.fill", + style: .primary, + isDisabled: installingSlug != nil + ) { + guard installingSlug == nil else { return } + let attemptId = UUID() + installingSlug = skill.slug + installAttemptId = attemptId + skillsManager.installSkill(slug: skill.slug) + installTimeoutTask?.cancel() + installTimeoutTask = Task { + try? await Task.sleep(nanoseconds: 10_000_000_000) + guard !Task.isCancelled else { return } + if installingSlug == skill.slug && installAttemptId == attemptId { + installingSlug = nil + installAttemptId = nil + } } } } @@ -823,11 +837,25 @@ struct AgentPanelContent: View { } .frame(minHeight: 100) } else { - Text("No skills installed") - .font(VFont.caption) - .foregroundColor(VColor.textMuted) + VStack(spacing: VSpacing.md) { + Text("No skills installed") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: { skillsManager.fetchSkills() }) { + HStack(spacing: VSpacing.xs) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11)) + Text("Refresh") + .font(VFont.caption) + } + .foregroundColor(VColor.accent) + } + .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, VSpacing.sm) + } + .padding(.vertical, VSpacing.sm) } } else { VStack(spacing: VSpacing.md) { diff --git a/clients/shared/App/Auth/SessionTokenManager.swift b/clients/shared/App/Auth/SessionTokenManager.swift index 7f1b939c8a5..129fac43ba9 100644 --- a/clients/shared/App/Auth/SessionTokenManager.swift +++ b/clients/shared/App/Auth/SessionTokenManager.swift @@ -8,23 +8,53 @@ public extension Notification.Name { /// Replaces the macOS-only `/usr/bin/security` CLI approach. /// Uses provider "session-token" to match the old keychain account name /// so existing macOS users' stored sessions are preserved after upgrade. +/// +/// Also writes the token to `~/.vellum/platform-token` so the daemon can +/// read it for authenticated platform API calls without IPC round-trips. public enum SessionTokenManager { private static let provider = "session-token" + /// Path to the platform token file the daemon reads. + private static var platformTokenPath: String { + resolveVellumDir() + "/platform-token" + } + public static func getToken() -> String? { APIKeyManager.shared.getAPIKey(provider: provider) } public static func setToken(_ token: String) { _ = APIKeyManager.shared.setAPIKey(token, provider: provider) + writePlatformTokenFile(token) NotificationCenter.default.post(name: .sessionTokenDidChange, object: nil) } public static func deleteToken() { _ = APIKeyManager.shared.deleteAPIKey(provider: provider) + removePlatformTokenFile() NotificationCenter.default.post(name: .sessionTokenDidChange, object: nil) } + // MARK: - Platform token file bridge + + private static func writePlatformTokenFile(_ token: String) { + let path = platformTokenPath + do { + try token.write(toFile: path, atomically: true, encoding: .utf8) + // Restrict permissions to owner-only (0600) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path + ) + } catch { + // Best-effort; daemon falls back to bundled catalog if token is unavailable + } + } + + private static func removePlatformTokenFile() { + try? FileManager.default.removeItem(atPath: platformTokenPath) + } + public static func getTokenAsync() async -> String? { await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async {