@wenrwa/marketplace-cli 0.6.2 → 0.6.4

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
@@ -14,11 +14,19 @@ npx @wenrwa/marketplace-cli
14
14
  ## Setup
15
15
 
16
16
  ```bash
17
- # Authenticate with the marketplace
18
- wen-market auth login --api-key YOUR_WALLET_OR_API_KEY
17
+ # Sign in with your browser (recommended for interactive use)
18
+ wen-market auth login
19
19
 
20
- # Optionally specify a custom API URL
21
- wen-market auth login --api-key YOUR_KEY --api-url http://localhost:3002/api/v1
20
+ # Prints an approval URL and code, opens your browser when possible, and
21
+ # works over SSH too (approve from any device — the code and URL are
22
+ # always printed). Approving on marketplace.wenrwa.com/settings/cli-login
23
+ # mints an API key named after this machine.
24
+
25
+ # CI / scripts: use a key created in Settings → API Keys instead
26
+ wen-market auth login --api-key wm_sk_...
27
+
28
+ # Point at a non-production API (local dev)
29
+ MARKETPLACE_API_URL=http://localhost:3002/api/v1 wen-market auth login
22
30
  ```
23
31
 
24
32
  ## Commands
@@ -26,8 +34,10 @@ wen-market auth login --api-key YOUR_KEY --api-url http://localhost:3002/api/v1
26
34
  ### auth — Authentication
27
35
 
28
36
  ```bash
29
- wen-market auth login --api-key <key> # Authenticate
30
- wen-market auth whoami # Show current session
37
+ wen-market auth login # Browser device-code login (interactive)
38
+ wen-market auth login --api-key wm_sk_... # CI/scripts: use an existing API key
39
+ wen-market auth whoami # Show current session
40
+ wen-market auth logout # Revoke the key server-side and remove it locally
31
41
  ```
32
42
 
33
43
  ### bounty — Bounty Lifecycle & Management
@@ -168,16 +178,19 @@ Config is saved to `~/.wen-market/config.json` after `auth login`:
168
178
 
169
179
  ```json
170
180
  {
171
- "apiKey": "your-wallet-or-api-key",
172
- "apiUrl": "http://localhost:3002/api/v1"
181
+ "apiKey": "wm_sk_...",
182
+ "apiUrl": "https://api.wenrwa.com/api/v1"
173
183
  }
174
184
  ```
175
185
 
186
+ `apiKey` is always a real API key (`wm_sk_...`), sent as `X-API-Key` on every
187
+ request — never a wallet public key.
188
+
176
189
  ## Environment Variables
177
190
 
178
191
  | Variable | Default | Description |
179
192
  |----------|---------|-------------|
180
- | `MARKETPLACE_API_URL` | `https://api.wenrwa.com/api/v1` | Override API URL |
193
+ | `MARKETPLACE_API_URL` | `https://api.wenrwa.com/api/v1` (production) | Override API URL, e.g. for local development |
181
194
 
182
195
  ## Development
183
196
 
@@ -158,3 +158,25 @@ describe('apps export-github', () => {
158
158
  spy.mockRestore();
159
159
  });
160
160
  });
