logwork-cli 0.0.0-stage → 1.1.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 (96) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +330 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +104 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/commits.mjs +82 -0
  7. package/commands/delete.mjs +69 -0
  8. package/commands/doctor.mjs +24 -0
  9. package/commands/edit.mjs +88 -0
  10. package/commands/history.mjs +62 -0
  11. package/commands/query.mjs +52 -0
  12. package/commands/quick.mjs +163 -0
  13. package/commands/reconcile.mjs +55 -0
  14. package/commands/release.mjs +77 -0
  15. package/commands/reminder.mjs +172 -0
  16. package/commands/setup.mjs +508 -0
  17. package/commands/skill.mjs +91 -0
  18. package/commands/tui.mjs +50 -0
  19. package/commands/update.mjs +161 -0
  20. package/docs/advanced.md +334 -0
  21. package/docs/mcp-setup.md +368 -0
  22. package/docs/operations-guide.md +587 -0
  23. package/docs/recipes.md +259 -0
  24. package/docs/security.md +151 -0
  25. package/examples/mcp/antigravity.mcp_config.json +8 -0
  26. package/examples/mcp/codex.config.toml +5 -0
  27. package/examples/mcp/copilot.mcp.json +9 -0
  28. package/lib/agent-skill.mjs +16 -0
  29. package/lib/api-auth.mjs +1259 -0
  30. package/lib/api.mjs +1422 -0
  31. package/lib/apply-ledger.mjs +227 -0
  32. package/lib/atomic-file.mjs +24 -0
  33. package/lib/auth-diagnostics.mjs +113 -0
  34. package/lib/auth-errors.mjs +22 -0
  35. package/lib/auth-redaction.mjs +78 -0
  36. package/lib/auth-terminal.mjs +97 -0
  37. package/lib/auth.mjs +323 -0
  38. package/lib/batch-parser.mjs +145 -0
  39. package/lib/batch-workflow.mjs +842 -0
  40. package/lib/commit-workflow.mjs +402 -0
  41. package/lib/config.mjs +191 -0
  42. package/lib/credential-store.mjs +130 -0
  43. package/lib/diagnostics.mjs +307 -0
  44. package/lib/entrypoint.mjs +14 -0
  45. package/lib/file-lock.mjs +44 -0
  46. package/lib/http.mjs +125 -0
  47. package/lib/jira-api.mjs +258 -0
  48. package/lib/jira-auth.mjs +203 -0
  49. package/lib/jira-workflow.mjs +523 -0
  50. package/lib/keychain.mjs +105 -0
  51. package/lib/legacy-migration.mjs +66 -0
  52. package/lib/list-navigation.mjs +19 -0
  53. package/lib/logwork-config.mjs +199 -0
  54. package/lib/logwork-f03-enrich.mjs +41 -0
  55. package/lib/macos.mjs +33 -0
  56. package/lib/manual-apply-state.mjs +35 -0
  57. package/lib/manual-commands.mjs +294 -0
  58. package/lib/manual-drafts.mjs +154 -0
  59. package/lib/manual-ink-app.mjs +1654 -0
  60. package/lib/manual-ink-auth.mjs +349 -0
  61. package/lib/manual-ink-ui.mjs +675 -0
  62. package/lib/manual-input.mjs +21 -0
  63. package/lib/manual-logwork-wizard.mjs +310 -0
  64. package/lib/manual-mcp.mjs +187 -0
  65. package/lib/manual-repl.mjs +1037 -0
  66. package/lib/manual-targets.mjs +78 -0
  67. package/lib/mcp-server.mjs +833 -0
  68. package/lib/os-notification.mjs +105 -0
  69. package/lib/package-info.mjs +17 -0
  70. package/lib/paths.mjs +68 -0
  71. package/lib/project-identity.mjs +66 -0
  72. package/lib/project-mapping-workflow.mjs +309 -0
  73. package/lib/project-resolver.mjs +373 -0
  74. package/lib/query-workflow.mjs +553 -0
  75. package/lib/reconciliation-workflow.mjs +446 -0
  76. package/lib/release-doctor.mjs +448 -0
  77. package/lib/reminder-config.mjs +71 -0
  78. package/lib/reminder-scheduler.mjs +301 -0
  79. package/lib/reminder-service.mjs +118 -0
  80. package/lib/reminder-state.mjs +38 -0
  81. package/lib/reminder-workflow.mjs +299 -0
  82. package/lib/ro-delete-args.mjs +38 -0
  83. package/lib/ro-delete-workflow.mjs +115 -0
  84. package/lib/ro-edit-args.mjs +142 -0
  85. package/lib/ro-edit-workflow.mjs +632 -0
  86. package/lib/ro-entry-reader.mjs +56 -0
  87. package/lib/ro-resubmit-workflow.mjs +134 -0
  88. package/lib/terminal-auth-prompts.mjs +120 -0
  89. package/lib/ui.mjs +133 -0
  90. package/lib/update-service.mjs +350 -0
  91. package/lib/util.mjs +66 -0
  92. package/lib/windows-credential-manager.mjs +260 -0
  93. package/lib/worklog-task-resolver.mjs +113 -0
  94. package/package.json +73 -4
  95. package/setup.sh +22 -0
  96. package/skills/logwork/SKILL.md +43 -0
