voicemode-channel 0.0.1 → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mike Bailey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,160 @@
1
- # voicemode-channel
1
+ # VoiceMode Channel
2
2
 
3
- VoiceMode Channel - inbound voice calls to Claude Code via VoiceMode Connect.
3
+ A Claude Code plugin that enables inbound voice calls via [VoiceMode Connect](https://voicemode.dev).
4
4
 
5
- This is a placeholder package to reserve the name. See the full project at:
6
- https://github.com/mbailey/voicemode-channel
5
+ Users speak on their phone or web app, and their messages arrive in your Claude Code session as channel events. Claude responds using the reply tool, and the response is spoken aloud on the caller's device.
6
+
7
+ ![VoiceMode Connect web app showing a voice conversation with Claude Code](assets/screenshot.png)
8
+
9
+ ```
10
+ User speaks on phone/web -> VoiceMode gateway -> Channel plugin -> Claude Code
11
+ |
12
+ User hears TTS response <- Channel reply tool <----------------------+
13
+ ```
14
+
15
+ ## Install
16
+
17
+ ### Claude Code plugin
18
+
19
+ ```bash
20
+ claude plugin marketplace add mbailey/claude-plugins
21
+ claude plugin install voicemode-channel@mbailey
22
+ ```
23
+
24
+ ### Standalone (any MCP host)
25
+
26
+ ```bash
27
+ npx voicemode-channel
28
+ ```
29
+
30
+ ## Prerequisites
31
+
32
+ - Node.js 20+
33
+ - VoiceMode Connect credentials (`~/.voicemode/credentials`)
34
+ - Run `voicemode-channel auth login` to authenticate
35
+
36
+ ## Auth
37
+
38
+ Manage your VoiceMode Connect credentials:
39
+
40
+ ```bash
41
+ voicemode-channel auth login # Authenticate via browser (PKCE flow)
42
+ voicemode-channel auth logout # Remove stored credentials
43
+ voicemode-channel auth status # Show current auth state
44
+ ```
45
+
46
+ Or via npx:
47
+
48
+ ```bash
49
+ npx voicemode-channel auth login
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Enable the channel
55
+
56
+ ```bash
57
+ # During research preview, use the development flag
58
+ VOICEMODE_CHANNEL_ENABLED=true claude --dangerously-load-development-channels plugin:voicemode-channel@mbailey
59
+ ```
60
+
61
+ ### Make a call
62
+
63
+ Open **[app.voicemode.dev](https://app.voicemode.dev)** on your phone or browser. Sign in with the same account, tap the call button, and speak. Claude will respond and you'll hear TTS audio playback.
64
+
65
+ ## Configuration
66
+
67
+ | Environment Variable | Default | Description |
68
+ | ------------------------------ | ------------------------ | --------------------------------------------------------------------------- |
69
+ | `VOICEMODE_CHANNEL_ENABLED` | `false` | **Required.** Must be `true` to enable. Server exits immediately otherwise. |
70
+ | `VOICEMODE_CHANNEL_DEBUG` | `false` | Enable debug logging |
71
+ | `VOICEMODE_CONNECT_WS_URL` | `wss://voicemode.dev/ws` | WebSocket gateway URL |
72
+ | `VOICEMODE_AGENT_NAME` | `voicemode` | Agent identity for gateway registration |
73
+ | `VOICEMODE_AGENT_DISPLAY_NAME` | `Claude Code` | Display name shown to callers |
74
+
75
+ ## How it works
76
+
77
+ This plugin provides an MCP server that declares the experimental `claude/channel` capability. It:
78
+
79
+ 1. Connects to the VoiceMode Connect WebSocket gateway (authenticated via Auth0)
80
+ 2. Registers as a callable agent so callers can reach it
81
+ 3. Receives voice transcripts and pushes them as channel notifications
82
+ 4. Provides a `reply` tool for Claude to send responses back
83
+
84
+ Channel events appear in Claude's session as:
85
+
86
+ ```
87
+ <channel source="voicemode-channel" caller="NAME">TRANSCRIPT</channel>
88
+ ```
89
+
90
+ ## Troubleshooting
91
+
92
+ **Channel not connecting**
93
+
94
+ - Ensure `VOICEMODE_CHANNEL_ENABLED=true` is set
95
+ - Check credentials exist: `voicemode-channel auth status`
96
+ - Re-authenticate: `voicemode-channel auth login`
97
+ - Enable debug logging: `VOICEMODE_CHANNEL_DEBUG=true`
98
+
99
+ **No audio on caller's device**
100
+
101
+ - Confirm you're signed into [app.voicemode.dev](https://app.voicemode.dev) with the same account
102
+ - Check that Claude is using the `reply` tool (not a plain text response)
103
+
104
+ **Plugin not found after install**
105
+
106
+ - Verify Claude Code v2.1.80+ is installed: `claude --version`
107
+ - Reinstall: `claude plugin install voicemode-channel@mbailey`
108
+
109
+ **Hook timeout on startup**
110
+
111
+ - The SessionStart hook installs npm dependencies -- this may take a moment on first run
112
+ - Subsequent starts use the cached install and are fast
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ # Clone and test locally
118
+ git clone https://github.com/mbailey/voicemode-channel.git
119
+ cd voicemode-channel
120
+ npm install
121
+
122
+ # Build
123
+ make build
124
+
125
+ # Test with --plugin-dir
126
+ VOICEMODE_CHANNEL_ENABLED=true claude --plugin-dir . --dangerously-load-development-channels server:voicemode-channel
127
+ ```
128
+
129
+ ### Testing with mcptools
130
+
131
+ [mcptools](https://github.com/f/mcptools) provides an interactive shell for testing MCP servers (`brew install mcptools`):
132
+
133
+ ```bash
134
+ make shell
135
+ ```
136
+
137
+ Example session:
138
+
139
+ ```
140
+ mcp > tools # List available tools
141
+ mcp > status # Check connection state
142
+ mcp > reply {"text":"hello from cli"} # Send a voice reply
143
+ mcp > profile # View agent profile
144
+ mcp > profile {"voice":"af_sky"} # Update profile fields
145
+ mcp > /q # Quit
146
+ ```
147
+
148
+ The MCP Inspector web UI is also available:
149
+
150
+ ```bash
151
+ make inspect
152
+ ```
153
+
154
+ ## Status
155
+
156
+ Research preview. Requires Claude Code v2.1.80+ with channel support.
157
+
158
+ ## License
159
+
160
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * OAuth PKCE login flow for VoiceMode Connect.
3
+ *
4
+ * Implements Auth0 PKCE authentication using only Node built-ins:
5
+ * - crypto for PKCE code verifier/challenge
6
+ * - http for localhost callback server
7
+ * - child_process for opening browser
8
+ *
9
+ * Ported from voice_mode/auth.py in the Python CLI.
10
+ */
11
+ import { type StoredCredentials } from './credentials.js';
12
+ export declare function login(log: (msg: string) => void): Promise<StoredCredentials>;
package/dist/auth.js ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * OAuth PKCE login flow for VoiceMode Connect.
3
+ *
4
+ * Implements Auth0 PKCE authentication using only Node built-ins:
5
+ * - crypto for PKCE code verifier/challenge
6
+ * - http for localhost callback server
7
+ * - child_process for opening browser
8
+ *
9
+ * Ported from voice_mode/auth.py in the Python CLI.
10
+ */
11
+ import { createHash, randomBytes } from 'node:crypto';
12
+ import { createServer } from 'node:http';
13
+ import { execFile } from 'node:child_process';
14
+ import { platform } from 'node:os';
15
+ import { AUTH0_DOMAIN, AUTH0_CLIENT_ID, save_credentials, } from './credentials.js';
16
+ // ---------------------------------------------------------------------------
17
+ // Auth0 OAuth parameters (matching Python CLI)
18
+ // ---------------------------------------------------------------------------
19
+ const AUTH0_SCOPES = 'openid profile email offline_access';
20
+ const AUTH0_AUDIENCE = 'https://voicemode.dev/api';
21
+ // ---------------------------------------------------------------------------
22
+ // Callback server configuration
23
+ // ---------------------------------------------------------------------------
24
+ const CALLBACK_PORT_START = 8765;
25
+ const CALLBACK_PORT_END = 8769;
26
+ const CALLBACK_TIMEOUT_MS = 300_000; // 5 minutes
27
+ function generate_pkce_params() {
28
+ // 32 random bytes → base64url ≈ 43 characters
29
+ const code_verifier = randomBytes(32)
30
+ .toString('base64url');
31
+ // SHA256 hash → base64url (no padding)
32
+ const code_challenge = createHash('sha256')
33
+ .update(code_verifier, 'ascii')
34
+ .digest('base64url');
35
+ return { code_verifier, code_challenge };
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Port selection
39
+ // ---------------------------------------------------------------------------
40
+ /**
41
+ * Try to listen on a port, resolving with the server if successful.
42
+ * Eliminates TOCTOU race by using listen() directly instead of checking first.
43
+ */
44
+ function try_listen(server, port) {
45
+ return new Promise((resolve) => {
46
+ server.once('error', (err) => {
47
+ if (err.code === 'EADDRINUSE')
48
+ resolve(false);
49
+ else
50
+ resolve(false);
51
+ });
52
+ server.listen(port, '127.0.0.1', () => resolve(true));
53
+ });
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Callback HTML page
57
+ // ---------------------------------------------------------------------------
58
+ function escape_html(s) {
59
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
60
+ }
61
+ function callback_page(success, error_message = '') {
62
+ const icon_bg = success ? '#3fb950' : '#f85149';
63
+ const icon_svg = success
64
+ ? '<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" fill="#0d1117"/>'
65
+ : '<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" fill="#0d1117"/>';
66
+ const heading = success ? 'Authentication Successful' : 'Authentication Failed';
67
+ const message = success
68
+ ? 'You can close this window and return to the terminal.'
69
+ : (error_message ? `Error: ${escape_html(error_message)}` : 'Something went wrong.');
70
+ return `<!DOCTYPE html>
71
+ <html lang="en">
72
+ <head>
73
+ <meta charset="utf-8">
74
+ <meta name="viewport" content="width=device-width, initial-scale=1">
75
+ <title>VoiceMode - ${heading}</title>
76
+ <style>
77
+ * { margin: 0; padding: 0; box-sizing: border-box; }
78
+ body {
79
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
80
+ background: #0d1117; color: #e6edf3;
81
+ display: flex; align-items: center; justify-content: center;
82
+ min-height: 100vh; padding: 24px;
83
+ }
84
+ .card {
85
+ background: #161b22; border: 1px solid #30363d; border-radius: 12px;
86
+ padding: 48px 40px; max-width: 420px; width: 100%; text-align: center;
87
+ }
88
+ .icon {
89
+ display: inline-flex; align-items: center; justify-content: center;
90
+ width: 48px; height: 48px; background: ${icon_bg}; border-radius: 50%; margin-bottom: 20px;
91
+ }
92
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: #e6edf3; }
93
+ p { font-size: 14px; color: #8b949e; line-height: 1.5; }
94
+ </style>
95
+ </head>
96
+ <body>
97
+ <div class="card">
98
+ <div><div class="icon">
99
+ <svg width="24" height="24" viewBox="0 0 24 24">${icon_svg}</svg>
100
+ </div></div>
101
+ <h1>${heading}</h1>
102
+ <p>${message}</p>
103
+ </div>
104
+ </body>
105
+ </html>`;
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // Browser opening
109
+ // ---------------------------------------------------------------------------
110
+ function open_browser(url) {
111
+ const plat = platform();
112
+ let cmd;
113
+ let args;
114
+ if (plat === 'darwin') {
115
+ cmd = 'open';
116
+ args = [url];
117
+ }
118
+ else if (plat === 'linux') {
119
+ cmd = 'xdg-open';
120
+ args = [url];
121
+ }
122
+ else {
123
+ // Windows fallback (unlikely for this project)
124
+ cmd = 'cmd';
125
+ args = ['/c', 'start', '', url];
126
+ }
127
+ try {
128
+ execFile(cmd, args, (err) => {
129
+ if (err) {
130
+ // Browser open failed -- headless fallback already handled by caller
131
+ }
132
+ });
133
+ return true;
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // Auth0 API calls
141
+ // ---------------------------------------------------------------------------
142
+ async function exchange_code_for_tokens(code, code_verifier, redirect_uri) {
143
+ const token_url = `https://${AUTH0_DOMAIN}/oauth/token`;
144
+ const body = new URLSearchParams({
145
+ grant_type: 'authorization_code',
146
+ client_id: AUTH0_CLIENT_ID,
147
+ code,
148
+ code_verifier,
149
+ redirect_uri,
150
+ });
151
+ const response = await fetch(token_url, {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
154
+ body: body.toString(),
155
+ });
156
+ if (!response.ok) {
157
+ let detail = `HTTP ${response.status}`;
158
+ try {
159
+ const err = (await response.json());
160
+ detail = `${err.error ?? 'unknown'}: ${err.error_description ?? 'Token exchange failed'}`;
161
+ }
162
+ catch { /* use HTTP status */ }
163
+ throw new Error(`Token exchange failed: ${detail}`);
164
+ }
165
+ return (await response.json());
166
+ }
167
+ async function get_user_info(access_token) {
168
+ const url = `https://${AUTH0_DOMAIN}/userinfo`;
169
+ try {
170
+ const response = await fetch(url, {
171
+ headers: { Authorization: `Bearer ${access_token}` },
172
+ });
173
+ if (!response.ok)
174
+ return null;
175
+ return (await response.json());
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Authorization URL builder
183
+ // ---------------------------------------------------------------------------
184
+ function build_authorize_url(redirect_uri, pkce, state) {
185
+ const params = new URLSearchParams({
186
+ response_type: 'code',
187
+ client_id: AUTH0_CLIENT_ID,
188
+ redirect_uri,
189
+ scope: AUTH0_SCOPES,
190
+ audience: AUTH0_AUDIENCE,
191
+ code_challenge: pkce.code_challenge,
192
+ code_challenge_method: 'S256',
193
+ state,
194
+ });
195
+ return `https://${AUTH0_DOMAIN}/authorize?${params.toString()}`;
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // Main login flow
199
+ // ---------------------------------------------------------------------------
200
+ export async function login(log) {
201
+ // Generate PKCE parameters and state upfront (needed for the request handler)
202
+ const pkce = generate_pkce_params();
203
+ const state = randomBytes(16).toString('base64url');
204
+ // Create the server with request handler and find a port by trying to listen directly.
205
+ // The server stays bound to the port the entire time -- no TOCTOU race.
206
+ const result = await new Promise((resolve, reject) => {
207
+ let bound_port = 0;
208
+ const callback_server = createServer((req, res) => {
209
+ const url = new URL(req.url ?? '/', `http://localhost:${bound_port}`);
210
+ if (url.pathname !== '/callback') {
211
+ res.writeHead(404);
212
+ res.end();
213
+ return;
214
+ }
215
+ const error = url.searchParams.get('error');
216
+ if (error) {
217
+ const desc = url.searchParams.get('error_description') ?? 'Unknown error';
218
+ res.writeHead(200, { 'Content-Type': 'text/html' });
219
+ res.end(callback_page(false, desc));
220
+ cleanup();
221
+ reject(new Error(`${error}: ${desc}`));
222
+ return;
223
+ }
224
+ const code = url.searchParams.get('code');
225
+ if (!code) {
226
+ res.writeHead(200, { 'Content-Type': 'text/html' });
227
+ res.end(callback_page(false, 'Missing authorization code'));
228
+ cleanup();
229
+ reject(new Error('Missing authorization code in callback'));
230
+ return;
231
+ }
232
+ res.writeHead(200, { 'Content-Type': 'text/html' });
233
+ res.end(callback_page(true));
234
+ cleanup();
235
+ resolve({ code, state: url.searchParams.get('state'), port: bound_port });
236
+ });
237
+ const timeout_timer = setTimeout(() => {
238
+ cleanup();
239
+ resolve(null);
240
+ }, CALLBACK_TIMEOUT_MS);
241
+ function cleanup() {
242
+ clearTimeout(timeout_timer);
243
+ callback_server.close();
244
+ }
245
+ // Try ports sequentially until one binds
246
+ async function bind_port() {
247
+ for (let p = CALLBACK_PORT_START; p <= CALLBACK_PORT_END; p++) {
248
+ if (await try_listen(callback_server, p)) {
249
+ bound_port = p;
250
+ return;
251
+ }
252
+ }
253
+ cleanup();
254
+ reject(new Error(`No available ports in range ${CALLBACK_PORT_START}-${CALLBACK_PORT_END}. ` +
255
+ 'Please close applications using these ports and try again.'));
256
+ }
257
+ bind_port().then(() => {
258
+ const redirect_uri = `http://localhost:${bound_port}/callback`;
259
+ const auth_url = build_authorize_url(redirect_uri, pkce, state);
260
+ log(`Callback server listening on port ${bound_port}`);
261
+ // Try to open browser
262
+ const opened = open_browser(auth_url);
263
+ if (!opened || !process.env.DISPLAY && !process.env.BROWSER && platform() === 'linux') {
264
+ // Headless fallback
265
+ process.stderr.write(`\nOpen this URL in your browser to log in:\n\n ${auth_url}\n\n`);
266
+ }
267
+ else {
268
+ log('Opening browser for authentication...');
269
+ }
270
+ log('Waiting for authentication (up to 5 minutes)...');
271
+ }).catch(reject);
272
+ });
273
+ if (result === null) {
274
+ throw new Error('Authentication timed out. Please try again.');
275
+ }
276
+ // Verify state (CSRF protection)
277
+ if (result.state !== state) {
278
+ throw new Error('State mismatch -- possible CSRF attack. Please try again.');
279
+ }
280
+ log('Authorization code received, exchanging for tokens...');
281
+ // Exchange code for tokens
282
+ const redirect_uri = `http://localhost:${result.port}/callback`;
283
+ const token_response = await exchange_code_for_tokens(result.code, pkce.code_verifier, redirect_uri);
284
+ const expires_in = token_response.expires_in ?? 3600;
285
+ // Fetch user info (optional, non-fatal)
286
+ const user_info = await get_user_info(token_response.access_token);
287
+ // Build and save credentials
288
+ const creds = {
289
+ access_token: token_response.access_token,
290
+ refresh_token: token_response.refresh_token ?? null,
291
+ expires_at: Date.now() / 1000 + expires_in,
292
+ token_type: token_response.token_type ?? 'Bearer',
293
+ user_info: user_info ?? undefined,
294
+ };
295
+ save_credentials(creds);
296
+ log('Login successful -- credentials saved to ~/.voicemode/credentials');
297
+ return creds;
298
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared credential management for VoiceMode Connect.
3
+ *
4
+ * Auth0 constants, credential file I/O, and token refresh logic
5
+ * used by both the gateway client and the auth login flow.
6
+ */
7
+ export declare const AUTH0_DOMAIN = "dev-2q681p5hobd1dtmm.us.auth0.com";
8
+ export declare const AUTH0_CLIENT_ID = "1uJR1Q4HMkLkhzOXTg5JFuqBCq0FBsXK";
9
+ export declare const CREDENTIALS_FILE: string;
10
+ export interface StoredCredentials {
11
+ access_token: string;
12
+ refresh_token: string | null;
13
+ expires_at: number;
14
+ token_type: string;
15
+ user_info?: Record<string, unknown>;
16
+ }
17
+ export declare function load_credentials(): StoredCredentials | null;
18
+ export declare function save_credentials(creds: StoredCredentials): void;
19
+ export declare function is_expired(creds: StoredCredentials): boolean;
20
+ export declare function refresh_access_token(refresh_token: string): Promise<StoredCredentials | null>;
21
+ /**
22
+ * Get a valid (non-expired) access token, refreshing if necessary.
23
+ * Returns the access token string, or null if no valid token is available.
24
+ */
25
+ export declare function get_valid_token(log: (msg: string) => void): Promise<string | null>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Shared credential management for VoiceMode Connect.
3
+ *
4
+ * Auth0 constants, credential file I/O, and token refresh logic
5
+ * used by both the gateway client and the auth login flow.
6
+ */
7
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ // ---------------------------------------------------------------------------
11
+ // Auth0 configuration
12
+ // ---------------------------------------------------------------------------
13
+ export const AUTH0_DOMAIN = 'dev-2q681p5hobd1dtmm.us.auth0.com';
14
+ export const AUTH0_CLIENT_ID = '1uJR1Q4HMkLkhzOXTg5JFuqBCq0FBsXK';
15
+ // ---------------------------------------------------------------------------
16
+ // Credential storage
17
+ // ---------------------------------------------------------------------------
18
+ export const CREDENTIALS_FILE = join(homedir(), '.voicemode', 'credentials');
19
+ const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
20
+ // ---------------------------------------------------------------------------
21
+ // Credential helpers
22
+ // ---------------------------------------------------------------------------
23
+ export function load_credentials() {
24
+ try {
25
+ const raw = readFileSync(CREDENTIALS_FILE, 'utf-8');
26
+ const data = JSON.parse(raw);
27
+ if (!data.access_token)
28
+ return null;
29
+ return data;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function save_credentials(creds) {
36
+ try {
37
+ const dir = join(homedir(), '.voicemode');
38
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
39
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
40
+ }
41
+ catch {
42
+ // Best effort -- if we can't save, we continue with the in-memory token
43
+ }
44
+ }
45
+ export function is_expired(creds) {
46
+ return Date.now() / 1000 >= (creds.expires_at - TOKEN_EXPIRY_BUFFER_SECONDS);
47
+ }
48
+ export async function refresh_access_token(refresh_token) {
49
+ const token_url = `https://${AUTH0_DOMAIN}/oauth/token`;
50
+ const body = new URLSearchParams({
51
+ grant_type: 'refresh_token',
52
+ client_id: AUTH0_CLIENT_ID,
53
+ refresh_token,
54
+ });
55
+ try {
56
+ const response = await fetch(token_url, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
59
+ body: body.toString(),
60
+ });
61
+ if (!response.ok) {
62
+ return null;
63
+ }
64
+ const data = (await response.json());
65
+ const expires_in = data.expires_in ?? 3600;
66
+ const new_creds = {
67
+ access_token: data.access_token,
68
+ refresh_token: data.refresh_token ?? refresh_token,
69
+ expires_at: Date.now() / 1000 + expires_in,
70
+ token_type: data.token_type ?? 'Bearer',
71
+ };
72
+ return new_creds;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /**
79
+ * Get a valid (non-expired) access token, refreshing if necessary.
80
+ * Returns the access token string, or null if no valid token is available.
81
+ */
82
+ export async function get_valid_token(log) {
83
+ const creds = load_credentials();
84
+ if (!creds) {
85
+ log('No credentials found at ~/.voicemode/credentials');
86
+ log('Run: voicemode connect auth login');
87
+ return null;
88
+ }
89
+ if (!is_expired(creds)) {
90
+ return creds.access_token;
91
+ }
92
+ log('Access token expired, attempting refresh...');
93
+ if (!creds.refresh_token) {
94
+ log('No refresh token available -- please re-login');
95
+ log('Run: voicemode connect auth login');
96
+ return null;
97
+ }
98
+ const refreshed = await refresh_access_token(creds.refresh_token);
99
+ if (!refreshed) {
100
+ log('Token refresh failed -- please re-login');
101
+ log('Run: voicemode connect auth login');
102
+ return null;
103
+ }
104
+ // Preserve user_info from original credentials
105
+ refreshed.user_info = creds.user_info;
106
+ save_credentials(refreshed);
107
+ log('Token refreshed successfully');
108
+ return refreshed.access_token;
109
+ }