logwork-cli 1.1.0 → 1.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.
Files changed (70) hide show
  1. package/README.md +35 -9
  2. package/RELEASE.md +1 -0
  3. package/commands/commits.mjs +57 -6
  4. package/commands/history.mjs +0 -4
  5. package/commands/quick.mjs +38 -23
  6. package/commands/reminder.mjs +41 -2
  7. package/commands/setup.mjs +157 -19
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +57 -15
  11. package/docs/operations-guide.md +37 -2
  12. package/docs/recipes.md +27 -0
  13. package/docs/security.md +3 -1
  14. package/examples/mcp/antigravity.mcp_config.json +1 -1
  15. package/examples/mcp/codex.config.toml +1 -1
  16. package/examples/mcp/copilot.mcp.json +1 -1
  17. package/lib/api-auth-forms.mjs +393 -0
  18. package/lib/api-auth-http.mjs +184 -0
  19. package/lib/api-auth-support.mjs +101 -0
  20. package/lib/api-auth-tokens.mjs +129 -0
  21. package/lib/api-auth.mjs +39 -813
  22. package/lib/api.mjs +38 -1422
  23. package/lib/apply-ledger.mjs +1 -1
  24. package/lib/batch-parser.mjs +23 -0
  25. package/lib/batch-workflow.mjs +201 -2
  26. package/lib/commit-workflow.mjs +278 -30
  27. package/lib/config.mjs +7 -0
  28. package/lib/diagnostics.mjs +53 -4
  29. package/lib/jira-api.mjs +25 -3
  30. package/lib/jira-workflow.mjs +36 -6
  31. package/lib/keychain.mjs +19 -12
  32. package/lib/logwork-config.mjs +64 -13
  33. package/lib/manual-apply-state.mjs +15 -0
  34. package/lib/manual-commands.mjs +2 -2
  35. package/lib/manual-ink-app.mjs +108 -1281
  36. package/lib/manual-ink-draft-actions.mjs +227 -0
  37. package/lib/manual-ink-interaction.mjs +306 -0
  38. package/lib/manual-ink-session.mjs +41 -0
  39. package/lib/manual-ink-task-editor.mjs +338 -0
  40. package/lib/manual-ink-wizard.mjs +520 -0
  41. package/lib/manual-mcp.mjs +27 -15
  42. package/lib/manual-repl.mjs +39 -3
  43. package/lib/mcp-server.mjs +63 -21
  44. package/lib/node-runtime.mjs +115 -0
  45. package/lib/npm-command.mjs +29 -0
  46. package/lib/paths.mjs +11 -1
  47. package/lib/project-mapping-workflow.mjs +30 -7
  48. package/lib/query-workflow.mjs +0 -1
  49. package/lib/reconciliation-workflow.mjs +1 -1
  50. package/lib/release-doctor.mjs +4 -2
  51. package/lib/reminder-config.mjs +38 -2
  52. package/lib/reminder-scheduler.mjs +54 -3
  53. package/lib/reminder-service.mjs +28 -3
  54. package/lib/reminder-workflow.mjs +118 -22
  55. package/lib/ro-api-diagnostics.mjs +109 -0
  56. package/lib/ro-api-entries.mjs +359 -0
  57. package/lib/ro-api-fields.mjs +290 -0
  58. package/lib/ro-api-http.mjs +68 -0
  59. package/lib/ro-api-projects.mjs +137 -0
  60. package/lib/ro-api-tasks.mjs +50 -0
  61. package/lib/ro-api-timesheet.mjs +413 -0
  62. package/lib/ro-delete-workflow.mjs +5 -8
  63. package/lib/ro-edit-workflow.mjs +66 -8
  64. package/lib/ro-entry-reader.mjs +49 -6
  65. package/lib/ro-resubmit-workflow.mjs +35 -21
  66. package/lib/terminal-auth-prompts.mjs +7 -4
  67. package/lib/ui.mjs +80 -59
  68. package/lib/update-service.mjs +5 -2
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +6 -4
@@ -0,0 +1,520 @@
1
+ // Logwork wizard flow for the Ink app: target, date and project pickers, live
2
+ // preview, apply and cancel. Called from ManualApp with its state and setters.
3
+ import { useCallback } from 'react';
4
+ import {
5
+ buildCurrentWeekDateOptions,
6
+ buildLogworkText,
7
+ buildProjectOptions,
8
+ buildProjectOverrides,
9
+ buildWeekDateOptions,
10
+ canApplyPreview,
11
+ formatDraftPreview
12
+ } from './manual-logwork-wizard.mjs';
13
+ import {
14
+ BOTH_TARGET,
15
+ JIRA_TARGET,
16
+ previewTargets,
17
+ RO_TARGET,
18
+ targetLabel
19
+ } from './manual-targets.mjs';
20
+ import { loadManualDrafts } from './manual-drafts.mjs';
21
+ import { enrichRoPreviewWithJiraProjects } from './project-mapping-workflow.mjs';
22
+ import {
23
+ clearSessionPreviews,
24
+ hasSessionPreview,
25
+ sessionPreview,
26
+ setSessionPreview
27
+ } from './manual-ink-session.mjs';
28
+
29
+ export function useLogworkWizard(ctx) {
30
+ const {
31
+ cwd,
32
+ sessionRef,
33
+ wizard,
34
+ setWizard,
35
+ setActivePanel,
36
+ setLoading,
37
+ setInputValue,
38
+ setSelectedIndex,
39
+ inkWorkflows,
40
+ prompts,
41
+ runWithInlineAuth,
42
+ runWithJiraAuthInstruction,
43
+ executeCommand
44
+ } = ctx;
45
+
46
+ const startNewLogworkWizard = useCallback(async (target = RO_TARGET) => {
47
+ setInputValue('');
48
+ setSelectedIndex(0);
49
+ setLoading(target === JIRA_TARGET ? 'Building current week...' : 'Fetching current week...');
50
+ setActivePanel({
51
+ kind: 'logwork',
52
+ title: 'Logwork',
53
+ text: target === JIRA_TARGET
54
+ ? 'Preparing Jira worklog dates...'
55
+ : 'Loading this week booking/logwork data...'
56
+ });
57
+ try {
58
+ const weekly = target === JIRA_TARGET
59
+ ? null
60
+ : await runWithInlineAuth(() => inkWorkflows.queryLogwork({
61
+ period: 'this_week',
62
+ cwd,
63
+ includeEntries: false
64
+ }));
65
+ const dateOptions = weekly ? buildWeekDateOptions(weekly) : buildCurrentWeekDateOptions();
66
+ if (!dateOptions.length) {
67
+ throw new Error('Unable to build week date options.');
68
+ }
69
+ const todayIndex = dateOptions.findIndex((option) => option.isToday);
70
+ setWizard({
71
+ step: 'select_date',
72
+ target,
73
+ weekly,
74
+ dateOptions,
75
+ dateIndex: todayIndex >= 0 ? todayIndex : 0,
76
+ projectsResult: null,
77
+ projectOptions: [],
78
+ projectIndex: 0,
79
+ selectedDate: null,
80
+ selectedProject: null,
81
+ tasks: [],
82
+ preview: null,
83
+ activeDraftId: null
84
+ });
85
+ setActivePanel({
86
+ kind: 'logwork',
87
+ title: 'Select Date',
88
+ text: `Choose a day for this ${targetLabel(target)} logwork session.`
89
+ });
90
+ } catch (error) {
91
+ setActivePanel({
92
+ kind: 'error',
93
+ title: 'Logwork Error',
94
+ text: error.message
95
+ });
96
+ } finally {
97
+ setLoading('');
98
+ }
99
+ }, [cwd, inkWorkflows, runWithInlineAuth, runWithJiraAuthInstruction]);
100
+
101
+ const startLogworkTargetPicker = useCallback(() => {
102
+ setInputValue('');
103
+ setSelectedIndex(0);
104
+ setWizard({
105
+ step: 'select_logwork_target',
106
+ targetIndex: 0
107
+ });
108
+ setActivePanel({
109
+ kind: 'logwork',
110
+ title: 'Select Logwork Target',
111
+ text: 'Choose where this logwork session should write.'
112
+ });
113
+ }, []);
114
+
115
+ const startAuthTargetPicker = useCallback(() => {
116
+ setInputValue('');
117
+ setSelectedIndex(0);
118
+ setWizard({
119
+ step: 'select_auth_target',
120
+ targetIndex: 0
121
+ });
122
+ setActivePanel({
123
+ kind: 'auth',
124
+ title: 'Select Auth Target',
125
+ text: 'Choose which service to authenticate.'
126
+ });
127
+ }, []);
128
+
129
+ const startReconciliationPeriodPicker = useCallback(() => {
130
+ setInputValue('');
131
+ setSelectedIndex(0);
132
+ setWizard({
133
+ step: 'select_reconciliation_period',
134
+ periodIndex: 2
135
+ });
136
+ setActivePanel({
137
+ kind: 'output',
138
+ title: 'RO / Jira Reconciliation',
139
+ text: 'Choose a preset period to compare.'
140
+ });
141
+ }, []);
142
+
143
+ const startLogworkWizard = useCallback(async (target) => {
144
+ if (!target) {
145
+ startLogworkTargetPicker();
146
+ return;
147
+ }
148
+
149
+ if (target !== RO_TARGET) {
150
+ await startNewLogworkWizard(target);
151
+ return;
152
+ }
153
+
154
+ setInputValue('');
155
+ setSelectedIndex(0);
156
+ setLoading('Loading saved drafts...');
157
+ try {
158
+ const drafts = await loadManualDrafts({ cwd });
159
+ if (drafts.length) {
160
+ setWizard({
161
+ step: 'select_draft',
162
+ target: RO_TARGET,
163
+ drafts,
164
+ draftIndex: 0,
165
+ draftSource: 'start',
166
+ weekly: null,
167
+ dateOptions: [],
168
+ dateIndex: 0,
169
+ projectsResult: null,
170
+ projectOptions: [],
171
+ projectIndex: 0,
172
+ selectedDate: null,
173
+ selectedProject: null,
174
+ tasks: [],
175
+ preview: null,
176
+ activeDraftId: null
177
+ });
178
+ setActivePanel({
179
+ kind: 'logwork',
180
+ title: 'Saved Drafts',
181
+ text: 'Resume a saved draft, delete one, or start a new logwork session.'
182
+ });
183
+ return;
184
+ }
185
+ await startNewLogworkWizard(RO_TARGET);
186
+ } catch (error) {
187
+ setActivePanel({
188
+ kind: 'error',
189
+ title: 'Draft Error',
190
+ text: error.message
191
+ });
192
+ } finally {
193
+ setLoading('');
194
+ }
195
+ }, [cwd, startLogworkTargetPicker, startNewLogworkWizard]);
196
+
197
+ const startMcpClientPicker = useCallback(() => {
198
+ setInputValue('');
199
+ setSelectedIndex(0);
200
+ setWizard({
201
+ step: 'select_mcp_client',
202
+ mcpClientIndex: 0
203
+ });
204
+ setActivePanel({
205
+ kind: 'output',
206
+ title: 'MCP Setup',
207
+ text: 'Choose an MCP client to print copy-ready setup.'
208
+ });
209
+ }, []);
210
+
211
+ const cancelMcpClientPicker = useCallback(() => {
212
+ setWizard(null);
213
+ setActivePanel({
214
+ kind: 'idle',
215
+ title: 'Ready',
216
+ text: 'Type / for commands.'
217
+ });
218
+ }, []);
219
+
220
+ const selectWizardDate = useCallback(async (dateOption) => {
221
+ const target = wizard?.target || RO_TARGET;
222
+ if (target === JIRA_TARGET) {
223
+ const draft = {
224
+ target,
225
+ date: dateOption.date,
226
+ project: null,
227
+ tasks: [],
228
+ preview: null
229
+ };
230
+ sessionRef.current.logworkDraft = draft;
231
+ clearSessionPreviews(sessionRef.current);
232
+ sessionRef.current.activeTarget = target;
233
+ setWizard((current) => ({
234
+ ...current,
235
+ step: 'edit_tasks',
236
+ selectedDate: dateOption.date,
237
+ selectedProject: null,
238
+ tasks: [],
239
+ preview: null,
240
+ previews: {}
241
+ }));
242
+ setActivePanel({
243
+ kind: 'logwork',
244
+ title: 'Jira Worklog Draft',
245
+ text: [
246
+ formatDraftPreview(draft),
247
+ '',
248
+ 'Enter tasks one per line: +2 Feature Development | check ui/ux (SCB-213)',
249
+ 'Empty Enter applies after preview is ready.',
250
+ 'Type / for task commands. Esc cancels.'
251
+ ].join('\n')
252
+ });
253
+ return;
254
+ }
255
+
256
+ setLoading('Fetching project memberships...');
257
+ setActivePanel({
258
+ kind: 'logwork',
259
+ title: 'Select Project',
260
+ text: `Loading projects for ${dateOption.date}...`
261
+ });
262
+ try {
263
+ const projectsResult = await runWithInlineAuth(() => inkWorkflows.listLogworkProjects({ cwd }));
264
+ const projectOptions = buildProjectOptions({
265
+ projectsResult,
266
+ weekly: wizard.weekly,
267
+ date: dateOption.date
268
+ });
269
+ if (!projectOptions.length) {
270
+ throw new Error('No Resource Optimiser project memberships found.');
271
+ }
272
+ setWizard((current) => ({
273
+ ...current,
274
+ step: 'select_project',
275
+ target,
276
+ selectedDate: dateOption.date,
277
+ projectsResult,
278
+ projectOptions,
279
+ projectIndex: 0,
280
+ selectedProject: null,
281
+ tasks: [],
282
+ preview: null
283
+ }));
284
+ setActivePanel({
285
+ kind: 'logwork',
286
+ title: 'Select Project',
287
+ text: `Choose a project for ${dateOption.date}.`
288
+ });
289
+ } catch (error) {
290
+ setActivePanel({
291
+ kind: 'error',
292
+ title: 'Project Error',
293
+ text: error.message
294
+ });
295
+ } finally {
296
+ setLoading('');
297
+ }
298
+ }, [cwd, inkWorkflows, runWithInlineAuth, wizard]);
299
+
300
+ const selectWizardProject = useCallback((project) => {
301
+ const target = wizard?.target || RO_TARGET;
302
+ const draft = {
303
+ target,
304
+ date: wizard.selectedDate,
305
+ project,
306
+ tasks: [],
307
+ preview: null
308
+ };
309
+ sessionRef.current.logworkDraft = draft;
310
+ clearSessionPreviews(sessionRef.current);
311
+ sessionRef.current.activeTarget = target;
312
+ setWizard((current) => ({
313
+ ...current,
314
+ step: 'edit_tasks',
315
+ target,
316
+ selectedProject: project,
317
+ tasks: [],
318
+ preview: null,
319
+ previews: {}
320
+ }));
321
+ setActivePanel({
322
+ kind: 'logwork',
323
+ title: target === BOTH_TARGET ? 'RO + Jira Draft' : 'Logwork Draft',
324
+ text: [
325
+ formatDraftPreview(draft),
326
+ '',
327
+ target === BOTH_TARGET ? 'Enter tasks one per line: +2 Feature Development | check ui/ux (SCB-213)' : 'Enter tasks one per line: +2 Feature Development | check ui/ux',
328
+ 'Empty Enter applies after preview is ready.',
329
+ 'Type / for task commands. Esc cancels.'
330
+ ].join('\n')
331
+ });
332
+ }, [wizard]);
333
+
334
+ const previewDraft = useCallback(async (nextWizard) => {
335
+ const target = nextWizard.target || RO_TARGET;
336
+ const draft = {
337
+ target,
338
+ date: nextWizard.selectedDate,
339
+ project: nextWizard.selectedProject,
340
+ tasks: nextWizard.tasks
341
+ };
342
+
343
+ if (!draft.tasks.length) {
344
+ sessionRef.current.logworkDraft = draft;
345
+ clearSessionPreviews(sessionRef.current);
346
+ sessionRef.current.activeTarget = target;
347
+ setWizard(nextWizard);
348
+ setActivePanel({
349
+ kind: 'logwork',
350
+ title: target === JIRA_TARGET ? 'Jira Worklog Draft' : target === BOTH_TARGET ? 'RO + Jira Draft' : 'Logwork Draft',
351
+ text: [
352
+ formatDraftPreview(draft),
353
+ '',
354
+ target === RO_TARGET ? 'Enter tasks one per line: +2 Feature Development | check ui/ux' : 'Enter tasks one per line: +2 Feature Development | check ui/ux (SCB-213)',
355
+ 'Empty Enter applies after preview is ready.'
356
+ ].join('\n')
357
+ });
358
+ return;
359
+ }
360
+
361
+ setLoading('Building live preview...');
362
+ try {
363
+ const text = buildLogworkText({
364
+ date: draft.date,
365
+ tasks: draft.tasks
366
+ });
367
+ const previews = {};
368
+ if (previewTargets(target).includes(RO_TARGET)) {
369
+ const projectOverrides = buildProjectOverrides({
370
+ date: draft.date,
371
+ tasks: draft.tasks,
372
+ projectMemberId: draft.project.projectMemberId
373
+ });
374
+ previews[RO_TARGET] = await runWithInlineAuth(() => inkWorkflows.previewLogworkBatch({
375
+ text,
376
+ projectOverrides,
377
+ cwd
378
+ }));
379
+ }
380
+ if (previewTargets(target).includes(JIRA_TARGET)) {
381
+ previews[JIRA_TARGET] = await runWithJiraAuthInstruction(() => inkWorkflows.previewJiraWorklogBatch({
382
+ text
383
+ }));
384
+ }
385
+ if (target === BOTH_TARGET && previews[RO_TARGET] && previews[JIRA_TARGET]) {
386
+ previews[RO_TARGET] = enrichRoPreviewWithJiraProjects(previews[RO_TARGET], previews[JIRA_TARGET]);
387
+ }
388
+ const preview = target === JIRA_TARGET ? previews[JIRA_TARGET] : previews[RO_TARGET];
389
+ const withPreview = {
390
+ ...draft,
391
+ preview,
392
+ previews
393
+ };
394
+ sessionRef.current.logworkDraft = withPreview;
395
+ sessionRef.current.activeTarget = target;
396
+ for (const previewTarget of previewTargets(target)) {
397
+ setSessionPreview(sessionRef.current, previewTarget, previews[previewTarget] || null);
398
+ }
399
+ setWizard({
400
+ ...nextWizard,
401
+ target,
402
+ preview,
403
+ previews
404
+ });
405
+ const hasBlockedJira = previews[JIRA_TARGET]?.entries?.some((entry) => entry.status !== 'ready');
406
+ setActivePanel({
407
+ kind: preview?.unresolvedEntries?.length || preview?.errors?.length || hasBlockedJira ? 'error' : 'logwork',
408
+ title: target === JIRA_TARGET ? 'Jira Worklog Preview' : target === BOTH_TARGET ? 'RO + Jira Preview' : 'Logwork Preview',
409
+ text: [
410
+ formatDraftPreview(withPreview),
411
+ '',
412
+ 'Empty Enter applies this preview.',
413
+ 'Type another +hours task to add more.',
414
+ '/remove selects tasks to delete.'
415
+ ].join('\n')
416
+ });
417
+ return preview;
418
+ } catch (error) {
419
+ setActivePanel({
420
+ kind: 'error',
421
+ title: 'Preview Error',
422
+ text: error.message
423
+ });
424
+ } finally {
425
+ setLoading('');
426
+ }
427
+ }, [cwd, inkWorkflows, runWithInlineAuth, runWithJiraAuthInstruction]);
428
+
429
+ const cancelCurrentLogwork = useCallback(async () => {
430
+ if (wizard?.tasks?.length) {
431
+ const ok = await prompts.confirm('Discard this unsaved logwork session?', false);
432
+ if (!ok) {
433
+ return;
434
+ }
435
+ }
436
+ sessionRef.current.logworkDraft = null;
437
+ clearSessionPreviews(sessionRef.current);
438
+ setWizard(null);
439
+ setActivePanel({
440
+ kind: 'idle',
441
+ title: 'Ready',
442
+ text: 'Logwork session discarded. Type / for commands.'
443
+ });
444
+ }, [prompts, wizard]);
445
+
446
+ const applyWizardPreview = useCallback(async () => {
447
+ const target = wizard.target || RO_TARGET;
448
+ if (!wizard.tasks.length) {
449
+ setActivePanel({
450
+ kind: 'logwork',
451
+ title: 'Logwork Draft',
452
+ text: target === RO_TARGET ? 'Enter a task like +2 Feature Development | check ui/ux.' : 'Enter a task like +2 Feature Development | check ui/ux (SCB-213).'
453
+ });
454
+ return;
455
+ }
456
+
457
+ const targets = previewTargets(target);
458
+ const missingPreview = targets.some((previewTarget) => !sessionPreview(sessionRef.current, previewTarget));
459
+ if (missingPreview) {
460
+ await previewDraft(wizard);
461
+ return;
462
+ }
463
+
464
+ for (const previewTarget of targets) {
465
+ const applyState = canApplyPreview(sessionPreview(sessionRef.current, previewTarget), { target: previewTarget });
466
+ if (!applyState.ok) {
467
+ setActivePanel({
468
+ kind: 'error',
469
+ title: 'Apply Blocked',
470
+ text: `${targetLabel(previewTarget)}: ${applyState.reason}`
471
+ });
472
+ return;
473
+ }
474
+ }
475
+
476
+ await executeCommand({ type: 'apply', target });
477
+ if (!hasSessionPreview(sessionRef.current)) {
478
+ setWizard(null);
479
+ }
480
+ }, [executeCommand, previewDraft, wizard]);
481
+
482
+ const restoreTaskEditorPanel = useCallback((nextWizard = wizard) => {
483
+ const target = nextWizard.target || RO_TARGET;
484
+ const draft = {
485
+ target,
486
+ date: nextWizard.selectedDate,
487
+ project: nextWizard.selectedProject,
488
+ tasks: nextWizard.tasks,
489
+ preview: nextWizard.preview,
490
+ previews: nextWizard.previews
491
+ };
492
+ setActivePanel({
493
+ kind: 'logwork',
494
+ title: nextWizard.preview ? `${targetLabel(target)} Preview` : `${targetLabel(target)} Draft`,
495
+ text: [
496
+ formatDraftPreview(draft),
497
+ '',
498
+ nextWizard.preview ? 'Empty Enter applies this preview.' : target === RO_TARGET ? 'Enter tasks one per line: +2 Feature Development | check ui/ux' : 'Enter tasks one per line: +2 Feature Development | check ui/ux (SCB-213)',
499
+ nextWizard.preview ? 'Type another +hours task to add more.' : 'Empty Enter applies after preview is ready.',
500
+ 'Type / for task commands. Esc cancels.'
501
+ ].join('\n')
502
+ });
503
+ }, [wizard]);
504
+
505
+ return {
506
+ startNewLogworkWizard,
507
+ startLogworkTargetPicker,
508
+ startAuthTargetPicker,
509
+ startReconciliationPeriodPicker,
510
+ startLogworkWizard,
511
+ startMcpClientPicker,
512
+ cancelMcpClientPicker,
513
+ selectWizardDate,
514
+ selectWizardProject,
515
+ previewDraft,
516
+ cancelCurrentLogwork,
517
+ applyWizardPreview,
518
+ restoreTaskEditorPanel
519
+ };
520
+ }
@@ -39,11 +39,14 @@ export const MCP_CLIENTS = [
39
39
  }
