logwork-cli 1.1.1 → 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.
Files changed (70) hide show
  1. package/README.md +53 -25
  2. package/RELEASE.md +4 -2
  3. package/bin/logwork.mjs +2 -2
  4. package/commands/commits.mjs +128 -13
  5. package/commands/history.mjs +0 -4
  6. package/commands/mcp.mjs +266 -0
  7. package/commands/quick.mjs +38 -23
  8. package/commands/reminder.mjs +41 -2
  9. package/commands/setup.mjs +197 -42
  10. package/commands/tui.mjs +1 -0
  11. package/docs/advanced.md +18 -7
  12. package/docs/mcp-setup.md +120 -26
  13. package/docs/operations-guide.md +50 -2
  14. package/docs/recipes.md +40 -0
  15. package/docs/security.md +23 -2
  16. package/examples/mcp/antigravity.mcp_config.json +2 -2
  17. package/examples/mcp/codex.config.toml +2 -2
  18. package/examples/mcp/copilot.mcp.json +2 -2
  19. package/lib/api-auth-forms.mjs +393 -0
  20. package/lib/api-auth-http.mjs +184 -0
  21. package/lib/api-auth-support.mjs +101 -0
  22. package/lib/api-auth-tokens.mjs +129 -0
  23. package/lib/api-auth.mjs +39 -813
  24. package/lib/api.mjs +38 -1422
  25. package/lib/apply-ledger.mjs +1 -1
  26. package/lib/batch-workflow.mjs +187 -2
  27. package/lib/commit-workflow.mjs +345 -23
  28. package/lib/config.mjs +7 -0
  29. package/lib/diagnostics.mjs +116 -4
  30. package/lib/jira-api.mjs +25 -3
  31. package/lib/jira-workflow.mjs +36 -6
  32. package/lib/launcher.mjs +303 -0
  33. package/lib/logwork-config.mjs +64 -13
  34. package/lib/manual-apply-state.mjs +15 -0
  35. package/lib/manual-commands.mjs +2 -2
  36. package/lib/manual-ink-app.mjs +142 -1282
  37. package/lib/manual-ink-draft-actions.mjs +227 -0
  38. package/lib/manual-ink-interaction.mjs +318 -0
  39. package/lib/manual-ink-session.mjs +41 -0
  40. package/lib/manual-ink-task-editor.mjs +338 -0
  41. package/lib/manual-ink-ui.mjs +38 -0
  42. package/lib/manual-ink-wizard.mjs +520 -0
  43. package/lib/manual-mcp.mjs +78 -20
  44. package/lib/manual-repl.mjs +171 -22
  45. package/lib/mcp-install.mjs +930 -0
  46. package/lib/mcp-server.mjs +51 -20
  47. package/lib/node-runtime.mjs +155 -0
  48. package/lib/paths.mjs +7 -0
  49. package/lib/project-mapping-workflow.mjs +32 -27
  50. package/lib/query-workflow.mjs +19 -1
  51. package/lib/reconciliation-workflow.mjs +1 -1
  52. package/lib/reminder-config.mjs +38 -2
  53. package/lib/reminder-scheduler.mjs +85 -18
  54. package/lib/reminder-service.mjs +42 -3
  55. package/lib/reminder-workflow.mjs +118 -22
  56. package/lib/repo-project-suggestions.mjs +285 -0
  57. package/lib/ro-api-diagnostics.mjs +109 -0
  58. package/lib/ro-api-entries.mjs +359 -0
  59. package/lib/ro-api-fields.mjs +290 -0
  60. package/lib/ro-api-http.mjs +68 -0
  61. package/lib/ro-api-projects.mjs +137 -0
  62. package/lib/ro-api-tasks.mjs +50 -0
  63. package/lib/ro-api-timesheet.mjs +413 -0
  64. package/lib/ro-delete-workflow.mjs +2 -1
  65. package/lib/ro-entry-reader.mjs +11 -6
  66. package/lib/ro-resubmit-workflow.mjs +2 -1
  67. package/lib/terminal-auth-prompts.mjs +7 -4
  68. package/lib/ui.mjs +121 -56
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +10 -4
@@ -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,15 +40,48 @@ export const MCP_CLIENTS = [
39
40
  }
