@settinghead/voxlert 0.3.9 → 0.3.11

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
@@ -8,11 +8,14 @@
8
8
  <a href="https://github.com/settinghead/voxlert/actions/workflows/cli-integration.yml">
9
9
  <img src="https://github.com/settinghead/voxlert/actions/workflows/cli-integration.yml/badge.svg" alt="CLI Integration" />
10
10
  </a>
11
+ <a href="https://fazier.com/launches/voxlert">
12
+ <img src="https://fazier.com/api/v1//public/badges/launch_badges.svg?badge_type=launched&theme=light" alt="Launched on Fazier" />
13
+ </a>
11
14
  </p>
12
15
 
13
16
  # Voxlert
14
17
 
15
- LLM-generated voice notifications for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor](https://cursor.com/docs/agent/hooks), [OpenAI Codex](https://developers.openai.com/codex/), and [OpenClaw](https://openclaw.dev), spoken by game characters like the StarCraft Adjutant, Kerrigan, C&C EVA, SHODAN, and more.
18
+ LLM-generated voice notifications for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor](https://cursor.com/docs/agent/hooks), [OpenAI Codex](https://developers.openai.com/codex/), [pi](https://github.com/badlogic/pi-mono), and [OpenClaw](https://openclaw.dev), spoken by game characters like the StarCraft Adjutant, Kerrigan, C&C EVA, SHODAN, and more.
16
19
 
17
20
  ## Why Voxlert?
18
21
 
@@ -24,7 +27,7 @@ Voxlert makes each session speak in a distinct character voice with its own tone
24
27
 
25
28
  Voxlert is for users who:
26
29
 
27
- - Run two or more AI coding agent sessions concurrently (Claude Code, Cursor, Codex, OpenClaw)
30
+ - Run two or more AI coding agent sessions concurrently (Claude Code, Cursor, Codex, pi, OpenClaw)
28
31
  - Get interrupted by notification chimes but can't tell which window needs attention
29
32
  - Want ambient audio feedback that doesn't require looking at a screen
30
33
  - Are comfortable installing local tooling (Node.js, optionally Python for TTS)
@@ -71,7 +74,7 @@ The setup wizard configures:
71
74
  - Voice pack downloads
72
75
  - Active voice pack
73
76
  - TTS backend
74
- - Platform hooks for Claude Code, Cursor, and Codex
77
+ - Platform hooks for Claude Code, Cursor, Codex, and pi
75
78
 
76
79
  For OpenClaw, install the separate [OpenClaw plugin](docs/openclaw.md).
77
80
 
@@ -113,12 +116,6 @@ Run tests locally with:
113
116
  npm test
114
117
  ```
115
118
 
116
- For release-impacting changes, add a changeset before opening a PR:
117
-
118
- ```bash
119
- npm run changeset
120
- ```
121
-
122
119
  ## Supported Voices
123
120
 
124
121
  The `sc1-adjutant` preview below uses the animated in-game portrait GIF from `assets/sc1-adjutant.gif`.
@@ -188,6 +185,27 @@ voxlert codex-notify
188
185
 
189
186
  `voxlert setup` can install or update the `notify` entry in `~/.codex/config.toml`. See [Codex integration](docs/codex.md).
190
187
 
188
+ ### pi
189
+
190
+ Installed through `voxlert setup`, which copies a TypeScript extension to `~/.pi/agent/extensions/voxlert.ts`. Alternatively, install the pi package directly:
191
+
192
+ ```bash
193
+ pi install npm:@settinghead/pi-voxlert
194
+ ```
195
+
196
+ The extension hooks into pi's lifecycle events and pipes them through `voxlert hook`:
197
+
198
+ | pi Event | Voxlert Event | Category |
199
+ |---|---|---|
200
+ | `agent_end` | Stop | `task.complete` |
201
+ | `tool_result` (error) | PostToolUseFailure | `task.error` |
202
+ | `session_shutdown` | SessionEnd | `session.end` |
203
+ | `session_before_compact` | PreCompact | `resource.limit` |
204
+
205
+ The extension also registers a `/voxlert` command (test, status) and a `voxlert_speak` tool that lets the LLM speak phrases on demand.
206
+
207
+ Run `/reload` in pi or start a new session after installing. See the [pi-voxlert README](pi-package/README.md) for details.
208
+
191
209
  ### OpenClaw
192
210
 
193
211
  OpenClaw uses a separate plugin. See [OpenClaw integration](docs/openclaw.md) for installation, config, and troubleshooting.
@@ -218,6 +236,7 @@ flowchart TD
218
236
  A2[OpenClaw Plugin] --> B
219
237
  A3[Cursor Hook] --> B
220
238
  A4[Codex notify] --> B
239
+ A5[pi Extension] --> B
221
240
  B --> C[src/voxlert.js]
222
241
  C --> D{Event type?}
223
242
  D -- "Contextual (e.g. Stop)" --> E[LLM<br><i>generate in-character phrase</i>]
@@ -233,7 +252,7 @@ flowchart TD
233
252
  J --> K[afplay / ffplay]
234
253
  ```
235
254
 
236
- 1. A hook or notify event fires from Claude Code, Cursor, Codex, or OpenClaw.
255
+ 1. A hook or notify event fires from Claude Code, Cursor, Codex, pi, or OpenClaw.
237
256
  2. Voxlert maps it to an event category and loads the active voice pack.
238
257
  3. Contextual events such as task completion or tool failure can use the configured LLM to generate a short in-character phrase.
239
258
  4. Other events use predefined fallback phrases from the pack.
@@ -278,7 +297,7 @@ Run `voxlert config path` to find `config.json`. You can edit it directly or use
278
297
 
279
298
  ### Event categories
280
299
 
281
- Event categories apply across Claude Code, Cursor, Codex, and OpenClaw where the corresponding event exists.
300
+ Event categories apply across Claude Code, Cursor, Codex, pi, and OpenClaw where the corresponding event exists.
282
301
 
283
302
  | Category | Hook Event | Description | Default |
284
303
  |---|---|---|---|
@@ -321,9 +340,9 @@ You can also manage configuration interactively with the `/voxlert-config` slash
321
340
 
322
341
  ### Integration behavior
323
342
 
324
- - `voxlert setup` installs hooks for Claude Code, Cursor, and Codex.
343
+ - `voxlert setup` installs hooks for Claude Code, Cursor, Codex, and pi.
325
344
  - Re-run setup anytime to add a platform you skipped earlier.
326
- - `voxlert uninstall` removes Claude Code, Cursor, and Codex integration.
345
+ - `voxlert uninstall` removes Claude Code, Cursor, Codex, and pi integration.
327
346
  - OpenClaw is managed separately through its plugin.
328
347
  - The global `enabled` flag disables processing everywhere; there is no separate per-integration toggle in `config.json`.
329
348
 
@@ -353,7 +372,7 @@ voxlert notification # Choose notification style (popup / system / off
353
372
  voxlert test "<text>" # Run full pipeline: LLM -> TTS -> audio playback
354
373
  voxlert cost # Show accumulated token usage and estimated cost
355
374
  voxlert cost reset # Clear the usage log
356
- voxlert uninstall # Remove hooks from Claude Code, Cursor, and Codex, optionally config/cache
375
+ voxlert uninstall # Remove hooks from Claude Code, Cursor, Codex, and pi, optionally config/cache
357
376
  voxlert help # Show help
358
377
  voxlert --version # Show version
359
378
  ```
@@ -371,7 +390,7 @@ voxlert uninstall
371
390
  npm uninstall -g @settinghead/voxlert
372
391
  ```
373
392
 
374
- This removes Voxlert hooks from Claude Code, Cursor, and Codex, the `voxlert-config` skill, and optionally your local config and cache in `~/.voxlert`.
393
+ This removes Voxlert hooks from Claude Code, Cursor, Codex, and pi, the `voxlert-config` skill, and optionally your local config and cache in `~/.voxlert`.
375
394
 
376
395
  ## Advanced
377
396
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@settinghead/voxlert",
3
- "version": "0.3.9",
4
- "description": "LLM-generated voice notifications for Claude Code, Cursor, OpenAI Codex, and OpenClaw, spoken by game characters like the StarCraft Adjutant, Kerrigan, C&C EVA, SHODAN, and more.",
3
+ "version": "0.3.11",
4
+ "description": "LLM-generated voice notifications for Claude Code, Cursor, OpenAI Codex, pi, and OpenClaw, spoken by game characters like the StarCraft Adjutant, Kerrigan, C&C EVA, SHODAN, and more.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/settinghead/voxlert.git"
@@ -16,6 +16,7 @@
16
16
  "packs/*/pack.json",
17
17
  "packs/*/voice.wav",
18
18
  "skills/",
19
+ "pi-package/extensions/",
19
20
  "assets/*.png",
20
21
  "assets/*.jpg",
21
22
  "assets/*.gif",
@@ -27,14 +28,10 @@
27
28
  "scripts": {
28
29
  "start": "node src/voxlert.js",
29
30
  "postinstall": "node src/postinstall.js",
30
- "changeset": "changeset",
31
- "version": "changeset version",
32
- "release": "changeset publish",
33
31
  "test": "node --test ./test/*.test.js"
34
32
  },
35
33
  "publishConfig": {
36
- "access": "public",
37
- "provenance": true
34
+ "access": "public"
38
35
  },
39
36
  "license": "MIT",
40
37
  "dependencies": {
@@ -43,9 +40,5 @@
43
40
  "@inquirer/input": "^4.1.0",
44
41
  "@inquirer/select": "^5.1.0",
45
42
  "node-notifier": "^10.0.1"
46
- },
47
- "devDependencies": {
48
- "@changesets/changelog-github": "^0.6.0",
49
- "@changesets/cli": "^2.30.0"
50
43
  }
51
44
  }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * pi-voxlert — Voice notifications for pi coding sessions.
