Add skills catalog and sidebar tooling

This commit is contained in:
Gemini AI
2025-12-24 14:23:51 +04:00
Unverified
parent d153892bdf
commit f9748391a9
13 changed files with 1178 additions and 106 deletions

View File

@@ -22,6 +22,26 @@ export interface SessionTasks {
[sessionId: string]: Task[]
}
export interface SkillSelection {
id: string
name: string
description?: string
}
export interface SkillDescriptor {
id: string
name: string
description?: string
}
export interface SkillDetail extends SkillDescriptor {
content: string
}
export interface SkillCatalogResponse {
skills: SkillDescriptor[]
}
/**
* Canonical HTTP/SSE contract for the CLI server.
* These types are consumed by both the CLI implementation and any UI clients.
@@ -120,10 +140,25 @@ export interface WorkspaceFileResponse {
export type WorkspaceFileSearchResponse = FileSystemEntry[]
export interface WorkspaceGitStatusEntry {
path: string
status: string
}
export interface WorkspaceGitStatus {
isRepo: boolean
branch: string | null
ahead: number
behind: number
changes: WorkspaceGitStatusEntry[]
error?: string
}
export interface InstanceData {
messageHistory: string[]
agentModelSelections: AgentModelSelection
sessionTasks?: SessionTasks // Multi-task chat support: tasks per session
sessionSkills?: Record<string, SkillSelection[]> // Selected skills per session
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"

View File

@@ -22,6 +22,7 @@ import { registerOllamaRoutes } from "./routes/ollama"
import { registerQwenRoutes } from "./routes/qwen"
import { registerZAIRoutes } from "./routes/zai"
import { registerOpenCodeZenRoutes } from "./routes/opencode-zen"
import { registerSkillsRoutes } from "./routes/skills"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
@@ -118,6 +119,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerQwenRoutes(app, { logger: deps.logger })
registerZAIRoutes(app, { logger: deps.logger })
registerOpenCodeZenRoutes(app, { logger: deps.logger })
await registerSkillsRoutes(app)
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -0,0 +1,141 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SkillCatalogResponse, SkillDetail, SkillDescriptor } from "../../api-types"
const SKILLS_OWNER = "anthropics"
const SKILLS_REPO = "skills"
const SKILLS_BRANCH = "main"
const SKILLS_ROOT = "skills"
const CATALOG_TTL_MS = 30 * 60 * 1000
const DETAIL_TTL_MS = 30 * 60 * 1000
type CachedCatalog = { skills: SkillDescriptor[]; fetchedAt: number }
type CachedDetail = { detail: SkillDetail; fetchedAt: number }
let catalogCache: CachedCatalog | null = null
const detailCache = new Map<string, CachedDetail>()
interface RepoEntry {
name: string
path: string
type: "file" | "dir"
}
function parseFrontmatter(markdown: string): { attributes: Record<string, string>; body: string } {
if (!markdown.startsWith("---")) {
return { attributes: {}, body: markdown.trim() }
}
const end = markdown.indexOf("\n---", 3)
if (end === -1) {
return { attributes: {}, body: markdown.trim() }
}
const frontmatter = markdown.slice(3, end).trim()
const body = markdown.slice(end + 4).trimStart()
const attributes: Record<string, string> = {}
for (const line of frontmatter.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
if (!match) continue
const key = match[1].trim()
const value = match[2]?.trim() ?? ""
attributes[key] = value
}
return { attributes, body }
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: { "User-Agent": "NomadArch-Skills" },
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed (${response.status})`)
}
return (await response.json()) as T
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, {
headers: { "User-Agent": "NomadArch-Skills" },
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed (${response.status})`)
}
return await response.text()
}
async function fetchCatalog(): Promise<SkillDescriptor[]> {
const url = `https://api.github.com/repos/${SKILLS_OWNER}/${SKILLS_REPO}/contents/${SKILLS_ROOT}?ref=${SKILLS_BRANCH}`
const entries = await fetchJson<RepoEntry[]>(url)
const directories = entries.filter((entry) => entry.type === "dir")
const results: SkillDescriptor[] = []
for (const dir of directories) {
try {
const skill = await fetchSkillDetail(dir.name)
results.push({ id: skill.id, name: skill.name, description: skill.description })
} catch {
results.push({ id: dir.name, name: dir.name, description: "" })
}
}
return results
}
async function fetchSkillDetail(id: string): Promise<SkillDetail> {
const markdownUrl = `https://raw.githubusercontent.com/${SKILLS_OWNER}/${SKILLS_REPO}/${SKILLS_BRANCH}/${SKILLS_ROOT}/${id}/SKILL.md`
const markdown = await fetchText(markdownUrl)
const parsed = parseFrontmatter(markdown)
const name = parsed.attributes.name || id
const description = parsed.attributes.description || ""
return {
id,
name,
description,
content: parsed.body.trim(),
}
}
async function getCatalogCached(): Promise<SkillDescriptor[]> {
const now = Date.now()
if (catalogCache && now - catalogCache.fetchedAt < CATALOG_TTL_MS) {
return catalogCache.skills
}
const skills = await fetchCatalog()
catalogCache = { skills, fetchedAt: now }
return skills
}
async function getDetailCached(id: string): Promise<SkillDetail> {
const now = Date.now()
const cached = detailCache.get(id)
if (cached && now - cached.fetchedAt < DETAIL_TTL_MS) {
return cached.detail
}
const detail = await fetchSkillDetail(id)
detailCache.set(id, { detail, fetchedAt: now })
return detail
}
export async function registerSkillsRoutes(app: FastifyInstance) {
app.get("/api/skills/catalog", async (): Promise<SkillCatalogResponse> => {
const skills = await getCatalogCached()
return { skills }
})
app.get<{ Querystring: { id?: string } }>("/api/skills/detail", async (request, reply): Promise<SkillDetail> => {
const query = z.object({ id: z.string().min(1) }).parse(request.query ?? {})
try {
return await getDetailCached(query.id)
} catch (error) {
request.log.error({ err: error, skillId: query.id }, "Failed to fetch skill detail")
reply.code(502)
return {
id: query.id,
name: query.id,
description: "",
content: "Unable to load skill content.",
}
}
})
}

View File

@@ -1,4 +1,5 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { spawnSync } from "child_process"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
@@ -100,6 +101,68 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return handleWorkspaceError(error, reply)
}
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id/git/status", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const result = spawnSync("git", ["-C", workspace.path, "status", "--porcelain=v1", "-b"], { encoding: "utf8" })
if (result.error) {
return {
isRepo: false,
branch: null,
ahead: 0,
behind: 0,
changes: [],
error: result.error.message,
}
}
if (result.status !== 0) {
const stderr = (result.stderr || "").toLowerCase()
if (stderr.includes("not a git repository")) {
return { isRepo: false, branch: null, ahead: 0, behind: 0, changes: [] }
}
reply.code(400)
return {
isRepo: false,
branch: null,
ahead: 0,
behind: 0,
changes: [],
error: result.stderr || "Unable to read git status",
}
}
const lines = (result.stdout || "").split(/\r?\n/).filter((line) => line.trim().length > 0)
let branch: string | null = null
let ahead = 0
let behind = 0
const changes: Array<{ path: string; status: string }> = []
for (const line of lines) {
if (line.startsWith("##")) {
const header = line.replace(/^##\s*/, "")
const [branchPart, trackingPart] = header.split("...")
branch = branchPart?.trim() || null
const tracking = trackingPart || ""
const aheadMatch = tracking.match(/ahead\s+(\d+)/)
const behindMatch = tracking.match(/behind\s+(\d+)/)
ahead = aheadMatch ? Number(aheadMatch[1]) : 0
behind = behindMatch ? Number(behindMatch[1]) : 0
continue
}
const status = line.slice(0, 2).trim() || line.slice(0, 2)
const path = line.slice(3).trim()
changes.push({ path, status })
}
return { isRepo: true, branch, ahead, behind, changes }
})
}