Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/sdk-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@
"node": ">=22"
},
"dependencies": {
"@nimblebrain/mpak-schemas": "workspace:*",
"jszip": "^3.10.1"
"@nimblebrain/mpak-schemas": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"jszip": "^3.10.1",
"tsup": "^8.4.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
Expand Down
254 changes: 57 additions & 197 deletions packages/sdk-typescript/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import type { BundleSearchResponse, SkillSearchResponse } from '@nimblebrain/mpak-schemas';
import { createHash } from 'crypto';
import { MpakIntegrityError, MpakNetworkError, MpakNotFoundError } from './errors.js';
import type {
MpakClientConfig,
BundleSearchParams,
BundleDetailResponse,
BundleVersionsResponse,
BundleVersionResponse,
BundleDownloadResponse,
BundleSearchParams,
BundleVersionResponse,
BundleVersionsResponse,
MpakClientConfig,
Platform,
SkillDetailResponse,
SkillDownloadResponse,
SkillSearchParams,
Platform,
SkillReference,
GithubSkillReference,
UrlSkillReference,
ResolvedSkill,
} from './types.js';
import type { BundleSearchResponse, SkillSearchResponse } from '@nimblebrain/mpak-schemas';
import { MpakNotFoundError, MpakIntegrityError, MpakNetworkError } from './errors.js';

const DEFAULT_REGISTRY_URL = 'https://registry.mpak.dev';
const DEFAULT_TIMEOUT = 30000;
Expand All @@ -25,7 +21,6 @@ const DEFAULT_TIMEOUT = 30000;
* Client for interacting with the mpak registry
*
* Requires Node.js 18+ for native fetch support.
* Uses jszip for skill bundle extraction.
*/
export class MpakClient {
private readonly registryUrl: string;
Expand Down Expand Up @@ -262,214 +257,79 @@ export class MpakClient {
return response.json() as Promise<SkillDownloadResponse>;
}

/**
* Download skill content and verify integrity
*
* @throws {MpakIntegrityError} If expectedSha256 is provided and doesn't match (fail-closed)
*/
async downloadSkillContent(
downloadUrl: string,
expectedSha256?: string,
): Promise<{ content: string; verified: boolean }> {
const response = await this.fetchWithTimeout(downloadUrl);

if (!response.ok) {
throw new MpakNetworkError(`Failed to download skill: HTTP ${response.status}`);
}

const content = await response.text();

if (expectedSha256) {
const actualHash = this.computeSha256(content);
if (actualHash !== expectedSha256) {
throw new MpakIntegrityError(expectedSha256, actualHash);
}
return { content, verified: true };
}

return { content, verified: false };
}
// ===========================================================================
// Download Methods
// ===========================================================================

/**
* Resolve a skill reference to actual content
* Download content from a URL and verify its SHA-256 integrity.
*
* Supports mpak, github, and url sources. This is the main method for
* fetching skill content from any supported source.
*
* @throws {MpakNotFoundError} If skill not found
* @throws {MpakIntegrityError} If integrity check fails (fail-closed)
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*
* @example
* ```typescript
* // Resolve from mpak registry
* const skill = await client.resolveSkillRef({
* source: 'mpak',
* name: '@nimblebraininc/folk-crm',
* version: '1.3.0',
* });
*
* // Resolve from GitHub
* const skill = await client.resolveSkillRef({
* source: 'github',
* name: '@example/my-skill',
* version: 'v1.0.0',
* repo: 'owner/repo',
* path: 'skills/my-skill/SKILL.md',
* });
*
* // Resolve from URL
* const skill = await client.resolveSkillRef({
* source: 'url',
* name: '@example/custom',
* version: '1.0.0',
* url: 'https://example.com/skill.md',
* });
* ```
*/
async resolveSkillRef(ref: SkillReference): Promise<ResolvedSkill> {
switch (ref.source) {
case 'mpak':
return this.resolveMpakSkill(ref);
case 'github':
return this.resolveGithubSkill(ref);
case 'url':
return this.resolveUrlSkill(ref);
default: {
const _exhaustive: never = ref;
throw new Error(`Unknown skill source: ${(_exhaustive as SkillReference).source}`);
}
}
}

/**
* Resolve a skill from mpak registry
*
* The API returns a ZIP bundle containing SKILL.md and metadata.
*/
private async resolveMpakSkill(ref: SkillReference & { source: 'mpak' }): Promise<ResolvedSkill> {
const url = `${this.registryUrl}/v1/skills/${ref.name}/versions/${ref.version}/download`;

const response = await this.fetchWithTimeout(url);

if (response.status === 404) {
throw new MpakNotFoundError(`${ref.name}@${ref.version}`);
}

if (!response.ok) {
throw new MpakNetworkError(`Failed to fetch skill: HTTP ${response.status}`);
}

// Response is a ZIP file - extract SKILL.md
const zipBuffer = await response.arrayBuffer();
const content = await this.extractSkillFromZip(zipBuffer, ref.name);

if (ref.integrity) {
this.verifyIntegrityOrThrow(content, ref.integrity);
return { content, version: ref.version, source: 'mpak', verified: true };
}

return { content, version: ref.version, source: 'mpak', verified: false };
}

/**
* Resolve a skill from GitHub releases
*/
private async resolveGithubSkill(ref: GithubSkillReference): Promise<ResolvedSkill> {
const url = `https://github.com/${ref.repo}/releases/download/${ref.version}/${ref.path}`;
async downloadContent(url: string, sha256: string): Promise<Uint8Array> {
const response = await this.fetchWithTimeout(url);

if (!response.ok) {
throw new MpakNotFoundError(`github:${ref.repo}/${ref.path}@${ref.version}`);
throw new MpakNetworkError(`Failed to download: HTTP ${response.status}`);
}

const content = await response.text();
const downloadedRawData = new Uint8Array(await response.arrayBuffer());

if (ref.integrity) {
this.verifyIntegrityOrThrow(content, ref.integrity);
return {
content,
version: ref.version,
source: 'github',
verified: true,
};
const computedHash = this.computeSha256(downloadedRawData);
if (computedHash !== sha256) {
throw new MpakIntegrityError(sha256, computedHash);
}

return {
content,
version: ref.version,
source: 'github',
verified: false,
};
return downloadedRawData;
}

/**
* Resolve a skill from a direct URL
* Download a bundle by name, with optional version and platform.
* Defaults to latest version and auto-detected platform.
*
* @throws {MpakNotFoundError} If bundle not found
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
private async resolveUrlSkill(ref: UrlSkillReference): Promise<ResolvedSkill> {
const response = await this.fetchWithTimeout(ref.url);

if (!response.ok) {
throw new MpakNotFoundError(`url:${ref.url}`);
}

const content = await response.text();

if (ref.integrity) {
this.verifyIntegrityOrThrow(content, ref.integrity);
return { content, version: ref.version, source: 'url', verified: true };
}

return { content, version: ref.version, source: 'url', verified: false };
}
async downloadBundle(
name: string,
version?: string,
platform?: Platform,
): Promise<{
data: Uint8Array;
metadata: BundleDownloadResponse['bundle'];
}> {
const resolvedPlatform = platform ?? MpakClient.detectPlatform();
const resolvedVersion = version ?? 'latest';

/**
* Extract SKILL.md content from a skill bundle ZIP
*/
private async extractSkillFromZip(zipBuffer: ArrayBuffer, skillName: string): Promise<string> {
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(zipBuffer);

// Skill name format: @scope/name -> folder is just 'name'
const folderName = skillName.split('/').pop() ?? skillName;
const skillPath = `${folderName}/SKILL.md`;

const skillFile = zip.file(skillPath);
if (!skillFile) {
// Try without folder prefix
const altFile = zip.file('SKILL.md');
if (!altFile) {
throw new MpakNotFoundError(`SKILL.md not found in bundle for ${skillName}`);
}
return altFile.async('string');
}
const downloadInfo = await this.getBundleDownload(name, resolvedVersion, resolvedPlatform);
const data = await this.downloadContent(downloadInfo.url, downloadInfo.bundle.sha256);

return skillFile.async('string');
return { data, metadata: downloadInfo.bundle };
}

/**
* Verify content integrity and throw if mismatch (fail-closed)
* Download a skill bundle by name, with optional version.
* Defaults to latest version.
*
* @throws {MpakNotFoundError} If skill not found
* @throws {MpakIntegrityError} If SHA-256 doesn't match
* @throws {MpakNetworkError} For network failures
*/
private verifyIntegrityOrThrow(content: string, integrity: string): void {
const expectedHash = this.extractHash(integrity);
const actualHash = this.computeSha256(content);
async downloadSkillBundle(
name: string,
version?: string,
): Promise<{
data: Uint8Array;
metadata: SkillDownloadResponse['skill'];
}> {
const resolvedVersion = version ?? 'latest';

if (actualHash !== expectedHash) {
throw new MpakIntegrityError(expectedHash, actualHash);
}
}
const downloadInfo = await this.getSkillVersionDownload(name, resolvedVersion);
const data = await this.downloadContent(downloadInfo.url, downloadInfo.skill.sha256);

/**
* Extract hash from integrity string (removes prefix)
*/
private extractHash(integrity: string): string {
if (integrity.startsWith('sha256:')) {
return integrity.slice(7);
}
if (integrity.startsWith('sha256-')) {
return integrity.slice(7);
}
return integrity;
return { data, metadata: downloadInfo.skill };
}

// ===========================================================================
Expand Down Expand Up @@ -516,8 +376,8 @@ export class MpakClient {
/**
* Compute SHA256 hash of content
*/
private computeSha256(content: string): string {
return createHash('sha256').update(content, 'utf8').digest('hex');
private computeSha256(content: string | Uint8Array): string {
return createHash('sha256').update(content).digest('hex');
}

/**
Expand Down
25 changes: 5 additions & 20 deletions packages/sdk-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,18 @@
*
* @example
* ```typescript
* import { MpakClient, SkillReference } from '@nimblebrain/mpak-sdk';
* import { MpakClient } from '@nimblebrain/mpak-sdk';
*
* const client = new MpakClient();
*
* // Search for bundles
* const bundles = await client.searchBundles({ q: 'mcp' });
*
* // Get bundle details
* const bundle = await client.getBundle('@nimbletools/echo');
* // Download a bundle (latest version, auto-detected platform)
* const { data, metadata } = await client.downloadBundle('@nimbletools/echo');
*
* // Search for skills
* const skills = await client.searchSkills({ q: 'crm' });
*
* // Resolve a skill reference to content (recommended)
* const ref: SkillReference = {
* source: 'mpak',
* name: '@nimblebraininc/folk-crm',
* version: '1.3.0',
* };
* const resolved = await client.resolveSkillRef(ref);
* console.log(resolved.content); // Skill markdown content
* // Download a skill bundle
* const { data, metadata } = await client.downloadSkillBundle('@nimblebraininc/folk-crm');
* ```
*/

Expand Down Expand Up @@ -62,12 +53,6 @@ export type {
SkillDetail,
SkillDownloadInfo,
SkillVersion,
// Skill reference types (for resolveSkillRef)
SkillReference,
MpakSkillReference,
GithubSkillReference,
UrlSkillReference,
ResolvedSkill,
} from './types.js';

// Re-export SkillSearchResponse from schemas
Expand Down
Loading
Loading