logwork-cli 1.2.0 → 1.3.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.
@@ -0,0 +1,303 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants as fsConstants, promises as fs } from 'node:fs';
3
+ import { posix, win32 } from 'node:path';
4
+ import { atomicWriteFile } from './atomic-file.mjs';
5
+ import { helperHome } from './paths.mjs';
6
+
7
+ // IDE MCP configs and the reminder schedule must keep working after nvm,
8
+ // Homebrew or a Node installer upgrade, without the user editing anything.
9
+ //
10
+ // - macOS/Linux: `logwork setup` writes <home>/bin/logwork-launch, a POSIX sh
11
+ // script that finds a usable Node 22+ at start time and runs
12
+ // <home>/bin/logwork.mjs with it. Configs point at the script, which never
13
+ // moves.
14
+ // - Windows: GUI clients would need `cmd /c` for a .cmd launcher, so configs
15
+ // use a stable node.exe instead: the nvm-windows symlink when Node runs from
16
+ // an nvm-windows version folder, otherwise the running node.exe (usually
17
+ // C:\Program Files\nodejs\node.exe, which an installer upgrade keeps).
18
+
19
+ export const LAUNCHER_FILE_NAME = 'logwork-launch';
20
+ export const MIN_NODE_MAJOR = 22;
21
+ // Makes the launcher print the Node it would use and exit, without starting
22
+ // logwork. Used by `logwork doctor`.
23
+ export const LAUNCHER_PRINT_NODE_ENV = 'LOGWORK_LAUNCHER_PRINT_NODE';
24
+ const ENTRY_RELATIVE = ['bin', 'logwork.mjs'];
25
+
26
+ export function launcherPath(home = helperHome()) {
27
+ return posix.join(home, 'bin', LAUNCHER_FILE_NAME);
28
+ }
29
+
30
+ export function entryPath(home = helperHome(), platform = process.platform) {
31
+ return pathApi(platform).join(home, ...ENTRY_RELATIVE);
32
+ }
33
+
34
+ // The command every MCP config and the reminder schedule run. MCP args are
35
+ // [...argsPrefix, 'mcp'].
36
+ // stable: true when the command survives a Node upgrade without re-running
37
+ // `logwork setup`.
38
+ export function resolveLaunchCommand({
39
+ platform = process.platform,
40
+ home = helperHome(),
41
+ execPath = process.execPath,
42
+ env = process.env
43
+ } = {}) {
44
+ if (platform !== 'win32') {
45
+ return {
46
+ command: launcherPath(home),
47
+ argsPrefix: [],
48
+ kind: 'launcher',
49
+ stable: true
50
+ };
51
+ }
52
+ const entry = win32.join(home, ...ENTRY_RELATIVE);
53
+ const symlink = String(readEnv(env, 'NVM_SYMLINK') || '').trim();
54
+ if (symlink && isNvmWindowsNode(execPath, env)) {
55
+ return {
56
+ command: win32.join(symlink, 'node.exe'),
57
+ argsPrefix: [entry],
58
+ kind: 'nvm-symlink',
59
+ stable: true
60
+ };
61
+ }
62
+ return {
63
+ command: String(execPath),
64
+ argsPrefix: [entry],
65
+ kind: 'node',
66
+ stable: isStandardWindowsInstall(execPath)
67
+ };
68
+ }
69
+
70
+ export function mcpArgs(launch) {
71
+ return [...(launch?.argsPrefix || []), 'mcp'];
72
+ }
73
+
74
+ // Node inside an nvm-windows version folder, e.g. C:\Users\me\AppData\Roaming\nvm\v22.11.0\node.exe.
75
+ export function isNvmWindowsNode(execPath, env = process.env) {
76
+ const path = String(execPath || '').toLowerCase().replaceAll('/', '\\');
77
+ const nvmHome = String(readEnv(env, 'NVM_HOME') || '').toLowerCase().replaceAll('/', '\\').replace(/\\+$/, '');
78
+ if (nvmHome && path.startsWith(`${nvmHome}\\`)) {
79
+ return true;
80
+ }
81
+ return /\\nvm\\v\d+\.\d+\.\d+[^\\]*\\node\.exe$/.test(path);
82
+ }
83
+
84
+ // The official installer keeps node.exe at <Program Files>\nodejs\node.exe
85
+ // across upgrades.
86
+ export function isStandardWindowsInstall(execPath) {
87
+ return /\\nodejs\\node\.exe$/i.test(String(execPath || '').replaceAll('/', '\\'));
88
+ }
89
+
90
+ export function buildPosixLauncherScript({ home, nodePath }) {
91
+ const entry = posix.join(home, ...ENTRY_RELATIVE);
92
+ return `#!/bin/sh
93
+ # Generated by \`logwork setup\`; it is rewritten on every setup, so do not edit.
94
+ # Starts logwork with the first usable Node.js ${MIN_NODE_MAJOR}+, so IDE MCP configs and the
95
+ # reminder keep working after nvm, Homebrew or installer upgrades.
96
+ LOGWORK_ENTRY=${quoteSh(entry)}
97
+ LOGWORK_RECORDED_NODE=${quoteSh(nodePath || '')}
98
+
99
+ logwork_node_ok() {
100
+ [ -n "$1" ] && [ -f "$1" ] && [ -x "$1" ] || return 1
101
+ "$1" -e 'process.exit(+process.versions.node.split(".")[0]>=${MIN_NODE_MAJOR}?0:1)' >/dev/null 2>&1
102
+ }
103
+
104
+ logwork_run() {
105
+ logwork_node=$1
106
+ shift
107
+ if [ "\${${LAUNCHER_PRINT_NODE_ENV}:-}" = "1" ]; then
108
+ printf '%s\\n' "$logwork_node"
109
+ exit 0
110
+ fi
111
+ exec "$logwork_node" "$LOGWORK_ENTRY" "$@"
112
+ }
113
+
114
+ logwork_newest_nvm_node() {
115
+ logwork_best=''
116
+ logwork_best_key=0
117
+ for logwork_dir in "$@"; do
118
+ [ -n "$logwork_dir" ] && [ -d "$logwork_dir/versions/node" ] || continue
119
+ for logwork_candidate in "$logwork_dir"/versions/node/v*/bin/node; do
120
+ [ -x "$logwork_candidate" ] || continue
121
+ logwork_version=\${logwork_candidate%/bin/node}
122
+ logwork_version=\${logwork_version##*/v}
123
+ logwork_major=\${logwork_version%%.*}
124
+ logwork_rest=\${logwork_version#*.}
125
+ logwork_minor=\${logwork_rest%%.*}
126
+ logwork_patch=\${logwork_rest#*.}
127
+ logwork_patch=\${logwork_patch%%[!0-9]*}
128
+ case "$logwork_major.$logwork_minor.$logwork_patch" in
129
+ *[!0-9.]*|.*|*..*|*.) continue ;;
130
+ esac
131
+ [ "$logwork_major" -ge ${MIN_NODE_MAJOR} ] || continue
132
+ logwork_key=$((logwork_major * 1000000 + logwork_minor * 1000 + logwork_patch))
133
+ if [ "$logwork_key" -gt "$logwork_best_key" ]; then
134
+ logwork_best=$logwork_candidate
135
+ logwork_best_key=$logwork_key
136
+ fi
137
+ done
138
+ done
139
+ printf '%s' "$logwork_best"
140
+ }
141
+
142
+ # 1. The Node recorded by setup. Its path names its version, so an existing
143
+ # file is still that Node: skip the version check for a fast start.
144
+ if [ -n "$LOGWORK_RECORDED_NODE" ] && [ -f "$LOGWORK_RECORDED_NODE" ] && [ -x "$LOGWORK_RECORDED_NODE" ]; then
145
+ logwork_run "$LOGWORK_RECORDED_NODE" "$@"
146
+ fi
147
+
148
+ # 2. node on PATH.
149
+ logwork_candidate=$(command -v node 2>/dev/null || true)
150
+ if logwork_node_ok "$logwork_candidate"; then
151
+ logwork_run "$logwork_candidate" "$@"
152
+ fi
153
+
154
+ # 3. The newest nvm Node ${MIN_NODE_MAJOR}+.
155
+ logwork_candidate=$(logwork_newest_nvm_node "\${NVM_DIR:-}" "\${HOME:-}/.nvm")
156
+ if logwork_node_ok "$logwork_candidate"; then
157
+ logwork_run "$logwork_candidate" "$@"
158
+ fi
159
+
160
+ # 4. Volta and fnm defaults, then 5. Homebrew and /usr/local.
161
+ for logwork_candidate in \\
162
+ "\${HOME:-}/.volta/bin/node" \\
163
+ "\${HOME:-}/.local/share/fnm/aliases/default/bin/node" \\
164
+ "\${HOME:-}/Library/Application Support/fnm/aliases/default/bin/node" \\
165
+ /opt/homebrew/bin/node \\
166
+ /usr/local/bin/node
167
+ do
168
+ if logwork_node_ok "$logwork_candidate"; then
169
+ logwork_run "$logwork_candidate" "$@"
170
+ fi
171
+ done
172
+
173
+ echo "logwork: no usable Node.js ${MIN_NODE_MAJOR}+ found. Install Node.js ${MIN_NODE_MAJOR} or newer, or run \\\`logwork setup\\\` again." >&2
174
+ exit 127
175
+ `;
176
+ }
177
+
178
+ // Writes the launcher on macOS/Linux. A no-op on Windows (returns null).
179
+ export async function writeLauncher({
180
+ home = helperHome(),
181
+ nodePath = process.execPath,
182
+ platform = process.platform,
183
+ writeFile = atomicWriteFile,
184
+ chmod = fs.chmod
185
+ } = {}) {
186
+ if (platform === 'win32') {
187
+ return null;
188
+ }
189
+ const path = launcherPath(home);
190
+ await writeFile(path, buildPosixLauncherScript({ home, nodePath }), { mode: 0o755 });
191
+ // atomicWriteFile applies the mode through the umask; make it explicit.
192
+ await chmod(path, 0o755);
193
+ return { path, nodePath };
194
+ }
195
+
196
+ // Checks the command MCP configs and the reminder run. Read-only: it runs the
197
+ // command with `--version` (which prints the logwork version through
198
+ // bin/logwork.mjs) and, for the launcher, asks which Node it would pick.
199
+ export async function checkLaunchCommand(launch = resolveLaunchCommand(), {
200
+ platform = process.platform,
201
+ access = fs.access,
202
+ run = runProcess,
203
+ env = process.env
204
+ } = {}) {
205
+ const result = {
206
+ command: launch.command,
207
+ kind: launch.kind,
208
+ status: 'ok',
209
+ node: launch.kind === 'launcher' ? '' : launch.command,
210
+ version: ''
211
+ };
212
+ try {
213
+ await access(launch.command, fsConstants.F_OK);
214
+ } catch {
215
+ return { ...result, status: 'missing' };
216
+ }
217
+ if (platform !== 'win32') {
218
+ try {
219
+ await access(launch.command, fsConstants.X_OK);
220
+ } catch {
221
+ return { ...result, status: 'not_executable' };
222
+ }
223
+ }
224
+ const childEnv = withoutLogworkEnv(env);
225
+ if (launch.kind === 'launcher') {
226
+ const picked = await run(launch.command, [], { env: { ...childEnv, [LAUNCHER_PRINT_NODE_ENV]: '1' } }).catch((error) => ({ code: 1, stdout: '', stderr: error.message }));
227
+ if (picked.code !== 0) {
228
+ return { ...result, status: 'no_node', detail: firstLine(picked.stderr || picked.stdout) };
229
+ }
230
+ result.node = firstLine(picked.stdout);
231
+ }
232
+ const version = await run(launch.command, [...launch.argsPrefix, '--version'], { env: childEnv }).catch((error) => ({ code: 1, stdout: '', stderr: error.message }));
233
+ if (version.code !== 0) {
234
+ return { ...result, status: 'failed', detail: firstLine(version.stderr || version.stdout) };
235
+ }
236
+ result.version = firstLine(version.stdout);
237
+ return result;
238
+ }
239
+
240
+ export function formatLaunchCheckIssue(check) {
241
+ switch (check?.status) {
242
+ case 'missing':
243
+ return check.kind === 'launcher'
244
+ ? `Launcher: ${check.command} is missing, so IDE MCP configs and the reminder cannot start. Fix: run \`logwork setup\`.`
245
+ : `Node: ${check.command} no longer exists, so IDE MCP configs and the reminder cannot start. Fix: run \`logwork setup\`.`;
246
+ case 'not_executable':
247
+ return `Launcher: ${check.command} is not executable. Fix: run \`logwork setup\`.`;
248
+ case 'no_node':
249
+ return `Launcher: found no usable Node.js ${MIN_NODE_MAJOR}+. Fix: install Node.js ${MIN_NODE_MAJOR} or newer, then run \`logwork setup\`.`;
250
+ case 'failed':
251
+ return `Launcher: ${check.command} --version failed${check.detail ? ` (${check.detail})` : ''}. Fix: run \`logwork setup\`.`;
252
+ default:
253
+ return '';
254
+ }
255
+ }
256
+
257
+ export function runProcess(command, args, { env = process.env, cwd } = {}) {
258
+ return new Promise((resolveValue, reject) => {
259
+ const child = spawn(command, args, {
260
+ cwd,
261
+ env,
262
+ stdio: ['ignore', 'pipe', 'pipe'],
263
+ windowsHide: true
264
+ });
265
+ let stdout = '';
266
+ let stderr = '';
267
+ child.stdout.setEncoding('utf8');
268
+ child.stderr.setEncoding('utf8');
269
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
270
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
271
+ child.on('error', reject);
272
+ child.on('close', (code, signal) => resolveValue({ code: signal ? 1 : (code ?? 1), stdout, stderr }));
273
+ });
274
+ }
275
+
276
+ export function pathApi(platform = process.platform) {
277
+ return platform === 'win32' ? win32 : posix;
278
+ }
279
+
280
+ // The launcher must not inherit a test or dev LOGWORK_HOME that points at a
281
+ // different runtime than the one it launches.
282
+ function withoutLogworkEnv(env) {
283
+ return Object.fromEntries(Object.entries(env || {}).filter(([key]) => !key.startsWith('LOGWORK_')));
284
+ }
285
+
286
+ function firstLine(value) {
287
+ return String(value || '').trim().split(/\r?\n/)[0] || '';
288
+ }
289
+
290
+ function quoteSh(value) {
291
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
292
+ }
293
+
294
+ function readEnv(env, name) {
295
+ if (!env) {
296
+ return undefined;
297
+ }
298
+ if (env[name] !== undefined) {
299
+ return env[name];
300
+ }
301
+ const key = Object.keys(env).find((item) => item.toUpperCase() === name);
302
+ return key ? env[key] : undefined;
303
+ }
@@ -74,8 +74,8 @@ export const MANUAL_COMMANDS = [
74
74
  },
