promptmic 0.1.2 → 0.1.3

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
@@ -71,6 +71,12 @@ If you want the browser to open automatically:
71
71
  npx promptmic --open
72
72
  ```
73
73
 
74
+ If you want to delete the saved local config and start fresh:
75
+
76
+ ```bash
77
+ npx promptmic --reset-config
78
+ ```
79
+
74
80
  If you install it globally, the executable name is:
75
81
 
76
82
  ```bash
@@ -171,6 +177,18 @@ If you want to use shell wrappers or functions such as `personal` or `work`, the
171
177
  - `npm run start:repo`: forces `./config.json`
172
178
  - `npx promptmic`: should behave like `npm run start`
173
179
 
180
+ To reset the default local config file used by the published CLI:
181
+
182
+ ```bash
183
+ npx promptmic --reset-config
184
+ ```
185
+
186
+ To reset a specific config file instead:
187
+
188
+ ```bash
189
+ npx promptmic --config ./config.json --reset-config
190
+ ```
191
+
174
192
  ### Provider fields
175
193
 
176
194
  | Field | Type | Required | Description |
@@ -184,9 +202,19 @@ If you want to use shell wrappers or functions such as `personal` or `work`, the
184
202
  ### Execution modes
185
203
 
186
204
  - `direct` is the recommended default for most providers because it runs the CLI directly and avoids shell startup noise
205
+ - on macOS and Linux, `direct` also probes the user's interactive shell to recover user PATH entries when the app was launched from a GUI or another stripped-down environment
187
206
  - `shell` is useful when you rely on shell wrappers, aliases, or functions such as `personal`, `work`, or custom environment bootstrap commands
207
+ - `direct` still requires a real executable; aliases and shell functions remain a `shell` use case
188
208
  - In `shell` mode, anything printed by your shell startup files can appear at the start of the session
189
209
 
210
+ ### Troubleshooting command resolution
211
+
212
+ - If a provider works in your terminal but fails inside TermSpeak, prefer `direct` with a real executable name or absolute path
213
+ - If the executable lives in a user-managed bin directory such as `~/.local/bin`, `~/.nvm/.../bin`, or a Homebrew prefix, TermSpeak will try to recover those PATH entries from your interactive shell on macOS and Linux
214
+ - For maximum portability across macOS, Linux, and Windows, use an absolute executable path when you can
215
+ - If you depend on aliases, shell functions, or wrapper commands, switch that provider to `shell`
216
+ - You can also set `PATH` explicitly in the provider `env` when you need a fully controlled runtime environment
217
+
190
218
  ### Example: multiple Claude accounts with direct execution
191
219
 
192
220
  ```json
@@ -1,7 +1,22 @@
1
1
  import pty from 'node-pty';
2
+ import { execFileSync } from 'node:child_process';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { HOME_DIR } from './runtime-config.js';
6
+ function getEnvValue(env, key, platform) {
7
+ if (platform !== 'win32')
8
+ return env[key];
9
+ const actualKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === key.toLowerCase());
10
+ return actualKey ? env[actualKey] : undefined;
11
+ }
12
+ function setEnvValue(env, key, value, platform) {
13
+ if (platform !== 'win32') {
14
+ env[key] = value;
15
+ return;
16
+ }
17
+ const actualKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === key.toLowerCase()) ?? key;
18
+ env[actualKey] = value;
19
+ }
5
20
  function isExecutable(filePath, platform) {
6
21
  try {
7
22
  fs.accessSync(filePath, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
@@ -60,8 +75,15 @@ function readShebang(filePath) {
60
75
  return null;
61
76
  }
62
77
  }
63
- function resolveDirectSpawnTarget(command, args, env, platform) {
64
- const executable = resolveDirectExecutable(command, env.PATH, platform, env.PATHEXT) ?? command;
78
+ function resolveDirectSpawnTarget(command, args, env, platform, userShell, execFileSyncFn) {
79
+ const executableFromPath = resolveDirectExecutable(command, getEnvValue(env, 'PATH', platform), platform, getEnvValue(env, 'PATHEXT', platform));
80
+ const shellProbe = executableFromPath ? null : readShellProbe(command, userShell, env, platform, execFileSyncFn);
81
+ if (shellProbe?.shellPath) {
82
+ const mergedPath = mergePathValues(shellProbe.shellPath, getEnvValue(env, 'PATH', platform), platform);
83
+ if (mergedPath)
84
+ setEnvValue(env, 'PATH', mergedPath, platform);
85
+ }
86
+ const executable = shellProbe?.executablePath || executableFromPath || command;
65
87
  try {
66
88
  const realPath = fs.realpathSync(executable);
67
89
  const shebang = readShebang(realPath)?.toLowerCase() ?? '';
@@ -88,6 +110,66 @@ function quoteShellArg(value) {
88
110
  return "''";
89
111
  return `'${value.replace(/'/g, `'\\''`)}'`;
