logwork-cli 1.2.0 → 1.3.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.
package/docs/recipes.md CHANGED
@@ -41,16 +41,29 @@ The assistant uses `list_projects` and `list_tasks`, writes `+2 <task> | <descri
41
41
 
42
42
  ## Log From Commits Into The Right Projects
43
43
 
44
- Tell logwork which RO project each repo belongs to, once:
44
+ Each repo needs its RO project once. Just run the draft:
45
+
46
+ ```bash
47
+ logwork commits yesterday
48
+ ```
49
+
50
+ For a repo with no project yet, it asks in place:
45
51
 
46
52
  ```text
47
- logwork
48
- /map --repo ~/code/portal 5234
53
+ Which RO project does portal-web belong to?
54
+ › Customer Portal · ticket CP-12 was logged to this project on 2026-11-03
55
+ GKE Fair Chem System (FCS)
56
+ Managed Hosting
57
+ Skip for now
49
58
  ```
50
59
 
51
- or ask your assistant: `Map the repo /Users/me/code/portal to my Portal project` (it calls `list_projects`, then `map_project` with `repos` after you approve).
60
+ The likely project is preselected. Press Enter to save it as a repo mapping; the draft is then redone with that repo's lines on the project. `Skip for now` asks again next time; `--no-prompt` never asks.
61
+
62
+ Other ways to map a repo, all without ids:
63
+ - Terminal UI: `/map --repo` (this folder) or `/map --repo ~/code/portal` opens the same picker; `/map --repo ~/code/portal Customer Portal` maps by name (a unique part of the name is enough).
64
+ - Assistant: `draft_from_commits` returns `repoSuggestions`, so the assistant asks "Is portal-web part of Customer Portal?" and calls `map_project` with `projectName` and `repos` after you say yes.
52
65
 
53
- Then `logwork commits yesterday` (or the assistant's `draft_from_commits`) puts that repo's commits on project 5234. The hours come from what is still free on that project: booked minus logged. If the project is already full, its lines get 0h and the summary says so. Repos without a mapping fall back to ticket/keyword mappings and that day's bookings, and are listed at the end so you can map them.
66
+ Mapped repos put their commits on that project. The hours come from what is still free on that project: booked minus logged. If the project is already full, its lines get 0h and the summary says so. Repos without a mapping fall back to ticket/keyword mappings and that day's bookings.
54
67
 
55
68
  The assistant passes the draft's `projectOverrides` to `log_work` with the same text, so each line is logged to the project the draft chose.
56
69
 
package/docs/security.md CHANGED
@@ -113,11 +113,30 @@ The runtime and local state live in `~/.logwork-cli` (`%USERPROFILE%\.logwork-cl
113
113
  | `update-state.json` | Update check cache: package name, latest SemVer, and check timestamp. Expires after 24 hours and contains no npm credentials. |
114
114
  | `reminder.json`, `reminder-state.json` | Reminder config: enabled state, local time, target, weekdays and optional git repo paths for the commit summary. Last-run state: sanitized timestamps and status summaries. |
115
115
  | `runtime.json` | The Node path and version `logwork setup` ran with, its install time and the package version. |
116
+ | `bin/logwork-launch` (macOS) | The launcher MCP configs and the reminder run. A `sh` script with the runtime path and the recorded Node path, nothing else. |
116
117
  | `diagnostics/` | Sanitized diagnostics reports from `logwork doctor`. |
117
118
 
118
119
  The apply ledger stores the target, flow target, batch ID, timestamp, workspace path, entry metadata, and submitted/failed/blocked status. It does not store credentials, cookies, or raw API responses, and token-like error text is redacted before writing.
119
120
 
