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 ── // // `//`, 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 { 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 { await rm(affordanceDirFor(plugin), { recursive: true, force: true }); }