A plugin is a new SURFACE arriving at runtime, which is exactly the case hand-written prose could never cover: install gitea and no agent learns that `tea` is authenticated, or that plain `git` is the better tool for what git already does. The plugin knows; nothing carried it across. An `affordances/` directory in the plugin repository is copied to capabilities/affordances/<publisher>/<plugin>/ on install and removed on uninstall — the same shape as assets/ and publishAssets, deliberately, because two mechanisms for "the plugin ships some files" would drift and that one is already understood. Namespaced by publisher because two plugins named `gitea` from different publishers must not overwrite each other, and because an affordance in the tree should say whose it is without being opened. It mirrors mountPrefix. Uninstall removes the PLUGIN's directory, never the publisher's: every first-party plugin publishes under one officerdev/, and taking that level out because one was uninstalled would strip the rest silently. That is what the tests are mostly about — a copy going wrong is obvious, this would not be. Publishing is not fatal to an install. An affordance that failed to copy costs an agent some knowledge; refusing the install over it costs the owner the plugin. Verified against the real gitea plugin: published, the loader saw officerdev/gitea/gitea beside officer/core/image-rendering, uninstall removed it and left core alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
63 lines
2.8 KiB
TypeScript
63 lines
2.8 KiB
TypeScript
import { cp, readdir, rm } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { AFFORDANCES_DIR } from './load';
|
|
import type { DiscoveredPlugin } from '../plugins/manifest';
|
|
|
|
// Copying a plugin's affordances into the store the agent reads.
|
|
//
|
|
// A plugin is a new SURFACE arriving at runtime, which is the case hand-written prose could never cover:
|
|
// install gitea and no agent learns that `tea` is authenticated, or that `git` is the better tool for
|
|
// what git already does. The plugin knows; nothing was carrying that across.
|
|
//
|
|
// Same shape as `publishAssets` — a directory in the plugin repository, copied to a platform-managed
|
|
// place on install, removed on uninstall. Deliberately so: two mechanisms for "the plugin ships some
|
|
// files" would drift, and this one is already understood.
|
|
//
|
|
// ── Why the destination is namespaced ──
|
|
//
|
|
// `<publisher>/<appName>/`, mirroring `mountPrefix`. Two plugins called `gitea` from different publishers
|
|
// must not overwrite each other's affordances, and an affordance in the tree should say whose it is
|
|
// without opening it. `core` occupies the appName slot for the platform's own.
|
|
|
|
/** Where a plugin's affordances land. `officerdev/gitea/`, beside `officer/core/`. */
|
|
export const affordanceDirFor = (plugin: DiscoveredPlugin): string =>
|
|
join(AFFORDANCES_DIR, plugin.manifest.publisher, plugin.appName);
|
|
|
|
/** What the plugin ships, if anything. */
|
|
export const affordanceSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'affordances');
|
|
|
|
/**
|
|
* Publish a plugin's affordances. Returns the number of files, or 0 when it ships none — which is the
|
|
* ordinary case and not a failure.
|
|
*
|
|
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT
|
|
* remove files the plugin has since dropped; that is what uninstall is for, and deleting on every
|
|
* install would make a half-finished copy destructive.
|
|
*/
|
|
export async function publishAffordances(plugin: DiscoveredPlugin): Promise<number> {
|
|
const source = affordanceSourceFor(plugin);
|
|
if (!existsSync(source)) return 0;
|
|
|
|
const dest = affordanceDirFor(plugin);
|
|
await cp(source, dest, { recursive: true, force: true });
|
|
|
|
try {
|
|
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => f.endsWith('.md')).length;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop a plugin's affordances.
|
|
*
|
|
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
|
|
* plugin's, and taking it out because one was uninstalled would silently strip the rest.
|
|
*
|
|
* Safe to call when nothing was ever published.
|
|
*/
|
|
export async function unpublishAffordances(plugin: DiscoveredPlugin): Promise<void> {
|
|
await rm(affordanceDirFor(plugin), { recursive: true, force: true });
|
|
}
|