75
75
  {
76
76
  name: '/map',
77
- description: 'Map ticket prefix or git repo to projectMemberId',
78
- usage: '/map SCB 5234 | /map --repo [path] 5234',
77
+ description: 'Map ticket prefix or git repo to an RO project (picker)',
78
+ usage: '/map SCB [project] | /map --repo [path] [project]',
79
79
  aliases: []
80
80
  }
81
81
  ];
@@ -44,6 +44,7 @@ export {
44
44
  OutputPanel,
45
45
  ProjectChart,
46
46
  ProjectPicker,
47
+ SelectPrompt,
47
48
  SelectionList,
48
49
  SlashMenu,
49
50
  StatusBar,
@@ -102,6 +103,7 @@ export function ManualApp({
102
103
  const { exit } = useApp();
103
104
  const sessionRef = useRef(controller.createManualSession());
104
105
  const confirmResolverRef = useRef(null);
106
+ const selectResolverRef = useRef(null);
105
107
  const authResolverRef = useRef(null);
106
108
  const authRejecterRef = useRef(null);
107
109
  const [inputValue, setInputValue] = useState('');
@@ -115,6 +117,7 @@ export function ManualApp({
115
117
  }));
116
118
  const [loading, setLoading] = useState('');
117
119
  const [pendingConfirm, setPendingConfirm] = useState(null);
120
+ const [pendingSelect, setPendingSelect] = useState(null);
118
121
  const [authPrompt, setAuthPrompt] = useState(null);
119
122
  const [authActive, setAuthActive] = useState(false);
120
123
  const [wizard, setWizard] = useState(null);
@@ -128,9 +131,35 @@ export function ManualApp({
128
131
  initialValue
129
132
  });
130
133
  });
