← Public packages
@kentcdodds/skills
Store, retrieve, edit, version, and revert reusable agent skill documents via skillList then skillGet.
src/skill-save.ts
211 lines · 5.2 KB · TypeScriptimport { getBackend } from './backend.ts'
import {
getFile,
getSkill,
insertSkillRevision,
nowIso,
snapshotFile,
sqlRun,
ensureSchema,
upsertSkillsIndex,
} from './db.ts'
import {
commitAndPublish,
withSkillsRepoSession,
parseSkillMeta,
readRepoFile,
serializeSkillMeta,
skillFilePath,
skillMetaPath,
writeRepoFiles,
type SkillMeta,
} from './repo.ts'
export type SkillSaveInput = {
id: string
path: string
content: string
name?: string
description?: string
}
export type SkillSaveResult = {
id: string
path: string
versionSaved: boolean
}
async function saveLegacy(params: {
id: string
path: string
content: string
name?: string
description?: string
}): Promise<SkillSaveResult> {
await ensureSchema()
const { id, path, content } = params
const existing = await getSkill(id)
const name = params.name
const description = params.description
const timestamp = nowIso()
if (!existing) {
if (!name || !description) {
throw new Error(
`skillSave: creating skill \`${id}\` requires both \`name\` and \`description\`.`,
)
}
await sqlRun(
`INSERT INTO skills (id, name, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
[id, name, description, timestamp, timestamp],
)
} else {
const nextName = name ?? existing.name
const nextDescription = description ?? existing.description
await sqlRun(
`UPDATE skills
SET name = ?, description = ?, updated_at = ?
WHERE id = ?`,
[nextName, nextDescription, timestamp, id],
)
}
const previous = await getFile(id, path)
let versionSaved = false
if (previous) {
await snapshotFile(id, path, previous.content, timestamp)
versionSaved = true
await sqlRun(
`UPDATE skill_files
SET content = ?, updated_at = ?
WHERE skill_id = ? AND path = ?`,
[content, timestamp, id, path],
)
} else {
await sqlRun(
`INSERT INTO skill_files (skill_id, path, content, updated_at)
VALUES (?, ?, ?, ?)`,
[id, path, content, timestamp],
)
}
return { id, path, versionSaved }
}
async function saveRepo(params: {
id: string
path: string
content: string
name?: string
description?: string
}): Promise<SkillSaveResult> {
const { id, path, content } = params
return withSkillsRepoSession(async (session) => {
const metaPath = skillMetaPath(id)
const filePath = skillFilePath(id, path)
const existingMeta = parseSkillMeta(await readRepoFile(session.id, metaPath))
const previousContent = await readRepoFile(session.id, filePath)
const timestamp = nowIso()
if (!existingMeta) {
if (!params.name || !params.description) {
throw new Error(
`skillSave: creating skill \`${id}\` requires both \`name\` and \`description\`.`,
)
}
}
const files = existingMeta
? existingMeta.files.includes(path)
? existingMeta.files
: [...existingMeta.files, path].sort()
: [path]
const meta: SkillMeta = {
name: params.name ?? existingMeta!.name,
description: params.description ?? existingMeta!.description,
createdAt: existingMeta?.createdAt ?? timestamp,
updatedAt: timestamp,
files,
}
await writeRepoFiles(session.id, [
{ path: metaPath, content: serializeSkillMeta(meta) },
{ path: filePath, content },
])
const { oid } = await commitAndPublish(
session.id,
`skill-save: ${id} ${path}`,
)
let versionSaved = false
if (previousContent != null) {
await insertSkillRevision({
commitOid: session.baseCommit,
skillId: id,
path,
replacedAt: timestamp,
contentLength: previousContent.length,
})
versionSaved = true
}
await upsertSkillsIndex({
id,
name: meta.name,
description: meta.description,
files: meta.files,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
})
void oid
return { id, path, versionSaved }
})
}
/**
* Upsert a skill file. On first create, `name` and `description` are required.
* Overwriting an existing file records a git revision (repo backend) or
* snapshots previous content into skill_versions (legacy backend).
*
* @example
* import skillSave from 'kody:@kentcdodds/skills/skill-save'
* const result = await skillSave({
* id: 'kent-writing-voice',
* path: 'SKILL.md',
* content: '# ...',
* name: 'kent-writing-voice',
* description: 'Drafts articles in Kent teaching voice...',
* })
*/
export default async function skillSave(
params: SkillSaveInput = {} as SkillSaveInput,
): Promise<SkillSaveResult> {
const id = String(params.id ?? '').trim()
const path = String(params.path ?? '').trim()
const content = params.content == null ? '' : String(params.content)
if (!id) throw new Error('skillSave requires `id`.')
if (!path) throw new Error('skillSave requires `path`.')
if (path.includes('/') || path.includes('\\') || path === 'skill.json') {
throw new Error(
`skillSave: invalid path \`${path}\`. Use a skill-relative file name (e.g. SKILL.md), not skill.json.`,
)
}
const name =
params.name == null ? undefined : String(params.name).trim() || undefined
const description =
params.description == null
? undefined
: String(params.description).trim() || undefined
const backend = await getBackend()
if (backend === 'repo') {
return saveRepo({ id, path, content, name, description })
}
return saveLegacy({ id, path, content, name, description })
}