pi-voice-input 0.1.0 → 0.1.1

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
@@ -70,18 +70,18 @@ Planned provider direction:
70
70
 
71
71
  ## Configure credentials
72
72
 
73
- Create a config file:
73
+ In pi, run:
74
74
 
75
- ```bash
76
- mkdir -p ~/.pi/agent
77
- cp .env.example ~/.pi/agent/voice-input.env
78
- $EDITOR ~/.pi/agent/voice-input.env
75
+ ```text
76
+ /voice key
79
77
  ```
80
78
 
81
- At minimum, set:
79
+ Paste your VolcEngine Speech API key into the prompt. The extension saves it for future sessions and keeps it out of your project files.
82
80
 
83
- ```bash
84
- VOLC_API_KEY=your_volcengine_speech_api_key
81
+ Then verify:
82
+
83
+ ```text
84
+ /voice config
85
85
  ```
86
86
 
87
87
  You can get/manage the key here:
@@ -90,18 +90,25 @@ https://console.volcengine.com/speech/new/setting/apikeys?projectName=default
90
90
 
91
91
  If `VOLC_API_KEY` is missing, the extension does not silently fail. It shows an error notification explaining:
92
92
 
93
- - that `VOLC_API_KEY` is missing
94
- - where to put it: `~/.pi/agent/voice-input.env`
95
- - the exact config line to add
96
- - the Volcengine API-key settings URL
93
+ - that the current provider API key is missing
94
+ - to run `/voice key`
95
+ - the VolcEngine API-key settings URL
97
96
  - that `/voice config` can be used to verify detection
98
97
 
98
+ Manual fallback:
99
+
100
+ ```bash
101
+ mkdir -p ~/.pi/agent
102
+ cp .env.example ~/.pi/agent/voice-input.env
103
+ $EDITOR ~/.pi/agent/voice-input.env
104
+ ```
105
+
99
106
  ## Configuration reference
100
107
 
101
108
  Example:
102
109
 
103
110
  ```bash
104
- # Required
111
+ # Required for the current provider. Usually set by /voice key.
105
112
  VOLC_API_KEY=your_volcengine_speech_api_key
106
113
 
107
114
  # Current provider: VolcEngine WebSocket ASR endpoint and resource
@@ -139,7 +146,7 @@ Config loading order, later values override earlier ones:
139
146
  3. current-working-directory `.env`
140
147
  4. shell environment variables
141
148
 
142
- Do not commit real credentials. Keep private local values in `.env` or `~/.pi/agent/voice-input.env`.
149
+ Do not commit real credentials. Prefer `/voice key`, or keep private local values in `.env` or `~/.pi/agent/voice-input.env`.
143
150
 
144
151
  ## Usage
145
152
 
@@ -158,6 +165,7 @@ Slash commands:
158
165
  /voice cancel # stop recording without transcribing
159
166
  /voice status # show recorder state
160
167
  /voice config # show effective non-secret config and whether API key is detected
168
+ /voice key # prompt for and save the current provider API key
161
169
  ```
162
170
 
163
171
  ## Notes