@@ -0,0 +1,199 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import {
3
+ legacyUserConfigPath,
4
+ projectConfigPath,
5
+ userConfigPath
6
+ } from './paths.mjs';
7
+ import { atomicWriteFile } from './atomic-file.mjs';
8
+ import { withFileLock } from './file-lock.mjs';
9
+ import {
10
+ normalizeProjectName,
11
+ sameProjectIdentity
12
+ } from './project-identity.mjs';
13
+ import { safeJsonParse } from './util.mjs';
14
+
15
+ export async function loadLocalConfig(cwd = process.cwd()) {
16
+ const legacyConfig = normalizeConfig(await readRawConfigPath(legacyUserConfigPath()));
17
+ const userConfig = normalizeConfig(await readRawConfigPath(userConfigPath()));
18
+ const projectPath = projectConfigPath(cwd);
19
+ const projectConfig = projectPath === userConfigPath()
20
+ ? normalizeConfig({})
21
+ : normalizeConfig(await readRawConfigPath(projectPath));
22
+
23
+ return mergeConfigs(legacyConfig, userConfig, projectConfig);
24
+ }
25
+
26
+ export async function upsertProjectMappingConfig({
27
+ cwd = process.cwd(),
28
+ scope = 'user',
29
+ project,
30
+ tickets = [],
31
+ keywords = [],
32
+ lockOptions = {}
33
+ }) {
34
+ const path = scope === 'project' ? projectConfigPath(cwd) : userConfigPath();
35
+ return withFileLock(path, async () => {
36
+ const config = scope === 'project'
37
+ ? normalizeConfig(await readRawConfigPath(path))
38
+ : mergeConfigs(
39
+ normalizeConfig(await readRawConfigPath(legacyUserConfigPath())),
40
+ normalizeConfig(await readRawConfigPath(path))
41
+ );
42
+ const mapping = {
43
+ projectName: project.projectName,
44
+ projectMemberId: project.projectMemberId,
45
+ tickets: normalizeTickets(tickets),
46
+ keywords: normalizeKeywords(keywords)
47
+ };
48
+ const existingIndex = config.projectMappings.findIndex((candidate) => (
49
+ sameProjectIdentity(candidate, mapping)
50
+ ));
51
+
52
+ const projectMappings = [...config.projectMappings];
53
+ if (existingIndex >= 0) {
54
+ const existing = projectMappings[existingIndex];
55
+ projectMappings[existingIndex] = {
56
+ projectName: mapping.projectName || existing.projectName,
57
+ projectMemberId: mapping.projectMemberId ?? existing.projectMemberId,
58
+ tickets: uniqueStrings([...existing.tickets, ...mapping.tickets], { uppercase: true }),
59
+ keywords: uniqueStrings([...existing.keywords, ...mapping.keywords])
60
+ };
61
+ } else {
62
+ projectMappings.push(mapping);
63
+ }
64
+
65
+ const nextConfig = { projectMappings };
66
+ await atomicWriteFile(path, `${JSON.stringify(nextConfig, null, 2)}\n`);
67
+
68
+ return {
69
+ path,
70
+ scope,
71
+ config: normalizeConfig(nextConfig),
72
+ mapping: normalizeConfig({ projectMappings: [existingIndex >= 0 ? projectMappings[existingIndex] : mapping] }).projectMappings[0],
73
+ created: existingIndex < 0
74
+ };
75
+ }, lockOptions);
76
+ }
77
+
78
+ export function normalizeConfig(config) {
79
+ const rawMappings = Array.isArray(config.projectMappings)
80
+ ? config.projectMappings
81
+ : Array.isArray(config.mappings)
82
+ ? config.mappings
83
+ : [];
84
+
85
+ return {
86
+ projectMappings: rawMappings
87
+ .filter((mapping) => mapping && typeof mapping === 'object')
88
+ .map(normalizeMapping)
89
+ .filter((mapping) => mapping.projectMemberId !== undefined || mapping.projectName)
90
+ };
91
+ }
92
+
93
+ export function configPath(cwd = process.cwd(), scope = 'user') {
94
+ return scope === 'project' ? projectConfigPath(cwd) : userConfigPath();
95
+ }
96
+
97
+ async function readRawConfigPath(path) {
98
+ try {
99
+ const text = await fs.readFile(path, 'utf8');
100
+ const data = safeJsonParse(text, null);
101
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
102
+ throw new Error(`${path} must contain a JSON object.`);
103
+ }
104
+ return data;
105
+ } catch (error) {
106
+ if (error.code === 'ENOENT') {
107
+ return {};
108
+ }
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ function mergeConfigs(...configs) {
114
+ const mappings = new Map();
115
+
116
+ for (const config of configs) {
117
+ for (const mapping of config.projectMappings) {
118
+ const key = mappingKey(mapping);
119
+ const existing = mappings.get(key);
120
+ mappings.set(key, existing ? mergeMapping(existing, mapping) : mapping);
121
+ }
122
+ }
123
+
124
+ return {
125
+ projectMappings: [...mappings.values()]
126
+ };
127
+ }
128
+
129
+ function mappingKey(mapping) {
130
+ return mapping.projectMemberId !== undefined && mapping.projectMemberId !== null
131
+ ? `member:${mapping.projectMemberId}`
132
+ : `name:${normalizeProjectName(mapping.projectName)}`;
133
+ }
134
+
135
+ function normalizeMapping(mapping) {
136
+ return {
137
+ projectName: stringOrEmpty(mapping.projectName),
138
+ projectMemberId: optionalId(mapping.projectMemberId),
139
+ tickets: normalizeTickets(mapping.tickets),
140
+ keywords: normalizeKeywords(mapping.keywords)
141
+ };
142
+ }
143
+
144
+ function mergeMapping(existing, next) {
145
+ return {
146
+ projectName: next.projectName || existing.projectName,
147
+ projectMemberId: next.projectMemberId ?? existing.projectMemberId,
148
+ tickets: uniqueStrings([...existing.tickets, ...next.tickets], { uppercase: true }),
149
+ keywords: uniqueStrings([...existing.keywords, ...next.keywords])
150
+ };
151
+ }
152
+
153
+ function stringOrEmpty(value) {
154
+ return typeof value === 'string' ? value.trim() : '';
155
+ }
156
+
157
+ function optionalId(value) {
158
+ if (value === undefined || value === null) {
159
+ return undefined;
160
+ }
161
+
162
+ if (typeof value === 'number') {
163
+ return Number.isFinite(value) ? value : undefined;
164
+ }
165
+
166
+ const text = String(value).trim();
167
+ return text ? text : undefined;
168
+ }
169
+
170
+ function stringArray(value) {
171
+ return Array.isArray(value)
172
+ ? value.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean)
173
+ : [];
174
+ }
175
+
176
+ function normalizeTickets(value) {
177
+ return uniqueStrings(stringArray(value), { uppercase: true });
178
+ }
179
+
180
+ function normalizeKeywords(value) {
181
+ return uniqueStrings(stringArray(value));
182
+ }
183
+
184
+ function uniqueStrings(values, { uppercase = false } = {}) {
185
+ const seen = new Set();
186
+ const results = [];
187
+
188
+ for (const value of values) {
189
+ const normalized = uppercase ? value.toUpperCase() : value.trim();
190
+ const key = normalized.toLowerCase();
191
+ if (!normalized || seen.has(key)) {
192
+ continue;
193
+ }
194
+ seen.add(key);
195
+ results.push(normalized);
196
+ }
197
+
198
+ return results;
199
+ }
@@ -0,0 +1,41 @@
1
+ export async function enrichEntriesWithLogworkF03(entries, { dates = [], fetchLogworkDay } = {}) {
2
+ if (!fetchLogworkDay || !entries?.length || !dates?.length) {
3
+ return { entries, f03ByDate: {} };
4
+ }
5
+
6
+ const f03ByDate = {};
7
+ const statusById = new Map();
8
+
9
+ for (const date of [...new Set(dates.filter(Boolean))]) {
10
+ const day = await fetchLogworkDay(date);
11
+ f03ByDate[date] = {
12
+ isLocked: day.isLocked,
13
+ entryCount: day.entries?.length || 0
14
+ };
15
+ for (const entry of day.entries || []) {
16
+ if (entry.id !== undefined && entry.id !== null) {
17
+ statusById.set(String(entry.id), entry);
18
+ }
19
+ }
20
+ }
21
+
22
+ const enriched = entries.map((entry) => {
23
+ const f03 = entry.id !== undefined && entry.id !== null
24
+ ? statusById.get(String(entry.id))
25
+ : null;
26
+ if (!f03) {
27
+ return entry;
28
+ }
29
+ return {
30
+ ...entry,
31
+ status: f03.status || entry.status || '',
32
+ typeOfWork: f03.typeOfWork || entry.typeOfWork || '',
33
+ worklogTaskId: f03.worklogTaskId ?? entry.worklogTaskId ?? null,
34
+ description: f03.description || entry.description || '',
35
+ rejectionComment: f03.rejectionComment || entry.rejectionComment || '',
36
+ noteToPm: f03.noteToPm || entry.noteToPm || ''
37
+ };
38
+ });
39
+
40
+ return { entries: enriched, f03ByDate };
41
+ }
package/lib/macos.mjs ADDED
@@ -0,0 +1,33 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export async function runOsascript(script, args = []) {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn('/usr/bin/osascript', ['-e', script, ...args], {
6
+ stdio: ['ignore', 'pipe', 'pipe']
7
+ });
8
+
9
+ let stdout = '';
10
+ let stderr = '';
11
+
12
+ child.stdout.setEncoding('utf8');
13
+ child.stderr.setEncoding('utf8');
14
+ child.stdout.on('data', (chunk) => {
15
+ stdout += chunk;
16
+ });
17
+ child.stderr.on('data', (chunk) => {
18
+ stderr += chunk;
19
+ });
20
+ child.on('error', reject);
21
+ child.on('close', (code) => {
22
+ if (code === 0) {
23
+ resolve(stdout.trim());
24
+ return;
25
+ }
26
+
27
+ const error = new Error(`osascript failed with exit code ${code}: ${stderr.trim() || 'no stderr'}`);
28
+ error.code = code;
29
+ error.stderr = stderr;
30
+ reject(error);
31
+ });
32
+ });
33
+ }
@@ -0,0 +1,35 @@
1
+ import { formatHours } from './manual-logwork-wizard.mjs';
2
+
3
+ export function getManualApplyBlocker(preview) {
4
+ if (!preview) {
5
+ return 'No preview available. Run /logwork first.';
6
+ }
7
+
8
+ if (preview.errors?.length) {
9
+ return 'Cannot apply preview with parse errors. Run /logwork again after fixing the text.';
10
+ }
11
+
12
+ if (preview.unresolvedEntries?.length) {
13
+ return `Cannot apply preview with ${preview.unresolvedEntries.length} unresolved entries. Use /projects and /map, then /logwork again.`;
14
+ }
15
+
16
+ return null;
17
+ }
18
+
19
+ export function buildManualApplyConfirmation(preview) {
20
+ const totalHours = totalPreviewHours(preview);
21
+ const entryCount = preview?.entries?.length || 0;
22
+ return `Apply ${entryCount} logwork entries totaling ${formatHours(totalHours)}h?`;
23
+ }
24
+
25
+ export function buildManualUnbookedConfirmation(preview) {
26
+ return `This preview contains ${manualUnbookedEntryCount(preview)} unbooked entries. Submit them anyway?`;
27
+ }
28
+
29
+ export function manualUnbookedEntryCount(preview) {
30
+ return preview?.unbookedEntries?.length || 0;
31
+ }
32
+
33
+ export function totalPreviewHours(preview) {
34
+ return (preview?.entries || []).reduce((sum, entry) => sum + Number(entry.hours || 0), 0);
35
+ }
@@ -0,0 +1,294 @@
1
+ export const MANUAL_COMMANDS = [
2
+ {
3
+ name: '/help',
4
+ description: 'Show this help',
5
+ usage: '/help',
6
+ aliases: []
7
+ },
8
+ {
9
+ name: '/auth',
10
+ description: 'Authenticate Resource Optimiser or show Jira login command',
11
+ usage: '/auth [ro|jira]',
12
+ aliases: []
13
+ },
14
+ {
15
+ name: '/status',
16
+ description: 'Show Resource Optimiser and Jira auth status',
17
+ usage: '/status [ro|jira]',
18
+ aliases: []
19
+ },
20
+ {
21
+ name: '/diagnostics',
22
+ description: 'Write a sanitized support report',
23
+ usage: '/diagnostics',
24
+ aliases: []
25
+ },
26
+ {
27
+ name: '/history',
28
+ description: 'Show recent RO/Jira apply results',
29
+ usage: '/history [ro|jira|both]',
30
+ aliases: []
31
+ },
32
+ {
33
+ name: '/query',
34
+ description: 'Query logwork by preset period',
35
+ usage: '/query today | yesterday | this-week | last-week | this-month | last-month',
36
+ aliases: []
37
+ },
38
+ {
39
+ name: '/reconcile',
40
+ description: 'Compare Resource Optimiser and Jira worklogs',
41
+ usage: '/reconcile [today|yesterday|this-week|last-week|this-month|last-month]',
42
+ aliases: []
43
+ },
44
+ {
45
+ name: '/logwork',
46
+ description: 'Create Resource Optimiser, Jira, or combined logwork',
47
+ usage: '/logwork [ro|jira|both]',
48
+ aliases: []
49
+ },
50
+ {
51
+ name: '/mcp',
52
+ description: 'Show copy-ready MCP setup for an IDE',
53
+ usage: '/mcp [cursor|antigravity|copilot|claude-code|codex]',
54
+ aliases: []
55
+ },
56
+ {
57
+ name: '/edit-logwork',
58
+ description: 'Edit hours, worklog task, description, or type on existing RO logwork (replaces the entry)',
59
+ usage: '/edit-logwork <id> --hours <h> [--task-name <task>] [--description <text>] [--type <type>]',
60
+ aliases: []
61
+ },
62
+ {
63
+ name: '/apply',
64
+ description: 'Apply the last preview after confirmation',
65
+ usage: '/apply',
66
+ aliases: [],
67
+ hidden: true
68
+ },
69
+ {
70
+ name: '/projects',
71
+ description: 'List projects with weekly booked/logged chart',
72
+ usage: '/projects [projectMemberId|projectId|name]',
73
+ aliases: []
74
+ },
75
+ {
76
+ name: '/map',
77
+ description: 'Map ticket prefix to projectMemberId',
78
+ usage: '/map SCB 5234',
79
+ aliases: []
80
+ }
81
+ ];
82
+
83
+ export const TASK_COMMANDS = [
84
+ {
85
+ name: '/save',
86
+ description: 'Save this draft locally',
87
+ usage: '/save',
88
+ aliases: []
89
+ },
90
+ {
91
+ name: '/drafts',
92
+ description: 'Resume or delete saved drafts',
93
+ usage: '/drafts',
94
+ aliases: []
95
+ },
96
+ {
97
+ name: '/diagnostics',
98
+ description: 'Write a sanitized support report',
99
+ usage: '/diagnostics',
100
+ aliases: []
101
+ },
102
+ {
103
+ name: '/remove',
104
+ description: 'Select tasks to delete',
105
+ usage: '/remove',
106
+ aliases: []
107
+ },
108
+ {
109
+ name: '/edit',
110
+ description: 'Select one task and replace it',
111
+ usage: '/edit',
112
+ aliases: []
113
+ },
114
+ {
115
+ name: '/clear',
116
+ description: 'Clear current task list',
117
+ usage: '/clear',
118
+ aliases: []
119
+ },
120
+ {
121
+ name: '/back',
122
+ description: 'Return to project picker',
123
+ usage: '/back',
124
+ aliases: []
125
+ },
126
+ {
127
+ name: '/cancel',
128
+ description: 'Discard this logwork session',
129
+ usage: '/cancel',
130
+ aliases: ['/discard']
131
+ }
132
+ ];
133
+
134
+ export const QUERY_PERIOD_COMMANDS = [
135
+ {
136
+ name: '/query today',
137
+ description: 'Query today',
138
+ aliases: []
139
+ },
140
+ {
141
+ name: '/query yesterday',
142
+ description: 'Query yesterday',
143
+ aliases: []
144
+ },
145
+ {
146
+ name: '/query this-week',
147
+ description: 'Query this week',
148
+ aliases: []
149
+ },
150
+ {
151
+ name: '/query last-week',
152
+ description: 'Query last week',
153
+ aliases: []
154
+ },
155
+ {
156
+ name: '/query this-month',
157
+ description: 'Query this month',
158
+ aliases: []
159
+ },
160
+ {
161
+ name: '/query last-month',
162
+ description: 'Query last month',
163
+ aliases: []
164
+ }
165
+ ];
166
+
167
+ export const RECONCILE_PERIOD_COMMANDS = QUERY_PERIOD_COMMANDS.map((command) => ({
168
+ ...command,
169
+ name: command.name.replace('/query', '/reconcile'),
170
+ description: command.description.replace('Query', 'Reconcile')
171
+ }));
172
+
173
+ export const LOGWORK_TARGET_COMMANDS = [
174
+ {
175
+ name: '/logwork ro',
176
+ description: 'Create Resource Optimiser logwork',
177
+ aliases: []
178
+ },
179
+ {
180
+ name: '/logwork jira',
181
+ description: 'Create Jira worklogs',
182
+ aliases: []
183
+ },
184
+ {
185
+ name: '/logwork both',
186
+ description: 'Create RO and Jira previews',
187
+ aliases: []
188
+ }
189
+ ];
190
+
191
+ export const COMMAND_SUGGESTION_LIMIT = 9;
192
+
193
+ export function formatManualHelp(commands = MANUAL_COMMANDS) {
194
+ const visibleCommands = commands.filter((command) => command.hidden !== true);
195
+ return [
196
+ 'Commands:',
197
+ ...visibleCommands.map((command) => ` ${command.usage.padEnd(36)} ${command.description}`)
198
+ ].join('\n');
199
+ }
200
+
201
+ export function findManualCommand(name, commands = MANUAL_COMMANDS) {
202
+ const normalized = String(name || '').toLowerCase();
203
+ return commands.find((command) => (
204
+ command.name === normalized ||
205
+ command.aliases.includes(normalized)
206
+ ));
207
+ }
208
+
209
+ export function getCommandSuggestions(buffer, commands = MANUAL_COMMANDS) {
210
+ const value = String(buffer || '');
211
+ if (!value.startsWith('/')) {
212
+ return [];
213
+ }
214
+
215
+ const queryPeriodSuggestions = getQueryPeriodSuggestions(value, commands);
216
+ if (queryPeriodSuggestions.length) {
217
+ return queryPeriodSuggestions;
218
+ }
219
+
220
+ const reconciliationPeriodSuggestions = getReconciliationPeriodSuggestions(value, commands);
221
+ if (reconciliationPeriodSuggestions.length) {
222
+ return reconciliationPeriodSuggestions;
223
+ }
224
+
225
+ const logworkTargetSuggestions = getLogworkTargetSuggestions(value, commands);
226
+ if (logworkTargetSuggestions.length) {
227
+ return logworkTargetSuggestions;
228
+ }
229
+
230
+ if (/^\S+\s/.test(value)) {
231
+ return [];
232
+ }
233
+
234
+ const commandPart = value.split(/\s+/, 1)[0].toLowerCase();
235
+ if (!commandPart) {
236
+ return [];
237
+ }
238
+
239
+ return commands.filter((command) => command.hidden !== true && command.name.startsWith(commandPart));
240
+ }
241
+
242
+ function getQueryPeriodSuggestions(value, commands) {
243
+ if (commands !== MANUAL_COMMANDS) {
244
+ return [];
245
+ }
246
+
247
+ const normalized = value.toLowerCase();
248
+ if (normalized !== '/query' && !normalized.startsWith('/query ')) {
249
+ return [];
250
+ }
251
+
252
+ return QUERY_PERIOD_COMMANDS.filter((command) => command.name.startsWith(normalized));
253
+ }
254
+
255
+ function getReconciliationPeriodSuggestions(value, commands) {
256
+ if (commands !== MANUAL_COMMANDS) {
257
+ return [];
258
+ }
259
+
260
+ const normalized = value.toLowerCase();
261
+ if (normalized !== '/reconcile' && !normalized.startsWith('/reconcile ')) {
262
+ return [];
263
+ }
264
+
265
+ return RECONCILE_PERIOD_COMMANDS.filter((command) => command.name.startsWith(normalized));
266
+ }
267
+
268
+ function getLogworkTargetSuggestions(value, commands) {
269
+ if (commands !== MANUAL_COMMANDS) {
270
+ return [];
271
+ }
272
+
273
+ const normalized = value.toLowerCase();
274
+ if (!normalized.startsWith('/logwork ')) {
275
+ return [];
276
+ }
277
+
278
+ return LOGWORK_TARGET_COMMANDS.filter((command) => command.name.startsWith(normalized));
279
+ }
280
+
281
+ export function renderCommandSuggestions(buffer, selectedIndex = 0, commands = MANUAL_COMMANDS) {
282
+ const suggestions = getCommandSuggestions(buffer, commands);
283
+ if (!suggestions.length) {
284
+ return '';
285
+ }
286
+
287
+ return suggestions
288
+ .slice(0, COMMAND_SUGGESTION_LIMIT)
289
+ .map((command, index) => {
290
+ const marker = index === selectedIndex ? '›' : ' ';
291
+ return `${marker} ${command.name.padEnd(12)} ${command.description}`;
292
+ })
293
+ .join('\n');
294
+ }