the inverse of an affordance, and deliberately so: affordance body always compiled in, description never sent skill description always compiled in, body read on demand an affordance is a fact about the surface — short, and every conversation needs it. a skill is reference material that is dead weight in a conversation about something else. so what reaches the prompt is the index: name, when to reach for it, and the path. measured on the first one: 608 characters in the prompt against 6,070 bytes left on disk. no protocol. progressive disclosure is normally a harness feature — claude code discovers .claude/skills, opencode has its own conventions, the next one will have a third — and pointing at a file needs none of that, because every harness worth running can already read a file. the cost is that the agent has to CHOOSE to open it, which is what the description is for and why a vague one makes a skill useless rather than merely verbose. the first skill is the email database, moved out of tools/email-db/TOOL.md where NOTHING has been reading it: the loader parses frontmatter and discards the body, so that schema and its eight queries have never reached any claude code session. written for pi, inert since pi left. it carries two things the schema alone gets wrong, both measured against the real 19,097-message database rather than copied from the old doc: `read` is populated on 93 rows, so the documented unread query returns essentially the whole mailbox; and `labels` is a comma-separated string with real values like `updates,inbox`, so `labels = 'inbox'` misses most of the inbox. plugins can ship skills, published and unpublished on install like affordances and notification catalogues. flat namespace unlike those two — a skill's directory name is the path an agent reads, and `officerdev/gitea/SKILL.md` in a prompt is noise. a skill has no authority to usurp, so nothing needs protecting. wired to claude code. opencode still gets NOTHING — its runner sends no system prompt at all, and session create takes only a directory. that needs its own channel and a guess would be worse than the gap.
53 lines
2.2 KiB
TypeScript
53 lines
2.2 KiB
TypeScript
import { cp, readdir, rm } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { SKILLS_DIR } from './load';
|
|
import type { DiscoveredPlugin } from '../plugins/manifest';
|
|
|
|
// Publishing a plugin's skills, the same shape as affordances and notification catalogues.
|
|
//
|
|
// Deliberately NOT namespaced by publisher, unlike those two. A skill's directory name is what appears in
|
|
// the index and what the agent reads a path to, and `officerdev/gitea/SKILL.md` in a prompt is noise for
|
|
// a human and for a model. Collisions are handled by the loader taking whichever sorts first, which is
|
|
// the same rule the items store has always used — one flat namespace, first come.
|
|
//
|
|
// That trade is only acceptable because a skill is REFERENCE. An affordance's namespace matters because a
|
|
// plugin must not be able to claim `officer/core:`; a skill has no authority to usurp.
|
|
|
|
export const skillDirFor = (plugin: DiscoveredPlugin): string => join(SKILLS_DIR, plugin.appName);
|
|
|
|
/** What the plugin ships, if anything. */
|
|
export const skillSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'skills');
|
|
|
|
/** Publish a plugin's skills. Returns the number of directories, or 0 when it ships none. */
|
|
export async function publishSkills(plugin: DiscoveredPlugin): Promise<number> {
|
|
const source = skillSourceFor(plugin);
|
|
if (!existsSync(source)) return 0;
|
|
|
|
await cp(source, SKILLS_DIR, { recursive: true, force: true });
|
|
|
|
try {
|
|
return (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop a plugin's skills.
|
|
*
|
|
* Removes only the directories THIS plugin shipped, read from its own source tree — not everything under
|
|
* `skills/`, which holds the platform's own and every other plugin's.
|
|
*/
|
|
export async function unpublishSkills(plugin: DiscoveredPlugin): Promise<void> {
|
|
const source = skillSourceFor(plugin);
|
|
if (!existsSync(source)) return;
|
|
let dirs: string[];
|
|
try {
|
|
dirs = (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const dir of dirs) await rm(join(SKILLS_DIR, dir), { recursive: true, force: true });
|
|
}
|