premanmcp 0.7.1 → 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,6 +26,11 @@ 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,
31
36
  cliInvocation,
@@ -66,10 +71,17 @@ function printHelp() {
66
71
  // for a global install and 38 for npm exec, so a fixed layout is wrong for
67
72
  // one of them.
68
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"],
69
79
  ["onboard", "Sign in, then connect agent, GitHub, AWS, Slack"],
70
80
  ["connect [options]", "Pick a coding agent and connect it"],
71
81
  ["aws | github | slack", "Connect one integration on its own"],
72
- ["login", "Create/login to PreMan from the terminal"],
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"],
73
85
  ["install [options]", "Install PreMan into Cursor MCP config"],
74
86
  ["", "Start the PreMan MCP server"],
75
87
  ["link|tools|run ...", "Drive a published hosted MCP"],
@@ -85,7 +97,7 @@ function printHelp() {
85
97
 
86
98
  Usage:
87
99
  ${usageLines}
88
- ${INTEGRATIONS_HELP}${CONNECT_HELP}${ENDPOINTS_HELP}${TEST_HELP}
100
+ ${INTEGRATIONS_HELP}${CONNECT_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}
89
101
  Login options:
90
102
  --email <email> Pre-fill the email prompt
91
103
  --backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
@@ -109,6 +121,10 @@ ${HOSTED_HELP}`);
109
121
  }
110
122
 
111
123
  async function loginCommand() {
124
+ if (hasFlag("--browser")) {
125
+ await loginBrowser(cliArgs);
126
+ return;
127
+ }
112
128
  const creds = await authenticateTerminal(cliArgs);
113
129
  process.stdout.write(`PreMan account ready.
114
130
 
@@ -192,6 +208,20 @@ function startServer() {
192
208
  async function main() {
193
209
  if (command === "login") {
194
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);
195
225
  } else if (command === "connect") {
196
226
  await connectCommand(commandArgs);
197
227
  } else if (command === "onboard" || command === "setup") {
package/bin/connect.js CHANGED
@@ -25,6 +25,8 @@ import {
25
25
  cliInvocation,
26
26
  frontendUrl,
27
27
  hasKeyAvailable,
28
+ LAUNCHER_ARGS,
29
+ LAUNCHER_COMMAND,
28
30
  makeArgs,
29
31
  promptSecret,
30
32
  promptText,
@@ -272,8 +274,13 @@ export function renderAllAgentSnippets(args, serverName, { projectInstall = fals
272
274
  PREMAN_BACKEND: backendUrl(args),
273
275
  PREMAN_FRONTEND: frontendUrl(args),
274
276
  };
275
- const serverConfig = { command: "npx", args: ["-y", "premanmcp@latest"], env };
276
-
277
+ const serverConfig = {
278
+ command: LAUNCHER_COMMAND,
279
+ args: [...LAUNCHER_ARGS],
280
+ env,
281
+ };
282
+
283
+
277
284
  return AGENTS.map((agent) => {
278
285
  // Adjust hint based on projectInstall, matching verifyWrittenConfig logic
279
286
  let hint = agent.snippetHint;
@@ -319,8 +326,11 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
319
326
  if (!block) {
320
327
  return { status: "mismatch", detail: `no [mcp_servers.${serverName}] block in ${written.path}` };
321
328
  }
322
- if (!block.some((line) => line.trim() === 'command = "npx"')) {
323
- 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
+ };
324
334
  }
325
335
  return { status: "verified" };
326
336
  }
@@ -336,8 +346,11 @@ export function verifyWrittenConfig(agent, { serverName, written }) {
336
346
 
337
347
  const entry = readJsonFile(written.path).mcpServers?.[serverName];
338
348
  if (!entry) return { status: "mismatch", detail: `${serverName} is missing from ${written.path}` };
339
- if (entry.command !== "npx") {
340
- 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
+ };
341
354
  }
342
355
  return { status: "verified" };
343
356
  } catch (error) {