Thanks to visit codestin.com
Credit goes to github.com

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
70 changes: 69 additions & 1 deletion src/SkillServer/Endpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ namespace SkillServer;

public static class Endpoints
{
private const long MaxResourcePreviewBytes = 256 * 1024;

public static WebApplication MapSkillServerEndpoints(this WebApplication app)
{
app.MapDiscoveryEndpoints();
Expand Down Expand Up @@ -174,6 +176,7 @@ private static void MapSkillEndpoints(this WebApplication app)
skills.MapGet("/{name}/{version}", GetVersion);
skills.MapGet("/{name}/{version}/SKILL.md", DownloadSkillMd);
skills.MapGet("/{name}/{version}/archive.zip", DownloadArchive);
skills.MapGet("/{name}/{version}/resources", ListSkillResources);
skills.MapGet("/{name}/{version}/{*path}", DownloadResource);
skills.MapPost("/check-updates", CheckUpdates);
skills.MapPost("/", UploadSkill).DisableAntiforgery().AddEndpointFilter<ApiKeyEndpointFilter>();
Expand Down Expand Up @@ -285,6 +288,43 @@ private static async Task<IResult> GetVersion(
return Results.Json(summary, SkillServerJsonContext.Default.SkillVersionSummary);
}

private static async Task<IResult> ListSkillResources(
string name,
string version,
SkillRepository repository,
CancellationToken ct)
{
var skill = await repository.GetSkillByNameAsync(name, ct);
if (skill is null)
return Results.NotFound(new ErrorResponse { Error = "not_found", Message = $"Skill '{name}' not found." });

var skillVersion = await repository.GetVersionAsync(skill.Id, version, ct);
if (skillVersion is null)
return Results.NotFound(new ErrorResponse { Error = "not_found", Message = $"Version '{version}' not found for skill '{name}'." });

var files = await repository.GetFilesAsync(skillVersion.Id, ct);
var summaries = files
.OrderBy(f => f.RelativePath, StringComparer.Ordinal)
.Select(f =>
{
var contentType = GetContentType(f.RelativePath);
return new SkillResourceSummary
{
Path = f.RelativePath,
Url = $"/api/v1/skills/{Uri.EscapeDataString(skill.Name)}/{Uri.EscapeDataString(skillVersion.Version)}/{EncodeResourcePath(f.RelativePath)}",
Sha256 = Sha256Digest.Create(f.Sha256).Value,
SizeBytes = f.SizeBytes,
UnixMode = f.UnixMode,
ContentType = contentType,
Previewable = IsPreviewableResource(contentType, f.SizeBytes),
Language = GetResourceLanguage(f.RelativePath)
};
})
.ToList();

return Results.Json(summaries, SkillServerJsonContext.Default.IReadOnlyListSkillResourceSummary);
}

private static async Task<IResult> DownloadSkillMd(
string name,
string version,
Expand Down Expand Up @@ -901,10 +941,38 @@ private static void MapBlobEndpoints(this WebApplication app)
".json" => "application/json",
".yaml" or ".yml" => "application/x-yaml",
".py" => "text/x-python",
".sh" => "application/x-sh",
".sh" or ".bash" => "application/x-sh",
".ps1" or ".psm1" => "text/x-powershell",
".js" => "application/javascript",
".ts" => "application/typescript",
".txt" => "text/plain",
_ => "application/octet-stream"
};

private static string? GetResourceLanguage(string path) => Path.GetExtension(path).ToLowerInvariant() switch
{
".md" => "markdown",
".json" => "json",
".yaml" or ".yml" => "yaml",
".py" => "python",
".sh" or ".bash" => "shell",
".ps1" or ".psm1" => "powershell",
".js" => "javascript",
".ts" => "typescript",
".txt" => "text",
_ => null
};

private static bool IsPreviewableResource(string contentType, long sizeBytes)
{
if (sizeBytes > MaxResourcePreviewBytes)
return false;

return contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) ||
contentType is "application/json" or "application/x-yaml" or "application/x-sh" or
"application/javascript" or "application/typescript";
}

private static string EncodeResourcePath(string path) =>
string.Join("/", path.Split('/').Select(Uri.EscapeDataString));
}
27 changes: 27 additions & 0 deletions src/SkillServer/Models/ApiModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,33 @@ public sealed record SkillVersionSummary
public required int FileCount { get; init; }
}

public sealed record SkillResourceSummary
{
[JsonPropertyName("path")]
public required string Path { get; init; }

[JsonPropertyName("url")]
public required string Url { get; init; }

[JsonPropertyName("sha256")]
public required string Sha256 { get; init; }

[JsonPropertyName("sizeBytes")]
public required long SizeBytes { get; init; }

[JsonPropertyName("unixMode")]
public int? UnixMode { get; init; }

[JsonPropertyName("contentType")]
public required string ContentType { get; init; }

[JsonPropertyName("previewable")]
public required bool Previewable { get; init; }

[JsonPropertyName("language")]
public string? Language { get; init; }
}