40
40
  ];
41
41
 
42
- // IDE configs run `node <runtime>/bin/logwork.mjs mcp`. An absolute node entry
43
- // path is used instead of the `logwork` command because GUI IDEs often start
44
- // without the npm global bin directory on PATH.
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
44
+ // Finder) often start without the npm global bin directory or nvm's node on
45
+ // PATH, so neither `logwork` nor a bare `node` would be found.
45
46
  export const MCP_SERVER_NAME = 'logwork';
46
47
 
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.';
49
+
47
50
  export function defaultMcpServerPath() {
48
51
  return join(helperHome(), 'bin', 'logwork.mjs');
49
52
  }
@@ -83,6 +86,7 @@ export function formatMcpClientOptions(clients = MCP_CLIENTS) {
83
86
  export function formatMcpSetup({
84
87
  client,
85
88
  serverPath = defaultMcpServerPath(),
89
+ nodePath = process.execPath,
86
90
  platform = process.platform
87
91
  } = {}) {
88
92
  const resolvedClient = typeof client === 'object' ? client : resolveMcpClient(client);
@@ -92,6 +96,7 @@ export function formatMcpSetup({
92
96
 
93
97
  const lines = [
94
98
  `MCP setup: ${resolvedClient.label}`,
99
+ `Node: ${nodePath}`,
95
100
  `Entry path: ${serverPath} (run with the "mcp" argument)`,
96
101
  ''
97
102
  ];
@@ -100,34 +105,35 @@ export function formatMcpSetup({
100
105
  lines.push(
101
106
  `Copy this TOML into ${resolvedClient.target}:`,
102
107
  '',
103
- formatCodexToml(serverPath)
108
+ formatCodexToml(serverPath, { nodePath })
104
109
  );
105
110
  } else if (resolvedClient.key === 'copilot') {
106
111
  lines.push(
107
112
  `Copy this JSON into ${resolvedClient.target}:`,
108
113
  '',
109
- JSON.stringify(vscodeMcpConfig(serverPath), null, 2)
114
+ JSON.stringify(vscodeMcpConfig(serverPath, { nodePath }), null, 2)
110
115
  );
111
116
  } else if (resolvedClient.key === 'claude-code') {
112
117
  lines.push(
113
118
  'Recommended command:',
114
119
  '',
115
- `claude mcp add --transport stdio ${MCP_SERVER_NAME} -- node ${quoteShellArg(serverPath, { platform })} mcp`,
120
+ `claude mcp add --transport stdio ${MCP_SERVER_NAME} -- ${quoteShellArg(nodePath, { platform })} ${quoteShellArg(serverPath, { platform })} mcp`,
116
121
  '',
117
122
  'Or copy this JSON into project .mcp.json:',
118
123
  '',
119
- JSON.stringify(stdioMcpServersConfig(serverPath), null, 2)
124
+ JSON.stringify(stdioMcpServersConfig(serverPath, { nodePath }), null, 2)
120
125
  );
121
126
  } else {
122
127
  lines.push(
123
128
  `Copy this JSON into ${resolvedClient.target}:`,
124
129
  '',
125
- JSON.stringify(stdioMcpServersConfig(serverPath), null, 2)
130
+ JSON.stringify(stdioMcpServersConfig(serverPath, { nodePath }), null, 2)
126
131
  );
127
132
  }
128
133
 
129
134
  lines.push(
130
135
  '',
136
+ MCP_NODE_PATH_NOTE,
131
137
  'After saving, restart or reload the MCP client.',
132
138
  'Do not paste passwords, 2FA codes, Bearer tokens, cookies, or raw auth logs into AI chat.'
133
139
  );
@@ -142,41 +148,47 @@ function normalizeMcpClientValue(value) {
142
148
  .replace(/[\s_]+/g, '-');
143
149
  }
144
150
 
145
- export function stdioMcpServersConfig(serverPath) {
151
+ export function stdioMcpServersConfig(serverPath, { nodePath = process.execPath } = {}) {
146
152
  return {
147
153
  mcpServers: {
148
154
  [MCP_SERVER_NAME]: {
149
- command: 'node',
155
+ command: nodePath,
150
156
  args: [serverPath, 'mcp']
151
157
  }
152
158
  }
153
159
  };
154
160
  }
155
161
 
156
- export function vscodeMcpConfig(serverPath) {
162
+ export function vscodeMcpConfig(serverPath, { nodePath = process.execPath } = {}) {
157
163
  return {
158
164
  servers: {
159
165
  [MCP_SERVER_NAME]: {
160
166
  type: 'stdio',
161
- command: 'node',
167
+ command: nodePath,
162
168
  args: [serverPath, 'mcp']
163
169
  }
164
170
  }
165
171
  };
166
172
  }
167
173
 
168
- export function formatCodexToml(serverPath) {
174
+ export function formatCodexToml(serverPath, { nodePath = process.execPath } = {}) {
169
175
  return [
170
176
  `[mcp_servers.${MCP_SERVER_NAME}]`,
171
- 'command = "node"',
177
+ `command = "${escapeTomlString(nodePath)}"`,
172
178
  `args = ["${escapeTomlString(serverPath)}", "mcp"]`,
173
179
  'startup_timeout_sec = 20',
174
180
  'tool_timeout_sec = 120'
175
181
  ].join('\n');
176
182
  }
177
183
 
184
+ // TOML basic string: escape backslashes (Windows paths), quotes and control
185
+ // characters.
178
186
  function escapeTomlString(value) {
179
- return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
187
+ return String(value)
188
+ .replace(/\\/g, '\\\\')
189
+ .replace(/"/g, '\\"')
190
+ // eslint-disable-next-line no-control-regex -- TOML needs control characters escaped.
191
+ .replace(/[\u0000-\u001f\u007f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
180
192
  }
181
193
 
182
194
  function quoteShellArg(value, { platform = process.platform } = {}) {