// Skill versioning for Case 1: a workspace's installed skill copy drifting // behind the version this CLI ships. The CLI is "stupid" — it only reads the // version marker stamped in each skill's `SKILL.md`, compares the workspace // copy against the bundled copy, and reports drift. Updating is never // automatic: the agent (which reads command output) sees the notice and runs // `moi skill update`. See `moi skill` in `cli.ts`. import { readdir } from 'node:fs/promises' import { join } from 'node:path' import type { WorkspaceSkillStatus, WorkspaceType } from '@/lib/types' import { findWorkspaceForPath, liftToWorkspaceRoot, listWorkspaces } from './registry' import { BUNDLED_SKILLS_DIR } from './skills-template' import { skillsDirFor } from './workspace-init' // The version is stamped as a self-contained marker in SKILL.md, e.g. // ``, rather than in YAML frontmatter. SKILL.md // frontmatter is NOT reliably strict YAML — the free-text `description` routinely // contains `: ` and other tokens that make real parsers reject the block — so a // controlled, unambiguous marker we own is both simpler and more robust than // scraping the frontmatter. Returns `null` when the file or marker is missing. const VERSION_MARKER = /]*\bversion="([^"]+)"/ export async function readSkillVersion(skillMdPath: string): Promise { const file = Bun.file(skillMdPath) if (!(await file.exists())) return null const m = (await file.text()).match(VERSION_MARKER) return m ? m[1] : null } // Bun.semver (built-in: `order` + `satisfies`) isn't in this repo's bun-types // yet, so reach it through a narrow typed view rather than `any`. const semver = ( Bun as unknown as { semver: { order(a: string, b: string): -1 | 0 | 1 satisfies(v: string, range: string): boolean } } ).semver function isValidVersion(v: string): boolean { try { semver.order(v, v) return true } catch { return false } } // Compare an installed version against the bundled one with Bun's semver. // `behind` = bundled is strictly newer; `minorBehind` = that gap is at least a // minor bump (patch gaps stay silent). `~installed` spans installed's whole // major.minor, so a bundled version outside it is a minor-or-major bump. A // missing/invalid installed counts as behind (legacy or broken stamp → prompt); // a missing/invalid bundled never does (don't nag on our own broken ship). function compare( installed: string | null, bundled: string | null ): { behind: boolean; minorBehind: boolean } { if (!bundled || !isValidVersion(bundled)) return { behind: false, minorBehind: false } if (!installed || !isValidVersion(installed)) return { behind: true, minorBehind: true } const behind = semver.order(installed, bundled) < 0 return { behind, minorBehind: behind && !semver.satisfies(bundled, `~${installed}`) } } // Bundled is a MINOR-or-major release ahead — surfaced to the agent. Patch bumps // stay silent and ride along on the next minor update. export const isMinorBehind = (installed: string | null, bundled: string | null): boolean => compare(installed, bundled).minorBehind // Any difference at all, patch included — decides whether `moi skill update` // would change anything, for status display. export const isBehind = (installed: string | null, bundled: string | null): boolean => compare(installed, bundled).behind // Skills shipped with this CLI — directory names under the bundled skills dir. export async function bundledSkillNames(): Promise { try { const entries = await readdir(BUNDLED_SKILLS_DIR, { withFileTypes: true }) return entries.filter(e => e.isDirectory()).map(e => e.name) } catch { return [] } } // Per-skill installed-vs-bundled versions for a workspace root. `type` picks // the installed-skills location (OpenClaw keeps them in `skills/`, not // `.claude/skills/`) — pass the registry entry's type when known. export async function skillStatuses( workspaceRoot: string, type?: WorkspaceType ): Promise { const names = await bundledSkillNames() const skillsDir = skillsDirFor(workspaceRoot, type) return Promise.all( names.map(async name => ({ name, bundled: await readSkillVersion(join(BUNDLED_SKILLS_DIR, name, 'SKILL.md')), installed: await readSkillVersion(join(skillsDir, name, 'SKILL.md')) })) ) } export type ResolvedWorkspace = { root: string; type?: WorkspaceType } // Resolve a path to its workspace root: the registered workspace that owns it // (carrying its backend type), else lifted out of any `.moi/`, else the path // itself. Mirrors how `moi bundle` resolves a target so `moi skill` works from // the same places. export async function resolveWorkspace(cwd: string): Promise { const ws = findWorkspaceForPath(await listWorkspaces(), cwd) if (ws) return { root: ws.path, type: ws.type } return { root: liftToWorkspaceRoot(cwd) } } export async function resolveWorkspaceRoot(cwd: string): Promise { return (await resolveWorkspace(cwd)).root } // One-line, agent-facing notice when any installed skill is a minor+ behind the // bundled version — else `null`. Other commands append it to their output so // the agent reading that output knows to run `moi skill update`. Never throws: // version checks must not break the command they ride on. export async function staleSkillNotice(cwd: string): Promise { try { const { root, type } = await resolveWorkspace(cwd) const stale = (await skillStatuses(root, type)).filter(s => isMinorBehind(s.installed, s.bundled) ) if (stale.length === 0) return null const parts = stale.map(s => `${s.name} (${s.installed ?? 'none'} → ${s.bundled})`) return `⚠ moi skill outdated: ${parts.join(', ')} — run \`moi skill update\` to refresh.` } catch { return null } }