ship the capabilities repository, and update it from the same button as plugins

capabilities/ is the file-based item store, and most of it belongs to the
owner: the six item directories are created by the platform at boot and
extensions/tool-loader is written from platform source, so neither is 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, which it cannot discover from the filesystem.

That now lives in officerdev/capabilities, public so a fresh install can clone
it without credentials.

Two ways in. The setup script syncs it during the layout step — non-fatal, and
it says what an install without it costs rather than failing: agents go back to
guessing, which is what they did before this existed. And "Update all" on the
marketplace screen carries it alongside the plugins, because it is the same
question — get me what has been published since I installed — and a second
different gesture for it is one nobody remembers.

Three states in both places, and the third is the normal one on an existing
machine: a checkout is pulled, an empty directory is cloned into, and a
directory that already holds the platform's generated items gets a repository
initialised around them. UNTRACKED files never block a pull, because the
owner's own skills and tools living here is the ordinary state — only tracked
changes do.

`cloned` and `adopted` are reported in the UI where `pulled` is not: they mean
this machine had no affordances at all until the button was pressed.

Verified against the real checkout: sync reports pulled, and the loader finds
officer/core/image-rendering afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 07:34:30 +00:00
co-authored by Claude Opus 5
parent 061e213780
commit 4b2c896523
6 changed files with 143 additions and 3 deletions
+11
View File
@@ -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
+42
View File
@@ -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
}
+13 -2
View File
@@ -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 }]),
],
});
});
+69 -1
View File
@@ -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<CapabilitiesResult> {
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) };
}
}
@@ -50,6 +50,12 @@ export const PluginsList = () => {
<span className="text-duck-dark/60">
{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}`
: ''}
</span>
{/* 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. */}
@@ -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 }[];
};