From 94e4821b8d2199ee8737b026fd4ca9cbb28eb66c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 22 Sep 2026 14:47:02 +0000 Subject: [PATCH] feat(omarchy): the herd on the Omarchy bar (PRD 0017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moshcode omarchy` — status, validate, install, doctor — plus the QML bar widget and panel the snapshot feeds. status is the one command the plugin runs: agents, counts, burn windows, fleets and alerts in a single process, read-only, with the cost half cached (5s, or a minute when a reading was slow) and a partial snapshot rather than a failure when cost throws. The herd and cost module graphs load lazily, so validate/doctor/install never pay for them. The plugin is deliberately small: one Process at a time, no shell, guarded parses, theme-driven colours, and no writes at all. It runs unsandboxed in Omarchy's shared Quickshell process, where a leak or a throw is everyone's bar, not just ours. Measured during implementation and fed back into the PRD: a moshcode process costs 0.66-4.2s on a loaded box, almost all of it node and CLI start-up, so the poll is 10s closed / 3s open rather than the 5s/1s the PRD first claimed, and the success metric now bounds the snapshot's own work instead of wall clock. PRD 0017 moves Draft -> Accepted. Not verified: anything QML. This box is Ubuntu with no omarchy, omarchy-shell or qmllint, so R13 (qmllint, omarchy plugin validate, a real bar) and the marketplace submission are still open. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + bin/moshcode.mjs | 8 + omarchy/BarWidget.qml | 185 ++++++++ omarchy/LICENSE | 21 + omarchy/Model.js | 118 +++++ omarchy/Panel.qml | 173 ++++++++ omarchy/README.md | 103 +++++ omarchy/manifest.json | 20 + package.json | 1 + prd/0017-moshcode-on-the-omarchy-bar.md | 14 +- src/cli-schema.mjs | 37 ++ src/commands.mjs | 1 + src/omarchy.mjs | 565 ++++++++++++++++++++++++ test/omarchy.test.mjs | 289 ++++++++++++ 14 files changed, 1530 insertions(+), 6 deletions(-) create mode 100644 omarchy/BarWidget.qml create mode 100644 omarchy/LICENSE create mode 100644 omarchy/Model.js create mode 100644 omarchy/Panel.qml create mode 100644 omarchy/README.md create mode 100644 omarchy/manifest.json create mode 100644 src/omarchy.mjs create mode 100644 test/omarchy.test.mjs diff --git a/README.md b/README.md index 4c3eb49f..362757b3 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ or miss one that does. A test fails the build when it drifts. | `moshcode start` | engines | launch an engine with its native defaults | | `moshcode herd` | runtime | run agent sessions that outlive this terminal | | `moshcode swarm` | runtime | one task, a herd of agents, one answer — plan, fan out, verify, synthesise (PRD 0015) | +| `moshcode omarchy` | runtime | the herd on the Omarchy bar: the snapshot a QML widget polls, and the plugin around it (PRD 0017) | | `moshcode fleet` | runtime | the OpenFleet sysop tool: open a fleet, cap it, see the tree, stop a swarm, read the ledger (PRD 0016) | | `moshcode ps` | runtime | list herd sessions and what each one is doing | | `moshcode cost`
`usage` | runtime | what each session is spending, read from the engines' own logs | diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 61f04270..9c50c603 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -370,6 +370,14 @@ async function main() { process.exitCode = (await fleetCommand(rest)) || 0; return; } + // The Omarchy bar (PRD 0017). `status` is the snapshot a QML widget polls; + // the rest is what makes the plugin shippable from a box that is not Omarchy. + // Lazy like swarm and fleet: a plain launch never reads a manifest. + if (cmd === "omarchy") { + const { omarchyCommand } = await import("../src/omarchy.mjs"); + process.exitCode = (await omarchyCommand(rest)) || 0; + return; + } if (["ps", "attach", "kill", "wait", "restore", "cost", "usage"].includes(cmd)) { process.exitCode = (await herdCommand([cmd === "usage" ? "cost" : cmd, ...rest])) || 0; return; diff --git a/omarchy/BarWidget.qml b/omarchy/BarWidget.qml new file mode 100644 index 00000000..8174dd50 --- /dev/null +++ b/omarchy/BarWidget.qml @@ -0,0 +1,185 @@ +// The bar item: one line that is always on screen (PRD 0017). +// +// It polls exactly one command, one at a time, and renders whatever comes back. +// It does not compute fleet state, it does not write anything, and it does not +// start a second process while the first is running. That restraint is the +// whole design: this runs unsandboxed inside Omarchy's shared, long-running +// Quickshell process, so a leaked process or an unguarded parse here is not a +// moshcode bug, it is everyone's bar falling over. +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Ui +import qs.Commons + +import "Model.js" as Model + +Item { + id: root + + // What the shell expects of a bar widget that owns a panel. + property bool opened: false + property bool popoutSwitchClosing: false + + // Tunables, all of them conservative. + // + // A poll is a `moshcode` process: node's own start-up plus the CLI's, which + // measured 0.66s to 4.2s on a developer box that was busy running the very + // agents this widget reports on. That is the floor, it is not work this + // plugin can avoid, and it is why the closed-panel poll is ten seconds and + // not one. The fast poll only runs while the panel is open, because that is + // the only time anyone is reading numbers that move. + property int idleIntervalMs: 10000 + property int openIntervalMs: 3000 + property int backoffIntervalMs: 60000 + property int staleAfterMs: 30000 + property real moneyFloor: 1.0 + property int schema: 1 + + property var snapshot: null + property int failures: 0 + property bool everRan: false + + readonly property string widgetState: { + if (!everRan) return "unknown"; + if (!snapshot) return Model.UNAVAILABLE; + return Model.state(snapshot); + } + readonly property bool isStale: snapshot ? Model.stale(snapshot, Date.now(), staleAfterMs) : false + + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + + // The theme decides the colours. A hard-coded colour is a widget that looks + // wrong on somebody else's theme, which is most people. + readonly property color fg: root.barForeground !== undefined ? root.barForeground : "white" + readonly property color accent: (typeof Colors !== "undefined" && Colors.accent !== undefined) ? Colors.accent : fg + + Process { + id: poll + command: ["moshcode", "omarchy", "status", "--json"] + running: false + + stdout: StdioCollector { + onStreamFinished: { + var parsed = Model.parse(this.text); + root.everRan = true; + if (parsed && Model.supported(parsed, root.schema)) { + root.snapshot = parsed; + root.failures = 0; + } else if (parsed) { + // A newer snapshot than this plugin knows. Say so; do not + // guess at fields that may have moved. + root.snapshot = { unavailable: true, reason: "snapshot schema " + parsed.schema + " is newer than this plugin" }; + root.failures = 0; + } else { + root.failures += 1; + if (root.failures >= 3) root.snapshot = { unavailable: true, reason: "moshcode omarchy status returned nothing readable" }; + } + timer.interval = root.pollInterval(); + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.everRan = true; + root.failures += 1; + if (root.failures >= 3) { + root.snapshot = { unavailable: true, reason: "moshcode is not installed, or is older than 0.104" }; + } + timer.interval = root.pollInterval(); + } + } + } + + function pollInterval() { + if (failures >= 3) return backoffIntervalMs; + return opened ? openIntervalMs : idleIntervalMs; + } + + // One process at a time. `poll.running` is the guard, not a mutex we keep + // ourselves, so a slow snapshot delays the next tick instead of stacking + // processes behind it. + Timer { + id: timer + interval: root.idleIntervalMs + repeat: true + running: true + triggeredOnStart: true + onTriggered: { + if (!poll.running) poll.running = true; + } + } + + onOpenedChanged: timer.interval = root.pollInterval() + + Row { + id: row + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + Rectangle { + width: 8 + height: 8 + radius: 4 + anchors.verticalCenter: parent.verticalCenter + color: root.widgetState === Model.BLOCKED ? root.accent : root.fg + opacity: { + if (root.widgetState === Model.UNAVAILABLE) return 0.35; + if (root.widgetState === Model.IDLE) return 0.5; + if (root.isStale) return 0.5; + return 1.0; + } + + // The blocked state is the reason this plugin exists, so it is the + // one thing on the bar that moves. + SequentialAnimation on opacity { + running: root.widgetState === Model.BLOCKED + loops: Animation.Infinite + NumberAnimation { to: 0.35; duration: 900 } + NumberAnimation { to: 1.0; duration: 900 } + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.everRan ? Model.label(root.snapshot, root.moneyFloor) : "moshcode …" + color: root.widgetState === Model.BLOCKED ? root.accent : root.fg + opacity: root.isStale ? 0.5 : 1.0 + font.family: root.bar !== undefined && root.bar.fontFamily !== undefined ? root.bar.fontFamily : undefined + font.pixelSize: root.bar !== undefined && root.bar.fontPixelSize !== undefined ? root.bar.fontPixelSize : 13 + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: root.toggle() + } + + Loader { + id: panelLoader + active: false + source: "Panel.qml" + onLoaded: { + item.widget = root; + item.open(); + } + } + + function open() { + if (!panelLoader.active) { panelLoader.active = true; return; } + if (panelLoader.item) panelLoader.item.open(); + opened = true; + } + + function close() { + if (panelLoader.item) panelLoader.item.close(); + opened = false; + } + + function toggle() { + if (opened) close(); + else open(); + } +} diff --git a/omarchy/LICENSE b/omarchy/LICENSE new file mode 100644 index 00000000..dd4f1f73 --- /dev/null +++ b/omarchy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Profullstack, Inc. (dba moshcoding) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/omarchy/Model.js b/omarchy/Model.js new file mode 100644 index 00000000..f8c49dbb --- /dev/null +++ b/omarchy/Model.js @@ -0,0 +1,118 @@ +// Shaping and formatting for the widget and the panel (PRD 0017). +// +// .pragma library: one copy, no QML context, no access to anything. Everything +// here is a pure function of the snapshot `moshcode omarchy status --json` +// printed, so the two surfaces cannot drift on what "blocked" or "$/h" means. +.pragma library + +// The four states the widget has. Blocked outranks everything: it is the only +// one a human can clear, and the only one where the machine is spending a pane +// and a context window on nothing at all. +var UNAVAILABLE = "unavailable"; +var BLOCKED = "blocked"; +var BUSY = "busy"; +var IDLE = "idle"; + +function parse(text) { + // A bad parse is a state, not an exception. Anything thrown from here ends + // up in Omarchy's shared shell process, which is everyone's bar. + try { + var snap = JSON.parse(text); + if (!snap || typeof snap !== "object") return null; + return snap; + } catch (e) { + return null; + } +} + +// The plugin renders a schema it knows and says so about anything newer, +// rather than reading fields that may have moved underneath it. +function supported(snap, schema) { + return !!snap && snap.schema === schema; +} + +function counts(snap) { + var c = (snap && snap.counts) || {}; + return { + live: c.live || 0, + working: c.working || 0, + blocked: c.blocked || 0, + idle: c.idle || 0, + done: c.done || 0 + }; +} + +function window(snap, key) { + var rows = (snap && snap.burn) || []; + for (var i = 0; i < rows.length; i++) { + if (rows[i] && rows[i].key === key) return rows[i]; + } + return null; +} + +function perHour(snap) { + var row = window(snap, "1h"); + return row && row.perHour !== null && row.perHour !== undefined ? row.perHour : null; +} + +function state(snap, staleMs) { + if (!snap) return UNAVAILABLE; + if (snap.unavailable) return UNAVAILABLE; + var c = counts(snap); + if (c.blocked > 0) return BLOCKED; + if (c.working > 0 || c.live > 0) return BUSY; + return IDLE; +} + +function stale(snap, now, staleMs) { + if (!snap || !snap.generatedAt) return false; + var at = Date.parse(snap.generatedAt); + if (isNaN(at)) return false; + return (now - at) > staleMs; +} + +function money(value) { + if (value === null || value === undefined) return ""; + if (value >= 100) return "$" + Math.round(value); + if (value >= 10) return "$" + value.toFixed(1); + return "$" + value.toFixed(2); +} + +// One line, read at a glance, in the order you care about it: what needs you, +// then what is running, then what it costs. +function label(snap, floor) { + if (!snap) return "moshcode ?"; + if (snap.unavailable) return "moshcode —"; + var c = counts(snap); + var parts = []; + if (c.blocked > 0) parts.push(c.blocked + " blocked"); + parts.push(c.live + " agent" + (c.live === 1 ? "" : "s")); + var rate = perHour(snap); + if (rate !== null && rate >= floor) parts.push(money(rate) + "/h"); + return parts.join(" · "); +} + +function age(ms) { + if (ms === null || ms === undefined) return ""; + var s = Math.round(ms / 1000); + if (s < 60) return s + "s"; + var m = Math.round(s / 60); + if (m < 60) return m + "m"; + var h = Math.floor(m / 60); + return h + "h" + (m % 60) + "m"; +} + +// The cwd matters for telling two `claude` sessions apart, and the head of it +// never does. Keep the tail. +function tail(cwd, keep) { + if (!cwd) return ""; + var parts = String(cwd).split("/").filter(function (p) { return p.length > 0; }); + if (parts.length <= keep) return cwd; + return "…/" + parts.slice(parts.length - keep).join("/"); +} + +function agentLine(agent) { + var bits = [agent.name, agent.engine, agent.state]; + if (agent.blockedOn) bits[2] = agent.state + ":" + agent.blockedOn; + return bits.join(" "); +} diff --git a/omarchy/Panel.qml b/omarchy/Panel.qml new file mode 100644 index 00000000..40902073 --- /dev/null +++ b/omarchy/Panel.qml @@ -0,0 +1,173 @@ +// The panel: the list you open when the bar item says something changed. +// +// A list, not a dashboard. The point is to read it in two seconds and then +// either go to a terminal or forget about it. It shows the same fields, in the +// same order, with the same words as `moshcode ps` and `moshcode cost`, so +// nobody has to learn a second vocabulary for the same fleet. +// +// Read-only, on purpose (PRD 0017 R8). Attaching and stopping are writes from +// an unsandboxed process; the version that only looks has to be boring in the +// wild first. +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Ui +import qs.Commons + +import "Model.js" as Model + +KeyboardPanel { + id: panel + + // Set by the bar widget when it loads this. Everything rendered here comes + // from its snapshot; the panel never polls on its own. + property var widget: null + readonly property var snapshot: widget ? widget.snapshot : null + readonly property var counts: snapshot ? Model.counts(snapshot) : ({ live: 0, working: 0, blocked: 0, idle: 0, done: 0 }) + + readonly property color fg: widget && widget.fg !== undefined ? widget.fg : "white" + readonly property color accent: widget && widget.accent !== undefined ? widget.accent : fg + + function open() { panel.visible = true; if (widget) widget.opened = true; } + function close() { panel.visible = false; if (widget) widget.opened = false; } + + PanelKeyCatcher { + anchors.fill: parent + onEscape: panel.close() + } + + ColumnLayout { + id: body + anchors.margins: 14 + anchors.fill: parent + spacing: 10 + + Text { + text: { + if (!snapshot) return "moshcode is not answering"; + if (snapshot.unavailable) return snapshot.reason || "unavailable"; + return counts.live + " live · " + counts.working + " working · " + counts.blocked + " blocked · " + counts.idle + " idle"; + } + color: counts.blocked > 0 ? panel.accent : panel.fg + font.bold: true + } + + // Blocked first and always: an agent that asked a question twenty + // minutes ago is the only row here anyone has to act on. + Repeater { + model: snapshot && snapshot.alerts ? snapshot.alerts : [] + delegate: Text { + required property var modelData + text: "⚠ " + modelData.subject + " is " + modelData.kind + " — " + Model.age(modelData.ageMs) + color: panel.accent + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.fg; opacity: 0.15 } + + Repeater { + model: snapshot && snapshot.agents ? snapshot.agents : [] + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: 10 + + Text { + text: modelData.name + color: panel.fg + font.bold: modelData.state === "blocked" + Layout.preferredWidth: 110 + elide: Text.ElideRight + } + Text { + text: modelData.blockedOn ? modelData.state + ":" + modelData.blockedOn : modelData.state + color: modelData.state === "blocked" ? panel.accent : panel.fg + opacity: modelData.alive ? 1.0 : 0.5 + Layout.preferredWidth: 110 + } + Text { + text: modelData.engine + (modelData.approvals === "bypass" ? " ⚡" : "") + color: panel.fg + opacity: 0.8 + Layout.preferredWidth: 90 + } + Text { + text: modelData.swarm ? modelData.swarm : (modelData.fleet ? modelData.fleet : "") + color: panel.fg + opacity: 0.6 + Layout.preferredWidth: 130 + elide: Text.ElideMiddle + } + Text { + text: Model.age(modelData.ageMs) + color: panel.fg + opacity: 0.6 + Layout.preferredWidth: 60 + } + Text { + text: Model.tail(modelData.cwd, 2) + color: panel.fg + opacity: 0.6 + Layout.fillWidth: true + elide: Text.ElideMiddle + } + } + } + + Text { + visible: snapshot && snapshot.agents && snapshot.agents.length === 0 + text: "nothing running — moshcode start claude" + color: panel.fg + opacity: 0.6 + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.fg; opacity: 0.15 } + + Repeater { + model: snapshot && snapshot.burn ? snapshot.burn : [] + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: 10 + Text { text: modelData.label; color: panel.fg; opacity: 0.8; Layout.preferredWidth: 110 } + Text { text: Model.money(modelData.cost); color: panel.fg; Layout.preferredWidth: 90 } + Text { + text: modelData.perHour !== null ? Model.money(modelData.perHour) + "/h" : "" + color: panel.fg + opacity: 0.8 + Layout.preferredWidth: 90 + } + Text { + text: modelData.runs + " run" + (modelData.runs === 1 ? "" : "s") + color: panel.fg + opacity: 0.6 + Layout.fillWidth: true + } + } + } + + // An engine that logs nothing priceable is not free, and a bar that + // renders it as $0 is lying quietly. + Text { + visible: snapshot && snapshot.unpriced && snapshot.unpriced.length > 0 + text: snapshot && snapshot.unpriced ? "no rate for " + snapshot.unpriced.join(", ") + " — those tokens count toward nothing" : "" + color: panel.fg + opacity: 0.6 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Text { + text: { + if (!snapshot || !snapshot.generatedAt) return ""; + var bits = ["read " + Model.age(Date.now() - Date.parse(snapshot.generatedAt)) + " ago"]; + if (snapshot.burnCached) bits.push("cost cached"); + if (snapshot.burnSlow) bits.push("cost is slow here"); + if (snapshot.partial) bits.push("partial: " + snapshot.error); + return bits.join(" · "); + } + color: panel.fg + opacity: 0.45 + } + } +} diff --git a/omarchy/README.md b/omarchy/README.md new file mode 100644 index 00000000..9b9d3f49 --- /dev/null +++ b/omarchy/README.md @@ -0,0 +1,103 @@ +# Moshcode Herd — an Omarchy bar widget + +The agents you have running, the ones that are blocked waiting on you, and what +the last hour cost. On the bar, so you stop finding out by typing. + +``` +● 1 blocked · 4 agents · $38.20/h +``` + +Click it for the list: every session with its engine, state, swarm, age and +directory, then the burn over the last minute, fifteen minutes and hour. + +## Why a bar widget + +Everything [moshcode](https://github.com/moshcoder/moshcode) knows about a +running fleet is behind a prompt. `moshcode ps` says who is alive, `moshcode +cost` says what it burns, `moshcode fleet tree` says who started whom. All three +are true and none of them are on screen, so you learn what your machine is doing +by deciding to ask. + +The state that costs the most is `blocked`: an agent that asked a question +twenty minutes ago, holding a pane and a context window, waiting for a human who +does not know it is waiting. This widget goes amber and pulses when that +happens. Everything else it shows is context around that one fact. + +## Install + +You need [moshcode](https://github.com/moshcoder/moshcode) 0.104 or newer on +your `PATH`: + +```bash +curl -fsSL https://moshcoding.com/install.sh | bash +moshcode omarchy doctor # what is present, and what that rules out +``` + +Then either let moshcode place the plugin: + +```bash +moshcode omarchy install # copies into ~/.config/omarchy/plugins and rescans +``` + +or install it the Omarchy way, from git: + +```bash +omarchy plugin add https://github.com/moshcoder/omarchy-moshcode --enable +omarchy bar move sh.moshcode.herd --section right +``` + +## What it runs + +One command, on a timer, one at a time: + +```bash +moshcode omarchy status --json +``` + +That is the whole contract. The snapshot carries the agent rows, the counts, the +burn windows, the fleets and the alerts, and moshcode caches the expensive half +of it, so a poll that lands inside the cache window re-reads a small file rather +than re-reading a month of transcripts. + +The widget polls every 10 seconds while the panel is closed and every 3 seconds +while it is open, backs off to a minute after three consecutive failures, and +never starts a second process while the first is running. + +Those intervals are deliberately unambitious. Each poll is a `moshcode` process, +and node's start-up plus the CLI's measured between 0.66s and 4.2s on a +developer box busy running the agents the widget reports on. A one-second poll +would spend a visible slice of a core telling you how busy you are. + +## What it will not do + +It is read-only. It does not start agents, stop them, answer them, or attach to +them — those are writes from a process that runs unsandboxed inside your shell, +and the version that only looks has to be boring in the wild first. + +It also refuses to guess. No moshcode on `PATH`, a snapshot older than the +plugin understands, an engine that logs nothing priceable: each of those reads +as itself, never as a zero. + +## Settings + +Set on the widget, in the bar's plugin configuration: + +| Property | Default | What it does | +|---|---|---| +| `idleIntervalMs` | 10000 | poll while the panel is closed | +| `openIntervalMs` | 3000 | poll while the panel is open | +| `backoffIntervalMs` | 60000 | poll after three failures in a row | +| `staleAfterMs` | 30000 | how old a snapshot has to be before the widget dims | +| `moneyFloor` | 1.0 | hide `$/h` below this, so an idle machine shows no money | + +## Development + +```bash +moshcode omarchy validate # manifest and layout, no Omarchy needed +omarchy plugin validate "$PWD" # the real thing, on an Omarchy box +qmllint -I "$OMARCHY_PATH/shell" BarWidget.qml Panel.qml +omarchy-shell shell rescanPlugins +``` + +MIT. Issues and pull requests at +[moshcoder/moshcode](https://github.com/moshcoder/moshcode). diff --git a/omarchy/manifest.json b/omarchy/manifest.json new file mode 100644 index 00000000..64cbb9c0 --- /dev/null +++ b/omarchy/manifest.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "id": "sh.moshcode.herd", + "name": "Moshcode Herd", + "version": "0.1.0", + "author": "Anthony Ettinger", + "license": "MIT", + "description": "The agents you have running, the ones that are blocked waiting on you, and what the last hour cost.", + "kinds": ["bar-widget", "panel"], + "entryPoints": { + "barWidget": "BarWidget.qml", + "panel": "Panel.qml" + }, + "barWidget": { + "displayName": "Moshcode Herd", + "category": "System", + "allowMultiple": false, + "defaultSection": "right" + } +} diff --git a/package.json b/package.json index 9f0a9bff..8fe846bc 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "prd", ".claude-plugin", "plugins", + "omarchy", "install.sh", "README.md" ], diff --git a/prd/0017-moshcode-on-the-omarchy-bar.md b/prd/0017-moshcode-on-the-omarchy-bar.md index 1d14196e..86e50fbb 100644 --- a/prd/0017-moshcode-on-the-omarchy-bar.md +++ b/prd/0017-moshcode-on-the-omarchy-bar.md @@ -2,14 +2,14 @@ openprd: "0.3" id: "0017" title: "Put the herd on the Omarchy bar — a plugin, and the one snapshot it reads" -status: Draft +status: Accepted authors: - anthony@profullstack.com created: 2026-09-22 updated: 2026-09-22 repo: https://github.com/moshcoder/moshcode discussion: -implementation: src/omarchy.mjs (new), src/cli-schema.mjs, moshcoder/omarchy-moshcode (new repo) +implementation: src/omarchy.mjs, omarchy/, test/omarchy.test.mjs, bin/moshcode.mjs, src/cli-schema.mjs tags: - omarchy - herd @@ -35,7 +35,7 @@ There is a cost to getting it wrong. Omarchy plugins run **unsandboxed, inside a - The state of the herd — how many agents are running, waiting, blocked, and what the last hour cost — is on screen without anyone asking for it. - A blocked agent is noticed in seconds, because the thing that changed is the one glyph that is always visible. - moshcode stays the only source of truth. The plugin renders a snapshot the CLI produced; it never computes fleet state itself, and it never becomes a second control plane. -- One cheap read. The bar polls a single command, one process at a time, bounded and read-only, with a budget small enough that a 1 second poll is not a tax. +- One cheap read. The bar polls a single command, one process at a time, bounded and read-only, at a cadence honest about what a process costs. - Installable by a stranger in one command, listed in the Omarchy marketplace, and removable without leaving anything behind. - Honest when it cannot know. No moshcode, a stale snapshot, or an engine that prices nothing all read as themselves, never as zero. @@ -57,12 +57,12 @@ There is a cost to getting it wrong. Omarchy plugins run **unsandboxed, inside a ## Requirements - R1 [P0] `moshcode omarchy status --json` is the only thing the plugin runs. One process, no shell, no arguments the plugin composes from user data. It prints one object: `schema` (integer, 1), `generatedAt` (ISO 8601), `moshcode` (version), `agents` (the `ps --json` rows, already carrying `name`, `engine`, `state`, `fleet`, `swarm`, `approvals`, `cwd`, `ageMs`, `alive`, `attached`), `counts` (`running`, `waiting`, `blocked`, `gone`), `burn` (the `1m`, `15m`, `1h` windows from `cost --json` with `cost`, `perHour`, `runs`, `engines`, `unpriced`), `fleets` (the `fleet tree --json` roots, one level deep), and `alerts` (zero or more `{ kind, subject, since }`). It exits 0 with a populated object or exits 0 with `{ schema, generatedAt, error }`; it never writes to the herd, the ledger, or the fleet. -- R2 [P0] The snapshot is bounded. `ps --json`, `cost --all --since 1h --json` and `fleet tree --json` each return in about 210ms today on the dev box, which is process boot rather than work; `omarchy status` does all three in one boot and caches the cost half for `--ttl` (default 5s) under `~/.moshcode/omarchy-status.json` (0600), so a 1s poll re-reads a file and a 5s poll recomputes. A single run that exceeds 2s returns what it has with the slow section marked `partial: true` rather than blocking the bar. +- R2 [P0] The snapshot is bounded, and the bound is on the work, not on the wall clock (the wall clock belongs to node's start-up). `omarchy status` reads the roster, the fleets and the cost in one process, and caches the cost half for `--ttl` (default 5s) under `~/.moshcode/herd/omarchy-status.json` (0600); a reading that took longer than 2s marks itself `slow` and is held for a minute instead, because paying two seconds every five for a number that moves by cents is the wrong trade. A cost reading that throws serves the last good one and marks the snapshot `partial`. Measured at implementation: roster 215ms, fleets 1ms, cost 5ms on a cache hit. - R3 [P0] The plugin lives in its own public repository, `moshcoder/omarchy-moshcode`, because `omarchy plugin add {git-url}` clones a repo into `~/.config/omarchy/plugins/{id}` and validation requires `manifest.json` at the repository root. Contents: `manifest.json`, `BarWidget.qml`, `Panel.qml`, `Model.js`, `preview.png`, `README.md`, `LICENSE` (MIT), and no symlinks anywhere, which the CLI validator rejects. - R4 [P0] The manifest is `schemaVersion: 1`, `id: "sh.moshcode.herd"` (namespaced, and not under the reserved `omarchy.*` prefix), `name: "Moshcode Herd"`, `version` semver, `author`, `license: "MIT"`, `description`, `kinds: ["bar-widget", "panel"]`, `entryPoints: { "barWidget": "BarWidget.qml", "panel": "Panel.qml" }`, and a `barWidget` block with `displayName`, `category: "System"`, `allowMultiple: false`, `defaultSection: "right"`. `BarWidget.qml` and `Panel.qml` share one `moduleName`, which is required for the widget to load the panel through a `Loader`. - R5 [P0] The widget is one line and it is theme-driven: agent count, a state glyph, and `$/h` from the `1h` window when it is above a configurable floor (default $1/hour, so an idle machine shows no money). Colors come from `root.barForeground` and the shell's theme properties and the font from `root.bar.fontFamily`. No hard-coded color, no hard-coded font, no icon that only reads on a dark theme. - R6 [P0] The panel is a list, not a dashboard: one row per agent (name, engine, state, fleet/swarm, age, cwd tail, and a marker when `approvals` is `bypass`), then the three burn windows with cost and cost per hour, then the snapshot's age. It exposes `open()`, `close()`, `toggle()`, the `opened` and `popoutSwitchClosing` properties the shell expects, anchors with `KeyboardPanel`, and handles Escape and Tab through `PanelKeyCatcher`. -- R7 [P0] Polling is one `Process` at a time, started on a timer, never overlapping: 5s while the panel is closed, 1s while it is open, and a back-off to 30s after three consecutive failures, recovering on the first success. No `sh -c`, no string interpolation into a command line, no second Quickshell process, and every parse wrapped so that malformed JSON renders as "unavailable" instead of throwing into the shared shell. +- R7 [P0] Polling is one `Process` at a time, started on a timer, never overlapping: 10s while the panel is closed, 3s while it is open, and a back-off to 60s after three consecutive failures, recovering on the first success. **Revised during implementation.** The first draft said 5s and 1s, on the strength of a 210ms reading of `moshcode ps --json`. Measured properly, a `moshcode` process costs 0.66s to 4.2s on a box busy running the agents it reports on, and almost all of that is node's start-up plus the CLI's — `moshcode omarchy validate`, which reads one manifest, costs the same. A one-second poll would spend a visible slice of a core reporting how busy the machine is. No `sh -c`, no string interpolation into a command line, no second Quickshell process, and every parse wrapped so that malformed JSON renders as "unavailable" instead of throwing into the shared shell. - R8 [P0] The plugin is read-only in 0.1. The only state it changes is its own panel through `summon`/`hide`. It never starts, stops, kills, or attaches anything. - R9 [P0] It degrades honestly. No `moshcode` on PATH reads "moshcode not installed" with an install hint in the panel, not zeros. A snapshot older than three poll intervals dims the widget and shows its timestamp. Engines that log nothing priceable (gemini, kimi, deepseek, openagents) are carried through from `cost`'s `unpriced` and named in the panel, so an unpriced fleet never renders as free. A moshcode too old to have `omarchy status` reads "moshcode 0.x is too old" with the version it needs. - R10 [P0] Blocked is the alert. Any agent in `blocked` state — a question, not a finish — puts the widget into its attention state (theme accent, blocked count first) and raises an `alerts` entry with the subject and how long it has been waiting. This is the single behaviour the whole plugin exists for; everything else is context around it. @@ -87,7 +87,7 @@ Default section is `right`, with the clock and the system indicators, because th - Time from an agent entering `blocked` to a human noticing, measured by the gap between the ledger's block and the next input to that session. Target: minutes, from the current "until someone happens to look". - `moshcode ps` and `moshcode cost` typed by hand on an Omarchy box drop, because the answer is already on screen. - Zero shell crashes or restarts attributable to the plugin, measured over the first month of daily use. A bar that falls over once will be uninstalled and never reinstalled. -- The snapshot stays under its budget: p95 of `moshcode omarchy status --json` under 400ms on a machine with a month of transcripts. +- The snapshot's own work stays small: the cost half, the roster and the fleet summary together under 300ms of the process's time, measured inside the process rather than by wall clock, since wall clock is dominated by a node start-up nothing here controls. (Measured at implementation: roster 215ms, fleets 1ms, burn 5ms on a cache hit.) - Listed in the marketplace, with installs and stars as a secondary read on whether any of this generalises beyond one user. ## Risks & Open Questions @@ -95,6 +95,8 @@ Default section is `right`, with the clock and the system indicators, because th - **Nothing here can be tested on this box.** The dev machine is Ubuntu 26.04 and has no `omarchy`, `omarchy-shell`, `quickshell` or `qmllint` on PATH. The CLI half (R1, R2, R11) is testable locally; every QML requirement and all of R13 needs a real Omarchy install. Decide early whether that is a VM, a spare machine, or a borrowed one, because it gates the entire plugin half. - **Unsandboxed, shared process.** The blast radius of a bug is the user's whole bar, not our widget. Mitigation is the shape of R7 and R9: one process, no shell, guarded parses, and no writes. It should still be reviewed as if it were a daemon, because it effectively is one. - **A very young contract.** The marketplace lists zero community plugins and the manifest is at `schemaVersion: 1`. Fields, validation, and the CLI verbs may move under us. Re-run `omarchy plugin validate` against the current Omarchy release before every plugin release, and keep the QML small enough that a breaking change is an afternoon. +- **A poll is a process, and that is the real ceiling.** Measured at implementation: node start-up alone is ~200ms on this box, the moshcode CLI's own start-up takes it to ~550ms idle, and under the load of a working fleet a single `moshcode omarchy status --json` ranged from 0.66s to 4.2s. Lazy-loading the herd and cost module graphs keeps the cheap verbs cheap, but nothing in this repo can make a per-poll process free. If sub-second freshness is ever wanted, the honest answer is a long-lived writer (the `service` kind, or `herd serve`) and a plugin that only reads a file — which is a different PRD, and a daemon this one explicitly refused. + - **The cost half may not stay cheap.** `moshcode cost` reads transcripts, and 210ms on this machine is not a promise about a machine with a year of them. R2's cache and `partial` flag are the hedge; if they are not enough, the burn windows need an index rather than a scan, which is a separate piece of work in `src/cost.mjs`. - **Repo split needs confirming.** The root-manifest requirement points at a separate `moshcoder/omarchy-moshcode` repository, which means a second release to keep in step. The alternative — a subdirectory here, mirrored out on release — keeps one repo but adds a publish step that can silently lag. Recommendation is the separate repo; it wants an explicit yes. - **Process spawning from QML is assumed, not verified.** Quickshell's `Process`/`Io` types are referenced by Omarchy's shell reference rather than demonstrated in the plugin guide. Confirm on a real box that argv-style spawning without a shell is available to a third-party plugin before committing to R1's design; if it is not, the fallback is the CLI writing a snapshot file on a timer and the plugin only reading it, which is strictly worse but workable. diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 9a412fa4..c3bd4438 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -139,6 +139,25 @@ export const CORE_CLI_COMMANDS = [ + "member.start on submit for a pane that cannot write its own, member.end and one swarm.end (synthesis as summary, --verify as verdict) " + "before the kills, and a bypass flag the ceiling forbids is refused with ceiling.refuse. `moshcode fleet tree` shows it.", }, + { + name: "omarchy", + group: "runtime", + description: "the herd on the Omarchy bar: the snapshot a QML widget polls, and the plugin around it (PRD 0017)", + synopsis: [ + ["moshcode omarchy [args…]", ""], + ], + verbs: "OMARCHY_VERBS", + flags: [["--json", "machine-readable, on every verb", ""]], + examples: [ + ["moshcode omarchy status --json", "the snapshot: agents, counts, burn windows, alerts"], + ["moshcode omarchy validate", "the marketplace's checks, with no Omarchy installed"], + ["moshcode omarchy install", "copy the plugin into ~/.config/omarchy/plugins and rescan"], + ["moshcode omarchy doctor", "what is present on this box, and what that rules out"], + ], + seeAlso: ["ps", "cost", "fleet", "herd"], + note: "the plugin runs unsandboxed inside Omarchy's shared Quickshell process, so `status` is read-only, bounded and cached: " + + "a cost reading is held for 5s, or a minute when it was slow, and the snapshot says which it gave you.", + }, { name: "fleet", group: "runtime", @@ -1644,9 +1663,25 @@ export const PAYMENT_VERBS = [ { name: "disconnect", description: "forget a rail (the CLI stays logged in)", synopsis: [["moshcode payments disconnect ", ""]] }, ]; +export const OMARCHY_VERBS = [ + { name: "status", description: "the snapshot the bar polls: agents, counts, burn, alerts", + synopsis: [["moshcode omarchy status [--json] [--ttl ] [--no-cache]", ""]], + flags: [ + ["--ttl ", "how long a cost reading stays good", "5"], + ["--no-cache", "read cost fresh, whatever it costs", ""], + ] }, + { name: "validate", description: "the marketplace's manifest and layout checks, in JS", + synopsis: [["moshcode omarchy validate [dir] [--json]", "defaults to the plugin this package ships"]] }, + { name: "install", description: "copy the plugin into ~/.config/omarchy/plugins and ask the shell to rescan", + synopsis: [["moshcode omarchy install [--link] [--json]", "--link is the development path"]] }, + { name: "doctor", description: "omarchy, omarchy-shell, qmllint, the plugin dir, and the installed version", + synopsis: [["moshcode omarchy doctor [--json]", ""]] }, +]; + export const VERB_TABLES = { HERD_VERBS, FLEET_VERBS, + OMARCHY_VERBS, SSH_VERBS, TIMER_VERBS, CLIENT_VERBS, @@ -1693,6 +1728,8 @@ export const PIT_COMMANDS = [ description: "one task, a herd of agents, one answer" }, { name: "fleet", args: "[verb] [args…]", cli: "fleet", description: "the fleet tree, and the sysop's verbs over it" }, + { name: "omarchy", args: "[verb] [args…]", cli: "omarchy", + description: "the herd on the Omarchy bar: the snapshot, and the plugin around it" }, { name: "cost", aliases: ["usage"], args: "[name] [--all]", cli: "cost", description: "what the herd is spending, from the engines' own logs" }, { name: "attach", args: "", cli: "attach", diff --git a/src/commands.mjs b/src/commands.mjs index 304a5b4e..a8496435 100644 --- a/src/commands.mjs +++ b/src/commands.mjs @@ -831,6 +831,7 @@ const COMMANDS = [ cliVerb("ps", "print the herd roster"), cliVerb("swarm", "one task, a herd of agents, one answer (moshcode swarm \"\" [--agents 4])"), cliVerb("fleet", "the OpenFleet sysop tool: open, cap, tree, stop, log (moshcode fleet )"), + cliVerb("omarchy", "the herd on the Omarchy bar: status, validate, install, doctor (moshcode omarchy )"), cliVerb("cost", "print what the herd is spending (moshcode cost [name] [--all])"), cliVerb("start", "raw-launch an engine (moshcode start )"), cliVerb("install", "install an engine or workflow tool"), diff --git a/src/omarchy.mjs b/src/omarchy.mjs new file mode 100644 index 00000000..19e49df5 --- /dev/null +++ b/src/omarchy.mjs @@ -0,0 +1,565 @@ +// `moshcode omarchy` — the herd, on the Omarchy bar (PRD 0017). +// +// WHY THIS EXISTS. Everything moshcode knows about a running fleet is behind a +// prompt: `ps` says who is alive, `cost` says what it burns, `fleet tree` says +// who started whom. All three are true and none of them are on screen, so you +// find out what your machine is doing by deciding to ask. The expensive version +// of that was a dozen background jobs at ~$131/hour that nobody saw for hours. +// The common version is an agent sitting in `blocked`, holding a pane and a +// context window, having asked a question twenty minutes ago. +// +// Omarchy's bar takes third-party QML plugins, so the missing surface is a bar +// widget. This module is the half that runs here: `status` is the one snapshot +// the widget polls, and `validate` / `install` / `doctor` are what make the +// plugin shippable from a box that does not run Omarchy. +// +// THE CONSTRAINT THAT SHAPES ALL OF IT. An Omarchy plugin runs unsandboxed +// inside a shared, long-running Quickshell process. A widget that leaks a +// process per tick or throws on a bad parse does not break moshcode, it breaks +// the whole bar for whoever installed it. So `status` is read-only, bounded, +// cached, and it never exits non-zero for a condition the bar should render: +// "no herd", "no rates", "cost was slow" are all answers, not failures. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import * as fleet from "./openfleet.mjs"; +import { acid, bone, dim, err, info, moshcodeVersion, ok, warn } from "./ui.mjs"; + +// The herd and cost module graphs are the expensive part of this process, and +// three of the four verbs never touch them: `validate` reads a manifest, +// `doctor` stats a few paths, `install` copies a directory. Only `status` pays, +// because only `status` is on the path the bar polls. +const loadCost = () => import("./cost.mjs"); +const loadCostCli = () => import("./cost-cli.mjs"); +const loadHerdCli = () => import("./herd-cli.mjs"); + +/** herd-cli's exit codes, repeated rather than imported, so the cheap verbs stay cheap. */ +const EXIT = { matched: 0, usage: 1, timeout: 2, gone: 3, below: 4, infra: 5 }; + +/** The snapshot contract. The plugin renders a schema it knows and says so for anything newer. */ +export const SCHEMA = 1; + +/** The plugin's id, which is also its directory name under ~/.config/omarchy/plugins. */ +export const PLUGIN_ID = "sh.moshcode.herd"; + +/** The windows the bar shows. The bar is one line; 4h and 8h belong in `moshcode cost`. */ +export const BAR_WINDOW_KEYS = ["1m", "15m", "1h"]; + +/** How long a cost reading stays good, and how long a SLOW one stays good. */ +const DEFAULT_TTL_MS = 5_000; +const SLOW_TTL_MS = 60_000; +/** Over this, the reading is slow enough that paying for it every `ttl` is the wrong trade. */ +const DEFAULT_BUDGET_MS = 2_000; + +/** The seven fields the marketplace requires of every manifest. */ +export const REQUIRED_FIELDS = ["schemaVersion", "id", "name", "version", "kinds", "entryPoints", "description"]; + +/** Each kind and the entry point key it has to declare. */ +export const KIND_ENTRY_POINTS = { + "bar-widget": "barWidget", + panel: "panel", + overlay: "overlay", + menu: "menu", + service: "service", + bar: "bar", +}; + +const USAGE = { + omarchy: "usage: moshcode omarchy [--json]", + status: "usage: moshcode omarchy status [--json] [--ttl ] [--no-cache]", + validate: "usage: moshcode omarchy validate [dir] [--json]", + install: "usage: moshcode omarchy install [--link] [--json]", + doctor: "usage: moshcode omarchy doctor [--json]", +}; + +function parseArgs(argv, { valued = [], flags: known = [] } = {}) { + const flags = {}; + const positional = []; + const errors = []; + for (let i = 0; i < argv.length; i++) { + const a = String(argv[i]); + if (!a.startsWith("--") || a === "--") { positional.push(a); continue; } + const eq = a.indexOf("="); + const key = eq > 0 ? a.slice(2, eq) : a.slice(2); + if (valued.includes(key)) { + const value = eq > 0 ? a.slice(eq + 1) : argv[++i]; + if (value === undefined) errors.push(`--${key} needs a value`); + else flags[key] = String(value); + } else if (known.includes(key) && eq < 0) { + flags[key] = true; + } else { + errors.push(`unknown flag ${a}`); + } + } + return { flags, positional, errors }; +} + +/* ------------------------------------------------------------------ paths */ + +/** The plugin source that ships inside the package. */ +export function pluginSource() { + return fileURLToPath(new URL("../omarchy", import.meta.url)); +} + +/** Where Omarchy looks for third-party plugins. */ +export function pluginsDir(env = process.env) { + const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(base, "omarchy", "plugins"); +} + +export function installedDir(env = process.env) { + return path.join(pluginsDir(env), PLUGIN_ID); +} + +/** The cost reading's cache. Its own file, because only the cost half is worth caching. */ +export function cachePath() { + return path.join(os.homedir(), ".moshcode", "herd", "omarchy-status.json"); +} + +/* --------------------------------------------------------------- snapshot */ + +function readCache(file = cachePath()) { + try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } +} + +function writeCache(value, file = cachePath()) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, JSON.stringify(value), { mode: 0o600 }); + } catch { /* a cache that cannot be written is a slower bar, not a broken one */ } +} + +/** + * The agents half: the same rows `moshcode ps --json` prints, trimmed to what a + * bar can show. Trimmed rather than renamed — a person reading the panel and a + * person reading `moshcode ps` have to be reading the same words. + */ +export function agentRows(rows) { + return rows.map(({ name, engine, herd, state, blockedOn, fleet: f, swarm, member, approvals, cwd, age, alive, attached }) => ({ + name, engine, herd: herd || null, state, ...(blockedOn ? { blockedOn } : {}), + fleet: f || null, swarm: swarm || null, member: member || null, + approvals: approvals || "native", cwd, ageMs: age, alive: Boolean(alive), attached: attached || 0, + })); +} + +/** One count per state in the vocabulary, plus `live`. Absent states are 0, never missing. */ +export function countStates(rows) { + const counts = { working: 0, blocked: 0, done: 0, idle: 0, unknown: 0, gone: 0, live: 0 }; + for (const r of rows) { + if (counts[r.state] === undefined) counts[r.state] = 0; + counts[r.state] += 1; + if (r.alive) counts.live += 1; + } + return counts; +} + +/** + * Blocked is the alert. It is the one state where the machine is spending a + * pane and a context window on nothing at all, and the only state a human can + * clear, so it is the reason the widget changes colour. + */ +export function alertsFor(rows, { now = Date.now() } = {}) { + return rows + .filter((r) => r.state === "blocked") + .map((r) => ({ + kind: r.blockedOn ? `blocked:${r.blockedOn}` : "blocked", + subject: r.name, + engine: r.engine, + ageMs: r.ageMs ?? null, + since: r.ageMs == null ? null : new Date(now - r.ageMs).toISOString(), + })); +} + +/** The fleets half: one line per fleet, not the tree. The tree is `moshcode fleet tree`. */ +export function fleetSummary(env = process.env) { + try { + const names = fleet.listFleets(env); + const implicit = fleet.implicitFleet(env); + const all = names.includes(implicit) ? names : [...names, implicit]; + return all.map((name) => { + let records = []; + try { records = fleet.listRecords(name, env); } catch { records = []; } + const swarms = new Set(records.map((r) => r?.swarm).filter(Boolean)); + return { + fleet: name, + implicit: name === implicit, + members: records.length, + swarms: swarms.size, + }; + }).filter((f) => f.members > 0 || f.implicit); + } catch { + return []; + } +} + +/** + * The cost half, with its cache. + * + * Reading cost means reading transcripts, which is ~200ms of process boot on + * this machine today and is not a promise about a machine with a year of them. + * So: a fresh reading is served from `~/.moshcode/omarchy-status.json` until it + * is `ttl` old; a reading that took longer than `budget` marks itself `slow` and + * is held for a minute instead, because paying two seconds every five is the + * wrong trade for a number that moves by cents. The bar is told which it got. + */ +export async function burnSnapshot({ + now = Date.now(), ttl = DEFAULT_TTL_MS, budget = DEFAULT_BUDGET_MS, cache = true, + file = cachePath(), report = null, windows = null, +} = {}) { + if (cache) { + const held = readCache(file); + const age = held?.at == null ? null : now - held.at; + const good = held?.slow ? SLOW_TTL_MS : ttl; + if (held && age != null && age >= 0 && age < good) { + return { burn: held.burn ?? null, burnAgeMs: age, cached: true, slow: Boolean(held.slow), unpriced: held.unpriced ?? [] }; + } + } + const started = Date.now(); + try { + // Only now does the cost half of the module graph get loaded: a cache hit + // above returned without it. + const { burn, BURN_WINDOWS } = await loadCost(); + const { costReport, parseWindow } = await loadCostCli(); + const run = report || costReport; + const since = now - parseWindow("1h"); + const r = await run({ since }); + const rows = burn(r.runs, { now, windows: windows || BURN_WINDOWS.filter((w) => BAR_WINDOW_KEYS.includes(w.key)), since: null }); + const elapsed = Date.now() - started; + const unpriced = [...new Set(rows.flatMap((row) => row.unpriced || []))]; + const slow = elapsed > budget; + if (cache) writeCache({ at: now, burn: rows, elapsedMs: elapsed, slow, unpriced }, file); + return { burn: rows, burnAgeMs: 0, cached: false, slow, elapsedMs: elapsed, unpriced }; + } catch (e) { + // A cost reading that throws is a missing number, not a broken bar: serve + // the last one we had, say how old it is, and mark the snapshot partial. + const held = cache ? readCache(file) : null; + return { + burn: held?.burn ?? null, + burnAgeMs: held?.at == null ? null : now - held.at, + cached: Boolean(held), + slow: Boolean(held?.slow), + unpriced: held?.unpriced ?? [], + partial: true, + error: String(e?.message || e), + }; + } +} + +/** + * The whole snapshot, in one process boot. This is the only thing the plugin + * runs, and it never writes to the herd, the ledger or the fleet. + */ +export async function snapshot({ + now = Date.now(), env = process.env, ttl = DEFAULT_TTL_MS, budget = DEFAULT_BUDGET_MS, + cache = true, roster = null, ...rest +} = {}) { + const out = { + schema: SCHEMA, + generatedAt: new Date(now).toISOString(), + moshcode: moshcodeVersion() || null, + host: os.hostname(), + }; + let rows = []; + try { + const read = roster || (await loadHerdCli()).roster; + rows = agentRows(read()); + } catch (e) { + out.partial = true; + out.error = String(e?.message || e); + } + out.agents = rows; + out.counts = countStates(rows); + out.alerts = alertsFor(rows, { now }); + out.fleets = fleetSummary(env); + + const b = await burnSnapshot({ now, ttl, budget, cache, ...rest }); + out.burn = b.burn; + out.burnAgeMs = b.burnAgeMs; + out.burnCached = b.cached; + if (b.slow) out.burnSlow = true; + if (b.unpriced?.length) out.unpriced = b.unpriced; + if (b.partial) { out.partial = true; out.error = out.error || b.error; } + return out; +} + +/* ------------------------------------------------------------- validation */ + +/** + * The marketplace's documented checks, in JavaScript. + * + * Omarchy's own `omarchy plugin validate` is the authority and it is not on + * this box (the dev machine is Ubuntu, with no omarchy, no quickshell and no + * qmllint). This is the part that can run in CI anyway: it says the listing + * will validate. It does not say the plugin works — only a real Omarchy install + * and `qmllint` can say that, which is why `doctor` reports whether you are on + * one. + */ +export function validatePlugin(dir = pluginSource()) { + const errors = []; + const warnings = []; + let manifest = null; + + const manifestPath = path.join(dir, "manifest.json"); + if (!fs.existsSync(manifestPath)) { + return { ok: false, dir, manifest: null, errors: [`no manifest.json in ${dir}`], warnings }; + } + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + } catch (e) { + return { ok: false, dir, manifest: null, errors: [`manifest.json is not valid JSON: ${e.message}`], warnings }; + } + + for (const field of REQUIRED_FIELDS) { + if (manifest[field] === undefined || manifest[field] === null || manifest[field] === "") { + errors.push(`manifest is missing required field "${field}"`); + } + } + if (manifest.schemaVersion !== undefined && manifest.schemaVersion !== 1) { + warnings.push(`schemaVersion ${JSON.stringify(manifest.schemaVersion)} is not 1 — the documented contract is 1`); + } + if (typeof manifest.id === "string" && /^omarchy\./.test(manifest.id)) { + errors.push(`id "${manifest.id}" is in the reserved omarchy.* namespace`); + } + if (typeof manifest.id === "string" && !manifest.id.includes(".")) { + warnings.push(`id "${manifest.id}" is not namespaced — the documented form is like io.github.you.thing`); + } + if (typeof manifest.version === "string" && manifest.version.length > 64) { + errors.push("version is longer than 64 characters"); + } + + const kinds = Array.isArray(manifest.kinds) ? manifest.kinds : []; + if (Array.isArray(manifest.kinds) && !kinds.length) errors.push("kinds is empty"); + if (manifest.kinds !== undefined && !Array.isArray(manifest.kinds)) errors.push("kinds must be an array"); + const entryPoints = manifest.entryPoints && typeof manifest.entryPoints === "object" ? manifest.entryPoints : {}; + + for (const kind of kinds) { + const key = KIND_ENTRY_POINTS[kind]; + if (!key) { errors.push(`unknown kind "${kind}"`); continue; } + if (!entryPoints[key]) errors.push(`kind "${kind}" declares no entryPoints.${key}`); + } + for (const key of Object.keys(entryPoints)) { + const kind = Object.keys(KIND_ENTRY_POINTS).find((k) => KIND_ENTRY_POINTS[k] === key); + if (!kind) { errors.push(`entryPoints.${key} matches no known kind`); continue; } + if (!kinds.includes(kind)) errors.push(`entryPoints.${key} is declared but kind "${kind}" is not`); + } + + // Every referenced file exists, as a safe relative path inside the plugin. + const referenced = [...Object.values(entryPoints), ...(manifest.preview ? [manifest.preview] : [])].filter((v) => typeof v === "string"); + for (const rel of referenced) { + if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes("..")) { + errors.push(`referenced file "${rel}" is not a safe relative path`); + continue; + } + const target = path.join(dir, rel); + if (!fs.existsSync(target)) errors.push(`referenced file "${rel}" does not exist`); + } + + for (const rel of walk(dir)) { + const full = path.join(dir, rel); + let st = null; + try { st = fs.lstatSync(full); } catch { continue; } + if (st.isSymbolicLink()) errors.push(`symlink in plugin directory: ${rel}`); + } + + for (const nice of ["README.md", "LICENSE"]) { + if (!fs.existsSync(path.join(dir, nice))) warnings.push(`no ${nice} — the marketplace asks for one`); + } + if (!fs.existsSync(path.join(dir, "preview.png"))) { + warnings.push("no preview.png — the listing card will have no image"); + } + + return { ok: errors.length === 0, dir, manifest, errors, warnings }; +} + +/** Every path under `dir`, relative, without following symlinks. */ +function walk(dir, prefix = "") { + let entries = []; + try { entries = fs.readdirSync(path.join(dir, prefix), { withFileTypes: true }); } catch { return []; } + const out = []; + for (const e of entries) { + const rel = prefix ? path.join(prefix, e.name) : e.name; + out.push(rel); + if (e.isDirectory() && !e.isSymbolicLink()) out.push(...walk(dir, rel)); + } + return out; +} + +/* ------------------------------------------------------------------ doctor */ + +/** + * PATH by hand, rather than a shelled-out `command -v`. + * + * A shell here would be a process spawn on a path the bar polls, and node's + * own `shell: true` warns that argv is concatenated rather than escaped. A + * directory read is cheaper than both and cannot be talked into running + * anything. + */ +function which(bin, env = process.env) { + for (const dir of String(env.PATH || "").split(path.delimiter)) { + if (!dir) continue; + const full = path.join(dir, bin); + try { + const st = fs.statSync(full); + if (st.isFile() && (st.mode & 0o111)) return full; + } catch { /* next */ } + } + return null; +} + +export function doctor(env = process.env) { + const source = pluginSource(); + const installed = installedDir(env); + const local = validatePlugin(source); + let installedVersion = null; + try { installedVersion = JSON.parse(fs.readFileSync(path.join(installed, "manifest.json"), "utf8")).version || null; } catch { /* not installed */ } + const shellPath = env.OMARCHY_PATH ? path.join(env.OMARCHY_PATH, "shell") : null; + return { + omarchy: which("omarchy"), + omarchyShell: which("omarchy-shell"), + qmllint: which("qmllint"), + omarchyPath: env.OMARCHY_PATH || null, + shellDir: shellPath && fs.existsSync(shellPath) ? shellPath : null, + pluginsDir: fs.existsSync(pluginsDir(env)) ? pluginsDir(env) : null, + source, + sourceValid: local.ok, + sourceVersion: local.manifest?.version || null, + installedDir: fs.existsSync(installed) ? installed : null, + installedVersion, + moshcode: moshcodeVersion() || null, + }; +} + +/* ----------------------------------------------------------------- install */ + +/** + * Copy the plugin into ~/.config/omarchy/plugins/ and ask the shell to + * rescan. A copy rather than a symlink because the validator rejects symlinks + * inside a plugin folder; `--link` is the development path and says so. + */ +export function install({ env = process.env, link = false, source = pluginSource(), now = Date.now() } = {}) { + const check = validatePlugin(source); + if (!check.ok) return { ok: false, errors: check.errors, target: null }; + const target = installedDir(env); + const backups = []; + if (fs.existsSync(target)) { + // Never overwrite in place: the replaced directory is kept beside itself, + // numbered, so a bad install is one `mv` from undone. + let n = 1; + let backup = `${target}.bak-${String(n).padStart(3, "0")}`; + while (fs.existsSync(backup)) { n += 1; backup = `${target}.bak-${String(n).padStart(3, "0")}`; } + fs.renameSync(target, backup); + backups.push(backup); + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (link) fs.symlinkSync(source, target); + else fs.cpSync(source, target, { recursive: true, dereference: true }); + + let rescan = null; + if (which("omarchy-shell")) { + const r = spawnSync("omarchy-shell", ["shell", "rescanPlugins"], { encoding: "utf8" }); + rescan = r.status === 0 ? "ok" : String(r.stderr || r.stdout || `exit ${r.status}`).trim(); + } + return { ok: true, target, link, backups, rescan, at: new Date(now).toISOString(), version: check.manifest?.version || null }; +} + +/* -------------------------------------------------------------------- CLI */ + +function usd(v) { + if (v == null) return "—"; + return v >= 100 ? `$${Math.round(v)}` : `$${v.toFixed(2)}`; +} + +async function statusCommand(argv, { write }) { + const { flags, errors } = parseArgs(argv, { valued: ["ttl"], flags: ["json", "no-cache"] }); + for (const e of errors) write(err(e)); + if (errors.length) { write(err(USAGE.status)); return EXIT.usage; } + const ttl = flags.ttl ? Math.max(0, Number(flags.ttl) * 1000) : DEFAULT_TTL_MS; + if (flags.ttl && !Number.isFinite(Number(flags.ttl))) { write(err("--ttl takes seconds")); return EXIT.usage; } + const snap = await snapshot({ ttl, cache: !flags["no-cache"] }); + if (flags.json) { write(JSON.stringify(snap, null, 2)); return EXIT.matched; } + + const c = snap.counts; + write(` ${bone("agents")} ${c.live} live · ${c.working} working · ${c.blocked} blocked · ${c.idle} idle`); + const hour = (snap.burn || []).find((b) => b.key === "1h"); + write(` ${bone("burn")} ${usd(hour?.perHour)}/h over the last hour${snap.burnCached ? dim(" (cached)") : ""}${snap.burnSlow ? dim(" · slow") : ""}`); + if (snap.unpriced?.length) write(warn(`no rate for ${snap.unpriced.join(", ")} — those tokens count toward nothing.`)); + for (const a of snap.alerts) { + const mins = a.ageMs == null ? "?" : Math.round(a.ageMs / 60000); + write(warn(`${a.subject} is ${a.kind} — ${mins}m`)); + } + if (snap.partial) write(warn(`partial snapshot: ${snap.error}`)); + write(info(`the bar polls ${acid("moshcode omarchy status --json")} — ${acid("moshcode omarchy doctor")} says whether it can.`)); + return EXIT.matched; +} + +function validateCommand(argv, { write }) { + const { flags, positional, errors } = parseArgs(argv, { valued: [], flags: ["json"] }); + for (const e of errors) write(err(e)); + if (errors.length) { write(err(USAGE.validate)); return EXIT.usage; } + const dir = positional[0] ? path.resolve(positional[0]) : pluginSource(); + const result = validatePlugin(dir); + if (flags.json) { write(JSON.stringify(result, null, 2)); return result.ok ? EXIT.matched : EXIT.usage; } + for (const e of result.errors) write(err(e)); + for (const w of result.warnings) write(warn(w)); + if (result.ok) { + write(ok(`${result.manifest?.id || dir} validates (${result.manifest?.version || "no version"})`)); + write(info("this says the listing will validate, not that the plugin runs — that needs a real Omarchy box and qmllint.")); + } + return result.ok ? EXIT.matched : EXIT.usage; +} + +function installCommand(argv, { write }) { + const { flags, errors } = parseArgs(argv, { valued: [], flags: ["json", "link"] }); + for (const e of errors) write(err(e)); + if (errors.length) { write(err(USAGE.install)); return EXIT.usage; } + const result = install({ link: Boolean(flags.link) }); + if (flags.json) { write(JSON.stringify(result, null, 2)); return result.ok ? EXIT.matched : EXIT.usage; } + if (!result.ok) { for (const e of result.errors) write(err(e)); return EXIT.usage; } + for (const b of result.backups) write(info(`replaced directory kept at ${b}`)); + write(ok(`installed ${PLUGIN_ID} ${result.version} to ${result.target}${result.link ? " (symlinked)" : ""}`)); + if (result.rescan === "ok") write(info("asked the shell to rescan its plugins.")); + else if (result.rescan) write(warn(`rescan failed: ${result.rescan}`)); + else write(info(`no omarchy-shell here — on the Omarchy box run ${acid("omarchy-shell shell rescanPlugins")}.`)); + return EXIT.matched; +} + +function doctorCommand(argv, { write }) { + const { flags, errors } = parseArgs(argv, { valued: [], flags: ["json"] }); + for (const e of errors) write(err(e)); + if (errors.length) { write(err(USAGE.doctor)); return EXIT.usage; } + const d = doctor(); + if (flags.json) { write(JSON.stringify(d, null, 2)); return EXIT.matched; } + const line = (label, value, hint) => write(` ${bone(label.padEnd(14))}${value ? acid(String(value)) : dim(hint || "not found")}`); + line("omarchy", d.omarchy); + line("omarchy-shell", d.omarchyShell); + line("qmllint", d.qmllint, "not found — cannot lint QML here"); + line("OMARCHY_PATH", d.shellDir, "unset — qmllint needs -I $OMARCHY_PATH/shell"); + line("plugins dir", d.pluginsDir, `${pluginsDir()} (absent)`); + line("plugin source", d.sourceValid ? `${d.source} (${d.sourceVersion})` : null, `${d.source} — invalid`); + line("installed", d.installedVersion ? `${d.installedDir} (${d.installedVersion})` : null, "not installed"); + if (!d.omarchy) write(info("this box is not an Omarchy install: the CLI half works, the plugin cannot be run or linted here.")); + else if (!d.installedVersion) write(info(`install it with ${acid("moshcode omarchy install")}.`)); + else if (d.installedVersion !== d.sourceVersion) write(warn(`installed ${d.installedVersion} is not the shipped ${d.sourceVersion} — ${acid("moshcode omarchy install")} updates it.`)); + return EXIT.matched; +} + +const VERBS = { status: statusCommand, validate: validateCommand, install: installCommand, doctor: doctorCommand }; + +export async function omarchyCommand(argv = [], { write = console.log } = {}) { + const [verb, ...rest] = argv; + if (!verb || verb === "--help" || verb === "help") { + write(USAGE.omarchy); + write(""); + write(` ${bone("status")} the snapshot the bar polls: agents, burn, alerts`); + write(` ${bone("validate")} the marketplace's checks, runnable with no Omarchy installed`); + write(` ${bone("install")} copy the plugin into ~/.config/omarchy/plugins and rescan`); + write(` ${bone("doctor")} what is present here, and what that rules out`); + return verb ? EXIT.matched : EXIT.usage; + } + const run = VERBS[verb]; + if (!run) { write(err(`unknown verb ${JSON.stringify(verb)}`)); write(err(USAGE.omarchy)); return EXIT.usage; } + return run(rest, { write }); +} diff --git a/test/omarchy.test.mjs b/test/omarchy.test.mjs new file mode 100644 index 00000000..f071b957 --- /dev/null +++ b/test/omarchy.test.mjs @@ -0,0 +1,289 @@ +// `moshcode omarchy` — the snapshot the bar polls, and the checks that let a +// non-Omarchy box ship the plugin (PRD 0017). +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, describe, it } from "node:test"; + +import { + KIND_ENTRY_POINTS, PLUGIN_ID, REQUIRED_FIELDS, SCHEMA, + agentRows, alertsFor, burnSnapshot, countStates, omarchyCommand, pluginSource, snapshot, validatePlugin, +} from "../src/omarchy.mjs"; + +const tmps = []; +function tmpdir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omarchy-test-")); + tmps.push(dir); + return dir; +} +after(() => { for (const d of tmps) fs.rmSync(d, { recursive: true, force: true }); }); + +const ROW = { + name: "api", engine: "claude", herd: "main", state: "working", fleet: "anthony@dev", + swarm: null, member: "api", approvals: "bypass", cwd: "/home/a/src/thing", age: 1000, alive: true, attached: 1, +}; + +/** A cost report with one run, one sample, priced. */ +function fakeReport(cost = true) { + return async () => ({ + runs: [{ + engine: "claude", id: "r1", + samples: [{ at: Date.now() - 1000, model: cost ? "claude-fable-5-1" : "mystery-model", usage: { input: 1000, output: 100 } }], + }], + rows: [], unattributed: [], sessions: [], + }); +} + +describe("the snapshot", () => { + it("carries the schema, so an older plugin can refuse a newer snapshot", async () => { + const snap = await snapshot({ roster: () => [], cache: false, report: fakeReport() }); + assert.equal(snap.schema, SCHEMA); + assert.match(snap.generatedAt, /^\d{4}-\d{2}-\d{2}T/); + }); + + it("keeps the words moshcode ps already uses", async () => { + const snap = await snapshot({ roster: () => [ROW], cache: false, report: fakeReport() }); + const [a] = snap.agents; + assert.equal(a.name, "api"); + assert.equal(a.state, "working"); + assert.equal(a.approvals, "bypass"); + assert.equal(a.ageMs, 1000); + assert.equal(a.fleet, "anthony@dev"); + }); + + it("counts every state in the vocabulary, present or not", () => { + const counts = countStates(agentRows([ + ROW, + { ...ROW, name: "web", state: "blocked", alive: true }, + { ...ROW, name: "old", state: "gone", alive: false }, + ])); + assert.equal(counts.working, 1); + assert.equal(counts.blocked, 1); + assert.equal(counts.gone, 1); + assert.equal(counts.idle, 0, "a state nobody is in is 0, not missing"); + assert.equal(counts.live, 2); + }); + + it("raises an alert for a blocked agent and nothing else", () => { + const now = Date.now(); + const alerts = alertsFor(agentRows([ + ROW, + { ...ROW, name: "web", state: "blocked", blockedOn: "question", age: 120000 }, + ]), { now }); + assert.equal(alerts.length, 1); + assert.equal(alerts[0].subject, "web"); + assert.equal(alerts[0].kind, "blocked:question"); + assert.equal(alerts[0].ageMs, 120000); + assert.equal(alerts[0].since, new Date(now - 120000).toISOString()); + }); + + it("is a snapshot, not a failure, when the roster throws", async () => { + const snap = await snapshot({ roster: () => { throw new Error("no tmux"); }, cache: false, report: fakeReport() }); + assert.equal(snap.partial, true); + assert.match(snap.error, /no tmux/); + assert.deepEqual(snap.agents, []); + assert.equal(snap.counts.live, 0, "the bar still gets numbers it can render"); + }); + + it("names the engines it could not price instead of implying they were free", async () => { + const snap = await snapshot({ roster: () => [], cache: false, report: fakeReport(false) }); + assert.ok(snap.unpriced.includes("mystery-model")); + const hour = snap.burn.find((b) => b.key === "1h"); + assert.equal(hour.cost, null, "unpriced tokens contribute nothing, rather than zero dollars"); + assert.equal(hour.runs, 1, "the run still counts"); + }); + + it("shows the bar the three windows it has room for", async () => { + const snap = await snapshot({ roster: () => [], cache: false, report: fakeReport() }); + assert.deepEqual(snap.burn.map((b) => b.key), ["1m", "15m", "1h"]); + }); +}); + +describe("the cost cache", () => { + it("serves a reading inside its ttl without asking again", async () => { + const file = path.join(tmpdir(), "cache.json"); + let calls = 0; + const report = async () => { calls += 1; return (await fakeReport()()); }; + const first = await burnSnapshot({ file, report, ttl: 60_000 }); + const second = await burnSnapshot({ file, report, ttl: 60_000 }); + assert.equal(calls, 1); + assert.equal(first.cached, false); + assert.equal(second.cached, true); + assert.deepEqual(second.burn.map((b) => b.key), first.burn.map((b) => b.key)); + }); + + it("asks again once the reading is stale", async () => { + const file = path.join(tmpdir(), "cache.json"); + let calls = 0; + const report = async () => { calls += 1; return (await fakeReport()()); }; + await burnSnapshot({ file, report, ttl: 5_000, now: Date.now() - 10_000 }); + await burnSnapshot({ file, report, ttl: 5_000 }); + assert.equal(calls, 2); + }); + + it("holds a slow reading longer than a fast one, and says it was slow", async () => { + const file = path.join(tmpdir(), "cache.json"); + let calls = 0; + const report = async () => { + calls += 1; + await new Promise((r) => setTimeout(r, 30)); + return (await fakeReport()()); + }; + // budget 0 makes every reading "slow", which is the condition under test. + const first = await burnSnapshot({ file, report, budget: 0, ttl: 1 }); + assert.equal(first.slow, true); + // Well past the 1ms ttl, still inside the slow ttl: held, not recomputed. + const second = await burnSnapshot({ file, report, budget: 0, ttl: 1, now: Date.now() + 2_000 }); + assert.equal(calls, 1); + assert.equal(second.cached, true); + assert.equal(second.slow, true); + }); + + it("serves the last reading it had when cost throws, and marks the snapshot partial", async () => { + const file = path.join(tmpdir(), "cache.json"); + await burnSnapshot({ file, report: fakeReport(), ttl: 60_000 }); + const after = await burnSnapshot({ + file, ttl: 0, + report: async () => { throw new Error("transcripts unreadable"); }, + }); + assert.equal(after.partial, true); + assert.match(after.error, /transcripts unreadable/); + assert.ok(Array.isArray(after.burn), "the last good reading is still what the bar shows"); + }); + + it("writes the cache 0600, because it names every session you are running", async () => { + const file = path.join(tmpdir(), "cache.json"); + await burnSnapshot({ file, report: fakeReport() }); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + }); +}); + +describe("the plugin this package ships", () => { + it("validates", () => { + const result = validatePlugin(pluginSource()); + assert.deepEqual(result.errors, []); + assert.equal(result.ok, true); + }); + + it("is the id the CLI installs, and is not in the reserved namespace", () => { + const { manifest } = validatePlugin(pluginSource()); + assert.equal(manifest.id, PLUGIN_ID); + assert.ok(!manifest.id.startsWith("omarchy.")); + assert.equal(manifest.schemaVersion, 1); + }); + + it("declares an entry point for every kind, and a file for every entry point", () => { + const { manifest } = validatePlugin(pluginSource()); + for (const kind of manifest.kinds) { + const key = KIND_ENTRY_POINTS[kind]; + assert.ok(manifest.entryPoints[key], `${kind} needs entryPoints.${key}`); + assert.ok(fs.existsSync(path.join(pluginSource(), manifest.entryPoints[key]))); + } + }); +}); + +describe("validate", () => { + function plugin(manifest, files = { "BarWidget.qml": "Item {}" }) { + const dir = tmpdir(); + fs.writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest)); + for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), body); + return dir; + } + const good = { + schemaVersion: 1, id: "io.github.someone.thing", name: "Thing", version: "1.0.0", + author: "Someone", license: "MIT", description: "A thing.", + kinds: ["bar-widget"], entryPoints: { barWidget: "BarWidget.qml" }, + }; + + it("passes a manifest that has everything", () => { + assert.deepEqual(validatePlugin(plugin(good)).errors, []); + }); + + it("names every missing required field", () => { + const { errors } = validatePlugin(plugin({ schemaVersion: 1, id: "a.b" })); + for (const field of REQUIRED_FIELDS.filter((f) => !["schemaVersion", "id"].includes(f))) { + assert.ok(errors.some((e) => e.includes(`"${field}"`)), `expected an error about ${field}`); + } + }); + + it("rejects the reserved namespace", () => { + const { errors, ok } = validatePlugin(plugin({ ...good, id: "omarchy.clock" })); + assert.equal(ok, false); + assert.ok(errors.some((e) => /reserved omarchy\./.test(e))); + }); + + it("rejects a kind with no entry point, and an entry point with no kind", () => { + const missing = validatePlugin(plugin({ ...good, kinds: ["bar-widget", "panel"] })); + assert.ok(missing.errors.some((e) => /entryPoints\.panel/.test(e))); + const orphan = validatePlugin(plugin({ ...good, entryPoints: { barWidget: "BarWidget.qml", panel: "Panel.qml" } })); + assert.ok(orphan.errors.some((e) => /entryPoints\.panel is declared but kind "panel" is not/.test(e))); + }); + + it("rejects a referenced file that is not there", () => { + const { errors } = validatePlugin(plugin({ ...good, entryPoints: { barWidget: "Missing.qml" } })); + assert.ok(errors.some((e) => /"Missing.qml" does not exist/.test(e))); + }); + + it("rejects a path that climbs out of the plugin", () => { + const { errors } = validatePlugin(plugin({ ...good, entryPoints: { barWidget: "../BarWidget.qml" } })); + assert.ok(errors.some((e) => /safe relative path/.test(e))); + }); + + it("rejects a symlink anywhere under the plugin", () => { + const dir = plugin(good); + fs.symlinkSync("/etc/passwd", path.join(dir, "sneaky.qml")); + const { errors, ok } = validatePlugin(dir); + assert.equal(ok, false); + assert.ok(errors.some((e) => /symlink in plugin directory: sneaky\.qml/.test(e))); + }); + + it("is an error, not a crash, when the manifest is not JSON", () => { + const dir = tmpdir(); + fs.writeFileSync(path.join(dir, "manifest.json"), "{ not json"); + const { ok, errors } = validatePlugin(dir); + assert.equal(ok, false); + assert.ok(errors[0].includes("not valid JSON")); + }); + + it("warns about the things the marketplace asks for but does not require", () => { + const { warnings } = validatePlugin(plugin(good)); + assert.ok(warnings.some((w) => /README\.md/.test(w))); + assert.ok(warnings.some((w) => /preview\.png/.test(w))); + }); +}); + +describe("the CLI surface", () => { + it("lists its verbs when asked for nothing", async () => { + const lines = []; + const code = await omarchyCommand([], { write: (l) => lines.push(l) }); + assert.equal(code, 1, "a bare namespace is a usage error, like the other namespaces"); + assert.match(lines.join("\n"), /status/); + assert.match(lines.join("\n"), /doctor/); + }); + + it("refuses a verb it does not have", async () => { + const lines = []; + const code = await omarchyCommand(["rescan"], { write: (l) => lines.push(l) }); + assert.equal(code, 1); + assert.match(lines.join("\n"), /unknown verb "rescan"/); + }); + + it("prints the shipped plugin as valid JSON under --json", async () => { + const lines = []; + const code = await omarchyCommand(["validate", "--json"], { write: (l) => lines.push(l) }); + assert.equal(code, 0); + const parsed = JSON.parse(lines.join("\n")); + assert.equal(parsed.ok, true); + assert.equal(parsed.manifest.id, PLUGIN_ID); + }); + + it("says what is missing rather than pretending a non-Omarchy box is one", async () => { + const lines = []; + const code = await omarchyCommand(["doctor", "--json"], { write: (l) => lines.push(l) }); + assert.equal(code, 0); + const d = JSON.parse(lines.join("\n")); + assert.equal(d.sourceValid, true); + assert.ok("omarchy" in d && "qmllint" in d); + }); +});