3
+ *
4
+ * Hooks into pi agent lifecycle events and pipes them through the Voxlert CLI
5
+ * to generate contextual, in-character voice notifications spoken by game
6
+ * characters (SHODAN, StarCraft Adjutant, C&C EVA, HEV Suit, etc.).
7
+ *
8
+ * Prerequisites:
9
+ * npm install -g @settinghead/voxlert
10
+ * voxlert setup
11
+ *
12
+ * The extension calls `voxlert hook` with the event data on stdin, so whatever
13
+ * TTS backend + voice pack + LLM backend you configured via `voxlert config`
14
+ * is used automatically.
15
+ */
16
+
17
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
18
+ import { Type } from "@sinclair/typebox";
19
+ import { execSync, spawn } from "node:child_process";
20
+ import { basename } from "node:path";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Resolve the voxlert binary path, or null if not installed. */
27
+ function findVoxlert(): string | null {
28
+ try {
29
+ return execSync("which voxlert", { encoding: "utf-8" }).trim();
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** Pipe an event through `voxlert hook` (fire-and-forget, async). */
36
+ function fireVoxlert(
37
+ eventName: string,
38
+ cwd: string,
39
+ extra: Record<string, unknown> = {},
40
+ ): void {
41
+ const payload = JSON.stringify({
42
+ hook_event_name: eventName,
43
+ cwd,
44
+ source: "pi",
45
+ ...extra,
46
+ });
47
+
48
+ const child = spawn("voxlert", ["hook"], {
49
+ stdio: ["pipe", "ignore", "ignore"],
50
+ detached: true,
51
+ });
52
+
53
+ child.stdin.write(payload);
54
+ child.stdin.end();
55
+ child.unref(); // don't block pi on audio playback
56
+ }
57
+
58
+ /** Check if Voxlert CLI is installed and available. */
59
+ function isVoxlertAvailable(): boolean {
60
+ return findVoxlert() !== null;
61
+ }
62
+
63
+ /**
64
+ * Extract the last assistant message text from pi's event messages array.
65
+ * Handles both direct message format and session entry format.
66
+ */
67
+ function extractLastAssistantText(messages: unknown[]): string {
68
+ if (!Array.isArray(messages)) return "";
69
+
70
+ for (let i = messages.length - 1; i >= 0; i--) {
71
+ const msg = messages[i] as any;
72
+ // Handle session entry format: { type: "message", message: { role, content } }
73
+ const actualMsg = msg?.message || msg;
74
+
75
+ if (actualMsg?.role === "assistant") {
76
+ const content = actualMsg.content;
77
+ if (typeof content === "string") return content.slice(0, 500);
78
+ if (Array.isArray(content)) {
79
+ return content
80
+ .filter((c: any) => c.type === "text")
81
+ .map((c: any) => c.text)
82
+ .join("\n")
83
+ .slice(0, 500);
84
+ }
85
+ }
86
+ }
87
+ return "";
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Extension
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export default function (pi: ExtensionAPI) {
95
+ let available = isVoxlertAvailable();
96
+
97
+ // ------------------------------------------------------------------
98
+ // Guided setup: install CLI + run setup --yes
99
+ // ------------------------------------------------------------------
100
+ async function runGuidedSetup(ctx: any): Promise<boolean> {
101
+ // Step 1: Install the CLI
102
+ ctx.ui.notify("Installing @settinghead/voxlert...", "info");
103
+ const install = await pi.exec("npm", ["install", "-g", "@settinghead/voxlert"], {
104
+ timeout: 120_000,
105
+ });
106
+ if (install.code !== 0) {
107
+ ctx.ui.notify(
108
+ `Install failed (exit ${install.code}):\n${install.stderr.slice(0, 300)}`,
109
+ "error",
110
+ );
111
+ return false;
112
+ }
113
+
114
+ // Step 2: Run non-interactive setup (downloads voice packs, detects TTS)
115
+ ctx.ui.notify("Running voxlert setup (downloading voice packs, detecting TTS)...", "info");
116
+ const setup = await pi.exec("voxlert", ["setup", "--yes"], { timeout: 120_000 });
117
+ if (setup.code !== 0) {
118
+ ctx.ui.notify(
119
+ `Setup failed (exit ${setup.code}):\n${setup.stderr.slice(0, 300)}`,
120
+ "error",
121
+ );
122
+ return false;
123
+ }
124
+
125
+ // Verify it worked
126
+ available = isVoxlertAvailable();
127
+ if (available) {
128
+ ctx.ui.notify(
129
+ "Voxlert installed and configured!\n\n" +
130
+ "Voice notifications will now play when the agent finishes a task.\n" +
131
+ "Run /voxlert test to hear it, or 'voxlert setup' in a terminal for full interactive config.",
132
+ "info",
133
+ );
134
+ ctx.ui.setStatus("voxlert", "🔊 Voxlert");
135
+ return true;
136
+ } else {
137
+ ctx.ui.notify("Install succeeded but voxlert binary not found in PATH.", "error");
138
+ return false;
139
+ }
140
+ }
141
+
142
+ // ------------------------------------------------------------------
143
+ // Session start: verify Voxlert is installed, offer setup if not
144
+ // ------------------------------------------------------------------
145
+ pi.on("session_start", async (_event, ctx) => {
146
+ available = isVoxlertAvailable();
147
+ if (!available) {
148
+ if (!ctx.hasUI) {
149
+ // Non-interactive mode (print mode, JSON mode) — just warn
150
+ return;
151
+ }
152
+
153
+ const install = await ctx.ui.confirm(
154
+ "Voxlert Setup",
155
+ "Voxlert CLI not found. Install it now?\n\n" +
156
+ "This will:\n" +
157
+ " • npm install -g @settinghead/voxlert\n" +
158
+ " • Download default voice packs (SHODAN, Adjutant, etc.)\n" +
159
+ " • Auto-detect your TTS backend\n\n" +
160
+ "You can run full interactive setup later with: voxlert setup",
161
+ );
162
+
163
+ if (install) {
164
+ await runGuidedSetup(ctx);
165
+ } else {
166
+ ctx.ui.notify(
167
+ "Skipped. Run /voxlert setup anytime, or manually:\n" +
168
+ " npm install -g @settinghead/voxlert && voxlert setup",
169
+ "info",
170
+ );
171
+ }
172
+ } else {
173
+ ctx.ui.setStatus("voxlert", "🔊 Voxlert");
174
+ }
175
+ });
176
+
177
+ // ------------------------------------------------------------------
178
+ // Agent end → "Stop" hook (task finished, waiting for input)
179
+ // Passes last_assistant_message so the LLM can generate a
180
+ // contextual phrase about what just happened.
181
+ // ------------------------------------------------------------------
182
+ pi.on("agent_end", async (event, ctx) => {
183
+ if (!available) return;
184
+
185
+ const messages = (event as any).messages || [];
186
+ const lastAssistantMessage = extractLastAssistantText(messages);
187
+
188
+ fireVoxlert("Stop", ctx.cwd, {
189
+ last_assistant_message: lastAssistantMessage,
190
+ });
191
+ });
192
+
193
+ // ------------------------------------------------------------------
194
+ // Tool errors → "PostToolUseFailure" hook (contextual event)
195
+ // ------------------------------------------------------------------
196
+ pi.on("tool_result", async (event, ctx) => {
197
+ if (!available) return;
198
+ if (event.isError) {
199
+ const text =
200
+ event.content
201
+ ?.filter((c: any) => c.type === "text")
202
+ .map((c: any) => c.text)
203
+ .join("\n")
204
+ .slice(0, 500) || "";
205
+
206
+ fireVoxlert("PostToolUseFailure", ctx.cwd, {
207
+ error_message: `${event.toolName}: ${text}`,
208
+ });
209
+ }
210
+ });
211
+
212
+ // ------------------------------------------------------------------
213
+ // Session shutdown → "SessionEnd" hook
214
+ // ------------------------------------------------------------------
215
+ pi.on("session_shutdown", async (_event, ctx) => {
216
+ if (!available) return;
217
+ fireVoxlert("SessionEnd", ctx.cwd);
218
+ });
219
+
220
+ // ------------------------------------------------------------------
221
+ // Context compaction → "PreCompact" hook
222
+ // ------------------------------------------------------------------
223
+ pi.on("session_before_compact", async (_event, ctx) => {
224
+ if (!available) return;
225
+ fireVoxlert("PreCompact", ctx.cwd);
226
+ });
227
+
228
+ // ------------------------------------------------------------------
229
+ // /voxlert command — quick controls
230
+ // ------------------------------------------------------------------
231
+ pi.registerCommand("voxlert", {
232
+ description: "Voxlert voice notifications: test, status, or configure",
233
+ handler: async (args, ctx) => {
234
+ const sub = (args || "").trim().split(/\s+/)[0];
235
+
236
+ if (sub === "setup") {
237
+ if (available) {
238
+ const redo = await ctx.ui.confirm(
239
+ "Voxlert Setup",
240
+ "Voxlert is already installed. Re-run setup with defaults?",
241
+ );
242
+ if (!redo) return;
243
+ }
244
+ await runGuidedSetup(ctx);
245
+ return;
246
+ }
247
+
248
+ if (sub === "test") {
249
+ if (!available) {
250
+ ctx.ui.notify("Voxlert CLI not installed. Run /voxlert setup first.", "error");
251
+ return;
252
+ }
253
+ fireVoxlert("Stop", ctx.cwd);
254
+ ctx.ui.notify("Sent test notification.", "info");
255
+ return;
256
+ }
257
+
258
+ if (sub === "status") {
259
+ ctx.ui.notify(
260
+ available
261
+ ? "Voxlert is active. Voice notifications will play on agent_end, tool errors, compaction, and session end."
262
+ : "Voxlert CLI not found. Run /voxlert setup to install.",
263
+ available ? "info" : "warning",
264
+ );
265
+ return;
266
+ }
267
+
268
+ // Default: show help
269
+ ctx.ui.notify(
270
+ "Usage: /voxlert [setup|test|status]\n" +
271
+ " setup — install Voxlert CLI and configure with defaults\n" +
272
+ " test — fire a test voice notification\n" +
273
+ " status — check if Voxlert CLI is available\n" +
274
+ "\nFor full interactive config, run in terminal: voxlert setup",
275
+ "info",
276
+ );
277
+ },
278
+ });
279
+
280
+ // ------------------------------------------------------------------
281
+ // voxlert_speak tool — let the LLM speak through Voxlert
282
+ // ------------------------------------------------------------------
283
+ pi.registerTool({
284
+ name: "voxlert_speak",
285
+ label: "Voxlert Speak",
286
+ description:
287
+ "Speak a phrase aloud through Voxlert using the user's configured voice pack and TTS backend. " +
288
+ "Use this when the user asks you to say something out loud, announce something, or test voice notifications.",
289
+ parameters: Type.Object({
290
+ phrase: Type.String({ description: "The phrase to speak aloud (2-12 words work best)" }),
291
+ }),
292
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
293
+ if (!available) {
294
+ throw new Error(
295
+ "Voxlert CLI not installed. Install with: npm install -g @settinghead/voxlert && voxlert setup",
296
+ );
297
+ }
298
+
299
+ fireVoxlert("Stop", ctx.cwd, {
300
+ phrase_override: params.phrase,
301
+ });
302
+
303
+ return {
304
+ content: [{ type: "text", text: `Speaking: "${params.phrase}"` }],
305
+ details: { phrase: params.phrase },
306
+ };
307
+ },
308
+ });
309
+ }
@@ -4,7 +4,7 @@ export function formatHelp(commands, pkg) {
4
4
  .map((command) => command.help.join("\n"));
5
5
 
6
6
  return [
7
- `voxlert v${pkg.version} — Game character voice notifications for Claude Code, Cursor, Codex, and OpenClaw`,
7
+ `voxlert v${pkg.version} — Game character voice notifications for Claude Code, Cursor, Codex, pi, and OpenClaw`,
8
8
  "",
9
9
  "Usage:",
10
10
  ...sections,
@@ -5,6 +5,7 @@ import { showOverlay } from "../overlay.js";
5
5
  import { listPacks, loadPack } from "../packs.js";
6
6
  import { generatePhrase } from "../llm.js";
7
7
  import { speakPhrase } from "../audio.js";
8
+ import { resolvePrefix, DEFAULT_PREFIX } from "../prefix.js";
8
9
 
9
10
  export async function testPipeline(text, pack) {
10
11
  if (!text) {
@@ -33,12 +34,19 @@ export async function testPipeline(text, pack) {
33
34
  phrase = text;
34
35
  }
35
36
 
37
+ // Resolve prefix from config, same as the hook path
38
+ const prefixTemplate = config.prefix !== undefined ? config.prefix : DEFAULT_PREFIX;
39
+ const resolvedPrefix = resolvePrefix(prefixTemplate, process.cwd());
40
+ if (resolvedPrefix) {
41
+ phrase = `${resolvedPrefix}; ${phrase}`;
42
+ }
43
+
36
44
  console.log("Sending to TTS...");
37
45
  showOverlay(phrase, {
38
46
  category: "notification",
39
47
  packName: activePack.name,
40
48
  packId: activePack.id || (config.active_pack || "sc1-kerrigan-infested"),
41
- prefix: "Test",
49
+ prefix: resolvedPrefix,
42
50
  config,
43
51
  overlayColors: activePack.overlay_colors,
44
52
  });
@@ -3,10 +3,11 @@ import confirm from "@inquirer/confirm";
3
3
  import { unregisterHooks, removeSkill } from "../hooks.js";
4
4
  import { unregisterCursorHooks } from "../cursor-hooks.js";
5
5
  import { unregisterCodexNotify } from "../codex-config.js";
6
+ import { removePiExtension } from "../pi-hooks.js";
6
7
  import { STATE_DIR } from "../paths.js";
7
8
 
8
9
  async function runUninstall() {
9
- console.log("Removing Voxlert hooks and skill...\n");
10
+ console.log("Removing Voxlert hooks, extensions, and skill...\n");
10
11
 
11
12
  const claudeRemoved = unregisterHooks();
12
13
  if (claudeRemoved > 0) {
@@ -23,13 +24,18 @@ async function runUninstall() {
23
24
  console.log(" Removed notify from ~/.codex/config.toml");
24
25
  }
25
26
 
27
+ const piRemoved = removePiExtension();
28
+ if (piRemoved) {
29
+ console.log(" Removed Voxlert extension from ~/.pi/agent/extensions/");
30
+ }
31
+
26
32
  const skillRemoved = removeSkill();
27
33
  if (skillRemoved) {
28
34
  console.log(" Removed voxlert-config skill");
29
35
  }
30
36
 
31
- if (claudeRemoved === 0 && cursorRemoved === 0 && !codexRemoved && !skillRemoved) {
32
- console.log(" No Voxlert hooks or skill were found.");
37
+ if (claudeRemoved === 0 && cursorRemoved === 0 && !codexRemoved && !piRemoved && !skillRemoved) {
38
+ console.log(" No Voxlert hooks, extensions, or skill were found.");
33
39
  }
34
40
 
35
41
  if (existsSync(STATE_DIR)) {
@@ -43,14 +49,14 @@ async function runUninstall() {
43
49
  }
44
50
  }
45
51
 
46
- console.log("\nUninstall complete. You can still run 'voxlert' if installed via npm; run 'npm uninstall -g @settinghead/voxlert' to remove the CLI.");
52
+ console.log("\nUninstall complete. You can still run 'voxlert' if installed via npm; run 'npm uninstall -g @settinghead/voxlert' to remove the CLI.\nFor pi, you can also run: pi remove npm:@settinghead/pi-voxlert");
47
53
  }
48
54
 
49
55
  export const uninstallCommand = {
50
56
  name: "uninstall",
51
57
  aliases: [],
52
58
  help: [
53
- " voxlert uninstall Remove hooks from Claude Code, Cursor, and Codex, optionally config/cache",
59
+ " voxlert uninstall Remove hooks from Claude Code, Cursor, Codex, and pi, optionally config/cache",
54
60
  ],
55
61
  skipSetupWizard: true,
56
62
  skipUpgradeCheck: false,
@@ -0,0 +1,45 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { homedir } from "os";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ const PI_EXTENSIONS_DIR = join(homedir(), ".pi", "agent", "extensions");
9
+ const PI_EXTENSION_FILE = join(PI_EXTENSIONS_DIR, "voxlert.ts");
10
+
11
+ /**
12
+ * The extension source is bundled at ../pi-package/extensions/voxlert.ts
13
+ * relative to this file (cli/src/).
14
+ */
15
+ const EXTENSION_SRC = join(__dirname, "..", "pi-package", "extensions", "voxlert.ts");
16
+
17
+ /**
18
+ * Check if the Voxlert extension is installed in pi's extensions directory.
19
+ */
20
+ export function hasPiExtension() {
21
+ return existsSync(PI_EXTENSION_FILE);
22
+ }
23
+
24
+ /**
25
+ * Install the Voxlert extension to ~/.pi/agent/extensions/voxlert.ts.
26
+ * Copies the bundled extension source file.
27
+ * @returns {boolean} true if installed successfully
28
+ */
29
+ export function installPiExtension() {
30
+ if (!existsSync(EXTENSION_SRC)) return false;
31
+ mkdirSync(PI_EXTENSIONS_DIR, { recursive: true });
32
+ const content = readFileSync(EXTENSION_SRC, "utf-8");
33
+ writeFileSync(PI_EXTENSION_FILE, content);
34
+ return true;
35
+ }
36
+
37
+ /**
38
+ * Remove the Voxlert extension from ~/.pi/agent/extensions/.
39
+ * @returns {boolean} true if a file was removed
40
+ */
41
+ export function removePiExtension() {
42
+ if (!existsSync(PI_EXTENSION_FILE)) return false;
43
+ rmSync(PI_EXTENSION_FILE);
44
+ return true;
45
+ }
package/src/prefix.js ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Prefix resolver — composable template variables for announcement prefixes.
3
+ *
4
+ * Template syntax:
5
+ * ${dirname} — basename of cwd
6
+ * ${project} — project name from manifest files (package.json, pyproject.toml, etc.)
7
+ * ${project|dirname} — pipe means "try left, fall back to right"
8
+ *
9
+ * Literal text is preserved as-is: "Project ${project|dirname}" → "Project my-app"
10
+ * Unknown variables and all-null chains resolve to empty string.
11
+ */
12
+
13
+ import { basename, join } from "path";
14
+ import { existsSync, readFileSync, readdirSync } from "fs";
15
+
16
+ /** Default prefix template — project name with dirname fallback. */
17
+ export const DEFAULT_PREFIX = "${project|dirname}";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Resolvers — each is (cwd: string) => string | null
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const RESOLVERS = {
24
+ dirname: (cwd) => (cwd ? basename(cwd) : null),
25
+ project: readProjectName,
26
+ };
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Project manifest readers, tried in priority order.
30
+ // Each entry: [filename, extractorFn(contents) => string|null]
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** @type {Array<[string, (content: string) => string|null]>} */
34
+ const MANIFEST_READERS = [
35
+ ["package.json", extractPackageName],
36
+ ["pyproject.toml", extractPyprojectName],
37
+ ["Cargo.toml", extractTomlSectionField("package", "name")],
38
+ ["setup.cfg", extractIniSectionField("metadata", "name")],
39
+ ["go.mod", extractGoModule],
40
+ ["composer.json", extractComposerName],
41
+ ["pubspec.yaml", extractYamlScalar("name")],
42
+ // gemspec handled separately — requires glob
43
+ ];
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Public API
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Resolve a prefix template string against the given working directory.
51
+ *
52
+ * @param {string} template - e.g. "${project|dirname}" or "my-app"
53
+ * @param {string} cwd - Absolute path to the project directory
54
+ * @returns {string} Resolved prefix (may be empty)
55
+ */
56
+ export function resolvePrefix(template, cwd) {
57
+ if (!template) return "";
58
+ return template.replace(/\$\{([^}]+)\}/g, (_match, expr) => {
59
+ const candidates = expr.split("|");
60
+ for (const name of candidates) {
61
+ const resolver = RESOLVERS[name.trim()];
62
+ if (!resolver) continue;
63
+ const value = resolver(cwd);
64
+ if (value) return value;
65
+ }
66
+ return "";
67
+ });
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // readProjectName — walks manifests in priority order
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Read the project name from the first recognized manifest file in cwd.
76
+ * Returns null if no manifest is found or none yields a name.
77
+ *
78
+ * @param {string} cwd
79
+ * @returns {string|null}
80
+ */
81
+ function readProjectName(cwd) {
82
+ if (!cwd) return null;
83
+
84
+ // Fixed-name manifests
85
+ for (const [filename, extractor] of MANIFEST_READERS) {
86
+ const filepath = join(cwd, filename);
87
+ const content = readFileSafe(filepath);
88
+ if (content === null) continue;
89
+ const name = extractor(content);
90
+ if (name) return name;
91
+ }
92
+
93
+ // *.gemspec — need a directory listing
94
+ try {
95
+ const entries = readdirSync(cwd);
96
+ const gemspec = entries.find((e) => e.endsWith(".gemspec"));
97
+ if (gemspec) {
98
+ const content = readFileSafe(join(cwd, gemspec));
99
+ if (content) {
100
+ const name = extractGemspecName(content);
101
+ if (name) return name;
102
+ }
103
+ }
104
+ } catch {
105
+ // directory unreadable — skip
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Extractors
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** JSON field extractor factory. */
116
+ function extractJsonField(field) {
117
+ return (content) => {
118
+ try {
119
+ const obj = JSON.parse(content);
120
+ const val = obj?.[field];
121
+ return typeof val === "string" && val.trim() ? val.trim() : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ };
126
+ }
127
+
128
+ /** package.json: strip npm scope (@scope/name → name). */
129
+ function extractPackageName(content) {
130
+ const name = extractJsonField("name")(content);
131
+ if (!name) return null;
132
+ // Strip @scope/ prefix for TTS-friendly output
133
+ const slashIdx = name.indexOf("/");
134
+ return slashIdx >= 0 && name.startsWith("@") ? name.slice(slashIdx + 1) : name;
135
+ }
136
+
137
+ /** composer.json: name is "vendor/package" — return the package segment. */
138
+ function extractComposerName(content) {
139
+ try {
140
+ const obj = JSON.parse(content);
141
+ const val = obj?.name;
142
+ if (typeof val !== "string" || !val.trim()) return null;
143
+ const parts = val.split("/");
144
+ return parts[parts.length - 1].trim() || null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * pyproject.toml: check [project].name then [tool.poetry].name.
152
+ * Lightweight line-based extraction — no full TOML parser.
153
+ */
154
+ function extractPyprojectName(content) {
155
+ return (
156
+ extractTomlSectionField("project", "name")(content) ||
157
+ extractTomlNestedField("tool.poetry", "name")(content)
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Factory: extract `key = "value"` under a [section] header in TOML.
163
+ * Handles both `key = "value"` and `key = 'value'`.
164
+ */
165
+ function extractTomlSectionField(section, key) {
166
+ // Match [section] exactly (no dots, no sub-tables)
167
+ const headerRe = new RegExp(`^\\[${escapeRegex(section)}\\]\\s*$`);
168
+ const fieldRe = new RegExp(`^${escapeRegex(key)}\\s*=\\s*["'](.+?)["']`);
169
+ return (content) => extractFromSections(content, headerRe, fieldRe);
170
+ }
171
+
172
+ /**
173
+ * Factory: extract `key = "value"` under a [dotted.section] header in TOML.
174
+ * e.g. [tool.poetry] → key = "value"
175
+ */
176
+ function extractTomlNestedField(dottedSection, key) {
177
+ const headerRe = new RegExp(`^\\[${escapeRegex(dottedSection)}\\]\\s*$`);
178
+ const fieldRe = new RegExp(`^${escapeRegex(key)}\\s*=\\s*["'](.+?)["']`);
179
+ return (content) => extractFromSections(content, headerRe, fieldRe);
180
+ }
181
+
182
+ /** Shared: scan lines for a section header, then find a field before the next header. */
183
+ function extractFromSections(content, headerRe, fieldRe) {
184
+ const lines = content.split("\n");
185
+ let inSection = false;
186
+ for (const line of lines) {
187
+ const trimmed = line.trim();
188
+ if (trimmed.startsWith("[")) {
189
+ inSection = headerRe.test(trimmed);
190
+ continue;
191
+ }
192
+ if (inSection) {
193
+ const m = fieldRe.exec(trimmed);
194
+ if (m) return m[1].trim() || null;
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /** setup.cfg: INI-style [metadata] section, name = value (no quotes). */
201
+ function extractIniSectionField(section, key) {
202
+ const headerRe = new RegExp(`^\\[${escapeRegex(section)}\\]\\s*$`, "i");
203
+ const fieldRe = new RegExp(`^${escapeRegex(key)}\\s*=\\s*(.+)`, "i");
204
+ return (content) => {
205
+ const lines = content.split("\n");
206
+ let inSection = false;
207
+ for (const line of lines) {
208
+ const trimmed = line.trim();
209
+ if (trimmed.startsWith("[")) {
210
+ inSection = headerRe.test(trimmed);
211
+ continue;
212
+ }
213
+ if (inSection) {
214
+ const m = fieldRe.exec(trimmed);
215
+ if (m) return m[1].trim() || null;
216
+ }
217
+ }
218
+ return null;
219
+ };
220
+ }
221
+
222
+ /** go.mod: `module github.com/user/repo` → "repo" */
223
+ function extractGoModule(content) {
224
+ const m = /^module\s+(\S+)/m.exec(content);
225
+ if (!m) return null;
226
+ const parts = m[1].split("/");
227
+ return parts[parts.length - 1].trim() || null;
228
+ }
229
+
230
+ /** *.gemspec: first `spec.name = "value"` or `.name = "value"` */
231
+ function extractGemspecName(content) {
232
+ const m = /\.name\s*=\s*["'](.+?)["']/.exec(content);
233
+ return m ? m[1].trim() || null : null;
234
+ }
235
+
236
+ /** YAML top-level scalar: `name: value` */
237
+ function extractYamlScalar(key) {
238
+ const re = new RegExp(`^${escapeRegex(key)}:\\s*(.+)`, "m");
239
+ return (content) => {
240
+ const m = re.exec(content);
241
+ if (!m) return null;
242
+ // Strip surrounding quotes if present
243
+ let val = m[1].trim();
244
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
245
+ val = val.slice(1, -1);
246
+ }
247
+ return val || null;
248
+ };
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Helpers
253
+ // ---------------------------------------------------------------------------
254
+
255
+ function readFileSafe(filepath) {
256
+ try {
257
+ if (!existsSync(filepath)) return null;
258
+ return readFileSync(filepath, "utf-8");
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+
264
+ function escapeRegex(s) {
265
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
266
+ }
package/src/setup.js CHANGED
@@ -21,6 +21,7 @@ import { LLM_PROVIDERS, getProvider } from "./providers.js";
21
21
  import { registerHooks, installSkill, unregisterHooks, hasVoxlertHooks, hasInstalledSkill, removeSkill } from "./hooks.js";
22
22
  import { registerCursorHooks, unregisterCursorHooks, hasCursorHooks } from "./cursor-hooks.js";
23
23
  import { registerCodexNotify, getCodexConfigPath, unregisterCodexNotify, hasCodexNotify } from "./codex-config.js";
24
+ import { installPiExtension, removePiExtension, hasPiExtension } from "./pi-hooks.js";
24
25
  import { printSetupHeader, printStep, printStatus, printSuccess, printWarning, highlight } from "./setup-ui.js";
25
26
  import {
26
27
  probeTtsBackend,
@@ -238,6 +239,7 @@ export async function runSetup({ nonInteractive = false } = {}) {
238
239
  if (hasVoxlertHooks() || hasInstalledSkill()) installedPlatforms.push("Claude");
239
240
  if (hasCursorHooks()) installedPlatforms.push("Cursor");
240
241
  if (hasCodexNotify()) installedPlatforms.push("Codex");
242
+ if (hasPiExtension()) installedPlatforms.push("pi");
241
243
  await printSetupHeader(config, installedPlatforms);
242
244
 
243
245
  // --- Step 1: LLM Provider ---
@@ -502,6 +504,12 @@ export async function runSetup({ nonInteractive = false } = {}) {
502
504
  description: "Install/update notify in ~/.codex/config.toml",
503
505
  checked: hasCodexNotify(),
504
506
  },
507
+ {
508
+ name: "pi",
509
+ value: "pi",
510
+ description: "Install extension to ~/.pi/agent/extensions/",
511
+ checked: hasPiExtension(),
512
+ },
505
513
  ];
506
514
 
507
515
  const selectedPlatforms = await checkbox({
@@ -554,6 +562,20 @@ export async function runSetup({ nonInteractive = false } = {}) {
554
562
  }
555
563
  }
556
564
 
565
+ if (selectedPlatforms.includes("pi")) {
566
+ if (installPiExtension()) {
567
+ printSuccess("Installed Voxlert extension to ~/.pi/agent/extensions/voxlert.ts");
568
+ printStatus("Next", "Run /reload in pi or start a new session to activate.");
569
+ } else {
570
+ printWarning("Could not install pi extension (source file not found).");
571
+ }
572
+ } else {
573
+ const piRemoved = removePiExtension();
574
+ if (piRemoved) {
575
+ printWarning("Removed Voxlert extension from ~/.pi/agent/extensions/");
576
+ }
577
+ }
578
+
557
579
  if (selectedPlatforms.length === 0) {
558
580
  printWarning("No platforms selected. Run 'voxlert setup' again to install hooks later.");
559
581
  }
@@ -681,6 +703,9 @@ function printSetupSummary(config, chosenProvider, selectedPlatforms) {
681
703
  if (selectedPlatforms.includes("codex")) {
682
704
  printStatus("Codex", "Start a new Codex session to pick up the notify config.");
683
705
  }
706
+ if (selectedPlatforms.includes("pi")) {
707
+ printStatus("pi", "Run /reload in pi or start a new session.");
708
+ }
684
709
  printStatus("Reconfigure", "voxlert setup");
685
710
  console.log("");
686
711
  }
@@ -91,13 +91,9 @@ const ansi = {
91
91
  grey: "\x1b[90m",
92
92
  };
93
93
 
94
- function stripAnsi(str) {
95
- return str.replace(/\x1b\[[0-9;]*m/g, "");
96
- }
97
-
98
94
  /**
99
- * Print upgrade notification to stdout, styled like a terminal box:
100
- * dark background hint, cyan "Update available!", version range, command, release notes link.
95
+ * Print upgrade notification to stdout, styled with horizontal separator lines:
96
+ * yellow lines above and below, bold header, version info, install command, changelog link.
101
97
  * Only uses colors when stdout is TTY.
102
98
  */
103
99
  export function printUpgradeNotification(info, options = {}) {
@@ -110,28 +106,16 @@ export function printUpgradeNotification(info, options = {}) {
110
106
  releaseNotesUrl ||
111
107
  `https://github.com/settinghead/voxlert/releases/latest`;
112
108
 
113
- const line1 = `${c.yellow}✨${c.reset} ${c.cyan}Update available!${c.reset} ${info.current} -> ${info.latest}`;
114
- const line2 = `Run ${installCmd} to update.`;
115
- const line3 = "See full release notes:";
116
- const line4 = `${c.cyan}${url}${c.reset}`;
117
-
118
- const padding = 2;
119
- const maxLen = Math.max(
120
- stripAnsi(line1).length,
121
- line2.length,
122
- line3.length,
123
- url.length
124
- );
125
- const width = maxLen + padding * 2;
126
- const border = "─".repeat(width);
127
- const pad = (s) => " ".repeat(Math.max(0, width - 2 - stripAnsi(s).length));
109
+ const rule = `${c.yellow}${"─".repeat(60)}${c.reset}`;
128
110
 
129
111
  console.log("");
130
- console.log(`${c.grey}┌${border}┐${c.reset}`);
131
- console.log(`${c.grey}│${c.reset}${" ".repeat(padding)}${line1}${pad(line1)}${c.grey}│${c.reset}`);
132
- console.log(`${c.grey}│${c.reset}${" ".repeat(padding)}${line2}${pad(line2)}${c.grey}│${c.reset}`);
133
- console.log(`${c.grey}│${c.reset}${" ".repeat(padding)}${line3}${pad(line3)}${c.grey}│${c.reset}`);
134
- console.log(`${c.grey}│${c.reset}${" ".repeat(padding)}${line4}${pad(line4)}${c.grey}│${c.reset}`);
135
- console.log(`${c.grey}└${border}┘${c.reset}`);
112
+ console.log(rule);
113
+ console.log("");
114
+ console.log(` ${c.bold}${c.yellow}Update Available${c.reset}`);
115
+ console.log(` New version ${c.bold}${info.latest}${c.reset} is available. Run: ${c.cyan}${installCmd}${c.reset}`);
116
+ console.log(` Changelog:`);
117
+ console.log(` ${c.cyan}${url}${c.reset}`);
118
+ console.log("");
119
+ console.log(rule);
136
120
  console.log("");
137
121
  }
package/src/voxlert.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * speaks them through a local Chatterbox TTS server.
7
7
  */
8
8
 
9
- import { basename } from "path";
9
+ import { resolvePrefix, DEFAULT_PREFIX } from "./prefix.js";
10
10
  import { appendFileSync, mkdirSync } from "fs";
11
11
  import { loadConfig, EVENT_MAP, CONTEXTUAL_EVENTS, FALLBACK_PHRASES } from "./config.js";
12
12
  import { extractContext, generatePhrase } from "./llm.js";
@@ -58,7 +58,7 @@ export async function processHookEvent(eventData) {
58
58
  llm_backend: config.llm_backend || "",
59
59
  tts_backend: config.tts_backend || "",
60
60
  overlay: config.overlay === true,
61
- prefix: config.prefix !== undefined ? config.prefix : "${dirname}",
61
+ prefix: config.prefix !== undefined ? config.prefix : DEFAULT_PREFIX,
62
62
  task_complete_enabled: config.categories?.["task.complete"] !== false,
63
63
  task_error_enabled: config.categories?.["task.error"] !== false,
64
64
  });
@@ -91,7 +91,53 @@ export async function processHookEvent(eventData) {
91
91
  debugLog("processHookEvent processing", { source, eventName, category });
92
92
  // Load active voice pack
93
93
  const pack = loadPack(config);
94
- const projectName = cwd ? basename(cwd) : "";
94
+ // Allow callers to bypass LLM generation entirely with a pre-built phrase
95
+ if (eventData.phrase_override && typeof eventData.phrase_override === "string") {
96
+ const overridePhrase = eventData.phrase_override.trim();
97
+ if (overridePhrase) {
98
+ debugLog("processHookEvent using phrase_override", { source, phrase: overridePhrase.slice(0, 120) });
99
+
100
+ // Resolve prefix
101
+ const prefixTemplate = config.prefix !== undefined ? config.prefix : DEFAULT_PREFIX;
102
+ const resolvedPrefix = resolvePrefix(prefixTemplate, cwd);
103
+ if (resolvedPrefix) {
104
+ const finalPhrase = `${resolvedPrefix}; ${overridePhrase}`;
105
+ const packId = config.active_pack || "sc1-kerrigan-infested";
106
+ appendLog(
107
+ `[${new Date().toISOString()}] source=${source} event=${eventName} category=${category} phrase=${finalPhrase.replace(/\s+/g, " ").slice(0, 120)}`,
108
+ config,
109
+ );
110
+ showOverlay(finalPhrase, {
111
+ category,
112
+ packName: pack.name,
113
+ packId: pack.id || packId,
114
+ prefix: resolvedPrefix,
115
+ config,
116
+ overlayColors: pack.overlay_colors,
117
+ });
118
+ await speakPhrase(finalPhrase, config, pack);
119
+ debugLog("processHookEvent done (phrase_override)", { source });
120
+ return;
121
+ }
122
+
123
+ const packId = config.active_pack || "sc1-kerrigan-infested";
124
+ appendLog(
125
+ `[${new Date().toISOString()}] source=${source} event=${eventName} category=${category} phrase=${overridePhrase.replace(/\s+/g, " ").slice(0, 120)}`,
126
+ config,
127
+ );
128
+ showOverlay(overridePhrase, {
129
+ category,
130
+ packName: pack.name,
131
+ packId: pack.id || packId,
132
+ prefix: "",
133
+ config,
134
+ overlayColors: pack.overlay_colors,
135
+ });
136
+ await speakPhrase(overridePhrase, config, pack);
137
+ debugLog("processHookEvent done (phrase_override)", { source });
138
+ return;
139
+ }
140
+ }
95
141
 
96
142
  // For contextual events, try LLM phrase generation
97
143
  let phrase = null;
@@ -145,14 +191,11 @@ export async function processHookEvent(eventData) {
145
191
  phrase = phrases[Math.floor(Math.random() * phrases.length)];
146
192
  }
147
193
 
148
- // Prepend prefix (supports ${dirname} template variable)
149
- const prefixTemplate = config.prefix !== undefined ? config.prefix : "${dirname}";
150
- let resolvedPrefix = "";
151
- if (prefixTemplate !== "") {
152
- resolvedPrefix = prefixTemplate.replace(/\$\{dirname\}/g, projectName);
153
- if (resolvedPrefix) {
154
- phrase = `${resolvedPrefix}; ${phrase}`;
155
- }
194
+ // Resolve prefix (supports ${project|dirname} template variables)
195
+ const prefixTemplate = config.prefix !== undefined ? config.prefix : DEFAULT_PREFIX;
196
+ const resolvedPrefix = resolvePrefix(prefixTemplate, cwd);
197
+ if (resolvedPrefix) {
198
+ phrase = `${resolvedPrefix}; ${phrase}`;
156
199
  }
157
200
 
158
201
  const packId = config.active_pack || "sc1-kerrigan-infested";