@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.
- package/bin/cli.js +921 -0
- package/hook.js +111 -0
- 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
|
+
}
|