90
112
  }
113
+ function mergePathValues(preferredPath, fallbackPath, platform) {
114
+ const delimiter = platform === 'win32' ? ';' : path.delimiter;
115
+ const seen = new Set();
116
+ const merged = [];
117
+ for (const source of [preferredPath, fallbackPath]) {
118
+ if (!source)
119
+ continue;
120
+ for (const entry of source.split(delimiter)) {
121
+ const trimmed = entry.trim();
122
+ if (!trimmed)
123
+ continue;
124
+ const key = platform === 'win32' ? trimmed.toLowerCase() : trimmed;
125
+ if (seen.has(key))
126
+ continue;
127
+ seen.add(key);
128
+ merged.push(trimmed);
129
+ }
130
+ }
131
+ return merged.length > 0 ? merged.join(delimiter) : undefined;
132
+ }
133
+ function readShellProbe(command, userShell, env, platform, execFileSyncFn = execFileSync) {
134
+ if (platform === 'win32' || !userShell)
135
+ return null;
136
+ if (!command || command.includes('/') || command.includes('\\'))
137
+ return null;
138
+ const pathStartMarker = '__TERMSPEAK_PATH_START__';
139
+ const pathEndMarker = '__TERMSPEAK_PATH_END__';
140
+ const commandStartMarker = '__TERMSPEAK_COMMAND_START__';
141
+ const commandEndMarker = '__TERMSPEAK_COMMAND_END__';
142
+ const shellCommand = [
143
+ `printf '${pathStartMarker}%s${pathEndMarker}\\n' "$PATH"`,
144
+ `resolved=$(command -v ${quoteShellArg(command)} 2>/dev/null || true)`,
145
+ `printf '${commandStartMarker}%s${commandEndMarker}' "$resolved"`,
146
+ ].join('; ');
147
+ try {
148
+ const output = execFileSyncFn(userShell, ['-ic', shellCommand], {
149
+ encoding: 'utf8',
150
+ env: { ...env },
151
+ timeout: 3000,
152
+ maxBuffer: 64 * 1024,
153
+ stdio: ['ignore', 'pipe', 'ignore'],
154
+ });
155
+ const shellPath = output.match(new RegExp(`${pathStartMarker}([\\s\\S]*?)${pathEndMarker}`))?.[1]?.trim() || undefined;
156
+ const commandLines = output
157
+ .match(new RegExp(`${commandStartMarker}([\\s\\S]*?)${commandEndMarker}`))?.[1]
158
+ ?.split(/\r?\n/)
159
+ .map((line) => line.trim())
160
+ .filter(Boolean);
161
+ const executablePath = commandLines
162
+ ? [...commandLines].reverse().find((line) => line.includes('/') || line.includes('\\'))
163
+ : undefined;
164
+ if (!executablePath)
165
+ return shellPath ? { executablePath: '', shellPath } : null;
166
+ const resolvedPath = expandTilde(executablePath);
167
+ return isExecutable(resolvedPath, platform) ? { executablePath: resolvedPath, shellPath } : shellPath ? { executablePath: '', shellPath } : null;
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
91
173
  function formatSpawnError(entry, error) {
92
174
  const errorMessage = error instanceof Error ? error.message : String(error ?? '');
93
175
  const errorCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
@@ -96,7 +178,7 @@ function formatSpawnError(entry, error) {
96
178
  /posix_spawnp failed/i.test(errorMessage) ||
97
179
  /spawn .* ENOENT/i.test(errorMessage) ||
98
180
  /not found/i.test(errorMessage))) {
99
- return new Error(`Could not start "${entry.label}". Command "${entry.command}" was not found in PATH. Install it or switch this provider to Shell command if it depends on a shell wrapper.`);
181
+ return new Error(`Could not start "${entry.label}". Command "${entry.command}" was not found in PATH. Install it, use an absolute path, set PATH in provider env, or switch this provider to Shell command if it depends on a shell wrapper.`);
100
182
  }
101
183
  if (error instanceof Error) {
102
184
  return new Error(`Could not start "${entry.label}": ${error.message}`);
@@ -132,6 +214,7 @@ export function createPtySpawner(options = {}) {
132
214
  const spawn = options.spawn ?? pty.spawn;
133
215
  const shell = options.shell ?? process.env.SHELL ?? '/bin/bash';
134
216
  const platform = options.platform ?? process.platform;
217
+ const execFileSyncFn = options.execFileSync ?? execFileSync;
135
218
  return ({ provider, cwd, config }) => {
136
219
  const entry = config.providers[provider];
137
220
  const command = resolveProviderCommand(config, provider, shell);
@@ -140,7 +223,7 @@ export function createPtySpawner(options = {}) {
140
223
  ...command.envOverrides,
141
224
  };
142
225
  const spawnTarget = command.executionMode === 'direct'
143
- ? resolveDirectSpawnTarget(command.file, command.args, env, platform)
226
+ ? resolveDirectSpawnTarget(command.file, command.args, env, platform, shell, execFileSyncFn)
144
227
  : { file: command.file, args: command.args };
145
228
  try {
146
229
  return spawn(spawnTarget.file, spawnTarget.args, {
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
@@ -31,6 +31,7 @@ Options:
31
31
  --no-open Do not open the browser after the server starts (default)
32
32
  --config <path> Path to the config file (default: ${DEFAULT_CONFIG_PATH})
33
33
  --cwd <path> Default working directory for new sessions
34
+ --reset-config Delete the selected local config file and exit
34
35
  --help, -h Show this help message
35
36
  --version, -v Show the current version
36
37
  `;
@@ -81,6 +82,11 @@ export function parseArgs(argv) {
81
82
  continue;
82
83
  }
83
84
 
85
+ if (arg === '--reset-config') {
86
+ options.resetConfig = true;
87
+ continue;
88
+ }
89
+
84
90
  if (arg === '--port' || arg.startsWith('--port=')) {
85
91
  const raw = arg.includes('=') ? arg.slice('--port='.length) : takeValue(arg, i++);
86
92
  const port = Number(raw);
@@ -115,6 +121,23 @@ export function parseArgs(argv) {
115
121
  return options;
116
122
  }
117
123
 
124
+ export function resetConfigFile(configPath = DEFAULT_CONFIG_PATH) {
125
+ const resolvedPath = path.resolve(configPath);
126
+
127
+ if (!existsSync(resolvedPath)) {
128
+ return {
129
+ removed: false,
130
+ configPath: resolvedPath,
131
+ };
132
+ }
133
+
134
+ rmSync(resolvedPath, { force: true });
135
+ return {
136
+ removed: true,
137
+ configPath: resolvedPath,
138
+ };
139
+ }
140
+
118
141
  export function openBrowser(url) {
119
142
  const platform = process.platform;
120
143
 
@@ -147,6 +170,16 @@ export async function main(argv = process.argv.slice(2)) {
147
170
  return;
148
171
  }
149
172
 
173
+ if (options.resetConfig) {
174
+ const result = resetConfigFile(options.configPath ?? DEFAULT_CONFIG_PATH);
175
+ console.log(
176
+ result.removed
177
+ ? `Deleted config file: ${result.configPath}`
178
+ : `Config file not found, nothing to reset: ${result.configPath}`,
179
+ );
180
+ return;
181
+ }
182
+
150
183
  if (!existsSync(SERVER_ENTRY)) {
151
184
  throw new Error('TermSpeak is not built yet. Run `npm run build` first.');
152
185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptmic",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Voice-first local UI for terminal-based AI coding assistants.",
5
5
  "license": "MIT",
6
6
  "keywords": [