public sealed record SubAgentSummary
{
[JsonPropertyName("name")]
Expand Down
2 changes: 2 additions & 0 deletions src/SkillServer/Models/SkillServerJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ namespace SkillServer.Models;
[JsonSerializable(typeof(SubAgentUploadResponse))]
[JsonSerializable(typeof(SkillSummary))]
[JsonSerializable(typeof(SkillVersionSummary))]
[JsonSerializable(typeof(SkillResourceSummary))]
[JsonSerializable(typeof(SubAgentSummary))]
[JsonSerializable(typeof(SubAgentVersionSummary))]
[JsonSerializable(typeof(IReadOnlyList<SkillSummary>))]
[JsonSerializable(typeof(IReadOnlyList<SkillVersionSummary>))]
[JsonSerializable(typeof(IReadOnlyList<SkillResourceSummary>))]
[JsonSerializable(typeof(IReadOnlyList<SubAgentSummary>))]
[JsonSerializable(typeof(IReadOnlyList<SubAgentVersionSummary>))]
[JsonSerializable(typeof(ErrorResponse))]
Expand Down
17 changes: 17 additions & 0 deletions src/SkillServer/wwwroot/js/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ export async function fetchSkillMd(name, version) {
return res.text();
}

export async function fetchSkillResources(name, version) {
const res = await fetch(`${API_BASE}/skills/${encodeURIComponent(name)}/${encodeURIComponent(version)}/resources`);
if (!res.ok) throw new Error(`Failed to fetch skill resources: ${res.status}`);
return res.json();
}

export async function fetchSkillResource(name, version, path) {
const res = await fetch(getSkillResourceUrl(name, version, path));
if (!res.ok) throw new Error(`Failed to fetch skill resource: ${res.status}`);
return res.text();
}

export function getSkillResourceUrl(name, version, path) {
const encodedPath = path.split('/').map(encodeURIComponent).join('/');
return `${API_BASE}/skills/${encodeURIComponent(name)}/${encodeURIComponent(version)}/${encodedPath}`;
}

export async function fetchSubAgents() {
const res = await fetch(`${API_BASE}/subagents/`);
if (!res.ok) throw new Error(`Failed to fetch subagents: ${res.status}`);
Expand Down
147 changes: 143 additions & 4 deletions src/SkillServer/wwwroot/skills/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
</footer>

<script type="module">
import { fetchSkills, fetchSkillVersions, fetchSkillMd } from '/js/api.js';
import { fetchSkills, fetchSkillVersions, fetchSkillMd, fetchSkillResources, fetchSkillResource, getSkillResourceUrl } from '/js/api.js';
import { renderAppVersion } from '/js/footer.js';
import { getSkillName, getSkillVersion } from '/js/router.js';

Expand All @@ -53,6 +53,7 @@
const app = document.getElementById('app');
const skillName = getSkillName();
const requestedVersion = getSkillVersion();
let selectedFileRequestId = 0;

if (skillName) {
await renderDetail(skillName, requestedVersion);
Expand Down Expand Up @@ -107,7 +108,10 @@ <h1>${initialQuery ? 'Search Results' : 'All Skills'}</h1>
? (versions.find(v => v.version === version) || versions.find(v => v.isLatest) || versions[0])
: (versions.find(v => v.isLatest) || versions[0]);
document.title = `${name}${version ? ' v' + targetVersion.version : ''} - SkillServer Gallery`;
const skillMd = await fetchSkillMd(name, targetVersion.version);
const [skillMd, resources] = await Promise.all([
fetchSkillMd(name, targetVersion.version),
fetchSkillResources(name, targetVersion.version)
]);
app.innerHTML = `
<div class="breadcrumb">
<a href="/">Home</a> <span>›</span>
Expand All @@ -130,9 +134,16 @@ <h1>${name}</h1>
</div>
<section class="section">
<div class="section-header">
<h2>SKILL.md</h2>
<h2>Files</h2>
</div>
<div class="file-browser">
<div class="file-tree" aria-label="Skill files">
${renderFileTree(targetVersion, resources)}
</div>
<div class="file-preview" id="file-preview">
${renderTextPreview(createSkillMdFile(targetVersion), skillMd)}
</div>
</div>
<div class="code-block"><pre>${escapeHtml(skillMd)}</pre></div>
</section>
<section class="section">
<div class="section-header">
Expand All @@ -149,6 +160,7 @@ <h2>Version History</h2>
</div>
</section>
`;
wireFileBrowser(name, targetVersion.version, targetVersion, skillMd, resources);
} catch (err) {
app.innerHTML = `<div class="error">Failed to load skill: ${err.message}</div>`;
}
Expand All @@ -169,6 +181,125 @@ <h2>Version History</h2>
`;
}

function createSkillMdFile(version) {
return {
path: 'SKILL.md',
sizeBytes: version.sizeBytes,
contentType: 'text/markdown',
previewable: true,
language: 'markdown'
};
}

function renderFileTree(version, resources) {
const rows = [{ type: 'file', depth: 0, file: createSkillMdFile(version) }];
const seenDirectories = new Set();

for (const resource of resources.slice().sort((a, b) => a.path.localeCompare(b.path))) {
const segments = resource.path.split('/');

for (let i = 0; i < segments.length - 1; i++) {
const directoryPath = segments.slice(0, i + 1).join('/');
if (seenDirectories.has(directoryPath)) continue;

seenDirectories.add(directoryPath);
rows.push({ type: 'directory', depth: i, name: segments[i] });
}

rows.push({ type: 'file', depth: segments.length - 1, file: resource });
}

return rows.map(row => row.type === 'directory'
? `<div class="file-tree-row directory" style="--depth:${row.depth}"><span class="file-icon">▸</span><span>${escapeHtml(row.name)}/</span></div>`
: renderFileTreeButton(row.file, row.depth)).join('');
}

function renderFileTreeButton(file, depth) {
const active = file.path === 'SKILL.md' ? ' active' : '';
const meta = file.path === 'SKILL.md' ? 'entry' : formatBytes(file.sizeBytes);

return `
<button type="button" class="file-tree-row file${active}" style="--depth:${depth}" data-file-path="${escapeAttribute(file.path)}">
<span class="file-icon">${file.previewable ? '•' : '⬢'}</span>
<span class="file-name">${escapeHtml(file.path.split('/').pop())}</span>
<span class="file-tree-meta">${escapeHtml(meta)}</span>
</button>
`;
}

function wireFileBrowser(name, version, targetVersion, skillMd, resources) {
const preview = document.getElementById('file-preview');
if (!preview) return;

const files = new Map(resources.map(resource => [resource.path, resource]));
files.set('SKILL.md', createSkillMdFile(targetVersion));

document.querySelectorAll('[data-file-path]').forEach(button => {
button.addEventListener('click', async () => {
const path = button.getAttribute('data-file-path');
const file = files.get(path);
if (!file) return;

document.querySelectorAll('[data-file-path]').forEach(item => item.classList.remove('active'));
button.classList.add('active');

await renderSelectedFile(preview, name, version, file, skillMd, ++selectedFileRequestId);
});
});
}

async function renderSelectedFile(preview, name, version, file, skillMd, requestId) {
if (!file.previewable) {
preview.innerHTML = renderUnavailablePreview(name, version, file);
return;
}

preview.innerHTML = `<div class="file-preview-state">Loading ${escapeHtml(file.path)}...</div>`;

try {
const content = file.path === 'SKILL.md'
? skillMd
: await fetchSkillResource(name, version, file.path);
if (requestId !== selectedFileRequestId) return;

preview.innerHTML = renderTextPreview(file, content);
} catch (err) {
if (requestId !== selectedFileRequestId) return;

preview.innerHTML = `<div class="error">Failed to load ${escapeHtml(file.path)}: ${escapeHtml(err.message)}</div>`;
}
}

function renderTextPreview(file, content) {
return `
<div class="file-preview-header">
<div>
<div class="file-preview-title">${escapeHtml(file.path)}</div>
<div class="file-preview-subtitle">${escapeHtml(file.contentType || 'text/plain')}</div>
</div>
<div class="file-preview-meta">${file.language ? escapeHtml(file.language) : 'text'} · ${formatBytes(file.sizeBytes)}</div>
</div>
<div class="code-block file-code"><pre><code class="language-${escapeAttribute(file.language || 'text')}">${escapeHtml(content)}</code></pre></div>
`;
}

function renderUnavailablePreview(name, version, file) {
const url = getSkillResourceUrl(name, version, file.path);
return `
<div class="file-preview-header">
<div>
<div class="file-preview-title">${escapeHtml(file.path)}</div>
<div class="file-preview-subtitle">${escapeHtml(file.contentType || 'application/octet-stream')}</div>
</div>
<div class="file-preview-meta">${formatBytes(file.sizeBytes)}</div>
</div>
<div class="file-preview-state">
Preview unavailable for this file type or size.
<a href="${escapeAttribute(url)}" target="_blank" rel="noopener">Open raw file</a>
</div>
`;
}

function formatBytes(bytes) {
if (!bytes) return '—';
if (bytes < 1024) return bytes + ' B';
Expand All @@ -193,6 +324,14 @@ <h2>Version History</h2>
div.textContent = str;
return div.innerHTML;
}

function escapeAttribute(str) {
return String(str)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
</script>

</body>
Expand Down
Loading
Loading