a plugin can ship affordances, and installing publishes them
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>
This commit is contained in:
@@ -15,6 +15,7 @@ import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } fr
|
||||
import { discoverPlugins } from './discover';
|
||||
import { fetchPlugin } from './fetch';
|
||||
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
||||
import { publishAffordances, unpublishAffordances } from '../affordances/publish';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
import { generatePluginSchemas, pushPluginSchema } from './schema';
|
||||
import { declaredTables, dropTables, existingTables } from './tables';
|
||||
@@ -327,6 +328,22 @@ export async function installPlugin(
|
||||
await step(steps, onStep, `assets: published to /plugins/${appName}/`);
|
||||
}
|
||||
|
||||
// What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at
|
||||
// runtime, and nothing else carries that across: install gitea and no agent learns that `tea` is
|
||||
// authenticated unless the plugin says so. Same shape as the assets above — a directory in the
|
||||
// repository, copied to a platform-managed place.
|
||||
//
|
||||
// Not fatal. An affordance that failed to copy costs an agent some knowledge; refusing the install
|
||||
// over it would cost the owner the plugin.
|
||||
try {
|
||||
const published = await publishAffordances(plugin);
|
||||
if (published) {
|
||||
await step(steps, onStep, `affordances: ${published} published to capabilities/affordances/`);
|
||||
}
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
|
||||
// offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
|
||||
if (plugin.web && forUserId !== undefined) {
|
||||
@@ -398,7 +415,11 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
|
||||
// The one thing uninstall IS allowed to delete: published assets are copies, and the originals are
|
||||
// still in the plugin's own directory. Nothing a user made is in there.
|
||||
await unpublishAssets(appName);
|
||||
await step(steps, onStep, 'tables and data: untouched (published assets removed)');
|
||||
// Same reasoning: a copy, with the original still in the plugin's directory. Removed so an agent stops
|
||||
// being told about a surface that is no longer there — an affordance for an uninstalled plugin is
|
||||
// worse than none, because it reads as true.
|
||||
if (plugin) await unpublishAffordances(plugin).catch(() => {});
|
||||
await step(steps, onStep, 'tables and data: untouched (published assets and affordances removed)');
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user