134
+ },
135
+ // { message, options: [{ value, label, hint }], initialValue } -> the
136
+ // chosen value, or null on Escape.
137
+ select({ message, options = [], initialValue }) {
138
+ return new Promise((resolveValue) => {
139
+ selectResolverRef.current = resolveValue;
140
+ const initialIndex = Math.max(0, options.findIndex((option) => option.value === initialValue));
141
+ setPendingSelect({
142
+ message,
143
+ options,
144
+ selectedIndex: initialIndex
145
+ });
146
+ });
131
147
  }
132
148
  }), []);
133
149
 
150
+ const changeSelect = useCallback((index) => {
151
+ setPendingSelect((current) => (current ? { ...current, selectedIndex: index } : current));
152
+ }, []);
153
+
154
+ const resolveSelect = useCallback((value) => {
155
+ const resolver = selectResolverRef.current;
156
+ selectResolverRef.current = null;
157
+ setPendingSelect(null);
158
+ if (resolver) {
159
+ resolver(value ?? null);
160
+ }
161
+ }, []);
162
+
134
163
  const resolveConfirm = useCallback((value) => {
135
164
  const resolver = confirmResolverRef.current;
136
165
  confirmResolverRef.current = null;
@@ -343,7 +372,7 @@ export function ManualApp({
343
372
  const text = String(value || '');
344
373
  const trimmed = text.trim();
345
374
 
346
- if (pendingConfirm) {
375
+ if (pendingConfirm || pendingSelect) {
347
376
  return;
348
377
  }
349
378
 
@@ -421,6 +450,7 @@ export function ManualApp({
421
450
  executeCommand,
422
451
  inputValue,
423
452
  pendingConfirm,
453
+ pendingSelect,
424
454
  authActive,
425
455
  authPrompt,
426
456
  selectedIndex,
@@ -450,6 +480,7 @@ export function ManualApp({
450
480
  authPrompt,
451
481
  authActive,
452
482
  pendingConfirm,
483
+ pendingSelect,
453
484
  wizard,
454
485
  inputValue,
455
486
  selectedIndex,
@@ -461,6 +492,8 @@ export function ManualApp({
461
492
  resolveAuthPrompt,
462
493
  rejectAuthPrompt,
463
494
  resolveConfirm,
495
+ changeSelect,
496
+ resolveSelect,
464
497
  exit,
465
498
  executeCommand,
466
499
  submitInput,
@@ -11,6 +11,7 @@ import {
11
11
  DraftPicker,
12
12
  McpClientPicker,
13
13
  ProjectPicker,
14
+ SelectPrompt,
14
15
  TargetPicker,
15
16
  TaskEditPicker,
16
17
  TaskRemovePicker
@@ -24,6 +25,7 @@ export function renderManualInteraction({
24
25
  authPrompt,
25
26
  authActive,
26
27
  pendingConfirm,
28
+ pendingSelect,
27
29
  wizard,
28
30
  inputValue,
29
31
  selectedIndex,
@@ -35,6 +37,8 @@ export function renderManualInteraction({
35
37
  resolveAuthPrompt,
36
38
  rejectAuthPrompt,
37
39
  resolveConfirm,
40
+ changeSelect,
41
+ resolveSelect,
38
42
  exit,
39
43
  executeCommand,
40
44
  submitInput,
@@ -84,6 +88,14 @@ export function renderManualInteraction({
84
88
  initialValue: pendingConfirm.initialValue,
85
89
  onResolve: resolveConfirm
86
90
  })
91
+ : pendingSelect
92
+ ? h(SelectPrompt, {
93
+ message: pendingSelect.message,
94
+ options: pendingSelect.options,
95
+ selectedIndex: pendingSelect.selectedIndex || 0,
96
+ onChange: changeSelect,
97
+ onResolve: resolveSelect
98
+ })
87
99
  : wizard?.step === 'select_logwork_target'
88
100
  ? h(TargetPicker, {
89
101
  title: 'Pick logwork target',
@@ -387,6 +387,44 @@ export function McpClientPicker({
387
387
  });
388
388
  }
389
389
 
390
+ // A generic single-choice prompt for prompts.select({ message, options }):
391
+ // Enter picks the highlighted option, Escape cancels (null). Each option is
392
+ // { value, label, hint? }; the hint is shown after the label.
393
+ export function SelectPrompt({
394
+ message = 'Pick one',
395
+ options = [],
396
+ selectedIndex = 0,
397
+ onChange,
398
+ onResolve
399
+ }) {
400
+ useInput((input, key) => {
401
+ if (key.upArrow) {
402
+ onChange(previousIndex(selectedIndex, options.length));
403
+ return;
404
+ }
405
+ if (key.downArrow) {
406
+ onChange(nextIndex(selectedIndex, options.length));
407
+ return;
408
+ }
409
+ if (key.return) {
410
+ onResolve(options[selectedIndex]?.value ?? null);
411
+ return;
412
+ }
413
+ if (key.escape) {
414
+ onResolve(null);
415
+ }
416
+ });
417
+
418
+ return h(SelectionList, {
419
+ title: `${message} · Esc cancel`,
420
+ items: options.map((option, index) => ({
421
+ key: `${index}:${option.value}`,
422
+ label: option.hint ? `${option.label} · ${option.hint}` : option.label
423
+ })),
424
+ selectedIndex
425
+ });
426
+ }
427
+
390
428
  export function TaskEditPicker({
391
429
  tasks = [],
392
430
  selectedIndex = 0,
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path';
2
+ import { mcpArgs, resolveLaunchCommand } from './launcher.mjs';
2
3
  import { helperHome } from './paths.mjs';
3
4
 
4
5
  export const MCP_CLIENTS = [
@@ -39,18 +40,48 @@ export const MCP_CLIENTS = [
39
40
  }
40
41
  ];
41
42
 
42
- // IDE configs run `<absolute node> <runtime>/bin/logwork.mjs mcp`. Both paths
43
- // are absolute because GUI IDEs (Cursor, VS Code started from the Dock or
43
+ // IDE configs run the launch command from lib/launcher.mjs: on macOS/Linux
44
+ // the generated <home>/bin/logwork-launch script (it finds a usable Node 22+
45
+ // at start time), on Windows a stable node.exe plus <home>\bin\logwork.mjs.
46
+ // Both are absolute because GUI IDEs (Cursor, VS Code started from the Dock or
44
47
  // Finder) often start without the npm global bin directory or nvm's node on
45
48
  // PATH, so neither `logwork` nor a bare `node` would be found.
46
49
  export const MCP_SERVER_NAME = 'logwork';
47
50
 
48
- export const MCP_NODE_PATH_NOTE = 'The config uses the absolute path of the Node that ran this command. If Node is upgraded or moved (nvm, Homebrew, a new installer), re-run `logwork setup` and paste the new config.';
51
+ // Only printed when the command is a raw Node path that an upgrade can remove.
52
+ export const MCP_NODE_PATH_NOTE = 'The config uses the absolute path of the Node that ran this command. If Node is upgraded or moved (nvm, Homebrew, a new installer), re-run `logwork setup` (it updates the config for you).';
53
+
54
+ export const MCP_LAUNCHER_NOTE = 'The command is the logwork launcher; it finds Node 22+ itself, so Node upgrades need no config change.';
55
+
56
+ const INSTALL_CLIENT_KEYS = {
57
+ cursor: 'cursor',
58
+ antigravity: 'antigravity',
59
+ copilot: 'vscode',
60
+ 'claude-code': 'claude',
61
+ codex: 'codex'
62
+ };
49
63
 
50
64
  export function defaultMcpServerPath() {
51
65
  return join(helperHome(), 'bin', 'logwork.mjs');
52
66
  }
53
67
 
68
+ // Accepts a launch command ({ command, argsPrefix }) or, for older callers, an
69
+ // entry path plus { nodePath }: a raw `<node> <entry>` command.
70
+ export function toLaunchCommand(launchOrServerPath, { nodePath } = {}) {
71
+ if (launchOrServerPath && typeof launchOrServerPath === 'object') {
72
+ return launchOrServerPath;
73
+ }
74
+ if (launchOrServerPath === undefined && nodePath === undefined) {
75
+ return resolveLaunchCommand();
76
+ }
77
+ return {
78
+ command: nodePath || process.execPath,
79
+ argsPrefix: [launchOrServerPath || defaultMcpServerPath()],
80
+ kind: 'node',
81
+ stable: false
82
+ };
83
+ }
84
+
54
85
  export function resolveMcpClient(value) {
55
86
  const normalized = normalizeMcpClientValue(value);
56
87
  if (!normalized) {
@@ -85,19 +116,22 @@ export function formatMcpClientOptions(clients = MCP_CLIENTS) {
85
116
 
86
117
  export function formatMcpSetup({
87
118
  client,
88
- serverPath = defaultMcpServerPath(),
89
- nodePath = process.execPath,
119
+ launch,
120
+ serverPath,
121
+ nodePath,
90
122
  platform = process.platform
91
123
  } = {}) {
92
124
  const resolvedClient = typeof client === 'object' ? client : resolveMcpClient(client);
93
125
  if (!resolvedClient) {
94
126
  throw new Error('Unknown MCP client. Use: cursor, antigravity, copilot, claude-code, or codex.');
95
127
  }
128
+ const command = launch || (serverPath === undefined && nodePath === undefined
129
+ ? resolveLaunchCommand({ platform })
130
+ : toLaunchCommand(serverPath, { nodePath }));
96
131
 
97
132
  const lines = [
98
133
  `MCP setup: ${resolvedClient.label}`,
99
- `Node: ${nodePath}`,
100
- `Entry path: ${serverPath} (run with the "mcp" argument)`,
134
+ `Command: ${[command.command, ...mcpArgs(command)].join(' ')}`,
101
135
  ''
102
136
  ];
103
137
 
@@ -105,35 +139,38 @@ export function formatMcpSetup({
105
139
  lines.push(
106
140
  `Copy this TOML into ${resolvedClient.target}:`,
107
141
  '',
108
- formatCodexToml(serverPath, { nodePath })
142
+ formatCodexToml(command)
109
143
  );
110
144
  } else if (resolvedClient.key === 'copilot') {
111
145
  lines.push(
112
146
  `Copy this JSON into ${resolvedClient.target}:`,
113
147
  '',
114
- JSON.stringify(vscodeMcpConfig(serverPath, { nodePath }), null, 2)
148
+ JSON.stringify(vscodeMcpConfig(command), null, 2)
115
149
  );
116
150
  } else if (resolvedClient.key === 'claude-code') {
117
151
  lines.push(
118
152
  'Recommended command:',
119
153
  '',
120
- `claude mcp add --transport stdio ${MCP_SERVER_NAME} -- ${quoteShellArg(nodePath, { platform })} ${quoteShellArg(serverPath, { platform })} mcp`,
154
+ formatClaudeMcpAddCommand(command, { platform }),
121
155
  '',
122
156
  'Or copy this JSON into project .mcp.json:',
123
157
  '',
124
- JSON.stringify(stdioMcpServersConfig(serverPath, { nodePath }), null, 2)
158
+ JSON.stringify(stdioMcpServersConfig(command), null, 2)
125
159
  );
126
160
  } else {
127
161
  lines.push(
128
162
  `Copy this JSON into ${resolvedClient.target}:`,
129
163
  '',
130
- JSON.stringify(stdioMcpServersConfig(serverPath, { nodePath }), null, 2)
164
+ JSON.stringify(stdioMcpServersConfig(command), null, 2)
131
165
  );
132
166
  }
133
167
 
168
+ const installKey = INSTALL_CLIENT_KEYS[resolvedClient.key];
169
+ const note = command.stable ? (command.kind === 'launcher' ? MCP_LAUNCHER_NOTE : '') : MCP_NODE_PATH_NOTE;
134
170
  lines.push(
135
171
  '',
136
- MCP_NODE_PATH_NOTE,
172
+ ...(note ? [note] : []),
173
+ ...(installKey ? [`To let logwork write it for you instead, run: logwork mcp install --client ${installKey}`] : []),
137
174
  'After saving, restart or reload the MCP client.',
138
175
  'Do not paste passwords, 2FA codes, Bearer tokens, cookies, or raw auth logs into AI chat.'
139
176
  );
@@ -148,39 +185,48 @@ function normalizeMcpClientValue(value) {
148
185
  .replace(/[\s_]+/g, '-');
149
186
  }
150
187
 
151
- export function stdioMcpServersConfig(serverPath, { nodePath = process.execPath } = {}) {
188
+ export function stdioMcpServersConfig(launchOrServerPath, options = {}) {
189
+ const launch = toLaunchCommand(launchOrServerPath, options);
152
190
  return {
153
191
  mcpServers: {
154
192
  [MCP_SERVER_NAME]: {
155
- command: nodePath,
156
- args: [serverPath, 'mcp']
193
+ command: launch.command,
194
+ args: mcpArgs(launch)
157
195
  }
158
196
  }
159
197
  };
160
198
  }
161
199
 
162
- export function vscodeMcpConfig(serverPath, { nodePath = process.execPath } = {}) {
200
+ export function vscodeMcpConfig(launchOrServerPath, options = {}) {
201
+ const launch = toLaunchCommand(launchOrServerPath, options);
163
202
  return {
164
203
  servers: {
165
204
  [MCP_SERVER_NAME]: {
166
205
  type: 'stdio',
167
- command: nodePath,
168
- args: [serverPath, 'mcp']
206
+ command: launch.command,
207
+ args: mcpArgs(launch)
169
208
  }
170
209
  }
171
210
  };
172
211
  }
173
212
 
174
- export function formatCodexToml(serverPath, { nodePath = process.execPath } = {}) {
213
+ export function formatCodexToml(launchOrServerPath, options = {}) {
214
+ const launch = toLaunchCommand(launchOrServerPath, options);
175
215
  return [
176
216
  `[mcp_servers.${MCP_SERVER_NAME}]`,
177
- `command = "${escapeTomlString(nodePath)}"`,
178
- `args = ["${escapeTomlString(serverPath)}", "mcp"]`,
217
+ `command = "${escapeTomlString(launch.command)}"`,
218
+ `args = [${mcpArgs(launch).map((arg) => `"${escapeTomlString(arg)}"`).join(', ')}]`,
179
219
  'startup_timeout_sec = 20',
180
220
  'tool_timeout_sec = 120'
181
221
  ].join('\n');
182
222
  }
183
223
 
224
+ // User scope, so the server is available in every project.
225
+ export function formatClaudeMcpAddCommand(launchOrServerPath, { platform = process.platform, ...options } = {}) {
226
+ const launch = toLaunchCommand(launchOrServerPath, options);
227
+ return `claude mcp add --transport stdio --scope user ${MCP_SERVER_NAME} -- ${[launch.command, ...mcpArgs(launch)].map((arg) => (arg === 'mcp' ? arg : quoteShellArg(arg, { platform }))).join(' ')}`;
228
+ }
229
+
184
230
  // TOML basic string: escape backslashes (Windows paths), quotes and control
185
231
  // characters.
186
232
  function escapeTomlString(value) {