@martenhq/cli 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +92 -2
- package/dist/marten.js +2156 -0
- package/package.json +31 -4
package/dist/marten.js
ADDED
|
@@ -0,0 +1,2156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/marten.ts
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { homedir, hostname, userInfo } from "node:os";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
|
|
8
|
+
// src/api/api.ts
|
|
9
|
+
class ApiError extends Error {
|
|
10
|
+
}
|
|
11
|
+
function dispositionName(header) {
|
|
12
|
+
if (!header)
|
|
13
|
+
return null;
|
|
14
|
+
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1];
|
|
15
|
+
if (encoded) {
|
|
16
|
+
try {
|
|
17
|
+
return decodeURIComponent(encoded);
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
return /filename="([^"]*)"/i.exec(header)?.[1] ?? null;
|
|
21
|
+
}
|
|
22
|
+
function createApi(config) {
|
|
23
|
+
const doFetch = config.fetch ?? fetch;
|
|
24
|
+
const base = config.apiUrl.replace(/\/+$/, "");
|
|
25
|
+
async function send(method, path, body) {
|
|
26
|
+
const raw = body instanceof Uint8Array;
|
|
27
|
+
let res;
|
|
28
|
+
try {
|
|
29
|
+
res = await doFetch(`${base}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { "x-marten-chat": config.chat, ...body === undefined ? {} : { "content-type": raw ? "application/octet-stream" : "application/json" } },
|
|
32
|
+
body: body === undefined ? undefined : raw ? body : JSON.stringify(body)
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new ApiError(`Can't reach Marten at ${base}: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
if (res.ok)
|
|
38
|
+
return res;
|
|
39
|
+
const data = await res.json().catch(() => null);
|
|
40
|
+
if (res.status === 401)
|
|
41
|
+
throw new ApiError(`Marten didn't recognise this box${data?.message ? ` (${data.message})` : ""}. Run marten inside a Marten's box.`);
|
|
42
|
+
throw new ApiError(data?.message ?? `Marten answered ${res.status}`);
|
|
43
|
+
}
|
|
44
|
+
const call = async (method, path, body) => {
|
|
45
|
+
const res = await send(method, path, body);
|
|
46
|
+
return await res.json().catch(() => null);
|
|
47
|
+
};
|
|
48
|
+
return Object.assign(call, {
|
|
49
|
+
download: async (path) => {
|
|
50
|
+
const res = await send("GET", path);
|
|
51
|
+
return { bytes: new Uint8Array(await res.arrayBuffer()), name: dispositionName(res.headers.get("content-disposition")) };
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/args/args.ts
|
|
57
|
+
import { parseArgs } from "node:util";
|
|
58
|
+
|
|
59
|
+
class UsageError extends Error {
|
|
60
|
+
}
|
|
61
|
+
var HELP = `marten — talk to Marten from inside your box
|
|
62
|
+
|
|
63
|
+
Usage:
|
|
64
|
+
marten chat read [--limit N] Print recent messages (default 20, max 200)
|
|
65
|
+
marten chat send <message|-> --group <id>
|
|
66
|
+
Post in one of your owner's groups (see your 1:1's roster) when they ask
|
|
67
|
+
you to in your 1:1; --reply-to quotes one of its messages. Your reply to
|
|
68
|
+
them stays \`marten reply\`, here in the 1:1
|
|
69
|
+
marten reply <message|-> Post your reply in the chat, as your reply tool does (- reads it from
|
|
70
|
+
stdin). Once, at the end; not replying is staying quiet. Your final
|
|
71
|
+
text isn't shown. Only for a new message's turn (or an answer's)
|
|
72
|
+
--reply-to <message id> quote one of the chat's messages (its id, or its first 8 characters)
|
|
73
|
+
marten task add <title> [options] Add a task to the chat (its card is posted here)
|
|
74
|
+
--description <text>
|
|
75
|
+
--assignee <name|id|me> a person, or a Marten here (me: you); a Marten starts on it
|
|
76
|
+
at once, at --due's time, or on --due's date (09:00 UTC)
|
|
77
|
+
--due <when> friday, "tomorrow 9am", "next monday at 10", "in 2 hours",
|
|
78
|
+
2026-10-31, "2026-10-31 09:00" (all in --tz) or an ISO time
|
|
79
|
+
--remind the time (not a date alone) is a reminder: a person is reminded in the chat,
|
|
80
|
+
a Marten assignee is woken then to remind them
|
|
81
|
+
--repeat <rule> "every weekday at 9", "every 2 weeks on friday", daily, or an RRULE (at most hourly)
|
|
82
|
+
--tz <zone> IANA time zone for --due and --repeat (default UTC)
|
|
83
|
+
marten task list [options] List the chat's tasks
|
|
84
|
+
--assignee <who> me (you, this Marten), a person or Marten here by name or id,
|
|
85
|
+
or none (unassigned)
|
|
86
|
+
--for-owner assigned to your owner (a person's Marten only)
|
|
87
|
+
--status <s> todo, in_progress, needs_review, done or open (not done)
|
|
88
|
+
--due <d> today, overdue, upcoming or none
|
|
89
|
+
marten task show <id> A task, with its activity
|
|
90
|
+
marten task update <id> [options] Change a task: --title, --description, --status,
|
|
91
|
+
--assignee (none to clear), --due (none to clear),
|
|
92
|
+
--remind, --repeat (none to stop), --tz
|
|
93
|
+
marten task start|done|reopen <id> Start, finish or reopen a task
|
|
94
|
+
marten task comment <id> <text> Log progress or a note in the task's activity
|
|
95
|
+
marten task delete <id> [--series] Delete a task (--series: stop its repeat too)
|
|
96
|
+
marten members List the chat's people and Martens: name, kind, id, and what each Marten is for
|
|
97
|
+
marten tell-owner <text|-> In a visitor's conversation on your public page: message your owner (it lands in your 1:1)
|
|
98
|
+
marten end-conversation [--reason <why>] In a visitor's conversation: end it (off-topic, spam, abusive); they can't write for a day
|
|
99
|
+
marten ask <id|name> <question> Ask another Marten in this chat; prints its answer.
|
|
100
|
+
Names repeat: a name works only if one Marten here has it.
|
|
101
|
+
A person's Marten can also ask its owner's other Martens and, in
|
|
102
|
+
its owner's 1:1, a shared group's own Marten (see the roster)
|
|
103
|
+
marten sketch publish <path> [options] Save a sketch people come back to: a .jsx/.tsx file (its default
|
|
104
|
+
export is the sketch), an .html file, or a folder with index.html.
|
|
105
|
+
Every publish is a new version; prints the sketch's id. (To draw
|
|
106
|
+
something for the moment, use your sketch tool instead)
|
|
107
|
+
--app <id> add a version to this sketch (without it, a new one is created)
|
|
108
|
+
--title <t> its name (default: from the file name) --label <l> what changed
|
|
109
|
+
marten sketch preview <path|id> [options]
|
|
110
|
+
Render a sketch in a headless browser without posting it: a file or
|
|
111
|
+
folder (as publish takes it), or a sketch's id (its current version).
|
|
112
|
+
Writes a screenshot and prints its path, the console, page errors,
|
|
113
|
+
failed requests, or the build's errors. Read the screenshot to see it.
|
|
114
|
+
--width <px> --height <px> the window (default 1280 × 800; 390 × 844 is a phone)
|
|
115
|
+
--full-page the whole page, down to 4000 px --theme dark Marten's dark theme
|
|
116
|
+
--wait <ms> let it settle after loading and after each step (default 500)
|
|
117
|
+
--click <selector|text> click an element, by CSS selector or its text (repeatable)
|
|
118
|
+
--type "<selector|text>=<text>" type into a field (repeatable; steps run in order)
|
|
119
|
+
--out <file.png> where to write it (default /tmp/marten-preview/<name>-<n>.png)
|
|
120
|
+
marten sketch save <id> Save a sketch for the chat: it joins everyone's list and keeps data
|
|
121
|
+
marten sketch unsave <id> Take a saved sketch off the chat's list; its message still shows it
|
|
122
|
+
marten sketch delete <id> Delete a sketch from the chat, its data too (only when someone asks)
|
|
123
|
+
marten sketch list List the chat's sketches (saved or not), who drew each and when
|
|
124
|
+
marten sketch get <id> Show a sketch
|
|
125
|
+
marten sketch versions <id> List a sketch's versions, newest first
|
|
126
|
+
marten sketch pull <id> [path] [--version N]
|
|
127
|
+
Write a sketch's source (default: its current version) to path
|
|
128
|
+
marten sketch storage list <id> [prefix]
|
|
129
|
+
List a saved sketch's shared storage (what everyone in the chat sees)
|
|
130
|
+
marten sketch storage get <id> <key> Print one shared value
|
|
131
|
+
marten sketch storage set <id> <key> <json|->
|
|
132
|
+
Set one shared value (JSON; - reads it from stdin)
|
|
133
|
+
marten sketch storage delete <id> <key> Delete one shared value
|
|
134
|
+
(\`marten app …\` is the same command.)
|
|
135
|
+
marten file share <path> [options] Share a file from your folder (/agent) in the chat as a card people
|
|
136
|
+
open, preview and save: a PDF, image, CSV, markdown, zip…
|
|
137
|
+
(at most 25 MB; never paste a path in a message instead)
|
|
138
|
+
--title <t> what the card calls it (default: the file's name)
|
|
139
|
+
--message <m> what you say with it, posted with the card
|
|
140
|
+
marten file list List the files shared in this chat
|
|
141
|
+
marten file get <id> [--out <path>] Download a file shared in this chat (by a person or a Marten) to
|
|
142
|
+
/agent/files/<name> (or --out); prints its path. A file people attach
|
|
143
|
+
says its id in your turn's input
|
|
144
|
+
marten trigger apps The apps triggers can listen to: the person's connected accounts and
|
|
145
|
+
each app's events (their slugs and the --config fields they take)
|
|
146
|
+
marten trigger list List the chat's triggers: id, title, app · account, events today
|
|
147
|
+
marten trigger add <app> [options] Listen to an app's events; each batch comes to you as an ## Events turn
|
|
148
|
+
--event <SLUG> the event, from \`marten trigger apps\` (e.g. LINEAR_ISSUE_CREATED_TRIGGER)
|
|
149
|
+
--account <label|id> which connected account (needed when there are several)
|
|
150
|
+
--config <key=value> a filter the event takes (repeatable; numbers and true/false as such)
|
|
151
|
+
--title <t> what people see it as
|
|
152
|
+
--instructions <text> how to handle its events (or --instructions-file <path>)
|
|
153
|
+
marten trigger update <id> [options] Change a trigger: --title, --instructions or --instructions-file,
|
|
154
|
+
--config (the filters given replace the ones set)
|
|
155
|
+
marten trigger pause|resume <id> Stop or restart handling a trigger's events (paused, they're dropped)
|
|
156
|
+
marten trigger remove <id> Stop listening and delete the trigger
|
|
157
|
+
marten trigger events <id> [--since 1d] What came in and what you did with it: batches with their outcome
|
|
158
|
+
(--since: 30m, 12h, 1d, 7d or an ISO time)
|
|
159
|
+
|
|
160
|
+
marten browser open <url> Your own browser (your owner's, with their logins; they can watch it live):
|
|
161
|
+
open a page in this chat's tab; prints the page (see snapshot)
|
|
162
|
+
marten browser snapshot The page now: its address, the elements you can act on, numbered, and its text
|
|
163
|
+
marten browser click <n> Click element n of the last snapshot; prints the page after
|
|
164
|
+
marten browser type <n> <text> Fill field n (--submit presses Enter after); prints the page after
|
|
165
|
+
marten browser press <key> Press a key: Enter, Escape, Tab, ArrowDown, Control+a…
|
|
166
|
+
marten browser scroll [down|up|top|bottom]
|
|
167
|
+
marten browser back Go back a page
|
|
168
|
+
marten browser screenshot Save a picture of the page (--full-page; --out <file.jpg>) and print its path;
|
|
169
|
+
read it to see the page
|
|
170
|
+
marten browser run <file.js|-> Run Playwright code with \`page\` (this chat's tab), \`context\` and \`browser\`;
|
|
171
|
+
\`return\` a value to print it (60 s at most)
|
|
172
|
+
marten browser close Close this chat's tab when you're done
|
|
173
|
+
|
|
174
|
+
Every command takes --group <id> (default: $MARTEN_GROUP_ID) and needs $MARTEN_API_URL.`;
|
|
175
|
+
var OPTIONS = {
|
|
176
|
+
group: { type: "string" },
|
|
177
|
+
limit: { type: "string" },
|
|
178
|
+
"reply-to": { type: "string" },
|
|
179
|
+
reason: { type: "string" },
|
|
180
|
+
due: { type: "string" },
|
|
181
|
+
assignee: { type: "string" },
|
|
182
|
+
description: { type: "string" },
|
|
183
|
+
status: { type: "string" },
|
|
184
|
+
repeat: { type: "string" },
|
|
185
|
+
remind: { type: "boolean" },
|
|
186
|
+
tz: { type: "string" },
|
|
187
|
+
"for-owner": { type: "boolean" },
|
|
188
|
+
mine: { type: "boolean" },
|
|
189
|
+
series: { type: "boolean" },
|
|
190
|
+
app: { type: "string" },
|
|
191
|
+
title: { type: "string" },
|
|
192
|
+
label: { type: "string" },
|
|
193
|
+
message: { type: "string" },
|
|
194
|
+
version: { type: "string" },
|
|
195
|
+
width: { type: "string" },
|
|
196
|
+
height: { type: "string" },
|
|
197
|
+
"full-page": { type: "boolean" },
|
|
198
|
+
theme: { type: "string" },
|
|
199
|
+
wait: { type: "string" },
|
|
200
|
+
click: { type: "string", multiple: true },
|
|
201
|
+
type: { type: "string", multiple: true },
|
|
202
|
+
out: { type: "string" },
|
|
203
|
+
event: { type: "string" },
|
|
204
|
+
account: { type: "string" },
|
|
205
|
+
config: { type: "string", multiple: true },
|
|
206
|
+
instructions: { type: "string" },
|
|
207
|
+
"instructions-file": { type: "string" },
|
|
208
|
+
since: { type: "string" },
|
|
209
|
+
submit: { type: "boolean" },
|
|
210
|
+
help: { type: "boolean", short: "h" }
|
|
211
|
+
};
|
|
212
|
+
function words(rest, what) {
|
|
213
|
+
const text = rest.join(" ").trim();
|
|
214
|
+
if (!text)
|
|
215
|
+
throw new UsageError(`Missing ${what}`);
|
|
216
|
+
return text;
|
|
217
|
+
}
|
|
218
|
+
function parseCommand(argv, now = new Date) {
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true, tokens: true });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
throw new UsageError(err.message);
|
|
224
|
+
}
|
|
225
|
+
const { values, positionals, tokens } = parsed;
|
|
226
|
+
const [first, second, ...rest] = positionals;
|
|
227
|
+
if (values.help || !first || first === "help")
|
|
228
|
+
return { name: "help" };
|
|
229
|
+
const group = values.group;
|
|
230
|
+
switch (first) {
|
|
231
|
+
case "ask": {
|
|
232
|
+
if (!second?.trim())
|
|
233
|
+
throw new UsageError("Missing the Marten: marten ask <id|name> <question> (see `marten members`)");
|
|
234
|
+
return { name: "ask", marten: second.trim(), question: words(rest, "question"), group };
|
|
235
|
+
}
|
|
236
|
+
case "reply": {
|
|
237
|
+
const text = words(second === undefined ? [] : [second, ...rest], "message: marten reply <message> (or - to read it from stdin)");
|
|
238
|
+
const replyTo = values["reply-to"]?.trim().replace(/^#/, "");
|
|
239
|
+
return { name: "reply", text, ...replyTo ? { replyTo } : {}, group };
|
|
240
|
+
}
|
|
241
|
+
case "members": {
|
|
242
|
+
if (second)
|
|
243
|
+
throw new UsageError(`Unknown command: members ${second}`);
|
|
244
|
+
return { name: "members", group };
|
|
245
|
+
}
|
|
246
|
+
case "tell-owner":
|
|
247
|
+
return { name: "tell-owner", text: words(second === undefined ? [] : [second, ...rest], "message: marten tell-owner <message> (or - to read it from stdin)"), group };
|
|
248
|
+
case "end-conversation": {
|
|
249
|
+
if (second !== undefined)
|
|
250
|
+
throw new UsageError("marten end-conversation takes no text; give the reason with --reason");
|
|
251
|
+
const reason = values.reason?.trim();
|
|
252
|
+
return { name: "end-conversation", ...reason ? { reason } : {}, group };
|
|
253
|
+
}
|
|
254
|
+
case "chat": {
|
|
255
|
+
if (second === "send") {
|
|
256
|
+
if (!group)
|
|
257
|
+
throw new UsageError("Say which group with --group <id> (your 1:1's roster lists your owner's groups); to answer here, use marten reply");
|
|
258
|
+
const replyTo = values["reply-to"]?.trim().replace(/^#/, "");
|
|
259
|
+
return { name: "chat-send", text: words(rest, "message: marten chat send <message> --group <id> (or - to read it from stdin)"), ...replyTo ? { replyTo } : {}, group };
|
|
260
|
+
}
|
|
261
|
+
if (second !== "read")
|
|
262
|
+
throw new UsageError(`Unknown command: chat ${second ?? ""}`.trim());
|
|
263
|
+
const limit = values.limit === undefined ? 20 : Number(values.limit);
|
|
264
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
265
|
+
throw new UsageError("--limit must be a whole number from 1 to 200");
|
|
266
|
+
return { name: "chat-read", limit, group };
|
|
267
|
+
}
|
|
268
|
+
case "task":
|
|
269
|
+
return parseTask(second, rest, values, group);
|
|
270
|
+
case "sketch":
|
|
271
|
+
case "app":
|
|
272
|
+
if (second === "preview")
|
|
273
|
+
return parsePreview(rest, values, tokens, group);
|
|
274
|
+
return parseApp(second, rest, values, group);
|
|
275
|
+
case "file":
|
|
276
|
+
return parseFile(second, rest, values, group);
|
|
277
|
+
case "trigger":
|
|
278
|
+
return parseTrigger(second, rest, values, group, now);
|
|
279
|
+
case "browser":
|
|
280
|
+
return parseBrowser(second, rest, values, group);
|
|
281
|
+
default:
|
|
282
|
+
throw new UsageError(`Unknown command: ${first}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
var STATUSES = ["todo", "in_progress", "needs_review", "done"];
|
|
286
|
+
var DUE_FILTERS = ["today", "overdue", "upcoming", "none"];
|
|
287
|
+
function oneOf(value, allowed, option) {
|
|
288
|
+
if (value === undefined)
|
|
289
|
+
return;
|
|
290
|
+
if (!allowed.includes(value))
|
|
291
|
+
throw new UsageError(`${option} must be one of ${allowed.join(", ")}`);
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
function taskFields(values) {
|
|
295
|
+
const fields = {
|
|
296
|
+
description: values.description,
|
|
297
|
+
due: values.due === undefined ? undefined : values.due.toLowerCase() === "none" ? null : values.due,
|
|
298
|
+
assignee: values.assignee,
|
|
299
|
+
repeat: values.repeat,
|
|
300
|
+
remind: values.remind,
|
|
301
|
+
timezone: values.tz
|
|
302
|
+
};
|
|
303
|
+
return Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
|
|
304
|
+
}
|
|
305
|
+
function parseTask(sub, rest, values, group) {
|
|
306
|
+
const id = (usage) => {
|
|
307
|
+
if (!rest[0])
|
|
308
|
+
throw new UsageError(`Missing the task id: ${usage} (see \`marten task list\`)`);
|
|
309
|
+
return rest[0];
|
|
310
|
+
};
|
|
311
|
+
switch (sub) {
|
|
312
|
+
case "add":
|
|
313
|
+
return { name: "task-add", title: words(rest, "task title"), ...taskFields(values), group };
|
|
314
|
+
case "update": {
|
|
315
|
+
const taskId = id("marten task update <id> [options]");
|
|
316
|
+
if (values.title !== undefined && !values.title.trim())
|
|
317
|
+
throw new UsageError("--title can't be empty");
|
|
318
|
+
const status = oneOf(values.status, STATUSES, "--status");
|
|
319
|
+
const changes = { ...taskFields(values), ...values.title ? { title: values.title.trim() } : {}, ...status ? { status } : {} };
|
|
320
|
+
if (!Object.keys(changes).length)
|
|
321
|
+
throw new UsageError("Nothing to change: pass --title, --description, --status, --assignee, --due, --remind or --repeat");
|
|
322
|
+
return { name: "task-update", id: taskId, ...changes, group };
|
|
323
|
+
}
|
|
324
|
+
case "list": {
|
|
325
|
+
const assignee = values.assignee?.trim() || (values.mine ? "me" : undefined);
|
|
326
|
+
const forOwner = values["for-owner"] === true;
|
|
327
|
+
if (assignee && forOwner)
|
|
328
|
+
throw new UsageError("Pass --assignee or --for-owner, not both");
|
|
329
|
+
return { name: "task-list", assignee, forOwner, status: oneOf(values.status, [...STATUSES, "open"], "--status"), due: oneOf(values.due, DUE_FILTERS, "--due"), group };
|
|
330
|
+
}
|
|
331
|
+
case "show":
|
|
332
|
+
return { name: "task-show", id: id("marten task show <id>"), group };
|
|
333
|
+
case "start":
|
|
334
|
+
case "done":
|
|
335
|
+
case "reopen":
|
|
336
|
+
return { name: "task-move", verb: sub, id: id(`marten task ${sub} <id>`), group };
|
|
337
|
+
case "delete":
|
|
338
|
+
return { name: "task-delete", id: id("marten task delete <id>"), series: values.series === true, group };
|
|
339
|
+
case "comment":
|
|
340
|
+
return { name: "task-comment", id: id("marten task comment <id> <text>"), text: words(rest.slice(1), "comment text"), group };
|
|
341
|
+
default:
|
|
342
|
+
throw new UsageError(`Unknown command: task ${sub ?? ""}`.trim());
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function parseApp(sub, rest, values, group) {
|
|
346
|
+
const id = (usage) => {
|
|
347
|
+
if (!rest[0])
|
|
348
|
+
throw new UsageError(`Missing the sketch id: ${usage} (see \`marten sketch list\`)`);
|
|
349
|
+
return rest[0];
|
|
350
|
+
};
|
|
351
|
+
switch (sub) {
|
|
352
|
+
case "publish": {
|
|
353
|
+
if (!rest[0])
|
|
354
|
+
throw new UsageError("Missing what to publish: marten sketch publish <file.jsx|file.html|folder>");
|
|
355
|
+
if (values.title !== undefined && !values.title.trim())
|
|
356
|
+
throw new UsageError("--title can't be empty");
|
|
357
|
+
return { name: "app-publish", path: rest[0], app: values.app, title: values.title?.trim(), label: values.label?.trim(), group };
|
|
358
|
+
}
|
|
359
|
+
case "list":
|
|
360
|
+
return { name: "app-list", group };
|
|
361
|
+
case "save":
|
|
362
|
+
return { name: "app-save", id: id("marten sketch save <id>"), group };
|
|
363
|
+
case "unsave":
|
|
364
|
+
return { name: "app-unsave", id: id("marten sketch unsave <id>"), group };
|
|
365
|
+
case "delete":
|
|
366
|
+
return { name: "app-delete", id: id("marten sketch delete <id>"), group };
|
|
367
|
+
case "get":
|
|
368
|
+
return { name: "app-get", id: id("marten sketch get <id>"), group };
|
|
369
|
+
case "versions":
|
|
370
|
+
return { name: "app-versions", id: id("marten sketch versions <id>"), group };
|
|
371
|
+
case "storage":
|
|
372
|
+
return parseAppStorage(rest, group);
|
|
373
|
+
case "pull": {
|
|
374
|
+
const version = values.version === undefined ? undefined : Number(values.version);
|
|
375
|
+
if (version !== undefined && (!Number.isInteger(version) || version < 1))
|
|
376
|
+
throw new UsageError("--version must be a version number like 2");
|
|
377
|
+
return { name: "app-pull", id: id("marten sketch pull <id> [path]"), out: rest[1], version, group };
|
|
378
|
+
}
|
|
379
|
+
default:
|
|
380
|
+
throw new UsageError(`Unknown command: sketch ${sub ?? ""}`.trim());
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function whole(value, option) {
|
|
384
|
+
if (value === undefined)
|
|
385
|
+
return;
|
|
386
|
+
const n = Number(value);
|
|
387
|
+
if (!/^\d+$/.test(value.trim()) || !Number.isSafeInteger(n))
|
|
388
|
+
throw new UsageError(`${option} must be a whole number`);
|
|
389
|
+
return n;
|
|
390
|
+
}
|
|
391
|
+
function typeStep(value) {
|
|
392
|
+
let depth = 0;
|
|
393
|
+
let quote = null;
|
|
394
|
+
for (let i = 0;i < value.length; i++) {
|
|
395
|
+
const ch = value[i];
|
|
396
|
+
if (quote)
|
|
397
|
+
quote = ch === quote ? null : quote;
|
|
398
|
+
else if (ch === '"' || ch === "'")
|
|
399
|
+
quote = ch;
|
|
400
|
+
else if (ch === "[" || ch === "(")
|
|
401
|
+
depth++;
|
|
402
|
+
else if (ch === "]" || ch === ")")
|
|
403
|
+
depth--;
|
|
404
|
+
else if (ch === "=" && depth === 0 && i > 0)
|
|
405
|
+
return { target: value.slice(0, i).trim(), text: value.slice(i + 1) };
|
|
406
|
+
}
|
|
407
|
+
throw new UsageError(`--type takes "<selector or text>=<what to type>", e.g. --type "input[name=email]=nina@example.com"`);
|
|
408
|
+
}
|
|
409
|
+
function parsePreview(rest, values, tokens, group) {
|
|
410
|
+
if (!rest[0])
|
|
411
|
+
throw new UsageError("Missing what to preview: marten sketch preview <file.jsx|file.html|folder|sketch id>");
|
|
412
|
+
if (rest.length > 1)
|
|
413
|
+
throw new UsageError("Preview one sketch at a time");
|
|
414
|
+
const steps = tokens.flatMap((t) => {
|
|
415
|
+
if (t.kind !== "option" || t.value === undefined)
|
|
416
|
+
return [];
|
|
417
|
+
if (t.name === "click")
|
|
418
|
+
return [{ click: t.value }];
|
|
419
|
+
return t.name === "type" ? [{ type: typeStep(t.value) }] : [];
|
|
420
|
+
});
|
|
421
|
+
if (steps.some((s) => ("click" in s) && !s.click.trim()))
|
|
422
|
+
throw new UsageError("--click needs a selector or the element's text");
|
|
423
|
+
return {
|
|
424
|
+
name: "app-preview",
|
|
425
|
+
target: rest[0],
|
|
426
|
+
out: values.out,
|
|
427
|
+
title: values.title?.trim() || undefined,
|
|
428
|
+
width: whole(values.width, "--width"),
|
|
429
|
+
height: whole(values.height, "--height"),
|
|
430
|
+
fullPage: values["full-page"] === true,
|
|
431
|
+
theme: oneOf(values.theme, ["light", "dark"], "--theme"),
|
|
432
|
+
waitMs: whole(values.wait, "--wait"),
|
|
433
|
+
steps,
|
|
434
|
+
group
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
var STORAGE_ARGS = { list: "[prefix]", get: "<key>", set: "<key> <json|->", delete: "<key>" };
|
|
438
|
+
function parseAppStorage([sub = "list", id, key, ...extra], group) {
|
|
439
|
+
if (!Object.hasOwn(STORAGE_ARGS, sub))
|
|
440
|
+
throw new UsageError(`Unknown command: sketch storage ${sub}`);
|
|
441
|
+
const usage = `marten sketch storage ${sub} <id> ${STORAGE_ARGS[sub]}`;
|
|
442
|
+
if (!id)
|
|
443
|
+
throw new UsageError(`Missing the sketch id: ${usage} (see \`marten sketch list\`)`);
|
|
444
|
+
if (sub === "list") {
|
|
445
|
+
if (extra.length)
|
|
446
|
+
throw new UsageError(`Too many arguments: ${usage}`);
|
|
447
|
+
return { name: "app-storage-list", id, prefix: key ?? "", group };
|
|
448
|
+
}
|
|
449
|
+
if (!key)
|
|
450
|
+
throw new UsageError(`Missing the key: ${usage}`);
|
|
451
|
+
if (sub === "set") {
|
|
452
|
+
if (extra.length !== 1)
|
|
453
|
+
throw new UsageError(`Pass the value as one JSON argument, quoted, or - to read it from stdin: ${usage}`);
|
|
454
|
+
return { name: "app-storage-set", id, key, value: extra[0], group };
|
|
455
|
+
}
|
|
456
|
+
if (extra.length)
|
|
457
|
+
throw new UsageError(`Too many arguments: ${usage}`);
|
|
458
|
+
return { name: sub === "get" ? "app-storage-get" : "app-storage-delete", id, key, group };
|
|
459
|
+
}
|
|
460
|
+
function ref(text, usage) {
|
|
461
|
+
const n = Number(text);
|
|
462
|
+
if (!text || !Number.isInteger(n) || n < 1)
|
|
463
|
+
throw new UsageError(`Give the element's number from the last snapshot: ${usage}`);
|
|
464
|
+
return n;
|
|
465
|
+
}
|
|
466
|
+
function parseBrowser(sub, rest, values, group) {
|
|
467
|
+
const command = (action, input = {}, extra = {}) => ({ name: "browser", action, input, ...extra, group });
|
|
468
|
+
switch (sub) {
|
|
469
|
+
case "open": {
|
|
470
|
+
const url = rest[0]?.trim();
|
|
471
|
+
if (!url || rest.length > 1)
|
|
472
|
+
throw new UsageError("Give one address: marten browser open <url>");
|
|
473
|
+
return command("open", { url: /^https?:\/\//i.test(url) ? url : `https://${url}` });
|
|
474
|
+
}
|
|
475
|
+
case "snapshot":
|
|
476
|
+
case "back":
|
|
477
|
+
case "close":
|
|
478
|
+
if (rest.length)
|
|
479
|
+
throw new UsageError(`marten browser ${sub} takes nothing more`);
|
|
480
|
+
return command(sub);
|
|
481
|
+
case "click":
|
|
482
|
+
return command("click", { ref: ref(rest[0], "marten browser click <n>") });
|
|
483
|
+
case "type": {
|
|
484
|
+
const usage = "marten browser type <n> <text> [--submit]";
|
|
485
|
+
const n = ref(rest[0], usage);
|
|
486
|
+
if (rest.length < 2)
|
|
487
|
+
throw new UsageError(`Missing the text: ${usage}`);
|
|
488
|
+
return command("type", { ref: n, text: rest.slice(1).join(" "), ...values.submit ? { submit: true } : {} });
|
|
489
|
+
}
|
|
490
|
+
case "press":
|
|
491
|
+
if (!rest[0] || rest.length > 1)
|
|
492
|
+
throw new UsageError("Give one key: marten browser press <Enter|Escape|Tab|ArrowDown|Control+a…>");
|
|
493
|
+
return command("press", { key: rest[0] });
|
|
494
|
+
case "scroll": {
|
|
495
|
+
const direction = rest[0] ?? "down";
|
|
496
|
+
if (!["down", "up", "top", "bottom"].includes(direction) || rest.length > 1)
|
|
497
|
+
throw new UsageError("marten browser scroll [down|up|top|bottom]");
|
|
498
|
+
return command("scroll", { direction });
|
|
499
|
+
}
|
|
500
|
+
case "screenshot":
|
|
501
|
+
if (rest.length)
|
|
502
|
+
throw new UsageError("marten browser screenshot [--full-page] [--out <file.jpg>]");
|
|
503
|
+
return command("screenshot", values["full-page"] ? { fullPage: true } : {}, { out: values.out });
|
|
504
|
+
case "run":
|
|
505
|
+
if (!rest[0] || rest.length > 1)
|
|
506
|
+
throw new UsageError("Give the code's file, or - to read it from stdin: marten browser run <file.js|->");
|
|
507
|
+
return command("run", {}, { code: rest[0] });
|
|
508
|
+
default:
|
|
509
|
+
throw new UsageError(`Unknown command: browser ${sub ?? ""}`.trim());
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function parseFile(sub, rest, values, group) {
|
|
513
|
+
switch (sub) {
|
|
514
|
+
case "share": {
|
|
515
|
+
if (!rest[0])
|
|
516
|
+
throw new UsageError("Missing the file: marten file share <path> [--title <t>] [--message <m>]");
|
|
517
|
+
if (rest.length > 1)
|
|
518
|
+
throw new UsageError("Share one file at a time (zip several to share them together)");
|
|
519
|
+
if (values.title !== undefined && !values.title.trim())
|
|
520
|
+
throw new UsageError("--title can't be empty");
|
|
521
|
+
return { name: "file-share", path: rest[0], title: values.title?.trim(), message: values.message?.trim() || undefined, group };
|
|
522
|
+
}
|
|
523
|
+
case "list":
|
|
524
|
+
return { name: "file-list", group };
|
|
525
|
+
case "get": {
|
|
526
|
+
if (!rest[0]?.trim())
|
|
527
|
+
throw new UsageError("Missing the file's id: marten file get <id> [--out <path>] (see `marten file list`)");
|
|
528
|
+
if (rest.length > 1)
|
|
529
|
+
throw new UsageError("Get one file at a time: marten file get <id> [--out <path>]");
|
|
530
|
+
if (values.out !== undefined && !values.out.trim())
|
|
531
|
+
throw new UsageError("--out can't be empty");
|
|
532
|
+
return { name: "file-get", id: rest[0].trim(), out: values.out, group };
|
|
533
|
+
}
|
|
534
|
+
default:
|
|
535
|
+
throw new UsageError(`Unknown command: file ${sub ?? ""}`.trim());
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function configEntry(entry) {
|
|
539
|
+
const at = entry.indexOf("=");
|
|
540
|
+
const key = at > 0 ? entry.slice(0, at).trim() : "";
|
|
541
|
+
if (!key)
|
|
542
|
+
throw new UsageError(`--config takes key=value, e.g. --config team_id=ENG (got "${entry}")`);
|
|
543
|
+
const raw = entry.slice(at + 1).trim();
|
|
544
|
+
if (raw === "true" || raw === "false")
|
|
545
|
+
return [key, raw === "true"];
|
|
546
|
+
return [key, raw !== "" && /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw];
|
|
547
|
+
}
|
|
548
|
+
function instructions(values) {
|
|
549
|
+
const file = values["instructions-file"];
|
|
550
|
+
if (values.instructions !== undefined && file !== undefined)
|
|
551
|
+
throw new UsageError("Pass --instructions or --instructions-file, not both");
|
|
552
|
+
if (file !== undefined) {
|
|
553
|
+
if (!file.trim())
|
|
554
|
+
throw new UsageError("--instructions-file needs a path");
|
|
555
|
+
return { instructionsFile: file };
|
|
556
|
+
}
|
|
557
|
+
if (values.instructions === undefined)
|
|
558
|
+
return;
|
|
559
|
+
if (!values.instructions.trim())
|
|
560
|
+
throw new UsageError("--instructions can't be empty");
|
|
561
|
+
return { instructions: values.instructions.trim() };
|
|
562
|
+
}
|
|
563
|
+
var SINCE_UNITS = { m: 60000, h: 3600000, d: 86400000, w: 7 * 86400000 };
|
|
564
|
+
function sinceTime(value, now) {
|
|
565
|
+
const age = /^(\d+)\s*([mhdw])$/i.exec(value.trim());
|
|
566
|
+
if (age)
|
|
567
|
+
return new Date(now.getTime() - Number(age[1]) * SINCE_UNITS[age[2].toLowerCase()]).toISOString();
|
|
568
|
+
const time = /^\d{4}-\d{2}-\d{2}/.test(value.trim()) ? new Date(value.trim()) : null;
|
|
569
|
+
if (!time || Number.isNaN(time.getTime()))
|
|
570
|
+
throw new UsageError("--since takes an age (30m, 12h, 1d, 7d) or an ISO time like 2026-10-15T09:00:00Z");
|
|
571
|
+
return time.toISOString();
|
|
572
|
+
}
|
|
573
|
+
function parseTrigger(sub, rest, values, group, now) {
|
|
574
|
+
const id = (usage) => {
|
|
575
|
+
if (!rest[0]?.trim())
|
|
576
|
+
throw new UsageError(`Missing the trigger id: ${usage} (see \`marten trigger list\`)`);
|
|
577
|
+
if (rest.length > 1)
|
|
578
|
+
throw new UsageError(`Too many arguments: ${usage}`);
|
|
579
|
+
return rest[0].trim();
|
|
580
|
+
};
|
|
581
|
+
const config = values.config && Object.fromEntries(values.config.map(configEntry));
|
|
582
|
+
if (values.title !== undefined && !values.title.trim())
|
|
583
|
+
throw new UsageError("--title can't be empty");
|
|
584
|
+
const title = values.title?.trim();
|
|
585
|
+
switch (sub) {
|
|
586
|
+
case "apps":
|
|
587
|
+
return { name: "trigger-apps", group };
|
|
588
|
+
case "list":
|
|
589
|
+
return { name: "trigger-list", group };
|
|
590
|
+
case "add": {
|
|
591
|
+
const usage = 'marten trigger add <app> --event <SLUG> --title "<t>" --instructions "<text>"';
|
|
592
|
+
if (!rest[0]?.trim())
|
|
593
|
+
throw new UsageError(`Missing the app: ${usage} (see \`marten trigger apps\`)`);
|
|
594
|
+
if (rest.length > 1)
|
|
595
|
+
throw new UsageError(`Too many arguments: ${usage} (quote the title and instructions)`);
|
|
596
|
+
if (!values.event?.trim())
|
|
597
|
+
throw new UsageError(`Missing --event: ${usage} (see \`marten trigger apps\` for the events)`);
|
|
598
|
+
if (!title)
|
|
599
|
+
throw new UsageError(`Missing --title: ${usage}`);
|
|
600
|
+
const given = instructions(values);
|
|
601
|
+
if (!given)
|
|
602
|
+
throw new UsageError(`Missing --instructions (or --instructions-file): say how to handle its events`);
|
|
603
|
+
const account = values.account?.trim() || undefined;
|
|
604
|
+
return { name: "trigger-add", toolkit: rest[0].trim().toLowerCase(), triggerType: values.event.trim(), ...account ? { account } : {}, config: config ?? {}, title, ...given, group };
|
|
605
|
+
}
|
|
606
|
+
case "update": {
|
|
607
|
+
const triggerId = id("marten trigger update <id> [--title] [--instructions] [--config key=value]");
|
|
608
|
+
const changes = { ...title ? { title } : {}, ...config ? { config } : {}, ...instructions(values) };
|
|
609
|
+
if (!Object.keys(changes).length)
|
|
610
|
+
throw new UsageError("Nothing to change: pass --title, --instructions, --instructions-file or --config");
|
|
611
|
+
return { name: "trigger-update", id: triggerId, ...changes, group };
|
|
612
|
+
}
|
|
613
|
+
case "pause":
|
|
614
|
+
case "resume":
|
|
615
|
+
return { name: "trigger-pause", id: id(`marten trigger ${sub} <id>`), paused: sub === "pause", group };
|
|
616
|
+
case "remove":
|
|
617
|
+
return { name: "trigger-remove", id: id("marten trigger remove <id>"), group };
|
|
618
|
+
case "events": {
|
|
619
|
+
const triggerId = id("marten trigger events <id> [--since 1d]");
|
|
620
|
+
return { name: "trigger-events", id: triggerId, ...values.since !== undefined ? { since: sinceTime(values.since, now) } : {}, group };
|
|
621
|
+
}
|
|
622
|
+
default:
|
|
623
|
+
throw new UsageError(`Unknown command: trigger ${sub ?? ""}`.trim());
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/commands/commands.ts
|
|
628
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
629
|
+
|
|
630
|
+
// src/app-files/app-files.ts
|
|
631
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
632
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
633
|
+
var SKIPPED = new Set(["node_modules", ".git"]);
|
|
634
|
+
var MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
635
|
+
async function readAppFiles(path) {
|
|
636
|
+
const info = await stat(path).catch(() => {
|
|
637
|
+
throw new UsageError(`Nothing at ${path}`);
|
|
638
|
+
});
|
|
639
|
+
const paths = info.isDirectory() ? await walk(path) : [path];
|
|
640
|
+
let total = 0;
|
|
641
|
+
const files = [];
|
|
642
|
+
for (const p of paths) {
|
|
643
|
+
const data = await readFile(p);
|
|
644
|
+
total += data.byteLength;
|
|
645
|
+
if (total > MAX_TOTAL_BYTES)
|
|
646
|
+
throw new UsageError(`${path} is over 50 MB; apps can be at most 50 MB`);
|
|
647
|
+
files.push({ path: info.isDirectory() ? relative(path, p).split(sep).join("/") : basename(p), data: data.toString("base64") });
|
|
648
|
+
}
|
|
649
|
+
if (files.length === 0)
|
|
650
|
+
throw new UsageError(`${path} is empty`);
|
|
651
|
+
return files;
|
|
652
|
+
}
|
|
653
|
+
async function walk(dir) {
|
|
654
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
655
|
+
const nested = await Promise.all(entries.filter((e) => !e.name.startsWith(".") && !SKIPPED.has(e.name)).map((e) => e.isDirectory() ? walk(join(dir, e.name)) : Promise.resolve(e.isFile() ? [join(dir, e.name)] : [])));
|
|
656
|
+
return nested.flat().sort();
|
|
657
|
+
}
|
|
658
|
+
async function writeAppFiles(source, out, appId) {
|
|
659
|
+
if (source.files.length === 1) {
|
|
660
|
+
const [file] = source.files;
|
|
661
|
+
const isDir = out ? await stat(out).then((s) => s.isDirectory(), () => out.endsWith("/")) : false;
|
|
662
|
+
const target = !out ? source.entry : isDir ? join(out, file.path) : out;
|
|
663
|
+
await mkdir(dirname(resolve(target)), { recursive: true });
|
|
664
|
+
await writeFile(target, Buffer.from(file.data, "base64"));
|
|
665
|
+
return target;
|
|
666
|
+
}
|
|
667
|
+
const root = out ?? `app-${appId.slice(0, 8)}`;
|
|
668
|
+
for (const file of source.files) {
|
|
669
|
+
const target = resolve(root, ...file.path.split("/"));
|
|
670
|
+
if (!target.startsWith(resolve(root) + sep))
|
|
671
|
+
throw new UsageError(`Refusing to write ${file.path} outside ${root}`);
|
|
672
|
+
await mkdir(dirname(target), { recursive: true });
|
|
673
|
+
await writeFile(target, Buffer.from(file.data, "base64"));
|
|
674
|
+
}
|
|
675
|
+
return `${root}/`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/app-preview/app-preview.ts
|
|
679
|
+
import { mkdir as mkdir2, readdir as readdir2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
|
|
680
|
+
import { basename as basename2, dirname as dirname2, join as join2 } from "node:path";
|
|
681
|
+
var PREVIEW_DIR = "/tmp/marten-preview";
|
|
682
|
+
var APP_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
683
|
+
async function isAppId(target) {
|
|
684
|
+
return APP_ID.test(target) && !await stat2(target).then(() => true, () => false);
|
|
685
|
+
}
|
|
686
|
+
async function writePreviewImage(image, name, out, dir = PREVIEW_DIR) {
|
|
687
|
+
const ext = image.type === "png" ? "png" : "jpg";
|
|
688
|
+
let path = out;
|
|
689
|
+
if (!path) {
|
|
690
|
+
const slug = basename2(name).replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "app";
|
|
691
|
+
const taken = await readdir2(dir).catch(() => []);
|
|
692
|
+
const numbers = taken.flatMap((f) => f.startsWith(`${slug}-`) ? [Number(f.slice(slug.length + 1).split(".")[0])] : []).filter(Number.isInteger);
|
|
693
|
+
path = join2(dir, `${slug}-${Math.max(0, ...numbers) + 1}.${ext}`);
|
|
694
|
+
}
|
|
695
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
696
|
+
await writeFile2(path, Buffer.from(image.data, "base64"));
|
|
697
|
+
return path;
|
|
698
|
+
}
|
|
699
|
+
function formatPreview(result, imagePath) {
|
|
700
|
+
const lines = [];
|
|
701
|
+
if (result.image && imagePath)
|
|
702
|
+
lines.push(`Screenshot: ${imagePath} (${result.image.width} × ${result.image.height}). Read it to see the app.`);
|
|
703
|
+
if (result.problem)
|
|
704
|
+
lines.push(result.problem);
|
|
705
|
+
const section = (title, items) => items.length && lines.push(`${title} (${items.length}):`, ...items.map((i) => ` ${i}`));
|
|
706
|
+
section("Steps", result.steps.map((s) => s.error ? `${s.step}: failed, ${s.error}` : `${s.step}: done`));
|
|
707
|
+
section("Console", result.console.map((m) => `[${m.level}] ${m.text}`));
|
|
708
|
+
section("Page errors", result.errors);
|
|
709
|
+
section("Failed requests", result.failedRequests.map((r) => `${r.url}: ${r.error}`));
|
|
710
|
+
if (!result.console.length && !result.errors.length && !result.failedRequests.length)
|
|
711
|
+
lines.push("Nothing in the console, no page errors and no failed requests.");
|
|
712
|
+
return lines.join(`
|
|
713
|
+
`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/browser-page/browser-page.ts
|
|
717
|
+
import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
|
|
718
|
+
import { join as join3 } from "node:path";
|
|
719
|
+
var SHOT_DIR = "/tmp/marten-browser";
|
|
720
|
+
function formatPage(page) {
|
|
721
|
+
const lines = [`${page.title || "(no title)"}`, page.url];
|
|
722
|
+
const { y, height, viewport } = page.scroll;
|
|
723
|
+
if (height > viewport + 4) {
|
|
724
|
+
const below = Math.max(0, height - y - viewport);
|
|
725
|
+
lines.push(`Scrolled ${y}px of ${height}px${below ? `, ${below}px more below (marten browser scroll down)` : ", at the bottom"}`);
|
|
726
|
+
}
|
|
727
|
+
lines.push("", page.elements.length ? `Elements (${page.elements.length}):` : "Nothing to click or fill on screen.");
|
|
728
|
+
for (const e of page.elements) {
|
|
729
|
+
const state = e.password ? " [password]" : e.checked !== undefined ? e.checked ? " [checked]" : " [unchecked]" : e.value ? ` = "${e.value}"` : "";
|
|
730
|
+
lines.push(` [${e.ref}] ${e.role}${e.name ? ` "${e.name}"` : ""}${state}${e.href ? ` → ${e.href}` : ""}`);
|
|
731
|
+
}
|
|
732
|
+
lines.push("", "Text:", page.text || "(none)");
|
|
733
|
+
if (page.truncated)
|
|
734
|
+
lines.push("…(cut; scroll down or use marten browser run to read more)");
|
|
735
|
+
return lines.join(`
|
|
736
|
+
`);
|
|
737
|
+
}
|
|
738
|
+
async function writeShot(base64, out, now = Date.now) {
|
|
739
|
+
const path = out ?? join3(SHOT_DIR, `page-${now()}.jpg`);
|
|
740
|
+
await mkdir3(join3(path, ".."), { recursive: true });
|
|
741
|
+
await writeFile3(path, Buffer.from(base64, "base64"));
|
|
742
|
+
return path;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/shared-file/shared-file.ts
|
|
746
|
+
import { createHash } from "node:crypto";
|
|
747
|
+
import { mkdir as mkdir4, readFile as readFile2, realpath, stat as stat3, writeFile as writeFile4 } from "node:fs/promises";
|
|
748
|
+
import { basename as basename3, dirname as dirname3, extname, join as join4, resolve as resolve2, sep as sep2 } from "node:path";
|
|
749
|
+
var AGENT_DIR = "/agent";
|
|
750
|
+
async function readSharedFile(path, agentDir = AGENT_DIR) {
|
|
751
|
+
const real = await realpath(resolve2(path)).catch(() => {
|
|
752
|
+
throw new UsageError(`Nothing at ${path}`);
|
|
753
|
+
});
|
|
754
|
+
const root = await realpath(agentDir).catch(() => resolve2(agentDir));
|
|
755
|
+
if (!real.startsWith(root + sep2))
|
|
756
|
+
throw new UsageError(`${path} isn't in ${agentDir}; only files in your folder can be shared`);
|
|
757
|
+
if (!(await stat3(real)).isFile())
|
|
758
|
+
throw new UsageError(`${path} isn't a file; zip a folder to share it`);
|
|
759
|
+
const bytes = new Uint8Array(await readFile2(real));
|
|
760
|
+
return { name: basename3(resolve2(path)), bytes, sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
761
|
+
}
|
|
762
|
+
async function saveSharedFile(bytes, name, opts = {}) {
|
|
763
|
+
if (opts.out) {
|
|
764
|
+
const path = resolve2(opts.out);
|
|
765
|
+
await mkdir4(dirname3(path), { recursive: true });
|
|
766
|
+
await writeFile4(path, bytes);
|
|
767
|
+
return path;
|
|
768
|
+
}
|
|
769
|
+
const dir = join4(opts.agentDir ?? AGENT_DIR, "files");
|
|
770
|
+
await mkdir4(dir, { recursive: true });
|
|
771
|
+
const plain = basename3(name).replace(/^\.+/, "") || "file";
|
|
772
|
+
const ext = extname(plain);
|
|
773
|
+
const stem = plain.slice(0, plain.length - ext.length);
|
|
774
|
+
for (let n = 1;; n++) {
|
|
775
|
+
const path = join4(dir, n === 1 ? plain : `${stem} (${n})${ext}`);
|
|
776
|
+
const existing = await readFile2(path).catch(() => null);
|
|
777
|
+
if (existing && Buffer.compare(existing, bytes) === 0)
|
|
778
|
+
return path;
|
|
779
|
+
if (existing)
|
|
780
|
+
continue;
|
|
781
|
+
await writeFile4(path, bytes, { flag: "wx" });
|
|
782
|
+
return path;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// src/commands/commands.ts
|
|
787
|
+
async function readStdin() {
|
|
788
|
+
const chunks = [];
|
|
789
|
+
for await (const chunk of process.stdin)
|
|
790
|
+
chunks.push(chunk);
|
|
791
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
792
|
+
}
|
|
793
|
+
function storageValue(text) {
|
|
794
|
+
try {
|
|
795
|
+
JSON.parse(text);
|
|
796
|
+
} catch {
|
|
797
|
+
throw new UsageError(`The value must be JSON, e.g. 3, '"text"' or '{"glasses":3}'`);
|
|
798
|
+
}
|
|
799
|
+
return text;
|
|
800
|
+
}
|
|
801
|
+
var when = (iso) => iso.slice(0, 16).replace("T", " ");
|
|
802
|
+
var labelled = (label) => label ? ` "${label}"` : "";
|
|
803
|
+
var appLine = (a) => `${a.id} ${a.name} ${a.version ? `v${a.version.number}${labelled(a.version.label)}` : "draft"}${a.saved ? " saved" : ""}`;
|
|
804
|
+
function withQuery(path, group, params = {}) {
|
|
805
|
+
const query = new URLSearchParams;
|
|
806
|
+
for (const [name, value] of Object.entries({ ...params, groupId: group }))
|
|
807
|
+
if (value !== undefined)
|
|
808
|
+
query.set(name, String(value));
|
|
809
|
+
const text = query.toString();
|
|
810
|
+
return text ? `${path}?${text}` : path;
|
|
811
|
+
}
|
|
812
|
+
var STATUS_MARK = { todo: "[ ]", in_progress: "[~]", needs_review: "[?]", done: "[x]" };
|
|
813
|
+
function formatTask(t) {
|
|
814
|
+
const time = t.scheduledAt && `${t.remind ? "remind" : "at"} ${when(t.scheduledAt)} UTC`;
|
|
815
|
+
const details = [t.due && `due ${t.due}`, time, t.repeat && t.repeat.toLowerCase(), t.assignee && `@${t.assignee}`].filter(Boolean);
|
|
816
|
+
return `${t.id} ${STATUS_MARK[t.status]} ${t.title}${details.length ? ` (${details.join(", ")})` : ""}`;
|
|
817
|
+
}
|
|
818
|
+
var SHOWN_OPTIONS = 25;
|
|
819
|
+
var optionsLine = (options) => ` one of: ${options.slice(0, SHOWN_OPTIONS).map((o) => `${o.label} (${o.value})`).join(", ")}${options.length > SHOWN_OPTIONS ? `, and ${options.length - SHOWN_OPTIONS} more` : ""}`;
|
|
820
|
+
var fieldLines = (f) => [
|
|
821
|
+
` --config ${f.name}=<${f.type}>${f.required ? " (required)" : ""}${f.default !== undefined ? ` (default ${f.default})` : ""} ${f.label}${f.description && f.description !== f.label ? `: ${f.description}` : ""}`,
|
|
822
|
+
...f.options?.length ? [optionsLine(f.options)] : []
|
|
823
|
+
];
|
|
824
|
+
function formatToolkit(t) {
|
|
825
|
+
const accounts = t.accounts.length ? t.accounts.map((a) => `${a.label} (${a.id}${a.status === "active" ? "" : `, ${a.status}`})`).join(", ") : "none connected (the person connects one in Marten's Integrations)";
|
|
826
|
+
const events = t.triggerTypes.flatMap((e) => [` ${e.slug} ${e.name}${e.description ? `: ${e.description}` : ""}`, ...e.fields.flatMap(fieldLines)]);
|
|
827
|
+
return [`${t.slug} ${t.name}`, ` accounts: ${accounts}`, ...events.length ? [" events:", ...events.map((l) => ` ${l}`)] : [" no events"]].join(`
|
|
828
|
+
`);
|
|
829
|
+
}
|
|
830
|
+
var configText = (config) => Object.entries(config).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
831
|
+
function formatTrigger(t) {
|
|
832
|
+
const details = [t.accountLabel ? `${t.toolkit} · ${t.accountLabel}` : t.toolkit, t.triggerName || t.triggerType, ...t.paused ? ["paused"] : [], `${t.eventsToday} event${t.eventsToday === 1 ? "" : "s"} today`];
|
|
833
|
+
return `${t.id} ${t.title} (${details.join(", ")})`;
|
|
834
|
+
}
|
|
835
|
+
var triggerDetails = (t) => [formatTrigger(t), ...Object.keys(t.config).length ? [`Filters: ${configText(t.config)}`] : [], `Instructions: ${t.instructions}`].join(`
|
|
836
|
+
`);
|
|
837
|
+
var eventLine = (e) => ` - [${when(e.receivedAt)}] ${e.title ?? "(an event)"}${e.status === "handled" || e.status === "pending" ? "" : ` (${e.status})`}${e.action ? ` → ${e.action}${e.note ? `: ${e.note}` : ""}` : ""}`;
|
|
838
|
+
function formatEvents(events, batches) {
|
|
839
|
+
const sorted = [...batches].sort((a, b) => b.at.localeCompare(a.at));
|
|
840
|
+
const lines = sorted.flatMap((b) => [
|
|
841
|
+
`[${when(b.at)}] ${b.count} event${b.count === 1 ? "" : "s"}: ${b.outcome?.trim() || "(no note)"}`,
|
|
842
|
+
...events.filter((e) => e.batchId === b.id).map(eventLine)
|
|
843
|
+
]);
|
|
844
|
+
const loose = events.filter((e) => !e.batchId || !batches.some((b) => b.id === e.batchId));
|
|
845
|
+
if (loose.length)
|
|
846
|
+
lines.push(...lines.length ? [""] : [], "Not in a batch:", ...loose.map((e) => `${eventLine(e)}${e.status === "pending" ? " (waiting)" : ""}`));
|
|
847
|
+
return lines.join(`
|
|
848
|
+
`) || "No events in that time.";
|
|
849
|
+
}
|
|
850
|
+
async function instructionsOf(command) {
|
|
851
|
+
if (command.instructionsFile === undefined)
|
|
852
|
+
return command.instructions;
|
|
853
|
+
let text;
|
|
854
|
+
try {
|
|
855
|
+
text = await readFile3(command.instructionsFile, "utf8");
|
|
856
|
+
} catch (err) {
|
|
857
|
+
throw new UsageError(`Can't read ${command.instructionsFile}: ${err.message}`);
|
|
858
|
+
}
|
|
859
|
+
if (!text.trim())
|
|
860
|
+
throw new UsageError(`${command.instructionsFile} is empty`);
|
|
861
|
+
return text.trim();
|
|
862
|
+
}
|
|
863
|
+
var storage = (api, command, op) => api("POST", `/cli/apps/${encodeURIComponent(command.id)}/storage`, { ...op, groupId: command.group });
|
|
864
|
+
async function runCommand(command, api, stdin = readStdin, agentDir = AGENT_DIR) {
|
|
865
|
+
switch (command.name) {
|
|
866
|
+
case "help":
|
|
867
|
+
return HELP;
|
|
868
|
+
case "chat-read": {
|
|
869
|
+
const { messages } = await api("GET", withQuery("/cli/messages", command.group, { limit: command.limit }));
|
|
870
|
+
return messages.map((m) => `[${when(m.at)}] ${m.author}: ${m.text}`).join(`
|
|
871
|
+
`) || "No messages yet.";
|
|
872
|
+
}
|
|
873
|
+
case "chat-send": {
|
|
874
|
+
const text = (command.text === "-" ? await stdin() : command.text).trim();
|
|
875
|
+
if (!text)
|
|
876
|
+
throw new UsageError("The message is empty");
|
|
877
|
+
await api("POST", "/cli/messages", { text, ...command.replyTo ? { replyTo: command.replyTo } : {}, groupId: command.group });
|
|
878
|
+
return "Posted in that group; its people can see it now. Tell your owner it's done with your reply here.";
|
|
879
|
+
}
|
|
880
|
+
case "task-add": {
|
|
881
|
+
const { name: _, group, ...task } = command;
|
|
882
|
+
const res = await api("POST", "/cli/tasks", { ...task, groupId: group });
|
|
883
|
+
return `Added ${formatTask(res.task)}`;
|
|
884
|
+
}
|
|
885
|
+
case "task-update": {
|
|
886
|
+
const { name: _, id, group, ...changes } = command;
|
|
887
|
+
const res = await api("POST", `/cli/tasks/${encodeURIComponent(id)}/update`, { ...changes, groupId: group });
|
|
888
|
+
return `Updated ${formatTask(res.task)}`;
|
|
889
|
+
}
|
|
890
|
+
case "task-move": {
|
|
891
|
+
const res = await api("POST", `/cli/tasks/${encodeURIComponent(command.id)}/${command.verb}`, { groupId: command.group });
|
|
892
|
+
return `${command.verb === "start" ? "Started" : command.verb === "done" ? "Done" : "Reopened"} ${formatTask(res.task)}`;
|
|
893
|
+
}
|
|
894
|
+
case "task-list": {
|
|
895
|
+
const query = { assignee: command.assignee, forOwner: command.forOwner ? "1" : undefined, status: command.status, due: command.due };
|
|
896
|
+
const { tasks } = await api("GET", withQuery("/cli/tasks", command.group, query));
|
|
897
|
+
return tasks.map(formatTask).join(`
|
|
898
|
+
`) || "No tasks.";
|
|
899
|
+
}
|
|
900
|
+
case "task-show": {
|
|
901
|
+
const { task, activity } = await api("GET", withQuery(`/cli/tasks/${encodeURIComponent(command.id)}`, command.group));
|
|
902
|
+
return [formatTask(task), ...task.description ? ["", task.description] : [], "", "Activity:", ...activity.map((a) => `[${when(a.at)}] ${a.kind}${a.text ? `: ${a.text}` : ""}`)].join(`
|
|
903
|
+
`);
|
|
904
|
+
}
|
|
905
|
+
case "task-comment": {
|
|
906
|
+
await api("POST", `/cli/tasks/${encodeURIComponent(command.id)}/comments`, { text: command.text, groupId: command.group });
|
|
907
|
+
return "Logged.";
|
|
908
|
+
}
|
|
909
|
+
case "task-delete": {
|
|
910
|
+
await api("POST", `/cli/tasks/${encodeURIComponent(command.id)}/delete`, { series: command.series, groupId: command.group });
|
|
911
|
+
return command.series ? "Deleted; it won't repeat again." : "Deleted.";
|
|
912
|
+
}
|
|
913
|
+
case "members": {
|
|
914
|
+
const { roster } = await api("GET", withQuery("/cli/members", command.group));
|
|
915
|
+
return roster;
|
|
916
|
+
}
|
|
917
|
+
case "reply": {
|
|
918
|
+
const text = (command.text === "-" ? await stdin() : command.text).trim();
|
|
919
|
+
if (!text)
|
|
920
|
+
throw new UsageError("The reply is empty: to stay quiet, don't reply");
|
|
921
|
+
await api("POST", "/cli/reply", { text, ...command.replyTo ? { replyTo: command.replyTo } : {}, groupId: command.group });
|
|
922
|
+
return "Posted: that's your reply, and people in the chat can see it now. You're done: end the turn without more text (it isn't shown to anyone).";
|
|
923
|
+
}
|
|
924
|
+
case "tell-owner": {
|
|
925
|
+
const text = (command.text === "-" ? await stdin() : command.text).trim();
|
|
926
|
+
if (!text)
|
|
927
|
+
throw new UsageError("The message is empty");
|
|
928
|
+
await api("POST", "/cli/tell-owner", { text, groupId: command.group });
|
|
929
|
+
return "Sent to your owner, in your 1:1 with them.";
|
|
930
|
+
}
|
|
931
|
+
case "end-conversation":
|
|
932
|
+
await api("POST", "/cli/end-conversation", { ...command.reason ? { reason: command.reason } : {}, groupId: command.group });
|
|
933
|
+
return "Ended the conversation.";
|
|
934
|
+
case "ask": {
|
|
935
|
+
const res = await api("POST", "/cli/ask", { marten: command.marten, question: command.question, groupId: command.group });
|
|
936
|
+
return res.answer;
|
|
937
|
+
}
|
|
938
|
+
case "app-publish": {
|
|
939
|
+
const files = await readAppFiles(command.path);
|
|
940
|
+
const { app, version } = await api("POST", "/cli/apps/publish", {
|
|
941
|
+
files,
|
|
942
|
+
app: command.app,
|
|
943
|
+
title: command.title,
|
|
944
|
+
label: command.label,
|
|
945
|
+
groupId: command.group
|
|
946
|
+
});
|
|
947
|
+
return `Published ${app.name} version ${version.number}; it's saved in the chat, on everyone's list of sketches.
|
|
948
|
+
Sketch id: ${app.id} (publish again with --app ${app.id} to add a version)`;
|
|
949
|
+
}
|
|
950
|
+
case "app-preview": {
|
|
951
|
+
const { name: _, target, out, title, group, ...options } = command;
|
|
952
|
+
const app = await isAppId(target);
|
|
953
|
+
const source = app ? { app: target } : { files: await readAppFiles(target), title };
|
|
954
|
+
const result = await api("POST", "/cli/apps/preview", { ...source, ...options, groupId: group });
|
|
955
|
+
const imagePath = result.image ? await writePreviewImage(result.image, app ? target.slice(0, 8) : target, out) : null;
|
|
956
|
+
return formatPreview(result, imagePath);
|
|
957
|
+
}
|
|
958
|
+
case "app-list": {
|
|
959
|
+
const { apps } = await api("GET", withQuery("/cli/apps", command.group));
|
|
960
|
+
return apps.map((a) => `${appLine(a)} · drawn by ${a.createdBy ?? "someone"}, ${when(a.createdAt)}`).join(`
|
|
961
|
+
`) || "No sketches in this chat yet.";
|
|
962
|
+
}
|
|
963
|
+
case "app-unsave": {
|
|
964
|
+
const { app } = await api("DELETE", withQuery(`/cli/apps/${encodeURIComponent(command.id)}/save`, command.group));
|
|
965
|
+
return `Unsaved ${app.name}: it's off the chat's list of sketches; its message stays.`;
|
|
966
|
+
}
|
|
967
|
+
case "app-delete": {
|
|
968
|
+
const { app } = await api("DELETE", withQuery(`/cli/apps/${encodeURIComponent(command.id)}`, command.group));
|
|
969
|
+
return `Deleted ${app.name}.`;
|
|
970
|
+
}
|
|
971
|
+
case "app-save": {
|
|
972
|
+
const { app } = await api("POST", `/cli/apps/${encodeURIComponent(command.id)}/save`, { groupId: command.group });
|
|
973
|
+
return `Saved ${app.name}: it's on everyone's list of sketches in this chat, and keeps its data.`;
|
|
974
|
+
}
|
|
975
|
+
case "app-get": {
|
|
976
|
+
const { app } = await api("GET", withQuery(`/cli/apps/${encodeURIComponent(command.id)}`, command.group));
|
|
977
|
+
return [appLine(app), ...app.description ? [app.description] : [], ...app.version ? [`Published ${when(app.version.createdAt)}`] : []].join(`
|
|
978
|
+
`);
|
|
979
|
+
}
|
|
980
|
+
case "app-versions": {
|
|
981
|
+
const { versions } = await api("GET", withQuery(`/cli/apps/${encodeURIComponent(command.id)}/versions`, command.group));
|
|
982
|
+
return versions.map((v) => `v${v.number} ${when(v.createdAt)} ${v.author?.name ?? "someone"}${labelled(v.label)}${v.current ? " (current)" : ""}`).join(`
|
|
983
|
+
`) || "Not published yet.";
|
|
984
|
+
}
|
|
985
|
+
case "app-pull": {
|
|
986
|
+
const source = await api("GET", withQuery(`/cli/apps/${encodeURIComponent(command.id)}/source`, command.group, { version: command.version }));
|
|
987
|
+
return `Wrote ${await writeAppFiles(source, command.out, command.id)} (version ${source.number})`;
|
|
988
|
+
}
|
|
989
|
+
case "app-storage-list": {
|
|
990
|
+
const { items } = await storage(api, command, { op: "list", prefix: command.prefix });
|
|
991
|
+
return items.map((i) => `${i.key} ${i.value}`).join(`
|
|
992
|
+
`) || `No shared data${command.prefix ? ` under "${command.prefix}"` : ""} in this app.`;
|
|
993
|
+
}
|
|
994
|
+
case "app-storage-get": {
|
|
995
|
+
const { item } = await storage(api, command, { op: "get", key: command.key });
|
|
996
|
+
if (!item)
|
|
997
|
+
throw new ApiError(`No "${command.key}" in this app's shared storage`);
|
|
998
|
+
return item.value;
|
|
999
|
+
}
|
|
1000
|
+
case "app-storage-set": {
|
|
1001
|
+
const value = storageValue(command.value === "-" ? (await stdin()).trim() : command.value);
|
|
1002
|
+
await storage(api, command, { op: "set", key: command.key, value });
|
|
1003
|
+
return `Set ${command.key}.`;
|
|
1004
|
+
}
|
|
1005
|
+
case "app-storage-delete": {
|
|
1006
|
+
const { deleted } = await storage(api, command, { op: "delete", key: command.key });
|
|
1007
|
+
return deleted ? `Deleted ${command.key}.` : `No "${command.key}" to delete.`;
|
|
1008
|
+
}
|
|
1009
|
+
case "file-share": {
|
|
1010
|
+
const file = await readSharedFile(command.path, agentDir);
|
|
1011
|
+
const upload = await api("POST", "/cli/files/uploads", {
|
|
1012
|
+
name: file.name,
|
|
1013
|
+
size: file.bytes.byteLength,
|
|
1014
|
+
groupId: command.group
|
|
1015
|
+
});
|
|
1016
|
+
for (let i = 0;i < upload.parts; i++) {
|
|
1017
|
+
await api("POST", `/cli/files/uploads/${upload.uploadId}/parts/${i}`, file.bytes.subarray(i * upload.partBytes, (i + 1) * upload.partBytes));
|
|
1018
|
+
}
|
|
1019
|
+
await api("POST", "/cli/files", {
|
|
1020
|
+
uploadId: upload.uploadId,
|
|
1021
|
+
name: file.name,
|
|
1022
|
+
title: command.title,
|
|
1023
|
+
size: file.bytes.byteLength,
|
|
1024
|
+
sha256: file.sha256,
|
|
1025
|
+
message: command.message,
|
|
1026
|
+
groupId: command.group
|
|
1027
|
+
});
|
|
1028
|
+
return `Shared ${file.name} in the chat; people open, preview and save it from its card.`;
|
|
1029
|
+
}
|
|
1030
|
+
case "file-get": {
|
|
1031
|
+
const { bytes, name } = await api.download(withQuery(`/cli/files/${encodeURIComponent(command.id)}/content`, command.group));
|
|
1032
|
+
return `Saved ${await saveSharedFile(bytes, name ?? command.id, { out: command.out, agentDir })}`;
|
|
1033
|
+
}
|
|
1034
|
+
case "file-list": {
|
|
1035
|
+
const { files } = await api("GET", withQuery("/cli/files", command.group));
|
|
1036
|
+
return files.map((f) => `${f.id} ${f.name}${f.title ? ` "${f.title}"` : ""} ${f.size} by ${f.sharedBy} ${when(f.createdAt)}`).join(`
|
|
1037
|
+
`) || "No files shared in this chat yet.";
|
|
1038
|
+
}
|
|
1039
|
+
case "browser": {
|
|
1040
|
+
const { action, input, group } = command;
|
|
1041
|
+
let body = { ...input, groupId: group };
|
|
1042
|
+
if (action === "run") {
|
|
1043
|
+
const code = command.code === "-" ? await stdin() : await readFile3(command.code, "utf8").catch((err) => Promise.reject(new UsageError(`Can't read ${command.code}: ${err.message}`)));
|
|
1044
|
+
if (!code.trim())
|
|
1045
|
+
throw new UsageError("The code is empty");
|
|
1046
|
+
body = { ...body, code };
|
|
1047
|
+
}
|
|
1048
|
+
const { result, stdout } = await api("POST", `/cli/browser/${action}`, body);
|
|
1049
|
+
if (action === "close")
|
|
1050
|
+
return "Closed this chat's tab.";
|
|
1051
|
+
if (action === "screenshot") {
|
|
1052
|
+
const shot = result;
|
|
1053
|
+
return `Screenshot of ${shot.title || shot.url} (${shot.url}): ${await writeShot(shot.image, command.out)}. Read it to see the page.`;
|
|
1054
|
+
}
|
|
1055
|
+
if (action === "run") {
|
|
1056
|
+
const printed = result === undefined || result === null ? "(returned nothing)" : typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
1057
|
+
return stdout ? `${stdout.trimEnd()}
|
|
1058
|
+
${printed}` : printed;
|
|
1059
|
+
}
|
|
1060
|
+
return formatPage(result);
|
|
1061
|
+
}
|
|
1062
|
+
case "trigger-apps": {
|
|
1063
|
+
const { toolkits } = await api("GET", withQuery("/cli/triggers/apps", command.group));
|
|
1064
|
+
return toolkits.map(formatToolkit).join(`
|
|
1065
|
+
|
|
1066
|
+
`) || "No apps to listen to yet.";
|
|
1067
|
+
}
|
|
1068
|
+
case "trigger-list": {
|
|
1069
|
+
const { triggers } = await api("GET", withQuery("/cli/triggers", command.group));
|
|
1070
|
+
return triggers.map(formatTrigger).join(`
|
|
1071
|
+
`) || "No triggers in this chat.";
|
|
1072
|
+
}
|
|
1073
|
+
case "trigger-add": {
|
|
1074
|
+
const { toolkit, triggerType, account, config, title, group } = command;
|
|
1075
|
+
const instructions2 = await instructionsOf(command);
|
|
1076
|
+
const { trigger } = await api("POST", "/cli/triggers", { groupId: group, toolkit, account, triggerType, config, title, instructions: instructions2 });
|
|
1077
|
+
return `Added ${triggerDetails(trigger)}`;
|
|
1078
|
+
}
|
|
1079
|
+
case "trigger-update": {
|
|
1080
|
+
const { id, title, config, group } = command;
|
|
1081
|
+
const instructions2 = await instructionsOf(command);
|
|
1082
|
+
const { trigger } = await api("PATCH", `/cli/triggers/${encodeURIComponent(id)}`, { groupId: group, title, instructions: instructions2, config });
|
|
1083
|
+
return `Updated ${triggerDetails(trigger)}`;
|
|
1084
|
+
}
|
|
1085
|
+
case "trigger-pause": {
|
|
1086
|
+
const { trigger } = await api("PATCH", `/cli/triggers/${encodeURIComponent(command.id)}`, { groupId: command.group, paused: command.paused });
|
|
1087
|
+
return `${command.paused ? "Paused; its events are dropped until you resume it" : "Resumed"}: ${formatTrigger(trigger)}`;
|
|
1088
|
+
}
|
|
1089
|
+
case "trigger-remove": {
|
|
1090
|
+
await api("DELETE", withQuery(`/cli/triggers/${encodeURIComponent(command.id)}`, command.group));
|
|
1091
|
+
return "Removed; its events stop coming.";
|
|
1092
|
+
}
|
|
1093
|
+
case "trigger-events": {
|
|
1094
|
+
const { events, batches } = await api("GET", withQuery(`/cli/triggers/${encodeURIComponent(command.id)}/events`, command.group, { since: command.since }));
|
|
1095
|
+
return formatEvents(events, batches);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/box/box.ts
|
|
1101
|
+
async function boxMain(argv, env) {
|
|
1102
|
+
try {
|
|
1103
|
+
const command = parseCommand(argv);
|
|
1104
|
+
if (command.name === "help") {
|
|
1105
|
+
console.log(HELP);
|
|
1106
|
+
return 0;
|
|
1107
|
+
}
|
|
1108
|
+
const group = command.group ?? env.MARTEN_GROUP_ID;
|
|
1109
|
+
if (!env.MARTEN_API_URL || !group)
|
|
1110
|
+
throw new UsageError("MARTEN_API_URL and MARTEN_GROUP_ID must be set (they are, inside a Marten's turn)");
|
|
1111
|
+
const chat = env.MARTEN_GROUP_ID ?? group;
|
|
1112
|
+
console.log(await runCommand({ ...command, group }, createApi({ apiUrl: env.MARTEN_API_URL, chat })));
|
|
1113
|
+
return 0;
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
if (err instanceof UsageError) {
|
|
1116
|
+
console.error(`marten: ${err.message}
|
|
1117
|
+
Run \`marten --help\` for usage.`);
|
|
1118
|
+
return 2;
|
|
1119
|
+
}
|
|
1120
|
+
if (err instanceof ApiError) {
|
|
1121
|
+
console.error(`marten: ${err.message}`);
|
|
1122
|
+
return 1;
|
|
1123
|
+
}
|
|
1124
|
+
throw err;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/context/context.ts
|
|
1129
|
+
function insideBox(env) {
|
|
1130
|
+
return Boolean(env.MARTEN_BOX_ID || env.MARTEN_GROUP_ID);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ../sdk/src/marten/marten.ts
|
|
1134
|
+
var DEFAULT_BASE_URL = "https://api.marten.sh";
|
|
1135
|
+
|
|
1136
|
+
class MartenError extends Error {
|
|
1137
|
+
status;
|
|
1138
|
+
code;
|
|
1139
|
+
retryAfter;
|
|
1140
|
+
constructor(status, code, message, opts = {}) {
|
|
1141
|
+
super(message);
|
|
1142
|
+
this.name = "MartenError";
|
|
1143
|
+
this.status = status;
|
|
1144
|
+
this.code = code;
|
|
1145
|
+
this.retryAfter = opts.retryAfter ?? null;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
function retryAfterOf(header) {
|
|
1149
|
+
if (!header)
|
|
1150
|
+
return null;
|
|
1151
|
+
const seconds = Number(header);
|
|
1152
|
+
if (Number.isFinite(seconds))
|
|
1153
|
+
return Math.max(0, seconds);
|
|
1154
|
+
const at = Date.parse(header);
|
|
1155
|
+
return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - Date.now()) / 1000));
|
|
1156
|
+
}
|
|
1157
|
+
function env(name) {
|
|
1158
|
+
try {
|
|
1159
|
+
return globalThis.process?.env?.[name];
|
|
1160
|
+
} catch {
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
var query = (params) => {
|
|
1165
|
+
const entries = Object.entries(params).filter((e) => e[1] !== undefined);
|
|
1166
|
+
return entries.length ? `?${new URLSearchParams(entries.map(([k, v]) => [k, String(v)]))}` : "";
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
class MartenHandle {
|
|
1170
|
+
id;
|
|
1171
|
+
name;
|
|
1172
|
+
chatId;
|
|
1173
|
+
info;
|
|
1174
|
+
#client;
|
|
1175
|
+
constructor(client, info) {
|
|
1176
|
+
this.#client = client;
|
|
1177
|
+
this.info = info;
|
|
1178
|
+
this.id = info.id;
|
|
1179
|
+
this.name = info.name;
|
|
1180
|
+
this.chatId = info.chatId;
|
|
1181
|
+
}
|
|
1182
|
+
ask(text, opts = {}) {
|
|
1183
|
+
return this.#client.request("POST", `/v1/martens/${this.id}/ask`, { text, ...opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {} });
|
|
1184
|
+
}
|
|
1185
|
+
send(text, opts = {}) {
|
|
1186
|
+
return this.#client.chats.send(this.chatId, text, opts);
|
|
1187
|
+
}
|
|
1188
|
+
messages(opts = {}) {
|
|
1189
|
+
return this.#client.chats.messages(this.chatId, opts);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
class Marten {
|
|
1194
|
+
baseUrl;
|
|
1195
|
+
#apiKey;
|
|
1196
|
+
#fetch;
|
|
1197
|
+
constructor(options = {}) {
|
|
1198
|
+
const apiKey = options.apiKey ?? env("MARTEN_API_KEY");
|
|
1199
|
+
if (!apiKey)
|
|
1200
|
+
throw new MartenError(0, "no_api_key", "Pass an API key (new Marten({ apiKey })) or set MARTEN_API_KEY; make one with `npx @martenhq/cli login`");
|
|
1201
|
+
this.#apiKey = apiKey;
|
|
1202
|
+
this.baseUrl = (options.baseUrl ?? env("MARTEN_API_URL") ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
1203
|
+
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
1204
|
+
}
|
|
1205
|
+
async request(method, path, body) {
|
|
1206
|
+
let res;
|
|
1207
|
+
try {
|
|
1208
|
+
res = await this.#fetch(`${this.baseUrl}${path}`, {
|
|
1209
|
+
method,
|
|
1210
|
+
headers: { authorization: `Bearer ${this.#apiKey}`, accept: "application/json", ...body === undefined ? {} : { "content-type": "application/json" } },
|
|
1211
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
1212
|
+
});
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
throw new MartenError(0, "network", `Can't reach Marten at ${this.baseUrl}: ${err.message}`);
|
|
1215
|
+
}
|
|
1216
|
+
const data = await res.json().catch(() => null);
|
|
1217
|
+
if (res.ok)
|
|
1218
|
+
return data;
|
|
1219
|
+
const error = typeof data?.error === "object" ? data.error : { code: data?.error, message: data?.message };
|
|
1220
|
+
throw new MartenError(res.status, error?.code ?? "http_error", error?.message ?? `Marten answered ${res.status}`, { retryAfter: retryAfterOf(res.headers.get("retry-after")) });
|
|
1221
|
+
}
|
|
1222
|
+
me() {
|
|
1223
|
+
return this.request("GET", "/v1/me");
|
|
1224
|
+
}
|
|
1225
|
+
async revokeKey() {
|
|
1226
|
+
await this.request("DELETE", "/v1/key");
|
|
1227
|
+
}
|
|
1228
|
+
martens = {
|
|
1229
|
+
list: async () => (await this.request("GET", "/v1/martens")).martens,
|
|
1230
|
+
get: async (nameOrId) => {
|
|
1231
|
+
const list = await this.martens.list();
|
|
1232
|
+
const found = pick(list, nameOrId, (m) => m.name, "Marten");
|
|
1233
|
+
if (!found.chatId)
|
|
1234
|
+
throw new MartenError(404, "not_found", `${found.name} has no 1:1 with you`);
|
|
1235
|
+
return new MartenHandle(this, found);
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
chats = {
|
|
1239
|
+
list: async () => (await this.request("GET", "/v1/chats")).chats,
|
|
1240
|
+
messages: (chatId, opts = {}) => this.request("GET", `/v1/chats/${encodeURIComponent(chatId)}/messages${query({ limit: opts.limit, before: opts.before })}`),
|
|
1241
|
+
send: async (chatId, text, opts = {}) => (await this.request("POST", `/v1/chats/${encodeURIComponent(chatId)}/messages`, { text, ...opts })).message
|
|
1242
|
+
};
|
|
1243
|
+
tasks = {
|
|
1244
|
+
list: async (opts = {}) => (await this.request("GET", `/v1/tasks${query({ chat: opts.chatId, status: opts.status })}`)).tasks,
|
|
1245
|
+
create: async (task) => (await this.request("POST", "/v1/tasks", task)).task,
|
|
1246
|
+
update: async (taskId, changes) => (await this.request("PATCH", `/v1/tasks/${encodeURIComponent(taskId)}`, changes)).task
|
|
1247
|
+
};
|
|
1248
|
+
registry = {
|
|
1249
|
+
list: async () => (await this.request("GET", "/v1/registry")).listings,
|
|
1250
|
+
get: async (slug) => (await this.request("GET", `/v1/registry/${encodeURIComponent(slug)}`)).listing,
|
|
1251
|
+
install: (slug, target = "you", opts = {}) => this.request("POST", `/v1/registry/${encodeURIComponent(slug)}/install`, {
|
|
1252
|
+
...target === "you" ? {} : { chatId: target.chatId },
|
|
1253
|
+
timezone: opts.timezone ?? localZone()
|
|
1254
|
+
})
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
function localZone() {
|
|
1258
|
+
try {
|
|
1259
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
1260
|
+
} catch {
|
|
1261
|
+
return "UTC";
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
function pick(list, wanted, name, what) {
|
|
1265
|
+
const w = wanted.trim().toLowerCase();
|
|
1266
|
+
const byId = list.find((x) => x.id.toLowerCase() === w);
|
|
1267
|
+
if (byId)
|
|
1268
|
+
return byId;
|
|
1269
|
+
const exact = list.filter((x) => name(x).toLowerCase() === w);
|
|
1270
|
+
const matches = exact.length ? exact : list.filter((x) => name(x).toLowerCase().startsWith(w));
|
|
1271
|
+
if (matches.length === 1)
|
|
1272
|
+
return matches[0];
|
|
1273
|
+
if (matches.length > 1)
|
|
1274
|
+
throw new MartenError(409, "ambiguous", `Several match "${wanted}": ${matches.map(name).join(", ")}; use an id`);
|
|
1275
|
+
throw new MartenError(404, "not_found", `No ${what} called "${wanted}"${list.length ? ` (yours: ${list.map(name).join(", ")})` : ""}`);
|
|
1276
|
+
}
|
|
1277
|
+
// src/credentials/credentials.ts
|
|
1278
|
+
import { chmod, mkdir as mkdir5, readFile as readFile4, rm, writeFile as writeFile5 } from "node:fs/promises";
|
|
1279
|
+
import { dirname as dirname4, join as join5 } from "node:path";
|
|
1280
|
+
var DEFAULT_API_URL = "https://api.marten.sh";
|
|
1281
|
+
function credentialsPath(env2, home) {
|
|
1282
|
+
return join5(env2.XDG_CONFIG_HOME || join5(home, ".config"), "marten", "credentials");
|
|
1283
|
+
}
|
|
1284
|
+
async function readCredentials(path) {
|
|
1285
|
+
const text = await readFile4(path, "utf8").catch(() => null);
|
|
1286
|
+
if (!text)
|
|
1287
|
+
return null;
|
|
1288
|
+
try {
|
|
1289
|
+
const parsed = JSON.parse(text);
|
|
1290
|
+
return parsed.apiKey && parsed.apiUrl ? parsed : null;
|
|
1291
|
+
} catch {
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
async function writeCredentials(path, credentials) {
|
|
1296
|
+
await mkdir5(dirname4(path), { recursive: true, mode: 448 });
|
|
1297
|
+
await writeFile5(path, `${JSON.stringify(credentials, null, 2)}
|
|
1298
|
+
`, { mode: 384 });
|
|
1299
|
+
await chmod(path, 384);
|
|
1300
|
+
}
|
|
1301
|
+
async function removeCredentials(path) {
|
|
1302
|
+
await rm(path, { force: true });
|
|
1303
|
+
}
|
|
1304
|
+
function resolveAuth(env2, stored) {
|
|
1305
|
+
const apiKey = env2.MARTEN_API_KEY || stored?.apiKey;
|
|
1306
|
+
if (!apiKey)
|
|
1307
|
+
return null;
|
|
1308
|
+
return { apiKey, apiUrl: env2.MARTEN_API_URL || stored?.apiUrl || DEFAULT_API_URL };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// src/login/login.ts
|
|
1312
|
+
async function call(doFetch, url, body, token) {
|
|
1313
|
+
let res;
|
|
1314
|
+
try {
|
|
1315
|
+
res = await doFetch(url, { method: "POST", headers: { "content-type": "application/json", ...token ? { authorization: `Bearer ${token}` } : {} }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
1316
|
+
} catch (err) {
|
|
1317
|
+
throw new MartenError(0, "network", `Can't reach Marten at ${new URL(url).origin}: ${err.message}`);
|
|
1318
|
+
}
|
|
1319
|
+
return { status: res.status, data: await res.json().catch(() => ({})) };
|
|
1320
|
+
}
|
|
1321
|
+
var refused = (status, data) => new MartenError(status, data.error ?? "http_error", data.message ?? `Marten answered ${status}`);
|
|
1322
|
+
async function login(opts) {
|
|
1323
|
+
const doFetch = opts.fetch ?? ((input, init) => fetch(input, init));
|
|
1324
|
+
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
1325
|
+
const email = (opts.email ?? await opts.io.ask("Email: ", new AbortController().signal)).trim();
|
|
1326
|
+
if (!email)
|
|
1327
|
+
throw new MartenError(400, "invalid", "An email is needed to sign in");
|
|
1328
|
+
const started = await call(doFetch, `${base}/auth/magic-link`, { email });
|
|
1329
|
+
if (started.status !== 201)
|
|
1330
|
+
throw refused(started.status, started.data);
|
|
1331
|
+
const { requestId, pollSecret } = started.data;
|
|
1332
|
+
opts.io.say(`We emailed ${email} a sign-in link and a 6-digit code. Type the code here, or open the link on any device.`);
|
|
1333
|
+
const stop = new AbortController;
|
|
1334
|
+
const byLink = (async () => {
|
|
1335
|
+
while (!stop.signal.aborted) {
|
|
1336
|
+
await new Promise((resolve3) => {
|
|
1337
|
+
const timer = setTimeout(resolve3, opts.pollMs ?? 2000);
|
|
1338
|
+
stop.signal.addEventListener("abort", () => (clearTimeout(timer), resolve3()), { once: true });
|
|
1339
|
+
});
|
|
1340
|
+
if (stop.signal.aborted)
|
|
1341
|
+
return null;
|
|
1342
|
+
const polled = await call(doFetch, `${base}/auth/poll`, { requestId, pollSecret });
|
|
1343
|
+
if (polled.status === 200)
|
|
1344
|
+
return polled.data;
|
|
1345
|
+
if (polled.status !== 425) {
|
|
1346
|
+
if (stop.signal.aborted)
|
|
1347
|
+
return null;
|
|
1348
|
+
throw refused(polled.status, polled.data);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
return null;
|
|
1352
|
+
})();
|
|
1353
|
+
const byCode = (async () => {
|
|
1354
|
+
for (;; ) {
|
|
1355
|
+
const code = (await opts.io.ask("Code: ", stop.signal).catch(() => null))?.replace(/\s+/g, "");
|
|
1356
|
+
if (code === undefined || stop.signal.aborted)
|
|
1357
|
+
return null;
|
|
1358
|
+
if (!/^\d{6,8}$/.test(code)) {
|
|
1359
|
+
opts.io.say("The code is the 6 digits in the email.");
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
const verified = await call(doFetch, `${base}/auth/code`, { requestId, pollSecret, code });
|
|
1363
|
+
if (verified.status === 200)
|
|
1364
|
+
return verified.data;
|
|
1365
|
+
if (verified.status !== 401)
|
|
1366
|
+
throw refused(verified.status, verified.data);
|
|
1367
|
+
opts.io.say("That code didn't work; try again, or open the link.");
|
|
1368
|
+
}
|
|
1369
|
+
})();
|
|
1370
|
+
let session;
|
|
1371
|
+
try {
|
|
1372
|
+
session = await new Promise((resolve3, reject) => {
|
|
1373
|
+
for (const way of [byLink, byCode])
|
|
1374
|
+
way.then((s) => s && resolve3(s), reject);
|
|
1375
|
+
});
|
|
1376
|
+
} finally {
|
|
1377
|
+
stop.abort();
|
|
1378
|
+
}
|
|
1379
|
+
const keyName = `${opts.machine} · CLI`;
|
|
1380
|
+
const made = await call(doFetch, `${base}/api-keys`, { name: keyName, ...opts.scopes?.length ? { scopes: opts.scopes } : {} }, session.sessionToken);
|
|
1381
|
+
if (made.status !== 201)
|
|
1382
|
+
throw refused(made.status, made.data);
|
|
1383
|
+
await call(doFetch, `${base}/auth/logout`, undefined, session.sessionToken).catch(() => {
|
|
1384
|
+
return;
|
|
1385
|
+
});
|
|
1386
|
+
return { apiUrl: base, apiKey: made.data.token, keyId: made.data.key.id, keyName: made.data.key.name, email: session.user.email };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/mcp-server/mcp-server.ts
|
|
1390
|
+
var VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
1391
|
+
var str = (args, key) => typeof args[key] === "string" && args[key].trim() ? args[key].trim() : undefined;
|
|
1392
|
+
var need = (args, key) => {
|
|
1393
|
+
const value = str(args, key);
|
|
1394
|
+
if (!value)
|
|
1395
|
+
throw new ToolInputError(`${key} is required`);
|
|
1396
|
+
return value;
|
|
1397
|
+
};
|
|
1398
|
+
var defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
1399
|
+
|
|
1400
|
+
class ToolInputError extends Error {
|
|
1401
|
+
}
|
|
1402
|
+
var taskProps = {
|
|
1403
|
+
assignee: { type: "string", description: "me, or a person or Marten in the chat by name or id" },
|
|
1404
|
+
due: { type: "string", description: 'friday, "tomorrow 9am", 2026-10-31, "2026-10-31 09:00" (in timezone) or an ISO time; none clears it' },
|
|
1405
|
+
repeat: { type: "string", description: '"every weekday at 9", "every 2 weeks on friday", daily, an RRULE, or none' },
|
|
1406
|
+
timezone: { type: "string", description: "IANA zone for due and repeat (default UTC)" },
|
|
1407
|
+
description: { type: "string", description: "Markdown; - [ ] items are a checklist" }
|
|
1408
|
+
};
|
|
1409
|
+
var taskOptions = (args) => {
|
|
1410
|
+
const none = (v) => v === undefined ? undefined : v.toLowerCase() === "none" ? null : v;
|
|
1411
|
+
return defined({ assignee: none(str(args, "assignee")), due: none(str(args, "due")), repeat: str(args, "repeat"), timezone: str(args, "timezone"), description: str(args, "description") });
|
|
1412
|
+
};
|
|
1413
|
+
var TOOLS = [
|
|
1414
|
+
{ name: "marten_list_martens", description: "List your Martens (your own agents in Marten), each with its 1:1's chat id.", inputSchema: { type: "object", properties: {} }, command: () => ({ name: "martens" }) },
|
|
1415
|
+
{
|
|
1416
|
+
name: "marten_ask",
|
|
1417
|
+
description: "Ask one of your Martens something in its 1:1 with you and wait for its reply (up to 120 s). It has its own memory, tasks, connected apps and tools.",
|
|
1418
|
+
inputSchema: { type: "object", properties: { marten: { type: "string", description: "Its name or id" }, text: { type: "string" }, timeoutSeconds: { type: "number" } }, required: ["marten", "text"] },
|
|
1419
|
+
command: (a) => ({ name: "ask", marten: need(a, "marten"), text: need(a, "text"), timeoutMs: Math.round(Math.min(Math.max(typeof a.timeoutSeconds === "number" ? a.timeoutSeconds : 120, 1), 120) * 1000) })
|
|
1420
|
+
},
|
|
1421
|
+
{ name: "marten_list_chats", description: "List your chats: your Martens' 1:1s, and your groups if the key may read them.", inputSchema: { type: "object", properties: {} }, command: () => ({ name: "chats" }) },
|
|
1422
|
+
{
|
|
1423
|
+
name: "marten_read_chat",
|
|
1424
|
+
description: "Read a chat's recent messages, oldest first.",
|
|
1425
|
+
inputSchema: { type: "object", properties: { chat: { type: "string", description: "The chat's title or id" }, limit: { type: "number", description: "1 to 200 (default 20)" } }, required: ["chat"] },
|
|
1426
|
+
command: (a) => ({ name: "chat-read", chat: need(a, "chat"), limit: typeof a.limit === "number" ? Math.min(Math.max(Math.round(a.limit), 1), 200) : 20 })
|
|
1427
|
+
},
|
|
1428
|
+
{
|
|
1429
|
+
name: "marten_send_message",
|
|
1430
|
+
description: "Post a message in a chat as the person (shown as sent via this key). It wakes the Martens a message from them would.",
|
|
1431
|
+
inputSchema: { type: "object", properties: { chat: { type: "string", description: "The chat's title or id" }, text: { type: "string" } }, required: ["chat", "text"] },
|
|
1432
|
+
command: (a) => ({ name: "chat-send", chat: need(a, "chat"), text: need(a, "text") })
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
name: "marten_list_tasks",
|
|
1436
|
+
description: "List tasks in the person's chats (open ones and those done in the last 30 days).",
|
|
1437
|
+
inputSchema: { type: "object", properties: { chat: { type: "string" }, status: { type: "string", enum: ["todo", "in_progress", "needs_review", "done", "open"] } } },
|
|
1438
|
+
command: (a) => ({ name: "task-list", ...defined({ chat: str(a, "chat"), status: str(a, "status") }) })
|
|
1439
|
+
},
|
|
1440
|
+
{
|
|
1441
|
+
name: "marten_add_task",
|
|
1442
|
+
description: "Add a task to a chat; a Marten assignee starts on it at once or at its time.",
|
|
1443
|
+
inputSchema: { type: "object", properties: { title: { type: "string" }, chat: { type: "string", description: "The chat's title or id (optional with one 1:1)" }, ...taskProps }, required: ["title"] },
|
|
1444
|
+
command: (a) => ({ name: "task-add", title: need(a, "title"), ...defined({ chat: str(a, "chat") }), ...taskOptions(a) })
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
name: "marten_update_task",
|
|
1448
|
+
description: "Change a task: its title, status, assignee, time or repeat.",
|
|
1449
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["todo", "in_progress", "needs_review", "done"] }, ...taskProps }, required: ["id"] },
|
|
1450
|
+
command: (a) => ({ name: "task-update", id: need(a, "id"), ...defined({ title: str(a, "title"), status: str(a, "status") }), ...taskOptions(a) })
|
|
1451
|
+
},
|
|
1452
|
+
{ name: "marten_registry_list", description: "List the Martens anyone can install from the registry.", inputSchema: { type: "object", properties: {} }, command: () => ({ name: "registry-list" }) },
|
|
1453
|
+
{
|
|
1454
|
+
name: "marten_registry_show",
|
|
1455
|
+
description: "Show a registry listing: what it does, its instructions, starter tasks, sketches and the apps it needs.",
|
|
1456
|
+
inputSchema: { type: "object", properties: { slug: { type: "string" } }, required: ["slug"] },
|
|
1457
|
+
command: (a) => ({ name: "registry-show", slug: need(a, "slug") })
|
|
1458
|
+
},
|
|
1459
|
+
{
|
|
1460
|
+
name: "marten_registry_install",
|
|
1461
|
+
description: "Install a registry listing as a Marten of the person's own, or add it to one of their groups.",
|
|
1462
|
+
inputSchema: { type: "object", properties: { slug: { type: "string" }, group: { type: "string", description: "A group's title or id; leave out to install it for the person" } }, required: ["slug"] },
|
|
1463
|
+
command: (a) => ({ name: "registry-install", slug: need(a, "slug"), ...defined({ group: str(a, "group") }) })
|
|
1464
|
+
}
|
|
1465
|
+
];
|
|
1466
|
+
var reply = (id, result) => ({ jsonrpc: "2.0", id: id ?? null, result });
|
|
1467
|
+
var fail = (id, code, message) => ({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
|
1468
|
+
async function handleMcp(raw, run, serverVersion) {
|
|
1469
|
+
let request;
|
|
1470
|
+
try {
|
|
1471
|
+
request = JSON.parse(raw);
|
|
1472
|
+
} catch {
|
|
1473
|
+
return fail(null, -32700, "Parse error");
|
|
1474
|
+
}
|
|
1475
|
+
const isNotification = request.id === undefined;
|
|
1476
|
+
switch (request.method) {
|
|
1477
|
+
case "initialize":
|
|
1478
|
+
return reply(request.id, {
|
|
1479
|
+
protocolVersion: VERSIONS.find((v) => v === request.params?.protocolVersion) ?? VERSIONS[0],
|
|
1480
|
+
capabilities: { tools: {} },
|
|
1481
|
+
serverInfo: { name: "marten", version: serverVersion },
|
|
1482
|
+
instructions: "Marten is a messenger where each person has their own AI agents (Martens). These tools act as the person: ask their Martens, read and post in their chats, manage tasks."
|
|
1483
|
+
});
|
|
1484
|
+
case "ping":
|
|
1485
|
+
return reply(request.id, {});
|
|
1486
|
+
case "tools/list":
|
|
1487
|
+
return reply(request.id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
1488
|
+
case "tools/call": {
|
|
1489
|
+
const tool = TOOLS.find((t) => t.name === request.params?.name);
|
|
1490
|
+
if (!tool)
|
|
1491
|
+
return fail(request.id, -32602, `Unknown tool: ${String(request.params?.name)}`);
|
|
1492
|
+
try {
|
|
1493
|
+
const output = await run(tool.command(request.params?.arguments ?? {}));
|
|
1494
|
+
return reply(request.id, { content: [{ type: "text", text: JSON.stringify(output.data, null, 2) }] });
|
|
1495
|
+
} catch (err) {
|
|
1496
|
+
return reply(request.id, { content: [{ type: "text", text: err.message }], isError: true });
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
default:
|
|
1500
|
+
return isNotification ? null : fail(request.id, -32601, `Method not found: ${request.method}`);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
async function serveMcp(lines, write, run, serverVersion) {
|
|
1504
|
+
const inFlight = new Set;
|
|
1505
|
+
for await (const line of lines) {
|
|
1506
|
+
if (!line.trim())
|
|
1507
|
+
continue;
|
|
1508
|
+
const answering = handleMcp(line, run, serverVersion).then((answer) => {
|
|
1509
|
+
if (answer)
|
|
1510
|
+
write(JSON.stringify(answer));
|
|
1511
|
+
});
|
|
1512
|
+
inFlight.add(answering);
|
|
1513
|
+
answering.finally(() => inFlight.delete(answering));
|
|
1514
|
+
}
|
|
1515
|
+
await Promise.all(inFlight);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// src/outside-args/outside-args.ts
|
|
1519
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
1520
|
+
var SCOPES = ["martens", "chats:read", "chats:write", "tasks", "registry"];
|
|
1521
|
+
var TASK_USAGE = ` marten task list [--chat <chat>] [--status <s>] [--tz <zone>]
|
|
1522
|
+
Your chats' tasks (--status todo, in_progress, needs_review, done or open);
|
|
1523
|
+
times in --tz (default: this machine's zone)
|
|
1524
|
+
marten task add <title> [--chat <chat>] [options]
|
|
1525
|
+
Add a task (--chat may be left out when you have one 1:1)
|
|
1526
|
+
--assignee <me|name|id> --due <when> --repeat <rule> --tz <zone> --description <text> --remind
|
|
1527
|
+
--due takes friday, "tomorrow 9am", 2026-10-31 or an ISO time; --repeat
|
|
1528
|
+
"every weekday at 9" or an RRULE; both in --tz (default: this machine's zone)
|
|
1529
|
+
marten task update <id> [--title] [--status] [--assignee|none] [--due|none] [--repeat|none] [--tz] [--description]`;
|
|
1530
|
+
var REGISTRY_USAGE = ` marten registry list Martens anyone can install
|
|
1531
|
+
marten registry show <slug> A listing: what it does, its tasks, sketches and what it needs
|
|
1532
|
+
marten registry install <slug> [--group <chat>]
|
|
1533
|
+
Install it for you, or add it to one of your groups (a chats:write key)`;
|
|
1534
|
+
var OUTSIDE_HELP = `marten — talk to your Martens from your terminal and your other agents
|
|
1535
|
+
|
|
1536
|
+
Usage:
|
|
1537
|
+
marten login [--email <you@…>] [--scope <scope>…]
|
|
1538
|
+
Sign in (an email with a link and a 6-digit code); keeps an API key for
|
|
1539
|
+
this machine in ~/.config/marten/credentials. Scopes: martens, tasks and
|
|
1540
|
+
registry by default; add chats:read and chats:write for your groups
|
|
1541
|
+
marten logout Revoke this machine's key and forget it
|
|
1542
|
+
marten whoami Who you're signed in as, and the key's scopes
|
|
1543
|
+
marten martens Your Martens, each with its 1:1's chat id
|
|
1544
|
+
marten ask <marten> <text|-> [--timeout <seconds>]
|
|
1545
|
+
Ask one of your Martens (by name or id) in its 1:1 and wait for its reply
|
|
1546
|
+
(default 120 s; on timeout, exit 1 with the message id; - reads stdin)
|
|
1547
|
+
marten chats Your chats (your Martens' 1:1s; groups with a chats:read key)
|
|
1548
|
+
marten chat read <chat> [--limit N] A chat's recent messages (default 20, max 200), by title or id
|
|
1549
|
+
marten chat send <chat> <text|-> Post as you ("via <key>" under it); a group needs a chats:write key
|
|
1550
|
+
${TASK_USAGE}
|
|
1551
|
+
${REGISTRY_USAGE}
|
|
1552
|
+
marten mcp Serve these commands as MCP tools over stdio (for Claude Code, Codex…)
|
|
1553
|
+
marten setup claude|codex Teach Claude Code (a skill) or Codex (AGENTS.md) to use marten
|
|
1554
|
+
marten --version This CLI's version
|
|
1555
|
+
|
|
1556
|
+
Every command takes --json (one JSON document on stdout; errors as {"error":{"code","message"}} on stderr).
|
|
1557
|
+
MARTEN_API_KEY and MARTEN_API_URL override the stored key and https://api.marten.sh.
|
|
1558
|
+
Exit codes: 0 ok, 1 Marten refused or failed, 2 bad usage.`;
|
|
1559
|
+
var OPTIONS2 = {
|
|
1560
|
+
json: { type: "boolean" },
|
|
1561
|
+
help: { type: "boolean", short: "h" },
|
|
1562
|
+
version: { type: "boolean", short: "v" },
|
|
1563
|
+
email: { type: "string" },
|
|
1564
|
+
scope: { type: "string", multiple: true },
|
|
1565
|
+
timeout: { type: "string" },
|
|
1566
|
+
limit: { type: "string" },
|
|
1567
|
+
chat: { type: "string" },
|
|
1568
|
+
group: { type: "string" },
|
|
1569
|
+
status: { type: "string" },
|
|
1570
|
+
title: { type: "string" },
|
|
1571
|
+
assignee: { type: "string" },
|
|
1572
|
+
due: { type: "string" },
|
|
1573
|
+
repeat: { type: "string" },
|
|
1574
|
+
tz: { type: "string" },
|
|
1575
|
+
description: { type: "string" },
|
|
1576
|
+
remind: { type: "boolean" }
|
|
1577
|
+
};
|
|
1578
|
+
var STATUSES2 = ["todo", "in_progress", "needs_review", "done"];
|
|
1579
|
+
var MAX_ASK_SECONDS = 120;
|
|
1580
|
+
function argsProblem(err) {
|
|
1581
|
+
const message = err.message;
|
|
1582
|
+
const option = /'(-{1,2}[^' ]+)/.exec(message)?.[1];
|
|
1583
|
+
const code = err.code;
|
|
1584
|
+
if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" && option)
|
|
1585
|
+
return new UsageError(`Unknown option ${option}. See marten --help.`);
|
|
1586
|
+
if (code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE" && option)
|
|
1587
|
+
return new UsageError(`${option} needs a value. See marten --help.`);
|
|
1588
|
+
return new UsageError(`${message.split(/\.\s/)[0].replace(/\.$/, "")}. See marten --help.`);
|
|
1589
|
+
}
|
|
1590
|
+
function words2(rest, usage) {
|
|
1591
|
+
const text = rest.join(" ").trim();
|
|
1592
|
+
if (!text)
|
|
1593
|
+
throw new UsageError(`Missing the text: ${usage} (or - to read it from stdin)`);
|
|
1594
|
+
return text;
|
|
1595
|
+
}
|
|
1596
|
+
function status(value, allowed) {
|
|
1597
|
+
if (value === undefined)
|
|
1598
|
+
return;
|
|
1599
|
+
if (!allowed.includes(value))
|
|
1600
|
+
throw new UsageError(`--status must be one of ${allowed.join(", ")}`);
|
|
1601
|
+
return value;
|
|
1602
|
+
}
|
|
1603
|
+
function taskOptions2(values) {
|
|
1604
|
+
const none = (v) => v === undefined ? undefined : v.trim().toLowerCase() === "none" ? null : v.trim();
|
|
1605
|
+
const options = { description: values.description, assignee: none(values.assignee), due: none(values.due), repeat: values.repeat?.trim(), timezone: values.tz?.trim(), remind: values.remind };
|
|
1606
|
+
return Object.fromEntries(Object.entries(options).filter(([, v]) => v !== undefined));
|
|
1607
|
+
}
|
|
1608
|
+
function parseOutside(argv) {
|
|
1609
|
+
let parsed;
|
|
1610
|
+
try {
|
|
1611
|
+
parsed = parseArgs2({ args: argv, options: OPTIONS2, allowPositionals: true, strict: true });
|
|
1612
|
+
} catch (err) {
|
|
1613
|
+
throw argsProblem(err);
|
|
1614
|
+
}
|
|
1615
|
+
const values = parsed.values;
|
|
1616
|
+
const json = values.json === true;
|
|
1617
|
+
const [first, second, ...rest] = parsed.positionals;
|
|
1618
|
+
const done = (command) => ({ command, json });
|
|
1619
|
+
const none = (extra, usage) => {
|
|
1620
|
+
if (extra.some((x) => x !== undefined))
|
|
1621
|
+
throw new UsageError(`Too many arguments: ${usage}`);
|
|
1622
|
+
};
|
|
1623
|
+
if (values.version)
|
|
1624
|
+
return done({ name: "version" });
|
|
1625
|
+
if (values.help || !first || first === "help")
|
|
1626
|
+
return done({ name: "help" });
|
|
1627
|
+
switch (first) {
|
|
1628
|
+
case "login": {
|
|
1629
|
+
none([second], "marten login [--email <address>] [--scope <scope>…]");
|
|
1630
|
+
const scopes = values.scope?.map((s) => s.trim()).filter(Boolean);
|
|
1631
|
+
const unknown = scopes?.find((s) => !SCOPES.includes(s));
|
|
1632
|
+
if (unknown)
|
|
1633
|
+
throw new UsageError(`Unknown scope ${unknown}: use ${SCOPES.join(", ")}`);
|
|
1634
|
+
return done({ name: "login", ...values.email?.trim() ? { email: values.email.trim() } : {}, ...scopes?.length ? { scopes } : {} });
|
|
1635
|
+
}
|
|
1636
|
+
case "logout":
|
|
1637
|
+
case "whoami":
|
|
1638
|
+
case "martens":
|
|
1639
|
+
case "chats":
|
|
1640
|
+
case "mcp":
|
|
1641
|
+
none([second], `marten ${first}`);
|
|
1642
|
+
return done({ name: first });
|
|
1643
|
+
case "ask": {
|
|
1644
|
+
const usage = "marten ask <marten> <text|-> [--timeout <seconds>]";
|
|
1645
|
+
if (!second?.trim())
|
|
1646
|
+
throw new UsageError(`Missing the Marten: ${usage} (see \`marten martens\`)`);
|
|
1647
|
+
const seconds = values.timeout === undefined ? MAX_ASK_SECONDS : Number(values.timeout);
|
|
1648
|
+
if (!Number.isFinite(seconds) || seconds < 1 || seconds > MAX_ASK_SECONDS)
|
|
1649
|
+
throw new UsageError(`--timeout is seconds, from 1 to ${MAX_ASK_SECONDS}`);
|
|
1650
|
+
return done({ name: "ask", marten: second.trim(), text: words2(rest, usage), timeoutMs: Math.round(seconds * 1000) });
|
|
1651
|
+
}
|
|
1652
|
+
case "chat": {
|
|
1653
|
+
const [chat, ...text] = rest;
|
|
1654
|
+
if (second === "read") {
|
|
1655
|
+
if (!chat)
|
|
1656
|
+
throw new UsageError("Missing the chat: marten chat read <chat> [--limit N] (see `marten chats`)");
|
|
1657
|
+
none(text, "marten chat read <chat> [--limit N]");
|
|
1658
|
+
const limit = values.limit === undefined ? 20 : Number(values.limit);
|
|
1659
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
1660
|
+
throw new UsageError("--limit must be a whole number from 1 to 200");
|
|
1661
|
+
return done({ name: "chat-read", chat, limit });
|
|
1662
|
+
}
|
|
1663
|
+
if (second === "send") {
|
|
1664
|
+
if (!chat)
|
|
1665
|
+
throw new UsageError("Missing the chat: marten chat send <chat> <text|-> (see `marten chats`)");
|
|
1666
|
+
return done({ name: "chat-send", chat, text: words2(text, "marten chat send <chat> <text|->") });
|
|
1667
|
+
}
|
|
1668
|
+
if (second === undefined)
|
|
1669
|
+
return done({ name: "usage", text: ` marten chat read <chat> [--limit N]
|
|
1670
|
+
marten chat send <chat> <text|->` });
|
|
1671
|
+
throw new UsageError(`Unknown command: chat ${second}`);
|
|
1672
|
+
}
|
|
1673
|
+
case "task":
|
|
1674
|
+
if (second === undefined)
|
|
1675
|
+
return done({ name: "usage", text: TASK_USAGE });
|
|
1676
|
+
return done(parseTask2(second, rest, values));
|
|
1677
|
+
case "registry": {
|
|
1678
|
+
if (second === undefined)
|
|
1679
|
+
return done({ name: "usage", text: REGISTRY_USAGE });
|
|
1680
|
+
if (second === "list") {
|
|
1681
|
+
none([rest[0]], "marten registry list");
|
|
1682
|
+
return done({ name: "registry-list" });
|
|
1683
|
+
}
|
|
1684
|
+
if (second === "show" || second === "install") {
|
|
1685
|
+
const usage = second === "show" ? "marten registry show <slug>" : "marten registry install <slug> [--group <chat>]";
|
|
1686
|
+
if (!rest[0]?.trim())
|
|
1687
|
+
throw new UsageError(`Missing the listing: ${usage} (see \`marten registry list\`)`);
|
|
1688
|
+
none([rest[1]], usage);
|
|
1689
|
+
const slug = rest[0].trim();
|
|
1690
|
+
if (second === "show")
|
|
1691
|
+
return done({ name: "registry-show", slug });
|
|
1692
|
+
return done({ name: "registry-install", slug, ...values.group?.trim() ? { group: values.group.trim() } : {} });
|
|
1693
|
+
}
|
|
1694
|
+
throw new UsageError(`Unknown command: registry ${second}`);
|
|
1695
|
+
}
|
|
1696
|
+
case "setup":
|
|
1697
|
+
if (second !== "claude" && second !== "codex")
|
|
1698
|
+
throw new UsageError("marten setup claude|codex");
|
|
1699
|
+
none([rest[0]], `marten setup ${second}`);
|
|
1700
|
+
return done({ name: "setup", agent: second });
|
|
1701
|
+
default:
|
|
1702
|
+
throw new UsageError(`Unknown command: ${first}${INSIDE_ONLY.has(first) ? " (that's a Marten's command, inside its box)" : ""}`);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
var INSIDE_ONLY = new Set(["reply", "tell-owner", "end-conversation", "browser", "members", "sketch", "app", "file", "trigger"]);
|
|
1706
|
+
function parseTask2(sub, rest, values) {
|
|
1707
|
+
const chat = values.chat?.trim() || undefined;
|
|
1708
|
+
switch (sub) {
|
|
1709
|
+
case "list":
|
|
1710
|
+
if (rest.length)
|
|
1711
|
+
throw new UsageError("Too many arguments: marten task list [--chat <chat>] [--status <s>]");
|
|
1712
|
+
return {
|
|
1713
|
+
name: "task-list",
|
|
1714
|
+
...chat ? { chat } : {},
|
|
1715
|
+
...values.status ? { status: status(values.status, [...STATUSES2, "open"]) } : {},
|
|
1716
|
+
...values.tz?.trim() ? { timezone: values.tz.trim() } : {}
|
|
1717
|
+
};
|
|
1718
|
+
case "add":
|
|
1719
|
+
return { name: "task-add", title: words2(rest, "marten task add <title> [--chat <chat>]"), ...chat ? { chat } : {}, ...taskOptions2(values) };
|
|
1720
|
+
case "update": {
|
|
1721
|
+
const [id, ...extra] = rest;
|
|
1722
|
+
const usage = "marten task update <id> [--title] [--status] [--assignee] [--due] [--repeat] [--tz] [--description]";
|
|
1723
|
+
if (!id?.trim())
|
|
1724
|
+
throw new UsageError(`Missing the task id: ${usage} (see \`marten task list\`)`);
|
|
1725
|
+
if (extra.length)
|
|
1726
|
+
throw new UsageError(`Too many arguments: ${usage} (quote the title)`);
|
|
1727
|
+
if (values.title !== undefined && !values.title.trim())
|
|
1728
|
+
throw new UsageError("--title can't be empty");
|
|
1729
|
+
const changes = { ...taskOptions2(values), ...values.title ? { title: values.title.trim() } : {}, ...values.status ? { status: status(values.status, STATUSES2) } : {} };
|
|
1730
|
+
const { timezone: _tz, ...real } = changes;
|
|
1731
|
+
if (!Object.keys(real).length)
|
|
1732
|
+
throw new UsageError("Nothing to change: pass --title, --status, --assignee, --due, --repeat, --description or --remind");
|
|
1733
|
+
return { name: "task-update", id: id.trim(), ...changes };
|
|
1734
|
+
}
|
|
1735
|
+
default:
|
|
1736
|
+
throw new UsageError(`Unknown command: task ${sub}`);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// src/outside-commands/outside-commands.ts
|
|
1741
|
+
var out = (text, data) => ({ text, data });
|
|
1742
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1743
|
+
var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
1744
|
+
async function textOf(text, deps) {
|
|
1745
|
+
if (text !== "-")
|
|
1746
|
+
return text;
|
|
1747
|
+
const read = (await deps.readStdin()).trim();
|
|
1748
|
+
if (!read)
|
|
1749
|
+
throw new UsageError("Nothing came on stdin");
|
|
1750
|
+
return read;
|
|
1751
|
+
}
|
|
1752
|
+
async function chatOf(marten, wanted) {
|
|
1753
|
+
const name = wanted.trim();
|
|
1754
|
+
if (UUID.test(name))
|
|
1755
|
+
return { id: name, title: name };
|
|
1756
|
+
const chats = await marten.chats.list();
|
|
1757
|
+
const lower = name.toLowerCase();
|
|
1758
|
+
const exact = chats.filter((c) => c.title.toLowerCase() === lower);
|
|
1759
|
+
const matches = exact.length ? exact : chats.filter((c) => c.title.toLowerCase().startsWith(lower));
|
|
1760
|
+
if (matches.length === 1)
|
|
1761
|
+
return matches[0];
|
|
1762
|
+
if (matches.length > 1)
|
|
1763
|
+
throw new UsageError(`Several chats match "${name}": ${matches.map((c) => `${c.title} (${c.id})`).join(", ")}; use an id`);
|
|
1764
|
+
const { key } = await marten.me();
|
|
1765
|
+
if (!key.scopes.includes("chats:read")) {
|
|
1766
|
+
throw new MartenError(404, "not_found", `No chat called "${name}" among your Martens' 1:1s. Groups need a key with the chats:read scope: marten login --scope chats:read`);
|
|
1767
|
+
}
|
|
1768
|
+
throw new MartenError(404, "not_found", `No chat called "${name}" (see \`marten chats\`)`);
|
|
1769
|
+
}
|
|
1770
|
+
async function taskChat(marten, wanted) {
|
|
1771
|
+
if (wanted)
|
|
1772
|
+
return chatOf(marten, wanted);
|
|
1773
|
+
const directs = (await marten.chats.list()).filter((c) => c.kind === "direct");
|
|
1774
|
+
if (directs.length === 1)
|
|
1775
|
+
return directs[0];
|
|
1776
|
+
throw new UsageError(`Say which chat with --chat <chat> (${directs.map((c) => c.title).join(", ") || "see `marten chats`"})`);
|
|
1777
|
+
}
|
|
1778
|
+
var messageTime = (iso) => iso.replace("T", " ").slice(0, 16);
|
|
1779
|
+
function messageLine(m) {
|
|
1780
|
+
const [firstLine, ...more] = m.text.split(`
|
|
1781
|
+
`);
|
|
1782
|
+
const head = `[${messageTime(m.at)}] `;
|
|
1783
|
+
if (m.author.kind === "system")
|
|
1784
|
+
return [`${head}— ${firstLine}`, ...more.map((l) => ` ${l}`)].join(`
|
|
1785
|
+
`);
|
|
1786
|
+
const via = m.viaKey ? ` (via ${m.viaKey})` : "";
|
|
1787
|
+
return [`${head}${m.author.name}${via}: ${firstLine}`, ...more.map((l) => ` ${l}`)].join(`
|
|
1788
|
+
`);
|
|
1789
|
+
}
|
|
1790
|
+
function zoned(iso, timeZone) {
|
|
1791
|
+
let zone = timeZone;
|
|
1792
|
+
let format;
|
|
1793
|
+
try {
|
|
1794
|
+
format = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
1795
|
+
} catch {
|
|
1796
|
+
zone = "UTC";
|
|
1797
|
+
format = new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
1798
|
+
}
|
|
1799
|
+
const p = Object.fromEntries(format.formatToParts(new Date(iso)).map((x) => [x.type, x.value]));
|
|
1800
|
+
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute} ${zone}`;
|
|
1801
|
+
}
|
|
1802
|
+
function taskLine(t, timeZone) {
|
|
1803
|
+
const time = t.scheduledAt ? ` · ${zoned(t.scheduledAt, t.repeat?.timezone ?? timeZone)}` : t.due ? ` · due ${t.due}` : "";
|
|
1804
|
+
const repeat = t.repeat ? ` · ${t.repeat.text}` : "";
|
|
1805
|
+
return `${t.id} [${t.status}] ${t.title}${time}${repeat}`;
|
|
1806
|
+
}
|
|
1807
|
+
async function runOutside(command, deps) {
|
|
1808
|
+
const { marten } = deps;
|
|
1809
|
+
switch (command.name) {
|
|
1810
|
+
case "whoami": {
|
|
1811
|
+
const me = await marten.me();
|
|
1812
|
+
return out(`${me.user.displayName} <${me.user.email}>, with the key "${me.key.name}" (${me.key.scopes.join(", ")})`, me);
|
|
1813
|
+
}
|
|
1814
|
+
case "martens": {
|
|
1815
|
+
const list = await marten.martens.list();
|
|
1816
|
+
return out(list.length ? list.map((m) => `${m.name} ${m.kind} ${m.id} chat ${m.chatId ?? "—"}`).join(`
|
|
1817
|
+
`) : "You have no Martens yet: open Marten to make one.", { martens: list });
|
|
1818
|
+
}
|
|
1819
|
+
case "ask": {
|
|
1820
|
+
const handle = await marten.martens.get(command.marten);
|
|
1821
|
+
const result = await handle.ask(await textOf(command.text, deps), { timeoutMs: command.timeoutMs });
|
|
1822
|
+
if (result.status === "timeout") {
|
|
1823
|
+
throw new MartenError(408, "timeout", `${handle.name} is still working on it; its answer will land in your 1:1 (your message ${result.message.id}): marten chat read ${handle.chatId}`);
|
|
1824
|
+
}
|
|
1825
|
+
return out(result.reply?.text ?? `${handle.name} read it and didn't reply.`, result);
|
|
1826
|
+
}
|
|
1827
|
+
case "chats": {
|
|
1828
|
+
const chats = await marten.chats.list();
|
|
1829
|
+
const line = (c) => c.kind === "direct" ? `${c.title} (1:1) ${c.id}` : `${c.title} (group: ${plural(c.members.length, "person", "people")}, ${plural(c.martens.length, "Marten")}) ${c.id}`;
|
|
1830
|
+
return out(chats.map(line).join(`
|
|
1831
|
+
`) || "No chats.", { chats });
|
|
1832
|
+
}
|
|
1833
|
+
case "chat-read": {
|
|
1834
|
+
const chat = await chatOf(marten, command.chat);
|
|
1835
|
+
const page = await marten.chats.messages(chat.id, { limit: command.limit });
|
|
1836
|
+
return out(page.messages.map(messageLine).join(`
|
|
1837
|
+
`) || "No messages yet.", page);
|
|
1838
|
+
}
|
|
1839
|
+
case "chat-send": {
|
|
1840
|
+
const chat = await chatOf(marten, command.chat);
|
|
1841
|
+
const message = await marten.chats.send(chat.id, await textOf(command.text, deps));
|
|
1842
|
+
return out(`Sent to ${chat.title} (message ${message.id})`, { message });
|
|
1843
|
+
}
|
|
1844
|
+
case "task-list": {
|
|
1845
|
+
const chat = command.chat ? await chatOf(marten, command.chat) : undefined;
|
|
1846
|
+
const tasks = await marten.tasks.list({ chatId: chat?.id, status: command.status });
|
|
1847
|
+
const zone = command.timezone ?? deps.timeZone;
|
|
1848
|
+
return out(tasks.map((t) => taskLine(t, zone)).join(`
|
|
1849
|
+
`) || "No tasks.", { tasks });
|
|
1850
|
+
}
|
|
1851
|
+
case "task-add": {
|
|
1852
|
+
const { name: _name, chat: wanted, ...input } = command;
|
|
1853
|
+
const chat = await taskChat(marten, wanted);
|
|
1854
|
+
const timezone = input.timezone ?? deps.timeZone;
|
|
1855
|
+
const task = await marten.tasks.create({ ...input, timezone, assignee: input.assignee ?? undefined, chatId: chat.id });
|
|
1856
|
+
return out(`Added to ${chat.title}: ${taskLine(task, timezone)}`, { task });
|
|
1857
|
+
}
|
|
1858
|
+
case "task-update": {
|
|
1859
|
+
const { name: _name, id, ...changes } = command;
|
|
1860
|
+
const timezone = changes.timezone ?? deps.timeZone;
|
|
1861
|
+
const task = await marten.tasks.update(id, { ...changes, timezone });
|
|
1862
|
+
return out(taskLine(task, timezone), { task });
|
|
1863
|
+
}
|
|
1864
|
+
case "registry-list": {
|
|
1865
|
+
const listings = await marten.registry.list();
|
|
1866
|
+
return out(listings.map((l) => `${l.slug} ${l.name} — ${l.summary} (for ${l.for.join(", ")})`).join(`
|
|
1867
|
+
`) || "The registry is empty.", { listings });
|
|
1868
|
+
}
|
|
1869
|
+
case "registry-show": {
|
|
1870
|
+
const l = await marten.registry.get(command.slug);
|
|
1871
|
+
const needs = [...l.needs.apps.map((a) => `${a.app} (${a.why})`), ...l.needs.optional.map((a) => `${a.app}, optional (${a.why})`)];
|
|
1872
|
+
const text = [
|
|
1873
|
+
`${l.name} ${l.version} — ${l.summary}`,
|
|
1874
|
+
`For: ${l.for.join(", ")}`,
|
|
1875
|
+
needs.length ? `Needs: ${needs.join("; ")}` : null,
|
|
1876
|
+
l.tasks.length ? `Starts with: ${l.tasks.map((t) => t.title).join("; ")}` : null,
|
|
1877
|
+
l.sketches.length ? `Sketches: ${l.sketches.map((s) => s.name).join(", ")}` : null,
|
|
1878
|
+
l.examples.length ? `Try: ${l.examples.map((e) => `"${e}"`).join(", ")}` : null,
|
|
1879
|
+
"",
|
|
1880
|
+
l.instructions.trim()
|
|
1881
|
+
];
|
|
1882
|
+
return out(text.filter((x) => x !== null).join(`
|
|
1883
|
+
`), { listing: l });
|
|
1884
|
+
}
|
|
1885
|
+
case "registry-install": {
|
|
1886
|
+
const chat = command.group ? await chatOf(marten, command.group) : undefined;
|
|
1887
|
+
if (chat?.kind === "direct")
|
|
1888
|
+
throw new UsageError(`${chat.title} is a 1:1; --group takes one of your groups`);
|
|
1889
|
+
const installed = await marten.registry.install(command.slug, chat ? { chatId: chat.id } : "you", { timezone: deps.timeZone });
|
|
1890
|
+
return out(chat ? `Added ${installed.marten.name} to ${chat.title}` : `Installed ${installed.marten.name}: say hello in its 1:1 (chat ${installed.chatId})`, installed);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// src/setup/setup.ts
|
|
1896
|
+
import { mkdir as mkdir6, readFile as readFile5, writeFile as writeFile6 } from "node:fs/promises";
|
|
1897
|
+
import { dirname as dirname5, join as join6 } from "node:path";
|
|
1898
|
+
var GUIDE = `Marten is the person's messenger where their own AI agents ("Martens") live: each Marten has a 1:1 chat with
|
|
1899
|
+
them, its own memory, connected apps (Gmail, Linear, GitHub…), tasks and sketches. The \`marten\` CLI acts as the
|
|
1900
|
+
person, with their API key (\`marten login\` once, or \`MARTEN_API_KEY\`).
|
|
1901
|
+
|
|
1902
|
+
Use it when the person mentions their Martens or Marten, wants something done by one of them, or wants a task or a
|
|
1903
|
+
message in one of their chats.
|
|
1904
|
+
|
|
1905
|
+
- \`marten martens\`: their Martens, each with its 1:1's chat id
|
|
1906
|
+
- \`marten ask <marten> "<question>"\`: ask one and wait for its reply (\`--timeout <s>\`, up to 120)
|
|
1907
|
+
- \`marten chats\`, \`marten chat read <chat> [--limit N]\`, \`marten chat send <chat> "<text>"\` (posts as the person,
|
|
1908
|
+
marked as sent through this key; only when they asked you to)
|
|
1909
|
+
- \`marten task list [--chat <chat>] [--status open]\`, \`marten task add "<title>" [--chat <chat>] [--assignee me|<name>]
|
|
1910
|
+
[--due "friday 9am"] [--repeat "every weekday at 9"] [--tz Europe/Berlin]\`, \`marten task update <id> --status done\`
|
|
1911
|
+
- \`marten registry list\`, \`marten registry show <slug>\`, \`marten registry install <slug> [--group <chat>]\`
|
|
1912
|
+
|
|
1913
|
+
Add \`--json\` to any command for one JSON document on stdout. Exit codes: 0 ok, 1 Marten refused or failed (the
|
|
1914
|
+
message says why), 2 a mistake in the command. \`marten mcp\` serves the same as MCP tools.`;
|
|
1915
|
+
var SKILL = `---
|
|
1916
|
+
name: marten
|
|
1917
|
+
description: Talk to the person's Martens (their AI agents in the Marten messenger) and manage their Marten chats and tasks with the marten CLI. Use when they mention Marten or one of their Martens, or want a task or message in Marten.
|
|
1918
|
+
---
|
|
1919
|
+
|
|
1920
|
+
# Marten
|
|
1921
|
+
|
|
1922
|
+
${GUIDE}
|
|
1923
|
+
`;
|
|
1924
|
+
var START = "<!-- marten:start -->";
|
|
1925
|
+
var END = "<!-- marten:end -->";
|
|
1926
|
+
var SECTION = `${START}
|
|
1927
|
+
## Marten
|
|
1928
|
+
|
|
1929
|
+
${GUIDE}
|
|
1930
|
+
${END}`;
|
|
1931
|
+
async function setupAgent(agent, home) {
|
|
1932
|
+
if (agent === "claude") {
|
|
1933
|
+
const path2 = join6(home, ".claude", "skills", "marten", "SKILL.md");
|
|
1934
|
+
await mkdir6(dirname5(path2), { recursive: true });
|
|
1935
|
+
await writeFile6(path2, SKILL);
|
|
1936
|
+
return { path: path2, content: SKILL };
|
|
1937
|
+
}
|
|
1938
|
+
const path = join6(home, ".codex", "AGENTS.md");
|
|
1939
|
+
await mkdir6(dirname5(path), { recursive: true });
|
|
1940
|
+
const existing = await readFile5(path, "utf8").catch(() => "");
|
|
1941
|
+
const start = existing.indexOf(START);
|
|
1942
|
+
const end = existing.indexOf(END);
|
|
1943
|
+
const next = start >= 0 && end > start ? `${existing.slice(0, start)}${SECTION}${existing.slice(end + END.length)}` : `${existing}${existing && !existing.endsWith(`
|
|
1944
|
+
|
|
1945
|
+
`) ? existing.endsWith(`
|
|
1946
|
+
`) ? `
|
|
1947
|
+
` : `
|
|
1948
|
+
|
|
1949
|
+
` : ""}${SECTION}
|
|
1950
|
+
`;
|
|
1951
|
+
await writeFile6(path, next);
|
|
1952
|
+
return { path, content: SECTION };
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// src/outside/outside.ts
|
|
1956
|
+
async function outsideMain(argv, io) {
|
|
1957
|
+
let json = argv.includes("--json");
|
|
1958
|
+
const fail2 = (code, message, exit) => {
|
|
1959
|
+
const hint = exit === 2 && !message.includes("marten --help") ? "\nRun `marten --help` for usage." : "";
|
|
1960
|
+
io.stderr(json ? JSON.stringify({ error: { code, message } }) : `marten: ${message}${hint}`);
|
|
1961
|
+
return exit;
|
|
1962
|
+
};
|
|
1963
|
+
try {
|
|
1964
|
+
const parsed = parseOutside(argv);
|
|
1965
|
+
json = parsed.json;
|
|
1966
|
+
const output = await run(parsed.command, io);
|
|
1967
|
+
if (output)
|
|
1968
|
+
io.stdout(json ? JSON.stringify(output.data, null, 2) : output.text);
|
|
1969
|
+
return 0;
|
|
1970
|
+
} catch (err) {
|
|
1971
|
+
if (err instanceof UsageError)
|
|
1972
|
+
return fail2("usage", err.message, 2);
|
|
1973
|
+
if (err instanceof MartenError)
|
|
1974
|
+
return fail2(err.code, err.message, 1);
|
|
1975
|
+
throw err;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
async function run(command, io) {
|
|
1979
|
+
const path = credentialsPath(io.env, io.home);
|
|
1980
|
+
switch (command.name) {
|
|
1981
|
+
case "help":
|
|
1982
|
+
return { text: OUTSIDE_HELP, data: { help: OUTSIDE_HELP } };
|
|
1983
|
+
case "version":
|
|
1984
|
+
return { text: io.version, data: { version: io.version } };
|
|
1985
|
+
case "usage":
|
|
1986
|
+
return { text: `Usage:
|
|
1987
|
+
${command.text}`, data: { usage: command.text } };
|
|
1988
|
+
case "setup": {
|
|
1989
|
+
const { path: wrote, content } = await setupAgent(command.agent, io.home);
|
|
1990
|
+
const next = command.agent === "claude" ? `
|
|
1991
|
+
For MCP instead: claude mcp add marten -- marten mcp` : `
|
|
1992
|
+
For MCP instead: codex mcp add marten -- marten mcp`;
|
|
1993
|
+
return { text: `Wrote ${wrote}:
|
|
1994
|
+
|
|
1995
|
+
${content}${next}`, data: { path: wrote, content } };
|
|
1996
|
+
}
|
|
1997
|
+
case "login": {
|
|
1998
|
+
const previous = await readCredentials(path);
|
|
1999
|
+
const apiUrl = io.env.MARTEN_API_URL || previous?.apiUrl || DEFAULT_API_URL;
|
|
2000
|
+
const credentials = await login({ apiUrl, email: command.email, scopes: command.scopes, machine: io.machine, io, fetch: io.fetch });
|
|
2001
|
+
if (previous && previous.apiKey !== credentials.apiKey)
|
|
2002
|
+
await revoke(io, previous);
|
|
2003
|
+
await writeCredentials(path, credentials);
|
|
2004
|
+
return { text: `Signed in as ${credentials.email}. This machine's key, "${credentials.keyName}", is in ${path}.`, data: { email: credentials.email, keyId: credentials.keyId, keyName: credentials.keyName, path } };
|
|
2005
|
+
}
|
|
2006
|
+
case "logout": {
|
|
2007
|
+
const stored = await readCredentials(path);
|
|
2008
|
+
if (!stored)
|
|
2009
|
+
return { text: io.env.MARTEN_API_KEY ? "Nothing stored here; MARTEN_API_KEY is set by your environment." : "You weren't signed in.", data: { signedOut: false } };
|
|
2010
|
+
await revoke(io, stored);
|
|
2011
|
+
await removeCredentials(path);
|
|
2012
|
+
return { text: `Signed out: the key "${stored.keyName}" is revoked and ${path} removed.`, data: { signedOut: true } };
|
|
2013
|
+
}
|
|
2014
|
+
case "mcp": {
|
|
2015
|
+
const stored = await readCredentials(path);
|
|
2016
|
+
const run2 = (c) => runApi(c, { marten: client(io, stored), readStdin: async () => "", timeZone: io.timeZone });
|
|
2017
|
+
await serveMcp(io.stdinLines(), (line) => io.stdout(line), run2, io.version);
|
|
2018
|
+
return null;
|
|
2019
|
+
}
|
|
2020
|
+
default:
|
|
2021
|
+
return runApi(command, { marten: client(io, await readCredentials(path)), readStdin: io.readStdin, timeZone: io.timeZone });
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
async function revoke(io, stored) {
|
|
2025
|
+
await new Marten({ apiKey: stored.apiKey, baseUrl: stored.apiUrl, fetch: io.fetch }).revokeKey().catch((err) => {
|
|
2026
|
+
if (!(err instanceof MartenError && err.status === 401))
|
|
2027
|
+
throw err;
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
function runApi(command, deps) {
|
|
2031
|
+
switch (command.name) {
|
|
2032
|
+
case "help":
|
|
2033
|
+
case "version":
|
|
2034
|
+
case "usage":
|
|
2035
|
+
case "login":
|
|
2036
|
+
case "logout":
|
|
2037
|
+
case "mcp":
|
|
2038
|
+
case "setup":
|
|
2039
|
+
throw new UsageError(`marten ${command.name} isn't a tool`);
|
|
2040
|
+
default:
|
|
2041
|
+
return runOutside(command, deps);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
function client(io, stored) {
|
|
2045
|
+
const auth = resolveAuth(io.env, stored);
|
|
2046
|
+
if (!auth)
|
|
2047
|
+
throw new MartenError(401, "signed_out", "Not signed in: run `marten login` (or `npx @martenhq/cli login`), or set MARTEN_API_KEY");
|
|
2048
|
+
return new Marten({ apiKey: auth.apiKey, baseUrl: auth.apiUrl, fetch: io.fetch });
|
|
2049
|
+
}
|
|
2050
|
+
// package.json
|
|
2051
|
+
var package_default = {
|
|
2052
|
+
name: "@martenhq/cli",
|
|
2053
|
+
version: "0.1.0",
|
|
2054
|
+
description: "marten: talk to your Martens from your terminal, scripts and other agents (Claude Code, Codex), and the CLI Martens use inside their boxes.",
|
|
2055
|
+
license: "MIT",
|
|
2056
|
+
type: "module",
|
|
2057
|
+
homepage: "https://marten.sh",
|
|
2058
|
+
repository: { type: "git", url: "git+https://github.com/RedPlanetHQ/marten-app.git", directory: "packages/cli" },
|
|
2059
|
+
keywords: ["marten", "cli", "agents", "mcp"],
|
|
2060
|
+
engines: { node: ">=20" },
|
|
2061
|
+
bin: { marten: "dist/marten.js" },
|
|
2062
|
+
files: ["dist/marten.js", "README.md", "LICENSE"],
|
|
2063
|
+
publishConfig: { access: "public" },
|
|
2064
|
+
scripts: {
|
|
2065
|
+
build: "bun build.ts",
|
|
2066
|
+
test: "bun test",
|
|
2067
|
+
typecheck: "tsc -p tsconfig.json"
|
|
2068
|
+
},
|
|
2069
|
+
devDependencies: {
|
|
2070
|
+
"@martenhq/sdk": "workspace:*",
|
|
2071
|
+
"@types/bun": "^1.4.2"
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2074
|
+
|
|
2075
|
+
// src/marten.ts
|
|
2076
|
+
function machineName() {
|
|
2077
|
+
if (process.platform === "darwin") {
|
|
2078
|
+
try {
|
|
2079
|
+
const name = execFileSync("scutil", ["--get", "ComputerName"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2080
|
+
if (name)
|
|
2081
|
+
return name;
|
|
2082
|
+
} catch {}
|
|
2083
|
+
}
|
|
2084
|
+
const host = hostname().split(".")[0];
|
|
2085
|
+
return host || `${userInfo().username}'s computer`;
|
|
2086
|
+
}
|
|
2087
|
+
async function readStdin2() {
|
|
2088
|
+
const chunks = [];
|
|
2089
|
+
for await (const chunk of process.stdin)
|
|
2090
|
+
chunks.push(chunk);
|
|
2091
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2092
|
+
}
|
|
2093
|
+
function terminal() {
|
|
2094
|
+
let rl = null;
|
|
2095
|
+
const lines = [];
|
|
2096
|
+
const waiting = [];
|
|
2097
|
+
let ended = false;
|
|
2098
|
+
const open = () => {
|
|
2099
|
+
if (rl)
|
|
2100
|
+
return;
|
|
2101
|
+
rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
2102
|
+
rl.on("line", (line) => waiting.length ? waiting.shift().resolve(line) : lines.push(line));
|
|
2103
|
+
rl.on("close", () => {
|
|
2104
|
+
ended = true;
|
|
2105
|
+
for (const w of waiting.splice(0))
|
|
2106
|
+
w.reject(new Error("stdin closed"));
|
|
2107
|
+
});
|
|
2108
|
+
};
|
|
2109
|
+
const ask = (question, signal) => new Promise((resolve3, reject) => {
|
|
2110
|
+
if (signal.aborted)
|
|
2111
|
+
return reject(new Error("aborted"));
|
|
2112
|
+
open();
|
|
2113
|
+
process.stderr.write(question);
|
|
2114
|
+
if (lines.length)
|
|
2115
|
+
return resolve3(lines.shift());
|
|
2116
|
+
if (ended)
|
|
2117
|
+
return reject(new Error("stdin closed"));
|
|
2118
|
+
const waiter = {
|
|
2119
|
+
resolve: (line) => (signal.removeEventListener("abort", onAbort), resolve3(line)),
|
|
2120
|
+
reject: (err) => (signal.removeEventListener("abort", onAbort), reject(err))
|
|
2121
|
+
};
|
|
2122
|
+
const onAbort = () => {
|
|
2123
|
+
waiting.splice(waiting.indexOf(waiter), 1);
|
|
2124
|
+
process.stderr.write(`
|
|
2125
|
+
`);
|
|
2126
|
+
reject(new Error("aborted"));
|
|
2127
|
+
};
|
|
2128
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2129
|
+
waiting.push(waiter);
|
|
2130
|
+
});
|
|
2131
|
+
const close = () => {
|
|
2132
|
+
rl?.close();
|
|
2133
|
+
process.stdin.destroy();
|
|
2134
|
+
};
|
|
2135
|
+
return { ask, close };
|
|
2136
|
+
}
|
|
2137
|
+
var argv = process.argv.slice(2);
|
|
2138
|
+
if (insideBox(process.env)) {
|
|
2139
|
+
process.exitCode = await boxMain(argv, process.env);
|
|
2140
|
+
} else {
|
|
2141
|
+
const term = terminal();
|
|
2142
|
+
process.exitCode = await outsideMain(argv, {
|
|
2143
|
+
env: process.env,
|
|
2144
|
+
home: homedir(),
|
|
2145
|
+
machine: machineName(),
|
|
2146
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
2147
|
+
version: package_default.version,
|
|
2148
|
+
stdout: (text) => console.log(text),
|
|
2149
|
+
stderr: (text) => console.error(text),
|
|
2150
|
+
say: (line) => console.error(line),
|
|
2151
|
+
ask: term.ask,
|
|
2152
|
+
readStdin: readStdin2,
|
|
2153
|
+
stdinLines: () => createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
2154
|
+
});
|
|
2155
|
+
term.close();
|
|
2156
|
+
}
|