plonk-mcp 0.0.5 → 0.2.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 CHANGED
@@ -40,7 +40,10 @@ server. One-pagers for
40
40
 
41
41
  A client that cannot spawn a process connects over HTTP instead:
42
42
  `npx -y plonk-mcp --http` serves Streamable HTTP at
43
- `http://127.0.0.1:43918/mcp` (loopback only, `--port` to change).
43
+ `http://127.0.0.1:43918/mcp` (loopback only, `--port` to change). Requests to
44
+ it carry the same token as the app's own API — send the contents of
45
+ `~/Library/Application Support/Plonk/token` as an `X-Plonk-Token` header. The
46
+ stdio transport reads that file itself and needs no configuration.
44
47
 
45
48
  Several clients may be connected at once. Set `PLONK_AGENT_NAME` in a client's
46
49
  config to tell two sessions of the same client apart.
@@ -70,5 +73,10 @@ The server talks to the app over loopback HTTP on `127.0.0.1:43917` and nowhere
70
73
  else. No account, no cloud, no telemetry. It depends only on the official MCP
71
74
  SDK and zod.
72
75
 
76
+ The app gates that API on a token it writes to
77
+ `~/Library/Application Support/Plonk/token`. This server reads the file itself,
78
+ so there is nothing to configure — but it does have to run as the same user
79
+ Plonk is running as.
80
+
73
81
  MIT. Source, screenshots and the rest of the documentation are in the
74
82
  [repository](https://github.com/ostapondo/plonk).
package/dist/api.js CHANGED
@@ -35,17 +35,90 @@ function agentHeaders() {
35
35
  "x-plonk-agent-pid": String(id.pid),
36
36
  };
37
37
  }
