import { getBackend } from './backend.ts'
import { isDryRun } from './dry-run.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
dryRun?: boolean
}
export type SkillSaveResult = {
id: string
path: string
version_saved: boolean
dryRun?: true
would?: {
action: 'create' | 'update'
backend: 'legacy' | 'repo'
commit: false
}
}
async function saveLegacy(params: {
id: string
path: string
content: string
name?: string
description?: string
dryRun: boolean
}): 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(
`skill_save: creating skill \`${id}\` requires both \`name\` and \`description\`.`,
)
}
if (params.dryRun) {
return {
id,
path,
version_saved: false,
dryRun: true,
would: { action: 'create', backend: 'legacy', commit: false },
}
}
await sqlRun(
`INSERT INTO skills (id, name, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
[id, name, description, timestamp, timestamp],
)
} else if (params.dryRun) {
const previous = await getFile(id, path)
return {
id,
path,
version_saved: previous != null,
dryRun: true,
would: { action: 'update', backend: 'legacy', commit: false },
}
} 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, version_saved: versionSaved }
}
async function saveRepo(params: {
id: string
path: string
content: string
name?: string
description?: string
dryRun: boolean
}): 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(
`skill_save: creating skill \`${id}\` requires both \`name\` and \`description\`.`,
)
}
}
if (params.dryRun) {
return {
id,
path,
version_saved: previousContent != null,
dryRun: true,
would: {
action: existingMeta ? 'update' : 'create',
backend: 'repo',
commit: false,
},
}
}
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,
created_at: existingMeta?.created_at ?? timestamp,
updated_at: 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({
commit_oid: session.base_commit,
skill_id: id,
path,
replaced_at: timestamp,
content_length: previousContent.length,
})
versionSaved = true
}
await upsertSkillsIndex({
id,
name: meta.name,
description: meta.description,
files: meta.files,
created_at: meta.created_at,
updated_at: meta.updated_at,
})
void oid
return { id, path, version_saved: 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:@kody/skills/skill-save'
* const result = await skillSave({
* id: 'writing-style',
* path: 'SKILL.md',
* content: '# ...',
* name: 'writing-style',
* description: 'How to draft in the account owner voice...',
* dryRun: true,
* })
*/
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('skill_save requires `id`.')
if (!path) throw new Error('skill_save requires `path`.')
if (path.includes('/') || path.includes('\\') || path === 'skill.json') {
throw new Error(
`skill_save: 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 dryRun = isDryRun(params.dryRun)
const backend = await getBackend()
if (backend === 'repo') {
return saveRepo({ id, path, content, name, description, dryRun })
}
return saveLegacy({ id, path, content, name, description, dryRun })
}