diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index abb28277..5f299a8b 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -293,6 +293,17 @@ if ! skip; then create_layout ok "layout in place under ${OFFICER_ROOT}" SUMMARY+=("Layout: ${OFFICER_ROOT} (data, dockers, capabilities)") + + # The affordances an agent needs to know about this platform. Not fatal: an + # install without them works, agents just go back to guessing what the client + # on the other end will render — which is what they did before this existed. + if capabilities_sync; then + ok "capabilities synced from ${CAPABILITIES_REPO}" + SUMMARY+=("Capabilities: synced") + else + warn "capabilities not synced — agents will have no affordances. Run 'git pull' in $(layout_items_dir), or press Update all on the marketplace screen." + SUMMARY+=("Capabilities: NOT synced") + fi step_ok fi diff --git a/scripts/setup/officer-setup/lib/layout.sh b/scripts/setup/officer-setup/lib/layout.sh index 9560b797..8ed7b6ce 100644 --- a/scripts/setup/officer-setup/lib/layout.sh +++ b/scripts/setup/officer-setup/lib/layout.sh @@ -61,3 +61,45 @@ layout_wrong_owner() { [[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir" done < <(layout_dirs) } + +# ----------------------------------------------------------------------------- +# The capabilities Officer ships +# ----------------------------------------------------------------------------- +# +# `capabilities/` is the file-based item store. The platform creates the six item +# directories at boot and writes `extensions/tool-loader` from its own source, so +# those are generated and not tracked anywhere. What IS tracked is the small set +# the platform ships — today one affordance, telling an agent that markdown +# images render and that the user's DEVICE fetches the URL. +# +# Without this a fresh install has no affordances at all, and every agent on it +# goes back to guessing what the client on the other end will do. +# +# Cloned rather than copied out of the platform repository, so it can be updated +# on its own — by `git pull` here, or by "Update all" on the marketplace screen. +CAPABILITIES_REPO="https://gitea.officer.dev/officerdev/capabilities.git" + +# Three states, because a directory that already exists is the normal one: the +# layout step created it a moment ago, and a re-run finds a checkout. +capabilities_sync() { + local dir; dir="$(layout_items_dir)" + + if [[ -d "$dir/.git" ]]; then + (cd "$dir" && git pull --ff-only --quiet origin main 2>/dev/null) || return 1 + return 0 + fi + + # An empty directory can be cloned into directly. A non-empty one cannot, and + # it is not empty precisely because the platform has already run and written + # its generated directories — so fetch into place instead of refusing, and let + # the owner's own untracked items sit alongside untouched. + if [[ -z "$(ls -A "$dir" 2>/dev/null)" ]]; then + git clone --quiet "$CAPABILITIES_REPO" "$dir" || return 1 + else + (cd "$dir" \ + && git init --quiet \ + && git remote add origin "$CAPABILITIES_REPO" 2>/dev/null \ + && git fetch --quiet origin main \ + && git checkout --quiet -B main FETCH_HEAD) || return 1 + fi +} diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 185c9425..e4a51bb9 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -15,7 +15,7 @@ import { uninstallPlugin, type PluginActionResult, } from '../../plugins/install'; -import { updatePlugin } from '../../plugins/update'; +import { updatePlugin, syncCapabilities } from '../../plugins/update'; // /api/plugins — what is on this machine, what is installed, and the four verbs that change it. // @@ -195,10 +195,21 @@ pluginsRouter.post('/update-all', async (ctx) => { for (const appName of installed) { results.push(await updatePlugin(appName, undefined, userId)); } + + // The capabilities repository rides along. It is the same question the button asks — "get me what has + // been published since I installed" — and a second, different gesture for it is one nobody remembers. + // On a machine installed before affordances existed there is no checkout at all, so this is also how it + // first arrives. + const capabilities = await syncCapabilities(); + return ctx.json({ results, + capabilities, updated: results.filter((r) => r.ok).length, - failed: results.filter((r) => !r.ok).map((r) => ({ appName: r.appName, error: r.error })), + failed: [ + ...results.filter((r) => !r.ok).map((r) => ({ appName: r.appName, error: r.error })), + ...(capabilities.ok ? [] : [{ appName: 'capabilities', error: capabilities.error }]), + ], }); }); diff --git a/src/servers/plugins/update.ts b/src/servers/plugins/update.ts index 81e8c7fe..3ac29602 100644 --- a/src/servers/plugins/update.ts +++ b/src/servers/plugins/update.ts @@ -1,6 +1,7 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { PLUGINS_DIR } from './discover'; +import { OFFICER_ITEMS_DIR, PLATFORM_DIR } from '../data-path'; import { installPlugin } from './install'; import type { OnStep, PluginActionResult } from './install'; import type { OnLog } from './run-logged'; @@ -109,3 +110,70 @@ export async function updatePlugin( const result = await installPlugin(appName, onStep, userId, onLog); return { ...result, steps: [...steps, ...result.steps] }; } + +// ── The capabilities repository ── +// +// `capabilities/` is the file-based item store, and most of what is in it belongs to the owner. A small +// part does not: the affordances the platform ships, which tell an agent what the client on the other +// end will do. Those live in `officerdev/capabilities` and are cloned by the setup script. +// +// Updated from the same button as the plugins, because it is the same question — "get me what has been +// published since I installed" — and because an owner who has to remember a second, different gesture for +// it will not remember it. +// +// The three states mirror `capabilities_sync` in the setup script: a checkout is pulled, an empty +// directory is cloned into, and a directory that already has the platform's generated items in it gets a +// repository initialised around them. That last case is the normal one on a machine installed before this +// existed. + +const CAPABILITIES_REPO = 'https://gitea.officer.dev/officerdev/capabilities.git'; + +export type CapabilitiesResult = { ok: boolean; action: 'pulled' | 'cloned' | 'adopted' | 'failed'; error?: string }; + +export async function syncCapabilities(onLog?: OnLog): Promise { + const dir = OFFICER_ITEMS_DIR; + if (!Bun.which('git')) return { ok: false, action: 'failed', error: 'git is not installed on this machine' }; + + try { + if (existsSync(join(dir, '.git'))) { + // Refused rather than merged, exactly as a plugin is: whatever diverged here is somebody's work. + const dirty = await git(dir, ['status', '--porcelain']); + if (dirty.out) { + // Untracked files are the ORDINARY state — the owner's own skills and tools live here, and the + // platform writes its generated directories in. Only tracked changes block a pull. + const tracked = dirty.out.split('\n').filter((l) => !l.startsWith('??')); + if (tracked.length) { + return { ok: false, action: 'failed', error: `capabilities has ${tracked.length} uncommitted change(s)` }; + } + } + const pulled = await git(dir, ['pull', '--ff-only', 'origin', 'main']); + if (pulled.code !== 0) return { ok: false, action: 'failed', error: pulled.err || 'git pull failed' }; + onLog?.('[capabilities] pulled'); + return { ok: true, action: 'pulled' }; + } + + if (!existsSync(dir) || readdirSync(dir).length === 0) { + const cloned = await git(PLATFORM_DIR, ['clone', '--quiet', CAPABILITIES_REPO, dir]); + if (cloned.code !== 0) return { ok: false, action: 'failed', error: cloned.err || 'git clone failed' }; + onLog?.('[capabilities] cloned'); + return { ok: true, action: 'cloned' }; + } + + for (const argv of [ + ['init', '--quiet'], + ['remote', 'add', 'origin', CAPABILITIES_REPO], + ['fetch', '--quiet', 'origin', 'main'], + ['checkout', '--quiet', '-B', 'main', 'FETCH_HEAD'], + ]) { + const r = await git(dir, argv); + // `remote add` fails when one is already there, which is not a problem worth stopping for. + if (r.code !== 0 && argv[0] !== 'remote') { + return { ok: false, action: 'failed', error: r.err || `git ${argv[0]} failed` }; + } + } + onLog?.('[capabilities] adopted the existing directory'); + return { ok: true, action: 'adopted' }; + } catch (err) { + return { ok: false, action: 'failed', error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx index 489e4559..7e892f39 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx @@ -50,6 +50,12 @@ export const PluginsList = () => { {updateAllResult.updated} updated {updateAllResult.failed.length ? `, ${updateAllResult.failed.length} failed` : ''} + {/* `cloned` and `adopted` are worth saying out loud: they mean this machine had no + capabilities checkout until now, so its agents had no affordances at all. `pulled` is + the ordinary case and does not need announcing. */} + {updateAllResult.capabilities?.ok && updateAllResult.capabilities.action !== 'pulled' + ? ` · capabilities ${updateAllResult.capabilities.action}` + : ''} {/* Named, with the reason. The expected failure is a dirty checkout, and "2 failed" without saying which is a message that sends someone to the terminal to find out. */} diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index 84501467..9338da98 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -78,6 +78,8 @@ export type PluginActionResult = { ok: boolean; appName: string; steps: string[] /** What `POST /plugins/update-all` answers with. Per-plugin, because one failing must not hide the rest. */ export type UpdateAllResult = { results: PluginActionResult[]; + /** The capabilities repository rides along — see the route. `adopted` means it had no checkout before. */ + capabilities?: { ok: boolean; action: 'pulled' | 'cloned' | 'adopted' | 'failed'; error?: string }; updated: number; failed: { appName: string; error?: string }[]; };