pi-cost 0.1.0 → 0.2.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +13 -1
  3. package/extensions/cost.ts +53 -83
  4. package/lib/app.js +113 -0
  5. package/lib/args.js +17 -0
  6. package/lib/cli.js +593 -0
  7. package/lib/cost-dir.js +62 -0
  8. package/lib/costs.js +799 -0
  9. package/lib/net-guard.js +178 -0
  10. package/lib/pi-parsers-cost.js +234 -66
  11. package/lib/pi-pricing.js +35 -8
  12. package/lib/themes.js +90 -0
  13. package/package.json +16 -7
  14. package/public/app.js +2232 -448
  15. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  16. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  17. package/public/fonts/fonts.css +244 -0
  18. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  19. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  20. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  21. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  22. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  23. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  24. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  30. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  31. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  32. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  33. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  34. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  35. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  36. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  37. package/public/index.html +68 -46
  38. package/public/style.css +319 -8
  39. package/public/sw.js +2 -2
  40. package/server.js +58 -659
  41. package/themes/ayu-dark.json +21 -0
  42. package/themes/ayu-light.json +21 -0
  43. package/themes/catppuccin-dark.json +21 -0
  44. package/themes/catppuccin-light.json +21 -0
  45. package/themes/dracula-dark.json +21 -0
  46. package/themes/dracula-light.json +21 -0
  47. package/themes/everforest-dark.json +21 -0
  48. package/themes/everforest-light.json +21 -0
  49. package/themes/github-dark.json +21 -0
  50. package/themes/github-light.json +21 -0
  51. package/themes/gruvbox-dark.json +21 -0
  52. package/themes/gruvbox-light.json +21 -0
  53. package/themes/kanagawa-dark.json +21 -0
  54. package/themes/kanagawa-light.json +21 -0
  55. package/themes/monokai-dark.json +21 -0
  56. package/themes/monokai-light.json +21 -0
  57. package/themes/night-owl-dark.json +21 -0
  58. package/themes/night-owl-light.json +21 -0
  59. package/themes/nord-dark.json +21 -0
  60. package/themes/nord-light.json +21 -0
  61. package/themes/one-dark-dark.json +21 -0
  62. package/themes/one-dark-light.json +21 -0
  63. package/themes/rose-pine-dark.json +21 -0
  64. package/themes/rose-pine-light.json +21 -0
  65. package/themes/solarized-dark.json +21 -0
  66. package/themes/solarized-light.json +21 -0
  67. package/themes/synthwave-dark.json +21 -0
  68. package/themes/synthwave-light.json +21 -0
  69. package/themes/tokyo-night-dark.json +21 -0
  70. package/themes/tokyo-night-light.json +21 -0
  71. package/themes/vitesse-dark.json +21 -0
  72. package/themes/vitesse-light.json +21 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ ### Breaking changes