161
+ describe('apps download', () => {
162
+ it('authenticates with the API key header, never the retired wallet header', async () => {
163
+ mockClient.getApiUrl = jest.fn(() => 'https://api.example/api/v1');
164
+ mockClient.getApiKey = jest.fn(() => 'wm_sk_testkey');
165
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
166
+ ok: true,
167
+ arrayBuffer: async () => new ArrayBuffer(3),
168
+ });
169
+ const fs = require('fs');
170
+ const writeSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
171
+ const logSpy = jest.spyOn(console, 'log').mockImplementation();
172
+ await runCommand('download', 'proj-1', '-o', '/tmp/out.zip');
173
+ const [url, init] = fetchSpy.mock.calls[0];
174
+ expect(url).toBe('https://api.example/api/v1/projects/proj-1/source');
175
+ const headers = init.headers;
176
+ expect(headers['X-API-Key']).toBe('wm_sk_testkey');
177
+ expect(Object.keys(headers).map((h) => h.toLowerCase())).not.toContain('x-wallet-pubkey');
178
+ fetchSpy.mockRestore();
179
+ writeSpy.mockRestore();
180
+ logSpy.mockRestore();
181
+ });
182
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Fast wake — the relay socket is replaced within ~1s of a Fly resume, and
5
+ * within a few seconds even if the resume can't be seen.
6
+ *
7
+ * Production 2026-09-25: machine resumed in 0.25s, terminal back ~21s later —
8
+ * the bridge only noticed its dead socket on the next 20s heartbeat.
9
+ */
10
+ const relayClient_1 = require("../bridge/relayClient");
11
+ const sessionManager_1 = require("../bridge/sessionManager");
12
+ class FakePty {
13
+ write() { }
14
+ resize() { }
15
+ kill() { }
16
+ onData() { }
17
+ onExit() { }
18
+ }
19
+ const factory = { spawn: () => new FakePty() };
20
+ function fakeSocket() {
21
+ const handlers = new Map();
22
+ const sock = {
23
+ readyState: 1, pings: 0, terminated: 0,
24
+ send() { }, close() { handlers.get('close')?.(1006); },
25
+ terminate() { sock.terminated += 1; handlers.get('close')?.(1006); },
26
+ on(e, cb) { handlers.set(e, cb); },
27
+ fire(e, ...a) { handlers.get(e)?.(...a); },
28
+ ping() { sock.pings += 1; },
29
+ };
30
+ return sock;
31
+ }
32
+ function start(opts = {}) {
33
+ const socket = fakeSocket();
34
+ const intervals = [];
35
+ const deadlines = [];
36
+ (0, relayClient_1.startRelayClient)({
37
+ relayUrl: 'ws://relay.test', relayToken: 'tok', sessions: new sessionManager_1.SessionManager(factory),
38
+ wsFactory: () => socket,
39
+ setHeartbeatIntervalFn: (cb, ms) => { intervals.push({ cb, ms }); return intervals.length; },
40
+ clearHeartbeatIntervalFn: () => { },
41
+ setPongDeadlineFn: (cb, ms) => { const d = { cb, ms, cleared: false }; deadlines.push(d); return d; },
42
+ clearPongDeadlineFn: (h) => { h.cleared = true; },
43
+ ...(opts.resumed ? { resumeDetectorFactory: () => ({ check: opts.resumed }) } : {}),
44
+ setTimeoutFn: () => 0, clearTimeoutFn: () => { },
45
+ });
46
+ socket.fire('open');
47
+ const tick = (ms) => { for (const i of intervals)
48
+ if (i.ms === ms)
49
+ i.cb(); };
50
+ return { socket, intervals, deadlines, tick };
51
+ }
52
+ describe('fast wake: resume detection', () => {
53
+ it('checks for a resume every second while connected', () => {
54
+ const { intervals } = start({ resumed: () => false });
55
+ expect(intervals.some((i) => i.ms === 1000)).toBe(true);
56
+ });
57
+ it('a detected resume terminates the socket at once, so reconnect runs immediately', () => {
58
+ let resumed = false;
59
+ const { socket, tick } = start({ resumed: () => resumed });
60
+ tick(1000);
61
+ expect(socket.terminated).toBe(0);
62
+ resumed = true;
63
+ tick(1000);
64
+ expect(socket.terminated).toBe(1);
65
+ });
66
+ });
67
+ describe('fast wake: a ping must be answered within seconds', () => {
68
+ it('heartbeat defaults to every 5s (was 20s)', () => {
69
+ const { intervals } = start({ resumed: () => false });
70
+ expect(intervals.some((i) => i.ms === 5000)).toBe(true);
71
+ expect(intervals.some((i) => i.ms === 20_000)).toBe(false);
72
+ });
73
+ it('an unanswered ping is declared dead after its own 4s deadline, not a whole interval later', () => {
74
+ const { socket, tick, deadlines } = start({ resumed: () => false });
75
+ tick(5000);
76
+ expect(socket.pings).toBe(1);
77
+ const d = deadlines[deadlines.length - 1];
78
+ expect(d.ms).toBe(4000);
79
+ d.cb();
80
+ expect(socket.terminated).toBe(1);
81
+ });
82
+ it('a pong cancels the deadline — a healthy link is never killed', () => {
83
+ const { socket, tick, deadlines } = start({ resumed: () => false });
84
+ tick(5000);
85
+ socket.fire('pong');
86
+ const d = deadlines[deadlines.length - 1];
87
+ expect(d.cleared).toBe(true);
88
+ d.cb(); // even if a cleared timer somehow fired
89
+ expect(socket.terminated).toBe(0);
90
+ });
91
+ });
@@ -56,6 +56,12 @@ function startWithFakeSocket(socket, heartbeatMs) {
56
56
  ...(heartbeatMs === undefined ? {} : { heartbeatMs }),
57
57
  setHeartbeatIntervalFn: (cb, ms) => { intervals.push({ cb, ms }); return intervals.length; },
58
58
  clearHeartbeatIntervalFn: () => { },
59
+ // Fast wake (2026-09-27) added a per-ping deadline and a resume check;
60
+ // both have their own suite (bridge-relay-fast-wake.test.ts). Kept inert
61
+ // here so these tests still pin the original two-missed-beats rule.
62
+ setPongDeadlineFn: () => 0,
63
+ clearPongDeadlineFn: () => { },
64
+ resumeCheckMs: 0,
59
65
  // Never let a real reconnect timer fire during a unit test.
60
66
  setTimeoutFn: () => 0,
61
67
  clearTimeoutFn: () => { },
@@ -65,7 +71,7 @@ function startWithFakeSocket(socket, heartbeatMs) {
65
71
  // would be a different cadence, and isn't configured here anyway).
66
72
  const beat = () => {
67
73
  for (const i of intervals)
68
- if (i.ms === (heartbeatMs ?? 20_000))
74
+ if (i.ms === (heartbeatMs ?? 5_000))
69
75
  i.cb();
70
76
  };
71
77
  return { handle, sessions, beat, intervals };
@@ -147,8 +153,9 @@ describe('relay keepalive (twr-18)', () => {
147
153
  // so the default must leave a wide margin or the self-heal races a reboot.
148
154
  const socket = fakeSocket();
149
155
  const { intervals, handle } = startWithFakeSocket(socket);
150
- const beatMs = intervals.find((i) => i.ms === 20_000)?.ms;
151
- expect(beatMs).toBe(20_000);
156
+ // Fast wake (2026-09-27): default 5s (was 20s), plus a 4s per-ping deadline.
157
+ const beatMs = intervals.find((i) => i.ms === 5_000)?.ms;
158
+ expect(beatMs).toBe(5_000);
152
159
  expect(beatMs * 2).toBeLessThan(300_000);
153
160
  handle.close();
154
161
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Fast wake — the bridge notices a Fly SUSPEND/resume at once.
5
+ *
6
+ * Production 2026-09-25: Fly resumed the machine in 0.25s, but the terminal
7
+ * came back ~21s later. Suspend freezes the guest (and its MONOTONIC clock,
8
+ * which Node timers run on), the relay drops the old socket meanwhile, and the
9
+ * bridge only found out on its next 20s heartbeat. A 1s tick comparing wall-
10
+ * clock progress with monotonic progress sees the frozen stretch immediately.
11
+ */
12
+ const resumeDetector_1 = require("../bridge/resumeDetector");
13
+ function clocks() {
14
+ let wall = 1_000_000;
15
+ let mono = 5_000;
16
+ return {
17
+ wallNow: () => wall,
18
+ monoNow: () => mono,
19
+ run(ms) { wall += ms; mono += ms; }, // normal time passing
20
+ suspend(ms) { wall += ms; }, // VM frozen: wall moves, mono does not
21
+ };
22
+ }
23
+ describe('createResumeDetector', () => {
24
+ it('says nothing while time passes normally', () => {
25
+ const c = clocks();
26
+ const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow });
27
+ for (let i = 0; i < 10; i++) {
28
+ c.run(1000);
29
+ expect(d.check()).toBe(false);
30
+ }
31
+ });
32
+ it('flags a resume after the machine was frozen for longer than the threshold', () => {
33
+ const c = clocks();
34
+ const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow, thresholdMs: 3000 });
35
+ c.run(1000);
36
+ expect(d.check()).toBe(false);
37
+ c.suspend(45 * 60_000);
38
+ c.run(1000);
39
+ expect(d.check()).toBe(true);
40
+ // Only once per freeze.
41
+ c.run(1000);
42
+ expect(d.check()).toBe(false);
43
+ });
44
+ it('ignores a short stall below the threshold (GC, a busy event loop)', () => {
45
+ const c = clocks();
46
+ const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow, thresholdMs: 3000 });
47
+ c.suspend(1500);
48
+ c.run(1000);
49
+ expect(d.check()).toBe(false);
50
+ });
51
+ it('ignores the wall clock stepping BACKWARDS (an NTP correction is not a resume)', () => {
52
+ const c = clocks();
53
+ const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow });
54
+ c.suspend(-60_000);
55
+ c.run(1000);
56
+ expect(d.check()).toBe(false);
57
+ });
58
+ });
@@ -335,6 +335,26 @@ export interface RelayClientOpts {
335
335
  */
336
336
  setHeartbeatIntervalFn?: (cb: () => void, ms: number) => unknown;
337
337
  clearHeartbeatIntervalFn?: (handle: unknown) => void;
338
+ /**
339
+ * Fast wake (2026-09-27) — how long a single ping may go unanswered before
340
+ * the socket is declared dead. Default 4000. Before this, the deadline WAS
341
+ * the heartbeat interval (20s), so a dead socket survived up to 40s; that is
342
+ * most of why a resumed machine's terminal took ~21s to come back.
343
+ */
344
+ pongTimeoutMs?: number;
345
+ /** Injectable for the pong deadline only (tests). Defaults to setTimeout. */
346
+ setPongDeadlineFn?: (cb: () => void, ms: number) => unknown;
347
+ clearPongDeadlineFn?: (handle: unknown) => void;
348
+ /**
349
+ * Fast wake — how often to check for a Fly suspend/resume (resumeDetector.ts).
350
+ * A detected resume terminates the socket at once so the redial starts
351
+ * immediately. Default 1000; 0 disables. Runs on the heartbeat timer seam.
352
+ */
353
+ resumeCheckMs?: number;
354
+ /** Injectable for tests. */
355
+ resumeDetectorFactory?: () => {
356
+ check(): boolean;
357
+ };
338
358
  /**
339
359
  * Rock-solid sleep/wake — how often to push the PTY inventory (`sessions`
340
360
  * frame) while connected. Default 30_000ms.
@@ -56,6 +56,7 @@ exports.startRelayClient = startRelayClient;
56
56
  * that) — on re-pair the pane sends `attach` again and gets a fresh
57
57
  * `replay`, exactly as it would against the local server.
58
58
  */
59
+ const resumeDetector_1 = require("./resumeDetector");
59
60
  const fs = __importStar(require("fs"));
60
61
  const path = __importStar(require("path"));
61
62
  const ws_1 = require("ws");
@@ -239,7 +240,18 @@ function startRelayClient(opts) {
239
240
  const cancelInterval = opts.clearIntervalFn ?? ((h) => clearInterval(h));
240
241
  // pucc-int-03 activity-tap config (enabled by default; the container bridge
241
242
  // runs Claude Code, so observing its tool-use is the norm).
242
- const heartbeatMs = opts.heartbeatMs ?? 20_000;
243
+ // Fast wake (2026-09-27): 5s, not 20s — with the 4s pong deadline below a
244
+ // dead socket is found within ~9s even when a resume goes undetected.
245
+ const heartbeatMs = opts.heartbeatMs ?? 5_000;
246
+ const pongTimeoutMs = opts.pongTimeoutMs ?? 4_000;
247
+ const schedulePongDeadline = opts.setPongDeadlineFn ?? ((cb, ms) => {
248
+ const t = setTimeout(cb, ms);
249
+ t.unref?.();
250
+ return t;
251
+ });
252
+ const cancelPongDeadline = opts.clearPongDeadlineFn ?? ((h) => clearTimeout(h));
253
+ const resumeCheckMs = opts.resumeCheckMs ?? 1_000;
254
+ const makeResumeDetector = opts.resumeDetectorFactory ?? (() => (0, resumeDetector_1.createResumeDetector)());
243
255
  const scheduleHeartbeat = opts.setHeartbeatIntervalFn ?? ((cb, ms) => setInterval(cb, ms));
244
256
  const cancelHeartbeat = opts.clearHeartbeatIntervalFn ?? ((h) => clearInterval(h));
245
257
  const sessionsReportMs = opts.sessionsReportMs ?? 30_000;
@@ -746,16 +758,38 @@ function startRelayClient(opts) {
746
758
  // twr-18 relay keepalive (see RelayClientOpts#heartbeatMs). Per-connection:
747
759
  // recreated on every dial, torn down in onDown.
748
760
  let hbHandle = null;
761
+ let resumeHandle = null;
762
+ let pongDeadline = null;
749
763
  let awaitingPong = false;
764
+ const clearPongDeadline = () => {
765
+ if (pongDeadline != null)
766
+ cancelPongDeadline(pongDeadline);
767
+ pongDeadline = null;
768
+ };
750
769
  const stopHeartbeat = () => {
751
770
  if (hbHandle != null)
752
771
  cancelHeartbeat(hbHandle);
772
+ if (resumeHandle != null)
773
+ cancelHeartbeat(resumeHandle);
753
774
  hbHandle = null;
775
+ resumeHandle = null;
776
+ clearPongDeadline();
754
777
  awaitingPong = false;
755
778
  };
779
+ // Declare this socket dead and let the close handler's reconnect run.
780
+ // terminate() (not close()) — a half-open socket never completes a close
781
+ // handshake.
782
+ const dropSocket = (why) => {
783
+ console.error(`[bridge] ${why} — reconnecting`);
784
+ stopHeartbeat();
785
+ try {
786
+ (socket.terminate ?? socket.close).call(socket);
787
+ }
788
+ catch { /* the close handler still runs */ }
789
+ };
756
790
  // ANY inbound traffic proves the link is alive — a busy socket never needs
757
791
  // to ping at all, and a pong is just the idle case of the same proof.
758
- const markLinkAlive = () => { awaitingPong = false; };
792
+ const markLinkAlive = () => { awaitingPong = false; clearPongDeadline(); };
759
793
  const startHeartbeat = () => {
760
794
  // No ping() (test double, or a non-ws implementation) ⇒ no keepalive.
761
795
  // Never fake it: a heartbeat that can't be answered would kill every
@@ -767,23 +801,36 @@ function startRelayClient(opts) {
767
801
  return;
768
802
  if (awaitingPong) {
769
803
  // Previous ping went unanswered for a full interval: the server is
770
- // gone even though TCP still believes otherwise. terminate() (not
771
- // close()) — a half-open socket never completes a close handshake.
772
- console.error('[bridge] relay heartbeat timed out — reconnecting');
773
- stopHeartbeat();
774
- try {
775
- (socket.terminate ?? socket.close).call(socket);
776
- }
777
- catch { /* the close handler still runs */ }
804
+ // gone even though TCP still believes otherwise.
805
+ dropSocket('relay heartbeat timed out');
778
806
  return;
779
807
  }
780
808
  awaitingPong = true;
781
809
  try {
782
810
  socket.ping();
783
811
  }
784
- catch { /* a failed ping surfaces as the next tick's timeout */ }
812
+ catch { /* a failed ping surfaces via the deadline */ }
813
+ // Fast wake: the ping gets its OWN short deadline instead of waiting a
814
+ // whole interval for the next tick to notice.
815
+ clearPongDeadline();
816
+ pongDeadline = schedulePongDeadline(() => {
817
+ pongDeadline = null;
818
+ if (connected && awaitingPong)
819
+ dropSocket(`relay ping unanswered for ${pongTimeoutMs}ms`);
820
+ }, pongTimeoutMs);
785
821
  }, heartbeatMs);
786
822
  hbHandle?.unref?.();
823
+ // Fast wake: a Fly suspend froze this process; the socket it holds is
824
+ // almost certainly dead. Notice within a second of resuming and redial
825
+ // now, not on the next heartbeat (resumeDetector.ts).
826
+ if (resumeCheckMs > 0) {
827
+ const detector = makeResumeDetector();
828
+ resumeHandle = scheduleHeartbeat(() => {
829
+ if (connected && detector.check())
830
+ dropSocket('machine resumed from suspend');
831
+ }, resumeCheckMs);
832
+ resumeHandle?.unref?.();
833
+ }
787
834
  };
788
835
  socket.on('pong', markLinkAlive);
789
836
  socket.on('open', () => {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Fast wake: has this machine just been RESUMED from a Fly suspend?
3
+ *
4
+ * Suspend freezes the whole guest, including the monotonic clock Node's
5
+ * timers run on, while real (wall-clock) time keeps going. On resume the wall
6
+ * clock is corrected forward and the monotonic clock simply continues. So in
7
+ * one short tick, wall-clock progress far exceeding monotonic progress means
8
+ * the process was frozen, and every socket it held is almost certainly dead
9
+ * (the relay drops a silent socket within ~90s).
10
+ *
11
+ * Production 2026-09-25: Fly resumed in 0.25s but the terminal took ~21s,
12
+ * because the bridge only learned its socket was dead on the next 20s
13
+ * heartbeat. relayClient checks this every second and redials at once.
14
+ * (browserIdle.ts relies on the same clock property for the browser.)
15
+ *
16
+ * Pure: both clocks injectable.
17
+ */
18
+ export interface ResumeDetectorOpts {
19
+ /** Wall-clock gap beyond monotonic progress that counts as a freeze. */
20
+ thresholdMs?: number;
21
+ wallNow?: () => number;
22
+ monoNow?: () => number;
23
+ }
24
+ export interface ResumeDetector {
25
+ /** True exactly once after a freeze longer than the threshold. */
26
+ check(): boolean;
27
+ }
28
+ export declare function createResumeDetector(opts?: ResumeDetectorOpts): ResumeDetector;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createResumeDetector = createResumeDetector;
4
+ const defaultMono = () => Number(process.hrtime.bigint() / 1000000n);
5
+ function createResumeDetector(opts = {}) {
6
+ const threshold = opts.thresholdMs ?? 3000;
7
+ const wallNow = opts.wallNow ?? Date.now;
8
+ const monoNow = opts.monoNow ?? defaultMono;
9
+ let lastWall = wallNow();
10
+ let lastMono = monoNow();
11
+ return {
12
+ check() {
13
+ const w = wallNow();
14
+ const m = monoNow();
15
+ const frozenFor = (w - lastWall) - (m - lastMono);
16
+ lastWall = w;
17
+ lastMono = m;
18
+ // Only a FORWARD jump: a backwards NTP step is not a resume.
19
+ return frozenFor > threshold;
20
+ },
21
+ };
22
+ }
@@ -135,7 +135,7 @@ exports.appsCommand
135
135
  const client = (0, client_1.getClient)();
136
136
  const url = `${client.getApiUrl()}/projects/${id}/source`;
137
137
  const res = await fetch(url, {
138
- headers: { 'X-Wallet-Pubkey': client.getApiKey() },
138
+ headers: { 'X-API-Key': client.getApiKey() },
139
139
  });
140
140
  if (!res.ok) {
141
141
  const data = await res.json().catch(() => ({}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenrwa/marketplace-cli",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "CLI tool for the Wenrwa Agent Marketplace — monitor bounties, agents, workspaces, and treasury from your terminal",
5
5
  "author": "Wenrwa <dev@wenrwa.com>",
6
6
  "license": "MIT",