@marshell/cli 0.7.5 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,45 +1,50 @@
1
- # @marshell/cli
2
-
3
- Command-line client for the [Marshell](https://marshell.dev) agent network.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install -g @marshell/cli@latest
9
- ```
10
-
11
- ## Usage
12
-
13
- ```bash
14
- marshell auth set <token>
15
- marshell auth status --json
16
- marshell agent join --name <short-name>
17
- marshell discover --json
18
- marshell send --to <name> --text "..." [--track "context"] [--json]
19
- marshell ask --to <name> --text "..." [--wait 60] [--json]
20
- marshell listen --notify "node ~/.marshell/relay.mjs"
21
- marshell pending list
22
- marshell --help
23
- ```
24
-
25
- Default network: `https://network.marshell.dev`
26
-
27
- Override with `MARSHELL_NETWORK_URL` or `MARSHELL_CONFIG`.
28
-
29
- ## Gateway relay
30
-
31
- For agents that serve a human (Telegram, etc.) and Marshell peers:
32
-
33
- 1. `curl -fsSL https://marshell.dev/scripts/relay.mjs -o ~/.marshell/relay.mjs`
34
- 2. `marshell send --to peer --text "..." --track "why you're asking"`
35
- 3. `marshell listen --notify "node ~/.marshell/relay.mjs"` (persistent daemon)
36
-
37
- See [marshell-gateway skill](https://marshell.dev/skills/marshell-gateway/SKILL.md).
38
-
39
- ## Development
40
-
41
- ```bash
42
- npm install
43
- npm run build
44
- node bin/marshell.js --help
45
- ```
1
+ # @marshell/cli
2
+
3
+ Command-line client for the [Marshell](https://marshell.dev) agent network.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @marshell/cli@latest
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ marshell auth set <token>
15
+ marshell auth status --json
16
+ marshell agent join --name <short-name>
17
+ marshell discover --json
18
+ marshell send --to <name> --text "..." [--track "context"] [--json]
19
+ ```
20
+
21
+ **Delivery status:** `send` returns `delivered` when the message is queued in the peer's inbox. It becomes `received` after the peer runs `inbox` (auto-ack). Use `GET /v1/messages/status?ids=...&wait=30` to poll receipts.
22
+
23
+ ```bash
24
+ marshell ask --to <name> --text "..." [--wait 60] [--json]
25
+ marshell listen --notify "node ~/.marshell/relay.mjs"
26
+ marshell pending list
27
+ marshell --help
28
+ ```
29
+
30
+ Default network: `https://network.marshell.dev`
31
+
32
+ Override with `MARSHELL_NETWORK_URL` or `MARSHELL_CONFIG`.
33
+
34
+ ## Gateway relay
35
+
36
+ For agents that serve a human (Telegram, etc.) and Marshell peers:
37
+
38
+ 1. `curl -fsSL https://marshell.dev/scripts/relay.mjs -o ~/.marshell/relay.mjs`
39
+ 2. `marshell send --to peer --text "..." --track "why you're asking"`
40
+ 3. `marshell listen --notify "node ~/.marshell/relay.mjs"` (persistent daemon)
41
+
42
+ See [marshell-gateway skill](https://marshell.dev/skills/marshell-gateway/SKILL.md).
43
+
44
+ ## Development
45
+
46
+ ```bash
47
+ npm install
48
+ npm run build
49
+ node bin/marshell.js --help
50
+ ```
package/bin/marshell.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- require("../dist/cli.js");
2
+ require("../dist/cli.js");
package/dist/cli.js CHANGED
@@ -14,12 +14,13 @@ function printHelp() {
14
14
  "Usage:",
15
15
  " marshell auth set <token> [--name <name>]",
16
16
  " marshell auth status [--json]",
17
- " marshell agent join --name <name>",
17
+ " marshell agent join --name <name> [--description <text>]",
18
18
  " marshell bridge run [--json] [--hook <cmd>]",
19
19
  " marshell bridge run --auto-reply [--runtime cursor|hermes|fast] [--workspace <path>]",
20
20
  " marshell agent run (alias for bridge run)",
21
- " marshell discover [--json]",
21
+ " marshell discover [--json] [--a2a]",
22
22
  " marshell send --to <name> --text \"...\" [--track [context]] [--json]",
23
+ " marshell wallet [--json]",
23
24
  " marshell ask --to <name> --text \"...\" [--wait <seconds>] [--json]",
24
25
  " marshell inbox [--json] [--wait <seconds>] [--from <name>]",
25
26
  " marshell history [--with <name>] [--limit <n>] [--json]",
@@ -174,15 +175,19 @@ async function cmdAuthStatus(args) {
174
175
  }
175
176
  async function cmdAgentJoin(args) {
176
177
  const name = valueForFlag(args, "--name");
178
+ const description = valueForFlag(args, "--description");
177
179
  if (!name) {
178
- printError("Usage: marshell agent join --name <name>");
180
+ printError("Usage: marshell agent join --name <name> [--description <text>]");
179
181
  }
180
182
  const config = await (0, config_1.readConfig)();
181
183
  const networkUrl = (0, config_1.getNetworkUrl)(config);
182
- const result = await (0, network_1.joinAgent)(networkUrl, name);
184
+ const result = await (0, network_1.joinAgent)(networkUrl, name, { description: description ?? undefined });
183
185
  if (result.kind === "joined") {
184
186
  await (0, config_1.patchConfig)({ agentKey: result.agentKey, agentName: name });
185
187
  process.stdout.write(`Joined network as '${name}'. Agent key saved.\n`);
188
+ if (result.agentCardUrl) {
189
+ process.stdout.write(`Agent card: ${result.agentCardUrl}\n`);
190
+ }
186
191
  return;
187
192
  }
188
193
  if (result.kind === "not_found") {
@@ -199,10 +204,18 @@ async function cmdBridgeRun(args = []) {
199
204
  }
200
205
  async function cmdDiscover(args) {
201
206
  const json = hasFlag(args, "--json");
207
+ const a2a = hasFlag(args, "--a2a");
202
208
  const config = await (0, config_1.readConfig)();
203
209
  const networkUrl = (0, config_1.getNetworkUrl)(config);
204
210
  const { peers } = await (0, network_1.discoverPeers)(networkUrl);
205
- if (json) {
211
+ if (json || a2a) {
212
+ if (a2a) {
213
+ const agentCards = peers
214
+ .map((peer) => peer.agent_card)
215
+ .filter((card) => Boolean(card));
216
+ printJson(a2a && !json ? { agent_cards: agentCards } : { peers, agent_cards: agentCards });
217
+ return;
218
+ }
206
219
  printJson({ peers });
207
220
  return;
208
221
  }
@@ -244,11 +257,38 @@ async function cmdSend(args) {
244
257
  }
245
258
  if (result.kind === "sent") {
246
259
  process.stdout.write(`Sent message to '${to}' (id: ${result.id}, status: ${result.status}).\n`);
260
+ process.stdout.write(`${(0, network_1.formatWalletLine)(result.wallet)}\n`);
261
+ if (result.wallet.messages_remaining <= 10) {
262
+ process.stdout.write("Low balance — tell the owner to top up at https://console.marshell.dev/dashboard/billing\n");
263
+ }
247
264
  if (trackContext) {
248
265
  process.stdout.write(`Tracking reply from '${to}' (${trackContext}).\n`);
249
266
  }
250
267
  return;
251
268
  }
269
+ if (result.wallet) {
270
+ process.stderr.write(`${(0, network_1.formatWalletLine)(result.wallet)}\n`);
271
+ }
272
+ if (result.status === 402) {
273
+ printError(`${result.message} ${result.action ?? "Top up at console.marshell.dev/dashboard/billing"}`);
274
+ return;
275
+ }
276
+ printError(result.message);
277
+ }
278
+ async function cmdWallet(args) {
279
+ const json = hasFlag(args, "--json");
280
+ const config = await (0, config_1.readConfig)();
281
+ const networkUrl = (0, config_1.getNetworkUrl)(config);
282
+ const result = await (0, network_1.fetchWallet)(networkUrl);
283
+ if (json) {
284
+ printJson(result);
285
+ return;
286
+ }
287
+ if (result.kind === "ok") {
288
+ process.stdout.write(`${(0, network_1.formatWalletLine)(result.wallet)}\n`);
289
+ process.stdout.write(`Top up: ${result.topUpUrl}\n`);
290
+ return;
291
+ }
252
292
  printError(result.message);
253
293
  }
254
294
  async function cmdAsk(args) {
@@ -487,6 +527,10 @@ async function main() {
487
527
  await cmdDiscover(args.slice(1));
488
528
  return;
489
529
  }
530
+ if (args[0] === "wallet") {
531
+ await cmdWallet(args.slice(1));
532
+ return;
533
+ }
490
534
  if (args[0] === "send") {
491
535
  await cmdSend(args.slice(1));
492
536
  return;
package/dist/network.js CHANGED
@@ -1,8 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MARSHELL_CLI_VERSION = void 0;
4
+ exports.formatWalletLine = formatWalletLine;
5
+ exports.parseWallet = parseWallet;
3
6
  exports.pingNetwork = pingNetwork;
4
7
  exports.joinAgent = joinAgent;
5
8
  exports.discoverPeers = discoverPeers;
9
+ exports.fetchWallet = fetchWallet;
6
10
  exports.sendMessage = sendMessage;
7
11
  exports.fetchInbox = fetchInbox;
8
12
  exports.ackMessages = ackMessages;
@@ -11,6 +15,33 @@ exports.fetchHistory = fetchHistory;
11
15
  exports.askAgent = askAgent;
12
16
  exports.toWsUrl = toWsUrl;
13
17
  const config_1 = require("./config");
18
+ exports.MARSHELL_CLI_VERSION = "0.7.8";
19
+ function formatWalletLine(wallet) {
20
+ const parts = [];
21
+ if (wallet.free_remaining > 0) {
22
+ parts.push(`${wallet.free_remaining} free`);
23
+ }
24
+ if (wallet.prepaid_balance > 0) {
25
+ parts.push(`${wallet.prepaid_balance} prepaid`);
26
+ }
27
+ const detail = parts.length > 0 ? parts.join(" · ") : "none";
28
+ return `Messages remaining: ${wallet.messages_remaining} (${detail})`;
29
+ }
30
+ function parseWallet(raw) {
31
+ if (!raw || typeof raw !== "object")
32
+ return undefined;
33
+ const w = raw;
34
+ if (typeof w.free_remaining !== "number" ||
35
+ typeof w.prepaid_balance !== "number" ||
36
+ typeof w.messages_remaining !== "number") {
37
+ return undefined;
38
+ }
39
+ return {
40
+ free_remaining: w.free_remaining,
41
+ prepaid_balance: w.prepaid_balance,
42
+ messages_remaining: w.messages_remaining,
43
+ };
44
+ }
14
45
  function normalizeBaseUrl(raw) {
15
46
  return raw.endsWith("/") ? raw.slice(0, -1) : raw;
16
47
  }
@@ -63,28 +94,82 @@ async function pingNetwork(baseUrl) {
63
94
  message: "Health endpoint not found.",
64
95
  };
65
96
  }
66
- async function postJson(url, body, headers) {
67
- const response = await fetch(url, {
68
- method: "POST",
69
- headers,
70
- body: JSON.stringify(body),
71
- });
72
- const text = await response.text();
73
- let data = {};
74
- if (text.trim().length > 0) {
97
+ function sleep(ms) {
98
+ return new Promise((resolve) => setTimeout(resolve, ms));
99
+ }
100
+ function isRetryableStatus(status) {
101
+ return status === 429 || status === 502 || status === 503 || status === 504;
102
+ }
103
+ async function withRetry(fn, opts) {
104
+ const attempts = opts?.attempts ?? 3;
105
+ let lastError;
106
+ for (let i = 0; i < attempts; i++) {
75
107
  try {
76
- data = JSON.parse(text);
108
+ const result = await fn();
109
+ if (!opts?.shouldRetry?.(result) || i === attempts - 1) {
110
+ return result;
111
+ }
77
112
  }
78
- catch {
79
- data = { raw: text };
113
+ catch (error) {
114
+ lastError = error;
115
+ if (i === attempts - 1) {
116
+ throw error;
117
+ }
80
118
  }
119
+ await sleep(250 * (i + 1));
81
120
  }
82
- return {
83
- status: response.status,
84
- data: data,
85
- };
121
+ throw lastError instanceof Error ? lastError : new Error("request failed");
86
122
  }
87
- async function joinAgent(baseUrl, name) {
123
+ async function postJson(url, body, headers) {
124
+ return withRetry(async () => {
125
+ const response = await fetch(url, {
126
+ method: "POST",
127
+ headers,
128
+ body: JSON.stringify(body),
129
+ });
130
+ const text = await response.text();
131
+ let data = {};
132
+ if (text.trim().length > 0) {
133
+ try {
134
+ data = JSON.parse(text);
135
+ }
136
+ catch {
137
+ data = { raw: text };
138
+ }
139
+ }
140
+ return {
141
+ status: response.status,
142
+ data: data,
143
+ };
144
+ }, {
145
+ shouldRetry: (result) => isRetryableStatus(result.status),
146
+ });
147
+ }
148
+ async function getJson(url, headers) {
149
+ return withRetry(async () => {
150
+ const response = await fetch(url, {
151
+ method: "GET",
152
+ headers,
153
+ });
154
+ const text = await response.text();
155
+ let data = {};
156
+ if (text.trim().length > 0) {
157
+ try {
158
+ data = JSON.parse(text);
159
+ }
160
+ catch {
161
+ data = { raw: text };
162
+ }
163
+ }
164
+ return {
165
+ status: response.status,
166
+ data: data,
167
+ };
168
+ }, {
169
+ shouldRetry: (result) => isRetryableStatus(result.status),
170
+ });
171
+ }
172
+ async function joinAgent(baseUrl, name, options = {}) {
88
173
  const config = await (0, config_1.readConfig)();
89
174
  if (!config.token) {
90
175
  return {
@@ -97,12 +182,18 @@ async function joinAgent(baseUrl, name) {
97
182
  const result = await postJson(withPath(baseUrl, "/v1/agents/join"), {
98
183
  token: config.token,
99
184
  name,
185
+ description: options.description?.trim() || undefined,
186
+ version: options.version ?? exports.MARSHELL_CLI_VERSION,
100
187
  }, { "content-type": "application/json" });
101
188
  if (result.status === 404) {
102
189
  return { kind: "not_found" };
103
190
  }
104
191
  if (result.status >= 200 && result.status < 300 && result.data.agent_key) {
105
- return { kind: "joined", agentKey: result.data.agent_key };
192
+ return {
193
+ kind: "joined",
194
+ agentKey: result.data.agent_key,
195
+ agentCardUrl: result.data.agent_card_url,
196
+ };
106
197
  }
107
198
  const errText = typeof result.data === "object" &&
108
199
  result.data &&
@@ -147,21 +238,71 @@ async function discoverPeers(baseUrl) {
147
238
  }
148
239
  return { peers: [] };
149
240
  }
241
+ async function fetchWallet(baseUrl) {
242
+ try {
243
+ const headers = await authHeaders("agent");
244
+ const response = await fetch(withPath(baseUrl, "/v1/wallet"), {
245
+ method: "GET",
246
+ headers,
247
+ });
248
+ const data = (await response.json());
249
+ if (!response.ok) {
250
+ return {
251
+ kind: "error",
252
+ status: response.status,
253
+ message: data.error ?? `Wallet check failed with HTTP ${response.status}.`,
254
+ };
255
+ }
256
+ const wallet = parseWallet(data.wallet);
257
+ if (!wallet) {
258
+ return { kind: "error", message: "Wallet response missing balance." };
259
+ }
260
+ return {
261
+ kind: "ok",
262
+ wallet,
263
+ topUpUrl: data.pricing?.top_up ?? "https://console.marshell.dev/dashboard/billing",
264
+ };
265
+ }
266
+ catch (error) {
267
+ return { kind: "error", message: error.message };
268
+ }
269
+ }
150
270
  async function sendMessage(baseUrl, to, text) {
151
271
  try {
152
272
  const headers = await authHeaders("agent");
153
273
  const result = await postJson(withPath(baseUrl, "/v1/messages/send"), { to, text }, headers);
274
+ const wallet = parseWallet(result.data.wallet);
275
+ if (result.status === 402) {
276
+ return {
277
+ kind: "error",
278
+ status: 402,
279
+ code: result.data.code ?? "payment_required",
280
+ message: result.data.error ?? "No message credits remaining.",
281
+ wallet,
282
+ action: result.data.action ??
283
+ "Ask the subnet owner to top up at https://console.marshell.dev/dashboard/billing",
284
+ };
285
+ }
154
286
  if (result.status >= 200 && result.status < 300 && result.data.id) {
287
+ if (!wallet) {
288
+ return {
289
+ kind: "error",
290
+ message: "Send succeeded but wallet balance was missing from response.",
291
+ status: result.status,
292
+ };
293
+ }
155
294
  return {
156
295
  kind: "sent",
157
296
  id: result.data.id,
158
297
  status: result.data.status ?? "delivered",
298
+ wallet,
159
299
  };
160
300
  }
161
301
  return {
162
302
  kind: "error",
163
303
  status: result.status,
164
304
  message: result.data.error ?? `Send failed with HTTP ${result.status}.`,
305
+ wallet,
165
306
  };
166
307
  }
167
308
  catch (error) {
@@ -172,32 +313,29 @@ async function sendMessage(baseUrl, to, text) {
172
313
  }
173
314
  }
174
315
  async function fetchInbox(baseUrl, options) {
316
+ const peek = options?.peek !== false;
175
317
  try {
176
318
  const headers = await authHeaders("agent");
177
319
  const params = new URLSearchParams();
178
- if (options?.peek) {
320
+ if (peek) {
179
321
  params.set("peek", "1");
180
322
  }
181
323
  if (options?.waitSeconds && options.waitSeconds > 0) {
182
324
  params.set("wait", String(Math.min(120, options.waitSeconds)));
183
325
  }
184
326
  const qs = params.size > 0 ? `?${params.toString()}` : "";
185
- const response = await fetch(withPath(baseUrl, `/v1/messages/inbox${qs}`), {
186
- method: "GET",
187
- headers,
188
- });
189
- const data = (await response.json());
190
- if (!response.ok) {
327
+ const result = await getJson(withPath(baseUrl, `/v1/messages/inbox${qs}`), headers);
328
+ if (result.status < 200 || result.status >= 300) {
191
329
  return {
192
330
  kind: "error",
193
- status: response.status,
194
- message: data.error ?? `Inbox failed with HTTP ${response.status}.`,
331
+ status: result.status,
332
+ message: result.data.error ?? `Inbox failed with HTTP ${result.status}.`,
195
333
  };
196
334
  }
197
335
  return {
198
336
  kind: "ok",
199
- messages: data.messages ?? [],
200
- agent: data.agent ?? "",
337
+ messages: result.data.messages ?? [],
338
+ agent: result.data.agent ?? "",
201
339
  };
202
340
  }
203
341
  catch (error) {
package/package.json CHANGED
@@ -1,45 +1,45 @@
1
- {
2
- "name": "@marshell/cli",
3
- "version": "0.7.5",
4
- "description": "Marshell CLI — join a subnet, discover agents, send messages",
5
- "license": "MIT",
6
- "type": "commonjs",
7
- "bin": {
8
- "marshell": "bin/marshell.js"
9
- },
10
- "files": [
11
- "bin",
12
- "dist",
13
- "scripts",
14
- "README.md"
15
- ],
16
- "scripts": {
17
- "build": "tsc -p tsconfig.json",
18
- "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
19
- "prepublishOnly": "npm run build"
20
- },
21
- "engines": {
22
- "node": ">=20"
23
- },
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/marshell-labs/cli.git"
27
- },
28
- "bugs": {
29
- "url": "https://github.com/marshell-labs/cli/issues"
30
- },
31
- "homepage": "https://github.com/marshell-labs/cli#readme",
32
- "publishConfig": {
33
- "access": "public"
34
- },
35
- "keywords": [
36
- "marshell",
37
- "agents",
38
- "a2a",
39
- "cli"
40
- ],
41
- "devDependencies": {
42
- "@types/node": "^20.16.11",
43
- "typescript": "^5.6.3"
44
- }
45
- }
1
+ {
2
+ "name": "@marshell/cli",
3
+ "version": "0.7.8",
4
+ "description": "Marshell CLI — join a subnet, discover agents, send messages",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "marshell": "bin/marshell.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "dist",
13
+ "scripts",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/marshell-labs/cli.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/marshell-labs/cli/issues"
30
+ },
31
+ "homepage": "https://github.com/marshell-labs/cli#readme",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "keywords": [
36
+ "marshell",
37
+ "agents",
38
+ "a2a",
39
+ "cli"
40
+ ],
41
+ "devDependencies": {
42
+ "@types/node": "^20.16.11",
43
+ "typescript": "^5.6.3"
44
+ }
45
+ }
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+ # Hermes/cron wrapper — run `marshell relay cron --quiet`
3
+ # Install: curl -fsSL https://marshell.dev/scripts/relay-cron.sh -o ~/.hermes/scripts/marshell-relay-cron.sh && chmod +x ~/.hermes/scripts/marshell-relay-cron.sh
4
+ # Cron job script path: /root/.hermes/scripts/marshell-relay-cron.sh
5
+
6
+ set -e
7
+ export PATH="/usr/local/bin:/usr/bin:/bin:${HOME}/.hermes/node/bin:${PATH}"
8
+
9
+ exec marshell relay cron --quiet
package/scripts/relay.mjs CHANGED
@@ -1,81 +1,81 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Marshell relay — notify script for gateway agents (Harvey, etc.)
4
- *
5
- * Receives JSON on stdin from `marshell listen --notify`:
6
- * { event, id, from, text, created_at, pending?: { context, sentText, ... } }
7
- *
8
- * Env:
9
- * MARSHELL_NOTIFY_WEBHOOK POST JSON payload to this URL
10
- * MARSHELL_NOTIFY_FORMAT "text" (default) | "json"
11
- */
12
- import { readFileSync } from "node:fs";
13
-
14
- async function main() {
15
- const raw = readFileSync(0, "utf8").trim();
16
- if (!raw) {
17
- process.exit(0);
18
- }
19
-
20
- let payload;
21
- try {
22
- payload = JSON.parse(raw);
23
- } catch {
24
- process.stderr.write("relay: invalid JSON on stdin\n");
25
- process.exit(1);
26
- }
27
-
28
- const from = payload.from ?? "unknown";
29
- const text = payload.text ?? "";
30
- const pending = payload.pending;
31
- const context = pending?.context ?? pending?.sentText;
32
-
33
- const format = process.env.MARSHELL_NOTIFY_FORMAT ?? "text";
34
- const webhook = process.env.MARSHELL_NOTIFY_WEBHOOK?.trim();
35
-
36
- let message;
37
- if (context) {
38
- message = `Reply from ${from} (re: ${context}):\n${text}`;
39
- } else {
40
- message = `New from ${from}:\n${text}`;
41
- }
42
-
43
- if (webhook) {
44
- const body = JSON.stringify({
45
- ...payload,
46
- message,
47
- relay: true,
48
- });
49
- const res = await fetch(webhook, {
50
- method: "POST",
51
- headers: { "content-type": "application/json" },
52
- body,
53
- });
54
- if (!res.ok) {
55
- process.stderr.write(`relay: webhook ${res.status}\n`);
56
- process.exit(1);
57
- }
58
- process.exit(0);
59
- }
60
-
61
- // Without a webhook, stdout is only a log — not human delivery. Fail so
62
- // listener leaves inbox intact and `marshell relay cron` can deliver.
63
- if (process.env.MARSHELL_ALLOW_STDOUT_RELAY === "1") {
64
- if (format === "json") {
65
- process.stdout.write(`${JSON.stringify({ ...payload, message })}\n`);
66
- } else {
67
- process.stdout.write(`${message}\n`);
68
- }
69
- process.exit(0);
70
- }
71
-
72
- process.stderr.write(
73
- "relay: MARSHELL_NOTIFY_WEBHOOK not set — use relay cron for human delivery\n",
74
- );
75
- process.exit(1);
76
- }
77
-
78
- main().catch((err) => {
79
- process.stderr.write(`relay: ${err instanceof Error ? err.message : String(err)}\n`);
80
- process.exit(1);
81
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Marshell relay — notify script for gateway agents (Harvey, etc.)
4
+ *
5
+ * Receives JSON on stdin from `marshell listen --notify`:
6
+ * { event, id, from, text, created_at, pending?: { context, sentText, ... } }
7
+ *
8
+ * Env:
9
+ * MARSHELL_NOTIFY_WEBHOOK POST JSON payload to this URL
10
+ * MARSHELL_NOTIFY_FORMAT "text" (default) | "json"
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+
14
+ async function main() {
15
+ const raw = readFileSync(0, "utf8").trim();
16
+ if (!raw) {
17
+ process.exit(0);
18
+ }
19
+
20
+ let payload;
21
+ try {
22
+ payload = JSON.parse(raw);
23
+ } catch {
24
+ process.stderr.write("relay: invalid JSON on stdin\n");
25
+ process.exit(1);
26
+ }
27
+
28
+ const from = payload.from ?? "unknown";
29
+ const text = payload.text ?? "";
30
+ const pending = payload.pending;
31
+ const context = pending?.context ?? pending?.sentText;
32
+
33
+ const format = process.env.MARSHELL_NOTIFY_FORMAT ?? "text";
34
+ const webhook = process.env.MARSHELL_NOTIFY_WEBHOOK?.trim();
35
+
36
+ let message;
37
+ if (context) {
38
+ message = `Reply from ${from} (re: ${context}):\n${text}`;
39
+ } else {
40
+ message = `New from ${from}:\n${text}`;
41
+ }
42
+
43
+ if (webhook) {
44
+ const body = JSON.stringify({
45
+ ...payload,
46
+ message,
47
+ relay: true,
48
+ });
49
+ const res = await fetch(webhook, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json" },
52
+ body,
53
+ });
54
+ if (!res.ok) {
55
+ process.stderr.write(`relay: webhook ${res.status}\n`);
56
+ process.exit(1);
57
+ }
58
+ process.exit(0);
59
+ }
60
+
61
+ // Without a webhook, stdout is only a log — not human delivery. Fail so
62
+ // listener leaves inbox intact and `marshell relay cron` can deliver.
63
+ if (process.env.MARSHELL_ALLOW_STDOUT_RELAY === "1") {
64
+ if (format === "json") {
65
+ process.stdout.write(`${JSON.stringify({ ...payload, message })}\n`);
66
+ } else {
67
+ process.stdout.write(`${message}\n`);
68
+ }
69
+ process.exit(0);
70
+ }
71
+
72
+ process.stderr.write(
73
+ "relay: MARSHELL_NOTIFY_WEBHOOK not set — use relay cron for human delivery\n",
74
+ );
75
+ process.exit(1);
76
+ }
77
+
78
+ main().catch((err) => {
79
+ process.stderr.write(`relay: ${err instanceof Error ? err.message : String(err)}\n`);
80
+ process.exit(1);
81
+ });