@prowl-ai/prowl-skill 0.4.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.
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /*
4
+ * The Prowl plugin installer.
5
+ *
6
+ * npx @prowl-ai/prowl-skill install the plugin (and the CLI skill)
7
+ * npx @prowl-ai/prowl-skill update refresh an installed copy
8
+ * npx @prowl-ai/prowl-skill statusline wire the status line, with a backup taken first
9
+ * npx @prowl-ai/prowl-skill token store an API key at ~/.prowl/prowl_mcp_token
10
+ * npx @prowl-ai/prowl-skill status what is installed, and what is missing
11
+ * npx @prowl-ai/prowl-skill plain a plain skill copy, for agents with no plugin channel
12
+ *
13
+ * **It installs the PLUGIN, not a copy of the skill**, and that is the one design
14
+ * decision worth stating. The obvious shape — copy `skills/prowl/` into
15
+ * `~/.claude/skills/` — would deliver the text and silently drop everything the text
16
+ * is about: the hooks that make a running call visible and the MCP server that
17
+ * answers it. Worse, a plain copy under `~/.claude/skills/<id>` **shadows** an
18
+ * installed plugin of the same name and serves its frozen version forever.
19
+ *
20
+ * So `plain` exists for agents that have no plugin channel, and it refuses to run
21
+ * where the plugin is installed unless it is forced.
22
+ *
23
+ * Zero dependencies, on purpose: this runs through `npx` on a machine that has agreed
24
+ * to nothing yet.
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+ const { spawnSync } = require('child_process');
31
+
32
+ const ROOT = path.resolve(__dirname, '..');
33
+ const REPO = 'PROWL-AI/prowl-skill';
34
+ const MARKETPLACE = 'prowl';
35
+ const PLUGINS = ['prowl', 'prowl-cli'];
36
+ const TOKEN_FILE = ['.prowl', 'prowl_mcp_token'];
37
+
38
+ function version() {
39
+ try {
40
+ return require(path.join(ROOT, 'package.json')).version;
41
+ } catch (e) {
42
+ return 'unknown';
43
+ }
44
+ }
45
+
46
+ function usage() {
47
+ process.stdout.write(`prowl-skill installer v${version()}
48
+
49
+ Usage:
50
+ npx @prowl-ai/prowl-skill [install] add the marketplace and install the plugins
51
+ npx @prowl-ai/prowl-skill update refresh an installed copy
52
+ npx @prowl-ai/prowl-skill statusline wire the Prowl status line into ~/.claude/settings.json
53
+ npx @prowl-ai/prowl-skill token store an API key (reads stdin, never echoes it)
54
+ npx @prowl-ai/prowl-skill status report what is installed
55
+ npx @prowl-ai/prowl-skill plain copy the skills into ~/.claude/skills (agents without plugins)
56
+ npx @prowl-ai/prowl-skill --help | --version
57
+
58
+ Flags: --force (overwrite), --dry-run (print the commands, change nothing)
59
+
60
+ The plugin carries the hooks and the MCP server; a plain skill copy carries neither
61
+ and shadows an installed plugin. Prefer 'install'.
62
+ `);
63
+ }
64
+
65
+ /** Is Claude Code on PATH? Its absence is a fact to report, never a crash. */
66
+ function haveClaude() {
67
+ const r = spawnSync('claude', ['--version'], { encoding: 'utf8' });
68
+ return !r.error && r.status === 0;
69
+ }
70
+
71
+ /** Run a command, or print it under --dry-run. Returns {ok, out}. */
72
+ function run(cmd, args, opts) {
73
+ const o = opts || {};
74
+ if (o.dryRun) {
75
+ process.stdout.write(`would run: ${cmd} ${args.join(' ')}\n`);
76
+ return { ok: true, out: '', skipped: true };
77
+ }
78
+ const r = spawnSync(cmd, args, { encoding: 'utf8' });
79
+ const out = `${r.stdout || ''}${r.stderr || ''}`.trim();
80
+ return { ok: !r.error && r.status === 0, out };
81
+ }
82
+
83
+ /**
84
+ * The `statusLine` block this plugin wants, pointing at a stable path.
85
+ *
86
+ * Not at the plugin's own directory: that path carries the version, so a line wired
87
+ * to it breaks on the next update — silently, since a status line that fails prints
88
+ * nothing either way.
89
+ */
90
+ function statusLineBlock(scriptPath) {
91
+ return {
92
+ type: 'command',
93
+ command: `node ${JSON.stringify(scriptPath)}`,
94
+ refreshInterval: 2,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Merge the block into settings, reporting what it would displace.
100
+ *
101
+ * Pure, so the decision is fixtured without a home directory. It never removes a
102
+ * status line somebody else set: replacing one loses whatever it printed, and that is
103
+ * how a person discovers their configuration changed by noticing it is gone.
104
+ */
105
+ function planStatusLine(current, scriptPath, force) {
106
+ const next = JSON.parse(JSON.stringify(current || {}));
107
+ const want = statusLineBlock(scriptPath);
108
+ const existing = next.statusLine;
109
+ const ours = existing && typeof existing.command === 'string'
110
+ && existing.command.includes('prowl-statusline');
111
+
112
+ if (existing && !ours && !force) {
113
+ return { settings: next, changed: false, conflict: existing.command || '(set)' };
114
+ }
115
+ next.statusLine = want;
116
+ return { settings: next, changed: JSON.stringify(existing) !== JSON.stringify(want), conflict: null };
117
+ }
118
+
119
+ /**
120
+ * Copy the file, read it back, and compare — a backup that was not verified is a
121
+ * habit rather than a mechanism. A copy that cannot be taken cancels the write.
122
+ */
123
+ function backup(file) {
124
+ if (!fs.existsSync(file)) return { ok: true, path: null };
125
+ const dir = path.join(os.homedir(), '.prowl', 'backups');
126
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
127
+ const dest = path.join(dir, `${path.basename(file)}.${stamp}`);
128
+ try {
129
+ fs.mkdirSync(dir, { recursive: true });
130
+ fs.copyFileSync(file, dest);
131
+ if (fs.readFileSync(dest, 'utf8') !== fs.readFileSync(file, 'utf8')) {
132
+ return { ok: false, path: null };
133
+ }
134
+ return { ok: true, path: dest };
135
+ } catch (e) {
136
+ return { ok: false, path: null };
137
+ }
138
+ }
139
+
140
+ function copyDir(src, dest) {
141
+ fs.mkdirSync(dest, { recursive: true });
142
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
143
+ const s = path.join(src, e.name);
144
+ const d = path.join(dest, e.name);
145
+ if (e.isDirectory()) copyDir(s, d);
146
+ else fs.copyFileSync(s, d);
147
+ }
148
+ }
149
+
150
+ // --------------------------------------------------------------------- commands
151
+
152
+ function cmdInstall(opts) {
153
+ if (!haveClaude() && !opts.dryRun) {
154
+ process.stdout.write(
155
+ 'Claude Code is not on PATH, so the plugin channel is unavailable here.\n'
156
+ + `Install it, or run: npx @prowl-ai/prowl-skill plain (skills only, no hooks, no MCP)\n`);
157
+ return 1;
158
+ }
159
+ const add = run('claude', ['plugin', 'marketplace', 'add', REPO], opts);
160
+ if (!add.ok && !/already/i.test(add.out)) {
161
+ process.stdout.write(`could not add the marketplace:\n${add.out}\n`);
162
+ return 1;
163
+ }
164
+ let failed = 0;
165
+ for (const p of PLUGINS) {
166
+ const r = run('claude', ['plugin', 'install', `${p}@${MARKETPLACE}`], opts);
167
+ process.stdout.write(`${r.ok ? '✔' : '✘'} ${p}@${MARKETPLACE}${r.out ? ` ${r.out.split('\n').pop()}` : ''}\n`);
168
+ if (!r.ok) failed += 1;
169
+ }
170
+ if (!failed) {
171
+ process.stdout.write(
172
+ '\nRestart Claude Code — plugins and their hooks are read at session start.\n'
173
+ + 'Then: npx @prowl-ai/prowl-skill token (store your prowl_... key)\n'
174
+ + ' npx @prowl-ai/prowl-skill statusline (optional line at the bottom)\n');
175
+ }
176
+ return failed ? 1 : 0;
177
+ }
178
+
179
+ function cmdUpdate(opts) {
180
+ const m = run('claude', ['plugin', 'marketplace', 'update', MARKETPLACE], opts);
181
+ process.stdout.write(`${m.ok ? '✔' : '✘'} marketplace ${MARKETPLACE}\n`);
182
+ let stale = false;
183
+ for (const p of PLUGINS) {
184
+ const r = run('claude', ['plugin', 'update', `${p}@${MARKETPLACE}`], opts);
185
+ process.stdout.write(`${r.ok ? '✔' : '✘'} ${p}@${MARKETPLACE}${r.out ? ` ${r.out.split('\n').pop()}` : ''}\n`);
186
+ if (/already at the latest/i.test(r.out)) stale = true;
187
+ }
188
+ if (stale) {
189
+ // Measured, not assumed: an install copies the plugin into a directory named by
190
+ // its version, and `update` compares versions rather than contents. Same version,
191
+ // changed source, no refresh, no warning.
192
+ process.stdout.write(
193
+ '\nNote: a plugin already at the advertised version is NOT re-copied, even when\n'
194
+ + 'its source has changed. That is normal for a release; during development,\n'
195
+ + 'reinstall instead:\n'
196
+ + ` claude plugin uninstall prowl@${MARKETPLACE} && claude plugin install prowl@${MARKETPLACE}\n`);
197
+ }
198
+ return 0;
199
+ }
200
+
201
+ function cmdStatusLine(opts) {
202
+ const home = os.homedir();
203
+ const script = path.join(home, '.prowl', 'prowl-statusline.js');
204
+ const src = path.join(ROOT, 'plugins', 'prowl', 'statusline', 'prowl-statusline.js');
205
+ const settingsFile = path.join(home, '.claude', 'settings.json');
206
+
207
+ if (opts.dryRun) {
208
+ process.stdout.write(`would copy ${src} -> ${script}\n`);
209
+ process.stdout.write(`would set statusLine in ${settingsFile}\n`);
210
+ return 0;
211
+ }
212
+
213
+ let current = {};
214
+ try { current = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch (e) { current = {}; }
215
+
216
+ const plan = planStatusLine(current, script, opts.force);
217
+ if (plan.conflict) {
218
+ process.stdout.write(
219
+ `A status line is already set by something else:\n ${plan.conflict}\n`
220
+ + 'Replacing it would lose whatever it printed. Re-run with --force to replace it.\n');
221
+ return 1;
222
+ }
223
+
224
+ const b = backup(settingsFile);
225
+ if (!b.ok) {
226
+ process.stdout.write('could not back up settings.json, so nothing was written.\n');
227
+ return 1;
228
+ }
229
+
230
+ try {
231
+ fs.mkdirSync(path.dirname(script), { recursive: true });
232
+ fs.copyFileSync(src, script);
233
+ fs.mkdirSync(path.dirname(settingsFile), { recursive: true });
234
+ fs.writeFileSync(settingsFile, `${JSON.stringify(plan.settings, null, 2)}\n`, 'utf8');
235
+ } catch (e) {
236
+ process.stdout.write(`could not write the status line: ${e.message}\n`);
237
+ return 1;
238
+ }
239
+
240
+ process.stdout.write(
241
+ `✔ status line wired${b.path ? ` (settings backed up to ${b.path})` : ''}\n`
242
+ + ' It prints nothing until this session has called Prowl — that is the design.\n');
243
+ return 0;
244
+ }
245
+
246
+ function cmdToken(opts) {
247
+ const home = os.homedir();
248
+ const file = path.join(home, ...TOKEN_FILE);
249
+ const key = (process.env.PROWL_API_KEY || readStdin() || '').trim();
250
+
251
+ if (!key) {
252
+ process.stdout.write(
253
+ 'No key given. Pipe it in, so it never reaches your shell history:\n'
254
+ + ' printf %s "prowl_YOUR_KEY" | npx @prowl-ai/prowl-skill token\n'
255
+ + 'Generate one at https://prowl.chat -> MCP Home -> API keys & config.\n');
256
+ return 2;
257
+ }
258
+ if (!/^prowl_[0-9a-f]{32}$/i.test(key)) {
259
+ // Refuse early rather than write something that will fail at the first call with
260
+ // a 401 nobody connects back to this moment.
261
+ process.stdout.write('That does not look like a Prowl key (expected prowl_ + 32 hex).\n');
262
+ return 2;
263
+ }
264
+ if (opts.dryRun) { process.stdout.write(`would write a key to ${file} (mode 600)\n`); return 0; }
265
+
266
+ try {
267
+ fs.mkdirSync(path.dirname(file), { recursive: true });
268
+ fs.writeFileSync(file, key, { encoding: 'utf8', mode: 0o600 });
269
+ fs.chmodSync(file, 0o600);
270
+ } catch (e) {
271
+ process.stdout.write(`could not write ${file}: ${e.message}\n`);
272
+ return 1;
273
+ }
274
+ // The key is never printed back, here or anywhere else.
275
+ process.stdout.write(`✔ key stored at ${file} (mode 600)\n`);
276
+ return 0;
277
+ }
278
+
279
+ function readStdin() {
280
+ try {
281
+ if (process.stdin.isTTY) return '';
282
+ return fs.readFileSync(0, 'utf8');
283
+ } catch (e) {
284
+ return '';
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Where the running server would find a key — in `.mcp.json`'s own order.
290
+ *
291
+ * Returns a description of the source, never the key. Pure enough to fixture: it is
292
+ * given the home directory and reads the environment it was started with.
293
+ */
294
+ function tokenSource(home, env) {
295
+ const e = env || process.env;
296
+ if (e.PROWL_MCP_TOKEN) return 'present (PROWL_MCP_TOKEN)';
297
+ for (const p of [path.join(home, ...TOKEN_FILE), path.join(home, '.codex', 'prowl_mcp_token')]) {
298
+ try { if (fs.existsSync(p) && fs.readFileSync(p, 'utf8').trim()) return `present (${p})`; } catch (err) { /* unreadable is absent */ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function cmdStatus() {
304
+ const home = os.homedir();
305
+ const rows = [];
306
+ rows.push(['Claude Code', haveClaude() ? 'on PATH' : 'not found']);
307
+
308
+ // Ask the question `.mcp.json` actually asks, in its order: the env var, then this
309
+ // plugin's file, then Codex's. Reporting "absent" for a token the server would have
310
+ // found is worse than not reporting at all — it sends someone to re-issue a key
311
+ // that already works.
312
+ rows.push(['API key', tokenSource(home) || 'absent — run: npx @prowl-ai/prowl-skill token']);
313
+
314
+ let settings = {};
315
+ try { settings = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')); } catch (e) { /* none */ }
316
+ const sl = settings.statusLine && settings.statusLine.command;
317
+ rows.push(['Status line', sl ? (String(sl).includes('prowl-statusline') ? 'wired to Prowl' : 'set by something else') : 'not wired']);
318
+
319
+ for (const p of PLUGINS) {
320
+ const on = settings.enabledPlugins && settings.enabledPlugins[`${p}@${MARKETPLACE}`];
321
+ rows.push([`Plugin ${p}`, on === true ? 'enabled' : on === false ? 'installed, disabled' : 'not installed']);
322
+ }
323
+
324
+ const statusDir = path.join(home, '.prowl', 'status');
325
+ let sessions = 0;
326
+ try { sessions = fs.readdirSync(statusDir).filter((f) => f.endsWith('.json')).length; } catch (e) { /* none */ }
327
+ rows.push(['Sessions recorded', String(sessions)]);
328
+
329
+ for (const [k, v] of rows) process.stdout.write(`${k.padEnd(19)}${v}\n`);
330
+ return 0;
331
+ }
332
+
333
+ function cmdPlain(opts) {
334
+ const home = os.homedir();
335
+ let failed = 0;
336
+ for (const p of PLUGINS) {
337
+ const dest = path.join(home, '.claude', 'skills', p);
338
+ const src = path.join(ROOT, 'plugins', p, 'skills', p);
339
+
340
+ // One channel per agent. A plain copy beside an installed plugin is two listings
341
+ // of the same skill, and the stale one wins — forever, and quietly.
342
+ const installed = fs.existsSync(path.join(home, '.claude', 'plugins', 'marketplaces', MARKETPLACE))
343
+ || fs.existsSync(path.join(home, '.claude', 'plugins', 'cache', MARKETPLACE, p));
344
+ if (installed && !opts.force) {
345
+ process.stdout.write(
346
+ `skip: ${p} is installed as a plugin; a plain copy would shadow it and serve\n`
347
+ + ` its frozen version forever. Update instead:\n`
348
+ + ` npx @prowl-ai/prowl-skill update\n`
349
+ + ' Pass --force if you really want both.\n');
350
+ continue;
351
+ }
352
+ if (fs.existsSync(dest) && !opts.force) {
353
+ process.stdout.write(`skip: ${dest} exists (pass --force to overwrite)\n`);
354
+ continue;
355
+ }
356
+ if (opts.dryRun) { process.stdout.write(`would copy ${src} -> ${dest}\n`); continue; }
357
+ try {
358
+ fs.rmSync(dest, { recursive: true, force: true });
359
+ copyDir(src, dest);
360
+ process.stdout.write(`✔ ${p} -> ${dest}\n`);
361
+ } catch (e) {
362
+ process.stdout.write(`✘ ${p}: ${e.message}\n`);
363
+ failed += 1;
364
+ }
365
+ }
366
+ process.stdout.write('\nThe skills are text: no hooks, no status widget, no MCP server.\n');
367
+ return failed ? 1 : 0;
368
+ }
369
+
370
+ // ------------------------------------------------------------------------- main
371
+
372
+ function parse(argv) {
373
+ const opts = { force: argv.includes('--force'), dryRun: argv.includes('--dry-run') };
374
+ const verb = argv.find((a) => !a.startsWith('-')) || 'install';
375
+ return { verb, opts };
376
+ }
377
+
378
+ function main(argv) {
379
+ if (argv.includes('--help') || argv.includes('-h')) { usage(); return 0; }
380
+ if (argv.includes('--version') || argv.includes('-v')) { process.stdout.write(`${version()}\n`); return 0; }
381
+
382
+ const { verb, opts } = parse(argv);
383
+ switch (verb) {
384
+ case 'install': return cmdInstall(opts);
385
+ case 'update': return cmdUpdate(opts);
386
+ case 'statusline': return cmdStatusLine(opts);
387
+ case 'token': return cmdToken(opts);
388
+ case 'status': return cmdStatus();
389
+ case 'plain': return cmdPlain(opts);
390
+ default:
391
+ process.stdout.write(`unknown command: ${verb}\n\n`);
392
+ usage();
393
+ return 2;
394
+ }
395
+ }
396
+
397
+ module.exports = {
398
+ parse, planStatusLine, statusLineBlock, backup, tokenSource, version,
399
+ REPO, MARKETPLACE, PLUGINS, TOKEN_FILE,
400
+ };
401
+
402
+ if (require.main === module) process.exit(main(process.argv.slice(2)));
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@prowl-ai/prowl-skill",
3
+ "version": "0.4.0",
4
+ "description": "The Prowl plugin: install/usage skills, the hosted MCP config, and the live status widget that makes a running Prowl call, its progress and its cost visible in the terminal. This package is the installer CLI.",
5
+ "keywords": [
6
+ "prowl",
7
+ "mcp",
8
+ "market-intelligence",
9
+ "competitive-analysis",
10
+ "seo",
11
+ "claude-code",
12
+ "plugin",
13
+ "agent-skills",
14
+ "status-line",
15
+ "hooks"
16
+ ],
17
+ "homepage": "https://prowl.chat/",
18
+ "bugs": {
19
+ "url": "https://github.com/PROWL-AI/prowl-skill/issues"
20
+ },
21
+ "repository": "github:PROWL-AI/prowl-skill",
22
+ "license": "MIT",
23
+ "author": {
24
+ "name": "Prowl",
25
+ "email": "support@prowl.chat",
26
+ "url": "https://prowl.chat/"
27
+ },
28
+ "bin": {
29
+ "prowl-skill": "bin/prowl-skill.js"
30
+ },
31
+ "scripts": {
32
+ "test": "node test/run.js",
33
+ "check:tools": "node scripts/check-tool-count.js"
34
+ },
35
+ "files": [
36
+ "bin",
37
+ "plugins",
38
+ "README.md",
39
+ "LICENSE",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ },
46
+ "engines": {
47
+ "node": ">=18"
48
+ }
49
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "prowl",
3
+ "description": "Prowl market-intelligence skill backed by the hosted Prowl MCP server (448 tools), with a status widget that makes a running call, its progress and its cost visible in the terminal.",
4
+ "version": "0.3.0",
5
+ "author": {
6
+ "name": "Prowl",
7
+ "email": "support@prowl.chat",
8
+ "url": "https://prowl.chat/"
9
+ },
10
+ "homepage": "https://prowl.chat/",
11
+ "repository": "https://github.com/PROWL-AI/prowl-skill",
12
+ "license": "MIT"
13
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "prowl",
3
+ "version": "0.3.0",
4
+ "description": "Prowl market-intelligence skill backed by the hosted Prowl MCP server (448 tools), with a status widget that makes a running call, its progress and its cost visible in the terminal.",
5
+ "author": {
6
+ "name": "Prowl",
7
+ "email": "support@prowl.chat",
8
+ "url": "https://prowl.chat/"
9
+ },
10
+ "homepage": "https://prowl.chat/",
11
+ "repository": "https://github.com/PROWL-AI/prowl-skill",
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "prowl",
15
+ "market-intelligence",
16
+ "competitive-analysis",
17
+ "seo",
18
+ "ads",
19
+ "mcp"
20
+ ],
21
+ "skills": "./skills/",
22
+ "mcpServers": "./.mcp.json",
23
+ "interface": {
24
+ "displayName": "Prowl",
25
+ "shortDescription": "One MCP, 448 market-intelligence tools",
26
+ "longDescription": "Connect the hosted Prowl MCP server to give any agent 448 market-intelligence tools — SEO, ads, SERP, scraping, AI — plus the full Prowl analysis pipeline, billed pay-as-you-go from a USD wallet. Ships a status widget: an in-flight call and how long it has run, session spend from the server's own billing figures, async-session progress in the dock, and a one-time notice when a call fails or the wallet blocks one.",
27
+ "developerName": "Prowl",
28
+ "category": "Research",
29
+ "capabilities": [
30
+ "Read",
31
+ "Write"
32
+ ],
33
+ "websiteURL": "https://prowl.chat/",
34
+ "privacyPolicyURL": "https://prowl.chat/privacy/",
35
+ "termsOfServiceURL": "https://prowl.chat/terms/",
36
+ "defaultPrompt": [
37
+ "List the Prowl tools available",
38
+ "Run a competitive analysis on a domain",
39
+ "Pull SERP and keyword data for a topic"
40
+ ],
41
+ "brandColor": "#00ff41",
42
+ "screenshots": []
43
+ }
44
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "prowl": {
4
+ "command": "sh",
5
+ "args": [
6
+ "-lc",
7
+ "TOKEN=\"${PROWL_MCP_TOKEN:-}\"; if [ -z \"$TOKEN\" ] && [ -f \"$HOME/.prowl/prowl_mcp_token\" ]; then TOKEN=\"$(cat \"$HOME/.prowl/prowl_mcp_token\")\"; fi; if [ -z \"$TOKEN\" ] && [ -f \"$HOME/.codex/prowl_mcp_token\" ]; then TOKEN=\"$(cat \"$HOME/.codex/prowl_mcp_token\")\"; fi; exec npx -y mcp-remote https://prowl.chat/mcp --header \"Authorization: Bearer $TOKEN\" --transport http-first --silent"
8
+ ]
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "description": "Prowl — the live status widget. Three observers, no gates: nothing here can refuse a tool call, block a turn or change what the agent does. Every hook exits 0 immediately for any tool that is not Prowl's, so installing the plugin costs nothing in a session that never calls it.",
3
+ "hooks": {
4
+ "PreToolUse": [
5
+ {
6
+ "matcher": "mcp__.*prowl.*",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.js\"",
11
+ "timeout": 10
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "PostToolUse": [
17
+ {
18
+ "matcher": "mcp__.*prowl.*",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use.js\"",
23
+ "statusMessage": "recording the Prowl call",
24
+ "timeout": 10
25
+ }
26
+ ]
27
+ }
28
+ ],
29
+ "SessionStart": [
30
+ {
31
+ "matcher": "startup|resume",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js\"",
36
+ "timeout": 10
37
+ }
38
+ ]
39
+ }
40
+ ]
41
+ }
42
+ }
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * `PostToolUse` — the call finished, and this is the only moment its cost, its
5
+ * verdict and its progress are all knowable.
6
+ *
7
+ * Three channels, each used for exactly one thing:
8
+ *
9
+ * - **the record** — closed in the session file, which is what the status line reads.
10
+ * This happens on every call, and is the whole widget.
11
+ * - **`terminalSequence`** — the dock/taskbar bar, but only from a fraction a
12
+ * response actually carried, and taken down when the session reports a terminal
13
+ * status rather than when a poll happens to end. A bar driven by elapsed time
14
+ * would be a guess wearing a progress bar's clothes.
15
+ * - **`systemMessage` + `additionalContext`** — the two events worth interrupting
16
+ * for: a failed call and a wallet gate. Once per fingerprint, because an agent that
17
+ * retries a failing call five times must not produce five notices. The agent gets
18
+ * the context so it stops retrying; the operator gets the message.
19
+ *
20
+ * `tool_response` arrives as `{type, content}` with `content` a **string**, so every
21
+ * number here is parsed out of text by `lib/prowl.js` and is absent when the text did
22
+ * not contain it. Absent is never zero.
23
+ *
24
+ * Fails silent, always exits 0.
25
+ */
26
+
27
+ const path = require('path');
28
+ const os = require('os');
29
+
30
+ const LIB = path.join(__dirname, '..', 'lib');
31
+
32
+ let raw = '';
33
+ process.stdin.setEncoding('utf8');
34
+ process.stdin.on('data', (c) => { raw += c; });
35
+ process.stdin.on('end', () => {
36
+ try {
37
+ if (!raw.trim().startsWith('{')) return process.exit(0);
38
+ const data = JSON.parse(raw);
39
+
40
+ const prowl = require(path.join(LIB, 'prowl.js'));
41
+ const rec = prowl.classify(data);
42
+ if (!rec || rec.kind !== 'close') return process.exit(0);
43
+
44
+ const state = require(path.join(LIB, 'state.js'));
45
+ const render = require(path.join(LIB, 'render.js'));
46
+ const osc = require(path.join(LIB, 'osc.js'));
47
+
48
+ const home = os.homedir();
49
+ const session = data.session_id;
50
+
51
+ state.closeCall(home, session, {
52
+ id: rec.id,
53
+ tool: rec.tool,
54
+ ok: rec.ok,
55
+ usd: rec.usd,
56
+ progress: typeof rec.progress === 'number' ? rec.progress : undefined,
57
+ progress_session: rec.progress_session,
58
+ at: new Date().toISOString(),
59
+ });
60
+
61
+ const out = {};
62
+ const sequences = [];
63
+
64
+ if (rec.finished === true) {
65
+ sequences.push(osc.progressClear());
66
+ // The fraction described a session that has ended; keeping it would leave the
67
+ // status line reporting progress for something finished.
68
+ state.update(home, session, (s) => { s.progress = null; return s; });
69
+ } else if (typeof rec.progress === 'number') {
70
+ sequences.push(osc.progress(rec.progress));
71
+ }
72
+
73
+ if (!rec.ok && rec.error) {
74
+ // Once per fingerprint, and the claim is taken in the same write that answers,
75
+ // so two calls failing at once cannot both be told they are the first.
76
+ if (state.claimSpeech(home, session, rec.fingerprint)) {
77
+ const notice = render.block(rec);
78
+ if (notice) {
79
+ out.systemMessage = notice;
80
+ out.hookSpecificOutput = { hookEventName: 'PostToolUse', additionalContext: notice };
81
+ }
82
+ if (rec.error.gate) sequences.push(osc.notify('Prowl', rec.error.message));
83
+ }
84
+ }
85
+
86
+ const seq = sequences.filter(Boolean).join('');
87
+ if (seq && osc.isAllowed(seq)) out.terminalSequence = seq;
88
+
89
+ if (Object.keys(out).length) process.stdout.write(`${JSON.stringify(out)}\n`);
90
+ } catch (e) {
91
+ /* Silence, deliberately. */
92
+ }
93
+ process.exit(0);
94
+ });