@@ -3,6 +3,7 @@ import { Key } from "@earendil-works/pi-tui";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import {
6
+ chmodSync,
6
7
  closeSync,
7
8
  existsSync,
8
9
  mkdirSync,
@@ -20,6 +21,7 @@ import WebSocket from "ws";
20
21
 
21
22
  const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
22
23
  const PACKAGE_ROOT = path.resolve(EXTENSION_DIR, "..");
24
+ const PRIVATE_CONFIG_PATH = path.join(homedir(), ".pi", "agent", "voice-input.env");
23
25
  const DEFAULT_SHORTCUT = Key.ctrlShift("r");
24
26
 
25
27
  const MSG_TYPE_CLIENT_FULL_REQUEST = 0b0001;
@@ -101,7 +103,7 @@ function parseEnvText(text: string): EnvMap {
101
103
 
102
104
  function loadEnvFiles(): EnvMap {
103
105
  const candidates = [
104
- path.join(homedir(), ".pi", "agent", "voice-input.env"),
106
+ PRIVATE_CONFIG_PATH,
105
107
  path.join(PACKAGE_ROOT, ".env"),
106
108
  path.join(process.cwd(), ".env"),
107
109
  ];
@@ -185,6 +187,38 @@ function ensureDir(dir: string) {
185
187
  mkdirSync(dir, { recursive: true });
186
188
  }
187
189
 
190
+ function envValue(value: string): string {
191
+ if (/^[A-Za-z0-9_./:@+-]*$/.test(value)) return value;
192
+ return JSON.stringify(value);
193
+ }
194
+
195
+ function writePrivateEnvValue(name: string, value: string) {
196
+ if (/\r|\n/.test(value)) throw new Error(`${name} must be a single-line value`);
197
+ ensureDir(path.dirname(PRIVATE_CONFIG_PATH));
198
+
199
+ const original = existsSync(PRIVATE_CONFIG_PATH) ? readFileSync(PRIVATE_CONFIG_PATH, "utf8") : "";
200
+ const lines = original ? original.split(/\r?\n/) : [];
201
+ const replacement = `${name}=${envValue(value)}`;
202
+ let replaced = false;
203
+
204
+ const nextLines = lines.map((line) => {
205
+ if (new RegExp(`^\\s*${name}\\s*=`).test(line)) {
206
+ replaced = true;
207
+ return replacement;
208
+ }
209
+ return line;
210
+ });
211
+
212
+ if (!replaced) {
213
+ if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== "") nextLines.push("");
214
+ nextLines.push("# Managed by pi-voice-input. You can also update this with /voice key.");
215
+ nextLines.push(replacement);
216
+ }
217
+
218
+ writeFileSync(PRIVATE_CONFIG_PATH, nextLines.join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
219
+ chmodSync(PRIVATE_CONFIG_PATH, 0o600);
220
+ }
221
+
188
222
  function timestampForFilename(): string {
189
223
  return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "");
190
224
  }
@@ -422,10 +456,8 @@ function parseRecordedWav(filePath: string): { pcm: Buffer; durationMs: number }
422
456
 
423
457
  function missingCredentialsMessage(): string {
424
458
  return [
425
- "Missing VOLC_API_KEY for Volcengine ASR.",
426
- "Create ~/.pi/agent/voice-input.env with:",
427
- " VOLC_API_KEY=your_volcengine_speech_api_key",
428
- "Optional: copy .env.example from this package as a template.",
459
+ "Missing VOLC_API_KEY for the current VolcEngine ASR provider.",
460
+ "Run /voice key and paste your VolcEngine Speech API key.",
429
461
  "API key settings: https://console.volcengine.com/speech/new/setting/apikeys?projectName=default",
430
462
  "Run /voice config to verify whether the key is detected.",
431
463
  ].join("\n");
@@ -685,10 +717,32 @@ async function toggleRecording(ctx: ExtensionContext) {
685
717
  else await startRecording(ctx);
686
718
  }
687
719
 
720
+ async function configureApiKey(ctx: ExtensionContext, providedKey = "") {
721
+ let apiKey = providedKey.trim();
722
+
723
+ if (!apiKey) {
724
+ if (!ctx.hasUI) {
725
+ ctx.ui.notify("Run /voice key in interactive pi, or set VOLC_API_KEY in the environment.", "error");
726
+ return;
727
+ }
728
+ const current = getConfig().apiKey;
729
+ const placeholder = current ? "Paste a new VolcEngine API key (current key is already set)" : "Paste VOLC_API_KEY";
730
+ apiKey = (await ctx.ui.input("VolcEngine API key", placeholder))?.trim() ?? "";
731
+ }
732
+
733
+ if (!apiKey) {
734
+ ctx.ui.notify("API key unchanged.", "warning");
735
+ return;
736
+ }
737
+
738
+ writePrivateEnvValue("VOLC_API_KEY", apiKey);
739
+ ctx.ui.notify("VolcEngine API key saved for pi voice input. Run /voice config to verify it is detected.", "info");
740
+ }
741
+
688
742
  function configSummary(config: VoiceConfig): string {
689
743
  return [
690
744
  "Voice input config:",
691
- `- api key: ${config.apiKey ? "set" : "missing"}`,
745
+ `- api key: ${config.apiKey ? "set" : "missing"} (update with /voice key)`,
692
746
  `- ws url: ${config.wsUrl}`,
693
747
  `- resource id: ${config.resourceId}`,
694
748
  `- language: ${config.language || "auto"}`,
@@ -697,6 +751,7 @@ function configSummary(config: VoiceConfig): string {
697
751
  `- recordings: ${config.recordingsDir}`,
698
752
  `- state: ${config.statePath}`,
699
753
  `- shortcut: ${config.shortcut}`,
754
+ "Run /voice key to save/update the current provider API key.",
700
755
  "Config files checked: ~/.pi/agent/voice-input.env, package .env, current .env; shell env overrides them.",
701
756
  ].join("\n");
702
757
  }
@@ -717,9 +772,11 @@ export default function (pi: ExtensionAPI) {
717
772
  });
718
773
 
719
774
  pi.registerCommand("voice", {
720
- description: "Voice input: start | stop | status | toggle | cancel | config",
775
+ description: "Voice input: start | stop | status | toggle | cancel | config | key",
721
776
  handler: async (args, ctx) => {
722
- const action = (args || "toggle").trim().toLowerCase();
777
+ const input = (args || "toggle").trim();
778
+ const action = (input.split(/\s+/, 1)[0] || "toggle").toLowerCase();
779
+ const rest = input.slice(action.length).trim();
723
780
  try {
724
781
  if (action === "start") {
725
782
  await startRecording(ctx);
@@ -743,11 +800,15 @@ export default function (pi: ExtensionAPI) {
743
800
  ctx.ui.notify(configSummary(getConfig()), "info");
744
801
  return;
745
802
  }
803
+ if (["key", "api-key", "apikey", "setup", "configure"].includes(action)) {
804
+ await configureApiKey(ctx, rest);
805
+ return;
806
+ }
746
807
  if (action === "toggle" || action === "") {
747
808
  await toggleRecording(ctx);
748
809
  return;
749
810
  }
750
- ctx.ui.notify("Usage: /voice start | stop | status | toggle | cancel | config", "error");
811
+ ctx.ui.notify("Usage: /voice start | stop | status | toggle | cancel | config | key", "error");
751
812
  } catch (error) {
752
813
  ctx.ui.setStatus("voice-input", undefined);
753
814
  ctx.ui.notify(`Voice command error: ${error instanceof Error ? error.message : String(error)}`, "error");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-voice-input",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "provider-extensible voice input extension for pi",
5
5
  "type": "module",
6
6
  "keywords": [