← 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 · TypeScriptimport { 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()
}