Skip to content

Commit 25ad707

Browse files
midagedevclaude
andauthored
feat(omarchy): a bar widget that answers the one question no cloud plugin can (GDK-116) (#20)
Omarchy's plugin directory already has widgets for Jira, Linear, Todoist, ClickUp and Basecamp. Every one of them needs a cloud account, a token, and live network. Competing on "my assigned tickets" would lose to all of them, and a standalone gadak workspace has no "me" at all — `init --standalone` clears AccountID. So the widget shows the two numbers that only a local mirror can answer offline: open · stuck 368·201 on the demo snapshot open is issues outside the done status category; stuck is those whose status_changed_at is older than seven days. status_changed_at is deliberately not a stored duration, so the threshold is computed in the query. The data contract is the part that had to be right. The widget runs `gadak sql --json` and nothing else — it never opens gadak.db. The mirror is a disposable cache whose schema is explicitly not a contract; a plugin that opened it would break on a migration and would read a half-written file mid-sync. The query lives in one file (gadak/query.sql) that both the QML and the gate use, so the gate cannot pass on a string the widget does not run. The parser reads stdout only, because a stale-mirror warning goes to stderr and would otherwise be parsed as data. Every failure state is legible, because the state a stranger sees first is "the plugin is installed and the binary is not": no gadak PATH miss — tooltip carries the tarball URL and asset names not synced gadak ran, no mirror yet sql err non-zero exit, or stdout that is not the expected NDJSON Never an empty badge. Zero outbound network; one local command. contrib/omarchy/verify.sh is the gate: manifest shape against the first-party clock manifest, no sqlite/gadak.db path anywhere in the plugin, no display-name filter, and the real query run against examples/demo.db asserting NDJSON with both keys and open > 0. FAIL-first: renaming the query's output columns gives `missing key 'open' in {'broken_open': 368, ...}` and exit 1. qmllint is absent here, so that check skips with a printed reason instead of passing silently. Round premises the delegate corrected against the real sources (branch quattro): omarchy-plugin-clone only installs first-party ids and cannot install this, so install.sh copies into ~/.config/omarchy/plugins/<id>/ and enables; and `issues_full` on the demo snapshot is 534 rows, not 605 — 605 is `items`, which includes wiki pages. Lead review changed two things in the QML. `state` was a declared property, but QQuickItem already owns that name and redeclaring it is a compile error we have no local qmllint to catch; it is now viewState. And the missing-binary test ran before the no-mirror test and matched a bare "No such file or directory", so a plugin folder missing query.sql would have reported "no gadak" — a broken install disguised as an absent binary. Not verified: no Omarchy machine has run this. Nothing here has been submitted to the community directory. Co-authored-by: midagedev <midagedev@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 67974af commit 25ad707

8 files changed

Lines changed: 751 additions & 0 deletions

File tree

.github/workflows/omarchy.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# The Omarchy plugin cannot be loaded on this runner (no omarchy-shell).
2+
# What can be checked offline is the contract verify.sh owns: manifest
3+
# shape, no SQLite, no display-name filters, and the real query against
4+
# examples/demo.db. Registered the same way .github/workflows/scoop.yml
5+
# and .github/workflows/aur.yml register their verify scripts: path-scoped,
6+
# because nothing outside contrib/omarchy/ can change the answer.
7+
#
8+
# verify.sh builds gadak, so this job needs Go. It does not compare git
9+
# tags, so checkout stays shallow (the AUR workflow sets fetch-depth: 0
10+
# only because check-pkgver.sh reads `git describe --tags`).
11+
name: Omarchy
12+
13+
on:
14+
pull_request:
15+
paths: ['contrib/omarchy/**', '.github/workflows/omarchy.yml']
16+
push:
17+
branches: [main]
18+
paths: ['contrib/omarchy/**', '.github/workflows/omarchy.yml']
19+
workflow_dispatch:
20+
21+
permissions:
22+
contents: read
23+
24+
concurrency:
25+
group: omarchy-${{ github.event.pull_request.number || github.ref }}
26+
cancel-in-progress: true
27+
28+
jobs:
29+
plugin:
30+
name: Omarchy plugin offline checks
31+
runs-on: ubuntu-latest
32+
timeout-minutes: 15
33+
steps:
34+
- uses: actions/checkout@v4
35+
36+
- name: Set up Go
37+
uses: actions/setup-go@v5
38+
with:
39+
go-version-file: go.mod
40+
cache: true
41+
42+
- name: Offline plugin checks
43+
run: ./contrib/omarchy/verify.sh

contrib/omarchy/README.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Omarchy shell plugin
2+
3+
A bar widget for [omarchy-shell](https://omarchy.org/manual/shell-plugins/)
4+
that answers one question offline: how much of the local gadak mirror is
5+
open, and how much of that has been sitting in the same status for more
6+
than seven days.
7+
8+
This is not a “my assigned tickets” widget. A standalone workspace has no
9+
account (`gadak init --standalone` clears `AccountID`), and the competing
10+
community plugins (`tmn73.jira`, Linear, Todoist, ClickUp, `37signals.basecamp`)
11+
all need a cloud token and a live network. This one runs `gadak sql --json`
12+
against the on-disk mirror and nothing else.
13+
14+
**No Omarchy machine has run this plugin yet.** The gate below is offline
15+
(manifest, grep contracts, the real query against `examples/demo.db`). Load,
16+
placement, click-to-open, and `omarchy-plugin-validate` on a guest are
17+
unproven.
18+
19+
## What the widget shows
20+
21+
Two numbers in a short bar label, `open·stuck`:
22+
23+
| number | meaning |
24+
| --- | --- |
25+
| **open** | rows in `issues_full` whose `status_category` is not `done` |
26+
| **stuck** | those same rows whose `status_changed_at` is older than 7 days |
27+
28+
`status_category` is one of `new`, `inprogress`, `done`. Do not rewrite the
29+
query to filter on a status display name — that is empty on a Korean-language
30+
account (`AGENTS.md`, `CLAUDE.md`). `time-in-status` is not a stored column;
31+
age is `julianday('now') - julianday(status_changed_at)`. NULL
32+
`status_changed_at` is not counted as stuck.
33+
34+
The query lives in one file: [`gadak/query.sql`](gadak/query.sql).
35+
`BarWidget.qml` runs that file through `gadak sql --json`. `verify.sh`
36+
runs the same file. There is no second copy of the SQL.
37+
38+
`--json` is NDJSON (one object per line, `cmd/gadak/sql.go`). A stale-mirror
39+
warning goes to stderr; the widget parses stdout only.
40+
41+
### Degrade states (never an empty badge)
42+
43+
| condition | badge | tooltip |
44+
| --- | --- | --- |
45+
| `gadak` not on `PATH` | `no gadak` | install the Linux tarball from GitHub Releases |
46+
| `gadak` exits with `no mirror at … — run gadak sync` (`cmd/gadak/sql.go`) | `not synced` | run `gadak sync` |
47+
| non-zero exit, or stdout is not NDJSON with `open`/`stuck` | `sql err` | the command failed |
48+
| first poll still in flight | `` | reading |
49+
| success | `12·3` | `12 open · 3 stuck >7d` |
50+
51+
Left click opens `http://127.0.0.1:7777`. That is the default `--addr` of
52+
`gadak serve` (`cmd/gadak/serve.go:41`). Right click re-runs the query.
53+
54+
`gadak://` is not the click target. `gadak views open` prints a
55+
`gadak://view/…` deeplink (`cmd/gadak/views.go` `deepLinkURL`) that needs
56+
the macOS app bundle to register the scheme. Nothing on this desktop does.
57+
The bind is still `127.0.0.1:7777`; `gadak.localhost:7777` is only a
58+
display URL when the resolver maps that name to loopback
59+
(`cmd/gadak/main.go` `prettyOpenURL`).
60+
61+
The plugin makes no outbound network call. Telemetry is forbidden
62+
(`SECURITY.md`).
63+
64+
## Data contract
65+
66+
Read **only** with `gadak sql --json`. The mirror file is a disposable
67+
cache whose schema is not a 0.x promise (`specs/000-product/data-model.md`).
68+
Opening it from QML would break on a migration and can read a half-written
69+
file mid-sync. The three 0.x promises are `issues_full` + the RECIPES
70+
queries, the `gadak sql` stdout format, and `views open --keys -`.
71+
72+
## Install
73+
74+
On an Omarchy machine, from a clone of this repository:
75+
76+
```bash
77+
bash contrib/omarchy/install.sh
78+
```
79+
80+
The script:
81+
82+
- refuses with one line if `/etc/os-release` `ID` is not `omarchy`
83+
(`docs/runbooks/omarchy-vm.md`)
84+
- copies `gadak/` to `~/.config/omarchy/plugins/io.github.midagedev.gadak/`
85+
(a copy, not a symlink — `omarchy-plugin-validate` rejects symlinks
86+
inside a plugin folder)
87+
- runs `omarchy-plugin-validate` when that CLI exists
88+
- `omarchy-shell shell rescanPlugins` then `omarchy-plugin-enable`
89+
- offers `omarchy-webapp-install gadak http://127.0.0.1:7777 web-browser`
90+
(three arguments; fewer opens an interactive `gum` prompt —
91+
`bin/omarchy-webapp-install`)
92+
- prints `gadak install-service` as the way to keep `serve` up (systemd
93+
**user** unit on Linux; `cmd/gadak/service.go`). It does not install
94+
that unit itself.
95+
96+
`omarchy-plugin-clone` clones a **built-in** plugin into
97+
`~/.config/omarchy/plugins/<user>.<id>/`. It is the wrong verb here.
98+
`omarchy-plugin-add` clones a git URL whose root is a `manifest.json`;
99+
this plugin is a subdirectory of the gadak repo, so add cannot see it
100+
until it is published as its own repository.
101+
102+
If `gadak` is missing, the script prints the install options that exist
103+
today and still copies the plugin (the badge will say `no gadak`):
104+
105+
- **not** an AUR package — new AUR registration is closed
106+
(`docs/INSTALL.md`)
107+
- Linux tarball from
108+
<https://github.com/midagedev/gadak/releases/latest>:
109+
`gadak_<version>_linux_amd64.tar.gz` / `linux_arm64` plus
110+
`checksums.txt` (`README.md`)
111+
- `brew install midagedev/tap/gadak-cli`
112+
- in-repo `contrib/aur/gadak-bin` + `makepkg -si` (fetches that tarball)
113+
114+
Idempotent: a second run overwrites the copy, skips enable if already
115+
enabled, skips the desktop file if it exists.
116+
117+
```bash
118+
bash contrib/omarchy/uninstall.sh
119+
```
120+
121+
Disables and removes the plugin (`omarchy-plugin-remove --yes`) and the
122+
`gadak.desktop` web app. It does not remove the gadak binary or an
123+
`install-service` unit.
124+
125+
The hyphenated CLIs (`omarchy-plugin-enable`, `-validate`, `-remove`,
126+
`-list`) live in `basecamp/omarchy` `bin/` on branch `quattro`.
127+
`omarchy plugin …` is the documented dispatcher for the same scripts
128+
(`omarchy:alias` headers). This recipe calls the hyphenated names.
129+
130+
Plugins run **unsandboxed** inside `omarchy-shell`. Read the plugin
131+
before you enable it
132+
([Omarchy manual](https://omarchy.org/manual/shell-plugins/)).
133+
134+
## Offline gate
135+
136+
```bash
137+
bash contrib/omarchy/verify.sh
138+
```
139+
140+
Runs on macOS and Linux with no Omarchy present. Builds this repo's
141+
`gadak`, points a temp `GADAK_HOME` at a copy of `examples/demo.db`,
142+
and executes `gadak/query.sql`. CI:
143+
[`.github/workflows/omarchy.yml`](../../.github/workflows/omarchy.yml)
144+
(`actions/setup-go` + this script, path-scoped like the Scoop and AUR
145+
workflows). `qmllint` is skipped with a printed reason when the tool
146+
is absent.
147+
148+
## Community directory (later, not this round)
149+
150+
Listing at [omarchyplugins.com](https://omarchyplugins.com) /
151+
[HANCORE-linux/omarchy-plugin-marketplace](https://github.com/HANCORE-linux/omarchy-plugin-marketplace)
152+
is a lead step after this directory exists. Submission is an issue form
153+
plus maintainer review. The marketplace states that it does **not**
154+
security-audit plugins; they run as unsandboxed code. A listing also
155+
wants a standalone public git repo with `manifest.json` at the root,
156+
which this in-repo path is not.
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import QtQuick
2+
import Quickshell
3+
import Quickshell.Io
4+
import qs.Commons
5+
import qs.Ui
6+
7+
// Bar label: "<open>·<stuck>" from `gadak sql --json` of query.sql.
8+
// Data enters only through that command; this file never opens the mirror.
9+
// Click opens the gadak serve default bind (cmd/gadak/serve.go:41), not
10+
// gadak:// — that scheme is registered by the macOS app bundle only
11+
// (cmd/gadak/views.go deepLinkURL).
12+
BarWidget {
13+
id: root
14+
moduleName: "io.github.midagedev.gadak"
15+
16+
// serve.go default --addr. Named here so the port is not a magic number.
17+
readonly property string serveUrl: "http://127.0.0.1:7777"
18+
readonly property string queryPath: String(Qt.resolvedUrl("query.sql")).replace(/^file:\/\//, "")
19+
20+
// loading | ok | no-gadak | not-synced | sql-err.
21+
// Not named `state`: QQuickItem already declares that property, and
22+
// redeclaring it is a QML compile error we have no local qmllint to catch.
23+
property string viewState: "loading"
24+
property int openCount: 0
25+
property int stuckCount: 0
26+
27+
readonly property string displayText: {
28+
if (viewState === "ok") return openCount + "·" + stuckCount
29+
if (viewState === "no-gadak") return "no gadak"
30+
if (viewState === "not-synced") return "not synced"
31+
if (viewState === "sql-err") return "sql err"
32+
return ""
33+
}
34+
35+
readonly property string tooltipText: {
36+
if (viewState === "ok")
37+
return openCount + " open · " + stuckCount + " stuck >7d"
38+
if (viewState === "no-gadak")
39+
return "gadak is not on PATH. Install the Linux tarball from https://github.com/midagedev/gadak/releases/latest (gadak_<version>_linux_amd64.tar.gz or linux_arm64, plus checksums.txt)."
40+
if (viewState === "not-synced")
41+
return "no mirror — run gadak sync"
42+
if (viewState === "sql-err")
43+
return "gadak sql --json failed or was not NDJSON"
44+
return "reading gadak sql --json"
45+
}
46+
47+
function refresh() {
48+
if (queryProc.running) return
49+
if (queryPath === "") {
50+
viewState = "sql-err"
51+
return
52+
}
53+
// One argv is the query file; stdout is NDJSON. stderr is ignored for parse
54+
// (a stale-mirror warning is expected there).
55+
queryProc.command = ["bash", "-c", "gadak sql --json \"$(cat \"$1\")\"", "gadak-omarchy", queryPath]
56+
queryProc.running = true
57+
}
58+
59+
function openGadak() {
60+
if (root.bar && typeof root.bar.run === "function") {
61+
root.bar.run("omarchy-launch-webapp " + serveUrl)
62+
return
63+
}
64+
Quickshell.execDetached(["omarchy-launch-webapp", serveUrl])
65+
}
66+
67+
function applyResult(exitCode, stdout, stderr) {
68+
var err = String(stderr || "")
69+
var out = String(stdout || "").trim()
70+
var code = Number(exitCode)
71+
72+
// "no mirror" first: gadak ran, so it is not the missing-binary case.
73+
if (/no mirror/i.test(err)) {
74+
viewState = "not-synced"
75+
return
76+
}
77+
// 127 is the shell's "command not found". The message test names gadak
78+
// explicitly, because `cat` reports a missing query.sql with the same
79+
// "No such file or directory" and that is a broken install, not a
80+
// missing binary.
81+
if (code === 127 || /gadak: command not found/i.test(err)) {
82+
viewState = "no-gadak"
83+
return
84+
}
85+
if (code !== 0) {
86+
viewState = "sql-err"
87+
return
88+
}
89+
90+
var line = out.split("\n")[0] || ""
91+
var obj
92+
try {
93+
obj = JSON.parse(line)
94+
} catch (e) {
95+
viewState = "sql-err"
96+
return
97+
}
98+
if (!obj || typeof obj !== "object" || obj.open === undefined || obj.stuck === undefined) {
99+
viewState = "sql-err"
100+
return
101+
}
102+
var o = Number(obj.open)
103+
var s = Number(obj.stuck)
104+
if (!isFinite(o) || !isFinite(s)) {
105+
viewState = "sql-err"
106+
return
107+
}
108+
openCount = o
109+
stuckCount = s
110+
viewState = "ok"
111+
}
112+
113+
implicitWidth: button.implicitWidth
114+
implicitHeight: button.implicitHeight
115+
116+
Timer {
117+
interval: 60000
118+
repeat: true
119+
running: true
120+
triggeredOnStart: true
121+
onTriggered: root.refresh()
122+
}
123+
124+
Process {
125+
id: queryProc
126+
running: false
127+
command: []
128+
stdout: StdioCollector { id: queryOut; waitForEnd: true }
129+
stderr: StdioCollector { id: queryErr; waitForEnd: true }
130+
onExited: function (exitCode) {
131+
var code = (exitCode === undefined || exitCode === null) ? queryProc.exitCode : exitCode
132+
root.applyResult(code, String(queryOut.text || ""), String(queryErr.text || ""))
133+
}
134+
}
135+
136+
WidgetButton {
137+
id: button
138+
anchors.fill: parent
139+
bar: root.bar
140+
text: root.displayText
141+
tooltipText: root.tooltipText
142+
onPressed: function (b) {
143+
if (b === Qt.RightButton) root.refresh()
144+
else root.openGadak()
145+
}
146+
}
147+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"schemaVersion": 1,
3+
"id": "io.github.midagedev.gadak",
4+
"name": "gadak",
5+
"version": "0.1.0",
6+
"author": "midagedev",
7+
"description": "Open and stuck issue counts from the local gadak mirror, offline",
8+
"kinds": [
9+
"bar-widget"
10+
],
11+
"entryPoints": {
12+
"barWidget": "BarWidget.qml"
13+
},
14+
"barWidget": {
15+
"displayName": "gadak",
16+
"description": "Open and stuck counts from `gadak sql --json` (no network)",
17+
"category": "Info",
18+
"allowMultiple": false,
19+
"defaultSection": "right"
20+
}
21+
}

contrib/omarchy/gadak/query.sql

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Single owner of the bar-widget query. BarWidget.qml and verify.sh both
2+
-- run this file through `gadak sql --json`. Do not open the mirror file.
3+
--
4+
-- open = issues whose status_category is not `done`
5+
-- stuck = those same rows whose status_changed_at is older than 7 days
6+
-- (time-in-status is not a stored column; compute it here)
7+
-- NULL status_changed_at cannot be aged, so it is not stuck.
8+
SELECT
9+
COUNT(*) AS open,
10+
COALESCE(SUM(CASE
11+
WHEN status_changed_at IS NOT NULL
12+
AND julianday('now') - julianday(status_changed_at) > 7
13+
THEN 1 ELSE 0 END), 0) AS stuck
14+
FROM issues_full
15+
WHERE status_category != 'done';

0 commit comments

Comments
 (0)