Add skills catalog and sidebar tooling
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
|
||||
141
packages/server/src/server/routes/skills.ts
Normal file
141
packages/server/src/server/routes/skills.ts
Normal 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.",
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user