premanmcp 0.7.1 → 0.9.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
@@ -19,11 +19,12 @@ Local development form:
19
19
  node bin/cli.js connect
20
20
  ```
21
21
 
22
- First-time users are prompted for email, OTP, and password directly in the terminal.
23
- PreMan creates or connects the account, generates an API key, saves it to
24
- `~/.preman/credentials.json`, then writes a `preman` MCP server into the config your
25
- agent actually reads (`~/.cursor/mcp.json`, Claude Code's MCP config, or
26
- `~/.codex/config.toml`):
22
+ First-time users are asked for an email and the code sent to it — no password: the
23
+ terminal's credential is the `pm_live_` key. Pass `--password` to also set one for
24
+ dashboard sign-in, or set it later from the dashboard. PreMan creates or connects the
25
+ account, generates an API key, saves it to `~/.preman/credentials.json`, then writes a
26
+ `preman` MCP server into the config your agent actually reads (`~/.cursor/mcp.json`,
27
+ Claude Code's MCP config, or `~/.codex/config.toml`):
27
28
 
28
29
  ```json
29
30
  {
@@ -40,11 +41,12 @@ agent actually reads (`~/.cursor/mcp.json`, Claude Code's MCP config, or
40
41
  }
41
42
  ```
42
43
 
43
- Restart your agent afterwards, then ask it to `run preman_status` to finish linking.
44
-
45
44
  `connect` reads the config back after writing it, and prints a copy-paste snippet if it
46
- cannot confirm the entry landed. It then waits for your agent to check in — that
47
- check-in is the only real proof the agent loaded the server.
45
+ cannot confirm the entry landed. It then finishes the link itself by running your agent
46
+ headlessly (`claude -p`, `cursor-agent -p`, `codex exec`) so it makes one PreMan call —
47
+ that check-in is the only real proof the agent loaded the server. If the agent's CLI is
48
+ not on PATH, `connect` asks you to restart it and waits instead. `--no-auto-checkin`
49
+ turns the spawn off.
48
50
 
49
51
  Once the agent has checked in, `connect` walks you into your first test: it runs one
50
52
  against an endpoint you already have, or prints the discovery brief to hand your agent,
@@ -62,10 +64,18 @@ setup blocks for all three agents and exits 2.
62
64
 
63
65
  ### Cloud dispatch (optional)
64
66
 
65
- `connect` offers to store a coding-agent credential — a Cursor API key, or a Claude
66
- Code routine token and id. With one saved, PreMan can start an agent run for you when
67
- it finds a failing endpoint instead of handing back a prompt to paste. Press Enter to
68
- skip; everything else still works.
67
+ With a coding-agent credential saved — a Cursor API key from
68
+ `cursor.com/dashboard Integrations API Keys`, or a Claude Code routine token and id
69
+ from `claude.ai/code/routines your routine Add API trigger` PreMan can start an
70
+ agent run for you when it finds a failing endpoint, instead of handing back a prompt to
71
+ paste.
72
+
73
+ `connect` asks for it once you are already set up, so skipping costs nothing. Come back
74
+ to it any time:
75
+
76
+ ```bash
77
+ npm exec -y premanmcp@latest -- dispatch
78
+ ```
69
79
 
70
80
  You can also create or connect your account first:
71
81
 
package/bin/account.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Account-adjacent commands: browser login, logout, doctor, and watch.
3
+ *
4
+ * `loginBrowser` drives the device-code flow at /auth/device/*, which has existed
5
+ * in the backend with no client. It is the right path for anyone who does not
6
+ * want to type a password into a terminal, and the only one that works when the
7
+ * account is behind SSO.
8
+ */
9
+
10
+ import { existsSync, rmSync } from "node:fs";
11
+ import os from "node:os";
12
+
13
+ import { detectCandidates } from "./detect.js";
14
+ import {
15
+ CREDENTIALS_FILE,
16
+ backendUrl,
17
+ callBackendJson,
18
+ cliInvocation,
19
+ frontendUrl,
20
+ makeArgs,
21
+ openUrl,
22
+ readStoredCredentials,
23
+ resolveApiKey,
24
+ saveStoredCredentials,
25
+ } from "./shared.js";
26
+
27
+ export const ACCOUNT_HELP = `
28
+ Account options:
29
+ login --browser Approve sign-in in the browser instead of typing a password
30
+ logout Delete stored CLI credentials
31
+ doctor Check backend, credentials, target, and integrations
32
+ watch <run-id> Follow a push simulation as it runs
33
+ `;
34
+
35
+ const POLL_INTERVAL_MS = 3000;
36
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
37
+
38
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
39
+
40
+ export async function loginBrowser(args) {
41
+ const start = await callBackendJson(args, "POST", "/auth/device/request", {
42
+ json: { device_name: `PreMan CLI (${os.hostname()})` },
43
+ });
44
+ if (!start.ok) {
45
+ throw new Error(`could not start browser login: ${start.status_code} ${start.detail || ""}`);
46
+ }
47
+
48
+ const verificationUrl = start.verification_url || `${frontendUrl(args)}/device/verify?code=${start.device_code}`;
49
+ process.stdout.write(
50
+ `\nApprove this device in your browser:\n ${verificationUrl}\n\n` +
51
+ `Confirmation code: ${start.user_code}\n\n`
52
+ );
53
+ if (!openUrl(verificationUrl)) {
54
+ process.stdout.write("Could not open a browser automatically — open the link above.\n");
55
+ }
56
+ process.stdout.write("Waiting for approval…\n");
57
+
58
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
59
+ while (Date.now() < deadline) {
60
+ await sleep(POLL_INTERVAL_MS);
61
+ const poll = await callBackendJson(args, "POST", "/auth/device/poll", {
62
+ json: { device_code: start.device_code },
63
+ });
64
+ const status = String(poll.status || "").toLowerCase();
65
+ if (poll.ok && poll.api_key) {
66
+ const creds = {
67
+ api_key: String(poll.api_key),
68
+ backend_url: backendUrl(args),
69
+ user_email: poll.user_email || "",
70
+ device_name: os.hostname(),
71
+ created_at: new Date().toISOString(),
72
+ };
73
+ saveStoredCredentials(creds);
74
+ process.stdout.write(`\nSigned in${creds.user_email ? ` as ${creds.user_email}` : ""}.\n`);
75
+ process.stdout.write(`Saved to ${CREDENTIALS_FILE}\n`);
76
+ return creds;
77
+ }
78
+ if (status === "expired" || status === "denied") {
79
+ throw new Error(`browser login ${status}. Run \`${cliInvocation()} login --browser\` again.`);
80
+ }
81
+ }
82
+ throw new Error("browser login timed out after 5 minutes.");
83
+ }
84
+
85
+ export async function logoutCommand() {
86
+ if (existsSync(CREDENTIALS_FILE)) {
87
+ rmSync(CREDENTIALS_FILE, { force: true });
88
+ process.stdout.write(`Removed ${CREDENTIALS_FILE}\n`);
89
+ } else {
90
+ process.stdout.write("No stored credentials to remove.\n");
91
+ }
92
+ process.stdout.write(
93
+ "Note: this does not revoke the API key. Revoke it in the dashboard if the machine is shared.\n"
94
+ );
95
+ }
96
+
97
+ function line(label, ok, detail) {
98
+ const mark = ok === null ? "–" : ok ? "✓" : "✗";
99
+ return ` ${mark} ${label}${detail ? ` ${detail}` : ""}`;
100
+ }
101
+
102
+ export async function doctorCommand(commandArgs = []) {
103
+ const args = makeArgs(commandArgs);
104
+ const results = [];
105
+ let failures = 0;
106
+
107
+ const stored = readStoredCredentials();
108
+ const token = resolveApiKey(args);
109
+ results.push(
110
+ line(
111
+ "credentials",
112
+ Boolean(token),
113
+ token
114
+ ? `${stored?.user_email || "key present"} (${CREDENTIALS_FILE})`
115
+ : `none — run \`${cliInvocation()} login\``
116
+ )
117
+ );
118
+ if (!token) failures += 1;
119
+
120
+ let health = { ok: false, status_code: 0, detail: "" };
121
+ try {
122
+ health = await callBackendJson(args, "GET", "/health");
123
+ } catch (error) {
124
+ health = { ok: false, status_code: 0, detail: String(error?.message || error) };
125
+ }
126
+ results.push(line("backend", health.ok, `${backendUrl(args)}${health.ok ? "" : ` — ${health.detail || health.status_code}`}`));
127
+ if (!health.ok) failures += 1;
128
+
129
+ let status = null;
130
+ if (token && health.ok) {
131
+ try {
132
+ status = await callBackendJson(args, "GET", "/cli/status", { token });
133
+ } catch (error) {
134
+ status = { ok: false, detail: String(error?.message || error) };
135
+ }
136
+ results.push(
137
+ line("api key", Boolean(status.ok), status.ok ? `workspace ${status.workspace?.name || "?"}` : status.detail || `status ${status.status_code}`)
138
+ );
139
+ if (!status.ok) failures += 1;
140
+ }
141
+
142
+ const candidates = detectCandidates(process.cwd());
143
+ results.push(
144
+ line(
145
+ "local target",
146
+ candidates.length > 0,
147
+ candidates.length
148
+ ? `${candidates.length} candidate${candidates.length === 1 ? "" : "s"} (${candidates[0].url} from ${candidates[0].source})`
149
+ : "no signals in this directory"
150
+ )
151
+ );
152
+
153
+ if (status?.ok) {
154
+ const integrations = status.integrations || {};
155
+ for (const [name, section] of Object.entries(integrations)) {
156
+ results.push(line(name, Boolean(section?.connected), section?.connected ? "" : "not connected"));
157
+ }
158
+ results.push(
159
+ line("project link", Boolean(status.workspace?.project_id), status.workspace?.project_id ? "" : "workspace is not linked to a project")
160
+ );
161
+ }
162
+
163
+ process.stdout.write(`PreMan doctor\n\n${results.join("\n")}\n\n`);
164
+ process.stdout.write(failures ? `${failures} problem${failures === 1 ? "" : "s"} found.\n` : "Everything looks healthy.\n");
165
+ if (failures) process.exitCode = 1;
166
+ return { failures };
167
+ }
168
+
169
+ const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "error", "cancelled"]);
170
+
171
+ export async function watchCommand(commandArgs = []) {
172
+ const args = makeArgs(commandArgs);
173
+ const token = resolveApiKey(args);
174
+ if (!token) throw new Error(`no API key. Run \`${cliInvocation()} login\` first.`);
175
+
176
+ const positional = commandArgs.filter((value) => !value.startsWith("-"));
177
+ const runId = args.value("--run", positional[0] || "");
178
+ const integrationId = args.value("--integration", positional[1] || "");
179
+ if (!runId || !integrationId) {
180
+ throw new Error(
181
+ "usage: watch <run-id> <integration-id> (both are shown by `preman status`)"
182
+ );
183
+ }
184
+
185
+ const seen = new Set();
186
+ const deadline = Date.now() + 15 * 60 * 1000;
187
+ while (Date.now() < deadline) {
188
+ const result = await callBackendJson(
189
+ args,
190
+ "GET",
191
+ `/integrations/github/${integrationId}/simulations/${runId}`,
192
+ { token }
193
+ );
194
+ if (!result.ok) throw new Error(`could not read run: ${result.status_code} ${result.detail || ""}`);
195
+
196
+ for (const step of result.steps || []) {
197
+ const key = `${step.key}:${step.status}`;
198
+ if (seen.has(key)) continue;
199
+ seen.add(key);
200
+ const mark = step.status === "succeeded" ? "✓" : step.status === "failed" ? "✗" : "·";
201
+ process.stdout.write(` ${mark} ${String(step.key || "").padEnd(14)} ${step.message || ""}\n`);
202
+ }
203
+
204
+ if (TERMINAL_STATUSES.has(String(result.status || "").toLowerCase())) {
205
+ const verdict = result.summary?.verdict || result.status;
206
+ process.stdout.write(`\nRun ${result.status}: ${verdict}\n`);
207
+ return result;
208
+ }
209
+ await sleep(POLL_INTERVAL_MS);
210
+ }
211
+ throw new Error("stopped watching after 15 minutes; the run is still in progress.");
212
+ }
package/bin/changed.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Which files a push actually touches.
3
+ *
4
+ * Git hands a pre-push hook one line per ref on stdin —
5
+ * `<local ref> <local sha> <remote ref> <remote sha>` — which is the only
6
+ * authoritative statement of what is about to leave the machine. Everything
7
+ * else (diffing against HEAD, against a guessed upstream) is a guess that goes
8
+ * wrong on exactly the pushes that matter: a first push of a new branch, an
9
+ * amend, a force push.
10
+ *
11
+ * Reading it is best-effort by design. If stdin is absent, empty, or malformed,
12
+ * we fall back rather than fail, because a hook that cannot decide what changed
13
+ * must still let the push through.
14
+ */
15
+
16
+ import { spawnSync } from "node:child_process";
17
+ import { readFileSync } from "node:fs";
18
+ import path from "node:path";
19
+
20
+ // Git's "this ref does not exist yet" sentinel, in both SHA-1 and SHA-256 form.
21
+ const NULL_SHAS = new Set(["0".repeat(40), "0".repeat(64)]);
22
+
23
+ // A push of a brand-new branch has no remote counterpart to diff against, so
24
+ // walking its full history could mean scanning the entire repository. Cap it.
25
+ const NEW_BRANCH_DEPTH = 50;
26
+
27
+ const CODE_EXTENSIONS = new Set([
28
+ ".py", ".js", ".ts", ".mjs", ".cjs", ".tsx", ".jsx", ".go", ".rb", ".java", ".php", ".cs", ".rs",
29
+ ]);
30
+
31
+ const SPEC_NAMES = new Set([
32
+ "openapi.json", "openapi.yaml", "openapi.yml",
33
+ "swagger.json", "swagger.yaml", "swagger.yml",
34
+ ]);
35
+
36
+ function git(cwdArgs, { cwd } = {}) {
37
+ const result = spawnSync("git", cwdArgs, { encoding: "utf8", cwd, maxBuffer: 32 * 1024 * 1024 });
38
+ if (result.status !== 0) return null;
39
+ return String(result.stdout || "");
40
+ }
41
+
42
+ export function repoRoot() {
43
+ const out = git(["rev-parse", "--show-toplevel"]);
44
+ return out ? out.trim() : null;
45
+ }
46
+
47
+ /** Parse git's pre-push stdin payload into the ref pairs being pushed. */
48
+ export function parsePushRefs(raw) {
49
+ return String(raw || "")
50
+ .split("\n")
51
+ .map((line) => line.trim())
52
+ .filter(Boolean)
53
+ .map((line) => {
54
+ const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/);
55
+ return { localRef, localSha, remoteRef, remoteSha };
56
+ })
57
+ .filter((ref) => ref.localSha && !NULL_SHAS.has(ref.localSha));
58
+ }
59
+
60
+ /** Read git's stdin payload without hanging when the hook was run by hand. */
61
+ export function readPushRefsFromStdin() {
62
+ // A TTY means a human typed the command, so there is no ref payload coming
63
+ // and a blocking read would hang the terminal.
64
+ if (process.stdin.isTTY) return [];
65
+ try {
66
+ return parsePushRefs(readFileSync(0, "utf8"));
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
71
+
72
+ function diffNames(range, cwd) {
73
+ const out = git(["diff", "--name-only", "--diff-filter=ACMR", range], { cwd });
74
+ if (out === null) return null;
75
+ return out.split("\n").map((line) => line.trim()).filter(Boolean);
76
+ }
77
+
78
+ /**
79
+ * Files touched by the last `depth` commits reachable from `sha`.
80
+ *
81
+ * A new branch has no remote counterpart, and the obvious `sha~N..sha` range
82
+ * fails outright when the branch is younger than N commits -- which is most
83
+ * new branches. Walking the log bounds the work the same way without needing
84
+ * the ancestor to exist.
85
+ */
86
+ function logNames(sha, depth, cwd) {
87
+ const out = git(
88
+ ["log", "--format=", "--name-only", "--diff-filter=ACMR", `-n${depth}`, sha],
89
+ { cwd }
90
+ );
91
+ if (out === null) return null;
92
+ return out.split("\n").map((line) => line.trim()).filter(Boolean);
93
+ }
94
+
95
+ /**
96
+ * The files this push introduces, relative to the repo root.
97
+ *
98
+ * Uncommitted work is deliberately excluded: a push ships commits, so testing
99
+ * the dirty working tree would report on code the teammate pulling this branch
100
+ * will never receive.
101
+ */
102
+ export function changedFilesForPush(refs, { cwd } = {}) {
103
+ const root = cwd || repoRoot();
104
+ if (!root) return { files: [], reason: "not_a_git_repository" };
105
+
106
+ const names = new Set();
107
+ let reason = null;
108
+
109
+ for (const ref of refs) {
110
+ const isNewBranch = !ref.remoteSha || NULL_SHAS.has(ref.remoteSha);
111
+ // Never fall back to a bare `git diff <sha>`: that compares the working
112
+ // tree, so a clean checkout reports nothing at all and a dirty one reports
113
+ // edits this push does not carry.
114
+ let found;
115
+ if (isNewBranch) {
116
+ found = logNames(ref.localSha, NEW_BRANCH_DEPTH, root);
117
+ reason = "new_branch_bounded_history";
118
+ } else {
119
+ found = diffNames(`${ref.remoteSha}..${ref.localSha}`, root);
120
+ }
121
+ for (const name of found ?? []) names.add(name);
122
+ }
123
+
124
+ if (!refs.length) {
125
+ // Run by hand rather than by git: compare against the tracked upstream.
126
+ const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], { cwd: root });
127
+ const fallback = upstream ? diffNames(`${upstream.trim()}..HEAD`, root) : null;
128
+ for (const name of fallback ?? []) names.add(name);
129
+ reason = upstream ? "upstream_diff" : "no_refs_no_upstream";
130
+ }
131
+
132
+ return { files: [...names], reason, root };
133
+ }
134
+
135
+ /** Only files a scanner could find an endpoint in. */
136
+ export function isScannable(relPath) {
137
+ const base = path.basename(relPath).toLowerCase();
138
+ if (SPEC_NAMES.has(base)) return true;
139
+ return CODE_EXTENSIONS.has(path.extname(relPath).toLowerCase());
140
+ }
141
+
142
+ /**
143
+ * Read the pushed files' contents for the backend to scan.
144
+ *
145
+ * Reads from the working tree rather than the commit. They agree on the
146
+ * overwhelmingly common push-what-you-committed path, and when they disagree
147
+ * the working tree is what the developer's running dev server is serving —
148
+ * which is what the plan is about to be tested against.
149
+ */
150
+ export function readChangedFiles(files, { root, maxFiles = 300, maxBytes = 512 * 1024 } = {}) {
151
+ const out = [];
152
+ let skipped = 0;
153
+
154
+ for (const rel of files) {
155
+ if (out.length >= maxFiles) {
156
+ skipped += 1;
157
+ continue;
158
+ }
159
+ if (!isScannable(rel)) continue;
160
+ try {
161
+ const content = readFileSync(path.join(root, rel), "utf8");
162
+ if (Buffer.byteLength(content, "utf8") > maxBytes) {
163
+ skipped += 1;
164
+ continue;
165
+ }
166
+ out.push({ path: rel, content });
167
+ } catch {
168
+ // Deleted in the working tree, or unreadable. Either way there is nothing
169
+ // to scan, and the diff still reports the endpoint as removed.
170
+ skipped += 1;
171
+ }
172
+ }
173
+
174
+ return { files: out, skipped };
175
+ }
package/bin/cli.js CHANGED
@@ -17,7 +17,13 @@ import path from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
19
  import { ENDPOINTS_HELP, TEST_HELP, endpointsCommand, testCommand } from "./api_tools.js";