120
- The macOS launch agent and Windows scheduled task contain only fixed Node and script paths (`bin/logwork.mjs reminder run`). They never contain RO tokens, Jira PATs, passwords, or API responses.
121
+ The macOS launch agent runs `bin/logwork-launch reminder run`, and the Windows scheduled task runs a fixed `node.exe` with `bin/logwork.mjs reminder run`. They never contain RO tokens, Jira PATs, passwords, or API responses.
122
+
123
+ ## MCP Client Configs
124
+
125
+ `logwork setup` and `logwork mcp install` edit the MCP config of the clients they find, after asking (or with `--yes`):
126
+
127
+ | Client | File or command |
128
+ | --- | --- |
129
+ | Claude Code | `claude mcp remove` / `claude mcp add --scope user` (or `~/.claude.json` without the CLI) |
130
+ | Cursor | `~/.cursor/mcp.json` |
131
+ | Codex | `~/.codex/config.toml` |
132
+ | VS Code | user `mcp.json` (`~/Library/Application Support/Code/User`, `%APPDATA%\Code\User`) |
133
+ | Google Antigravity | `~/.gemini/antigravity/mcp_config.json` |
134
+
135
+ - Only the `logwork` server entry is written, plus removal of an old `logwork-helper` entry (and, in Claude Code, a stale project-scope `logwork` entry). Other servers and settings are kept.
136
+ - The entry holds only `command` and `args` (the launcher or `node.exe`, the runtime path and `mcp`). No token, PAT, password, cookie or email is ever written to an MCP config.
137
+ - Before changing a file, it is copied to `<file>.logwork-backup-<YYYYMMDDHHmmss>` in the same folder, with the same permissions as the original. The new file is written atomically. Backups are never deleted automatically; remove them when you no longer need them. A backup can contain other servers' settings (for example API keys in their `env`), exactly as the original file does.
138
+ - A file that does not parse cleanly (comments, trailing commas, unusual TOML) is never written; the snippet is printed instead.
139
+ - `logwork mcp install --dry-run` and `logwork mcp status` only read.
121
140
 
122
141
  ## Safety Model