6
+
7
+ - The server listens on `127.0.0.1` only. To reach it from another machine, start it with `--host 0.0.0.0 --allowed-hosts=<hostname>`.
8
+ - The Projects tab is gone. The Overview lists the projects, and a click opens a project's sessions. `1` and `2` switch between Overview and Insights.
9
+
10
+ ### Features
11
+
12
+ - **Date ranges.** Pick a range with the date picker or a brush on the charts. The cost-by-model charts show hourly or daily series.
13
+ - **Insights, sessions and agents.** New pages and APIs: `/api/insights`, `/api/sessions`, and `/api/agents`.
14
+ - **Command line.** `pi-cost overview|insights|projects|sessions|agents|session` prints the same numbers in the terminal, or as JSON with `--json`.
15
+ - **Color themes.** 18 theme families in a theme menu, in light and dark.
16
+ - **pi-kanban pane.** `GET /api/companion` and the embed theme handshake let the pi-kanban board show pi-cost in a pane, in the board's colors.
17
+ - Keyboard help, loading skeletons, and fonts served locally.
18
+ - Follows pi's directory settings: `PI_CODING_AGENT_DIR`, `--session-dir`, `PI_CODING_AGENT_SESSION_DIR`, and `sessionDir` in `settings.json`.
19
+ - Uses the cost that pi records for each message.
20
+
21
+ ### Security
22
+
23
+ - The server refuses requests with a non-local `Host` header and cross-site requests that change data.
24
+ - A content security policy lets only local pages frame pi-cost.
25
+
26
+ ### Other
27
+
28
+ - Express 5 and current dependencies.
29
+ - A `node:test` suite for the parsers and routes.
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # pi-cost
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/pi-cost.svg)](https://www.npmjs.com/package/pi-cost)
4
+ [![npm downloads](https://img.shields.io/npm/dm/pi-cost.svg)](https://www.npmjs.com/package/pi-cost)
5
+
3
6
  Cost dashboard for the [pi coding agent](https://pi.dev) — overview → project → session → message, with actual and estimated spend.
4
7
 
5
8
  [![pi-cost demo](./assets/session-light.png)](https://github.com/user-attachments/assets/f9383f3f-4379-4ca6-b920-badb35f5eea8)
@@ -24,7 +27,15 @@ Or run standalone:
24
27
  npx pi-cost
25
28
  ```
26
29
 
27
- Open `http://localhost:5461` — `npm run dev` auto-opens the browser.
30
+ Open `http://localhost:5461` — `npm run dev` auto-opens the browser. pi-cost reads the agent dir in `PI_CODING_AGENT_DIR` (default `~/.pi/agent`).
31
+
32
+ The same numbers from the command line:
33
+
34
+ ```sh
35
+ npx pi-cost overview --range 30d
36
+ npx pi-cost sessions --match refactor --json
37
+ npx pi-cost help
38
+ ```
28
39
 
29
40
  ### From source
30
41
 
@@ -33,6 +44,7 @@ git clone https://github.com/NikiforovAll/pi-cost
33
44
  cd pi-cost
34
45
  npm install
35
46
  npm start # http://localhost:5461
47
+ npm test # node:test suite on synthetic sessions
36
48
  pi --add ./extensions # register as a pi extension
37
49
  ```
38
50
 
@@ -1,47 +1,25 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
- import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
3
  import { createConnection } from "node:net";
4
- import { homedir } from "node:os";
5
- import { join as joinPath, resolve as resolvePath } from "node:path";
4
+ import { resolve as resolvePath } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
7
 
9
8
  const extDir = fileURLToPath(new URL('.', import.meta.url));
9
+ const requireLib = createRequire(import.meta.url);
10
+ const parsers = requireLib("../lib/pi-parsers-cost.js");
11
+ const { readServerRecord, isAlive } = requireLib("../lib/cost-dir.js");
10
12
 
11
13
  let child: ChildProcess | null = null;
12
14
  let lastStderr = "";
13
- const port = 5461;
14
-
15
- const COST_DIR = joinPath(homedir(), ".pi", "agent", "cost");
16
- const SETTINGS_PATH = joinPath(COST_DIR, "settings.json");
17
- const DEFAULT_THEME_DIR = joinPath(COST_DIR, "themes");
18
-
19
- type CostSettings = {
20
- themes?: { light?: string; dark?: string; dir?: string };
21
- };
22
-
23
- function readSettings(): CostSettings {
24
- try {
25
- return JSON.parse(readFileSync(SETTINGS_PATH, "utf8")) as CostSettings;
26
- } catch (e: any) {
27
- if (e?.code !== "ENOENT") console.warn(`pi-cost: cannot read ${SETTINGS_PATH}: ${e.message}`);
28
- return {};
29
- }
30
- }
15
+ const PREFERRED_PORT = 5461;
31
16
 
32
- function buildServerEnv(): NodeJS.ProcessEnv {
33
- const settings = readSettings();
34
- const t = settings.themes ?? {};
35
- const env: NodeJS.ProcessEnv = { ...process.env, PORT: String(port) };
36
- env.COST_THEME_DIR = process.env.COST_THEME_DIR ?? t.dir ?? DEFAULT_THEME_DIR;
37
- if (process.env.COST_LIGHT_THEME ?? t.light) {
38
- env.COST_LIGHT_THEME = process.env.COST_LIGHT_THEME ?? t.light;
39
- }
40
- if (process.env.COST_DARK_THEME ?? t.dark) {
41
- env.COST_DARK_THEME = process.env.COST_DARK_THEME ?? t.dark;
42
- }
43
- return env;
44
- }
17
+ // The server follows the same agent dir as this pi (PI_CODING_AGENT_DIR, else ~/.pi/agent), so a
18
+ // dev pi and the main pi each get their own dashboard. The server reads theme settings itself.
19
+ const AGENT_DIR: string = parsers.getAgentDir();
20
+ const COST_DIR: string = parsers.getCostDir();
21
+
22
+ type ServerRecord = { pid: number; port: number };
45
23
 
46
24
  const SUBCOMMANDS = ["start", "stop", "restart", "status", "open"] as const;
47
25
  type Sub = (typeof SUBCOMMANDS)[number];
@@ -57,35 +35,25 @@ function probePort(p: number, timeoutMs = 250): Promise<boolean> {
57
35
  });
58
36
  }
59
37
 
60
- async function waitForPort(p: number, totalMs = 5000): Promise<boolean> {
38
+ async function runningServer(): Promise<ServerRecord | null> {
39
+ const rec: ServerRecord | null = readServerRecord(COST_DIR);
40
+ return rec && (await probePort(rec.port)) ? rec : null;
41
+ }
42
+
43
+ async function waitForServer(pid: number, totalMs = 8000): Promise<ServerRecord | null> {
61
44
  const start = Date.now();
62
45
  while (Date.now() - start < totalMs) {
63
- if (await probePort(p)) return true;
46
+ const rec = await runningServer();
47
+ if (rec && rec.pid === pid) return rec;
64
48
  await new Promise((r) => setTimeout(r, 150));
65
49
  }
66
- return false;
50
+ return null;
67
51
  }
68
52
 
69
53
  function splitArgs(args: string): string[] {
70
54
  return args.trim().split(/\s+/).filter(Boolean);
71
55
  }
72
56
 
73
- function findPidsOnPort(p: number): number[] {
74
- if (process.platform === "win32") {
75
- const r = spawnSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf8" });
76
- if (r.status !== 0) return [];
77
- const pids = new Set<number>();
78
- for (const line of r.stdout.split(/\r?\n/)) {
79
- const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
80
- if (m && Number(m[1]) === p) pids.add(Number(m[2]));
81
- }
82
- return [...pids];
83
- }
84
- const r = spawnSync("lsof", ["-tiTCP:" + p, "-sTCP:LISTEN"], { encoding: "utf8" });
85
- if (r.status !== 0) return [];
86
- return r.stdout.split(/\s+/).map((s) => Number(s)).filter((n) => Number.isFinite(n) && n > 0);
87
- }
88
-
89
57
  function killPid(pid: number): void {
90
58
  if (process.platform === "win32") {
91
59
  spawnSync("taskkill", ["/F", "/PID", String(pid)], { stdio: "ignore" });
@@ -96,18 +64,20 @@ function killPid(pid: number): void {
96
64
 
97
65
  export default function costExtension(pi: ExtensionAPI) {
98
66
  const serverPath = resolvePath(extDir, "..", "server.js");
99
- const url = `http://localhost:${port}`;
67
+ const urlOf = (rec: ServerRecord) => `http://localhost:${rec.port}`;
100
68
 
101
69
  type Notify = (m: string, l?: "info" | "error") => void;
102
70
 
103
- async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
104
- if (await probePort(port)) {
105
- notify(`pi-cost already listening on ${url} — run /cost open to launch it`);
106
- return true;
71
+ async function startServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<ServerRecord | null> {
72
+ const existing = await runningServer();
73
+ if (existing) {
74
+ if (!opts.silentSuccess) notify(`pi-cost already listening on ${urlOf(existing)} — run /cost open to launch it`);
75
+ return existing;
107
76
  }
108
77
  lastStderr = "";
109
- child = spawn(process.execPath, [serverPath], {
110
- env: buildServerEnv(),
78
+ // No --port: the server takes PORT, and a random port when that one is in use.
79
+ child = spawn(process.execPath, [serverPath, "--no-open"], {
80
+ env: { ...process.env, PI_CODING_AGENT_DIR: AGENT_DIR, PORT: String(PREFERRED_PORT) },
111
81
  stdio: ["ignore", "ignore", "pipe"],
112
82
  detached: true,
113
83
  windowsHide: true,
@@ -115,35 +85,34 @@ export default function costExtension(pi: ExtensionAPI) {
115
85
  child.stderr?.on("data", (b) => { lastStderr += b.toString(); });
116
86
  child.on("exit", () => { child = null; });
117
87
 
118
- if (!(await waitForPort(port))) {
88
+ const rec = child.pid ? await waitForServer(child.pid) : null;
89
+ if (!rec || !child) {
119
90
  notify(`pi-cost failed to start.\n${lastStderr.slice(-500) || "(no stderr)"}`, "error");
120
- return false;
91
+ return null;
121
92
  }
122
93
  child.stderr?.removeAllListeners("data");
123
94
  child.stderr?.resume();
124
95
  child.unref();
125
- if (!opts.silentSuccess) notify(`pi-cost started → ${url} — run /cost open to launch it`);
126
- return true;
96
+ if (!opts.silentSuccess) notify(`pi-cost started → ${urlOf(rec)} — run /cost open to launch it`);
97
+ return rec;
127
98
  }
128
99
 
129
100
  async function stopServer(notify: Notify, opts: { silentSuccess?: boolean } = {}): Promise<boolean> {
101
+ const rec = await runningServer();
130
102
  if (child) child.kill("SIGINT");
131
103
  child = null;
132
-
133
- if (await probePort(port)) {
134
- const pids = findPidsOnPort(port);
135
- for (const pid of pids) killPid(pid);
136
- await new Promise((r) => setTimeout(r, 300));
137
- if (await probePort(port)) {
138
- notify(`port ${port} still in use after killing pids ${pids.join(",") || "?"}`, "error");
139
- return false;
140
- }
141
- if (!opts.silentSuccess) {
142
- notify(`pi-cost stopped (killed orphan pid${pids.length > 1 ? "s" : ""} ${pids.join(",")})`);
143
- }
104
+ if (!rec) {
105
+ if (!opts.silentSuccess) notify("pi-cost stopped");
144
106
  return true;
145
107
  }
146
- if (!opts.silentSuccess) notify("pi-cost stopped");
108
+ await new Promise((r) => setTimeout(r, 300));
109
+ if (isAlive(rec.pid)) killPid(rec.pid);
110
+ await new Promise((r) => setTimeout(r, 300));
111
+ if (await probePort(rec.port)) {
112
+ notify(`port ${rec.port} still in use after killing pid ${rec.pid}`, "error");
113
+ return false;
114
+ }
115
+ if (!opts.silentSuccess) notify(`pi-cost stopped (pid ${rec.pid})`);
147
116
  return true;
148
117
  }
149
118
 
@@ -160,19 +129,20 @@ export default function costExtension(pi: ExtensionAPI) {
160
129
  if (sub === "stop") { await stopServer(notify); return; }
161
130
  if (sub === "restart") {
162
131
  if (!(await stopServer(notify, { silentSuccess: true }))) return;
163
- if (!(await startServer(notify, { silentSuccess: true }))) return;
164
- notify(`pi-cost restarted → ${url}`);
132
+ const rec = await startServer(notify, { silentSuccess: true });
133
+ if (rec) notify(`pi-cost restarted → ${urlOf(rec)}`);
165
134
  return;
166
135
  }
167
136
  if (sub === "status") {
168
- const up = await probePort(port);
169
- const owned = child ? ` (pid ${child.pid})` : " (external)";
170
- notify(up ? `running on ${url}${owned}` : "not running");
137
+ const rec = await runningServer();
138
+ notify(rec ? `running on ${urlOf(rec)} (pid ${rec.pid}, agent dir ${AGENT_DIR})` : "not running");
171
139
  return;
172
140
  }
173
141
  if (sub === "open") {
142
+ const rec = (await runningServer()) ?? (await startServer(notify, { silentSuccess: true }));
143
+ if (!rec) return;
174
144
  const { default: open } = await import("open");
175
- await open(url);
145
+ await open(urlOf(rec));
176
146
  return;
177
147
  }
178
148
  notify(`Usage: /cost ${SUBCOMMANDS.join("|")}`, "error");
package/lib/app.js ADDED
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const path = require('node:path');
5
+ const parsers = require('./pi-parsers-cost');
6
+ const pricing = require('./pi-pricing');
7
+ const costs = require('./costs');
8
+
9
+ const VERSION = require('../package.json').version;
10
+ // Served from node_modules, not a CDN: the page must work offline inside the pi-kanban board.
11
+ const CHART_JS = path.join(path.dirname(require.resolve('chart.js')), 'chart.umd.min.js');
12
+
13
+ /**
14
+ * @param {{net: ReturnType<import('./net-guard').createNetGuard>, themes: ReturnType<import('./themes').createThemeStore>}} deps
15
+ */
16
+ function createApp({ net, themes }) {
17
+ const app = express();
18
+
19
+ // Before express.json() so a rejected request never buffers a body.
20
+ app.use(net.hostGuard);
21
+ app.use(net.frameGuard);
22
+ app.use(net.originGuard);
23
+ app.use(express.json());
24
+ app.use(express.static(path.join(__dirname, '..', 'public')));
25
+ app.get('/vendor/chart.umd.min.js', (_req, res) => res.sendFile(CHART_JS));
26
+
27
+ // A custom from/to wins over `range`. null is the all-time range, so malformed input is rejected
28
+ // here rather than passed on as a sentinel.
29
+ const withRange = (fallback = null) => (req, res, next) => {
30
+ const custom = costs.parseCustomRange(req.query);
31
+ if (custom === null) return res.status(400).json({ error: 'Invalid date range' });
32
+ req.range = custom || costs.parseRange(req.query.range, fallback);
33
+ next();
34
+ };
35
+
36
+ const withProject = (req, res, next) => {
37
+ const project = costs.parseProject(req.query.project);
38
+ if (project === null) return res.status(400).json({ error: 'Invalid project' });
39
+ req.project = project;
40
+ next();
41
+ };
42
+
43
+ const handle = (label, fn) => async (req, res) => {
44
+ try {
45
+ const data = await fn(req);
46
+ if (data == null) return res.status(404).json({ error: `${label} not found` });
47
+ res.json(data);
48
+ } catch (err) {
49
+ console.error(`[api] ${label}:`, err);
50
+ res.status(500).json({ error: `Failed to load ${label}` });
51
+ }
52
+ };
53
+
54
+ app.get('/api/themes', async (_req, res) => {
55
+ await themes.ready;
56
+ res.json({
57
+ light: themes.active('light'),
58
+ dark: themes.active('dark'),
59
+ families: themes.families(),
60
+ config: { lightId: themes.config.lightId, darkId: themes.config.darkId, themeDir: themes.config.dir },
61
+ });
62
+ });
63
+
64
+ // pi-kanban probes this before it frames the page; `session` is the deep-link template.
65
+ app.get('/api/companion', (_req, res) => {
66
+ res.json({ name: 'pi-cost', version: VERSION, session: '/?session={id}' });
67
+ });
68
+
69
+ app.get('/pi-config', (req, res) => {
70
+ res.json({
71
+ agentDir: parsers.getAgentDir(),
72
+ sessionsDir: parsers.getSessionsDir(),
73
+ sessionRoots: parsers.getSessionRoots(),
74
+ port: req.socket.localPort,
75
+ });
76
+ });
77
+
78
+ app.get('/api/overview', withRange(30), withProject, handle('overview', (req) => costs.getOverviewData(req.range, req.project)));
79
+ app.get('/api/insights', withRange(30), withProject, handle('insights', (req) => costs.getInsightsData(req.range, req.project)));
80
+ app.get('/api/projects', withRange(), handle('projects', (req) => costs.getProjectsData(req.range)));
81
+ app.get('/api/projects/:encoded/sessions', withRange(), handle('project sessions', (req) => costs.getProjectSessionsData(req.params.encoded, req.range)));
82
+ app.get('/api/sessions', withRange(), withProject, handle('sessions', (req) => costs.getAllSessionsData(req.range, req.project)));
83
+ app.get('/api/agents', withRange(30), withProject, handle('agents', (req) => costs.getAgentsData(req.range, req.project)));
84
+ app.get('/api/sessions/:id', withRange(), handle('session', async (req) => {
85
+ const base = await costs.getSessionDetailData(req.params.id);
86
+ return base && costs.withSessionSlices(base, req.range);
87
+ }));
88
+
89
+ app.get('/api/pricing', handle('pricing', async () => {
90
+ const map = await pricing.fetchPricing();
91
+ const models = [];
92
+ for (const [key, info] of map) {
93
+ models.push({
94
+ key,
95
+ input: info.input_cost_per_token,
96
+ output: info.output_cost_per_token,
97
+ cacheRead: info.cache_read_input_token_cost,
98
+ cacheWrite: info.cache_creation_input_token_cost,
99
+ });
100
+ }
101
+ return { models, ...pricing.getMeta() };
102
+ }));
103
+
104
+ app.post('/api/refresh', (_req, res) => {
105
+ costs.invalidateAllCache();
106
+ pricing.refresh();
107
+ res.json({ ok: true });
108
+ });
109
+
110
+ return app;
111
+ }
112
+
113
+ module.exports = { createApp, VERSION };
package/lib/args.js ADDED
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ // `--name value` or `--name=value`. A following flag is not taken as the value.
4
+ function getArg(name, argv = process.argv) {
5
+ const idx = argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
6
+ if (idx === -1) return null;
7
+ const arg = argv[idx];
8
+ if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
9
+ const next = argv[idx + 1];
10
+ return next !== undefined && !next.startsWith('--') ? next : null;
11
+ }
12
+
13
+ function hasFlag(name, argv = process.argv) {
14
+ return argv.includes(`--${name}`);
15
+ }
16
+
17
+ module.exports = { getArg, hasFlag };