Skip to content
← Public packages

@kentcdodds/skills

Store, retrieve, edit, version, and revert reusable agent skill documents via skillList then skillGet.

src/repo.ts

233 lines · 6.2 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
	createdAt: string
	updatedAt: string
	files: string[]
}

export type RepoSession = {
	id: string
	baseCommit: 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}`
}

/**
 * Parse skill.json. Accepts camelCase (preferred) and legacy snake_case
 * (`created_at` / `updated_at`) on disk.
 */
export function parseSkillMeta(content: string | null | undefined): SkillMeta | null {
	if (!content) return null
	try {
		const parsed = JSON.parse(content) as Record<string, unknown>
		if (typeof parsed.name !== 'string' || typeof parsed.description !== 'string') {
			return null
		}
		const createdAt =
			typeof parsed.createdAt === 'string'
				? parsed.createdAt
				: typeof parsed.created_at === 'string'
					? parsed.created_at
					: null
		const updatedAt =
			typeof parsed.updatedAt === 'string'
				? parsed.updatedAt
				: typeof parsed.updated_at === 'string'
					? parsed.updated_at
					: null
		if (!createdAt || !updatedAt) 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,
			createdAt,
			updatedAt,
			files,
		}
	} catch {
		return null
	}
}

/** Serialize skill.json in camelCase (public on-disk shape going forward). */
export function serializeSkillMeta(meta: SkillMeta): string {
	return `${JSON.stringify(
		{
			name: meta.name,
			description: meta.description,
			createdAt: meta.createdAt,
			updatedAt: meta.updatedAt,
			files: meta.files,
		},
		null,
		'\t',
	)}\n`
}

/**
 * Open a new MCP file-level session against the skills plain repo.
 * Always mints a session (no conversationId) so concurrent one-shot callers
 * do not share a workspace that another caller is about to discard.
 * Plain repos are live-at-HEAD; pair writes with repo_commit + repo_publish_session.
 *
 * Wire exception: `kody.repoOpenSession` returns `base_commit` — mapped to
 * `baseCommit` on our RepoSession type.
 */
export async function openSkillsRepoSession(): Promise<RepoSession> {
	try {
		await kody.repoGet({ name: SKILLS_REPO_NAME })
	} catch (error) {
		const message = error instanceof Error ? error.message : String(error)
		if (/not found|unknown repo/i.test(message)) {
			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.`,
			)
		}
		throw new Error(
			`Could not open plain repo \`${SKILLS_REPO_NAME}\`: ${message}`,
		)
	}

	try {
		const session = await kody.repoOpenSession({
			target: { kind: 'repo', name: SKILLS_REPO_NAME },
		})
		return {
			id: session.id,
			baseCommit: 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.
 * Do not resume a shared conversation here: discard-after-use races with any
 * other caller that reopened that conversation.
 */
export async function withSkillsRepoSession<T>(
	fn: (session: RepoSession) => Promise<T>,
): Promise<T> {
	const session = await openSkillsRepoSession()
	try {
		return await fn(session)
	} finally {
		// Wire: Kody repoDiscardSession expects session_id.
		await kody.repoDiscardSession({ 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.repoReadFile({ 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.repoEditFiles({
		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.repoEditFiles({
		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.repoCommit({
		session_id: sessionId,
		message,
	})
	const published = await kody.repoPublishSession({ 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.repoRestore({
		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.repoSearch({
		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()
}