premanmcp 0.7.0 → 0.8.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/bin/account.js ADDED
@@ -0,0 +1,223 @@
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 { spawn } from "node:child_process";
12
+ import os from "node:os";
13
+
14
+ import { detectCandidates } from "./detect.js";
15
+ import {
16
+ CREDENTIALS_FILE,
17
+ backendUrl,
18
+ callBackendJson,
19
+ cliInvocation,
20
+ frontendUrl,
21
+ makeArgs,
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
+ function openBrowser(url) {
39
+ const opener =
40
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
41
+ try {
42
+ spawn(opener, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
50
+
51
+ export async function loginBrowser(args) {
52
+ const start = await callBackendJson(args, "POST", "/auth/device/request", {
53
+ json: { device_name: `PreMan CLI (${os.hostname()})` },
54
+ });
55
+ if (!start.ok) {
56
+ throw new Error(`could not start browser login: ${start.status_code} ${start.detail || ""}`);
57
+ }
58
+
59
+ const verificationUrl = start.verification_url || `${frontendUrl(args)}/device/verify?code=${start.device_code}`;
60
+ process.stdout.write(
61
+ `\nApprove this device in your browser:\n ${verificationUrl}\n\n` +
62
+ `Confirmation code: ${start.user_code}\n\n`
63
+ );
64
+ if (!openBrowser(verificationUrl)) {
65
+ process.stdout.write("Could not open a browser automatically — open the link above.\n");
66
+ }
67
+ process.stdout.write("Waiting for approval…\n");
68
+
69
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
70
+ while (Date.now() < deadline) {
71
+ await sleep(POLL_INTERVAL_MS);
72
+ const poll = await callBackendJson(args, "POST", "/auth/device/poll", {
73
+ json: { device_code: start.device_code },
74
+ });
75
+ const status = String(poll.status || "").toLowerCase();
76
+ if (poll.ok && poll.api_key) {
77
+ const creds = {
78
+ api_key: String(poll.api_key),
79
+ backend_url: backendUrl(args),
80
+ user_email: poll.user_email || "",
81
+ device_name: os.hostname(),
82
+ created_at: new Date().toISOString(),
83
+ };
84
+ saveStoredCredentials(creds);
85
+ process.stdout.write(`\nSigned in${creds.user_email ? ` as ${creds.user_email}` : ""}.\n`);
86
+ process.stdout.write(`Saved to ${CREDENTIALS_FILE}\n`);
87
+ return creds;
88
+ }
89
+ if (status === "expired" || status === "denied") {
90
+ throw new Error(`browser login ${status}. Run \`${cliInvocation()} login --browser\` again.`);
91
+ }
92
+ }
93
+ throw new Error("browser login timed out after 5 minutes.");
94
+ }
95
+
96
+ export async function logoutCommand() {
97
+ if (existsSync(CREDENTIALS_FILE)) {
98
+ rmSync(CREDENTIALS_FILE, { force: true });
99
+ process.stdout.write(`Removed ${CREDENTIALS_FILE}\n`);
100
+ } else {
101
+ process.stdout.write("No stored credentials to remove.\n");
102
+ }
103
+ process.stdout.write(
104
+ "Note: this does not revoke the API key. Revoke it in the dashboard if the machine is shared.\n"
105
+ );
106
+ }
107
+
108
+ function line(label, ok, detail) {
109
+ const mark = ok === null ? "–" : ok ? "✓" : "✗";
110
+ return ` ${mark} ${label}${detail ? ` ${detail}` : ""}`;
111
+ }
112
+
113
+ export async function doctorCommand(commandArgs = []) {
114
+ const args = makeArgs(commandArgs);
115
+ const results = [];
116
+ let failures = 0;
117
+
118
+ const stored = readStoredCredentials();
119
+ const token = resolveApiKey(args);
120
+ results.push(
121
+ line(
122
+ "credentials",
123
+ Boolean(token),
124
+ token
125
+ ? `${stored?.user_email || "key present"} (${CREDENTIALS_FILE})`
126
+ : `none — run \`${cliInvocation()} login\``
127
+ )
128
+ );
129
+ if (!token) failures += 1;
130
+
131
+ let health = { ok: false, status_code: 0, detail: "" };
132
+ try {
133
+ health = await callBackendJson(args, "GET", "/health");
134
+ } catch (error) {
135
+ health = { ok: false, status_code: 0, detail: String(error?.message || error) };
136
+ }
137
+ results.push(line("backend", health.ok, `${backendUrl(args)}${health.ok ? "" : ` — ${health.detail || health.status_code}`}`));
138
+ if (!health.ok) failures += 1;
139
+
140
+ let status = null;
141
+ if (token && health.ok) {
142
+ try {
143
+ status = await callBackendJson(args, "GET", "/cli/status", { token });
144
+ } catch (error) {
145
+ status = { ok: false, detail: String(error?.message || error) };
146
+ }
147
+ results.push(
148
+ line("api key", Boolean(status.ok), status.ok ? `workspace ${status.workspace?.name || "?"}` : status.detail || `status ${status.status_code}`)
149
+ );
150
+ if (!status.ok) failures += 1;
151
+ }
152
+
153
+ const candidates = detectCandidates(process.cwd());
154
+ results.push(
155
+ line(
156
+ "local target",
157
+ candidates.length > 0,
158
+ candidates.length
159
+ ? `${candidates.length} candidate${candidates.length === 1 ? "" : "s"} (${candidates[0].url} from ${candidates[0].source})`
160
+ : "no signals in this directory"
161
+ )
162
+ );
163
+
164
+ if (status?.ok) {
165
+ const integrations = status.integrations || {};
166
+ for (const [name, section] of Object.entries(integrations)) {
167
+ results.push(line(name, Boolean(section?.connected), section?.connected ? "" : "not connected"));
168
+ }
169
+ results.push(
170
+ line("project link", Boolean(status.workspace?.project_id), status.workspace?.project_id ? "" : "workspace is not linked to a project")
171
+ );
172
+ }
173
+
174
+ process.stdout.write(`PreMan doctor\n\n${results.join("\n")}\n\n`);
175
+ process.stdout.write(failures ? `${failures} problem${failures === 1 ? "" : "s"} found.\n` : "Everything looks healthy.\n");
176
+ if (failures) process.exitCode = 1;
177
+ return { failures };
178
+ }
179
+
180
+ const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "error", "cancelled"]);
181
+
182
+ export async function watchCommand(commandArgs = []) {
183
+ const args = makeArgs(commandArgs);
184
+ const token = resolveApiKey(args);
185
+ if (!token) throw new Error(`no API key. Run \`${cliInvocation()} login\` first.`);
186
+
187
+ const positional = commandArgs.filter((value) => !value.startsWith("-"));
188
+ const runId = args.value("--run", positional[0] || "");
189
+ const integrationId = args.value("--integration", positional[1] || "");
190
+ if (!runId || !integrationId) {
191
+ throw new Error(
192
+ "usage: watch <run-id> <integration-id> (both are shown by `preman status`)"
193
+ );
194
+ }
195
+
196
+ const seen = new Set();
197
+ const deadline = Date.now() + 15 * 60 * 1000;
198
+ while (Date.now() < deadline) {
199
+ const result = await callBackendJson(
200
+ args,
201
+ "GET",
202
+ `/integrations/github/${integrationId}/simulations/${runId}`,
203
+ { token }
204
+ );
205
+ if (!result.ok) throw new Error(`could not read run: ${result.status_code} ${result.detail || ""}`);
206
+
207
+ for (const step of result.steps || []) {
208
+ const key = `${step.key}:${step.status}`;
209
+ if (seen.has(key)) continue;
210
+ seen.add(key);
211
+ const mark = step.status === "succeeded" ? "✓" : step.status === "failed" ? "✗" : "·";
212
+ process.stdout.write(` ${mark} ${String(step.key || "").padEnd(14)} ${step.message || ""}\n`);
213
+ }
214
+
215
+ if (TERMINAL_STATUSES.has(String(result.status || "").toLowerCase())) {
216
+ const verdict = result.summary?.verdict || result.status;
217
+ process.stdout.write(`\nRun ${result.status}: ${verdict}\n`);
218
+ return result;
219
+ }
220
+ await sleep(POLL_INTERVAL_MS);
221
+ }
222
+ throw new Error("stopped watching after 15 minutes; the run is still in progress.");
223
+ }
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
@@ -26,8 +26,14 @@ import {
26
26
  slackCommand,
27
27
  } from "./integrations.js";
28
28
  import { HOSTED_HELP, linkCommand, runCommand, toolsCommand } from "./hosted.js";
29
+ import { STATUS_HELP, statusCommand } from "./status.js";
30
+ import { HOOK_HELP, hookCommand } from "./hook.js";
31
+ import { VERIFY_HELP, verifyCommand } from "./verify.js";
32
+ import { DESKTOP_HELP, installDesktopCommand } from "./desktop.js";
33
+ import { ACCOUNT_HELP, doctorCommand, loginBrowser, logoutCommand, watchCommand } from "./account.js";
29
34
  import {
30
35
  CREDENTIALS_FILE,
36
+ cliInvocation,
31
37
  DEFAULT_BACKEND,
32
38
  DEFAULT_FRONTEND,
33
39
  authenticateTerminal,
@@ -58,24 +64,45 @@ function hasFlag(name) {
58
64
  }
59
65
 
60
66
  function printHelp() {
67
+ // Say "preman x" only to people who can actually run it; everyone else gets
68
+ // the form that works from a bare npm install.
69
+ const cli = cliInvocation();
70
+ // Padded here rather than hand-aligned: the invocation prefix is 6 characters
71
+ // for a global install and 38 for npm exec, so a fixed layout is wrong for
72
+ // one of them.
73
+ const usage = [
74
+ ["status", "Healthy / failing / recently fixed endpoints"],
75
+ ["verify [options]", "Test endpoints against your local app"],
76
+ ["hook install|uninstall|status", "Manage the git pre-push hook"],
77
+ ["doctor", "Diagnose credentials, backend, target, integrations"],
78
+ ["install-desktop", "Download and install the PreMan desktop app"],
79
+ ["onboard", "Sign in, then connect agent, GitHub, AWS, Slack"],
80
+ ["connect [options]", "Pick a coding agent and connect it"],
81
+ ["aws | github | slack", "Connect one integration on its own"],
82
+ ["login [--browser]", "Create/login to PreMan from the terminal"],
83
+ ["logout", "Delete stored CLI credentials"],
84
+ ["watch <run> <integration>", "Follow a push simulation live"],
85
+ ["install [options]", "Install PreMan into Cursor MCP config"],
86
+ ["", "Start the PreMan MCP server"],
87
+ ["link|tools|run ...", "Drive a published hosted MCP"],
88
+ ["endpoints list|discover|setup ...", "Discover, list, and set up API endpoints"],
89
+ ["test <id> [--scenario ...] [--stress]", "Generate + run tests for an endpoint"],
90
+ ];
91
+ const width = Math.max(...usage.map(([command]) => `${cli} ${command}`.trimEnd().length));
92
+ const usageLines = usage
93
+ .map(([command, blurb]) => ` ${`${cli} ${command}`.trimEnd().padEnd(width)} ${blurb}`)
94
+ .join("\n");
95
+
61
96
  process.stdout.write(`PreMan MCP
62
97
 
63
98
  Usage:
64
- preman onboard Sign in, then connect agent, GitHub, AWS, Slack
65
- preman connect [options] Pick a coding agent and connect it
66
- preman aws | github | slack Connect one integration on its own
67
- npm exec -y premanmcp@latest -- login Create/login to PreMan from the terminal
68
- npm exec -y premanmcp@latest -- install [options] Install PreMan into Cursor MCP config
69
- npm exec -y premanmcp@latest -- Start the PreMan MCP server
70
- preman link|tools|run ... Drive a published hosted MCP
71
- preman endpoints list|discover|setup ... Discover, list, and set up API endpoints
72
- preman test <id> [--scenario ...] [--stress] Generate + run tests for an endpoint
73
- ${INTEGRATIONS_HELP}${CONNECT_HELP}${ENDPOINTS_HELP}${TEST_HELP}
99
+ ${usageLines}
100
+ ${INTEGRATIONS_HELP}${CONNECT_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}
74
101
  Login options:
75
102
  --email <email> Pre-fill the email prompt
76
103
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
77
104
 
78
- Install options (Cursor only — prefer 'preman connect'):
105
+ Install options (Cursor only — prefer '${cli} connect'):
79
106
  --api-key <key> PreMan API key. If omitted, stored CLI credentials are used
80
107
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
81
108
  --frontend <url> PreMan frontend URL. Defaults to ${DEFAULT_FRONTEND}
@@ -87,13 +114,17 @@ Install options (Cursor only — prefer 'preman connect'):
87
114
 
88
115
  Examples:
89
116
  npm exec -y premanmcp@latest -- connect
90
- preman connect --agent claude-code
91
- npm exec -y premanmcp@latest -- login
117
+ ${cli} connect --agent claude-code
118
+ ${cli} login
92
119
  npm exec -y premanmcp@latest -- install --project --backend http://127.0.0.1:8000
93
120
  ${HOSTED_HELP}`);
94
121
  }
95
122
 
96
123
  async function loginCommand() {
124
+ if (hasFlag("--browser")) {
125
+ await loginBrowser(cliArgs);
126
+ return;
127
+ }
97
128
  const creds = await authenticateTerminal(cliArgs);
98
129
  process.stdout.write(`PreMan account ready.
99
130
 
@@ -103,7 +134,7 @@ API key: ${creds.api_key}
103
134
  Saved to: ${CREDENTIALS_FILE}
104
135
 
105
136
  You can now run:
106
- preman connect
137
+ ${cli} connect
107
138
  `);
108
139
  }
109
140
 
@@ -138,12 +169,12 @@ Server name: ${serverName}
138
169
  Backend: ${serverConfig.env.PREMAN_BACKEND}
139
170
 
140
171
  Next steps:
141
- 1. ${hasInlineKey ? "Your PreMan API key was written to the MCP config." : hasStoredKey ? `Your PreMan API key is saved in ${CREDENTIALS_FILE}; the MCP server will load it automatically.` : "Run npm exec -y premanmcp@latest -- login to create/connect your account and generate an API key."}
172
+ 1. ${hasInlineKey ? "Your PreMan API key was written to the MCP config." : hasStoredKey ? `Your PreMan API key is saved in ${CREDENTIALS_FILE}; the MCP server will load it automatically.` : "Run ${cli} login to create/connect your account and generate an API key."}
142
173
  2. Restart Cursor or toggle the PreMan MCP server off/on.
143
174
  3. In your API repo, ask your coding agent:
144
175
  "Use PreMan to convert the endpoints I choose into a hosted MCP server, then give me the Cursor/Claude install snippet."
145
176
 
146
- Tip: 'preman connect' also supports Claude Code and Codex, and links the agent to your account.
177
+ Tip: '${cli} connect' also supports Claude Code and Codex, and links the agent to your account.
147
178
  `);
148
179
  }
149
180
 
@@ -177,6 +208,20 @@ function startServer() {
177
208
  async function main() {
178
209
  if (command === "login") {
179
210
  await loginCommand();
211
+ } else if (command === "status") {
212
+ await statusCommand(commandArgs);
213
+ } else if (command === "verify") {
214
+ await verifyCommand(commandArgs);
215
+ } else if (command === "hook") {
216
+ await hookCommand(commandArgs);
217
+ } else if (command === "logout") {
218
+ await logoutCommand();
219
+ } else if (command === "doctor") {
220
+ await doctorCommand(commandArgs);
221
+ } else if (command === "watch") {
222
+ await watchCommand(commandArgs);
223
+ } else if (command === "install-desktop") {
224
+ await installDesktopCommand(commandArgs);
180
225
  } else if (command === "connect") {
181
226
  await connectCommand(commandArgs);
182
227
  } else if (command === "onboard" || command === "setup") {
package/bin/connect.js CHANGED
@@ -22,8 +22,11 @@ import {
22
22
  backendUrl,
23
23
  buildServerConfig,
24
24
  callBackendJson,
25
+ cliInvocation,
25
26
  frontendUrl,
26
27
  hasKeyAvailable,
28
+ LAUNCHER_ARGS,
29
+ LAUNCHER_COMMAND,
27
30
  makeArgs,
28
31
  promptSecret,
29
32
  promptText,
@@ -271,8 +274,13 @@ export function renderAllAgentSnippets(args, serverName, { projectInstall = fals
271
274
  PREMAN_BACKEND: backendUrl(args),
272
275
  PREMAN_FRONTEND: frontendUrl(args),
273
276
  };
274
- const serverConfig = { command: "npx", args: ["-y", "premanmcp@latest"], env };
275
-
277
+ const serverConfig = {
278
+ command: LAUNCHER_COMMAND,
279
+ args: [...LAUNCHER_ARGS],
280
+ env,
281
+ };
282
+
283
+
276
284
  return AGENTS.map((agent) => {
277
285
  // Adjust hint based on projectInstall, matching verifyWrittenConfig logic
278
286
  let hint = agent.snippetHint;
@@ -318,8 +326,11 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
318
326
  if (!block) {
319
327
  return { status: "mismatch", detail: `no [mcp_servers.${serverName}] block in ${written.path}` };
320
328
  }
321
- if (!block.some((line) => line.trim() === 'command = "npx"')) {
322
- return { status: "mismatch", detail: `${written.path} does not launch npx` };
329
+ if (!block.some((line) => line.trim() === `command = "${LAUNCHER_COMMAND}"`)) {
330
+ return {
331
+ status: "mismatch",
332
+ detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
333
+ };
323
334
  }
324
335
  return { status: "verified" };
325
336
  }
@@ -335,8 +346,11 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
335
346
 
336
347
  const entry = readJsonFile(written.path).mcpServers?.[serverName];
337
348
  if (!entry) return { status: "mismatch", detail: `${serverName} is missing from ${written.path}` };
338
- if (entry.command !== "npx") {
339
- return { status: "mismatch", detail: `${written.path} does not launch npx` };
349
+ if (entry.command !== LAUNCHER_COMMAND) {
350
+ return {
351
+ status: "mismatch",
352
+ detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
353
+ };
340
354
  }
341
355
  return { status: "verified" };
342
356
  } catch (error) {
@@ -401,11 +415,21 @@ async function captureDispatchCredential(args, agent, apiKey) {
401
415
 
402
416
  if (!secret) {
403
417
  if (!process.stdin.isTTY) return;
418
+ // Framed as the expected step rather than an optional aside. Without it
419
+ // PreMan can only suggest fixes; with it, it can run them. Presenting it as
420
+ // "optional, press Enter to skip" meant almost everyone skipped the thing
421
+ // that makes the product act rather than advise.
404
422
  process.stdout.write(
405
- `\nOptional: paste a ${agent.dispatch.credential} so PreMan can start ${agent.label} runs for you.\n`
423
+ `\nLet PreMan start ${agent.label} runs for you — it can then apply fixes and\n` +
424
+ `run checks on a schedule instead of only telling you what to do.\n`
406
425
  );
407
- secret = await promptSecret("(Enter to skip): ");
408
- if (!secret) return;
426
+ secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to set up later): `);
427
+ if (!secret) {
428
+ process.stdout.write(
429
+ `Skipped. Run '${cliInvocation()} connect --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
430
+ );
431
+ return;
432
+ }
409
433
  if (agent.dispatch.needsRoutine && !routineId) {
410
434
  routineId = await promptText("Routine id or URL: ");
411
435
  }