monty-cli 0.1.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/monty.js +418 -0
- package/package.json +28 -0
package/monty.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { spawnSync } = require("node:child_process");
|
|
8
|
+
|
|
9
|
+
const home = os.homedir();
|
|
10
|
+
const montyDir = path.join(home, ".monty");
|
|
11
|
+
const configPath = path.join(montyDir, "config.json");
|
|
12
|
+
const installedCliPath = path.join(montyDir, "monty.js");
|
|
13
|
+
const installedCliDisplayPath = path.join("~", ".monty", "monty.js");
|
|
14
|
+
const marker = "Monty prompt feed";
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
const [command = "help", ...args] = process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
if (command === "install") return install(args);
|
|
21
|
+
if (command === "capture") return captureCommand(args);
|
|
22
|
+
if (command === "hook") return hookCommandHandler(args);
|
|
23
|
+
if (command === "run") return runWrapped(args);
|
|
24
|
+
if (command === "doctor") return doctor();
|
|
25
|
+
if (command === "help" || command === "--help" || command === "-h") return help();
|
|
26
|
+
|
|
27
|
+
console.error(`Unknown command: ${command}`);
|
|
28
|
+
help();
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function install(args) {
|
|
37
|
+
const options = parseArgs(args);
|
|
38
|
+
const githubProfile = detectGitHubProfile(options);
|
|
39
|
+
const config = {
|
|
40
|
+
siteUrl: cleanUrl(options.site || options.url || process.env.MONTY_SITE_URL || "https://www.trymonty.ai"),
|
|
41
|
+
teamId: String(options.team || process.env.MONTY_TEAM_ID || "default"),
|
|
42
|
+
userName: String(options.user || process.env.MONTY_USER || process.env.USER || os.userInfo().username || "unknown"),
|
|
43
|
+
githubLogin: githubProfile.login,
|
|
44
|
+
avatarUrl: githubProfile.avatarUrl,
|
|
45
|
+
ingestToken: options.token || process.env.MONTY_INGEST_TOKEN || "",
|
|
46
|
+
machineId: os.hostname(),
|
|
47
|
+
installedAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
fs.mkdirSync(montyDir, { recursive: true });
|
|
51
|
+
fs.copyFileSync(__filename, installedCliPath);
|
|
52
|
+
fs.chmodSync(installedCliPath, 0o755);
|
|
53
|
+
writeJson(configPath, config);
|
|
54
|
+
|
|
55
|
+
const claudePath = path.join(home, ".claude", "settings.json");
|
|
56
|
+
const codexPath = path.join(home, ".codex", "hooks.json");
|
|
57
|
+
const codexConfigPath = path.join(home, ".codex", "config.toml");
|
|
58
|
+
upsertHookJson(claudePath, "claude");
|
|
59
|
+
upsertHookJson(codexPath, "codex");
|
|
60
|
+
upsertCodexOtelConfig(codexConfigPath, config);
|
|
61
|
+
|
|
62
|
+
console.log(`Monty installed for ${config.teamId}`);
|
|
63
|
+
console.log(`Site: ${config.siteUrl}`);
|
|
64
|
+
if (config.githubLogin) console.log(`GitHub avatar: ${config.githubLogin}`);
|
|
65
|
+
console.log(`Claude hook: ${claudePath}`);
|
|
66
|
+
console.log(`Codex hook: ${codexPath}`);
|
|
67
|
+
console.log(`Codex telemetry: ${codexConfigPath}`);
|
|
68
|
+
console.log("Open Claude Code or restart Codex CLI and submit a prompt. It will appear in the Monty feed.");
|
|
69
|
+
console.log("Note: Codex reads telemetry config at process start, so already-running Codex sessions must be restarted.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function captureCommand(args) {
|
|
73
|
+
const options = parseArgs(args);
|
|
74
|
+
const input = {
|
|
75
|
+
hook_event_name: "ManualCapture",
|
|
76
|
+
prompt: options.prompt || args.filter((arg) => !arg.startsWith("--")).join(" "),
|
|
77
|
+
cwd: process.cwd(),
|
|
78
|
+
};
|
|
79
|
+
await sendPrompt(options.source || "manual", input);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function hookCommandHandler(args) {
|
|
83
|
+
const options = parseArgs(args);
|
|
84
|
+
const source = options.source || "manual";
|
|
85
|
+
const input = await readStdinJson();
|
|
86
|
+
await sendPrompt(source, input);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function sendPrompt(source, input) {
|
|
90
|
+
const config = readConfig();
|
|
91
|
+
const prompt = extractPrompt(input);
|
|
92
|
+
|
|
93
|
+
if (!prompt) {
|
|
94
|
+
silentLog("No prompt found in hook payload.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const event = {
|
|
99
|
+
team_id: config.teamId || process.env.MONTY_TEAM_ID || "default",
|
|
100
|
+
source: normalizeSource(source),
|
|
101
|
+
prompt,
|
|
102
|
+
user_name: config.userName || process.env.MONTY_USER || process.env.USER || "unknown",
|
|
103
|
+
avatar_url: config.avatarUrl || process.env.MONTY_AVATAR_URL || null,
|
|
104
|
+
machine_id: config.machineId || os.hostname(),
|
|
105
|
+
cwd: input.cwd || process.cwd(),
|
|
106
|
+
model: input.model || input.model_id || null,
|
|
107
|
+
token_count: numberOrNull(input.token_count || input.tokens || input.total_tokens),
|
|
108
|
+
session_id: input.session_id || input.conversation_id || null,
|
|
109
|
+
metadata: {
|
|
110
|
+
hook_event_name: input.hook_event_name || input.hookEventName || null,
|
|
111
|
+
transcript_path: input.transcript_path || null,
|
|
112
|
+
cli: source,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const siteUrl = cleanUrl(config.siteUrl || process.env.MONTY_SITE_URL || "https://www.trymonty.ai");
|
|
117
|
+
const headers = { "content-type": "application/json" };
|
|
118
|
+
const token = config.ingestToken || process.env.MONTY_INGEST_TOKEN;
|
|
119
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
120
|
+
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timeout = setTimeout(() => controller.abort(), 2500);
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetch(`${siteUrl}/api/events`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers,
|
|
127
|
+
body: JSON.stringify(event),
|
|
128
|
+
signal: controller.signal,
|
|
129
|
+
});
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
silentLog(`Monty ingest failed: HTTP ${response.status}`);
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
silentLog(`Monty ingest failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
135
|
+
} finally {
|
|
136
|
+
clearTimeout(timeout);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function runWrapped(args) {
|
|
141
|
+
const [tool, ...toolArgs] = args;
|
|
142
|
+
if (!tool || !["claude", "codex"].includes(tool)) {
|
|
143
|
+
console.error("Usage: monty run <claude|codex> [...args]");
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const prompt = inferPromptFromArgs(tool, toolArgs);
|
|
149
|
+
if (prompt) {
|
|
150
|
+
sendPrompt(tool, { prompt, cwd: process.cwd(), hook_event_name: "WrappedRun" }).catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = spawnSync(tool, toolArgs, { stdio: "inherit", env: process.env });
|
|
154
|
+
process.exit(result.status ?? 1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function doctor() {
|
|
158
|
+
const config = readConfig();
|
|
159
|
+
const checks = [
|
|
160
|
+
["Config", fs.existsSync(configPath) ? configPath : "missing"],
|
|
161
|
+
["Site", config.siteUrl || "not set"],
|
|
162
|
+
["Claude settings", fs.existsSync(path.join(home, ".claude", "settings.json")) ? "present" : "missing"],
|
|
163
|
+
["Codex hooks", fs.existsSync(path.join(home, ".codex", "hooks.json")) ? "present" : "missing"],
|
|
164
|
+
["Installed CLI", fs.existsSync(installedCliPath) ? installedCliPath : "missing"],
|
|
165
|
+
["Codex restart needed", "yes, for Codex sessions started before Monty install"],
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
for (const [label, value] of checks) {
|
|
169
|
+
console.log(`${label}: ${value}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function upsertHookJson(filePath, source) {
|
|
174
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
175
|
+
const data = readJson(filePath, {});
|
|
176
|
+
data.hooks ||= {};
|
|
177
|
+
data.hooks.UserPromptSubmit ||= [];
|
|
178
|
+
|
|
179
|
+
const command = `"${process.execPath}" "${installedCliPath}" hook --source ${source}`;
|
|
180
|
+
const group = {
|
|
181
|
+
hooks: [
|
|
182
|
+
{
|
|
183
|
+
type: "command",
|
|
184
|
+
command,
|
|
185
|
+
timeout: 10,
|
|
186
|
+
statusMessage: marker,
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
data.hooks.UserPromptSubmit = data.hooks.UserPromptSubmit.filter((entry) => {
|
|
192
|
+
const serialized = JSON.stringify(entry);
|
|
193
|
+
return !serialized.includes(installedCliPath) && !serialized.includes(installedCliDisplayPath) && !serialized.includes(marker);
|
|
194
|
+
});
|
|
195
|
+
data.hooks.UserPromptSubmit.push(group);
|
|
196
|
+
writeJson(filePath, data);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function upsertCodexOtelConfig(filePath, config) {
|
|
200
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
201
|
+
const begin = "# BEGIN MONTY OTEL";
|
|
202
|
+
const end = "# END MONTY OTEL";
|
|
203
|
+
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
204
|
+
const withoutMonty = existing.replace(new RegExp(`\\n?${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "g"), "\n");
|
|
205
|
+
const hasOtel = /^\s*\[otel(?:\]|\.)/m.test(withoutMonty);
|
|
206
|
+
if (hasOtel) {
|
|
207
|
+
silentLog("Skipped Codex OTEL config because an existing [otel] section is present.");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const endpoint = otelEndpoint(config);
|
|
212
|
+
const block = `${begin}
|
|
213
|
+
[otel]
|
|
214
|
+
environment = "dev"
|
|
215
|
+
log_user_prompt = true
|
|
216
|
+
exporter = { otlp-http = { endpoint = "${endpoint}", protocol = "json" } }
|
|
217
|
+
${end}
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
fs.writeFileSync(filePath, `${withoutMonty.trimEnd()}\n\n${block}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function otelEndpoint(config) {
|
|
224
|
+
const params = new URLSearchParams();
|
|
225
|
+
params.set("team", config.teamId || "default");
|
|
226
|
+
params.set("user", config.userName || "unknown");
|
|
227
|
+
if (config.avatarUrl) params.set("avatar", config.avatarUrl);
|
|
228
|
+
if (config.githubLogin) params.set("github", config.githubLogin);
|
|
229
|
+
return `${cleanUrl(config.siteUrl)}/api/otel/v1/logs?${params.toString()}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function detectGitHubProfile(options = {}) {
|
|
233
|
+
const explicitLogin = options.github || options["github-login"] || process.env.MONTY_GITHUB_LOGIN || "";
|
|
234
|
+
const explicitAvatar = options.avatar || options["avatar-url"] || process.env.MONTY_AVATAR_URL || "";
|
|
235
|
+
if (explicitAvatar) {
|
|
236
|
+
return {
|
|
237
|
+
login: String(explicitLogin || githubLoginFromAvatar(explicitAvatar) || ""),
|
|
238
|
+
avatarUrl: String(explicitAvatar),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ghProfile = detectGitHubProfileFromGh();
|
|
243
|
+
const login =
|
|
244
|
+
explicitLogin ||
|
|
245
|
+
ghProfile.login ||
|
|
246
|
+
runGitConfig("github.user") ||
|
|
247
|
+
parseGitHubLoginFromEmail(runGitConfig("user.email")) ||
|
|
248
|
+
parseGitHubLoginFromRemote(runGitRemote());
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
login,
|
|
252
|
+
avatarUrl: ghProfile.avatarUrl || (login ? `https://github.com/${login}.png` : ""),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function detectGitHubProfileFromGh() {
|
|
257
|
+
const result = spawnSync("gh", ["api", "user", "--jq", ".login + \"\\t\" + .avatar_url"], {
|
|
258
|
+
encoding: "utf8",
|
|
259
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
260
|
+
});
|
|
261
|
+
if (result.status !== 0 || !result.stdout.trim()) return { login: "", avatarUrl: "" };
|
|
262
|
+
const [login = "", avatarUrl = ""] = result.stdout.trim().split("\t");
|
|
263
|
+
return { login, avatarUrl };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function runGitConfig(key) {
|
|
267
|
+
const result = spawnSync("git", ["config", "--get", key], {
|
|
268
|
+
encoding: "utf8",
|
|
269
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
270
|
+
});
|
|
271
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function runGitRemote() {
|
|
275
|
+
const result = spawnSync("git", ["remote", "get-url", "origin"], {
|
|
276
|
+
encoding: "utf8",
|
|
277
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
278
|
+
});
|
|
279
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function parseGitHubLoginFromEmail(email) {
|
|
283
|
+
const match = String(email).match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/i);
|
|
284
|
+
return match ? match[1] : "";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseGitHubLoginFromRemote(remote) {
|
|
288
|
+
const match = String(remote).match(/github\.com[:/]([^/]+)\//i);
|
|
289
|
+
return match ? match[1] : "";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function githubLoginFromAvatar(avatarUrl) {
|
|
293
|
+
const match = String(avatarUrl).match(/^https:\/\/github\.com\/([^/.]+)\.png/i);
|
|
294
|
+
return match ? match[1] : "";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function inferPromptFromArgs(tool, args) {
|
|
298
|
+
if (tool === "codex") {
|
|
299
|
+
const execIndex = args.findIndex((arg) => arg === "exec" || arg === "e");
|
|
300
|
+
const candidates = execIndex >= 0 ? args.slice(execIndex + 1) : args;
|
|
301
|
+
return lastNonFlag(candidates);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const printIndex = args.findIndex((arg) => arg === "-p" || arg === "--print");
|
|
305
|
+
if (printIndex >= 0) return lastNonFlag(args.slice(printIndex + 1));
|
|
306
|
+
return lastNonFlag(args);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function extractPrompt(input) {
|
|
310
|
+
if (typeof input.prompt === "string") return input.prompt;
|
|
311
|
+
if (typeof input.user_prompt === "string") return input.user_prompt;
|
|
312
|
+
if (typeof input.message === "string") return input.message;
|
|
313
|
+
if (input.message && typeof input.message.content === "string") return input.message.content;
|
|
314
|
+
if (Array.isArray(input.messages)) {
|
|
315
|
+
const lastUser = [...input.messages].reverse().find((message) => message && message.role === "user");
|
|
316
|
+
if (typeof lastUser?.content === "string") return lastUser.content;
|
|
317
|
+
}
|
|
318
|
+
return "";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function parseArgs(args) {
|
|
322
|
+
const options = {};
|
|
323
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
324
|
+
const arg = args[i];
|
|
325
|
+
if (!arg.startsWith("--")) continue;
|
|
326
|
+
const key = arg.slice(2);
|
|
327
|
+
const next = args[i + 1];
|
|
328
|
+
if (!next || next.startsWith("--")) {
|
|
329
|
+
options[key] = true;
|
|
330
|
+
} else {
|
|
331
|
+
options[key] = next;
|
|
332
|
+
i += 1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return options;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function readStdinJson() {
|
|
339
|
+
const chunks = [];
|
|
340
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
341
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
342
|
+
if (!raw) return {};
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(raw);
|
|
345
|
+
} catch {
|
|
346
|
+
return { prompt: raw };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function readConfig() {
|
|
351
|
+
return readJson(configPath, {});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function readJson(filePath, fallback) {
|
|
355
|
+
try {
|
|
356
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
357
|
+
} catch {
|
|
358
|
+
return fallback;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function writeJson(filePath, value) {
|
|
363
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
364
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function cleanUrl(value) {
|
|
368
|
+
return String(value).replace(/\/+$/, "");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function escapeRegExp(value) {
|
|
372
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function normalizeSource(value) {
|
|
376
|
+
return value === "claude" || value === "codex" || value === "test" ? value : "manual";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function numberOrNull(value) {
|
|
380
|
+
const number = Number(value);
|
|
381
|
+
return Number.isFinite(number) ? number : null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function lastNonFlag(args) {
|
|
385
|
+
return [...args].reverse().find((arg) => typeof arg === "string" && arg && !arg.startsWith("-")) || "";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function silentLog(message) {
|
|
389
|
+
try {
|
|
390
|
+
fs.mkdirSync(montyDir, { recursive: true });
|
|
391
|
+
fs.appendFileSync(path.join(montyDir, "monty.log"), `[${new Date().toISOString()}] ${message}\n`);
|
|
392
|
+
} catch {
|
|
393
|
+
// Hooks must never interrupt Claude Code or Codex.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function help() {
|
|
398
|
+
console.log(`Monty prompt feed
|
|
399
|
+
|
|
400
|
+
Usage:
|
|
401
|
+
monty install --site http://localhost:3000 --team default
|
|
402
|
+
monty capture --source test --prompt "hello"
|
|
403
|
+
monty run codex exec "prompt"
|
|
404
|
+
monty run claude -p "prompt"
|
|
405
|
+
monty doctor
|
|
406
|
+
`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (require.main === module) {
|
|
410
|
+
main();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = {
|
|
414
|
+
extractPrompt,
|
|
415
|
+
inferPromptFromArgs,
|
|
416
|
+
parseGitHubLoginFromEmail,
|
|
417
|
+
parseGitHubLoginFromRemote,
|
|
418
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "monty-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Live AI prompt feed and token leaderboard for your engineering team. Works with Claude Code and Codex CLI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"monty": "monty.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"claude-code",
|
|
11
|
+
"codex",
|
|
12
|
+
"ai",
|
|
13
|
+
"prompt",
|
|
14
|
+
"leaderboard",
|
|
15
|
+
"developer-tools",
|
|
16
|
+
"cli"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"monty.js"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/ethangoodhart/monty.git"
|
|
27
|
+
}
|
|
28
|
+
}
|