40
41
  ];
41
42
 
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.
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
47
+ // Finder) often start without the npm global bin directory or nvm's node on
48
+ // PATH, so neither `logwork` nor a bare `node` would be found.
45
49
  export const MCP_SERVER_NAME = 'logwork';
46
50
 
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
+ };
63
+
47
64
  export function defaultMcpServerPath() {
48
65
  return join(helperHome(), 'bin', 'logwork.mjs');
49
66
  }
50
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
+
51
85
  export function resolveMcpClient(value) {
52
86
  const normalized = normalizeMcpClientValue(value);
53
87
  if (!normalized) {
@@ -82,17 +116,22 @@ export function formatMcpClientOptions(clients = MCP_CLIENTS) {
82
116
 
83
117
  export function formatMcpSetup({
84
118
  client,
85
- serverPath = defaultMcpServerPath(),
119
+ launch,
120
+ serverPath,
121
+ nodePath,
86
122
  platform = process.platform
87
123
  } = {}) {
88
124
  const resolvedClient = typeof client === 'object' ? client : resolveMcpClient(client);
89
125
  if (!resolvedClient) {
90
126
  throw new Error('Unknown MCP client. Use: cursor, antigravity, copilot, claude-code, or codex.');
91
127
  }
128
+ const command = launch || (serverPath === undefined && nodePath === undefined
129
+ ? resolveLaunchCommand({ platform })
130
+ : toLaunchCommand(serverPath, { nodePath }));
92
131
 
93
132
  const lines = [
94
133
  `MCP setup: ${resolvedClient.label}`,
95
- `Entry path: ${serverPath} (run with the "mcp" argument)`,
134
+ `Command: ${[command.command, ...mcpArgs(command)].join(' ')}`,
96
135
  ''
97
136
  ];
98
137
 
@@ -100,34 +139,38 @@ export function formatMcpSetup({
100
139
  lines.push(
101
140
  `Copy this TOML into ${resolvedClient.target}:`,
102
141
  '',
103
- formatCodexToml(serverPath)
142
+ formatCodexToml(command)
104
143
  );
105
144
  } else if (resolvedClient.key === 'copilot') {
106
145
  lines.push(
107
146
  `Copy this JSON into ${resolvedClient.target}:`,
108
147
  '',
109
- JSON.stringify(vscodeMcpConfig(serverPath), null, 2)
148
+ JSON.stringify(vscodeMcpConfig(command), null, 2)
110
149
  );
111
150
  } else if (resolvedClient.key === 'claude-code') {
112
151
  lines.push(
113
152
  'Recommended command:',
114
153
  '',
115
- `claude mcp add --transport stdio ${MCP_SERVER_NAME} -- node ${quoteShellArg(serverPath, { platform })} mcp`,
154
+ formatClaudeMcpAddCommand(command, { platform }),
116
155
  '',
117
156
  'Or copy this JSON into project .mcp.json:',
118
157
  '',
119
- JSON.stringify(stdioMcpServersConfig(serverPath), null, 2)
158
+ JSON.stringify(stdioMcpServersConfig(command), null, 2)
120
159
  );
121
160
  } else {
122
161
  lines.push(
123
162
  `Copy this JSON into ${resolvedClient.target}:`,
124
163
  '',
125
- JSON.stringify(stdioMcpServersConfig(serverPath), null, 2)
164
+ JSON.stringify(stdioMcpServersConfig(command), null, 2)
126
165
  );
127
166
  }
128
167
 
168
+ const installKey = INSTALL_CLIENT_KEYS[resolvedClient.key];
169
+ const note = command.stable ? (command.kind === 'launcher' ? MCP_LAUNCHER_NOTE : '') : MCP_NODE_PATH_NOTE;
129
170
  lines.push(
130
171
  '',
172
+ ...(note ? [note] : []),
173
+ ...(installKey ? [`To let logwork write it for you instead, run: logwork mcp install --client ${installKey}`] : []),
131
174
  'After saving, restart or reload the MCP client.',
132
175
  'Do not paste passwords, 2FA codes, Bearer tokens, cookies, or raw auth logs into AI chat.'
133
176
  );
@@ -142,41 +185,56 @@ function normalizeMcpClientValue(value) {
142
185
  .replace(/[\s_]+/g, '-');
143
186
  }
144
187
 
145
- export function stdioMcpServersConfig(serverPath) {
188
+ export function stdioMcpServersConfig(launchOrServerPath, options = {}) {
189
+ const launch = toLaunchCommand(launchOrServerPath, options);
146
190
  return {
147
191
  mcpServers: {
148
192
  [MCP_SERVER_NAME]: {
149
- command: 'node',
150
- args: [serverPath, 'mcp']
193
+ command: launch.command,
194
+ args: mcpArgs(launch)
151
195
  }
152
196
  }
153
197
  };
154
198
  }
155
199
 
156
- export function vscodeMcpConfig(serverPath) {
200
+ export function vscodeMcpConfig(launchOrServerPath, options = {}) {
201
+ const launch = toLaunchCommand(launchOrServerPath, options);
157
202
  return {
158
203
  servers: {
159
204
  [MCP_SERVER_NAME]: {
160
205
  type: 'stdio',
161
- command: 'node',
162
- args: [serverPath, 'mcp']
206
+ command: launch.command,
207
+ args: mcpArgs(launch)
163
208
  }
164
209
  }
165
210
  };
166
211
  }
167
212
 
168
- export function formatCodexToml(serverPath) {
213
+ export function formatCodexToml(launchOrServerPath, options = {}) {
214
+ const launch = toLaunchCommand(launchOrServerPath, options);
169
215
  return [
170
216
  `[mcp_servers.${MCP_SERVER_NAME}]`,
171
- 'command = "node"',
172
- `args = ["${escapeTomlString(serverPath)}", "mcp"]`,
217
+ `command = "${escapeTomlString(launch.command)}"`,
218
+ `args = [${mcpArgs(launch).map((arg) => `"${escapeTomlString(arg)}"`).join(', ')}]`,
173
219
  'startup_timeout_sec = 20',
174
220
  'tool_timeout_sec = 120'
175
221
  ].join('\n');
176
222
  }
177
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
+
230
+ // TOML basic string: escape backslashes (Windows paths), quotes and control
231
+ // characters.
178
232
  function escapeTomlString(value) {
179
- return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
233
+ return String(value)
234
+ .replace(/\\/g, '\\\\')
235
+ .replace(/"/g, '\\"')
236
+ // eslint-disable-next-line no-control-regex -- TOML needs control characters escaped.
237
+ .replace(/[\u0000-\u001f\u007f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
180
238
  }
181
239
 
182
240
  function quoteShellArg(value, { platform = process.platform } = {}) {
@@ -1,5 +1,6 @@
1
1
  import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
2
- import { confirm, isCancel } from '@clack/prompts';
2
+ import { resolve as resolvePath } from 'node:path';
3
+ import { confirm, isCancel, select } from '@clack/prompts';
3
4
  import { getStoredAuthStatus, loginResourceOptimiser } from './auth.mjs';
4
5
  import { queryApplyLedger } from './apply-ledger.mjs';
5
6
  import { isAuthRequiredError } from './auth-errors.mjs';
@@ -30,8 +31,10 @@ import {
30
31
  import { findManualCommand, formatManualHelp } from './manual-commands.mjs';
31
32
  import {
32
33
  buildManualApplyConfirmation,
34
+ buildManualDuplicateConfirmation,
33
35
  buildManualUnbookedConfirmation,
34
36
  getManualApplyBlocker,
37
+ manualDuplicateEntryCount,
35
38
  manualUnbookedEntryCount
36
39
  } from './manual-apply-state.mjs';
37
40
  import { runManualInkApp } from './manual-ink-app.mjs';
@@ -46,8 +49,11 @@ import {
46
49
  import {
47
50
  enrichRoPreviewWithJiraProjects,
48
51
  listLogworkProjects,
52
+ resolveProjectSelection,
49
53
  upsertProjectMapping
50
54
  } from './project-mapping-workflow.mjs';
55
+ import { suggestProjectsForRepo } from './commit-workflow.mjs';
56
+ import { repoProjectFromOption, repoProjectSelectOptions } from './ui.mjs';
51
57
  import { queryLogwork } from './query-workflow.mjs';
52
58
  import { parseRoEditArgs } from './ro-edit-args.mjs';
53
59
  import {
@@ -363,21 +369,7 @@ export async function executeManualCommand(command, session, context) {
363
369
  }
364
370
 
365
371
  if (command.type === 'map') {
366
- const ok = await prompts.confirm(`Map ${command.tickets.join(', ')} to projectMemberId ${command.projectMemberId}?`);
367
- if (!ok) {
368
- print('Mapping cancelled.');
369
- return { exit: false };
370
- }
371
-
372
- const result = await runWithInlineAuth(() => workflows.upsertProjectMapping({
373
- projectMemberId: command.projectMemberId,
374
- tickets: command.tickets,
375
- keywords: [],
376
- scope: 'user',
377
- confirm: true,
378
- cwd
379
- }), { workflows, print });
380
- print(result.summary);
372
+ await executeMapCommand(command, { cwd, workflows, prompts, print });
381
373
  return { exit: false };
382
374
  }
383
375
 
@@ -498,10 +490,115 @@ export function createManualPrompts() {
498
490
  throw new Error('Cancelled.');
499
491
  }
500
492
  return Boolean(answer);
493
+ },
494
+ // { message, options: [{ value, label, hint }], initialValue } -> value,
495
+ // or null when cancelled.
496
+ async select({ message, options, initialValue }) {
497
+ const answer = await select({ message, options, initialValue });
498
+ return isCancel(answer) ? null : answer;
501
499
  }
502
500
  };
503
501
  }
504
502
 
503
+ // /map with a project member id saves it as before. Otherwise the project is
504
+ // chosen by name (case-insensitive, then a unique substring) or, with no name
505
+ // or an ambiguous one, from a picker of member projects; for a repo the
506
+ // picker lists the suggested projects first.
507
+ async function executeMapCommand(command, { cwd, workflows, prompts, print }) {
508
+ const subject = [
509
+ ...command.tickets,
510
+ ...(command.repos || []).map((repo) => `repo ${resolvePath(cwd, repo)}`)
511
+ ].join(', ');
512
+ const save = (project) => runWithInlineAuth(() => workflows.upsertProjectMapping({
513
+ projectMemberId: project.projectMemberId,
514
+ ...(project.projectMemberId === undefined ? { projectName: project.projectName } : {}),
515
+ tickets: command.tickets,
516
+ keywords: [],
517
+ ...(command.repos?.length ? { repos: command.repos } : {}),
518
+ scope: 'user',
519
+ confirm: true,
520
+ cwd
521
+ }), { workflows, print });
522
+
523
+ if (command.projectMemberId !== undefined) {
524
+ const ok = await prompts.confirm(`Map ${subject} to projectMemberId ${command.projectMemberId}?`);
525
+ if (!ok) {
526
+ print('Mapping cancelled.');
527
+ return;
528
+ }
529
+ print((await save({ projectMemberId: command.projectMemberId })).summary);
530
+ return;
531
+ }
532
+
533
+ const { projects = [] } = await runWithInlineAuth(() => workflows.listLogworkProjects({ cwd }), { workflows, print });
534
+ if (!projects.length) {
535
+ throw new Error('No Resource Optimiser project memberships found to map to.');
536
+ }
537
+
538
+ let project = null;
539
+ let pickerProjects = projects;
540
+ let pickerNote = '';
541
+ if (command.projectQuery) {
542
+ const resolution = resolveProjectSelection(projects, { projectName: command.projectQuery });
543
+ project = resolution.project || null;
544
+ if (!project) {
545
+ pickerNote = `${resolution.reason}.`;
546
+ pickerProjects = resolution.candidates?.length ? resolution.candidates : projects;
547
+ }
548
+ }
549
+
550
+ if (project) {
551
+ const ok = await prompts.confirm(`Map ${subject} to ${project.projectName}?`);
552
+ if (!ok) {
553
+ print('Mapping cancelled.');
554
+ return;
555
+ }
556
+ } else {
557
+ let suggestions = [];
558
+ if (command.repos?.length && !command.projectQuery) {
559
+ const suggested = await runWithInlineAuth(() => workflows.suggestProjectsForRepo({
560
+ repo: command.repos[0],
561
+ projects,
562
+ cwd
563
+ }), { workflows, print }).catch((error) => {
564
+ if (isAuthRequiredError(error)) {
565
+ throw error;
566
+ }
567
+ pickerNote = `No suggestions: ${error.message}`;
568
+ return null;
569
+ });
570
+ suggestions = suggested?.suggestions || [];
571
+ }
572
+ if (pickerNote) {
573
+ print(pickerNote);
574
+ }
575
+ project = await pickMapProject({ subject, suggestions, projects: pickerProjects, prompts, print });
576
+ if (!project) {
577
+ print('Mapping cancelled.');
578
+ return;
579
+ }
580
+ }
581
+
582
+ print((await save(project)).summary);
583
+ }
584
+
585
+ async function pickMapProject({ subject, suggestions, projects, prompts, print }) {
586
+ const { items, options, initialValue } = repoProjectSelectOptions(suggestions, projects, { skipLabel: 'Cancel' });
587
+ if (typeof prompts.select === 'function') {
588
+ return repoProjectFromOption(items, await prompts.select({
589
+ message: `Which RO project should ${subject} map to?`,
590
+ options,
591
+ initialValue
592
+ }));
593
+ }
594
+ // Without a picker, list the projects; the user re-runs /map with a name.
595
+ print([
596
+ `Which RO project should ${subject} map to? Re-run /map with the project name:`,
597
+ ...items.map((item, index) => ` ${index + 1}. ${item.project.projectName}${item.suggestion ? ` (${item.suggestion.reasons.join('; ')})` : ''}`)
598
+ ].join('\n'));
599
+ return null;
600
+ }
601
+
505
602
  async function readPasteBlock({ readLine, print, target = RO_TARGET }) {
506
603
  if (typeof readLine !== 'function') {
507
604
  throw new Error('/logwork requires an interactive terminal.');
@@ -666,10 +763,20 @@ async function applyManualRoPreview({
666
763
  }
667
764
  }
668
765
 
766
+ let allowDuplicates = false;
767
+ if (manualDuplicateEntryCount(preview)) {
768
+ allowDuplicates = await prompts.confirm(buildManualDuplicateConfirmation(preview), false);
769
+ if (!allowDuplicates) {
770
+ print('Resource Optimiser apply cancelled because possible duplicates were not approved. Nothing was written.');
771
+ return false;
772
+ }
773
+ }
774
+
669
775
  const result = await runWithInlineAuth(() => workflows.applyLogworkBatch({
670
776
  batch: preview,
671
777
  confirm: true,
672
778
  allowUnbooked,
779
+ allowDuplicates,
673
780
  cwd,
674
781
  ledgerContext: { flowTarget }
675
782
  }), { workflows, print });
@@ -871,28 +978,69 @@ function parseTargetCommand(type, parts, {
871
978
  };
872
979
  }
873
980
 
981
+ const MAP_USAGE = 'Usage: /map <TICKET_PREFIX[,PREFIX...]> [project name or id] | /map --repo [path] [project name or id]';
982
+ const PATH_LIKE = /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|~(?:[\\/]|$)|[A-Za-z]:[\\/])/;
983
+
874
984
  function parseMapCommand(parts) {
875
- if (parts.length < 2) {
876
- throw new Error('Usage: /map <TICKET_PREFIX[,PREFIX...]> <projectMemberId>');
985
+ if (parts[0] === '--repo') {
986
+ const { repo, project } = splitRepoAndProject(parts.slice(1));
987
+ return {
988
+ type: 'map',
989
+ tickets: [],
990
+ repos: [repo],
991
+ ...mapProjectFields(project)
992
+ };
993
+ }
994
+
995
+ if (!parts.length || parts[0].startsWith('-')) {
996
+ throw new Error(MAP_USAGE);
877
997
  }
878
998
 
879
999
  const tickets = parts[0]
880
1000
  .split(',')
881
1001
  .map((ticket) => ticket.trim().toUpperCase())
882
1002
  .filter(Boolean);
883
- const projectMemberId = parts[1];
884
1003
 
885
- if (!tickets.length || !projectMemberId) {
886
- throw new Error('Usage: /map <TICKET_PREFIX[,PREFIX...]> <projectMemberId>');
1004
+ if (!tickets.length) {
1005
+ throw new Error(MAP_USAGE);
887
1006
  }
888
1007
 
889
1008
  return {
890
1009
  type: 'map',
891
1010
  tickets,
892
- projectMemberId
1011
+ ...mapProjectFields(parts.slice(1).join(' ').trim())
893
1012
  };
894
1013
  }
895
1014
 
1015
+ // "/map --repo [path] [project]": the path defaults to the current folder. A
1016
+ // path starts with ".", "/", "~" or a drive letter, or is quoted (for spaces);
1017
+ // anything else is the project. The old form "<path> <projectMemberId>" still
1018
+ // reads any path.
1019
+ function splitRepoAndProject(tokens) {
1020
+ const rest = tokens.join(' ').trim();
1021
+ const quoted = /^(["'])(.*?)\1(?:\s+(.*))?$/.exec(rest);
1022
+ if (quoted) {
1023
+ return { repo: quoted[2].trim() || '.', project: (quoted[3] || '').trim() };
1024
+ }
1025
+ if (tokens.length >= 2 && /^\d+$/.test(tokens.at(-1))) {
1026
+ return { repo: tokens.slice(0, -1).join(' '), project: tokens.at(-1) };
1027
+ }
1028
+ if (tokens.length && PATH_LIKE.test(tokens[0])) {
1029
+ return { repo: tokens[0], project: tokens.slice(1).join(' ').trim() };
1030
+ }
1031
+ return { repo: '.', project: rest };
1032
+ }
1033
+
1034
+ function mapProjectFields(project) {
1035
+ if (!project) {
1036
+ return {};
1037
+ }
1038
+ if (project.startsWith('-')) {
1039
+ throw new Error(MAP_USAGE);
1040
+ }
1041
+ return /^\d+$/.test(project) ? { projectMemberId: project } : { projectQuery: project };
1042
+ }
1043
+
896
1044
  export function formatProjects(result, weekly = null, projectFilter = undefined) {
897
1045
  const lines = [result.summary];
898
1046
  const projects = filterProjects(result.projects || [], projectFilter);
@@ -1028,6 +1176,7 @@ function defaultWorkflows() {
1028
1176
  applyJiraWorklogBatch,
1029
1177
  listLogworkProjects,
1030
1178
  upsertProjectMapping,
1179
+ suggestProjectsForRepo,
1031
1180
  loginResourceOptimiser,
1032
1181
  getStoredAuthStatus,
1033
1182
  getStoredJiraStatus,