20
- import { CONNECT_HELP, connectCommand, writeCursorConfig } from "./connect.js";
20
+ import {
21
+ CONNECT_HELP,
22
+ DISPATCH_HELP,
23
+ connectCommand,
24
+ dispatchCommand,
25
+ writeCursorConfig,
26
+ } from "./connect.js";
21
27
  import {
22
28
  INTEGRATIONS_HELP,
23
29
  awsCommand,
@@ -26,6 +32,11 @@ import {
26
32
  slackCommand,
27
33
  } from "./integrations.js";
28
34
  import { HOSTED_HELP, linkCommand, runCommand, toolsCommand } from "./hosted.js";
35
+ import { STATUS_HELP, statusCommand } from "./status.js";
36
+ import { HOOK_HELP, hookCommand } from "./hook.js";
37
+ import { VERIFY_HELP, verifyCommand } from "./verify.js";
38
+ import { DESKTOP_HELP, installDesktopCommand } from "./desktop.js";
39
+ import { ACCOUNT_HELP, doctorCommand, loginBrowser, logoutCommand, watchCommand } from "./account.js";
29
40
  import {
30
41
  CREDENTIALS_FILE,
31
42
  cliInvocation,
@@ -66,10 +77,18 @@ function printHelp() {
66
77
  // for a global install and 38 for npm exec, so a fixed layout is wrong for
67
78
  // one of them.
68
79
  const usage = [
80
+ ["status", "Healthy / failing / recently fixed endpoints"],
81
+ ["verify [options]", "Test endpoints against your local app"],
82
+ ["hook install|uninstall|status", "Manage the git pre-push hook"],
83
+ ["doctor", "Diagnose credentials, backend, target, integrations"],
84
+ ["install-desktop", "Download and install the PreMan desktop app"],
69
85
  ["onboard", "Sign in, then connect agent, GitHub, AWS, Slack"],
70
86
  ["connect [options]", "Pick a coding agent and connect it"],
87
+ ["dispatch [options]", "Let PreMan start agent runs for you"],
71
88
  ["aws | github | slack", "Connect one integration on its own"],
72
- ["login", "Create/login to PreMan from the terminal"],
89
+ ["login [--browser]", "Create/login to PreMan from the terminal"],
90
+ ["logout", "Delete stored CLI credentials"],
91
+ ["watch <run> <integration>", "Follow a push simulation live"],
73
92
  ["install [options]", "Install PreMan into Cursor MCP config"],
74
93
  ["", "Start the PreMan MCP server"],
75
94
  ["link|tools|run ...", "Drive a published hosted MCP"],
@@ -85,7 +104,7 @@ function printHelp() {
85
104
 
86
105
  Usage:
87
106
  ${usageLines}
88
- ${INTEGRATIONS_HELP}${CONNECT_HELP}${ENDPOINTS_HELP}${TEST_HELP}
107
+ ${INTEGRATIONS_HELP}${CONNECT_HELP}${DISPATCH_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}
89
108
  Login options:
90
109
  --email <email> Pre-fill the email prompt
91
110
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
@@ -109,6 +128,10 @@ ${HOSTED_HELP}`);
109
128
  }
110
129
 
111
130
  async function loginCommand() {
131
+ if (hasFlag("--browser")) {
132
+ await loginBrowser(cliArgs);
133
+ return;
134
+ }
112
135
  const creds = await authenticateTerminal(cliArgs);
113
136
  process.stdout.write(`PreMan account ready.
114
137
 
@@ -192,8 +215,24 @@ function startServer() {
192
215
  async function main() {
193
216
  if (command === "login") {
194
217
  await loginCommand();
218
+ } else if (command === "status") {
219
+ await statusCommand(commandArgs);
220
+ } else if (command === "verify") {
221
+ await verifyCommand(commandArgs);
222
+ } else if (command === "hook") {
223
+ await hookCommand(commandArgs);
224
+ } else if (command === "logout") {
225
+ await logoutCommand();
226
+ } else if (command === "doctor") {
227
+ await doctorCommand(commandArgs);
228
+ } else if (command === "watch") {
229
+ await watchCommand(commandArgs);
230
+ } else if (command === "install-desktop") {
231
+ await installDesktopCommand(commandArgs);
195
232
  } else if (command === "connect") {
196
233
  await connectCommand(commandArgs);
234
+ } else if (command === "dispatch") {
235
+ await dispatchCommand(commandArgs);
197
236
  } else if (command === "onboard" || command === "setup") {
198
237
  // makeArgs/authenticateTerminal/connectCommand are injected rather than
199
238
  // imported there, so integrations.js stays free of a cycle back into the CLI.