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.
- package/LICENSE +18 -8
- package/README.md +41 -50
- package/RELEASE.md +3 -2
- package/bin/logwork.mjs +2 -2
- package/commands/commits.mjs +78 -14
- package/commands/mcp.mjs +266 -0
- package/commands/setup.mjs +161 -40
- package/docs/advanced.md +7 -6
- package/docs/mcp-setup.md +80 -28
- package/docs/operations-guide.md +18 -3
- package/docs/recipes.md +18 -5
- package/docs/security.md +20 -1
- package/examples/mcp/antigravity.mcp_config.json +2 -2
- package/examples/mcp/codex.config.toml +2 -2
- package/examples/mcp/copilot.mcp.json +2 -2
- package/lib/commit-workflow.mjs +160 -5
- package/lib/diagnostics.mjs +72 -9
- package/lib/launcher.mjs +303 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +34 -1
- package/lib/manual-ink-interaction.mjs +12 -0
- package/lib/manual-ink-ui.mjs +38 -0
- package/lib/manual-mcp.mjs +68 -22
- package/lib/manual-repl.mjs +147 -34
- package/lib/mcp-install.mjs +930 -0
- package/lib/mcp-server.mjs +2 -2
- package/lib/node-runtime.mjs +52 -12
- package/lib/project-mapping-workflow.mjs +2 -20
- package/lib/query-workflow.mjs +19 -0
- package/lib/reminder-scheduler.mjs +34 -18
- package/lib/reminder-service.mjs +18 -4
- package/lib/repo-project-suggestions.mjs +285 -0
- package/lib/ui.mjs +44 -0
- package/package.json +2 -2
- package/skills/logwork/SKILL.md +6 -1
package/lib/launcher.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/manual-commands.mjs
CHANGED
|
@@ -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
|
|
78
|
-
usage: '/map SCB
|
|
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
|
];
|
package/lib/manual-ink-app.mjs
CHANGED
|
@@ -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',
|
package/lib/manual-ink-ui.mjs
CHANGED
|
@@ -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,
|
package/lib/manual-mcp.mjs
CHANGED
|
@@ -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
|
|
43
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
`
|
|
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(
|
|
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(
|
|
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
|
-
|
|
154
|
+
formatClaudeMcpAddCommand(command, { platform }),
|
|
121
155
|
'',
|
|
122
156
|
'Or copy this JSON into project .mcp.json:',
|
|
123
157
|
'',
|
|
124
|
-
JSON.stringify(stdioMcpServersConfig(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
156
|
-
args:
|
|
193
|
+
command: launch.command,
|
|
194
|
+
args: mcpArgs(launch)
|
|
157
195
|
}
|
|
158
196
|
}
|
|
159
197
|
};
|
|
160
198
|
}
|
|
161
199
|
|
|
162
|
-
export function vscodeMcpConfig(
|
|
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:
|
|
168
|
-
args:
|
|
206
|
+
command: launch.command,
|
|
207
|
+
args: mcpArgs(launch)
|
|
169
208
|
}
|
|
170
209
|
}
|
|
171
210
|
};
|
|
172
211
|
}
|
|
173
212
|
|
|
174
|
-
export function formatCodexToml(
|
|
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(
|
|
178
|
-
`args = ["${escapeTomlString(
|
|
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) {
|