123
142
 
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "mcpServers": {
3
3
  "logwork": {
4
- "command": "<absolute path to node>",
5
- "args": ["<home>/.logwork-cli/bin/logwork.mjs", "mcp"]
4
+ "command": "<home>/.logwork-cli/bin/logwork-launch",
5
+ "args": ["mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,5 +1,5 @@
1
1
  [mcp_servers.logwork]
2
- command = "<absolute path to node>"
3
- args = ["<home>/.logwork-cli/bin/logwork.mjs", "mcp"]
2
+ command = "<home>/.logwork-cli/bin/logwork-launch"
3
+ args = ["mcp"]
4
4
  startup_timeout_sec = 20
5
5
  tool_timeout_sec = 120
@@ -2,8 +2,8 @@
2
2
  "servers": {
3
3
  "logwork": {
4
4
  "type": "stdio",
5
- "command": "<absolute path to node>",
6
- "args": ["<home>/.logwork-cli/bin/logwork.mjs", "mcp"]
5
+ "command": "<home>/.logwork-cli/bin/logwork-launch",
6
+ "args": ["mcp"]
7
7
  }
8
8
  }
9
9
  }
@@ -6,7 +6,16 @@ import { CONFIG } from './config.mjs';
6
6
  import { findRepoMapping, loadLocalConfig } from './logwork-config.mjs';
7
7
  import { projectIdentityKey, sameProjectIdentity } from './project-identity.mjs';
8
8
  import { resolveEntryProject } from './project-resolver.mjs';
9
- import { normalizeQueryRange, queryLogwork } from './query-workflow.mjs';
9
+ import {
10
+ createMembershipProjectsFetcher,
11
+ normalizeQueryRange,
12
+ queryLogwork
13
+ } from './query-workflow.mjs';
14
+ import {
15
+ bookedProjectsByDateFromQuery,
16
+ historyFromQueryResult,
17
+ suggestRepoProjects
18
+ } from './repo-project-suggestions.mjs';
10
19
  import { addLocalDays, todayLocalDateISO } from './util.mjs';
11
20
 
12
21
  // Builds a logwork draft from the user's own git commits:
@@ -18,7 +27,10 @@ import { addLocalDays, todayLocalDateISO } from './util.mjs';
18
27
  // -> each project's remaining hours (RO booked - logged) are split across its
19
28
  // groups by how much work each group holds; unresolved groups share the
20
29
  // room of booked projects no group claimed (without RO: a standard day)
21
- // -> a "+<hours> <worklog task> | <description>" block for log_work.
30
+ // -> a "+<hours> <worklog task> | <description>" block for log_work
31
+ // -> for each repo whose lines got no project, ranked project suggestions
32
+ // (ticket history, repo/remote name, single booked project) so the user
33
+ // can confirm one and map the repo once.
22
34
  // Nothing is written anywhere; the agent shows the draft, the user edits and
23
35
  // approves it, then log_work previews and applies it as usual.
24
36
 
@@ -31,6 +43,7 @@ const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
31
43
  const FIELD = '\x1f';
32
44
  const RECORD = '\x1e';
33
45
  const MAX_DESCRIPTION_LENGTH = 240;
46
+ const SUGGESTION_HISTORY_DAYS = 30;
34
47
 
35
48
  // Default RO catalog task per kind of work. These are the shared default task
36
49
  // names; a project with its own catalog may need a different name (list_tasks).
@@ -415,7 +428,11 @@ export async function draftLogworkFromCommits({
415
428
  runGit = runGitCommand,
416
429
  queryRo = queryLogwork,
417
430
  loadConfig = loadLocalConfig,
418
- platform = process.platform
431
+ platform = process.platform,
432
+ suggestProjects = true,
433
+ fetchProjects,
434
+ historyDays = SUGGESTION_HISTORY_DAYS,
435
+ today = todayLocalDateISO()
419
436
  } = {}) {
420
437
  const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
421
438
  const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
@@ -538,6 +555,25 @@ export async function draftLogworkFromCommits({
538
555
  .filter((group) => !group.project)
539
556
  .map((group) => group.repoPath))];
540
557
 
558
+ // Suggestions need RO (candidates and history), so they are skipped when the
559
+ // bookings were not read.
560
+ const suggestions = unmappedRepos.length && bookings && suggestProjects
561
+ ? await buildRepoSuggestions({
562
+ unmappedRepos,
563
+ days,
564
+ bookings,
565
+ queryRo,
566
+ // Memberships come from the same source as the bookings: a caller that
567
+ // injects queryRo (tests, other workflows) gets none unless it injects
568
+ // fetchProjects too, so it never reaches the real RO.
569
+ fetchProjects: fetchProjects || (queryRo === queryLogwork ? createMembershipProjectsFetcher() : async () => []),
570
+ runGit,
571
+ cwd,
572
+ today,
573
+ historyDays
574
+ })
575
+ : { repoSuggestions: {}, projectCandidates: [] };
576
+
541
577
  const result = {
542
578
  status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
543
579
  range,
@@ -548,7 +584,9 @@ export async function draftLogworkFromCommits({
548
584
  days,
549
585
  draftText,
550
586
  projectOverrides,
551
- unmappedRepos
587
+ unmappedRepos,
588
+ repoSuggestions: suggestions.repoSuggestions,
589
+ projectCandidates: suggestions.projectCandidates
552
590
  };
553
591
  result.summary = formatCommitDraftSummary(result);
554
592
  return result;
@@ -600,11 +638,128 @@ export function formatCommitDraftSummary(result) {
600
638
  }
601
639
  }
602
640
  if (result.unmappedRepos?.length) {
603
- lines.push('', `No project for some lines from: ${result.unmappedRepos.join(', ')}. Ask the user which RO project each repo belongs to and offer map_project with repos so later drafts know it.`);
641
+ lines.push('', 'No project for the lines of these repos. Confirm each repo\'s RO project once (logwork commits asks in a terminal; /map --repo in the terminal UI; agents call map_project with projectName and repos):');
642
+ for (const repo of result.unmappedRepos) {
643
+ const top = result.repoSuggestions?.[repo]?.[0];
644
+ lines.push(top
645
+ ? `- ${repo}: likely ${projectLabel(top)} (${top.reasons.join('; ')})`
646
+ : `- ${repo}: no suggestion; ask which project it belongs to`);
647
+ }
604
648
  }
605
649
  return lines.join('\n');
606
650
  }
607
651
 
652
+ // Ranked project suggestions per unmapped repo. RO history and memberships
653
+ // are read once per draft and are best effort: an auth error still reaches the
654
+ // caller (authRequired), any other error only drops that evidence.
655
+ async function buildRepoSuggestions({ unmappedRepos, days, bookings, queryRo, fetchProjects, runGit, cwd, today, historyDays }) {
656
+ const historyResult = await bestEffort(() => queryRo({
657
+ from: addLocalDays(today, -historyDays),
658
+ to: addLocalDays(today, 1),
659
+ includeEntries: true,
660
+ cwd
661
+ }));
662
+ const memberships = await bestEffort(fetchProjects);
663
+ const projectCandidates = mergeCandidates([
664
+ ...[...bookings.values()].flatMap((day) => day.projects.filter((project) => project.bookedHours > 0)),
665
+ ...(Array.isArray(memberships) ? memberships : [])
666
+ ]);
667
+ const history = historyFromQueryResult(historyResult);
668
+ const bookedProjectsByDate = {};
669
+ for (const [date, day] of bookings) {
670
+ bookedProjectsByDate[date] = day.projects.filter((project) => project.bookedHours > 0);
671
+ }
672
+
673
+ const repoSuggestions = {};
674
+ for (const repo of unmappedRepos) {
675
+ const repoGroups = days.flatMap((day) => day.groups
676
+ .filter((group) => group.repoPath === repo && !group.project)
677
+ .map((group) => ({ ...group, date: day.date })));
678
+ repoSuggestions[repo] = suggestRepoProjects({
679
+ repoPath: repo,
680
+ repoGroups,
681
+ candidates: projectCandidates,
682
+ history,
683
+ remoteUrl: await readRemoteUrl(repo, runGit),
684
+ bookedProjectsByDate
685
+ });
686
+ }
687
+ return { repoSuggestions, projectCandidates };
688
+ }
689
+
690
+ // Suggestions for one repo outside a draft (the terminal UI /map picker): the
691
+ // last historyDays of the repo's commits, RO entries and bookings, ranked
692
+ // against the given member projects.
693
+ export async function suggestProjectsForRepo({
694
+ repo,
695
+ projects = [],
696
+ author,
697
+ cwd = process.cwd(),
698
+ runGit = runGitCommand,
699
+ queryRo = queryLogwork,
700
+ historyDays = SUGGESTION_HISTORY_DAYS,
701
+ today = todayLocalDateISO()
702
+ } = {}) {
703
+ const repoPath = resolve(cwd, expandHome(String(repo || '.')));
704
+ const root = await resolveGitRepoRoot(repoPath, runGit);
705
+ const from = addLocalDays(today, -historyDays);
706
+ const to = addLocalDays(today, 1);
707
+ const commits = await readRepoCommits({ repo: root, from, to, author, runGit })
708
+ .then((result) => result.commits)
709
+ .catch(() => []);
710
+ const repoGroups = [...new Set(commits.map((commit) => commit.date))]
711
+ .flatMap((date) => groupCommits(commits.filter((commit) => commit.date === date)).map((group) => ({ ...group, date })));
712
+ const historyResult = await bestEffort(() => queryRo({ from, to, includeEntries: true, cwd }));
713
+ const candidates = mergeCandidates(projects);
714
+ return {
715
+ repoRoot: root,
716
+ candidates,
717
+ suggestions: suggestRepoProjects({
718
+ repoPath: root,
719
+ repoGroups,
720
+ candidates,
721
+ history: historyFromQueryResult(historyResult),
722
+ remoteUrl: await readRemoteUrl(root, runGit),
723
+ bookedProjectsByDate: bookedProjectsByDateFromQuery(historyResult)
724
+ })
725
+ };
726
+ }
727
+
728
+ export async function readRemoteUrl(repo, runGit = runGitCommand) {
729
+ try {
730
+ return String(await runGit(repo, ['remote', 'get-url', 'origin'])).trim();
731
+ } catch {
732
+ return '';
733
+ }
734
+ }
735
+
736
+ async function bestEffort(action) {
737
+ try {
738
+ return await action();
739
+ } catch (error) {
740
+ if (isAuthRequiredError(error)) {
741
+ throw error;
742
+ }
743
+ return null;
744
+ }
745
+ }
746
+
747
+ function mergeCandidates(projects) {
748
+ const results = [];
749
+ for (const project of projects) {
750
+ if (!project || !(project.projectName || hasValue(project.projectMemberId))) {
751
+ continue;
752
+ }
753
+ const known = results.findIndex((candidate) => sameProjectIdentity(candidate, project));
754
+ if (known < 0) {
755
+ results.push(projectRef(project));
756
+ } else {
757
+ results[known] = { ...projectRef(project), ...results[known] };
758
+ }
759
+ }
760
+ return results;
761
+ }
762
+
608
763
  function projectRef(project = {}) {
609
764
  return {
610
765
  projectName: project.projectName || '',
@@ -10,7 +10,9 @@ import { getStoredAuthStatus } from './auth.mjs';
10
10
  import { redactText } from './auth-redaction.mjs';
11
11
  import { authDebugLogPath, sanitizeDiagnosticValue } from './auth-diagnostics.mjs';
12
12
  import { configPath, loadLocalConfig } from './logwork-config.mjs';
13
- import { formatNodePathIssue, inspectNodePath, loadRuntimeInfo } from './node-runtime.mjs';
13
+ import { checkLaunchCommand, formatLaunchCheckIssue, resolveLaunchCommand } from './launcher.mjs';
14
+ import { createMcpInstallContext, inspectMcpClients } from './mcp-install.mjs';
15
+ import { formatNodePathIssue, inspectNodePath, inspectScheduledCommand, loadRuntimeInfo } from './node-runtime.mjs';
14
16
  import { helperHome, manualDraftsPath, runtimeInfoPath, userConfigPath } from './paths.mjs';
15
17
  import { readReminderSchedulerNodePath } from './reminder-scheduler.mjs';
16
18
 
@@ -37,6 +39,7 @@ export async function generateDiagnosticsReport({
37
39
  authStatusFn = getStoredAuthStatus,
38
40
  mcpSmokeFn = runMcpSmoke,
39
41
  nodeRuntimeFn = collectNodeRuntimeCheck,
42
+ mcpClientsFn = collectMcpClientCheck,
40
43
  readFileFn = readFile,
41
44
  statFn = stat,
42
45
  writeFileFn = writeFile,
@@ -50,7 +53,12 @@ export async function generateDiagnosticsReport({
50
53
  const configSnapshot = await safeCollect('config snapshot', () => collectConfigSnapshot({ cwd, includeMappingDetails }));
51
54
  const mcpSmoke = await safeCollect('mcp smoke', () => mcpSmokeFn({ cwd }));
52
55
  const nodeRuntime = await safeCollect('node runtime', () => nodeRuntimeFn());
53
- const nodeIssues = Array.isArray(nodeRuntime?.issues) ? nodeRuntime.issues : [];
56
+ const mcpClients = await safeCollect('mcp clients', () => mcpClientsFn());
57
+ const runtimeIssues = Array.isArray(nodeRuntime?.issues) ? nodeRuntime.issues : [];
58
+ const mcpIssues = (Array.isArray(mcpClients) ? mcpClients : [])
59
+ .filter((client) => client.status === 'stale' || client.legacy || client.localScopes)
60
+ .map((client) => `MCP: ${formatMcpClientCheck(client)}. Fix: run \`logwork mcp install\`.`);
61
+ const nodeIssues = [...runtimeIssues, ...mcpIssues];
54
62
  const authDebug = await readSanitizedAuthDebug({ readFileFn });
55
63
 
56
64
  sections.push(formatSection('logwork-cli Diagnostics', [
@@ -72,13 +80,26 @@ export async function generateDiagnosticsReport({
72
80
  ? formatObjectLines(nodeRuntime)
73
81
  : [
74
82
  `runtimeFile: ${nodeRuntime.runtimeFile}`,
83
+ ...(nodeRuntime.launchCommand ? [
84
+ `launchCommand: ${nodeRuntime.launchCommand}`,
85
+ `launchStatus: ${nodeRuntime.launchStatus}`,
86
+ `launchNode: ${nodeRuntime.launchNode || 'unknown'}`,
87
+ `launchVersion: ${nodeRuntime.launchVersion || 'unknown'}`
88
+ ] : []),
75
89
  `recordedNode: ${nodeRuntime.recordedNode || 'none (run logwork setup)'}${nodeRuntime.recordedVersion ? ` (${nodeRuntime.recordedVersion})` : ''}`,
76
- `recordedNodeStatus: ${nodeRuntime.recordedStatus}`,
90
+ `recordedNodeStatus: ${nodeRuntime.recordedStatus}${nodeRuntime.recordedNote ? ` (${nodeRuntime.recordedNote})` : ''}`,
77
91
  `reminderNode: ${nodeRuntime.reminderNode || 'none (no reminder schedule)'}`,
78
92
  `reminderNodeStatus: ${nodeRuntime.reminderStatus}`,
79
- ...(nodeIssues.length ? nodeIssues.map((issue) => `warning: ${issue}`) : ['warnings: none'])
93
+ ...(runtimeIssues.length ? runtimeIssues.map((issue) => `warning: ${issue}`) : ['warnings: none'])
80
94
  ]));
81
95
 
96
+ sections.push(formatSection('MCP Clients', Array.isArray(mcpClients)
97
+ ? [
98
+ ...(mcpClients.length ? mcpClients.map(formatMcpClientCheck) : ['none detected']),
99
+ ...mcpIssues.map((issue) => `warning: ${issue}`)
100
+ ]
101
+ : formatObjectLines(mcpClients)));
102
+
82
103
  sections.push(formatSection('Commands', commandChecks.map((entry) => `${entry.command}: ${entry.available ? 'found' : 'missing'}${entry.path ? ` (${entry.path})` : ''}`)));
83
104
  sections.push(formatSection('Files', fileChecks.map((entry) => formatFileCheck(entry))));
84
105
  sections.push(formatSection('Config Snapshot', formatObjectLines(configSnapshot)));
@@ -101,36 +122,78 @@ export async function generateDiagnosticsReport({
101
122
  };
102
123
  }
103
124
 
104
- // Compares the node recorded by `logwork setup` (runtime.json) and the node in
105
- // the installed reminder schedule with the node running now. Read-only.
125
+ // Checks the launch command IDE configs and the reminder run (it must exist
126
+ // and start a usable Node), the node recorded by `logwork setup`
127
+ // (runtime.json) and the command in the installed reminder schedule.
128
+ // Read-only: it only runs the launch command with --version.
106
129
  export async function collectNodeRuntimeCheck({
130
+ launch = resolveLaunchCommand(),
107
131
  loadRuntime = loadRuntimeInfo,
108
132
  readSchedulerNodePath = readReminderSchedulerNodePath,
109
- inspect = inspectNodePath
133
+ inspect = inspectNodePath,
134
+ inspectScheduled = (path) => inspectScheduledCommand(path, { launch }),
135
+ checkLaunch = () => checkLaunchCommand(launch)
110
136
  } = {}) {
111
137
  const runtime = await loadRuntime().catch(() => null);
112
138
  const reminderNode = await readSchedulerNodePath().catch(() => null);
139
+ const launchCheck = await checkLaunch().catch((error) => ({ status: 'failed', command: launch.command, kind: launch.kind, detail: error.message }));
113
140
  const recorded = runtime?.nodePath
114
141
  ? await inspect(runtime.nodePath)
115
142
  : { status: 'unknown' };
116
143
  const reminder = reminderNode
117
- ? await inspect(reminderNode)
144
+ ? await inspectScheduled(reminderNode)
118
145
  : { status: 'unknown' };
146
+ // With the launcher, a gone or switched recorded node is not fatal: the
147
+ // launcher falls back to another Node 22+.
148
+ const launcherCovers = launch.kind === 'launcher' && launchCheck?.status === 'ok';
149
+ const recordedNote = launcherCovers && (recorded.status === 'missing' || recorded.status === 'stale')
150
+ ? `the launcher uses ${launchCheck.node || 'another Node'}; run \`logwork setup\` to record it`
151
+ : '';
119
152
  const issues = [
120
- formatNodePathIssue('runtime', recorded),
153
+ formatLaunchCheckIssue(launchCheck),
154
+ launcherCovers ? '' : formatNodePathIssue('runtime', recorded),
121
155
  formatNodePathIssue('reminder', reminder)
122
156
  ].filter(Boolean);
123
157
  return {
124
158
  runtimeFile: runtimeInfoPath(),
159
+ launchCommand: [launch.command, ...launch.argsPrefix].join(' '),
160
+ launchStatus: launchCheck?.status || 'unknown',
161
+ launchNode: launchCheck?.node || '',
162
+ launchVersion: launchCheck?.version || '',
125
163
  recordedNode: runtime?.nodePath || '',
126
164
  recordedVersion: runtime?.nodeVersion || '',
127
165
  recordedStatus: recorded.status,
166
+ recordedNote,
128
167
  reminderNode: reminderNode || '',
129
168
  reminderStatus: reminder.status,
130
169
  issues
131
170
  };
132
171
  }
133
172
 
173
+ // The `logwork` entry of each MCP client config (what `logwork mcp status`
174
+ // shows). Read-only.
175
+ export async function collectMcpClientCheck({
176
+ ctx = createMcpInstallContext()
177
+ } = {}) {
178
+ const clients = await inspectMcpClients(ctx);
179
+ return clients.filter((client) => client.detected).map((client) => ({
180
+ label: client.label,
181
+ status: client.status,
182
+ reason: client.reason,
183
+ legacy: client.legacy,
184
+ localScopes: (client.localScopes || []).length
185
+ }));
186
+ }
187
+
188
+ function formatMcpClientCheck(client) {
189
+ const extra = [
190
+ client.reason,
191
+ client.legacy ? 'leftover logwork-helper entry' : '',
192
+ client.localScopes ? `${client.localScopes} stale local-scope entries` : ''
193
+ ].filter(Boolean).join('; ');
194
+ return `${client.label}: ${client.status}${extra ? ` (${extra})` : ''}`;
195
+ }
196
+
134
197
  async function readPackageInfo({ readFileFn }) {
135
198
  try {
136
199
  const packagePath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');