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 +28 -0
- package/apps/server/dist/pty-spawner.js +87 -4
- package/bin/termspeak-lib.mjs +34 -1
- package/package.json +1 -1
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
|
|
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, {
|
package/bin/termspeak-lib.mjs
CHANGED
|
@@ -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
|
}
|