stdout-chat 0.1.0 → 0.3.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/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to `stdout-chat` (the CLI). Dates are release dates.
4
+
5
+ ## [0.3.0] — 2026-09-20
6
+
7
+ ### What's New
8
+
9
+ - `/dm <nick|sid>` at the prompt invites someone from #void to a private chat. Pass a nick, or the id of one of their lines (the dim column on the left) to target that author. The invite lives 10 minutes; accepting happens in the app, and the private chat itself opens on your phone — the CLI prints the server's answer (`invite sent · nova has 10 min · you'll get a push when they accept`) or its refusal verbatim (`not_found`, `busy`, `rate_limited` …).
10
+
11
+ ### Technical
12
+
13
+ - `lib/api.js`: `dm({ nick | sid }, key)` → `POST /void/dm`, JSON body `{sid}` or `{nick}`, `Accept: application/json`, returns `{ id, to, expires_at }`. Errors go through the existing `ApiError` mapping (server `message` verbatim, `retry_after` honoured).
14
+ - `lib/session.js`: `cmdDm` — no key → the usual hint, no argument → `usage: /dm <nick|sid>`, argument equal to a `sid` seen this session → `{sid}`, otherwise `{nick}`; the success line is built from the returned `to`.
15
+ - Tests: 76 → 82 (`/dm` sid/nick routing, usage, no-key, verbatim errors; `api.dm` request shape).
16
+
17
+ ## [0.2.0] — 2026-09-20
18
+
19
+ ### What's New
20
+
21
+ - Desktop notifications on macOS and Linux: a banner when someone replies to your line or writes `@you` while the client is running (at the prompt or in `--tail`). Live lines only; history and your own lines never notify.
22
+ - `/notify` shows the level; `/notify mentions|all|off` sets it and saves it next to the key.
23
+ - `--no-notify` (and `--notify`) plus `STDOUT_CHAT_NO_NOTIFY=1` to silence a single run. Flag beats env beats config.
24
+
25
+ ### Technical
26
+
27
+ - New `lib/notify.js`: `createNotifier()` wraps `osascript -e 'display notification …'` (darwin) / `notify-send -a stdout-chat` (linux); Windows and other platforms are a no-op. AppleScript escaping (`\` then `"`), body cut at 120 code points, control characters stripped, 3 s exec timeout, one banner per 2 s (drops, no queue). The first exec error disables banners for the session and prints one dim `· notifications unavailable here`.
28
+ - `lib/render.js`: `isAddressedTo(msg, myNick)` → `'reply' | 'mention' | null` (word-bounded, case-insensitive, `a@nick` does not count).
29
+ - `lib/session.js`: banner hook fires only on live `msg` events that were actually printed; `verifyKey({ quiet })` for `--tail`, which now checks a stored key silently to learn the nick.
30
+ - `lib/config.js`: `notify` persisted in `config.json`; `/key` preserves it. Files stay `0600`.
31
+ - Tests: 55 → 76 (`test/notify.test.js` new; `isAddressedTo`, session, config and smoke cases added).
32
+
33
+ ## [0.1.0] — 2026-09-20
34
+
35
+ - First release: read, tail and post to `#void` from the terminal · zero dependencies · Node ≥ 18.
package/README.md CHANGED
@@ -47,7 +47,9 @@ The key is checked against the server, then saved to `~/.config/stdout-chat/conf
47
47
  | `/r <id> text` | reply to a line — ids are the dim column on the left |
48
48
  | `/top` | this week's top authors |
49
49
  | `/who` | how many are in the room |
50
+ | `/dm <nick|sid>` | invite them to a private chat — a line's id targets its author; the chat itself opens on your phone |
50
51
  | `/key sc_…` | save a key · `/key` shows who you are · `/key off` forgets it |
52
+ | `/notify` | desktop banners: `/notify` shows the level · `/notify mentions` (default) · `all` · `off` |
51
53
  | `/clear` | clear the screen |
52
54
  | `/help` | list this |
53
55
  | `/quit` | leave (Ctrl-C and Ctrl-D too) |
@@ -62,6 +64,7 @@ Server errors are printed as the server phrases them (`slow down · retry in 2s`
62
64
  -n <count> lines of history (default 30, max 100)
63
65
  --api <url> API base (default https://api.stdout.chat; env STDOUT_CHAT_API)
64
66
  --no-color plain output (NO_COLOR is honoured too)
67
+ --no-notify no desktop banners this run (env STDOUT_CHAT_NO_NOTIFY=1 works too)
65
68
  --help, --version
66
69
  ```
67
70
 
@@ -95,6 +98,23 @@ void() { # void · void -f · void -r a1b4 text · void some words
95
98
  - `GET /void` for history, `GET /void/stream` (Server-Sent Events) for the live feed, `POST /void` to speak, `GET /void/me` to check a key. All JSON.
96
99
  - Reconnects with backoff (1 → 30 s) and `Last-Event-ID`, so nothing is missed across the server's 15-minute stream rotation.
97
100
  - Plain scrolling output with a `readline` prompt: no alternate screen, no curses — works in tmux splits and over ssh.
101
+ - `POST /void/dm` sends a private-chat invite (`/dm`). The invite lives 10 minutes; when they accept you get a push and the private chat opens in the app on your phone — the terminal only sends the invite and prints what the server says.
102
+
103
+ ## Notifications
104
+
105
+ The terminal is usually behind the editor. When someone replies to your line or writes `@you` while the client is running, a desktop banner shows up:
106
+
107
+ ```
108
+ #void
109
+ mox → you
110
+ ceiling fans, obviously
111
+ ```
112
+
113
+ - **When:** live lines only — never the history printed at start, never your own lines. Works at the `>` prompt and in `--tail`; `--read` and piped output never notify.
114
+ - **Levels:** `mentions` (default: replies to you and `@nick`), `all` (every line by someone else), `off`. Set with `/notify mentions|all|off` at the prompt; it is saved to `config.json`. In `--tail` the key on file is checked quietly so `mentions` knows your nick; without a key only `all` can fire.
115
+ - **Off for one run:** `--no-notify`, or `STDOUT_CHAT_NO_NOTIFY=1`. Flag beats env beats config.
116
+ - **How:** macOS via `osascript` (`display notification`), Linux via `notify-send` (needs `libnotify`), no sound. Windows: nothing yet. If the local command fails once, banners switch off for the session and one dim `· notifications unavailable here` is printed. At most one banner per 2 seconds — a burst is dropped, not queued.
117
+ - **Privacy:** nothing leaves the machine. The banner is a line the room already sent you, handed to a local binary; no extra requests, no background process.
98
118
 
99
119
  ## Privacy
100
120
 
@@ -6,7 +6,8 @@ import { createApi, DEFAULT_API, errorMessage } from '../lib/api.js';
6
6
  import { loadConfig } from '../lib/config.js';
7
7
  import { createUI } from '../lib/ui.js';
8
8
  import { Session } from '../lib/session.js';
9
- import { renderError } from '../lib/render.js';
9
+ import { renderError, renderInfo } from '../lib/render.js';
10
+ import { createNotifier } from '../lib/notify.js';
10
11
 
11
12
  const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
12
13
 
@@ -19,20 +20,24 @@ usage: npx stdout-chat [options]
19
20
  -n <count> how many lines to load (default 30, max 100)
20
21
  --api <url> API base (default ${DEFAULT_API}; env STDOUT_CHAT_API)
21
22
  --no-color plain output (NO_COLOR is honoured too)
23
+ --no-notify no desktop banners (env STDOUT_CHAT_NO_NOTIFY=1 works too)
22
24
  -h, --help this text
23
25
  -v, --version print the version
24
26
 
25
27
  at the prompt:
26
- /help /r <id> text /top /who /key sc_… /key off /clear /quit
28
+ /help /r <id> text /dm <nick|sid> /top /who /key sc_… /key off /notify /clear /quit
27
29
  anything else is posted to #void
28
30
 
31
+ desktop banners (macOS / Linux) when someone replies to you or writes @you —
32
+ only while the prompt or --tail is running · /notify mentions|all|off
33
+
29
34
  key file: ~/.config/stdout-chat/config.json (get a key in the app: /key)
30
35
  no telemetry · https://stdout.chat/void`;
31
36
 
32
37
  class UsageError extends Error {}
33
38
 
34
39
  function parseArgs(argv) {
35
- const o = { read: false, tail: false, api: null, color: true, help: false, version: false, n: 30 };
40
+ const o = { read: false, tail: false, api: null, color: true, notify: null, help: false, version: false, n: 30 };
36
41
  for (let i = 0; i < argv.length; i++) {
37
42
  const a = argv[i];
38
43
  const eq = a.startsWith('--') ? a.indexOf('=') : -1;
@@ -45,6 +50,8 @@ function parseArgs(argv) {
45
50
  case '--api': o.api = next(); if (!o.api) throw new UsageError('--api needs a url'); break;
46
51
  case '--no-color': o.color = false; break;
47
52
  case '--color': o.color = true; break;
53
+ case '--no-notify': o.notify = false; break;
54
+ case '--notify': o.notify = true; break;
48
55
  case '-h': case '--help': o.help = true; break;
49
56
  case '-v': case '--version': o.version = true; break;
50
57
  case '-n': case '--lines': {
@@ -68,6 +75,15 @@ function wantColor(opts) {
68
75
  return Boolean(process.stdout.isTTY);
69
76
  }
70
77
 
78
+ // flag > env > config > default. Returns the level ('mentions' | 'all' | 'off').
79
+ function wantNotify(opts, cfg) {
80
+ if (opts.notify === false) return 'off';
81
+ if (opts.notify === true) return cfg.notify && cfg.notify !== 'off' ? cfg.notify : 'mentions';
82
+ const env = process.env.STDOUT_CHAT_NO_NOTIFY;
83
+ if (env != null && env !== '' && env !== '0') return 'off';
84
+ return cfg.notify || 'mentions';
85
+ }
86
+
71
87
  async function main() {
72
88
  let opts;
73
89
  try {
@@ -103,8 +119,18 @@ async function main() {
103
119
  persistApi: api.base !== DEFAULT_API ? api.base : null, // keep a non-default api next to the key
104
120
  n: opts.n,
105
121
  onQuit: () => shutdown(0),
122
+ notifyLevel: wantNotify(opts, cfg),
106
123
  });
107
124
  session.onClear = () => ui.clear();
125
+ // Banners only where a human is watching a live feed: never in --read, never
126
+ // when stdout is a pipe (follow mode). The level gates the hook at runtime, so
127
+ // `/notify all` after `--no-notify` works without a restart; nothing spawns
128
+ // until a line actually qualifies.
129
+ if (mode === 'interactive' || mode === 'tail') {
130
+ session.notify = createNotifier({
131
+ onUnavailable: () => ui.print(renderInfo('· notifications unavailable here', { color })),
132
+ }).notify;
133
+ }
108
134
 
109
135
  function shutdown(code = 0) {
110
136
  if (closing) return;
@@ -130,6 +156,9 @@ async function main() {
130
156
  process.on('SIGHUP', () => shutdown(0));
131
157
  if (mode !== 'interactive') process.on('SIGINT', () => shutdown(0));
132
158
 
159
+ if (mode === 'tail' && session.key && session.notify) {
160
+ await session.verifyKey({ quiet: true }); // only to learn the nick for reply / @mention matching
161
+ }
133
162
  if (mode !== 'tail') {
134
163
  await session.verifyKey();
135
164
  try {
package/lib/api.js CHANGED
@@ -90,6 +90,20 @@ export function createApi({ base = DEFAULT_API, version = '0.0.0', fetchImpl = n
90
90
  });
91
91
  },
92
92
 
93
+ /**
94
+ * POST /void/dm {sid} | {nick} → 201 { id, to, expires_at }. Same shape as
95
+ * post(): JSON in, JSON out (Accept: application/json); the session builds
96
+ * the human line from `to`. Errors carry the server's message verbatim.
97
+ */
98
+ dm({ nick, sid } = {}, key) {
99
+ const body = sid != null && sid !== '' ? { sid: String(sid) } : { nick: String(nick == null ? '' : nick) };
100
+ return json(`${root}/void/dm`, {
101
+ method: 'POST',
102
+ headers: headers({ 'Content-Type': 'application/json' }, key),
103
+ body: JSON.stringify(body),
104
+ });
105
+ },
106
+
93
107
  /** GET /void/stream → Response (200, body is a ReadableStream). Throws ApiError on non-2xx. */
94
108
  async openStream({ lastEventId = null, signal = null } = {}) {
95
109
  const extra = { 'Cache-Control': 'no-cache' };
package/lib/config.js CHANGED
@@ -1,9 +1,10 @@
1
- // Key storage: ~/.config/stdout-chat/config.json { "key": "sc_…", "api": "https://…" }
1
+ // Key storage: ~/.config/stdout-chat/config.json { "key": "sc_…", "api": "https://…", "notify": "mentions" }
2
2
  // plus a plain-text twin ~/.config/stdout-chat/key so shell one-liners can
3
3
  // `$(cat ~/.config/stdout-chat/key)`. Directory 0700, files 0600.
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
+ import { NOTIFY_LEVELS } from './notify.js';
7
8
 
8
9
  export function configDir() {
9
10
  if (process.env.STDOUT_CHAT_CONFIG_DIR) return process.env.STDOUT_CHAT_CONFIG_DIR;
@@ -27,6 +28,7 @@ export function loadConfig() {
27
28
  const out = {};
28
29
  if (typeof parsed.key === 'string' && parsed.key) out.key = parsed.key;
29
30
  if (typeof parsed.api === 'string' && parsed.api) out.api = parsed.api;
31
+ if (NOTIFY_LEVELS.includes(parsed.notify)) out.notify = parsed.notify;
30
32
  return out;
31
33
  } catch {
32
34
  return {};
@@ -44,14 +46,15 @@ function rmQuiet(file) {
44
46
  try { fs.unlinkSync(file); } catch { /* absent */ }
45
47
  }
46
48
 
47
- /** Writes config.json (+ plain `key` file). `api` is omitted when falsy. */
48
- export function saveConfig({ key, api } = {}) {
49
+ /** Writes config.json (+ plain `key` file). `api` / `notify` are omitted when falsy or unknown. */
50
+ export function saveConfig({ key, api, notify } = {}) {
49
51
  const dir = configDir();
50
52
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
51
53
  try { fs.chmodSync(dir, 0o700); } catch { /* win32 */ }
52
54
  const data = {};
53
55
  if (key) data.key = key;
54
56
  if (api) data.api = api;
57
+ if (NOTIFY_LEVELS.includes(notify)) data.notify = notify;
55
58
  writePrivate(configPath(), `${JSON.stringify(data, null, 2)}\n`);
56
59
  if (key) writePrivate(keyPath(), `${key}\n`);
57
60
  else rmQuiet(keyPath());
package/lib/notify.js ADDED
Binary file
package/lib/render.js CHANGED
@@ -161,6 +161,22 @@ export function displayNick(msg, collisions) {
161
161
  return nick;
162
162
  }
163
163
 
164
+ /**
165
+ * Is this line aimed at me? A reply to my nick → 'reply'; `@nick` in the text
166
+ * (word-bounded, case-insensitive, `a@nick` does not count) → 'mention'; else null.
167
+ */
168
+ export function isAddressedTo(msg, myNick) {
169
+ if (!msg || typeof msg !== 'object' || myNick == null || myNick === '') return null;
170
+ const me = String(myNick);
171
+ const reply = msg.reply && typeof msg.reply === 'object' ? msg.reply : null;
172
+ if (reply && reply.username != null && String(reply.username).toLowerCase() === me.toLowerCase()) return 'reply';
173
+ const text = msg.text == null ? '' : String(msg.text);
174
+ if (!text) return null;
175
+ const escaped = me.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
176
+ const re = new RegExp(`(^|[^\\w@])@${escaped}(?![\\w])`, 'i');
177
+ return re.test(text) ? 'mention' : null;
178
+ }
179
+
164
180
  /** `[["❤️",2],["😂",1]]` or `{"❤️":2}` → `❤️ 2 😂 1`. Unknown shapes → ''. */
165
181
  export function formatReactions(reactions) {
166
182
  if (!reactions) return '';
package/lib/session.js CHANGED
@@ -4,9 +4,10 @@ import { errorMessage } from './api.js';
4
4
  import { readEvents } from './sse.js';
5
5
  import {
6
6
  renderLine, renderReactions, renderHide, renderTopic, renderHeader, renderTop,
7
- renderError, renderInfo, MEDALS,
7
+ renderError, renderInfo, isAddressedTo, MEDALS,
8
8
  } from './render.js';
9
9
  import * as defaultConfig from './config.js';
10
+ import { NOTIFY_LEVELS } from './notify.js';
10
11
 
11
12
  export const HINT_NO_KEY = 'type /key sc_… to post · get it in the app: /key';
12
13
  export const HELP_TEXT = [
@@ -14,8 +15,10 @@ export const HELP_TEXT = [
14
15
  '/r <id> text reply to a line (ids are the dim column on the left)',
15
16
  '/top this week\'s top authors',
16
17
  '/who how many are in the room',
18
+ '/dm <nick|sid> invite them to a private chat (accept happens in the app)',
17
19
  '/key sc_… save your key (get it in the app: /key)',
18
20
  '/key off forget the key on this machine (revoke it in the app)',
21
+ '/notify desktop banners: mentions (default) · all · off',
19
22
  '/clear clear the screen',
20
23
  '/quit leave (Ctrl-C / Ctrl-D work too)',
21
24
  'anything else is posted to #void · @nick mentions pass through as typed',
@@ -56,6 +59,8 @@ export class Session {
56
59
  onQuit = () => {},
57
60
  onKeyChange = () => {},
58
61
  timers = { setTimeout, clearTimeout },
62
+ notify = null,
63
+ notifyLevel = 'mentions',
59
64
  }) {
60
65
  this.api = api;
61
66
  this.print = print;
@@ -68,6 +73,10 @@ export class Session {
68
73
  this.onQuit = onQuit;
69
74
  this.onKeyChange = onKeyChange;
70
75
  this.timers = timers;
76
+ // Desktop banner hook: (title, subtitle, body) → void. Null in read/follow
77
+ // modes and when the user opted out, so the session never spawns anything.
78
+ this.notify = typeof notify === 'function' ? notify : null;
79
+ this.notifyLevel = NOTIFY_LEVELS.includes(notifyLevel) ? notifyLevel : 'mentions';
71
80
 
72
81
  this.me = null;
73
82
  this.topic = null;
@@ -94,13 +103,17 @@ export class Session {
94
103
 
95
104
  // ── startup ─────────────────────────────────────────────────────────────
96
105
 
97
- /** Verifies the stored key via /void/me. Never throws; leaves the key in place on failure. */
98
- async verifyKey() {
106
+ /**
107
+ * Verifies the stored key via /void/me. Never throws; leaves the key in place on failure.
108
+ * `quiet` swallows the warnings — --tail only needs the nick for mention matching.
109
+ */
110
+ async verifyKey({ quiet = false } = {}) {
99
111
  if (!this.key) return null;
100
112
  try {
101
113
  this.me = await this.api.getMe(this.key);
102
114
  return this.me;
103
115
  } catch (err) {
116
+ if (quiet) return null;
104
117
  if (err && err.status === 401) this.error(`${errorMessage(err)} · /key sc_… to replace it`);
105
118
  else this.info(`· couldn't verify key: ${errorMessage(err)}`);
106
119
  return null;
@@ -178,7 +191,7 @@ export class Session {
178
191
  case 'msg':
179
192
  case 'message': {
180
193
  if (data.id == null && ev.id) data.id = ev.id;
181
- this.printMsg(data);
194
+ if (this.printMsg(data)) this.maybeNotify(data); // live lines only: history goes through loadHistory
182
195
  break;
183
196
  }
184
197
  case 'react': {
@@ -210,6 +223,18 @@ export class Session {
210
223
  return undefined;
211
224
  }
212
225
 
226
+ /** Desktop banner for a freshly printed live line, per `notifyLevel`. Own lines never notify. */
227
+ maybeNotify(msg) {
228
+ if (!this.notify || this.notifyLevel === 'off') return;
229
+ const myNick = this.me ? this.me.username : null;
230
+ const nick = msg.username == null || msg.username === '' ? 'anon' : String(msg.username);
231
+ if (myNick != null && msg.username === myNick) return;
232
+ const how = isAddressedTo(msg, myNick);
233
+ if (!how && this.notifyLevel !== 'all') return;
234
+ const subtitle = how === 'reply' ? `${nick} → you` : how === 'mention' ? `${nick} @you` : nick;
235
+ this.notify('#void', subtitle, msg.text == null ? '' : String(msg.text));
236
+ }
237
+
213
238
  // ── stream ──────────────────────────────────────────────────────────────
214
239
 
215
240
  /** Runs until `signal` aborts. Backoff 1,2,4…30 s; `bye` → immediate quiet reconnect. */
@@ -285,9 +310,15 @@ export class Session {
285
310
  case '/who':
286
311
  await this.cmdWho();
287
312
  return;
313
+ case '/dm':
314
+ await this.cmdDm(rest);
315
+ return;
288
316
  case '/key':
289
317
  await this.cmdKey(rest);
290
318
  return;
319
+ case '/notify':
320
+ this.cmdNotify(rest);
321
+ return;
291
322
  case '/r': case '/reply': {
292
323
  const m = rest.match(/^(\S+)\s+([\s\S]+)$/);
293
324
  if (!m) { this.info('usage: /r <id> text'); return; }
@@ -325,6 +356,28 @@ export class Session {
325
356
  this.info(this.count === 1 ? '1 in room' : `${this.count} in room`);
326
357
  }
327
358
 
359
+ /** `/dm <nick|sid>`: a sid seen this session targets that line's author, anything else is a nick. */
360
+ async cmdDm(arg) {
361
+ if (!this.key) { this.info(HINT_NO_KEY); return; }
362
+ if (!arg) { this.info('usage: /dm <nick|sid>'); return; }
363
+ const target = this.isKnownSid(arg) ? { sid: arg } : { nick: arg };
364
+ let res;
365
+ try {
366
+ res = await this.api.dm(target, this.key);
367
+ } catch (err) {
368
+ this.error(errorMessage(err));
369
+ return;
370
+ }
371
+ const to = res && res.to != null && res.to !== '' ? String(res.to) : arg;
372
+ this.info(`invite sent · ${to} has 10 min · you'll get a push when they accept`);
373
+ }
374
+
375
+ /** True when `s` is the short id (the dim left column) of a line seen this session. */
376
+ isKnownSid(s) {
377
+ for (const m of this.lines.values()) if (m && m.sid != null && String(m.sid) === s) return true;
378
+ return false;
379
+ }
380
+
328
381
  async cmdKey(arg) {
329
382
  if (!arg) {
330
383
  if (this.me) this.info(greeting(this.me));
@@ -354,11 +407,33 @@ export class Session {
354
407
  this.key = arg;
355
408
  this.me = me;
356
409
  try {
357
- this.config.saveConfig({ key: arg, api: this.persistApi || undefined });
410
+ this.config.saveConfig({ key: arg, api: this.persistApi || undefined, notify: this.notifyLevel });
358
411
  } catch (err) {
359
412
  this.error(`saved in memory only · ${errorMessage(err)}`);
360
413
  }
361
414
  this.onKeyChange(arg);
362
415
  this.info(greeting(me) || 'key saved');
363
416
  }
417
+
418
+ cmdNotify(arg) {
419
+ if (!arg) {
420
+ this.info(`notifications: ${this.notifyLevel}`);
421
+ return;
422
+ }
423
+ const level = arg.toLowerCase();
424
+ if (!NOTIFY_LEVELS.includes(level)) {
425
+ this.info('usage: /notify mentions · /notify all · /notify off');
426
+ return;
427
+ }
428
+ this.notifyLevel = level;
429
+ try {
430
+ // The key stays as it is; only the level changes. No key → no file is written
431
+ // for the key twin, but the level still persists in config.json.
432
+ this.config.saveConfig({ key: this.key || undefined, api: this.persistApi || undefined, notify: level });
433
+ } catch (err) {
434
+ this.error(`set for this session only · ${errorMessage(err)}`);
435
+ return;
436
+ }
437
+ this.info(`notifications: ${level}`);
438
+ }
364
439
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stdout-chat",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "#void from your terminal — read, tail and post to stdout.chat's public room. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "bin",
11
11
  "lib",
12
12
  "README.md",
13
+ "CHANGELOG.md",
13
14
  "LICENSE"
14
15
  ],
15
16
  "engines": {