premanmcp 0.10.6 → 0.11.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 +11 -5
- package/bin/account.js +19 -4
- package/bin/api_tools.js +8 -0
- package/bin/cli.js +8 -2
- package/bin/connect/agents.js +198 -0
- package/bin/connect/checkin.js +503 -0
- package/bin/connect/configs.js +276 -0
- package/bin/connect/dispatch.js +131 -0
- package/bin/connect/errors.js +16 -0
- package/bin/connect/guide.js +535 -0
- package/bin/connect/pairing.js +94 -0
- package/bin/connect.js +101 -1554
- package/bin/desktop.js +28 -3
- package/bin/detect.js +42 -3
- package/bin/hook.js +10 -1
- package/bin/hosted.js +4 -4
- package/bin/progress.js +2 -5
- package/bin/runner.js +3 -30
- package/bin/shared.js +26 -1
- package/bin/status.js +1 -6
- package/bin/tests.js +341 -0
- package/bin/verify.js +82 -54
- package/dist/server.js +112 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -48,11 +48,13 @@ to go restart anything, in cheapest-first order:
|
|
|
48
48
|
1. **Self-test.** It starts the MCP server exactly as your agent will and calls
|
|
49
49
|
`preman_status` over stdio. That both completes the link and proves the whole chain —
|
|
50
50
|
launcher, package, key, backend. `--no-self-test` turns it off.
|
|
51
|
-
2. **Agent run**, which also proves your agent can load what was written.
|
|
52
|
-
agent
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
2. **Agent run**, which also proves your agent can load what was written. For Cursor it
|
|
52
|
+
runs `cursor-agent mcp enable` after writing `mcp.json` (new servers stay off the CLI
|
|
53
|
+
approved list otherwise) and launches the agent with `--approve-mcps` so the check-in
|
|
54
|
+
can actually call `preman_status`. It opens your agent interactively in a new terminal
|
|
55
|
+
window — the session you go on to use — and falls back to a headless run (`claude -p`,
|
|
56
|
+
`cursor-agent --approve-mcps -p`, `codex exec`) where no window can be opened, such as
|
|
57
|
+
CI or SSH. `--no-auto-checkin` turns it off, `PREMAN_NO_TERMINAL=1` keeps it headless.
|
|
56
58
|
3. **Wait**, if neither is possible: restart your agent and it links on its first call.
|
|
57
59
|
|
|
58
60
|
A self-test that answers from an unexpected backend is reported with the file that
|
|
@@ -71,6 +73,10 @@ up a link that already worked:
|
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
75
|
preman endpoints discover # map this repo's endpoints
|
|
76
|
+
preman tests generate # heuristic suites on saved Collections requests
|
|
77
|
+
preman tests review # list flagged suites; --approve <id> enables schedule
|
|
78
|
+
preman tests setup # harvest path-param fixture IDs into a .env snippet
|
|
79
|
+
preman tests enrich # optional LLM cases on remaining heuristic suites
|
|
74
80
|
preman runner start --background # let PreMan apply fixes on this machine
|
|
75
81
|
preman github # or connect it in the dashboard
|
|
76
82
|
preman status # which of those are done
|
package/bin/account.js
CHANGED
|
@@ -29,7 +29,7 @@ export const ACCOUNT_HELP = `
|
|
|
29
29
|
Account options:
|
|
30
30
|
login --browser Approve sign-in in the browser instead of typing a password
|
|
31
31
|
logout Delete stored CLI credentials
|
|
32
|
-
doctor
|
|
32
|
+
doctor [--port n] Check backend, credentials, target, and integrations
|
|
33
33
|
watch <run-id> Follow a push simulation as it runs
|
|
34
34
|
`;
|
|
35
35
|
|
|
@@ -140,7 +140,10 @@ export async function doctorCommand(commandArgs = []) {
|
|
|
140
140
|
if (!status.ok) failures += 1;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
const
|
|
143
|
+
const explicitPort = args.value("--port", "");
|
|
144
|
+
const candidates = detectCandidates(process.cwd(), {
|
|
145
|
+
extraPorts: explicitPort ? [explicitPort] : [],
|
|
146
|
+
});
|
|
144
147
|
results.push(
|
|
145
148
|
line(
|
|
146
149
|
"local target",
|
|
@@ -150,6 +153,7 @@ export async function doctorCommand(commandArgs = []) {
|
|
|
150
153
|
: "no signals in this directory"
|
|
151
154
|
)
|
|
152
155
|
);
|
|
156
|
+
if (!candidates.length) failures += 1;
|
|
153
157
|
|
|
154
158
|
// An installed hook whose command no longer answers is the one failure here
|
|
155
159
|
// that reports itself as success everywhere else: `git push` prints a single
|
|
@@ -200,7 +204,18 @@ export async function doctorCommand(commandArgs = []) {
|
|
|
200
204
|
return { failures };
|
|
201
205
|
}
|
|
202
206
|
|
|
203
|
-
const TERMINAL_STATUSES = new Set([
|
|
207
|
+
const TERMINAL_STATUSES = new Set([
|
|
208
|
+
"complete",
|
|
209
|
+
"completed",
|
|
210
|
+
"succeeded",
|
|
211
|
+
"failed",
|
|
212
|
+
"error",
|
|
213
|
+
"cancelled",
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
export function isTerminalSimulationStatus(value) {
|
|
217
|
+
return TERMINAL_STATUSES.has(String(value || "").toLowerCase());
|
|
218
|
+
}
|
|
204
219
|
|
|
205
220
|
export async function watchCommand(commandArgs = []) {
|
|
206
221
|
const args = makeArgs(commandArgs);
|
|
@@ -235,7 +250,7 @@ export async function watchCommand(commandArgs = []) {
|
|
|
235
250
|
process.stdout.write(` ${mark} ${String(step.key || "").padEnd(14)} ${step.message || ""}\n`);
|
|
236
251
|
}
|
|
237
252
|
|
|
238
|
-
if (
|
|
253
|
+
if (isTerminalSimulationStatus(result.status)) {
|
|
239
254
|
const verdict = result.summary?.verdict || result.status;
|
|
240
255
|
process.stdout.write(`\nRun ${result.status}: ${verdict}\n`);
|
|
241
256
|
return result;
|
package/bin/api_tools.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
10
|
|
|
11
11
|
import { callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
|
|
12
|
+
import { printPlayground } from "./desktop.js";
|
|
12
13
|
|
|
13
14
|
export const ENDPOINTS_HELP = `
|
|
14
15
|
Endpoints:
|
|
@@ -144,6 +145,13 @@ export async function endpointsCommand(commandArgs) {
|
|
|
144
145
|
if (requests.length) {
|
|
145
146
|
process.stdout.write(`runnable as: ${requests.join(", ")}\n`);
|
|
146
147
|
}
|
|
148
|
+
const url = result.ui?.url || result.url;
|
|
149
|
+
printPlayground(url);
|
|
150
|
+
if (result.campaign?.id) {
|
|
151
|
+
process.stdout.write(
|
|
152
|
+
`test campaign ${result.campaign.id}: ${result.campaign.queued || 0} queued, ${result.campaign.skipped || 0} skipped\n`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
147
155
|
return undefined;
|
|
148
156
|
}
|
|
149
157
|
|
package/bin/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ 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 { TESTS_HELP, testsCommand } from "./tests.js";
|
|
20
21
|
import {
|
|
21
22
|
CONNECT_HELP,
|
|
22
23
|
DISPATCH_HELP,
|
|
@@ -56,7 +57,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
56
57
|
const ROOT = path.join(__dirname, "..");
|
|
57
58
|
|
|
58
59
|
const args = process.argv.slice(2);
|
|
59
|
-
const command = args
|
|
60
|
+
const command = args.length === 0 && process.stdin.isTTY
|
|
61
|
+
? "connect"
|
|
62
|
+
: args[0] === "--help" || args[0] === "-h"
|
|
60
63
|
? "help"
|
|
61
64
|
: args[0] && !args[0].startsWith("-")
|
|
62
65
|
? args[0]
|
|
@@ -98,6 +101,7 @@ function printHelp() {
|
|
|
98
101
|
["link|tools|run ...", "Drive a published hosted MCP"],
|
|
99
102
|
["endpoints list|discover|setup ...", "Discover, list, and set up API endpoints"],
|
|
100
103
|
["test <id> [--scenario ...] [--stress]", "Generate + run tests for an endpoint"],
|
|
104
|
+
["tests generate|review|setup|enrich", "Collections: generate, review, fixtures, enrich"],
|
|
101
105
|
];
|
|
102
106
|
const width = Math.max(...usage.map(([command]) => `${cli} ${command}`.trimEnd().length));
|
|
103
107
|
const usageLines = usage
|
|
@@ -108,7 +112,7 @@ function printHelp() {
|
|
|
108
112
|
|
|
109
113
|
Usage:
|
|
110
114
|
${usageLines}
|
|
111
|
-
${INTEGRATIONS_HELP}${CONNECT_HELP}${DISPATCH_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${RUNNER_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}
|
|
115
|
+
${INTEGRATIONS_HELP}${CONNECT_HELP}${DISPATCH_HELP}${STATUS_HELP}${VERIFY_HELP}${HOOK_HELP}${RUNNER_HELP}${ACCOUNT_HELP}${DESKTOP_HELP}${ENDPOINTS_HELP}${TEST_HELP}${TESTS_HELP}
|
|
112
116
|
Login options:
|
|
113
117
|
--email <email> Pre-fill the email prompt
|
|
114
118
|
--backend <url> PreMan backend URL. Defaults to ${DEFAULT_BACKEND}
|
|
@@ -277,6 +281,8 @@ async function main() {
|
|
|
277
281
|
await endpointsCommand(commandArgs);
|
|
278
282
|
} else if (command === "test") {
|
|
279
283
|
await testCommand(commandArgs);
|
|
284
|
+
} else if (command === "tests") {
|
|
285
|
+
await testsCommand(commandArgs);
|
|
280
286
|
} else if (command === "help" || command === "--help" || command === "-h") {
|
|
281
287
|
printHelp();
|
|
282
288
|
} else if (command === "start") {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which coding agent this machine should use, and how it is asked.
|
|
3
|
+
*
|
|
4
|
+
* The registry is the shared vocabulary: `id` matches the backend's
|
|
5
|
+
* normalize_agent(), so a link record and this CLI cannot disagree about who is
|
|
6
|
+
* connected. Resolution is ordered by how much each signal actually knows --
|
|
7
|
+
* the session we are inside, the agent PreMan is already written into, the only
|
|
8
|
+
* one installed -- because asking is the step people resent most.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { onPath, promptText, readJsonFile } from "../shared.js";
|
|
16
|
+
import { ConnectError, EXIT_USAGE } from "./errors.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Supported agents. `id` matches the backend's normalize_agent() vocabulary so
|
|
20
|
+
* the link record and this CLI cannot disagree about who is connected.
|
|
21
|
+
*/
|
|
22
|
+
export const AGENTS = [
|
|
23
|
+
{
|
|
24
|
+
id: "cursor",
|
|
25
|
+
label: "Cursor",
|
|
26
|
+
aliases: ["cursor"],
|
|
27
|
+
dispatch: {
|
|
28
|
+
credential: "Cursor API key",
|
|
29
|
+
needsRoutine: false,
|
|
30
|
+
source: "cursor.com/dashboard → Integrations → API Keys",
|
|
31
|
+
},
|
|
32
|
+
snippetHint: "merge into ~/.cursor/mcp.json, then run: cursor-agent mcp enable preman",
|
|
33
|
+
restartHint:
|
|
34
|
+
'Fully quit and reopen Cursor, then Settings → MCP → toggle "preman" off and on. For the CLI, run `cursor-agent mcp enable preman`.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "claude_code",
|
|
38
|
+
label: "Claude Code",
|
|
39
|
+
aliases: ["claude", "claude-code", "claude_code", "claudecode"],
|
|
40
|
+
dispatch: {
|
|
41
|
+
credential: "Claude Code routine token",
|
|
42
|
+
needsRoutine: true,
|
|
43
|
+
// Named explicitly because "Claude Code routine token" reads like an
|
|
44
|
+
// Anthropic API key, which is a different credential from a different page.
|
|
45
|
+
source: "claude.ai/code/routines → your routine → Add API trigger",
|
|
46
|
+
},
|
|
47
|
+
snippetHint: "run:",
|
|
48
|
+
restartHint: 'Start a new Claude Code session and run `claude mcp list` — "preman" should be listed.',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "codex",
|
|
52
|
+
label: "Codex",
|
|
53
|
+
aliases: ["codex", "openai-codex", "openai_codex"],
|
|
54
|
+
dispatch: null, // No public fire API; stays on the copy-paste path.
|
|
55
|
+
snippetHint: "append to ~/.codex/config.toml",
|
|
56
|
+
restartHint: "Restart Codex so it re-reads its config.toml.",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export function findAgent(value) {
|
|
61
|
+
const raw = String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
|
|
62
|
+
return AGENTS.find((a) => a.id === raw || a.aliases.includes(raw)) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Why this agent's CLI cannot run unattended right now, or "".
|
|
68
|
+
*
|
|
69
|
+
* Installed is not the same as usable. `cursor-agent` sits on PATH and exits 1
|
|
70
|
+
* on every `-p` run until someone signs in, which surfaced as "Cursor did not
|
|
71
|
+
* register any endpoints" and sent people looking at PreMan for an hour. Only
|
|
72
|
+
* Cursor is probed because only its CLI has a cheap non-interactive status
|
|
73
|
+
* subcommand; the others are diagnosed from their own output when they fail.
|
|
74
|
+
*/
|
|
75
|
+
export function agentBlocker(agentId) {
|
|
76
|
+
if (agentId !== "cursor") return "";
|
|
77
|
+
const probe = spawnSync("cursor-agent", ["status"], {
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
timeout: 15000,
|
|
80
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
+
});
|
|
82
|
+
const text = `${probe.stdout || ""}${probe.stderr || ""}`;
|
|
83
|
+
if (/not logged in|not authenticated|no active session/i.test(text)) {
|
|
84
|
+
return "cursor-agent is installed but not signed in — run `cursor-agent login`";
|
|
85
|
+
}
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Best guess at which agent this machine actually uses, for the default pick. */
|
|
90
|
+
export function detectAgents() {
|
|
91
|
+
const home = os.homedir();
|
|
92
|
+
return {
|
|
93
|
+
cursor: existsSync(path.join(home, ".cursor")) || Boolean(process.env.CURSOR_TRACE_ID),
|
|
94
|
+
claude_code: onPath("claude") || existsSync(path.join(home, ".claude.json")),
|
|
95
|
+
codex: onPath("codex") || existsSync(process.env.CODEX_HOME || path.join(home, ".codex")),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The agent whose session this command is running inside, if any.
|
|
101
|
+
*
|
|
102
|
+
* A better default than "first one installed": someone who types this into an
|
|
103
|
+
* agent's terminal almost always means that agent, and on a machine with all
|
|
104
|
+
* three installed the detected-order default is usually wrong.
|
|
105
|
+
*/
|
|
106
|
+
export function runningInside(env = process.env) {
|
|
107
|
+
if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude_code";
|
|
108
|
+
if (env.CURSOR_TRACE_ID) return "cursor";
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Is PreMan's MCP server already written into this agent's config? */
|
|
113
|
+
function agentIsLinked(agentId, serverName = "preman") {
|
|
114
|
+
try {
|
|
115
|
+
const home = os.homedir();
|
|
116
|
+
if (agentId === "cursor") {
|
|
117
|
+
return Boolean(readJsonFile(path.join(home, ".cursor", "mcp.json")).mcpServers?.[serverName]);
|
|
118
|
+
}
|
|
119
|
+
if (agentId === "claude_code") {
|
|
120
|
+
return Boolean(readJsonFile(path.join(home, ".claude.json")).mcpServers?.[serverName]);
|
|
121
|
+
}
|
|
122
|
+
if (agentId === "codex") {
|
|
123
|
+
const file = path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "config.toml");
|
|
124
|
+
return existsSync(file) && readFileSync(file, "utf8").includes(`[mcp_servers.${serverName}]`);
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// An unreadable config is not an answer; fall through to the other signals.
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Which agent this machine should pair as, or "" when only a person can say.
|
|
134
|
+
*
|
|
135
|
+
* Pairing needs an agent id, and demanding `--agent` for it turned "start the
|
|
136
|
+
* runner" into a command someone had to be told twice. The signals are ordered
|
|
137
|
+
* by how much they actually know: the session we are inside, then the agent
|
|
138
|
+
* PreMan is already configured in, then the only one installed. Asking is the
|
|
139
|
+
* last resort rather than the first, and a machine with no terminal to ask in
|
|
140
|
+
* gets "" so the caller can fail with a sentence instead of hanging on a prompt.
|
|
141
|
+
*/
|
|
142
|
+
export async function resolveAgentForPairing({ interactive = Boolean(process.stdin.isTTY) } = {}) {
|
|
143
|
+
const inside = runningInside();
|
|
144
|
+
if (inside) return inside;
|
|
145
|
+
|
|
146
|
+
const linked = AGENTS.filter((agent) => agentIsLinked(agent.id));
|
|
147
|
+
if (linked.length === 1) return linked[0].id;
|
|
148
|
+
|
|
149
|
+
const detected = detectAgents();
|
|
150
|
+
const present = AGENTS.filter((agent) => detected[agent.id]);
|
|
151
|
+
if (present.length === 1) return present[0].id;
|
|
152
|
+
|
|
153
|
+
if (!interactive) return "";
|
|
154
|
+
const picked = await promptAgentChoice(detected);
|
|
155
|
+
return picked?.id || "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The agent a directly-invoked command should drive, as an object, or null.
|
|
160
|
+
*
|
|
161
|
+
* Same signals as pairing, resolved to the record `discoverEndpoints` needs,
|
|
162
|
+
* with an explicit `--agent` taking precedence so the command stays scriptable
|
|
163
|
+
* on a machine that has all three installed. Commands the user typed on purpose
|
|
164
|
+
* get to run the agent; the caller falls back to printing the brief on null,
|
|
165
|
+
* which is also what a non-interactive run with no clear answer gets.
|
|
166
|
+
*/
|
|
167
|
+
export async function resolveAgentToDrive({ agent = "", ...options } = {}) {
|
|
168
|
+
return findAgent(agent) || findAgent(await resolveAgentForPairing(options));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function promptAgentChoice(detected, insideId = runningInside()) {
|
|
172
|
+
process.stdout.write("Which coding agent?\n");
|
|
173
|
+
AGENTS.forEach((agent, index) => {
|
|
174
|
+
let mark = "";
|
|
175
|
+
if (agent.id === insideId) mark = " (this session)";
|
|
176
|
+
else if (detected[agent.id]) mark = " (detected)";
|
|
177
|
+
process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const inside = AGENTS.findIndex((a) => a.id === insideId);
|
|
181
|
+
const defaultIndex =
|
|
182
|
+
inside >= 0
|
|
183
|
+
? inside
|
|
184
|
+
: Math.max(
|
|
185
|
+
0,
|
|
186
|
+
AGENTS.findIndex((a) => detected[a.id])
|
|
187
|
+
);
|
|
188
|
+
const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
|
|
189
|
+
if (!answer) return AGENTS[defaultIndex];
|
|
190
|
+
|
|
191
|
+
const byNumber = Number.parseInt(answer, 10);
|
|
192
|
+
if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= AGENTS.length) {
|
|
193
|
+
return AGENTS[byNumber - 1];
|
|
194
|
+
}
|
|
195
|
+
const byName = findAgent(answer);
|
|
196
|
+
if (byName) return byName;
|
|
197
|
+
throw new ConnectError(`Not a valid choice: ${answer}`, EXIT_USAGE);
|
|
198
|
+
}
|