Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assistant/src/daemon/handlers/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export async function handleSkillsSearch(
ctx: HandlerContext,
): Promise<void> {
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) => {
Expand Down
91 changes: 75 additions & 16 deletions assistant/src/skills/vellum-catalog-remote.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -43,16 +44,30 @@ 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<string, string> {
const headers: Record<string, string> = {};
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<CatalogEntry[]> {
const now = Date.now();
if (cachedEntries && now - cacheTimestamp < CACHE_TTL_MS) {
return cachedEntries;
Comment on lines 63 to 64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Key catalog cache by session token

In fetchCatalogEntries, the cache is returned before considering sessionToken, so a prior unauthenticated/bundled result is reused for authenticated requests (and vice versa) for up to CACHE_TTL_MS. In practice, if the first search happens before login and falls back to bundled data, users who log in afterward will continue to see the stale limited catalog until TTL expiry or daemon restart, which breaks the new authenticated catalog flow.

Useful? React with 👍 / 👎.

}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

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),
});

Expand All @@ -63,14 +78,14 @@ export async function fetchCatalogEntries(): Promise<CatalogEntry[]> {
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;
Expand All @@ -79,28 +94,72 @@ export async function fetchCatalogEntries(): Promise<CatalogEntry[]> {
}
}

/** 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<string | null> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this method is just a pass through to getBundledSkillContent, then we don't need the latter

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<boolean> {
const entries = await fetchCatalogEntries();
return entries.some((e) => e.id === skillId);
Expand Down
21 changes: 21 additions & 0 deletions assistant/src/util/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Comment on lines 155 to +157
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block reinstall path for already-installed Vellum skills

This change now surfaces already-installed Vellum skills in Available, but those entries still open the detail screen where detailInstallButton is active; clicking it triggers another install, and installFromVellumCatalog defaults to overwrite: true, so the existing managed skill can be silently replaced. Since the card UI now labels these as installed, this is an inconsistent flow that enables accidental overwrite instead of a guarded update/reinstall action.

Useful? React with 👍 / 👎.

case .community:
filtered = filtered.filter { !$0.isVellum }
filtered = skillsManager.searchResults
.filter { !$0.isVellum && !installedNames.contains($0.name) }
}

// Local fuzzy filter by name/description
Expand Down Expand Up @@ -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"
)

Expand All @@ -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)
}
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading