Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/skills

src/repo.ts

200 lines · 5.1 KB · TypeScript
import { kody } from 'kody:runtime'

/** Plain Kody repo that is the durable home for skill documents. */
export const SKILLS_REPO_NAME = 'skills'

export const META_FILE = 'skill.json'

export type SkillMeta = {
	name: string
	description: string
	created_at: string
	updated_at: string
	files: string[]
}

export type RepoSession = {
	id: string
	base_commit: string
}

export function skillDir(skillId: string): string {
	return skillId
}

export function skillMetaPath(skillId: string): string {
	return `${skillId}/${META_FILE}`
}

export function skillFilePath(skillId: string, path: string): string {
	return `${skillId}/${path}`
}

export function parseSkillMeta(content: string | null | undefined): SkillMeta | null {
	if (!content) return null
	try {
		const parsed = JSON.parse(content) as Partial<SkillMeta>
		if (
			typeof parsed.name !== 'string' ||
			typeof parsed.description !== 'string' ||
			typeof parsed.created_at !== 'string' ||
			typeof parsed.updated_at !== 'string'
		) {
			return null
		}
		const files = Array.isArray(parsed.files)
			? parsed.files.filter((value): value is string => typeof value === 'string')
			: []
		return {
			name: parsed.name,
			description: parsed.description,
			created_at: parsed.created_at,
			updated_at: parsed.updated_at,
			files,
		}
	} catch {
		return null
	}
}

export function serializeSkillMeta(meta: SkillMeta): string {
	return `${JSON.stringify(meta, null, '\t')}\n`
}

/**
 * Open (or resume) an MCP file-level session against the skills plain repo.
 * Plain repos are live-at-HEAD; pair writes with repo_commit + repo_publish_session.
 */
export async function openSkillsRepoSession(
	conversationId?: string,
): Promise<RepoSession> {
	try {
		await kody.repo_get({ name: SKILLS_REPO_NAME })
	} catch {
		throw new Error(
			`Plain repo \`${SKILLS_REPO_NAME}\` not found. Create it with repo_create({ name: "${SKILLS_REPO_NAME}" }), push an initial commit via repo_get_git_remote, then retry.`,
		)
	}

	try {
		const session = await kody.repo_open_session({
			target: { kind: 'repo', name: SKILLS_REPO_NAME },
			...(conversationId ? { conversation_id: conversationId } : {}),
		})
		return {
			id: session.id,
			base_commit: session.base_commit,
		}
	} catch (error) {
		const message = error instanceof Error ? error.message : String(error)
		if (message.includes('no commits yet')) {
			throw new Error(
				`Plain repo \`${SKILLS_REPO_NAME}\` has no commits yet. Push an initial commit through repo_get_git_remote before using skill_* exports.`,
			)
		}
		throw error
	}
}

/**
 * Open a skills-repo session, run `fn`, then discard. One-shot skill_*
 * helpers must not leave active sessions against the repo_sessions entitlement.
 */
export async function withSkillsRepoSession<T>(
	fn: (session: RepoSession) => Promise<T>,
	conversationId?: string,
): Promise<T> {
	const session = await openSkillsRepoSession(conversationId)
	try {
		return await fn(session)
	} finally {
		await kody.repo_discard_session({ session_id: session.id }).catch(() => {
			// Best effort; unused-session sweep is the platform backstop.
		})
	}
}

export async function readRepoFile(
	sessionId: string,
	path: string,
): Promise<string | null> {
	const result = await kody.repo_read_file({ session_id: sessionId, path })
	return result.content ?? null
}

export async function writeRepoFiles(
	sessionId: string,
	files: Array<{ path: string; content: string }>,
): Promise<void> {
	if (files.length === 0) return
	await kody.repo_edit_files({
		session_id: sessionId,
		edits: files.map((file) => ({
			kind: 'write' as const,
			path: file.path,
			content: file.content,
		})),
	})
}

export async function deleteRepoPaths(
	sessionId: string,
	paths: string[],
): Promise<void> {
	if (paths.length === 0) return
	await kody.repo_edit_files({
		session_id: sessionId,
		edits: paths.map((path) => ({ kind: 'delete' as const, path })),
	})
}

export async function commitAndPublish(
	sessionId: string,
	message: string,
): Promise<{ oid: string }> {
	const commit = await kody.repo_commit({
		session_id: sessionId,
		message,
	})
	const published = await kody.repo_publish_session({ session_id: sessionId })
	if (published.status !== 'ok') {
		throw new Error(
			`repo_publish_session failed (${published.status}): ${published.message}`,
		)
	}
	return { oid: commit.oid }
}

export async function restoreAndPublish(input: {
	sessionId: string
	paths: string[]
	commit: string
	message: string
}): Promise<{ oid: string }> {
	await kody.repo_restore({
		session_id: input.sessionId,
		paths: input.paths,
		commit: input.commit,
	})
	return commitAndPublish(input.sessionId, input.message)
}

/** Discover skill ids by finding skill.json files under each skill directory. */
export async function listSkillIdsFromRepo(sessionId: string): Promise<string[]> {
	const result = await kody.repo_search({
		session_id: sessionId,
		pattern: 'name',
		glob: '**/skill.json',
		output_mode: 'files',
		limit: 200,
	})
	const ids = new Set<string>()
	for (const file of result.files ?? []) {
		const path = String(file.path ?? '')
		const parts = path.split('/')
		if (parts.length === 2 && parts[1] === META_FILE && parts[0]) {
			ids.add(parts[0])
		}
	}
	return [...ids].sort()
}