shortcuts.ts is data with no component around it, so it carries catalog keys and ShortcutsModal resolves them -- same shape as the settings sections. gen-platform-skill.ts also reads this registry to generate the agent-facing SKILL.md, which docs:check validates. That doc stays in one language, so the generator resolves against en-GB directly and throws if a key is missing rather than emitting a dot-path into a file an agent will quote at somebody. The regenerated skill is byte-identical, which is the check that keying the registry changed nothing an agent reads.
168 lines
8.2 KiB
TypeScript
168 lines
8.2 KiB
TypeScript
/*
|
||
Generates the PLATFORM skill — the reference an agent opens when a user asks what the platform can do.
|
||
|
||
── Why generated ──
|
||
|
||
Because a hand-written one rots, and we measured it. Spotlight's settings list was hand-written and drifted
|
||
within two days: nine rows against twenty-one sections, and a route that does not exist. `CLAUDE.md` claimed
|
||
27 permission entries against 18, and twenty PM2 processes against thirteen, both for over a week.
|
||
|
||
A skill is worse than a doc when it is wrong, because an agent quotes it with confidence and the user has no
|
||
way to tell. So the parts a registry can answer are derived here, at generation time, from the registry —
|
||
and the prose is limited to what no registry can express.
|
||
|
||
── The split, and where the line is ──
|
||
|
||
GENERATED permissions: key, label, kind, api prefixes, sockets, frontend routes
|
||
keyboard shortcuts
|
||
PROSE what the platform IS, what a permission KIND means for a user, where to look next
|
||
|
||
Run `bun run skills:platform`. `bun run docs:check` regenerates into memory and fails if the committed file
|
||
differs, so a stale skill is a failed check rather than a confident wrong answer.
|
||
*/
|
||
|
||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
||
import { join, resolve } from 'node:path';
|
||
|
||
const ROOT = resolve(import.meta.dir, '..');
|
||
export const SKILL_PATH = join(ROOT, '..', 'capabilities', 'skills', 'platform', 'SKILL.md');
|
||
|
||
const KIND_MEANING: Record<string, string> = {
|
||
core: 'every account has it and it cannot be denied — the caller acting on themselves',
|
||
app: 'grantable per role, at read or write',
|
||
confined: 'grantable, but only to an account that has its own Linux user; without one the grant resolves to nothing',
|
||
execution: 'runs as the server owner in the owner’s home. NEVER grantable, at any level, structurally',
|
||
admin: 'the server owner only',
|
||
};
|
||
|
||
export async function renderPlatformSkill(): Promise<string> {
|
||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||
const { SHORTCUT_SECTIONS } = await import('../src/apps/officer-web/state/shortcuts');
|
||
const enCatalog = (await import('../src/apps/officer-web/locales/en-GB.json')).default as Record<string, unknown>;
|
||
/** Resolve a dot-path against the English catalog. This doc is for agents and stays in one language. */
|
||
const enString = (key: string): string => {
|
||
const value = key.split('.').reduce<unknown>((node, part) => {
|
||
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
|
||
return undefined;
|
||
}, enCatalog);
|
||
if (typeof value !== 'string') throw new Error(`gen-platform-skill: no English string for "${key}"`);
|
||
return value;
|
||
};
|
||
|
||
const byKind = new Map<string, typeof PERMISSIONS>();
|
||
for (const p of PERMISSIONS) {
|
||
const list = byKind.get(p.kind) ?? [];
|
||
list.push(p);
|
||
byKind.set(p.kind, list as typeof PERMISSIONS);
|
||
}
|
||
|
||
const lines: string[] = [];
|
||
const out = (s = '') => lines.push(s);
|
||
|
||
out('---');
|
||
out('name: Officer platform');
|
||
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
|
||
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
|
||
out('description: >-');
|
||
out(' Use when the user asks what Officer can do, how to use a feature, what an endpoint is, why something is');
|
||
out(' refused, or how permissions and accounts work — "how do I start a commander", "can a member see my files",');
|
||
out(' "what is the API for tasks". Carries the permission registry, every API prefix and socket, the frontend');
|
||
out(' routes and the keyboard shortcuts, derived from the code rather than written by hand.');
|
||
out('version: 1');
|
||
out('---');
|
||
out();
|
||
out('<!--');
|
||
out(' GENERATED by `bun run skills:platform`. Do not edit by hand — `bun run docs:check` fails if this file');
|
||
out(' differs from what the generator produces, and an edit here is lost on the next run.');
|
||
out();
|
||
out(' The prose lives in scripts/gen-platform-skill.ts, beside the generation, so the two cannot separate.');
|
||
out('-->');
|
||
out();
|
||
out('# What Officer is');
|
||
out();
|
||
out('One self-hosted app for one person — the **owner** — that also admits additional accounts holding a');
|
||
out('strict subset of it. The owner is user id 1, role `Super Admin`, and bypasses every permission check.');
|
||
out('Everyone else gets what their ROLE is granted, and nothing else: grants are keyed on role, never on a');
|
||
out('user, and **absence denies** — there is no row meaning "no".');
|
||
out();
|
||
out('So "can this account do X" has three possible answers, and the third is the one people miss: yes,');
|
||
out('no, and *not shareable at all*.');
|
||
out();
|
||
out('# Permissions');
|
||
out();
|
||
out('The authority for this is `src/servers/permissions/registry.ts`. Mounting an API router without an');
|
||
out('entry there makes the server **refuse to boot** — so this list is complete by construction, not by');
|
||
out('diligence.');
|
||
out();
|
||
for (const [kind, meaning] of Object.entries(KIND_MEANING)) {
|
||
const entries = byKind.get(kind) ?? [];
|
||
if (!entries.length) continue;
|
||
out(`## \`${kind}\` — ${meaning}`);
|
||
out();
|
||
for (const p of entries) {
|
||
const bits: string[] = [];
|
||
if (p.api?.length) bits.push(`API \`${p.api.join('`, `')}\``);
|
||
if (p.ws?.length) bits.push(`socket \`${p.ws.join('`, `')}\``);
|
||
if (p.routes?.length) bits.push(`screen \`${p.routes.join('`, `')}\``);
|
||
out(`- **${p.label}** (\`${p.key}\`) — ${p.description}`);
|
||
if (bits.length) out(` - ${bits.join(' · ')}`);
|
||
if (p.selfService?.length) {
|
||
out(` - acts on the caller themselves, allowed with no grant: \`${p.selfService.join('`, `')}\``);
|
||
}
|
||
if (p.ownerOnly?.length) out(` - owner only, even when granted: \`${p.ownerOnly.join('`, `')}\``);
|
||
if (p.readOnlyWrites?.length) {
|
||
out(` - a POST that is really a read, allowed at read level: \`${p.readOnlyWrites.join('`, `')}\``);
|
||
}
|
||
}
|
||
out();
|
||
}
|
||
|
||
out('# Keyboard shortcuts');
|
||
out();
|
||
out('Derived from `src/apps/officer-web/state/shortcuts.ts`. These have moved several times; quote this');
|
||
out('list rather than remembering one.');
|
||
out();
|
||
// The registry holds catalog keys since the shell was localized; this doc is agent-facing and stays
|
||
// English, so it resolves against en-GB rather than the reader's locale.
|
||
for (const section of SHORTCUT_SECTIONS) {
|
||
out(`## ${enString(section.titleKey)}`);
|
||
if (section.noteKey) {
|
||
out();
|
||
out(enString(section.noteKey));
|
||
}
|
||
out();
|
||
for (const s of section.shortcuts) out(`- \`${s.keys}\` — ${enString(s.descriptionKey)}`);
|
||
out();
|
||
}
|
||
|
||
out('# What is NOT in here');
|
||
out();
|
||
out('- **Installed plugins.** They vary per machine and per install, so a generated list would be wrong on');
|
||
out(' every other server. Ask the platform: `GET /api/plugins` for what is installed here.');
|
||
out('- **Request and response shapes.** This says which prefix a permission covers, not what a route');
|
||
out(' accepts. Read the router under `src/servers/api/<feature>/` when the shape matters.');
|
||
out('- **Anything about a commander beyond its permission.** That has its own skill — open it for what a');
|
||
out(' commander is, how one is started, and how to help somebody shape one.');
|
||
out();
|
||
out('# When you are about to guess');
|
||
out();
|
||
out('Do not. This file is generated from the registry, so a question it does not answer is a question the');
|
||
out('registry does not answer either — say so, and read the code. Every documented count in this repository');
|
||
out('that was maintained by hand has been wrong, and an agent quoting a stale number sounds exactly as');
|
||
out('certain as one quoting a true one.');
|
||
|
||
return `${lines.join('\n')}\n`;
|
||
}
|
||
|
||
if (import.meta.main) {
|
||
const body = await renderPlatformSkill();
|
||
mkdirSync(join(SKILL_PATH, '..'), { recursive: true });
|
||
const existing = existsSync(SKILL_PATH) ? readFileSync(SKILL_PATH, 'utf-8') : null;
|
||
if (existing === body) {
|
||
console.log('skills:platform — already current');
|
||
} else {
|
||
writeFileSync(SKILL_PATH, body);
|
||
console.log(`skills:platform — wrote ${SKILL_PATH} (${body.split('\n').length} lines)`);
|
||
}
|
||
}
|