@pingroom/cli 0.7.6 → 0.8.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/README.md CHANGED
@@ -641,6 +641,35 @@ shared by ChatGPT and Codex.
641
641
  For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
642
642
  See <https://pingroom.io/connect-mcp.md> for the complete MCP and OAuth guide.
643
643
 
644
+ ## Agent skills
645
+
646
+ Two ready-to-install [Claude Code skills](https://github.com/pingroom/skills)
647
+ teach an agent when and how to reach a human — `pingroom-mcp` for conversational
648
+ sessions, `pingroom-cli` for shells, CI, and hooks.
649
+
650
+ ```bash
651
+ pingroom skills # list them and every install route (prints only)
652
+ pingroom skills install # copy both into ~/.claude/skills (needs git)
653
+ ```
654
+
655
+ `install` refuses to replace a skill that is already there; pass `--force` to
656
+ replace it, or `--dir <path>` to install somewhere other than
657
+ `~/.claude/skills`. Inside Claude Code you can instead use the plugin system,
658
+ which keeps them updated:
659
+
660
+ ```
661
+ /plugin marketplace add pingroom/skills
662
+ /plugin install pingroom-mcp
663
+ ```
664
+
665
+ ## Update notifications
666
+
667
+ When a newer `@pingroom/cli` is published the CLI prints a one-line notice on
668
+ stderr, at most once every 24 hours. It is deliberately invisible to automation:
669
+ the check is skipped entirely unless both stdout and stderr are a TTY, and
670
+ whenever `CI` or `PINGROOM_NO_UPDATE_CHECK=1` is set. A failed or slow check is
671
+ silent and can never change a command's output or exit code.
672
+
644
673
  ## License
645
674
 
646
675
  MIT
package/bin/pingroom.js CHANGED
@@ -23,6 +23,7 @@
23
23
  // live Drive a live progress card (iOS Live Activity / Android live
24
24
  // update) on the room members' lock screen: start / update / end.
25
25
  // mcp Print the canonical remote MCP endpoint and client setup snippets.
26
+ // skills List the published agent skills, or install them for Claude Code.
26
27
  // activate Send one optional test Question with the saved QR-paired credential.
27
28
  // config Read/write ~/.pingroom/config.json (default_room, api_url).
28
29
  // logout Forget the credential in ~/.pingroom/credentials.json.
@@ -39,9 +40,10 @@ import { EXIT } from '../lib/constants.js';
39
40
  import { fail, stripControlChars } from '../lib/util.js';
40
41
  import { VERSION } from '../lib/version.js';
41
42
  import { HELP } from '../lib/help.js';
43
+ import { maybeNotifyUpdate } from '../lib/update-check.js';
42
44
  import {
43
45
  parseArgs, parseConfigArgs, parseHandoffArgs, parseHandoffsArgs, parseHookArgs,
44
- parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs,
46
+ parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs, parseSkillsArgs,
45
47
  } from '../lib/parser.js';
46
48
  import { actions, approval, attachment, rooms, webhooks } from '../lib/commands/manage.js';
47
49
  import { ping } from '../lib/commands/ping.js';
@@ -51,6 +53,7 @@ import { listen } from '../lib/commands/listen.js';
51
53
  import { live } from '../lib/commands/live.js';
52
54
  import { hook } from '../lib/commands/hook.js';
53
55
  import { mcp } from '../lib/commands/mcp.js';
56
+ import { skills } from '../lib/commands/skills.js';
54
57
  import { activateStoredInbox, bare } from '../lib/commands/connect.js';
55
58
  import { config, logout } from '../lib/commands/config.js';
56
59
 
@@ -66,6 +69,7 @@ const COMMANDS = {
66
69
  listen: (rest) => listen(parseQArgs(rest)),
67
70
  hook: (rest) => hook(parseHookArgs(rest)),
68
71
  mcp,
72
+ skills: (rest) => skills(parseSkillsArgs(rest)),
69
73
  activate: (rest) => activateStoredInbox(parseQArgs(rest)),
70
74
  live: (rest) => live(parseLiveArgs(rest)),
71
75
  rooms: (rest) => rooms(parseManageArgs(rest)),
@@ -100,7 +104,9 @@ async function main() {
100
104
  // A leading flag with no subcommand (`pingroom --api …`) counts as bare — it
101
105
  // configures the connect attempt rather than naming a command.
102
106
  if (!command || command.startsWith('-')) {
103
- process.exit(await bare(parseQArgs(argv)));
107
+ const bareCode = await bare(parseQArgs(argv));
108
+ await maybeNotifyUpdate(VERSION);
109
+ process.exit(bareCode);
104
110
  }
105
111
 
106
112
  const handler = COMMANDS[command];
@@ -109,6 +115,9 @@ async function main() {
109
115
  }
110
116
 
111
117
  const code = await handler(argv.slice(1));
118
+ // After the command's own output, never before, and never in place of it:
119
+ // the notice is advisory and must not lead. It cannot alter `code`.
120
+ await maybeNotifyUpdate(VERSION);
112
121
  process.exit(code);
113
122
  }
114
123
 
@@ -0,0 +1,178 @@
1
+ // `skills` — the agent skills published at github.com/pingroom/skills.
2
+ //
3
+ // Bare `skills` prints the catalog and every install route, the same
4
+ // output-only contract `mcp` keeps. `skills install` is the one command in this
5
+ // CLI that writes outside ~/.pingroom, so it is explicit, refuses to clobber,
6
+ // and names every path it touched.
7
+
8
+ import { spawnSync } from 'node:child_process';
9
+ import { cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
10
+ import { homedir, tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { EXIT } from '../constants.js';
14
+ import { fail } from '../util.js';
15
+ import { commandHelp } from '../help.js';
16
+
17
+ export const SKILLS_REPO = 'https://github.com/pingroom/skills';
18
+ const SKILLS_CLONE_URL = `${SKILLS_REPO}.git`;
19
+
20
+ // Path in the repo -> the skill directory name it installs as.
21
+ //
22
+ // The repo is laid out as two Claude Code plugins (`mcp/`, `cli/`), each with a
23
+ // `skills/<name>/` directory whose name already matches the skill's frontmatter
24
+ // `name` — that agreement is what lets the same tree also be installed with
25
+ // `/plugin marketplace add`. So the source path is deep and the install name is
26
+ // simply its last segment; a copy install and a plugin install land the same
27
+ // directory name either way.
28
+ const SKILLS = [
29
+ {
30
+ source: ['mcp', 'skills', 'pingroom-mcp'],
31
+ install: 'pingroom-mcp',
32
+ summary: 'conversational agents — the hosted MCP connector',
33
+ },
34
+ {
35
+ source: ['cli', 'skills', 'pingroom-cli'],
36
+ install: 'pingroom-cli',
37
+ summary: 'shells, CI, and Claude Code hooks',
38
+ },
39
+ ];
40
+
41
+ export function claudeSkillsDir() {
42
+ return process.env.CLAUDE_SKILLS_DIR || join(homedir(), '.claude', 'skills');
43
+ }
44
+
45
+ function catalogLines() {
46
+ const width = Math.max(...SKILLS.map((s) => s.install.length));
47
+ return SKILLS.map((s) => ` ${s.install.padEnd(width)} ${s.summary}`).join('\n');
48
+ }
49
+
50
+ function printCatalog() {
51
+ process.stdout.write(
52
+ `PingRoom agent skills — ${SKILLS_REPO}
53
+
54
+ ${catalogLines()}
55
+
56
+ Install with this CLI (copies into ${claudeSkillsDir()}):
57
+ pingroom skills install
58
+
59
+ Install as a Claude Code plugin (auto-updates, no copy):
60
+ /plugin marketplace add pingroom/skills
61
+ /plugin install pingroom-mcp
62
+ /plugin install pingroom-cli
63
+
64
+ Or by hand:
65
+ git clone ${SKILLS_CLONE_URL} /tmp/pingroom-skills
66
+ ${SKILLS.map((s) => ` cp -r /tmp/pingroom-skills/${s.source.join('/')} ${claudeSkillsDir()}`).join('\n')}
67
+
68
+ Only "pingroom skills install" writes anything; this listing does not.
69
+ `);
70
+ return EXIT.OK;
71
+ }
72
+
73
+ function directoryExists(path) {
74
+ try {
75
+ return statSync(path).isDirectory();
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Clone the skills repo into a fresh temp directory and return its path.
83
+ *
84
+ * `git` rather than a tarball fetch: the repo is public and shallow-clones in
85
+ * one round trip, Node ships no tar reader, and vendoring one would put an
86
+ * archive parser in the dependency-free path every ping goes through. When git
87
+ * is missing the manual recipe above is still exact, so this fails with that
88
+ * rather than half-installing.
89
+ */
90
+ function cloneSkills() {
91
+ const probe = spawnSync('git', ['--version'], { stdio: 'ignore' });
92
+ if (probe.error || probe.status !== 0) {
93
+ fail(`git is required for "skills install".\nInstall git, or copy the skills by hand:\n pingroom skills`, EXIT.ERROR);
94
+ }
95
+
96
+ const workspace = mkdtempSync(join(tmpdir(), 'pingroom-skills-'));
97
+ const clone = spawnSync(
98
+ 'git',
99
+ ['clone', '--depth', '1', '--quiet', SKILLS_CLONE_URL, workspace],
100
+ { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' },
101
+ );
102
+
103
+ if (clone.error || clone.status !== 0) {
104
+ rmSync(workspace, { recursive: true, force: true });
105
+ const detail = (clone.stderr || clone.error?.message || 'git clone failed').trim().split('\n')[0];
106
+ fail(`could not fetch ${SKILLS_REPO}: ${detail}`, EXIT.ERROR);
107
+ }
108
+
109
+ return workspace;
110
+ }
111
+
112
+ function install(args) {
113
+ const target = args.dir ? String(args.dir) : claudeSkillsDir();
114
+ const force = Boolean(args.force);
115
+
116
+ // Resolve collisions BEFORE the network call. Cloning first and refusing
117
+ // afterwards would spend the round trip to tell the operator something that
118
+ // was knowable from the filesystem alone.
119
+ if (!force) {
120
+ const existing = SKILLS.filter((s) => directoryExists(join(target, s.install)));
121
+ if (existing.length > 0) {
122
+ const names = existing.map((s) => join(target, s.install)).join('\n ');
123
+ fail(
124
+ `already installed:\n ${names}\nRe-run with --force to replace ${existing.length === 1 ? 'it' : 'them'}.`,
125
+ EXIT.USAGE,
126
+ );
127
+ }
128
+ }
129
+
130
+ const workspace = cloneSkills();
131
+ const installed = [];
132
+ try {
133
+ for (const skill of SKILLS) {
134
+ const from = join(workspace, ...skill.source);
135
+ if (!directoryExists(from)) {
136
+ fail(`${SKILLS_REPO} has no "${skill.source.join('/')}" directory — the repo layout changed.`, EXIT.ERROR);
137
+ }
138
+ const to = join(target, skill.install);
139
+ mkdirSync(target, { recursive: true });
140
+ // Replace rather than merge: a stale SKILL.md left beside a new one is a
141
+ // skill that half-describes two versions, and Claude Code would load it.
142
+ rmSync(to, { recursive: true, force: true });
143
+ cpSync(from, to, { recursive: true });
144
+ installed.push({ name: skill.install, path: to, files: countFiles(to) });
145
+ }
146
+ } finally {
147
+ rmSync(workspace, { recursive: true, force: true });
148
+ }
149
+
150
+ const lines = installed.map((s) => ` ${s.name} -> ${s.path} (${s.files} file${s.files === 1 ? '' : 's'})`);
151
+ process.stdout.write(
152
+ `Installed ${installed.length} skill${installed.length === 1 ? '' : 's'}:
153
+ ${lines.join('\n')}
154
+
155
+ Restart Claude Code (or start a new session) to load them.
156
+ Connect the MCP server so the pingroom-mcp skill has tools to call:
157
+ pingroom mcp
158
+ `);
159
+ return EXIT.OK;
160
+ }
161
+
162
+ function countFiles(dir) {
163
+ let total = 0;
164
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
165
+ total += entry.isDirectory() ? countFiles(join(dir, entry.name)) : 1;
166
+ }
167
+ return total;
168
+ }
169
+
170
+ export function skills(args) {
171
+ if (args.help) { process.stdout.write(`${commandHelp('skills')}\n`); return EXIT.OK; }
172
+
173
+ const [sub] = args._;
174
+ if (sub === undefined || sub === 'list') return printCatalog();
175
+ if (sub === 'install') return install(args);
176
+
177
+ fail('usage: pingroom skills [list|install] [--dir <path>] [--force]', EXIT.USAGE);
178
+ }
package/lib/help.js CHANGED
@@ -30,6 +30,7 @@ Commands:
30
30
  permission prompts to a PingRoom question you answer from your phone
31
31
  mcp Print the remote MCP endpoint and setup for Claude Code, Cursor, and
32
32
  Claude Desktop
33
+ skills List the PingRoom agent skills, or install them for Claude Code
33
34
  activate Retry Agent Inbox activation with the saved QR-paired credential
34
35
  rooms List, inspect, create, or join rooms (rooms list|get|create|join)
35
36
  webhooks Manage a room's incoming webhooks (webhooks list|create|update|delete)
@@ -156,6 +157,13 @@ export const HELP_MCP = `mcp:
156
157
  pingroom mcp add claude-code Print the Claude Code setup command
157
158
  (output-only; does not change client config)`;
158
159
 
160
+ export const HELP_SKILLS = `skills:
161
+ pingroom skills List the published agent skills and every
162
+ install route (output-only)
163
+ pingroom skills install Copy them into ~/.claude/skills (needs git)
164
+ --dir <path> Install somewhere other than ~/.claude/skills
165
+ --force Replace skills that are already installed`;
166
+
159
167
  export const HELP_ACTIVATE = `activate:
160
168
  pingroom activate Send one test Question to your phone to prove the
161
169
  saved QR-paired credential works (optional —
@@ -278,7 +286,7 @@ human decision is not an infrastructure failure.`;
278
286
 
279
287
  export const HELP = [
280
288
  HELP_INTRO, HELP_PING, HELP_ASK, HELP_LIST, HELP_HANDOFF, HELP_HANDOFFS,
281
- HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_ACTIVATE, HELP_CONFIG,
289
+ HELP_LISTEN, HELP_LIVE, HELP_HOOK, HELP_MCP, HELP_SKILLS, HELP_ACTIVATE, HELP_CONFIG,
282
290
  HELP_SHARED, HELP_TAIL,
283
291
  ].join('\n\n');
284
292
 
@@ -299,6 +307,7 @@ export const COMMAND_HELP_SECTIONS = {
299
307
  listen: HELP_LISTEN,
300
308
  live: HELP_LIVE,
301
309
  hook: HELP_HOOK,
310
+ skills: HELP_SKILLS,
302
311
  activate: HELP_ACTIVATE,
303
312
  rooms: `rooms (agent token required):
304
313
  pingroom rooms list List the rooms this account belongs to
@@ -346,6 +355,10 @@ export const COMMAND_HELP_FOOTERS = {
346
355
  -h, --help Show this help`,
347
356
  logout: `Shared:
348
357
  -h, --help Show this help`,
358
+ // `skills` reaches GitHub, never the PingRoom API, so the shared credential
359
+ // and --json flags would all be lies here.
360
+ skills: `Shared:
361
+ -h, --help Show this help`,
349
362
  };
350
363
 
351
364
  // `<command> --help`: that command's section plus the shared flags, instead of
package/lib/parser.js CHANGED
@@ -214,6 +214,18 @@ export const parseLogoutArgs = makeParser({
214
214
  // Parser for the management nouns (rooms / webhooks / actions / approval /
215
215
  // attachment). One shared vocabulary: each sub-command reads the flags it
216
216
  // needs and the rest are usage errors at the command layer, same as everywhere.
217
+ // `skills` takes no credential and touches no API: only where to install and
218
+ // whether to replace what is already there. A dedicated vocabulary keeps
219
+ // --force from leaking into the parsers whose commands send real pings.
220
+ export const parseSkillsArgs = makeParser({
221
+ aliases: {
222
+ '--dir': 'dir',
223
+ '--force': 'force',
224
+ '-h': 'help', '--help': 'help',
225
+ },
226
+ booleans: ['force', 'help'],
227
+ });
228
+
217
229
  export const parseManageArgs = makeParser({
218
230
  aliases: {
219
231
  '-n': 'name', '--name': 'name',
@@ -0,0 +1,153 @@
1
+ // "A newer @pingroom/cli is available" — the one piece of output this tool
2
+ // prints that nobody asked for. Everything here exists to make that safe.
3
+ //
4
+ // The hard rule: this must never change what a command does. Not its exit code,
5
+ // not its stdout, not whether it succeeds. A version notice that breaks a
6
+ // deploy pipeline is worse than never shipping the notice at all, so every
7
+ // failure path below is a silent return.
8
+
9
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { join } from 'node:path';
12
+
13
+ import { pingroomHome, readJsonFile } from './config.js';
14
+
15
+ const REGISTRY_URL = 'https://registry.npmjs.org/@pingroom/cli/latest';
16
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
17
+
18
+ // Awaited before exit, so this is a real ceiling on how long a successful
19
+ // command can be delayed by a check nobody asked for. Measured round trips to
20
+ // the registry ran 0.63-1.19s warm, so a tighter budget (1.2s was tried) times
21
+ // out often enough to look like "there is never an update".
22
+ const TIMEOUT_MS = 2500;
23
+
24
+ export function updateCachePath() { return join(pingroomHome(), 'update-check.json'); }
25
+
26
+ /**
27
+ * Write the cache, or give up without a word.
28
+ *
29
+ * Deliberately NOT config.js's writeJsonFile: that one calls fail() on an
30
+ * unwritable path, and fail() calls process.exit() — which no try/catch can
31
+ * intercept. Borrowing it here would mean a read-only or full ~/.pingroom turns
32
+ * an advisory version check into the thing that kills the operator's ping. The
33
+ * write is still atomic (temp file + rename) so a crash mid-write cannot leave
34
+ * a torn file for the next run to parse.
35
+ */
36
+ function writeCacheQuietly(path, value) {
37
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
38
+ try {
39
+ mkdirSync(pingroomHome(), { recursive: true, mode: 0o700 });
40
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
41
+ renameSync(tmp, path);
42
+ } catch {
43
+ try { unlinkSync(tmp); } catch { /* never created */ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Why an env var AND a TTY test:
49
+ *
50
+ * `CI` covers the graders that set it (GitHub Actions, GitLab, CircleCI). The
51
+ * TTY test covers everything else — cron, systemd units, Docker builds, a
52
+ * pipeline that forgot to set CI, and `pingroom ... | jq`. Neither alone is
53
+ * enough, and the notice is worthless to a machine either way.
54
+ */
55
+ function suppressed() {
56
+ if (process.env.PINGROOM_NO_UPDATE_CHECK === '1') return true;
57
+ if (process.env.CI) return true;
58
+ if (process.env.NODE_ENV === 'test') return true;
59
+ return !process.stdout.isTTY || !process.stderr.isTTY;
60
+ }
61
+
62
+ /**
63
+ * Compare two dotted release numbers. Returns true when `candidate` is strictly
64
+ * newer than `current`.
65
+ *
66
+ * Anything carrying a prerelease or build suffix (`-beta.1`, `+sha`) is refused
67
+ * outright rather than guessed at: npm's `latest` tag should never point at one,
68
+ * and a wrong guess here nags every single run. Only the numeric release line is
69
+ * compared, and only when both sides parse.
70
+ */
71
+ export function isNewer(candidate, current) {
72
+ const parse = (value) => {
73
+ if (typeof value !== 'string') return null;
74
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value.trim());
75
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
76
+ };
77
+ const a = parse(candidate);
78
+ const b = parse(current);
79
+ if (!a || !b) return false;
80
+ for (let i = 0; i < 3; i++) {
81
+ if (a[i] > b[i]) return true;
82
+ if (a[i] < b[i]) return false;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ /**
88
+ * Fetch the published `latest` version, or null for any failure at all — no
89
+ * network, DNS refusal, a 5xx, a proxy returning HTML, a body without a version
90
+ * string. The caller cannot distinguish these and should not try to.
91
+ */
92
+ async function fetchLatest() {
93
+ try {
94
+ // Plain application/json, NOT npm's abbreviated-metadata type: that one is
95
+ // only accepted on the full packument, and asking for it here gets a 406
96
+ // that this function would swallow into a permanent "no update available".
97
+ // The single-version document is ~1.5 KB, smaller than the packument the
98
+ // abbreviated type would have saved us from.
99
+ const res = await fetch(REGISTRY_URL, {
100
+ headers: { Accept: 'application/json' },
101
+ signal: AbortSignal.timeout(TIMEOUT_MS),
102
+ });
103
+ if (!res.ok) return null;
104
+ const json = await res.json();
105
+ return typeof json?.version === 'string' ? json.version : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Check at most once every 24h and print a notice when a newer release exists.
113
+ *
114
+ * The cache timestamp is written on every completed check, including one that
115
+ * found nothing and one whose fetch failed. Writing it only on success would
116
+ * turn an offline machine into a machine that retries the registry on every
117
+ * single invocation.
118
+ *
119
+ * Note this is awaited by the caller rather than detached: the CLI ends in an
120
+ * explicit process.exit(), which would kill a floating promise mid-flight and
121
+ * leave the cache unwritten — so a detached check would re-fetch forever while
122
+ * appearing to cost nothing.
123
+ */
124
+ export async function maybeNotifyUpdate(currentVersion) {
125
+ try {
126
+ if (suppressed()) return;
127
+
128
+ const path = updateCachePath();
129
+ const cached = readJsonFile(path);
130
+ const checkedAt = Number(cached?.checked_at);
131
+ const fresh = Number.isFinite(checkedAt) && Date.now() - checkedAt < CHECK_INTERVAL_MS;
132
+
133
+ // Inside the window, still report what the last check found: the notice
134
+ // should persist until the operator actually upgrades, not appear once a day
135
+ // and vanish.
136
+ const latest = fresh ? cached?.latest : await fetchLatest();
137
+ if (!fresh) {
138
+ writeCacheQuietly(path, { checked_at: Date.now(), latest: latest ?? null });
139
+ }
140
+
141
+ if (typeof latest !== 'string' || !isNewer(latest, currentVersion)) return;
142
+
143
+ process.stderr.write(
144
+ `\nnote: @pingroom/cli ${latest} is available (you have ${currentVersion})\n`
145
+ + ' npm i -g @pingroom/cli\n'
146
+ + ' set PINGROOM_NO_UPDATE_CHECK=1 to silence this\n',
147
+ );
148
+ } catch {
149
+ // Unreachable by design — every step above already swallows its own
150
+ // failures. This is the backstop that guarantees the promise this function
151
+ // returns can never reject into the caller's exit path.
152
+ }
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingroom/cli",
3
- "version": "0.7.6",
3
+ "version": "0.8.0",
4
4
  "description": "Send PingRoom Pings and wait for human decisions from CI, scripts, and agents.",
5
5
  "type": "module",
6
6
  "bin": {