@movp/cli 1.0.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.
Files changed (3) hide show
  1. package/bin/cli.js +921 -0
  2. package/hook.js +111 -0
  3. package/package.json +33 -0
package/bin/cli.js ADDED
@@ -0,0 +1,921 @@
1
+ #!/usr/bin/env node
2
+ // @movp/cli
3
+ // Usage:
4
+ // npx @movp/cli install — install MoVP plugins to ~/.movp/plugins/
5
+ // npx @movp/cli install --tool claude|cursor|codex — install one plugin only
6
+ // npx @movp/cli install --dir <path> — custom install location
7
+ // npx @movp/cli install --version <tag> — specific release
8
+ // npx @movp/cli install --init — also run init (opt-in)
9
+ // npx @movp/cli init — auto-detect tool, write all config
10
+ // npx @movp/cli init --cursor — Cursor-specific setup (MCP + rules only)
11
+ // npx @movp/cli init --codex — Codex-specific setup (MCP config only)
12
+ // npx @movp/cli init --no-rules — skip writing movp-review rule (use when loading the plugin)
13
+ // npx @movp/cli login — device auth login
14
+ // npx @movp/cli hook — run as PostToolUse hook (reads stdin)
15
+ // npx @movp/cli — alias for `hook`
16
+
17
+ "use strict";
18
+
19
+ const fs = require("fs");
20
+ const os = require("os");
21
+ const path = require("path");
22
+ const readline = require("readline");
23
+ const https = require("https");
24
+ const http = require("http");
25
+
26
+ const [, , command, ...args] = process.argv;
27
+
28
+ if (!command || command === "hook") {
29
+ require("../hook.js");
30
+ return;
31
+ }
32
+
33
+ if (command === "--help" || command === "-h") {
34
+ console.log(`
35
+ npx @movp/cli <command>
36
+
37
+ Commands:
38
+ install Install MoVP plugins to ~/.movp/plugins/
39
+ init Write MCP + hook config into the current project
40
+ login Device auth login
41
+ hook PostToolUse hook (default when no command given)
42
+
43
+ Run 'npx @movp/cli install --help' for install-specific options.
44
+ `);
45
+ process.exit(0);
46
+ }
47
+
48
+ if (command === "install") {
49
+ if (args.includes("--help") || args.includes("-h")) {
50
+ printInstallHelp();
51
+ process.exit(0);
52
+ }
53
+ const dirIdx = args.indexOf("--dir");
54
+ const toolIdx = args.indexOf("--tool");
55
+ const versionIdx = args.indexOf("--version");
56
+ // Require that value-taking flags are followed by a non-flag token
57
+ function requireFlagValue(idx, flag) {
58
+ if (idx < 0) return null;
59
+ const val = args[idx + 1];
60
+ if (!val || val.startsWith("-")) {
61
+ console.error(` ${flag} requires a value`);
62
+ process.exit(1);
63
+ }
64
+ return val;
65
+ }
66
+ const installDir = requireFlagValue(dirIdx, "--dir");
67
+ const tool = requireFlagValue(toolIdx, "--tool");
68
+ const version = requireFlagValue(versionIdx, "--version");
69
+ const runInitAfter = args.includes("--init");
70
+ runInstall({ installDir, tool, version, runInitAfter }).catch((e) => { console.error(e.message); process.exit(1); });
71
+ } else if (command === "init") {
72
+ const forcedTool = args.includes("--cursor") ? "cursor"
73
+ : args.includes("--codex") ? "codex"
74
+ : null;
75
+ const noRules = args.includes("--no-rules");
76
+ runInit(forcedTool, { noRules }).catch((e) => { console.error(e.message); process.exit(1); });
77
+ } else if (command === "login") {
78
+ runLogin().catch((e) => { console.error(e.message); process.exit(1); });
79
+ } else {
80
+ console.error(`Unknown command: ${command}`);
81
+ console.error("Run 'npx @movp/cli --help' for available commands.");
82
+ process.exit(1);
83
+ }
84
+
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // Shared helpers
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+
89
+ function prompt(rl, question) {
90
+ return new Promise((resolve) => rl.question(question, resolve));
91
+ }
92
+
93
+ function sleep(ms) {
94
+ return new Promise((resolve) => setTimeout(resolve, ms));
95
+ }
96
+
97
+ function postJSON(baseUrl, urlPath, body, extraHeaders = {}) {
98
+ return new Promise((resolve, reject) => {
99
+ const data = JSON.stringify(body);
100
+ const parsed = new URL(urlPath, baseUrl);
101
+ const mod = parsed.protocol === "https:" ? https : http;
102
+ const req = mod.request(
103
+ {
104
+ hostname: parsed.hostname,
105
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
106
+ path: parsed.pathname + parsed.search,
107
+ method: "POST",
108
+ headers: {
109
+ "Content-Type": "application/json",
110
+ "Content-Length": Buffer.byteLength(data),
111
+ ...extraHeaders,
112
+ },
113
+ },
114
+ (res) => {
115
+ let buf = "";
116
+ res.on("data", (chunk) => (buf += chunk));
117
+ res.on("end", () => {
118
+ try { resolve({ status: res.statusCode, body: JSON.parse(buf) }); }
119
+ catch { resolve({ status: res.statusCode, body: buf }); }
120
+ });
121
+ }
122
+ );
123
+ req.on("error", reject);
124
+ req.write(data);
125
+ req.end();
126
+ });
127
+ }
128
+
129
+ function loadCredentials() {
130
+ try {
131
+ const credPath = path.join(os.homedir(), ".config", "movp", "credentials");
132
+ const lines = fs.readFileSync(credPath, "utf8").split("\n");
133
+ const creds = {};
134
+ for (const line of lines) {
135
+ const trimmed = line.trim();
136
+ if (!trimmed || trimmed.startsWith("#")) continue;
137
+ const eq = trimmed.indexOf("=");
138
+ if (eq < 0) continue;
139
+ creds[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
140
+ }
141
+ return creds;
142
+ } catch {
143
+ return {};
144
+ }
145
+ }
146
+
147
+ function writeCredentials(bffUrl, userId, tenantId) {
148
+ const configDir = path.join(os.homedir(), ".config", "movp");
149
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
150
+ const credPath = path.join(configDir, "credentials");
151
+ const content =
152
+ `# MoVP device credentials — written by 'npx @movp/cli login'\n` +
153
+ `# Do not commit this file.\n` +
154
+ `WORKDESK_URL=${bffUrl}\n` +
155
+ `WORKDESK_USER=${userId}\n` +
156
+ `WORKDESK_TENANT=${tenantId}\n`;
157
+ fs.writeFileSync(credPath, content, { mode: 0o600 });
158
+ return credPath;
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────────────────────
162
+ // Auto-detect the AI coding tool in the current directory
163
+ // ─────────────────────────────────────────────────────────────────────────────
164
+
165
+ function detectTool() {
166
+ const cwd = process.cwd();
167
+ if (fs.existsSync(path.join(cwd, ".claude"))) return "claude-code";
168
+ if (fs.existsSync(path.join(cwd, ".cursor")) || fs.existsSync(path.join(cwd, ".cursorrules"))) return "cursor";
169
+ return null; // unknown — will ask
170
+ }
171
+
172
+ // ─────────────────────────────────────────────────────────────────────────────
173
+ // movp-review.md rule content
174
+ // ─────────────────────────────────────────────────────────────────────────────
175
+
176
+ const MOVP_REVIEW_RULE = `rule_id: movp-review
177
+
178
+ # MoVP Adversarial Review
179
+
180
+ > Scope: plan files and significant code output
181
+ > MCP tools: \`get_review_status\`, \`trigger_review\`, \`resolve_review\`
182
+
183
+ After writing a plan file or producing a significant code change (new feature, refactor, migration, or multi-file edit), trigger an adversarial review using the MoVP tools. Do not trigger for trivial operations: file reads, git status/log, search queries, single-line fixes, or tool calls with no code output.
184
+
185
+ ## When to trigger
186
+
187
+ Trigger \`trigger_review\` automatically when you:
188
+
189
+ - Write or significantly modify a plan file (\`.md\` planning documents, architecture docs, PRDs)
190
+ - Complete a multi-file code change (3+ files, or any new service/module)
191
+ - Produce a significant new code output in a single response (functions, handlers, migrations)
192
+
193
+ Do **not** trigger for: reading files, running tests, git commands, grep/search, single-line edits, config-only changes.
194
+
195
+ ## How to run a review
196
+
197
+ \`\`\`
198
+ 1. Call trigger_review(artifact_type="plan_file"|"code_output", content=<artifact>, session_id=<current session>)
199
+ → returns review_id
200
+
201
+ 2. Poll get_review_status(review_id=<id>) until review_status is "completed" or "error"
202
+ Always use the review_id from step 1 — do NOT call get_review_status without review_id
203
+ when multiple reviews may be in flight (returns most recent tenant review otherwise).
204
+
205
+ 3. Ask the developer: implement fixes, dismiss findings, or accept as-is
206
+
207
+ 4. Call resolve_review(review_id=<id>, action="accept"|"dismiss"|"escalate"|"retry") based on their choice
208
+ \`\`\`
209
+
210
+ ## Presenting findings
211
+
212
+ Format findings as structured output with severity badges. After showing findings, always ask:
213
+
214
+ > **Reply with:** implement fixes, dismiss (false positive / not applicable / deferred), or accept as-is
215
+
216
+ ## Resolve actions
217
+
218
+ | Developer says | Action to call | Notes |
219
+ |---|---|---|
220
+ | "accept", "looks good", "ship it" | \`resolve_review(action="accept")\` | Idempotent — safe to call twice |
221
+ | "dismiss", "false positive", "not applicable" | \`resolve_review(action="dismiss", reason="false_positive"\\|"not_applicable"\\|"deferred")\` | |
222
+ | "escalate", "create a ticket" | \`resolve_review(action="escalate", target="todo")\` | |
223
+ | "retry", "run it again" | \`resolve_review(action="retry")\` | **Only valid when review_status is "error"** — do not call on completed reviews |
224
+
225
+ ## Rate and cost awareness
226
+
227
+ Reviews consume LLM budget. Do not trigger multiple reviews in a single session for the same artifact. If \`trigger_review\` returns a rate limit error (429), inform the developer and do not retry automatically.
228
+ `;
229
+
230
+ // ─────────────────────────────────────────────────────────────────────────────
231
+ // .movp/config.yaml default content
232
+ // ─────────────────────────────────────────────────────────────────────────────
233
+
234
+ const DEFAULT_PROJECT_CONFIG = `version: 1
235
+ review:
236
+ enabled: true
237
+ categories:
238
+ # Default 8 categories — all scored 1-10 by the adversarial model.
239
+ # All weights are equal by default. Increase a weight to emphasize a category.
240
+ # Weights must be positive integers >= 1.
241
+ - name: security
242
+ weight: 1
243
+ - name: correctness
244
+ weight: 1
245
+ - name: performance
246
+ weight: 1
247
+ - name: stability
248
+ weight: 1
249
+ - name: ux_drift
250
+ weight: 1
251
+ - name: outcome_drift
252
+ weight: 1
253
+ - name: missing_tests
254
+ weight: 1
255
+ - name: scope_creep
256
+ weight: 1
257
+ # Add custom categories:
258
+ # - name: accessibility
259
+ # description: WCAG 2.1 AA compliance
260
+ # weight: 1
261
+ auto_review:
262
+ plan_files: true # auto-trigger review after writing plan files
263
+ code_output: false # auto-trigger review after significant code output
264
+ cost_cap_daily_usd: 5.0
265
+ max_rounds: 3
266
+ # rule_apply_mode: "direct" # "direct" = write rules on confirm; "pr" = create branch + PR
267
+ control_plane:
268
+ health_check_interval: 20 # seconds between health checks
269
+ show_cost: true
270
+ show_recommendations: true
271
+ `;
272
+
273
+ const DEFAULT_LOCAL_CONFIG = `# .movp/config.local.yaml — personal overrides (gitignored)
274
+ # Overrides .movp/config.yaml for your local environment only.
275
+ # Example:
276
+ # review:
277
+ # enabled: false
278
+ `;
279
+
280
+ // ─────────────────────────────────────────────────────────────────────────────
281
+ // install
282
+ // ─────────────────────────────────────────────────────────────────────────────
283
+
284
+ function printInstallHelp() {
285
+ console.log(`
286
+ npx @movp/cli install [options]
287
+
288
+ Install MoVP plugins to ~/.movp/plugins/ (or a custom directory).
289
+
290
+ Options:
291
+ --tool <name> Install only one plugin: claude, cursor, or codex
292
+ --dir <path> Install to a custom directory instead of ~/.movp/plugins/
293
+ --version <tag> Install a specific git tag/branch (default: main)
294
+ --init Also run \`init\` for the installed tool after install
295
+ -h, --help Show this help text
296
+
297
+ Environment:
298
+ MOVP_RELEASE_URL If set, download a release .tar.gz instead of cloning.
299
+ Must be https://. Overrides --version.
300
+ The archive must have the mona-lisa repo layout at depth 1
301
+ (--strip-components=1 is applied during extraction).
302
+
303
+ Examples:
304
+ npx @movp/cli install
305
+ npx @movp/cli install --tool claude
306
+ npx @movp/cli install --dir /opt/movp/plugins
307
+ npx @movp/cli install --version v1.2.0
308
+ npx @movp/cli install --tool cursor --init
309
+ `);
310
+ }
311
+
312
+ async function runInstall({ installDir, tool, version, runInitAfter }) {
313
+ const { spawnSync } = require("child_process");
314
+
315
+ const targetDir = installDir
316
+ ? path.resolve(installDir)
317
+ : path.join(os.homedir(), ".movp", "plugins");
318
+
319
+ console.log("\n MoVP Plugin Installer\n");
320
+
321
+ // Check prerequisites — use process.version (we're already in Node) to avoid
322
+ // edge cases with multiple Node installs on PATH.
323
+ const nodeMatch = process.version.match(/^v(\d+)/);
324
+ const nodeMajor = nodeMatch ? parseInt(nodeMatch[1], 10) : 0;
325
+ if (nodeMajor < 18) {
326
+ console.error(` Node.js 18+ is required (found ${process.version}). Install from https://nodejs.org`);
327
+ process.exit(1);
328
+ }
329
+
330
+ const releaseUrl = process.env.MOVP_RELEASE_URL || "";
331
+
332
+ // git is only needed for the clone path — skip check when using MOVP_RELEASE_URL.
333
+ if (!releaseUrl && spawnSync("git", ["--version"]).status !== 0) {
334
+ console.error(" git is required. Install from https://git-scm.com");
335
+ process.exit(1);
336
+ }
337
+
338
+ // tar is only needed for the tarball path.
339
+ // Use a feature probe (not --version) because some BSD tar builds exit non-zero for --version.
340
+ if (releaseUrl) {
341
+ const tarProbe = spawnSync("tar", ["--help"]);
342
+ if (tarProbe.error && tarProbe.error.code === "ENOENT") {
343
+ console.error(" tar is required when MOVP_RELEASE_URL is set. Install BSD/GNU tar (comes with macOS and most Linux distros; on Windows, use Windows 10+ built-in tar or WSL).");
344
+ process.exit(1);
345
+ }
346
+ }
347
+
348
+ // Validate --tool
349
+ const validTools = ["claude", "cursor", "codex"];
350
+ if (tool && !validTools.includes(tool)) {
351
+ console.error(` Unknown tool: ${tool}. Must be one of: claude, cursor, codex`);
352
+ process.exit(1);
353
+ }
354
+
355
+ // Determine which plugin dirs to copy
356
+ const pluginDirs = tool
357
+ ? [`${tool}-plugin`]
358
+ : ["claude-plugin", "cursor-plugin", "codex-plugin"];
359
+
360
+ fs.mkdirSync(targetDir, { recursive: true });
361
+
362
+ // Download from release tarball or git clone — no shell interpolation
363
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "movp-install-"));
364
+ try {
365
+ if (releaseUrl) {
366
+ // MOVP_RELEASE_URL must be an https:// URL pointing to a .tar.gz with the
367
+ // mona-lisa repo layout at depth 1 (--strip-components=1 removes the top dir).
368
+ if (!releaseUrl.startsWith("https://")) {
369
+ throw new Error("MOVP_RELEASE_URL must start with https://");
370
+ }
371
+ if (version) {
372
+ console.warn(` Warning: --version is ignored when MOVP_RELEASE_URL is set.\n To install a specific tag, use a URL for that release or unset MOVP_RELEASE_URL.`);
373
+ }
374
+ // Log only origin + path — strip query and hash to avoid leaking pre-signed tokens.
375
+ const logUrl = (() => { try { const u = new URL(releaseUrl); return u.origin + u.pathname; } catch { return releaseUrl; } })();
376
+ console.log(` Downloading ${logUrl} ...`);
377
+ const tarPath = path.join(os.tmpdir(), `movp-release-${Date.now()}.tar.gz`);
378
+ try {
379
+ await downloadFile(releaseUrl, tarPath);
380
+ const tarResult = spawnSync("tar", ["-xz", "-C", tmpDir, "--strip-components=1", "-f", tarPath], { stdio: "inherit" });
381
+ if (tarResult.status !== 0) {
382
+ throw new Error(
383
+ "tar extraction failed. Verify MOVP_RELEASE_URL points to a .tar.gz with the mona-lisa repo layout at the top level."
384
+ );
385
+ }
386
+ } finally {
387
+ try { fs.unlinkSync(tarPath); } catch { /* best-effort */ }
388
+ }
389
+ } else {
390
+ const ref = version || "main";
391
+ console.log(` Cloning mona-lisa @ ${ref} ...`);
392
+ const cloneResult = spawnSync(
393
+ "git",
394
+ ["clone", "--depth", "1", "--filter=blob:none", "--single-branch", "--branch", ref,
395
+ "https://github.com/MostViableProduct/mona-lisa.git", tmpDir],
396
+ { stdio: "inherit" }
397
+ );
398
+ if (cloneResult.status !== 0) {
399
+ throw new Error(
400
+ `git clone failed (ref: ${ref}). Check the tag exists at https://github.com/MostViableProduct/mona-lisa/releases and that you have network access.`
401
+ );
402
+ }
403
+ }
404
+
405
+ // Copy each plugin directory into targetDir
406
+ for (const dir of pluginDirs) {
407
+ const src = path.join(tmpDir, dir);
408
+ const dest = path.join(targetDir, dir);
409
+ if (!fs.existsSync(src)) {
410
+ throw new Error(`Plugin directory not found in downloaded repo: ${dir}. The archive layout may have changed — check https://github.com/MostViableProduct/mona-lisa`);
411
+ }
412
+ if (fs.existsSync(dest)) {
413
+ fs.rmSync(dest, { recursive: true, force: true });
414
+ }
415
+ fs.cpSync(src, dest, { recursive: true });
416
+ console.log(` Installed ${dir} → ${dest}`);
417
+ }
418
+ } finally {
419
+ fs.rmSync(tmpDir, { recursive: true, force: true });
420
+ }
421
+
422
+ // Auto-detect installed tools (skip when --tool is specified)
423
+ if (!tool) {
424
+ const detected = [];
425
+ for (const [name, cmd] of [["Claude Code", "claude"], ["Cursor", "cursor"], ["Codex", "codex"]]) {
426
+ if (spawnSync(cmd, ["--version"]).status === 0) detected.push(name);
427
+ }
428
+ if (detected.length) {
429
+ console.log(`\n Detected: ${detected.join(", ")}`);
430
+ }
431
+ }
432
+
433
+ printInstallNextSteps(targetDir, tool);
434
+
435
+ // Opt-in: run init after install (requires a project cwd)
436
+ if (runInitAfter) {
437
+ // Map install --tool names to runInit's expected tool identifiers
438
+ const forcedTool = tool === "claude" ? "claude-code" : tool === "cursor" ? "cursor" : tool === "codex" ? "codex" : null;
439
+ try {
440
+ await runInit(forcedTool);
441
+ } catch (e) {
442
+ // Plugins are already on disk — distinguish install success from init failure.
443
+ const msg = e instanceof Error ? e.message : String(e);
444
+ console.error(`\n Plugins were installed to ${targetDir}/`);
445
+ console.error(` Init failed: ${msg}`);
446
+ process.exit(1);
447
+ }
448
+ }
449
+ }
450
+
451
+ // Downloads a URL (following up to maxRedirects redirects) to a local file path.
452
+ // Resolves relative Location headers against the current URL.
453
+ function downloadFile(url, dest, maxRedirects = 5) {
454
+ return new Promise((resolve, reject) => {
455
+ if (maxRedirects < 0) {
456
+ reject(new Error(`Too many redirects downloading ${url}`));
457
+ return;
458
+ }
459
+ const file = fs.createWriteStream(dest);
460
+ const mod = url.startsWith("https:") ? https : http;
461
+ const req = mod.get(url, (res) => {
462
+ if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
463
+ res.resume(); // drain body so the socket is released
464
+ file.close();
465
+ // Synchronously remove the empty file before starting the next request
466
+ // to the same path, avoiding a write overlap on slow filesystems.
467
+ try { fs.unlinkSync(dest); } catch { /* best-effort */ }
468
+ const location = res.headers.location;
469
+ if (!location) {
470
+ reject(new Error(`Redirect (${res.statusCode}) without Location header from ${url}`));
471
+ return;
472
+ }
473
+ let next;
474
+ try {
475
+ next = new URL(location, url).href;
476
+ } catch {
477
+ reject(new Error(`Invalid redirect Location header: ${JSON.stringify(location)}`));
478
+ return;
479
+ }
480
+ // Enforce HTTPS on every hop — reject downgrades via redirect.
481
+ if (!next.startsWith("https:")) {
482
+ reject(new Error(`Redirect to non-HTTPS URL rejected (${new URL(next).origin}). Set MOVP_RELEASE_URL to an https:// URL.`));
483
+ return;
484
+ }
485
+ downloadFile(next, dest, maxRedirects - 1).then(resolve).catch(reject);
486
+ return;
487
+ }
488
+ if (res.statusCode !== 200) {
489
+ file.close();
490
+ try { fs.unlinkSync(dest); } catch { /* best-effort */ }
491
+ const hint =
492
+ res.statusCode === 408 ? " (request timeout — retry or check network)" :
493
+ res.statusCode === 429 ? " (rate limited — wait a moment and retry)" :
494
+ res.statusCode === 401 || res.statusCode === 403 ? " (check authentication or access permissions)" :
495
+ res.statusCode === 404 ? " (URL not found — verify MOVP_RELEASE_URL)" :
496
+ res.statusCode >= 500 ? " (server error — try again later)" : "";
497
+ reject(new Error(`HTTP ${res.statusCode}${hint} downloading ${url}`));
498
+ return;
499
+ }
500
+ req.setTimeout(0); // clear the connection timeout once we're actively streaming
501
+ res.pipe(file);
502
+ file.on("finish", () => file.close(resolve));
503
+ file.on("error", (err) => {
504
+ try { fs.unlinkSync(dest); } catch { /* best-effort */ }
505
+ reject(err);
506
+ });
507
+ });
508
+ // Abort the request if no data arrives within 30 seconds.
509
+ req.setTimeout(30000, () => {
510
+ req.destroy(new Error(`Download timed out after 30s. Check your network or the URL: ${url}`));
511
+ });
512
+ req.on("error", (err) => {
513
+ file.close();
514
+ try { fs.unlinkSync(dest); } catch { /* best-effort */ }
515
+ reject(err);
516
+ });
517
+ });
518
+ }
519
+
520
+ function printInstallNextSteps(pluginsDir, tool) {
521
+ console.log(`\n Plugins installed to ${pluginsDir}/\n`);
522
+ console.log(" Next steps:\n");
523
+
524
+ if (!tool || tool === "claude") {
525
+ console.log(" Claude Code:");
526
+ console.log(" cd your-project");
527
+ console.log(" npx @movp/cli init");
528
+ console.log(` claude --plugin-dir ${pluginsDir}/claude-plugin\n`);
529
+ }
530
+ if (!tool || tool === "cursor") {
531
+ console.log(" Cursor:");
532
+ console.log(" cd your-project");
533
+ console.log(" npx @movp/cli init --cursor");
534
+ console.log(` cursor --plugin-dir ${pluginsDir}/cursor-plugin\n`);
535
+ }
536
+ if (!tool || tool === "codex") {
537
+ console.log(" Codex:");
538
+ console.log(" cd your-project");
539
+ console.log(" npx @movp/cli init --codex");
540
+ console.log(` codex --plugin-dir ${pluginsDir}/codex-plugin\n`);
541
+ }
542
+
543
+ console.log(" Need help? https://github.com/MostViableProduct/mona-lisa#troubleshooting\n");
544
+ }
545
+
546
+ // ─────────────────────────────────────────────────────────────────────────────
547
+ // init
548
+ // ─────────────────────────────────────────────────────────────────────────────
549
+
550
+ async function runInit(forcedTool, { noRules = false } = {}) {
551
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
552
+ const cwd = process.cwd();
553
+
554
+ console.log("\n MoVP CLI v1.0.0\n");
555
+
556
+ // ── Step 0: Determine tool ─────────────────────────────────────────────────
557
+ let tool = forcedTool || detectTool();
558
+ if (!tool) {
559
+ console.log(" Could not auto-detect AI coding tool.");
560
+ const answer = (await prompt(rl, " Tool [claude-code|cursor|codex]: ")).trim().toLowerCase();
561
+ tool = answer || "claude-code";
562
+ } else {
563
+ console.log(` Detected: ${tool}`);
564
+ }
565
+
566
+ if (!["claude-code", "cursor", "codex"].includes(tool)) {
567
+ console.error(` Unknown tool: ${tool}`);
568
+ rl.close();
569
+ process.exit(1);
570
+ }
571
+
572
+ // ── Step 1: Authentication ────────────────────────────────────────────────
573
+ console.log("\n Step 1/3: Authentication");
574
+ let creds = loadCredentials();
575
+ let bffUrl = creds.WORKDESK_URL || process.env.WORKDESK_URL || "";
576
+ let tenantId = creds.WORKDESK_TENANT || process.env.WORKDESK_TENANT || "";
577
+ let userId = creds.WORKDESK_USER || "";
578
+
579
+ if (bffUrl && tenantId) {
580
+ console.log(` Already authenticated (tenant: ${tenantId})`);
581
+ } else {
582
+ console.log(" Opening browser for device login...");
583
+ // runLogin() only writes to stdout and polls HTTP — rl stays open for prompts after
584
+ await runLogin();
585
+ creds = loadCredentials();
586
+ bffUrl = creds.WORKDESK_URL || "";
587
+ tenantId = creds.WORKDESK_TENANT || "";
588
+ userId = creds.WORKDESK_USER || "";
589
+ }
590
+
591
+ if (!bffUrl || !tenantId) {
592
+ console.error(" Authentication incomplete. Run `npx @movp/cli login` first.");
593
+ rl.close();
594
+ process.exit(1);
595
+ }
596
+
597
+ // ── Step 2: Agent Source ──────────────────────────────────────────────────
598
+ console.log("\n Step 2/3: Agent Source");
599
+ let apiKey = "";
600
+ try {
601
+ const res = await postJSON(bffUrl, "/api/workdesk/agents/setup", {
602
+ agent_type: tool,
603
+ user_id: userId,
604
+ tenant_id: tenantId,
605
+ }, {
606
+ "X-Tenant-ID": tenantId,
607
+ });
608
+ if (res.status === 200 || res.status === 201) {
609
+ apiKey = res.body.api_key || res.body.apiKey || "";
610
+ if (apiKey) {
611
+ console.log(" Agent source created.");
612
+ // Persist api_key to credentials file
613
+ const configDir = path.join(os.homedir(), ".config", "movp");
614
+ const credPath = path.join(configDir, "credentials");
615
+ let existing = "";
616
+ try { existing = fs.readFileSync(credPath, "utf8"); } catch {}
617
+ if (!existing.includes("WORKDESK_API_KEY=")) {
618
+ fs.appendFileSync(credPath, `WORKDESK_API_KEY=${apiKey}\n`);
619
+ }
620
+ } else {
621
+ console.log(" Agent source configured (no new key returned — using existing).");
622
+ apiKey = process.env.WORKDESK_API_KEY || "";
623
+ }
624
+ } else {
625
+ console.log(` Agent source setup returned HTTP ${res.status} — continuing with existing config.`);
626
+ apiKey = process.env.WORKDESK_API_KEY || "";
627
+ }
628
+ } catch (e) {
629
+ console.log(` Could not reach ${bffUrl}: ${e.message}`);
630
+ console.log(" Continuing with manual config...");
631
+ apiKey = process.env.WORKDESK_API_KEY || "";
632
+ }
633
+
634
+ // Ask for MCP server path (required for settings.json)
635
+ let mcpServerPath = "";
636
+ if (tool === "claude-code" || tool === "cursor") {
637
+ const envPath = process.env.MOVP_MCP_SERVER_PATH || "";
638
+ const inputPath = await prompt(rl, `\n MCP server path (dist/index.js) [${envPath || "skip"}]: `);
639
+ mcpServerPath = inputPath.trim() || envPath;
640
+ }
641
+
642
+ rl.close();
643
+
644
+ // ── Step 3: Write configuration ───────────────────────────────────────────
645
+ console.log("\n Step 3/3: Configuration");
646
+
647
+ if (tool === "claude-code") {
648
+ writeClaudeCodeConfig(cwd, bffUrl, apiKey, tenantId, mcpServerPath, noRules);
649
+ } else if (tool === "cursor") {
650
+ writeCursorConfig(cwd, bffUrl, apiKey, tenantId, mcpServerPath, noRules);
651
+ } else if (tool === "codex") {
652
+ writeCodexConfig(cwd, bffUrl, tenantId, apiKey);
653
+ }
654
+
655
+ // .movp/config.yaml (all tools)
656
+ writeMovpConfig(cwd);
657
+
658
+ console.log("\n Ready. Type /movp review to begin.\n");
659
+ }
660
+
661
+ // ─────────────────────────────────────────────────────────────────────────────
662
+ // Per-tool config writers
663
+ // ─────────────────────────────────────────────────────────────────────────────
664
+
665
+ function writeClaudeCodeConfig(cwd, bffUrl, apiKey, tenantId, mcpServerPath, noRules = false) {
666
+ const claudeDir = path.join(cwd, ".claude");
667
+ const rulesDir = path.join(claudeDir, "rules");
668
+ const settingsPath = path.join(claudeDir, "settings.json");
669
+ fs.mkdirSync(claudeDir, { recursive: true });
670
+ if (!noRules) fs.mkdirSync(rulesDir, { recursive: true });
671
+
672
+ // settings.json: hook + MCP server
673
+ let settings = {};
674
+ if (fs.existsSync(settingsPath)) {
675
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}
676
+ }
677
+
678
+ // PostToolUse hook
679
+ settings.hooks = settings.hooks || {};
680
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse || [];
681
+ const hookCmd = "npx @movp/cli hook";
682
+ const alreadyHooked = settings.hooks.PostToolUse.some(
683
+ (h) => h.hooks && h.hooks.some((hh) => hh.command === hookCmd || hh.command === "npx @movp/workdesk-hook")
684
+ );
685
+ if (!alreadyHooked) {
686
+ settings.hooks.PostToolUse.push({
687
+ matcher: "",
688
+ hooks: [{ type: "command", command: hookCmd }],
689
+ });
690
+ }
691
+
692
+ // MCP server
693
+ if (mcpServerPath) {
694
+ settings.mcpServers = settings.mcpServers || {};
695
+ settings.mcpServers.movp = {
696
+ type: "stdio",
697
+ command: "node",
698
+ args: [mcpServerPath],
699
+ env: {
700
+ WORKDESK_SERVICE_URL: "http://localhost:8115",
701
+ WORKDESK_TENANT: tenantId,
702
+ ...(apiKey ? { WORKDESK_API_KEY: apiKey } : {}),
703
+ },
704
+ };
705
+ }
706
+
707
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
708
+ console.log(` ${settingsPath} → PostToolUse hook${mcpServerPath ? " + MCP server" : ""}`);
709
+
710
+ // movp-review.md rule — skipped when --no-rules (plugin's skill replaces it)
711
+ if (!noRules) {
712
+ const rulePath = path.join(rulesDir, "movp-review.md");
713
+ fs.writeFileSync(rulePath, MOVP_REVIEW_RULE);
714
+ console.log(` ${rulePath}`);
715
+ } else {
716
+ console.log(" movp-review.md skipped (--no-rules) — plugin skill is active");
717
+ }
718
+
719
+ // .env.movp
720
+ const envPath = path.join(cwd, ".env.movp");
721
+ if (!fs.existsSync(envPath)) {
722
+ const envContent =
723
+ `# MoVP — Claude Code hook environment\n` +
724
+ `WORKDESK_URL=${bffUrl}\n` +
725
+ (apiKey ? `WORKDESK_API_KEY=${apiKey}\n` : `# WORKDESK_API_KEY=wdg_...\n`) +
726
+ `WORKDESK_TENANT=${tenantId}\n` +
727
+ `# WORKDESK_OUTCOME=outcome-id\n`;
728
+ fs.writeFileSync(envPath, envContent);
729
+ console.log(` ${envPath}`);
730
+ }
731
+ }
732
+
733
+ function writeCursorConfig(cwd, bffUrl, apiKey, tenantId, mcpServerPath, noRules = false) {
734
+ const cursorDir = path.join(cwd, ".cursor");
735
+ fs.mkdirSync(cursorDir, { recursive: true });
736
+
737
+ // MCP config
738
+ if (mcpServerPath) {
739
+ const mcpConfigPath = path.join(cursorDir, "mcp.json");
740
+ let mcpConfig = { mcpServers: {} };
741
+ if (fs.existsSync(mcpConfigPath)) {
742
+ try { mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, "utf8")); } catch {}
743
+ }
744
+ mcpConfig.mcpServers = mcpConfig.mcpServers || {};
745
+ mcpConfig.mcpServers.movp = {
746
+ type: "stdio",
747
+ command: "node",
748
+ args: [mcpServerPath],
749
+ env: {
750
+ // WORKDESK_SERVICE_URL points directly to the Workdesk service (port 8115),
751
+ // same as Claude Code. The hook/login use the BFF URL (bffUrl) separately.
752
+ WORKDESK_SERVICE_URL: "http://localhost:8115",
753
+ WORKDESK_TENANT: tenantId,
754
+ ...(apiKey ? { WORKDESK_API_KEY: apiKey } : {}),
755
+ },
756
+ };
757
+ fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + "\n");
758
+ console.log(` ${mcpConfigPath} → MCP server`);
759
+ }
760
+
761
+ // movp-review rule for Cursor — skipped when --no-rules (plugin's skill replaces it)
762
+ if (!noRules) {
763
+ const rulesDir = path.join(cursorDir, "rules");
764
+ fs.mkdirSync(rulesDir, { recursive: true });
765
+ const rulePath = path.join(rulesDir, "movp-review.mdc");
766
+ fs.writeFileSync(rulePath, MOVP_REVIEW_RULE.replace("rule_id:", "description:"));
767
+ console.log(` ${rulePath}`);
768
+ } else {
769
+ console.log(" movp-review.mdc skipped (--no-rules) — plugin skill is active");
770
+ }
771
+ }
772
+
773
+ function writeCodexConfig(cwd, bffUrl, tenantId, apiKey) {
774
+ // Write codex.yaml with MCP server configuration
775
+ const codexConfigPath = path.join(cwd, "codex.yaml");
776
+ const mcpServerPath = process.env.MOVP_MCP_SERVER_PATH || "";
777
+
778
+ const apiKeyLine = apiKey ? ` WORKDESK_API_KEY: "${apiKey}"` : "";
779
+ const codexConfig = [
780
+ "# MoVP control plane — Codex configuration",
781
+ "# Generated by @movp/cli init --codex",
782
+ "#",
783
+ "# Add your model and approval settings above the mcp_servers block.",
784
+ "# Example: model: o4-mini",
785
+ "# approval_policy: on-failure",
786
+ "",
787
+ "mcp_servers:",
788
+ " movp:",
789
+ ` command: node`,
790
+ ` args:`,
791
+ ` - ${mcpServerPath || "/path/to/movp-mcp/dist/index.js"}`,
792
+ ` env:`,
793
+ ` WORKDESK_SERVICE_URL: "http://localhost:8115"`,
794
+ ` WORKDESK_TENANT: "${tenantId}"`,
795
+ ...(apiKeyLine ? [apiKeyLine] : []),
796
+ "",
797
+ ].join("\n");
798
+
799
+ if (!fs.existsSync(codexConfigPath)) {
800
+ fs.writeFileSync(codexConfigPath, codexConfig);
801
+ console.log(` ${codexConfigPath} → Codex MCP config`);
802
+ } else {
803
+ console.log(` ${codexConfigPath} → already exists, not overwritten`);
804
+ }
805
+ if (!mcpServerPath) {
806
+ console.log(" [!] Set MOVP_MCP_SERVER_PATH env var or edit args in codex.yaml before use.");
807
+ }
808
+ }
809
+
810
+ function writeMovpConfig(cwd) {
811
+ const movpDir = path.join(cwd, ".movp");
812
+ fs.mkdirSync(movpDir, { recursive: true });
813
+
814
+ const configPath = path.join(movpDir, "config.yaml");
815
+ if (!fs.existsSync(configPath)) {
816
+ fs.writeFileSync(configPath, DEFAULT_PROJECT_CONFIG);
817
+ console.log(` ${configPath} → project config (commit this)`);
818
+ } else {
819
+ console.log(` ${configPath} → already exists, not overwritten`);
820
+ }
821
+
822
+ const localConfigPath = path.join(movpDir, "config.local.yaml");
823
+ if (!fs.existsSync(localConfigPath)) {
824
+ fs.writeFileSync(localConfigPath, DEFAULT_LOCAL_CONFIG);
825
+ console.log(` ${localConfigPath} → local overrides (gitignore this)`);
826
+ }
827
+
828
+ // Ensure .movp/config.local.yaml is gitignored
829
+ const gitignorePath = path.join(cwd, ".gitignore");
830
+ const gitignoreEntry = ".movp/config.local.yaml";
831
+ try {
832
+ let gitignore = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
833
+ if (!gitignore.includes(gitignoreEntry)) {
834
+ fs.appendFileSync(gitignorePath, `\n# MoVP local config\n${gitignoreEntry}\n.env.movp\n`);
835
+ }
836
+ } catch { /* gitignore update is best-effort */ }
837
+ }
838
+
839
+ // ─────────────────────────────────────────────────────────────────────────────
840
+ // login (RFC 8628 device auth)
841
+ // ─────────────────────────────────────────────────────────────────────────────
842
+
843
+ async function runLogin() {
844
+ const bffUrl =
845
+ process.env.WORKDESK_URL ||
846
+ process.env.MOVP_BFF_URL ||
847
+ "https://host.mostviableproduct.com";
848
+
849
+ console.log(`\n MoVP CLI — Device Login\n`);
850
+ console.log(` BFF: ${bffUrl}\n`);
851
+
852
+ let authorizeRes;
853
+ try {
854
+ authorizeRes = await postJSON(bffUrl, "/api/auth/device/authorize", {});
855
+ } catch (err) {
856
+ console.error(` Failed to connect to ${bffUrl}: ${err.message}`);
857
+ process.exit(1);
858
+ }
859
+
860
+ if (authorizeRes.status !== 200) {
861
+ console.error(` Device authorize failed (HTTP ${authorizeRes.status})`);
862
+ process.exit(1);
863
+ }
864
+
865
+ const { device_code, user_code, verification_uri_complete, expires_in, interval } =
866
+ authorizeRes.body;
867
+
868
+ console.log(` Your verification code: ${user_code}`);
869
+ console.log(`\n Open this URL to approve:\n`);
870
+ console.log(` ${verification_uri_complete}\n`);
871
+ console.log(` (Code expires in ${Math.round(expires_in / 60)} minutes)\n`);
872
+
873
+ try {
874
+ const { execSync } = require("child_process");
875
+ const openCmd =
876
+ process.platform === "darwin" ? `open "${verification_uri_complete}"`
877
+ : process.platform === "win32" ? `start "" "${verification_uri_complete}"`
878
+ : `xdg-open "${verification_uri_complete}"`;
879
+ execSync(openCmd, { stdio: "ignore" });
880
+ } catch { /* non-fatal */ }
881
+
882
+ const pollInterval = Math.max((interval || 5) * 1000, 5000);
883
+ const deadline = Date.now() + expires_in * 1000;
884
+
885
+ process.stdout.write(" Waiting for approval");
886
+ while (Date.now() < deadline) {
887
+ await sleep(pollInterval);
888
+ process.stdout.write(".");
889
+
890
+ let tokenRes;
891
+ try {
892
+ tokenRes = await postJSON(bffUrl, "/api/auth/device/token", { device_code });
893
+ } catch {
894
+ continue;
895
+ }
896
+
897
+ if (tokenRes.status === 404) {
898
+ console.log("\n Device code expired.");
899
+ process.exit(1);
900
+ }
901
+
902
+ if (tokenRes.status === 200) {
903
+ const { status, user_id, tenant_id } = tokenRes.body;
904
+ if (status === "authorized") {
905
+ console.log("\n\n Login successful!\n");
906
+ const credPath = writeCredentials(bffUrl, user_id, tenant_id);
907
+ console.log(` Credentials: ${credPath}`);
908
+ console.log(` User ID: ${user_id}`);
909
+ console.log(` Tenant ID: ${tenant_id}\n`);
910
+ return;
911
+ }
912
+ if (status === "denied") {
913
+ console.log("\n Login denied by user.");
914
+ process.exit(1);
915
+ }
916
+ }
917
+ }
918
+
919
+ console.log("\n Timed out waiting for approval.");
920
+ process.exit(1);
921
+ }
package/hook.js ADDED
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // @movp/cli — PostToolUse hook for Claude Code
3
+ // Reads the tool event from stdin and sends telemetry through the BFF to the
4
+ // Workdesk Gateway. Configured as a PostToolUse hook in .claude/settings.json:
5
+ //
6
+ // "hooks": { "PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "npx @movp/cli hook" }] }] }
7
+ //
8
+ // Required env vars:
9
+ // WORKDESK_URL - BFF origin (default: http://localhost:8080)
10
+ // WORKDESK_API_KEY - wdg_* key from agent source creation (required)
11
+ // WORKDESK_TENANT - Tenant UUID (required)
12
+ // Optional:
13
+ // WORKDESK_USER - User UUID (defaults to device-auth credentials, then process.env.USER)
14
+ // WORKDESK_OUTCOME - Outcome ID to attribute work to
15
+ // WORKDESK_REASONING_CHAIN - Reasoning chain ID
16
+ //
17
+ // Credentials written by 'npx @movp/cli login' are read from
18
+ // ~/.config/movp/credentials and used as fallback for WORKDESK_USER and WORKDESK_TENANT.
19
+
20
+ "use strict";
21
+
22
+ const fs = require("fs");
23
+ const os = require("os");
24
+ const path = require("path");
25
+
26
+ // Read device-auth credentials from ~/.config/movp/credentials (written by `login` subcommand).
27
+ // Environment variables take precedence over stored credentials.
28
+ function loadCredentials() {
29
+ try {
30
+ const credPath = path.join(os.homedir(), ".config", "movp", "credentials");
31
+ const lines = fs.readFileSync(credPath, "utf8").split("\n");
32
+ const creds = {};
33
+ for (const line of lines) {
34
+ const trimmed = line.trim();
35
+ if (!trimmed || trimmed.startsWith("#")) continue;
36
+ const eq = trimmed.indexOf("=");
37
+ if (eq < 0) continue;
38
+ creds[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
39
+ }
40
+ return creds;
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ const storedCreds = loadCredentials();
47
+
48
+ const url = process.env.WORKDESK_URL || storedCreds.WORKDESK_URL || "http://localhost:8080";
49
+ const apiKey = process.env.WORKDESK_API_KEY || "";
50
+ const tenant = process.env.WORKDESK_TENANT || storedCreds.WORKDESK_TENANT || "";
51
+
52
+ // Exit silently if not configured — never block the agent
53
+ if (!apiKey || !tenant) process.exit(0);
54
+
55
+ let raw = "";
56
+ process.stdin.setEncoding("utf8");
57
+ process.stdin.on("data", (chunk) => { raw += chunk; });
58
+ process.stdin.on("end", () => {
59
+ let event = {};
60
+ try { event = JSON.parse(raw); } catch { process.exit(0); }
61
+
62
+ const reasoningChainId =
63
+ event.reasoning_chain_id ||
64
+ process.env.WORKDESK_REASONING_CHAIN ||
65
+ "";
66
+
67
+ const payload = {
68
+ hook_type: "PostToolUse",
69
+ tool_name: event.tool_name || "",
70
+ tool_input: event.tool_input || {},
71
+ tool_result: event.tool_result || {},
72
+ session_id: event.session_id || `ses_${Date.now()}`,
73
+ tenant_id: tenant,
74
+ user_id: process.env.WORKDESK_USER || storedCreds.WORKDESK_USER || process.env.USER || "unknown",
75
+ outcome_id: process.env.WORKDESK_OUTCOME || "",
76
+ timestamp: new Date().toISOString(),
77
+ thinking_tokens: event.thinking_tokens || 0,
78
+ cache_read_tokens: event.cache_read_tokens || 0,
79
+ cache_creation_tokens: event.cache_creation_tokens || 0,
80
+ parent_activity_id: event.parent_activity_id || "",
81
+ reasoning_chain_id: reasoningChainId,
82
+ progress_pct: event.progress_pct || 0,
83
+ };
84
+
85
+ const body = JSON.stringify(payload);
86
+
87
+ // Fire and forget — use Node's built-in https/http, no dependencies
88
+ const { request } = url.startsWith("https") ? require("https") : require("http");
89
+ const parsed = new URL(`${url}/api/workdesk/ingest/claude-code`);
90
+
91
+ const req = request(
92
+ {
93
+ hostname: parsed.hostname,
94
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
95
+ path: parsed.pathname,
96
+ method: "POST",
97
+ headers: {
98
+ "Content-Type": "application/json",
99
+ "X-API-Key": apiKey,
100
+ "Content-Length": Buffer.byteLength(body),
101
+ },
102
+ timeout: 5000,
103
+ },
104
+ () => { process.exit(0); }
105
+ );
106
+
107
+ req.on("error", () => { process.exit(0); });
108
+ req.on("timeout", () => { req.destroy(); process.exit(0); });
109
+ req.write(body);
110
+ req.end();
111
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@movp/cli",
3
+ "version": "1.0.0",
4
+ "description": "MoVP CLI — configure AI coding tools with the MoVP control plane (PostToolUse hook + MCP setup)",
5
+ "main": "hook.js",
6
+ "bin": {
7
+ "movp": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "hook.js"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test"
15
+ },
16
+ "keywords": [
17
+ "movp",
18
+ "claude-code",
19
+ "cursor",
20
+ "mcp",
21
+ "telemetry",
22
+ "adversarial-review"
23
+ ],
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/MostViableProduct/big-wave-backend.git",
28
+ "directory": "packages/cli"
29
+ },
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ }
33
+ }