38
+ // The app gates its API on a secret it writes to a file only this user can
39
+ // read, because binding to loopback keeps the network out and does nothing
40
+ // about the machine. Reading it is the whole handshake: anything that can read
41
+ // the file could ask macOS for the screen directly.
42
+ import { readFileSync } from "node:fs";
43
+ import { homedir } from "node:os";
44
+ import { join } from "node:path";
45
+ const TOKEN_PATH = join(homedir(), "Library", "Application Support", "Plonk", "token");
46
+ let cachedToken;
47
+ function readTokenFile() {
48
+ try {
49
+ return readFileSync(TOKEN_PATH, "utf8").trim() || undefined;
50
+ }
51
+ catch {
52
+ // No app yet, or a file this user cannot read. The request goes without a
53
+ // token and the app's own 401 explains it better than a guess here would.
54
+ return undefined;
55
+ }
56
+ }
57
+ /** Only a successful read is cached: a client started before the app has ever
58
+ * run would otherwise never see the token the app writes on first launch. */
59
+ function apiToken() {
60
+ if (cachedToken)
61
+ return cachedToken;
62
+ cachedToken = readTokenFile();
63
+ return cachedToken;
64
+ }
65
+ /** The token the HTTP transport gates its own callers on, read from disk every
66
+ * time rather than from the cache above.
67
+ *
68
+ * A cache here would be a gate on a secret the app may already have replaced:
69
+ * it would keep accepting the token a restored backup leaked — the exact one
70
+ * the app rotated away from — and keep refusing the current one, until this
71
+ * process was restarted. Nothing else could fix it, because the retry that
72
+ * refreshes the cache lives on the far side of this check and never runs when
73
+ * no session gets in. It is one small read per request on a transport that
74
+ * handles few. */
75
+ export function localApiToken() {
76
+ const fresh = readTokenFile();
77
+ // Outgoing calls may as well learn about a rotation from the same read.
78
+ if (fresh !== undefined && fresh !== cachedToken)
79
+ cachedToken = fresh;
80
+ return fresh;
81
+ }
82
+ /** Drops the cache and reads again, true only when the file now holds
83
+ * something other than what this request actually sent — the one case where
84
+ * repeating a refused request can help.
85
+ *
86
+ * `sent` rather than the cache, because the cache is shared: the hello
87
+ * heartbeat and the inbox long-poll are always in flight beside a tool call,
88
+ * so after a rotation the first 401 refreshes the cache and every other
89
+ * request in the air would find it already fresh and give up, handing the
90
+ * model a token error for a token that had just been fixed. */
91
+ function refreshToken(sent) {
92
+ cachedToken = undefined;
93
+ const fresh = apiToken();
94
+ return fresh !== undefined && fresh !== sent;
95
+ }
38
96
  export async function call(path, options = {}) {
39
97
  const { method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
40
98
  const timeout = AbortSignal.timeout(timeoutMs);
41
- let res;
42
- try {
43
- res = await fetch(BASE + path, {
99
+ // Captured per attempt, so the retry can tell "the token changed" from
100
+ // "somebody else refreshed the cache while this request was in the air".
101
+ let sent;
102
+ const send = () => {
103
+ sent = apiToken();
104
+ return fetch(BASE + path, {
44
105
  method,
45
- headers: { "content-type": "application/json", ...agentHeaders() },
106
+ headers: {
107
+ "content-type": "application/json",
108
+ ...agentHeaders(),
109
+ ...(sent ? { "x-plonk-token": sent } : {}),
110
+ },
46
111
  body: body !== undefined ? JSON.stringify(body) : undefined,
47
112
  signal: timeout,
48
113
  });
114
+ };
115
+ let res;
116
+ try {
117
+ res = await send();
118
+ // A long-lived client outlives the token if the file is ever replaced.
119
+ // One re-read makes that a hiccup instead of a session to restart.
120
+ if (res.status === 401 && refreshToken(sent))
121
+ res = await send();
49
122
  }
50
123
  catch (err) {
51
124
  if (timeout.aborted) {
package/dist/args.js ADDED
@@ -0,0 +1,32 @@
1
+ // Argument parsing for the `plonk` command, kept out of cli.ts so it can be
2
+ // tested without running the CLI: importing cli.ts runs it.
3
+ /**
4
+ * Pulls "--name value" out of argv and returns what is left, in order.
5
+ *
6
+ * A flag with nothing usable after it — the end of argv, or another flag — is
7
+ * a switch and reads as "true", so `--json` and `--screen 1` parse the same
8
+ * way. Values are not converted here; the caller knows which of its flags are
9
+ * numbers and says so in the error when one is not.
10
+ */
11
+ export function options(argv) {
12
+ const flags = {};
13
+ const rest = [];
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const arg = argv[i];
16
+ if (arg.startsWith("--")) {
17
+ const key = arg.slice(2);
18
+ const next = argv[i + 1];
19
+ if (next === undefined || next.startsWith("--")) {
20
+ flags[key] = "true";
21
+ }
22
+ else {
23
+ flags[key] = next;
24
+ i++;
25
+ }
26
+ }
27
+ else {
28
+ rest.push(arg);
29
+ }
30
+ }
31
+ return { flags, rest };
32
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,217 @@
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
+ import { options } from "./args.js";
10
+ const USAGE = `plonk — drive the Plonk menu bar app from a shell
11
+
12
+ plonk state [--json] screens, windows, zone sets, workspaces
13
+ plonk snap <app> <zone> drop a window into a numbered zone
14
+ plonk workspaces list saved workspaces
15
+ plonk launch <name> [--screen N] launch one
16
+ plonk save <name> save the desktop as one
17
+ plonk zones [--screen N] <set> assign a zone set to a monitor
18
+ plonk awake off
19
+ plonk awake on [--minutes N] [--until HH:MM] [--pid N]
20
+ plonk awake while <command...> stay awake until that command exits
21
+ plonk text [--mode region|window|screen] [--path FILE]
22
+ plonk shot [--mode region|window|screen] [--path FILE]
23
+
24
+ Everything talks to ${BASE}; Plonk.app has to be running.`;
25
+ /** Unwinds to the top instead of calling process.exit, which on a pipe cuts
26
+ * stdout off mid-write: `plonk state --json | jq` would get invalid JSON.
27
+ * Letting the process end on its own flushes first. */
28
+ class Exit extends Error {
29
+ code;
30
+ constructor(code) {
31
+ super(`exit ${code}`);
32
+ this.code = code;
33
+ }
34
+ }
35
+ function fail(message) {
36
+ console.error(`plonk: ${message}`);
37
+ throw new Exit(1);
38
+ }
39
+ function number(raw, what) {
40
+ if (raw === undefined)
41
+ return undefined;
42
+ const value = Number(raw);
43
+ if (!Number.isInteger(value))
44
+ fail(`${what} must be a whole number, got "${raw}"`);
45
+ return value;
46
+ }
47
+ /** Prints the reply and exits non-zero when the app refused. */
48
+ function report(result) {
49
+ if ("error" in result) {
50
+ console.error(`plonk: ${result.error}`);
51
+ throw new Exit(1);
52
+ }
53
+ console.log(JSON.stringify(result, null, 2));
54
+ throw new Exit(0);
55
+ }
56
+ async function summarize() {
57
+ const state = await call("/state");
58
+ if ("error" in state)
59
+ report(state);
60
+ const lines = [];
61
+ lines.push(`awake ${state.awake_details.status}`);
62
+ lines.push(`screens ${state.screens.length}`);
63
+ for (const screen of state.screens) {
64
+ const set = state.screen_zone_sets[String(screen.index)];
65
+ const zones = set === "" ? "edge snapping" : (set ?? "Halves");
66
+ lines.push(` ${screen.index}: ${Math.round(screen.frame.w)}x${Math.round(screen.frame.h)} ${zones}`);
67
+ }
68
+ lines.push(`workspaces ${state.saved_layouts.join(", ") || "none"}`);
69
+ lines.push(`zone sets ${Object.keys(state.zone_sets).sort().join(", ")}`);
70
+ if (state.excluded_apps?.length)
71
+ lines.push(`excluded ${state.excluded_apps.join(", ")}`);
72
+ lines.push(`windows ${state.windows.length}`);
73
+ for (const window of state.windows) {
74
+ lines.push(` ${window.app}${window.title ? ` — ${window.title}` : ""} [screen ${window.screen}]`);
75
+ }
76
+ console.log(lines.join("\n"));
77
+ throw new Exit(0);
78
+ }
79
+ /** Runs a command with its output passed through, holding keep-awake for
80
+ * exactly as long as it lives. The exit status is the command's own, so this
81
+ * drops into a Makefile without changing what a failure means. */
82
+ async function awakeWhile(argv) {
83
+ if (argv.length === 0)
84
+ fail("awake while needs a command to run");
85
+ const child = spawn(argv[0], argv.slice(1), { stdio: "inherit" });
86
+ // Both listeners go on before the round trip below: a command that finishes
87
+ // inside it — `plonk awake while echo hi` — would otherwise fire exit into
88
+ // an empty room and hang here forever.
89
+ const finished = new Promise((resolve) => {
90
+ child.on("error", (err) => {
91
+ console.error(`plonk: could not run ${argv[0]}: ${err.message}`);
92
+ resolve(1);
93
+ });
94
+ child.on("exit", (code, signal) => resolve(signal ? 1 : (code ?? 0)));
95
+ });
96
+ // No pid means the spawn failed outright. Asking for keep-awake without one
97
+ // would hold an assertion nothing ever releases.
98
+ if (child.pid !== undefined) {
99
+ const started = await call("/awake", { method: "POST", body: { on: true, pid: child.pid } });
100
+ if ("error" in started) {
101
+ console.error(`plonk: keep-awake not held — ${started.error}`);
102
+ }
103
+ }
104
+ // Plonk drops the assertion itself when the process goes; waiting here only
105
+ // makes the shell finish at the same moment.
106
+ throw new Exit(await finished);
107
+ }
108
+ async function main() {
109
+ const argv = process.argv.slice(2);
110
+ // Everything after `awake while` belongs to the command being run, flags
111
+ // included, so it is taken verbatim rather than parsed for plonk's own.
112
+ // Otherwise `plonk awake while cargo build --release` builds in debug.
113
+ if (argv[0] === "awake" && argv[1] === "while")
114
+ await awakeWhile(argv.slice(2));
115
+ const { flags, rest } = options(argv);
116
+ const [command, ...args] = rest;
117
+ const screen = number(flags.screen, "--screen");
118
+ switch (command) {
119
+ case undefined:
120
+ case "help":
121
+ case "-h":
122
+ case "--help":
123
+ console.log(USAGE);
124
+ return;
125
+ case "ping":
126
+ report(await call("/ping"));
127
+ break;
128
+ case "state":
129
+ if (flags.json)
130
+ report(await call("/state"));
131
+ await summarize();
132
+ break;
133
+ case "snap": {
134
+ const [app, zone] = args;
135
+ if (!app || zone === undefined)
136
+ fail("snap needs an app and a zone number, e.g. plonk snap Safari 1");
137
+ report(await call("/layout/zone", {
138
+ method: "POST",
139
+ body: { app, zone: number(zone, "zone"), screen },
140
+ }));
141
+ break;
142
+ }
143
+ case "workspaces": {
144
+ const state = await call("/state");
145
+ if ("error" in state)
146
+ report(state);
147
+ console.log(state.saved_layouts.join("\n"));
148
+ return;
149
+ }
150
+ case "launch":
151
+ if (!args[0])
152
+ fail("launch needs a workspace name");
153
+ report(await call("/workspaces/launch", {
154
+ method: "POST",
155
+ body: { name: args[0], screen },
156
+ timeoutMs: 90_000,
157
+ }));
158
+ break;
159
+ case "save":
160
+ if (!args[0])
161
+ fail("save needs a name for the workspace");
162
+ report(await call("/workspaces/save", { method: "POST", body: { name: args[0] } }));
163
+ break;
164
+ case "zones":
165
+ if (!args[0])
166
+ fail("zones needs a set name, or 'edge' for edge snapping");
167
+ report(await call("/zones/assign", { method: "POST", body: { screen: screen ?? 0, name: args[0] } }));
168
+ break;
169
+ case "awake": {
170
+ // `while` is handled before flags are parsed; anything else must be on/off.
171
+ if (args[0] !== "on" && args[0] !== "off")
172
+ fail("awake needs 'on', 'off' or 'while <command>'");
173
+ report(await call("/awake", {
174
+ method: "POST",
175
+ body: {
176
+ on: args[0] === "on",
177
+ minutes: number(flags.minutes, "--minutes"),
178
+ until: flags.until,
179
+ pid: number(flags.pid, "--pid"),
180
+ },
181
+ }));
182
+ break;
183
+ }
184
+ case "text": {
185
+ const result = await call("/shot/text", {
186
+ method: "POST",
187
+ body: { mode: flags.mode ?? "region", path: flags.path },
188
+ timeoutMs: 5 * 60_000,
189
+ });
190
+ if ("error" in result)
191
+ report(result);
192
+ // Bare text, so it can be piped.
193
+ console.log(result.text ?? "");
194
+ return;
195
+ }
196
+ case "shot":
197
+ report(await call("/shot/capture", {
198
+ method: "POST",
199
+ body: { mode: flags.mode ?? "region", path: flags.path },
200
+ timeoutMs: 5 * 60_000,
201
+ }));
202
+ break;
203
+ default:
204
+ fail(`unknown command "${command}"\n\n${USAGE}`);
205
+ }
206
+ }
207
+ // Named so the app can attribute the calls, and so "only the active agent
208
+ // controls" can be pointed at the shell like anything else.
209
+ processIdentityHolder().identity = { name: "plonk-cli", version: "", pid: process.pid };
210
+ try {
211
+ await main();
212
+ }
213
+ catch (err) {
214
+ if (!(err instanceof Exit))
215
+ throw err;
216
+ process.exitCode = err.code;
217
+ }
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/http.js CHANGED
@@ -3,13 +3,22 @@
3
3
  // threat model as the app's own API: a web page must never be able to drive
4
4
  // the desktop, and a DNS-rebinding page must not reach the port by Host games.
5
5
  import { createServer } from "node:http";
6
- import { randomUUID } from "node:crypto";
6
+ import { randomUUID, timingSafeEqual } from "node:crypto";
7
7
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
- import { runWithIdentity } from "./api.js";
8
+ import { localApiToken, runWithIdentity } from "./api.js";
9
9
  import { createPlonkServer, startHello, startInboxLoop, watchClientInfo } from "./factory.js";
10
10
  // The app's registry tells sessions apart by (name, pid). Every HTTP client
11
11
  // shares this process, so each session gets a synthetic pid instead.
12
12
  let syntheticPid = 100_000 + (process.pid % 1_000) * 100;
13
+ function headerToken(req) {
14
+ const value = req.headers["x-plonk-token"];
15
+ return (Array.isArray(value) ? value[0] : value) ?? "";
16
+ }
17
+ /** Length is not the secret; which byte differed would be. */
18
+ function timingSafeEqualString(presented, token) {
19
+ const a = Buffer.from(presented, "utf8"), b = Buffer.from(token, "utf8");
20
+ return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
21
+ }
13
22
  function reject(res, status, error) {
14
23
  res.writeHead(status, { "content-type": "application/json" });
15
24
  res.end(JSON.stringify({ error }));
@@ -32,6 +41,20 @@ export async function serveHttp(port) {
32
41
  reject(res, 404, "the MCP endpoint is /mcp");
33
42
  return;
34
43
  }
44
+ // This process holds the app's token, so without a gate of its own it is a
45
+ // way around the app's: anything local could call take_screenshot through
46
+ // it and borrow Screen Recording. Same secret, same header, same file —
47
+ // there is nothing extra for a client to be given.
48
+ const token = localApiToken();
49
+ if (!token) {
50
+ reject(res, 503, "no Plonk API token could be read, so this transport is answering nothing");
51
+ return;
52
+ }
53
+ if (!timingSafeEqualString(headerToken(req), token)) {
54
+ reject(res, 401, "this request carried no valid token; send the contents of " +
55
+ "~/Library/Application Support/Plonk/token as the X-Plonk-Token header");
56
+ return;
57
+ }
35
58
  const sessionId = req.headers["mcp-session-id"];
36
59
  const existing = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
37
60
  if (existing) {
@@ -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. Optional 'minutes' limits the session (it ends automatically). Behavior also follows the user's settings: keep-awake may pause on battery or engage automatically while charging; the returned 'status' explains the current state. The menu bar icon glows while active.", {
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("Auto-off after this many minutes"),
7
- }, async ({ on, minutes }) => text(await call("/awake", { method: "POST", body: { on, minutes } })));
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.5",
3
+ "version": "0.2.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",
@@ -28,13 +28,15 @@
28
28
  "dist"
29
29
  ],
30
30
  "bin": {
31
- "plonk-mcp": "dist/server.js"
31
+ "plonk-mcp": "dist/server.js",
32
+ "plonk": "dist/cli.js"
32
33
  },
33
34
  "main": "dist/server.js",
34
35
  "scripts": {
35
36
  "build": "tsc",
36
37
  "prepublishOnly": "tsc",
37
- "typecheck": "tsc --noEmit"
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "tsc && node --test test/*.test.js"
38
40
  },
39
41
  "dependencies": {
40
42
  "@modelcontextprotocol/sdk": "^1.12.0",