plonk-mcp 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/cli.js +17 -4
- package/dist/schemas.js +13 -1
- package/dist/tools/layouts.js +4 -2
- package/dist/tools/state.js +1 -1
- package/dist/tools/zones.js +7 -1
- package/dist/types.js +1 -0
- package/dist/version.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# plonk-mcp
|
|
2
2
|
|
|
3
3
|
The MCP server for [Plonk](https://github.com/ostapondo/plonk), a macOS menu bar
|
|
4
|
-
app that is
|
|
5
|
-
work the desk: apply layouts across monitors, save and relaunch
|
|
6
|
-
windows into zones, keep the screen awake, take and annotate
|
|
7
|
-
read text off the screen without uploading a pixel.
|
|
4
|
+
app that is eight utilities at once, a window manager among them. It lets an
|
|
5
|
+
agent work the desk: apply layouts across monitors, save and relaunch
|
|
6
|
+
workspaces, snap windows into zones, keep the screen awake, take and annotate
|
|
7
|
+
screenshots, and read text off the screen without uploading a pixel.
|
|
8
8
|
|
|
9
9
|
> browser on the left 60%, terminal top right, notes bottom right
|
|
10
10
|
>
|
|
@@ -54,11 +54,14 @@ config to tell two sessions of the same client apart.
|
|
|
54
54
|
| | |
|
|
55
55
|
| --- | --- |
|
|
56
56
|
| `get_state` | Monitors, every open window and where it sits, zone sets, saved workspaces, awake status |
|
|
57
|
-
| `apply_layout` · `snap_window` | Place windows by fraction of a screen, or drop one into a
|
|
57
|
+
| `apply_layout` · `snap_window` | Place windows by fraction of a screen, or drop one into a zone by number or by name |
|
|
58
58
|
| `save_workspace` · `launch_workspace` · `delete_workspace` | Named desktops that reopen their apps and restore every window |
|
|
59
59
|
| `save_zone_set` · `assign_zone_set` · `delete_zone_set` | Snap zones, assigned per monitor |
|
|
60
|
+
| `set_app_rule` · `clear_app_rule` | Where an app's new windows open: a zone, and optionally a monitor |
|
|
60
61
|
| `set_awake` | Keep the Mac awake, optionally time-limited, optionally also holding your chat status at available |
|
|
61
62
|
| `take_screenshot` · `annotate_screenshot` | Capture, mark up, hand the image back — `mode: "app"` photographs one named window even when it is buried, without raising it |
|
|
63
|
+
| `extract_text` | Read the words off the screen, with a box per line in the same coordinates `annotate_screenshot` draws in |
|
|
64
|
+
| `measure_screen` | How far a point can travel each way before it meets an edge, or the distance between two points, in points and pixels |
|
|
62
65
|
| `select_agent` | Make an agent the active one, optionally the only one allowed to control |
|
|
63
66
|
| `check_for_update` · `install_update` | Ask GitHub for a newer release and install it |
|
|
64
67
|
|
package/dist/cli.js
CHANGED
|
@@ -8,10 +8,11 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { BASE, call, INTERACTIVE_TIMEOUT_MS, processIdentityHolder } from "./api.js";
|
|
9
9
|
import { options } from "./args.js";
|
|
10
10
|
import { CLI_NAME } from "./messages.js";
|
|
11
|
+
import { PACKAGE_VERSION } from "./version.js";
|
|
11
12
|
const USAGE = `plonk — drive the Plonk menu bar app from a shell
|
|
12
13
|
|
|
13
14
|
plonk state [--json] screens, windows, zone sets, workspaces
|
|
14
|
-
plonk snap <app> <zone> drop a window into a
|
|
15
|
+
plonk snap <app> <zone> drop a window into a zone, by number or name
|
|
15
16
|
plonk workspaces list saved workspaces
|
|
16
17
|
plonk launch <name> [--screen N] launch one
|
|
17
18
|
plonk save <name> save the desktop as one
|
|
@@ -80,6 +81,11 @@ async function summarize() {
|
|
|
80
81
|
lines.push(`zone sets ${Object.keys(state.zone_sets).sort().join(", ")}`);
|
|
81
82
|
if (state.excluded_apps?.length)
|
|
82
83
|
lines.push(`excluded ${state.excluded_apps.join(", ")}`);
|
|
84
|
+
if (state.app_rules?.length) {
|
|
85
|
+
const where = (rule) => rule.screen !== undefined ? ` on screen ${rule.screen}` : rule.screen_uuid ? " on a screen that is not attached" : "";
|
|
86
|
+
const rules = state.app_rules.map((rule) => `${rule.app} -> zone ${rule.zone}${where(rule)}`);
|
|
87
|
+
lines.push(`rules ${rules.join(", ")}`);
|
|
88
|
+
}
|
|
83
89
|
lines.push(`windows ${state.windows.length}`);
|
|
84
90
|
for (const window of state.windows) {
|
|
85
91
|
lines.push(` ${window.app}${window.title ? ` — ${window.title}` : ""} [screen ${window.screen}]`);
|
|
@@ -118,6 +124,10 @@ async function awakeWhile(argv) {
|
|
|
118
124
|
}
|
|
119
125
|
async function main() {
|
|
120
126
|
const argv = process.argv.slice(2);
|
|
127
|
+
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v")) {
|
|
128
|
+
console.log(PACKAGE_VERSION);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
121
131
|
// Everything after `awake while` belongs to the command being run, flags
|
|
122
132
|
// included, so it is taken verbatim rather than parsed for plonk's own.
|
|
123
133
|
// Otherwise `plonk awake while cargo build --release` builds in debug.
|
|
@@ -142,10 +152,13 @@ async function main() {
|
|
|
142
152
|
case "snap": {
|
|
143
153
|
const [app, zone] = args;
|
|
144
154
|
if (!app || zone === undefined)
|
|
145
|
-
fail("snap needs an app and a zone
|
|
155
|
+
fail("snap needs an app and a zone, e.g. plonk snap Safari 1 or plonk snap Slack chat");
|
|
156
|
+
// A whole number travels as one, which is what every app so far
|
|
157
|
+
// expects; anything else is a name, and the app says so if the set
|
|
158
|
+
// has neither.
|
|
146
159
|
report(await call("/layout/zone", {
|
|
147
160
|
method: "POST",
|
|
148
|
-
body: { app, zone:
|
|
161
|
+
body: { app, zone: Number.isInteger(Number(zone)) ? Number(zone) : zone, screen },
|
|
149
162
|
}));
|
|
150
163
|
}
|
|
151
164
|
case "workspaces": {
|
|
@@ -226,7 +239,7 @@ async function main() {
|
|
|
226
239
|
}
|
|
227
240
|
// Named so the app can attribute the calls, and so "only the active agent
|
|
228
241
|
// controls" can be pointed at the shell like anything else.
|
|
229
|
-
processIdentityHolder().identity = { name: CLI_NAME, version:
|
|
242
|
+
processIdentityHolder().identity = { name: CLI_NAME, version: PACKAGE_VERSION, pid: process.pid };
|
|
230
243
|
try {
|
|
231
244
|
await main();
|
|
232
245
|
}
|
package/dist/schemas.js
CHANGED
|
@@ -18,7 +18,19 @@ export const itemsSchema = z
|
|
|
18
18
|
frame: frameSchema,
|
|
19
19
|
}))
|
|
20
20
|
.min(1);
|
|
21
|
-
|
|
21
|
+
/** A zone is a frame with, optionally, what it is called. The app decides
|
|
22
|
+
* what a name may be (it cuts one at 24 characters and refuses a bare
|
|
23
|
+
* number), the way it decides the gap; measuring it here would count code
|
|
24
|
+
* units and refuse names the app itself keeps. */
|
|
25
|
+
export const zoneSchema = frameSchema.extend({
|
|
26
|
+
name: z
|
|
27
|
+
.string()
|
|
28
|
+
.trim()
|
|
29
|
+
.min(1)
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("What the zone is called, e.g. 'chat': drawn under its number, spoken to, and accepted by snap_window instead of the number. Unique within the set"),
|
|
32
|
+
});
|
|
33
|
+
export const zonesSchema = z.array(zoneSchema).min(1);
|
|
22
34
|
export const workspaceItemsSchema = z
|
|
23
35
|
.array(z.object({
|
|
24
36
|
app: z.string().describe("App name as it appears in get_state, e.g. 'Safari'"),
|
package/dist/tools/layouts.js
CHANGED
|
@@ -11,9 +11,11 @@ export function register(server) {
|
|
|
11
11
|
items: itemsSchema.optional(),
|
|
12
12
|
}, async ({ name, items }) => text(await call("/workspaces/save", { method: "POST", body: { name, items } })));
|
|
13
13
|
server.tool("apply_saved_layout", "Launch a saved workspace by name. Legacy name kept for older clients — new integrations should call launch_workspace, which adds a 'screen' option to pull the whole workspace onto one monitor. Opens every app that is not running, waits for its windows, and moves them into the saved positions; macOS cannot open an app straight into a position, so windows appear first and jump into place. Returns per-app success and reports apps that never opened a window. Takes up to a minute for a large workspace.", { name: z.string() }, async ({ name }) => text(await call("/workspaces/launch", { method: "POST", body: { name }, timeoutMs: 90_000 })));
|
|
14
|
-
server.tool("snap_window", "Drop one window into a
|
|
14
|
+
server.tool("snap_window", "Drop one window into a zone of the snap-zone set assigned to that monitor, by number or by name. The numbers are the ones Plonk draws on the zones while a window is dragged, so 'the middle zone' of a three-zone set is 2; a name is whatever the set calls a zone ('chat'), listed per zone in get_state.zone_sets, matched ignoring case. Fails with the zones that screen does have, names included, when neither matches. Zone sets and their per-monitor assignment are in get_state; use apply_layout instead when the user describes a size rather than a zone.", {
|
|
15
15
|
app: z.string().describe("App name to match, e.g. 'Visual Studio Code'"),
|
|
16
|
-
zone: z
|
|
16
|
+
zone: z
|
|
17
|
+
.union([z.number().int().min(1), z.string().min(1)])
|
|
18
|
+
.describe("1-based zone number as shown on the drag overlay, or the zone's name from get_state.zone_sets"),
|
|
17
19
|
title: z.string().optional().describe("Only windows whose title contains this substring"),
|
|
18
20
|
screen: z.number().int().optional().describe("Monitor index; defaults to the one the window is on"),
|
|
19
21
|
}, async (args) => text(await call("/layout/zone", { method: "POST", body: args })));
|
package/dist/tools/state.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { call, text } from "../api.js";
|
|
2
2
|
export function register(server) {
|
|
3
|
-
server.tool("get_state", "Get the current desktop state: all screens/monitors (index, frame, visible area — coordinates have origin at top-left of the primary screen, y grows down), all open windows (app name, title, which screen it is on, absolute frame, and 'fraction' — its position as fractions 0..1 of that screen's visible area), saved layout names, whether a keep-awake session is holding, and 'disabled_features': the modules the user switched off in Plonk (zones, workspaces, shot, ruler, awake and so on). A tool belonging to one of those fails with an error saying so until the user switches it back on. ALWAYS call this first before applying a layout, to see which apps are running and how many monitors there are.", {}, async () => text(await call("/state")));
|
|
3
|
+
server.tool("get_state", "Get the current desktop state: all screens/monitors (index, frame, visible area — coordinates have origin at top-left of the primary screen, y grows down), all open windows (app name, title, which screen it is on, absolute frame, and 'fraction' — its position as fractions 0..1 of that screen's visible area), 'zone_sets' (every set by name, each zone as {x,y,w,h} with a 'name' where the set gives it one), 'screen_zone_sets' (which set each monitor wears), saved layout names, 'app_rules' (where each app's new windows open, see set_app_rule), 'place_new_windows' and 'auto_fill_zones', whether a keep-awake session is holding, and 'disabled_features': the modules the user switched off in Plonk (zones, workspaces, shot, ruler, awake and so on). A tool belonging to one of those fails with an error saying so until the user switches it back on. ALWAYS call this first before applying a layout, to see which apps are running and how many monitors there are.", {}, async () => text(await call("/state")));
|
|
4
4
|
}
|
package/dist/tools/zones.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { call, text } from "../api.js";
|
|
3
3
|
import { zonesSchema } from "../schemas.js";
|
|
4
4
|
export function register(server) {
|
|
5
|
-
server.tool("save_zone_set", "Create or replace a named zone set used for drag snapping. Zones are rectangles {x,y,w,h} as fractions 0..1 of a screen's visible area, origin TOP-LEFT; each zone must stay inside the screen, but zones may overlap each other (the smallest one under the cursor wins). Pass 'screen' to also assign the set to that monitor so it becomes active immediately. Pass 'gap' to give this set its own spacing around windows in points, or null to make it follow the default gap again; omitting it keeps whatever the set had. Built-in sets already exist: Halves, Thirds, 60 / 40, Quarters, Priority.", {
|
|
5
|
+
server.tool("save_zone_set", "Create or replace a named zone set used for drag snapping. Zones are rectangles {x,y,w,h} as fractions 0..1 of a screen's visible area, origin TOP-LEFT; each zone must stay inside the screen, but zones may overlap each other (the smallest one under the cursor wins). A zone may also carry a 'name' ('chat', 'editor'): it is drawn under the zone's number, the user can say it out loud, and snap_window takes it instead of the number; names must be unique within the set, ignoring case, and cannot be a bare number. Pass 'screen' to also assign the set to that monitor so it becomes active immediately. Pass 'gap' to give this set its own spacing around windows in points, or null to make it follow the default gap again; omitting it keeps whatever the set had. Built-in sets already exist: Halves, Thirds, 60 / 40, Quarters, Priority.", {
|
|
6
6
|
name: z.string().describe("Zone set name, e.g. 'coding'"),
|
|
7
7
|
zones: zonesSchema,
|
|
8
8
|
screen: z.number().int().optional().describe("Monitor index to assign this set to (0 = primary)"),
|
|
@@ -18,4 +18,10 @@ export function register(server) {
|
|
|
18
18
|
name: z.string().optional().describe("Zone set name, or 'edge' for edge snapping; omit for the default set"),
|
|
19
19
|
}, async ({ screen, name }) => text(await call("/zones/assign", { method: "POST", body: { screen, name } })));
|
|
20
20
|
server.tool("delete_zone_set", "Delete a saved zone set by name. Any monitor currently using it falls back to the default set (Halves), so snapping keeps working. Only sets made with save_zone_set can go: the built-ins (Halves, Thirds, 60 / 40, Quarters, Priority) are refused. Deleting is immediate and cannot be undone — the zones would have to be described again. Saved sets and their per-monitor assignments are listed in get_state; use assign_zone_set instead when a monitor should merely stop using a set that others still need.", { name: z.string().describe("Saved zone set name, as shown in get_state") }, async ({ name }) => text(await call("/zones/delete", { method: "POST", body: { name } })));
|
|
21
|
+
server.tool("set_app_rule", "Make an app's windows open into a numbered zone from now on, so an arrangement holds without anyone dragging: 'Slack always in zone 1 on the second monitor'. 'app' is matched anywhere in the app's name or bundle id, case-insensitively, the way get_state.excluded_apps entries are; a bundle id such as 'com.tinyspeck.slackmacgap' is the safest form. 'zone' is the number Plonk draws on the zone (1-based) in the set assigned to that monitor. 'screen' is a monitor index from get_state and is stored as that display's identity, so it survives a reboot renumbering the screens; omit it and the window stays on whichever screen it opened on. One rule per app: setting it again replaces the old one; a rule that names the app exactly wins over a bare-word rule. Applies to ordinary windows that open after it is set, not to windows already open (use snap_window for those) and not to dialogs or panels. An app on get_state.excluded_apps is left alone even with a rule. A rule beats the habit Plonk keeps of where an app's last window went, and both beat filling an empty zone. Current rules are get_state.app_rules. Fails when 'screen' names a monitor that is not attached or a zone that monitor's set does not have.", {
|
|
22
|
+
app: z.string().describe("App name or bundle id to match, e.g. 'com.apple.Safari' or 'Safari'"),
|
|
23
|
+
zone: z.number().int().min(1).describe("1-based zone number, as shown on the drag overlay"),
|
|
24
|
+
screen: z.number().int().optional().describe("Monitor index from get_state (0 = primary); omit for the screen the window opens on"),
|
|
25
|
+
}, async ({ app, zone, screen }) => text(await call("/zones/rules", { method: "POST", body: { app, zone, screen } })));
|
|
26
|
+
server.tool("clear_app_rule", "Remove the rule set for an app with set_app_rule, so its new windows open wherever the app puts them again, or where its last window went when that habit is switched on in Plonk. 'app' is the pattern exactly as get_state.app_rules lists it, ignoring case. Fails when no rule matches; nothing else changes.", { app: z.string().describe("The app pattern as listed in get_state.app_rules") }, async ({ app }) => text(await call("/zones/rules/delete", { method: "POST", body: { app } })));
|
|
21
27
|
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plonk-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"mcpName": "io.github.ostapondo/plonk",
|
|
5
5
|
"description": "MCP server for Plonk, a macOS window manager: zones you draw yourself, workspaces, keep-awake, screenshots and on-device OCR, as tools an agent can call.",
|
|
6
6
|
"type": "module",
|