plonk-mcp 0.0.4 → 0.1.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/dist/cli.js +239 -0
- package/dist/factory.js +2 -0
- package/dist/tools/awake.js +16 -3
- package/dist/tools/text.js +27 -0
- package/package.json +11 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `plonk` — the same loopback API the MCP tools use, from a shell.
|
|
3
|
+
//
|
|
4
|
+
// Agents reach Plonk over MCP, and the settings window covers the rest, but
|
|
5
|
+
// neither helps a script, a Raycast command or a Makefile. This is that gap:
|
|
6
|
+
// every subcommand is one HTTP call to 127.0.0.1, and nothing here holds state.
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { BASE, call, processIdentityHolder } from "./api.js";
|
|
9
|
+
const USAGE = `plonk — drive the Plonk menu bar app from a shell
|
|
10
|
+
|
|
11
|
+
plonk state [--json] screens, windows, zone sets, workspaces
|
|
12
|
+
plonk snap <app> <zone> drop a window into a numbered zone
|
|
13
|
+
plonk workspaces list saved workspaces
|
|
14
|
+
plonk launch <name> [--screen N] launch one
|
|
15
|
+
plonk save <name> save the desktop as one
|
|
16
|
+
plonk zones [--screen N] <set> assign a zone set to a monitor
|
|
17
|
+
plonk awake off
|
|
18
|
+
plonk awake on [--minutes N] [--until HH:MM] [--pid N]
|
|
19
|
+
plonk awake while <command...> stay awake until that command exits
|
|
20
|
+
plonk text [--mode region|window|screen] [--path FILE]
|
|
21
|
+
plonk shot [--mode region|window|screen] [--path FILE]
|
|
22
|
+
|
|
23
|
+
Everything talks to ${BASE}; Plonk.app has to be running.`;
|
|
24
|
+
/** Pulls "--name value" out of argv and returns what is left. */
|
|
25
|
+
function options(argv) {
|
|
26
|
+
const flags = {};
|
|
27
|
+
const rest = [];
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const arg = argv[i];
|
|
30
|
+
if (arg.startsWith("--")) {
|
|
31
|
+
const key = arg.slice(2);
|
|
32
|
+
const next = argv[i + 1];
|
|
33
|
+
if (next === undefined || next.startsWith("--")) {
|
|
34
|
+
flags[key] = "true";
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
flags[key] = next;
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
rest.push(arg);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { flags, rest };
|
|
46
|
+
}
|
|
47
|
+
/** Unwinds to the top instead of calling process.exit, which on a pipe cuts
|
|
48
|
+
* stdout off mid-write: `plonk state --json | jq` would get invalid JSON.
|
|
49
|
+
* Letting the process end on its own flushes first. */
|
|
50
|
+
class Exit extends Error {
|
|
51
|
+
code;
|
|
52
|
+
constructor(code) {
|
|
53
|
+
super(`exit ${code}`);
|
|
54
|
+
this.code = code;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function fail(message) {
|
|
58
|
+
console.error(`plonk: ${message}`);
|
|
59
|
+
throw new Exit(1);
|
|
60
|
+
}
|
|
61
|
+
function number(raw, what) {
|
|
62
|
+
if (raw === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
const value = Number(raw);
|
|
65
|
+
if (!Number.isInteger(value))
|
|
66
|
+
fail(`${what} must be a whole number, got "${raw}"`);
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
/** Prints the reply and exits non-zero when the app refused. */
|
|
70
|
+
function report(result) {
|
|
71
|
+
if ("error" in result) {
|
|
72
|
+
console.error(`plonk: ${result.error}`);
|
|
73
|
+
throw new Exit(1);
|
|
74
|
+
}
|
|
75
|
+
console.log(JSON.stringify(result, null, 2));
|
|
76
|
+
throw new Exit(0);
|
|
77
|
+
}
|
|
78
|
+
async function summarize() {
|
|
79
|
+
const state = await call("/state");
|
|
80
|
+
if ("error" in state)
|
|
81
|
+
report(state);
|
|
82
|
+
const lines = [];
|
|
83
|
+
lines.push(`awake ${state.awake_details.status}`);
|
|
84
|
+
lines.push(`screens ${state.screens.length}`);
|
|
85
|
+
for (const screen of state.screens) {
|
|
86
|
+
const set = state.screen_zone_sets[String(screen.index)];
|
|
87
|
+
const zones = set === "" ? "edge snapping" : (set ?? "Halves");
|
|
88
|
+
lines.push(` ${screen.index}: ${Math.round(screen.frame.w)}x${Math.round(screen.frame.h)} ${zones}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push(`workspaces ${state.saved_layouts.join(", ") || "none"}`);
|
|
91
|
+
lines.push(`zone sets ${Object.keys(state.zone_sets).sort().join(", ")}`);
|
|
92
|
+
if (state.excluded_apps?.length)
|
|
93
|
+
lines.push(`excluded ${state.excluded_apps.join(", ")}`);
|
|
94
|
+
lines.push(`windows ${state.windows.length}`);
|
|
95
|
+
for (const window of state.windows) {
|
|
96
|
+
lines.push(` ${window.app}${window.title ? ` — ${window.title}` : ""} [screen ${window.screen}]`);
|
|
97
|
+
}
|
|
98
|
+
console.log(lines.join("\n"));
|
|
99
|
+
throw new Exit(0);
|
|
100
|
+
}
|
|
101
|
+
/** Runs a command with its output passed through, holding keep-awake for
|
|
102
|
+
* exactly as long as it lives. The exit status is the command's own, so this
|
|
103
|
+
* drops into a Makefile without changing what a failure means. */
|
|
104
|
+
async function awakeWhile(argv) {
|
|
105
|
+
if (argv.length === 0)
|
|
106
|
+
fail("awake while needs a command to run");
|
|
107
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: "inherit" });
|
|
108
|
+
// Both listeners go on before the round trip below: a command that finishes
|
|
109
|
+
// inside it — `plonk awake while echo hi` — would otherwise fire exit into
|
|
110
|
+
// an empty room and hang here forever.
|
|
111
|
+
const finished = new Promise((resolve) => {
|
|
112
|
+
child.on("error", (err) => {
|
|
113
|
+
console.error(`plonk: could not run ${argv[0]}: ${err.message}`);
|
|
114
|
+
resolve(1);
|
|
115
|
+
});
|
|
116
|
+
child.on("exit", (code, signal) => resolve(signal ? 1 : (code ?? 0)));
|
|
117
|
+
});
|
|
118
|
+
// No pid means the spawn failed outright. Asking for keep-awake without one
|
|
119
|
+
// would hold an assertion nothing ever releases.
|
|
120
|
+
if (child.pid !== undefined) {
|
|
121
|
+
const started = await call("/awake", { method: "POST", body: { on: true, pid: child.pid } });
|
|
122
|
+
if ("error" in started) {
|
|
123
|
+
console.error(`plonk: keep-awake not held — ${started.error}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Plonk drops the assertion itself when the process goes; waiting here only
|
|
127
|
+
// makes the shell finish at the same moment.
|
|
128
|
+
throw new Exit(await finished);
|
|
129
|
+
}
|
|
130
|
+
async function main() {
|
|
131
|
+
const argv = process.argv.slice(2);
|
|
132
|
+
// Everything after `awake while` belongs to the command being run, flags
|
|
133
|
+
// included, so it is taken verbatim rather than parsed for plonk's own.
|
|
134
|
+
// Otherwise `plonk awake while cargo build --release` builds in debug.
|
|
135
|
+
if (argv[0] === "awake" && argv[1] === "while")
|
|
136
|
+
await awakeWhile(argv.slice(2));
|
|
137
|
+
const { flags, rest } = options(argv);
|
|
138
|
+
const [command, ...args] = rest;
|
|
139
|
+
const screen = number(flags.screen, "--screen");
|
|
140
|
+
switch (command) {
|
|
141
|
+
case undefined:
|
|
142
|
+
case "help":
|
|
143
|
+
case "-h":
|
|
144
|
+
case "--help":
|
|
145
|
+
console.log(USAGE);
|
|
146
|
+
return;
|
|
147
|
+
case "ping":
|
|
148
|
+
report(await call("/ping"));
|
|
149
|
+
break;
|
|
150
|
+
case "state":
|
|
151
|
+
if (flags.json)
|
|
152
|
+
report(await call("/state"));
|
|
153
|
+
await summarize();
|
|
154
|
+
break;
|
|
155
|
+
case "snap": {
|
|
156
|
+
const [app, zone] = args;
|
|
157
|
+
if (!app || zone === undefined)
|
|
158
|
+
fail("snap needs an app and a zone number, e.g. plonk snap Safari 1");
|
|
159
|
+
report(await call("/layout/zone", {
|
|
160
|
+
method: "POST",
|
|
161
|
+
body: { app, zone: number(zone, "zone"), screen },
|
|
162
|
+
}));
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "workspaces": {
|
|
166
|
+
const state = await call("/state");
|
|
167
|
+
if ("error" in state)
|
|
168
|
+
report(state);
|
|
169
|
+
console.log(state.saved_layouts.join("\n"));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
case "launch":
|
|
173
|
+
if (!args[0])
|
|
174
|
+
fail("launch needs a workspace name");
|
|
175
|
+
report(await call("/workspaces/launch", {
|
|
176
|
+
method: "POST",
|
|
177
|
+
body: { name: args[0], screen },
|
|
178
|
+
timeoutMs: 90_000,
|
|
179
|
+
}));
|
|
180
|
+
break;
|
|
181
|
+
case "save":
|
|
182
|
+
if (!args[0])
|
|
183
|
+
fail("save needs a name for the workspace");
|
|
184
|
+
report(await call("/workspaces/save", { method: "POST", body: { name: args[0] } }));
|
|
185
|
+
break;
|
|
186
|
+
case "zones":
|
|
187
|
+
if (!args[0])
|
|
188
|
+
fail("zones needs a set name, or 'edge' for edge snapping");
|
|
189
|
+
report(await call("/zones/assign", { method: "POST", body: { screen: screen ?? 0, name: args[0] } }));
|
|
190
|
+
break;
|
|
191
|
+
case "awake": {
|
|
192
|
+
// `while` is handled before flags are parsed; anything else must be on/off.
|
|
193
|
+
if (args[0] !== "on" && args[0] !== "off")
|
|
194
|
+
fail("awake needs 'on', 'off' or 'while <command>'");
|
|
195
|
+
report(await call("/awake", {
|
|
196
|
+
method: "POST",
|
|
197
|
+
body: {
|
|
198
|
+
on: args[0] === "on",
|
|
199
|
+
minutes: number(flags.minutes, "--minutes"),
|
|
200
|
+
until: flags.until,
|
|
201
|
+
pid: number(flags.pid, "--pid"),
|
|
202
|
+
},
|
|
203
|
+
}));
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "text": {
|
|
207
|
+
const result = await call("/shot/text", {
|
|
208
|
+
method: "POST",
|
|
209
|
+
body: { mode: flags.mode ?? "region", path: flags.path },
|
|
210
|
+
timeoutMs: 5 * 60_000,
|
|
211
|
+
});
|
|
212
|
+
if ("error" in result)
|
|
213
|
+
report(result);
|
|
214
|
+
// Bare text, so it can be piped.
|
|
215
|
+
console.log(result.text ?? "");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
case "shot":
|
|
219
|
+
report(await call("/shot/capture", {
|
|
220
|
+
method: "POST",
|
|
221
|
+
body: { mode: flags.mode ?? "region", path: flags.path },
|
|
222
|
+
timeoutMs: 5 * 60_000,
|
|
223
|
+
}));
|
|
224
|
+
break;
|
|
225
|
+
default:
|
|
226
|
+
fail(`unknown command "${command}"\n\n${USAGE}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Named so the app can attribute the calls, and so "only the active agent
|
|
230
|
+
// controls" can be pointed at the shell like anything else.
|
|
231
|
+
processIdentityHolder().identity = { name: "plonk-cli", version: "", pid: process.pid };
|
|
232
|
+
try {
|
|
233
|
+
await main();
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
if (!(err instanceof Exit))
|
|
237
|
+
throw err;
|
|
238
|
+
process.exitCode = err.code;
|
|
239
|
+
}
|
package/dist/factory.js
CHANGED
|
@@ -10,6 +10,7 @@ import { register as registerZones } from "./tools/zones.js";
|
|
|
10
10
|
import { register as registerAwake } from "./tools/awake.js";
|
|
11
11
|
import { register as registerScreenshot } from "./tools/screenshot.js";
|
|
12
12
|
import { register as registerAnnotate } from "./tools/annotate.js";
|
|
13
|
+
import { register as registerText } from "./tools/text.js";
|
|
13
14
|
import { register as registerAgents } from "./tools/agents.js";
|
|
14
15
|
import { register as registerUpdate } from "./tools/update.js";
|
|
15
16
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
@@ -22,6 +23,7 @@ export function createPlonkServer() {
|
|
|
22
23
|
registerAwake(server);
|
|
23
24
|
registerScreenshot(server);
|
|
24
25
|
registerAnnotate(server);
|
|
26
|
+
registerText(server);
|
|
25
27
|
registerAgents(server);
|
|
26
28
|
registerUpdate(server);
|
|
27
29
|
return server;
|
package/dist/tools/awake.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { call, text } from "../api.js";
|
|
3
3
|
export function register(server) {
|
|
4
|
-
server.tool("set_awake", "Turn keep-awake on or off
|
|
4
|
+
server.tool("set_awake", "Turn keep-awake on or off, so the Mac does not sleep part-way through something. " +
|
|
5
|
+
"Three ways to end the session, in order of preference: 'pid' ends it the moment that process exits — best by far when something is running, because a build or a render knows when it is finished and nothing is left holding the machine awake afterwards; 'until' ends it at a wall-clock time ('17:00', or an ISO-8601 timestamp); 'minutes' ends it after a countdown. Give none of them and it runs until switched off. " +
|
|
6
|
+
"Behavior also follows the user's settings: keep-awake may pause on battery or engage automatically while charging, so the returned 'status' is what actually happened and 'awake' is whether an assertion is held right now. The menu bar icon glows while it is. " +
|
|
7
|
+
"A process-bound session is deliberately not restored if Plonk restarts, since the pid would mean nothing by then. Errors come back for a pid that is not running or a time that has already passed.", {
|
|
5
8
|
on: z.boolean(),
|
|
6
|
-
minutes: z.number().int().min(1).optional().describe("
|
|
7
|
-
|
|
9
|
+
minutes: z.number().int().min(1).optional().describe("End the session after this many minutes"),
|
|
10
|
+
until: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("End at a time of day, e.g. '17:00' (the next such moment — tomorrow if today's has passed), or an ISO-8601 timestamp like '2026-08-08T17:00:00Z'"),
|
|
14
|
+
pid: z
|
|
15
|
+
.number()
|
|
16
|
+
.int()
|
|
17
|
+
.min(1)
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("End when this process exits. Use the pid of the long job being waited on; get_state lists a pid for every open window"),
|
|
20
|
+
}, async ({ on, minutes, until, pid }) => text(await call("/awake", { method: "POST", body: { on, minutes, until, pid } })));
|
|
8
21
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { call, text } from "../api.js";
|
|
3
|
+
// The interactive mode hands the user a crosshair and waits for them.
|
|
4
|
+
const INTERACTIVE_TIMEOUT_MS = 5 * 60_000;
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.tool("extract_text", "Read the words off the screen, or off a saved image, and return them as text. Recognition runs on the Mac itself and nothing is uploaded. " +
|
|
7
|
+
"Prefer this over take_screenshot whenever the answer is words rather than a picture — an error dialog, a log, a terminal, a table, text baked into an image or a paused video, a PDF page in a viewer that will not let text be selected. It costs a fraction of the tokens an image does and does not depend on reading pixels correctly. " +
|
|
8
|
+
"Use take_screenshot instead when layout, colour or 'what does this look like' is the question. " +
|
|
9
|
+
"mode 'screen' captures everything with no user interaction; 'region' and 'window' hand the user the native crosshair or window picker and wait for them, up to five minutes. Pass 'path' instead of a mode to read an image already on disk, including one take_screenshot just wrote. " +
|
|
10
|
+
"Returns 'text' (every line in reading order, top to bottom) and 'lines' — each with the recognized string, Vision's 0..1 'confidence', and 'box' {x,y,w,h} as fractions 0..1 of the image with origin at TOP-LEFT. Those boxes share the coordinate space annotate_screenshot draws in, so a line can be circled where it was found by passing the same path to that tool. The text is also copied to the clipboard unless 'clipboard' is false. " +
|
|
11
|
+
"An area with no readable text returns ok with an empty 'text' and a 'note' rather than an error.", {
|
|
12
|
+
mode: z
|
|
13
|
+
.enum(["screen", "region", "window"])
|
|
14
|
+
.default("region")
|
|
15
|
+
.describe("What to capture; ignored when 'path' is given"),
|
|
16
|
+
path: z.string().optional().describe("Read this image file instead of capturing (.png, .jpg)"),
|
|
17
|
+
clipboard: z.boolean().optional().describe("Copy the recognized text to the clipboard (default true)"),
|
|
18
|
+
languages: z
|
|
19
|
+
.array(z.string())
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("BCP-47 tags to recognize, most likely first, e.g. ['uk-UA','en-US']. Omit to use the user's configured choice. Which are available depends on the macOS version; get_state lists the current setting under 'text_languages'"),
|
|
22
|
+
}, async ({ mode, path, clipboard, languages }) => text(await call("/shot/text", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
body: { mode, path, clipboard, languages },
|
|
25
|
+
timeoutMs: path || mode === "screen" ? 60_000 : INTERACTIVE_TIMEOUT_MS,
|
|
26
|
+
})));
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plonk-mcp",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"mcpName": "io.github.ostapondo/plonk",
|
|
5
5
|
"description": "MCP server for Plonk — the Mac window manager your AI agent can drive. Layouts, workspaces, snap zones, keep-awake and screenshots.",
|
|
6
6
|
"type": "module",
|
|
@@ -21,9 +21,16 @@
|
|
|
21
21
|
"workspaces",
|
|
22
22
|
"screenshots"
|
|
23
23
|
],
|
|
24
|
-
"engines": {
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"bin": {
|
|
31
|
+
"plonk-mcp": "dist/server.js",
|
|
32
|
+
"plonk": "dist/cli.js"
|
|
33
|
+
},
|
|
27
34
|
"main": "dist/server.js",
|
|
28
35
|
"scripts": {
|
|
29
36
|
"build": "tsc",
|