diff --git a/TODO.md b/TODO.md index 7a4a93e1..72d44d36 100644 --- a/TODO.md +++ b/TODO.md @@ -320,9 +320,16 @@ Still to build: - A bare `claude` run in a terminal on the host gets nothing either, since it never passes through `claude-manager`. Would need a generated file, which is the thing `user-instance.ts` refuses to write for the owner. -- Plugin contribution: an `affordances/` directory in the plugin repository, copied to - `capabilities/affordances///` on install and removed on uninstall. Same shape as - `assets/` and `publishAssets`. **Nothing does this yet** — gitea will be the first. +- ~~Plugin contribution~~ — built 2026-08-19 (`affordances/publish.ts`). An `affordances/` directory in + the plugin repository is copied to `capabilities/affordances///` on install and + removed on uninstall. Gitea is the first to ship one. +- **The other categories.** A plugin can contribute affordances and nothing else. Skills, tools, tasks + and processes are the obvious next ones and were deliberately left out — one mechanism, proven, before + five. When they land, `publishAffordances` generalises rather than gaining siblings. +- **The path uses `publisher` verbatim**, so gitea's is `officerdev/gitea` while the platform's own is + `officer/core`. Consistent with `mountPrefix`, which is the only thing allowed to branch on provenance, + but it does mean `officer` and `officerdev` both appear in the tree. Rename `core` to `officerdev/core` + or normalise first-party to `officer` — either is fine, neither is done. Open, and deliberately deferred: diff --git a/src/servers/affordances/publish.test.ts b/src/servers/affordances/publish.test.ts new file mode 100644 index 00000000..29ff22e8 --- /dev/null +++ b/src/servers/affordances/publish.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test, afterEach } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { affordanceDirFor, affordanceSourceFor, publishAffordances, unpublishAffordances } from './publish'; +import { AFFORDANCES_DIR } from './load'; +import type { DiscoveredPlugin } from '../plugins/manifest'; + +// The failure that matters is not a copy going wrong — it is uninstall taking somebody else's files with +// it. Every first-party plugin publishes under one `officerdev/` directory, so removing the wrong level +// would strip them all and nobody would notice until an agent stopped knowing something. + +const root = join(tmpdir(), `aff-pub-${process.pid}`); + +const plugin = (appName: string, publisher = 'officerdev'): DiscoveredPlugin => + ({ appName, dir: join(root, appName), manifest: { publisher } }) as DiscoveredPlugin; + +const withAffordance = (appName: string, publisher = 'officerdev') => { + const p = plugin(appName, publisher); + mkdirSync(affordanceSourceFor(p), { recursive: true }); + writeFileSync(join(affordanceSourceFor(p), `${appName}.md`), `---\nname: ${appName}\n---\n\nbody\n`); + return p; +}; + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + for (const pub of ['officerdev', 'alice']) rmSync(join(AFFORDANCES_DIR, pub), { recursive: true, force: true }); +}); + +describe('publishing', () => { + test('lands under /, mirroring mountPrefix', () => { + const p = withAffordance('test-gitea'); + expect(affordanceDirFor(p)).toBe(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea')); + }); + + test('copies what the plugin ships and counts it', async () => { + expect(await publishAffordances(withAffordance('test-gitea'))).toBe(1); + }); + + test('a plugin shipping none is not a failure — most ship none', async () => { + const bare = plugin('test-bare'); + mkdirSync(bare.dir, { recursive: true }); + expect(await publishAffordances(bare)).toBe(0); + }); + + test('re-publishing overwrites rather than erroring, so re-install works', async () => { + const p = withAffordance('test-gitea'); + expect(await publishAffordances(p)).toBe(1); + expect(await publishAffordances(p)).toBe(1); + }); + + test('two publishers with the same plugin name do not collide', async () => { + await publishAffordances(withAffordance('test-gitea', 'officerdev')); + await publishAffordances(withAffordance('test-gitea', 'alice')); + expect(existsSync(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea', 'test-gitea.md'))).toBe(true); + expect(existsSync(join(AFFORDANCES_DIR, 'alice', 'test-gitea', 'test-gitea.md'))).toBe(true); + }); +}); + +describe('unpublishing', () => { + test('removes the plugin', async () => { + const p = withAffordance('test-gitea'); + await publishAffordances(p); + await unpublishAffordances(p); + expect(existsSync(affordanceDirFor(p))).toBe(false); + }); + + test('leaves the PUBLISHER alone — the thing that would silently strip every sibling', async () => { + const a = withAffordance('test-gitea'); + const b = withAffordance('test-music'); + await publishAffordances(a); + await publishAffordances(b); + await unpublishAffordances(a); + expect(existsSync(affordanceDirFor(b))).toBe(true); + }); + + test('is safe when nothing was ever published', async () => { + await unpublishAffordances(plugin('test-never')); + }); +}); diff --git a/src/servers/affordances/publish.ts b/src/servers/affordances/publish.ts new file mode 100644 index 00000000..5755307d --- /dev/null +++ b/src/servers/affordances/publish.ts @@ -0,0 +1,62 @@ +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 }); +} diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index a63b4f49..ffc2d61a 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -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 }; }