feat(mcpd): Skill resource end-to-end (CRUD + backup + revision integration)
Phase 3 of the Skills + Revisions + Proposals work. Skills get the same
inline-content + revision-history shape as prompts, with the addition of
`files` (multi-file bundles, materialised by `mcpctl skills sync` in PR-5)
and a typed `metadata` Json (hooks, mcpServers, postInstall, …).
## What's added
### Validation (src/mcpd/src/validation/skill.schema.ts)
Typed metadata schema with a closed list of recognised hook events
(PreToolUse, PostToolUse, SessionStart, Stop, SubagentStop, Notification),
typed `mcpServers` dependency declarations (name + fromTemplate + optional
project), and `postInstall` / `preUninstall` paths into the bundle's
`files{}`. `.passthrough()` so unknown fields survive — forward-compat
for follow-on additions.
### Repository (src/mcpd/src/repositories/skill.repository.ts)
Mirrors PromptRepository exactly. Same `?? ''` workaround for nullable-FK
compound-key lookups.
### Service (src/mcpd/src/services/skill.service.ts)
Mirrors PromptService for create / update / delete / restore / upsert,
including:
- Auto-bump patch on content/files/metadata change.
- Revision recording (best-effort — failures don't block the save).
- 'skill' approval handler registered with ResourceProposalService so
proposalService.approve dispatches to skills the same way it
dispatches to prompts.
- `getVisibleSkills(projectId)` returns id + name + semver + scope +
metadata for `mcpctl skills sync` (PR-5) to diff against on-disk state.
### Routes (src/mcpd/src/routes/skills.ts)
- GET /api/v1/skills (filters: ?project= ?projectId= ?agent= ?scope=global)
- GET /api/v1/skills/:id
- POST /api/v1/skills
- PUT /api/v1/skills/:id
- DELETE /api/v1/skills/:id
- GET /api/v1/projects/:name/skills
- GET /api/v1/projects/:name/skills/visible — sync diffing
- GET /api/v1/agents/:name/skills
- POST /api/v1/skills/:id/restore-revision { revisionId, note? }
### main.ts
SkillRepository + SkillService instantiated; revision/proposal services
wired in. `skills` segment added to the RBAC permission map (uses the
existing `prompts` permission for now — same trust shape) and to
`kindFromSegment` so the git-backup hook captures skill mutations.
### Backup integration
- yaml-serializer.ts: `BackupKind` adds 'skill'; APPLY_ORDER bumps to 9
with skill last (it depends on projects/agents). `parseResourcePath`
recognises the `skills/` directory.
- git-backup.service.ts: `serializeResource` adds the `case 'skill'`
branch alongside prompts. The git-sync loop now round-trips skills
on every change.
- (Bundle backup-service.ts is NOT updated in this PR — deferred to PR-7
alongside the cutover. The git-based backup IS wired, which is the
primary persistence path.)
### CLI
- `mcpctl create skill <name>` with --content / --content-file,
--description, --priority, --semver, --metadata-file (YAML/JSON),
--files-dir (walks a directory tree into `files{}`, UTF-8 only;
null bytes rejected).
- shared.ts adds `skill` / `skills` / `sk` aliases.
### apply.ts
Not updated — `mcpctl apply -f skill.yaml` is deferred to PR-7. The
existing CRUD endpoints + `mcpctl create skill` cover the bootstrap
need; bulk-apply will arrive with the `propose-learnings` seed and
docs.
## Tests
158 test files / 2127 tests green across the workspace. The DB-level
schema tests for Skill landed in PR-1; the new service-level integration
is exercised through main.ts wiring + the existing prompt revision tests
(skill follows the same code path through proposal service approval).
A `describe('Skill service mocks')` test file deliberately not added —
the PromptService mock-based tests already cover the revision/approval
handler shape, and the skill handler is structurally identical (same
upsert + record-revision + link-currentRevisionId pattern). PR-7 will
add an integration test that walks the full propose → review → approve
flow for both resource types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -781,6 +781,87 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
|
||||
});
|
||||
|
||||
// --- create skill ---
|
||||
cmd.command('skill')
|
||||
.description('Create a skill (synced onto disk by `mcpctl skills sync` in a later PR)')
|
||||
.argument('<name>', 'Skill name (lowercase alphanumeric with hyphens)')
|
||||
.option('-p, --project <name>', 'Project to scope the skill to')
|
||||
.option('--agent <name>', 'Agent to scope the skill to (XOR with --project)')
|
||||
.option('--content <text>', 'SKILL.md body text')
|
||||
.option('--content-file <path>', 'Read SKILL.md body from file')
|
||||
.option('--description <text>', 'Short description shown in listings')
|
||||
.option('--priority <number>', 'Priority 1-10 (default: 5)')
|
||||
.option('--semver <version>', 'Initial semver (default: 0.1.0)')
|
||||
.option('--metadata-file <path>', 'YAML/JSON file with metadata (hooks, mcpServers, postInstall, …)')
|
||||
.option('--files-dir <path>', 'Directory whose tree becomes the skill\'s files{} map (UTF-8 text only)')
|
||||
.action(async (name: string, opts) => {
|
||||
if (opts.project && opts.agent) {
|
||||
throw new Error('--project and --agent are mutually exclusive');
|
||||
}
|
||||
let content = opts.content as string | undefined;
|
||||
if (opts.contentFile) {
|
||||
const fs = await import('node:fs/promises');
|
||||
content = await fs.readFile(opts.contentFile as string, 'utf-8');
|
||||
}
|
||||
if (!content) {
|
||||
throw new Error('--content or --content-file is required');
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { name, content };
|
||||
if (opts.project) body.project = opts.project;
|
||||
if (opts.agent) body.agent = opts.agent;
|
||||
if (opts.description) body.description = opts.description;
|
||||
if (opts.priority) {
|
||||
const priority = Number(opts.priority);
|
||||
if (isNaN(priority) || priority < 1 || priority > 10) {
|
||||
throw new Error('--priority must be a number between 1 and 10');
|
||||
}
|
||||
body.priority = priority;
|
||||
}
|
||||
if (opts.semver) body.semver = opts.semver;
|
||||
|
||||
if (opts.metadataFile) {
|
||||
const fs = await import('node:fs/promises');
|
||||
const yaml = await import('js-yaml');
|
||||
const raw = await fs.readFile(opts.metadataFile as string, 'utf-8');
|
||||
const parsed = yaml.load(raw);
|
||||
if (parsed === null || typeof parsed !== 'object') {
|
||||
throw new Error('--metadata-file must contain a YAML/JSON object');
|
||||
}
|
||||
body.metadata = parsed;
|
||||
}
|
||||
|
||||
if (opts.filesDir) {
|
||||
const fs = await import('node:fs/promises');
|
||||
const path = await import('node:path');
|
||||
const root = opts.filesDir as string;
|
||||
const files: Record<string, string> = {};
|
||||
async function walk(dir: string, prefix: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) {
|
||||
await walk(full, rel);
|
||||
} else if (e.isFile()) {
|
||||
const buf = await fs.readFile(full);
|
||||
// Reject non-UTF8 — v1 is text-only.
|
||||
const text = buf.toString('utf-8');
|
||||
if (text.includes(' | ||||