@liebig-technology/clockodo-cli 0.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.
package/dist/index.js ADDED
@@ -0,0 +1,1535 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command, CommanderError } from "commander";
5
+
6
+ // src/commands/absences.ts
7
+ import { styleText as styleText3 } from "util";
8
+ import * as p from "@clack/prompts";
9
+ import { AbsenceStatus, AbsenceType } from "clockodo";
10
+
11
+ // src/lib/client.ts
12
+ import { Clockodo } from "clockodo";
13
+
14
+ // src/lib/config.ts
15
+ import { chmodSync } from "fs";
16
+ import Conf from "conf";
17
+
18
+ // src/lib/errors.ts
19
+ import { styleText } from "util";
20
+ var ExitCode = {
21
+ SUCCESS: 0,
22
+ GENERAL_ERROR: 1,
23
+ INVALID_ARGS: 2,
24
+ EMPTY_RESULTS: 3,
25
+ AUTH_FAILURE: 4,
26
+ NOT_FOUND: 5,
27
+ FORBIDDEN: 6,
28
+ RATE_LIMITED: 7,
29
+ SERVER_ERROR: 8,
30
+ CONFIG_ERROR: 10
31
+ };
32
+ var CliError = class extends Error {
33
+ exitCode;
34
+ suggestion;
35
+ constructor(message, exitCode = ExitCode.GENERAL_ERROR, suggestion) {
36
+ super(message);
37
+ this.name = "CliError";
38
+ this.exitCode = exitCode;
39
+ this.suggestion = suggestion;
40
+ }
41
+ };
42
+ function mapHttpError(statusCode) {
43
+ switch (true) {
44
+ case statusCode === 401:
45
+ return ExitCode.AUTH_FAILURE;
46
+ case statusCode === 403:
47
+ return ExitCode.FORBIDDEN;
48
+ case statusCode === 404:
49
+ return ExitCode.NOT_FOUND;
50
+ case statusCode === 429:
51
+ return ExitCode.RATE_LIMITED;
52
+ case statusCode >= 500:
53
+ return ExitCode.SERVER_ERROR;
54
+ default:
55
+ return ExitCode.GENERAL_ERROR;
56
+ }
57
+ }
58
+ function sanitize(input) {
59
+ return input.replace(
60
+ /([a-zA-Z]*(?:key|token|secret|password|auth|credential)[a-zA-Z]*)\s*[:=]\s*["']?[^\s"',}]+/gi,
61
+ "$1=***"
62
+ ).slice(0, 500);
63
+ }
64
+ function sanitizeStack(stack) {
65
+ return stack.split("\n").filter((line) => !/apiKey|authorization|Bearer|password|secret/i.test(line)).join("\n");
66
+ }
67
+ function extractApiError(error) {
68
+ if (typeof error === "object" && error !== null && "response" in error && typeof error.response === "object") {
69
+ const response = error.response;
70
+ const status = response.status ?? 0;
71
+ const data = response.data;
72
+ let detail;
73
+ if (typeof data === "object" && data !== null) {
74
+ const d = data;
75
+ if (typeof d.error === "object" && d.error !== null) {
76
+ const errObj = d.error;
77
+ detail = String(errObj.message ?? errObj.description ?? JSON.stringify(errObj));
78
+ } else if (d.message) {
79
+ detail = String(d.message);
80
+ } else {
81
+ detail = JSON.stringify(data);
82
+ }
83
+ } else if (typeof data === "string") {
84
+ detail = data;
85
+ }
86
+ return {
87
+ statusCode: status,
88
+ message: `API error (HTTP ${status})`,
89
+ detail: detail ? sanitize(detail) : void 0
90
+ };
91
+ }
92
+ return null;
93
+ }
94
+ function handleError(error, options) {
95
+ if (error instanceof CliError) {
96
+ if (options?.json) {
97
+ console.error(
98
+ JSON.stringify({
99
+ success: false,
100
+ error: {
101
+ code: error.exitCode,
102
+ message: error.message,
103
+ suggestion: error.suggestion
104
+ }
105
+ })
106
+ );
107
+ } else {
108
+ console.error(styleText("red", `Error: ${error.message}`));
109
+ if (error.suggestion) {
110
+ console.error(styleText("yellow", `Hint: ${error.suggestion}`));
111
+ }
112
+ }
113
+ process.exit(error.exitCode);
114
+ }
115
+ const apiError = extractApiError(error);
116
+ if (apiError) {
117
+ const exitCode = mapHttpError(apiError.statusCode);
118
+ if (options?.json) {
119
+ console.error(
120
+ JSON.stringify({
121
+ success: false,
122
+ error: {
123
+ code: exitCode,
124
+ httpStatus: apiError.statusCode,
125
+ message: apiError.message,
126
+ detail: apiError.detail
127
+ }
128
+ })
129
+ );
130
+ } else {
131
+ console.error(styleText("red", `Error: ${apiError.message}`));
132
+ if (apiError.detail) {
133
+ console.error(styleText("yellow", `Detail: ${apiError.detail}`));
134
+ }
135
+ if (options?.verbose && error instanceof Error && error.stack) {
136
+ console.error(styleText("dim", sanitizeStack(error.stack)));
137
+ }
138
+ }
139
+ process.exit(exitCode);
140
+ }
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ if (options?.json) {
143
+ console.error(JSON.stringify({ success: false, error: { code: 1, message } }));
144
+ } else {
145
+ console.error(styleText("red", `Unexpected error: ${message}`));
146
+ if (options?.verbose && error instanceof Error && error.stack) {
147
+ console.error(styleText("dim", error.stack));
148
+ }
149
+ }
150
+ process.exit(ExitCode.GENERAL_ERROR);
151
+ }
152
+
153
+ // src/lib/config.ts
154
+ var schema = {
155
+ email: { type: "string" },
156
+ apiKey: { type: "string" },
157
+ defaultCustomerId: { type: "number" },
158
+ defaultServiceId: { type: "number" },
159
+ defaultProjectId: { type: "number" },
160
+ timezone: { type: "string", default: "Europe/Berlin" }
161
+ };
162
+ var configInstance = null;
163
+ function getStore() {
164
+ if (!configInstance) {
165
+ configInstance = new Conf({
166
+ projectName: "clockodo-cli",
167
+ schema
168
+ });
169
+ try {
170
+ chmodSync(configInstance.path, 384);
171
+ } catch {
172
+ }
173
+ }
174
+ return configInstance;
175
+ }
176
+ function getConfig() {
177
+ return getStore().store;
178
+ }
179
+ function getConfigValue(key) {
180
+ return getStore().get(key);
181
+ }
182
+ function setConfigValue(key, value) {
183
+ getStore().set(key, value);
184
+ }
185
+ function getConfigPath() {
186
+ return getStore().path;
187
+ }
188
+ function requireAuth() {
189
+ const email = getConfigValue("email") ?? process.env.CLOCKODO_EMAIL;
190
+ const apiKey = getConfigValue("apiKey") ?? process.env.CLOCKODO_API_KEY;
191
+ if (!email || !apiKey) {
192
+ throw new CliError(
193
+ "Authentication not configured.",
194
+ ExitCode.CONFIG_ERROR,
195
+ 'Run "clockodo config set" to configure your API credentials, or set CLOCKODO_EMAIL and CLOCKODO_API_KEY environment variables.'
196
+ );
197
+ }
198
+ return { email, apiKey };
199
+ }
200
+ function maskSecret(secret) {
201
+ if (secret.length <= 4) return "****";
202
+ return `****${secret.slice(-4)}`;
203
+ }
204
+
205
+ // src/lib/client.ts
206
+ var clientInstance = null;
207
+ function getClient() {
208
+ if (!clientInstance) {
209
+ const { email, apiKey } = requireAuth();
210
+ clientInstance = new Clockodo({
211
+ client: {
212
+ name: "clockodo-cli",
213
+ email
214
+ },
215
+ authentication: {
216
+ user: email,
217
+ apiKey
218
+ }
219
+ });
220
+ }
221
+ return clientInstance;
222
+ }
223
+
224
+ // src/lib/output.ts
225
+ import { styleText as styleText2 } from "util";
226
+ import Table from "cli-table3";
227
+ function resolveOutputMode(options) {
228
+ if (options.json) return "json";
229
+ if (options.plain) return "plain";
230
+ if (!process.stdout.isTTY && process.env.CLOCKODO_AUTO_JSON !== "0") {
231
+ return "json";
232
+ }
233
+ return "human";
234
+ }
235
+ function printResult(result, options) {
236
+ const mode = resolveOutputMode(options);
237
+ switch (mode) {
238
+ case "json":
239
+ console.log(JSON.stringify(result, null, 2));
240
+ break;
241
+ case "plain":
242
+ console.log(JSON.stringify(result.data));
243
+ break;
244
+ case "human":
245
+ console.log(result.data);
246
+ break;
247
+ }
248
+ }
249
+ function printTable(headers, rows, options) {
250
+ const mode = resolveOutputMode(options ?? {});
251
+ if (mode === "json" || mode === "plain") {
252
+ const data = rows.map((row) => {
253
+ const obj = {};
254
+ for (let i = 0; i < headers.length; i++) {
255
+ const key = headers[i] ?? `col${i}`;
256
+ obj[key] = row[i] ?? "";
257
+ }
258
+ return obj;
259
+ });
260
+ console.log(JSON.stringify(mode === "json" ? { data } : data, null, mode === "json" ? 2 : 0));
261
+ return;
262
+ }
263
+ const table = new Table({
264
+ head: headers.map((h) => styleText2("bold", h)),
265
+ style: { head: [], border: [] }
266
+ });
267
+ for (const row of rows) {
268
+ table.push(row);
269
+ }
270
+ console.log(table.toString());
271
+ }
272
+ function printDetail(entries, options) {
273
+ const mode = resolveOutputMode(options ?? {});
274
+ if (mode === "json" || mode === "plain") {
275
+ const obj = {};
276
+ for (const [key, value] of entries) {
277
+ obj[key] = value;
278
+ }
279
+ console.log(
280
+ JSON.stringify(mode === "json" ? { data: obj } : obj, null, mode === "json" ? 2 : 0)
281
+ );
282
+ return;
283
+ }
284
+ const maxKeyLen = Math.max(...entries.map(([k]) => k.length));
285
+ for (const [key, value] of entries) {
286
+ const label = styleText2("bold", key.padEnd(maxKeyLen));
287
+ console.log(` ${label} ${value ?? styleText2("dim", "\u2014")}`);
288
+ }
289
+ }
290
+ function printSuccess(message) {
291
+ console.error(styleText2("green", `\u2713 ${message}`));
292
+ }
293
+
294
+ // src/lib/validate.ts
295
+ function parseId(input, label = "ID") {
296
+ const num = Number(input);
297
+ if (!Number.isInteger(num) || num <= 0) {
298
+ throw new CliError(
299
+ `Invalid ${label}: "${input}". Must be a positive integer.`,
300
+ ExitCode.INVALID_ARGS
301
+ );
302
+ }
303
+ return num;
304
+ }
305
+ function parseIntStrict(value) {
306
+ const num = Number(value);
307
+ if (!Number.isInteger(num) || num <= 0) {
308
+ throw new CliError(`"${value}" is not a valid positive integer.`, ExitCode.INVALID_ARGS);
309
+ }
310
+ return num;
311
+ }
312
+
313
+ // src/commands/absences.ts
314
+ function formatEnumLabel(name) {
315
+ return name.replace(/([A-Z])/g, " $1").trim();
316
+ }
317
+ function formatAbsenceType(type) {
318
+ if (type == null) return styleText3("dim", "\u2014");
319
+ const name = AbsenceType[type];
320
+ return name ? formatEnumLabel(name) : String(type);
321
+ }
322
+ function formatAbsenceStatus(status) {
323
+ const name = AbsenceStatus[status];
324
+ return name ? formatEnumLabel(name) : String(status);
325
+ }
326
+ function formatDaysOrHours(absence) {
327
+ if ("countHours" in absence && absence.countHours != null) {
328
+ return `${absence.countHours}h`;
329
+ }
330
+ if ("countDays" in absence && absence.countDays != null) {
331
+ return `${absence.countDays}d`;
332
+ }
333
+ return "\u2014";
334
+ }
335
+ function registerAbsencesCommands(program2) {
336
+ const absences = program2.command("absences").description("Manage absences");
337
+ absences.command("list", { isDefault: true }).description("List absences").option("--year <year>", "Filter by year (default: current year)", parseIntStrict).option("--user <id>", "Filter by user ID", parseIntStrict).option("--type <type>", "Filter by absence type", parseIntStrict).option("--status <status>", "Filter by absence status", parseIntStrict).action(async (cmdOpts) => {
338
+ const opts = program2.opts();
339
+ const client = getClient();
340
+ const year = cmdOpts.year ?? (/* @__PURE__ */ new Date()).getFullYear();
341
+ const filter = { year: [year] };
342
+ if (cmdOpts.user) filter.usersId = [cmdOpts.user];
343
+ if (cmdOpts.type != null) filter.type = [cmdOpts.type];
344
+ if (cmdOpts.status != null) filter.status = [cmdOpts.status];
345
+ const result = await client.getAbsences({ filter });
346
+ const absenceList = result.data ?? [];
347
+ const mode = resolveOutputMode(opts);
348
+ if (absenceList.length === 0) {
349
+ if (mode !== "human") {
350
+ printResult({ data: [], meta: { count: 0 } }, opts);
351
+ } else {
352
+ console.log(styleText3("dim", " No absences found."));
353
+ }
354
+ return;
355
+ }
356
+ if (mode !== "human") {
357
+ printResult({ data: absenceList, meta: { count: absenceList.length } }, opts);
358
+ return;
359
+ }
360
+ const rows = absenceList.map((a) => [
361
+ String(a.id),
362
+ String(a.usersId),
363
+ a.dateSince,
364
+ a.dateUntil,
365
+ formatAbsenceType(a.type),
366
+ formatAbsenceStatus(a.status),
367
+ formatDaysOrHours(a)
368
+ ]);
369
+ printTable(["ID", "User", "Since", "Until", "Type", "Status", "Days/Hours"], rows, opts);
370
+ });
371
+ absences.command("get <id>").description("Get details of a specific absence").action(async (id) => {
372
+ const opts = program2.opts();
373
+ const client = getClient();
374
+ const result = await client.getAbsence({ id: parseId(id) });
375
+ const absence = result.data;
376
+ const mode = resolveOutputMode(opts);
377
+ if (mode !== "human") {
378
+ printResult({ data: absence }, opts);
379
+ return;
380
+ }
381
+ printDetail(
382
+ [
383
+ ["ID", absence.id],
384
+ ["User ID", absence.usersId],
385
+ ["Since", absence.dateSince],
386
+ ["Until", absence.dateUntil],
387
+ ["Type", formatAbsenceType(absence.type)],
388
+ ["Status", formatAbsenceStatus(absence.status)],
389
+ ["Days/Hours", formatDaysOrHours(absence)],
390
+ ["Note", absence.note ?? null],
391
+ ["Public Note", absence.publicNote ?? null],
392
+ ["Date Enquired", absence.dateEnquired ?? null],
393
+ ["Date Approved", absence.dateApproved ?? null]
394
+ ],
395
+ opts
396
+ );
397
+ });
398
+ absences.command("create").description("Create an absence").requiredOption("--since <date>", "Start date (YYYY-MM-DD)").requiredOption("--type <type>", "Absence type (number)", parseIntStrict).option("--until <date>", "End date (YYYY-MM-DD)").option("--half-day", "Half-day absence").option("--sick-note", "Has sick note").option("--note <text>", "Private note").option("--public-note <text>", "Public note").action(async (cmdOpts) => {
399
+ const opts = program2.opts();
400
+ const client = getClient();
401
+ const params = {
402
+ dateSince: cmdOpts.since,
403
+ type: cmdOpts.type,
404
+ ...cmdOpts.until && { dateUntil: cmdOpts.until },
405
+ ...cmdOpts.halfDay && { halfDay: true },
406
+ ...cmdOpts.sickNote && { sickNote: true },
407
+ ...cmdOpts.note != null && { note: cmdOpts.note },
408
+ ...cmdOpts.publicNote != null && { publicNote: cmdOpts.publicNote }
409
+ };
410
+ const result = await client.addAbsence(params);
411
+ const mode = resolveOutputMode(opts);
412
+ if (mode !== "human") {
413
+ printResult({ data: result.data }, opts);
414
+ return;
415
+ }
416
+ printSuccess(`Absence created (ID: ${result.data.id})`);
417
+ console.log(
418
+ ` ${result.data.dateSince} \u2014 ${result.data.dateUntil ?? result.data.dateSince} (${formatAbsenceType(result.data.type)})`
419
+ );
420
+ });
421
+ absences.command("update <id>").description("Update an absence").option("--since <date>", "New start date (YYYY-MM-DD)").option("--until <date>", "New end date (YYYY-MM-DD)").option("--type <type>", "New absence type (number)", parseIntStrict).option("--half-day", "Half-day absence").option("--no-half-day", "Not half-day").option("--sick-note", "Has sick note").option("--no-sick-note", "No sick note").option("--note <text>", "New private note").option("--public-note <text>", "New public note").action(async (id, cmdOpts) => {
422
+ const opts = program2.opts();
423
+ const client = getClient();
424
+ const params = { id: parseId(id) };
425
+ if (cmdOpts.since) params.dateSince = cmdOpts.since;
426
+ if (cmdOpts.until) params.dateUntil = cmdOpts.until;
427
+ if (cmdOpts.type != null) params.type = cmdOpts.type;
428
+ if (cmdOpts.halfDay !== void 0) params.halfDay = cmdOpts.halfDay;
429
+ if (cmdOpts.sickNote !== void 0) params.sickNote = cmdOpts.sickNote;
430
+ if (cmdOpts.note != null) params.note = cmdOpts.note;
431
+ if (cmdOpts.publicNote != null) params.publicNote = cmdOpts.publicNote;
432
+ const result = await client.editAbsence(params);
433
+ const mode = resolveOutputMode(opts);
434
+ if (mode !== "human") {
435
+ printResult({ data: result.data }, opts);
436
+ return;
437
+ }
438
+ printSuccess(`Absence ${id} updated`);
439
+ });
440
+ absences.command("delete <id>").description("Delete an absence").option("-f, --force", "Skip confirmation").action(async (id, cmdOpts) => {
441
+ const opts = program2.opts();
442
+ const client = getClient();
443
+ if (!cmdOpts.force && process.stdout.isTTY) {
444
+ const confirm3 = await p.confirm({
445
+ message: `Delete absence ${id}?`
446
+ });
447
+ if (!confirm3 || p.isCancel(confirm3)) return;
448
+ }
449
+ const absenceId = parseId(id);
450
+ await client.deleteAbsence({ id: absenceId });
451
+ const mode = resolveOutputMode(opts);
452
+ if (mode !== "human") {
453
+ printResult({ data: { success: true, id: absenceId } }, opts);
454
+ return;
455
+ }
456
+ printSuccess(`Absence ${id} deleted`);
457
+ });
458
+ }
459
+
460
+ // src/commands/clock.ts
461
+ import { Billability } from "clockodo";
462
+
463
+ // src/lib/prompts.ts
464
+ import * as p2 from "@clack/prompts";
465
+ function shouldPrompt(opts, mode) {
466
+ if (opts.noInput) return false;
467
+ if (mode !== "human") return false;
468
+ if (!process.stdout.isTTY) return false;
469
+ return true;
470
+ }
471
+ async function selectCustomerProjectService() {
472
+ const client = getClient();
473
+ const customersResult = await client.getCustomers({ filter: { active: true } });
474
+ const customers = customersResult.data ?? [];
475
+ const defaultCustomerId = getConfigValue("defaultCustomerId");
476
+ const customersId = await p2.select({
477
+ message: "Select a customer",
478
+ options: customers.map((c) => ({ value: c.id, label: c.name })),
479
+ ...defaultCustomerId && { initialValue: defaultCustomerId }
480
+ });
481
+ if (p2.isCancel(customersId)) {
482
+ return null;
483
+ }
484
+ const projectsResult = await client.getProjects({
485
+ filter: { customersId, active: true }
486
+ });
487
+ const projects = projectsResult.data ?? [];
488
+ const defaultProjectId = getConfigValue("defaultProjectId");
489
+ const projectOptions = [
490
+ { value: 0, label: "(no project)" },
491
+ ...projects.map((proj) => ({ value: proj.id, label: proj.name }))
492
+ ];
493
+ const projectsId = await p2.select({
494
+ message: "Select a project",
495
+ options: projectOptions,
496
+ ...defaultProjectId && { initialValue: defaultProjectId }
497
+ });
498
+ if (p2.isCancel(projectsId)) {
499
+ return null;
500
+ }
501
+ const servicesResult = await client.getServices({ filter: { active: true } });
502
+ const services = servicesResult.data ?? [];
503
+ const defaultServiceId = getConfigValue("defaultServiceId");
504
+ const servicesId = await p2.select({
505
+ message: "Select a service",
506
+ options: services.map((s) => ({ value: s.id, label: s.name })),
507
+ ...defaultServiceId && { initialValue: defaultServiceId }
508
+ });
509
+ if (p2.isCancel(servicesId)) {
510
+ return null;
511
+ }
512
+ return {
513
+ customersId,
514
+ ...projectsId !== 0 && { projectsId },
515
+ servicesId
516
+ };
517
+ }
518
+
519
+ // src/lib/time.ts
520
+ import {
521
+ addDays as _addDays,
522
+ endOfDay as _endOfDay,
523
+ endOfMonth as _endOfMonth,
524
+ endOfWeek as _endOfWeek,
525
+ startOfDay as _startOfDay,
526
+ startOfMonth as _startOfMonth,
527
+ startOfWeek as _startOfWeek,
528
+ differenceInSeconds,
529
+ format
530
+ } from "date-fns";
531
+ var startOfDay = _startOfDay;
532
+ var endOfDay = _endOfDay;
533
+ var startOfMonth = _startOfMonth;
534
+ var endOfMonth = _endOfMonth;
535
+ var addDays = _addDays;
536
+ function startOfWeek(date) {
537
+ return _startOfWeek(date, { weekStartsOn: 1 });
538
+ }
539
+ function endOfWeek(date) {
540
+ return _endOfWeek(date, { weekStartsOn: 1 });
541
+ }
542
+ function formatDate(date) {
543
+ return format(date, "yyyy-MM-dd");
544
+ }
545
+ function formatTime(date) {
546
+ const d = typeof date === "string" ? new Date(date) : date;
547
+ return format(d, "HH:mm");
548
+ }
549
+ function elapsedSince(isoString) {
550
+ return differenceInSeconds(/* @__PURE__ */ new Date(), new Date(isoString));
551
+ }
552
+ function formatDuration(seconds) {
553
+ const hours = Math.floor(seconds / 3600);
554
+ const minutes = Math.floor(seconds % 3600 / 60);
555
+ if (hours === 0) return `${minutes}m`;
556
+ if (minutes === 0) return `${hours}h`;
557
+ return `${hours}h ${minutes}m`;
558
+ }
559
+ function formatDecimalHours(seconds) {
560
+ return `${(seconds / 3600).toFixed(1)}h`;
561
+ }
562
+ function toClockodoDateTime(date) {
563
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
564
+ }
565
+ function parseDateTime(input) {
566
+ const now = /* @__PURE__ */ new Date();
567
+ switch (input.toLowerCase()) {
568
+ case "today":
569
+ return toClockodoDateTime(startOfDay(now));
570
+ case "yesterday":
571
+ return toClockodoDateTime(startOfDay(addDays(now, -1)));
572
+ case "tomorrow":
573
+ return toClockodoDateTime(startOfDay(addDays(now, 1)));
574
+ }
575
+ if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
576
+ return toClockodoDateTime(/* @__PURE__ */ new Date(`${input}T00:00:00`));
577
+ }
578
+ if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}$/.test(input)) {
579
+ return toClockodoDateTime(/* @__PURE__ */ new Date(`${input.replace(" ", "T")}:00`));
580
+ }
581
+ if (/^\d{2}:\d{2}$/.test(input)) {
582
+ const parts = input.split(":").map(Number);
583
+ const hours = parts[0] ?? 0;
584
+ const minutes = parts[1] ?? 0;
585
+ const date = /* @__PURE__ */ new Date();
586
+ date.setHours(hours, minutes, 0, 0);
587
+ return toClockodoDateTime(date);
588
+ }
589
+ const parsed = new Date(input);
590
+ if (Number.isNaN(parsed.getTime())) {
591
+ throw new Error(`Cannot parse date: "${input}"`);
592
+ }
593
+ return toClockodoDateTime(parsed);
594
+ }
595
+
596
+ // src/commands/clock.ts
597
+ function registerClockCommands(program2) {
598
+ program2.command("start").description("Start time tracking").option("-c, --customer <id>", "Customer ID", parseIntStrict).option("-p, --project <id>", "Project ID", parseIntStrict).option("-s, --service <id>", "Service ID", parseIntStrict).option("-t, --text <description>", "Entry description").option("-b, --billable", "Mark as billable").action(async (cmdOpts) => {
599
+ const opts = program2.opts();
600
+ const mode = resolveOutputMode(opts);
601
+ const client = getClient();
602
+ let customersId = cmdOpts.customer ?? getConfigValue("defaultCustomerId");
603
+ let servicesId = cmdOpts.service ?? getConfigValue("defaultServiceId");
604
+ let projectsId = cmdOpts.project ?? getConfigValue("defaultProjectId");
605
+ if ((!customersId || !servicesId) && shouldPrompt(opts, mode)) {
606
+ const selection = await selectCustomerProjectService();
607
+ if (!selection) return;
608
+ customersId ??= selection.customersId;
609
+ servicesId ??= selection.servicesId;
610
+ projectsId ??= selection.projectsId;
611
+ }
612
+ if (!customersId || !servicesId) {
613
+ throw new CliError(
614
+ "Customer ID and Service ID are required to start tracking.",
615
+ ExitCode.INVALID_ARGS,
616
+ "Use --customer and --service flags, or set defaults with: clockodo config set"
617
+ );
618
+ }
619
+ const result = await client.startClock({
620
+ customersId,
621
+ servicesId,
622
+ ...projectsId && { projectsId },
623
+ ...cmdOpts.text && { text: cmdOpts.text },
624
+ ...cmdOpts.billable && { billable: Billability.Billable }
625
+ });
626
+ if (mode !== "human") {
627
+ printResult({ data: result.running }, opts);
628
+ return;
629
+ }
630
+ printSuccess("Clock started");
631
+ if (result.running.text) {
632
+ console.log(` Description: ${result.running.text}`);
633
+ }
634
+ console.log(` Started at: ${formatTime(result.running.timeSince)}`);
635
+ });
636
+ program2.command("stop").description("Stop time tracking").action(async () => {
637
+ const opts = program2.opts();
638
+ const client = getClient();
639
+ const clock = await client.getClock();
640
+ if (!clock.running) {
641
+ throw new CliError("No clock is currently running.", ExitCode.EMPTY_RESULTS);
642
+ }
643
+ const result = await client.stopClock({ entriesId: clock.running.id });
644
+ const mode = resolveOutputMode(opts);
645
+ if (mode !== "human") {
646
+ printResult({ data: result.stopped }, opts);
647
+ return;
648
+ }
649
+ const duration = result.stopped.duration ?? elapsedSince(result.stopped.timeSince);
650
+ printSuccess(`Clock stopped (${formatDuration(duration)})`);
651
+ if (result.stopped.text) {
652
+ console.log(` Description: ${result.stopped.text}`);
653
+ }
654
+ });
655
+ }
656
+
657
+ // src/commands/config.ts
658
+ import * as p3 from "@clack/prompts";
659
+ function registerConfigCommands(program2) {
660
+ const config = program2.command("config").description("Manage CLI configuration");
661
+ config.command("set").description("Configure API credentials interactively").action(async () => {
662
+ p3.intro("Clockodo CLI Configuration");
663
+ const email = await p3.text({
664
+ message: "Clockodo email address:",
665
+ placeholder: "you@example.com",
666
+ initialValue: getConfigValue("email") ?? "",
667
+ validate: (v) => !v || !v.includes("@") ? "Must be a valid email" : void 0
668
+ });
669
+ if (p3.isCancel(email)) return process.exit(0);
670
+ const apiKey = await p3.password({
671
+ message: "Clockodo API key:",
672
+ validate: (v) => !v || v.length < 5 ? "API key seems too short" : void 0
673
+ });
674
+ if (p3.isCancel(apiKey)) return process.exit(0);
675
+ const timezone = await p3.text({
676
+ message: "Default timezone:",
677
+ placeholder: "Europe/Berlin",
678
+ initialValue: getConfigValue("timezone") ?? "Europe/Berlin"
679
+ });
680
+ if (p3.isCancel(timezone)) return process.exit(0);
681
+ setConfigValue("email", email);
682
+ setConfigValue("apiKey", apiKey);
683
+ setConfigValue("timezone", timezone);
684
+ p3.outro(`Configuration saved to ${getConfigPath()}`);
685
+ });
686
+ config.command("show").description("Show current configuration (secrets masked)").action(() => {
687
+ const opts = program2.opts();
688
+ const cfg = getConfig();
689
+ const mode = resolveOutputMode(opts);
690
+ if (mode !== "human") {
691
+ const safeCfg = { ...cfg };
692
+ if (safeCfg.apiKey) safeCfg.apiKey = maskSecret(safeCfg.apiKey);
693
+ printResult({ data: safeCfg }, opts);
694
+ return;
695
+ }
696
+ printDetail(
697
+ [
698
+ ["Email", cfg.email ?? null],
699
+ ["API Key", cfg.apiKey ? maskSecret(cfg.apiKey) : null],
700
+ ["Timezone", cfg.timezone ?? null],
701
+ ["Default Customer ID", cfg.defaultCustomerId ?? null],
702
+ ["Default Service ID", cfg.defaultServiceId ?? null],
703
+ ["Default Project ID", cfg.defaultProjectId ?? null],
704
+ ["Config Path", getConfigPath()]
705
+ ],
706
+ opts
707
+ );
708
+ });
709
+ config.command("path").description("Print config file path").action(() => {
710
+ console.log(getConfigPath());
711
+ });
712
+ }
713
+
714
+ // src/commands/customers.ts
715
+ function registerCustomersCommands(program2) {
716
+ const customers = program2.command("customers").description("Manage customers");
717
+ customers.command("list", { isDefault: true }).description("List customers").option("--active", "Show only active customers").option("--search <text>", "Search by name").action(async (cmdOpts) => {
718
+ const opts = program2.opts();
719
+ const client = getClient();
720
+ const filter = {};
721
+ if (cmdOpts.active) filter.active = true;
722
+ if (cmdOpts.search) filter.fulltext = cmdOpts.search;
723
+ const result = await client.getCustomers(
724
+ Object.keys(filter).length > 0 ? { filter } : void 0
725
+ );
726
+ const items = result.data ?? [];
727
+ const mode = resolveOutputMode(opts);
728
+ if (mode !== "human") {
729
+ printResult({ data: items, meta: { count: items.length } }, opts);
730
+ return;
731
+ }
732
+ const rows = items.map((c) => [
733
+ String(c.id),
734
+ c.name,
735
+ c.number ?? "\u2014",
736
+ c.active ? "Yes" : "No"
737
+ ]);
738
+ printTable(["ID", "Name", "Number", "Active"], rows, opts);
739
+ });
740
+ customers.command("get <id>").description("Get customer details").action(async (id) => {
741
+ const opts = program2.opts();
742
+ const client = getClient();
743
+ const result = await client.getCustomer({ id: parseId(id) });
744
+ const c = result.data;
745
+ const mode = resolveOutputMode(opts);
746
+ if (mode !== "human") {
747
+ printResult({ data: c }, opts);
748
+ return;
749
+ }
750
+ printDetail(
751
+ [
752
+ ["ID", c.id],
753
+ ["Name", c.name],
754
+ ["Number", c.number ?? null],
755
+ ["Active", c.active],
756
+ ["Billable Default", c.billableDefault],
757
+ ["Note", c.note ?? null]
758
+ ],
759
+ opts
760
+ );
761
+ });
762
+ }
763
+
764
+ // src/commands/entries.ts
765
+ import { styleText as styleText4 } from "util";
766
+ import * as p4 from "@clack/prompts";
767
+ import { getEntryDurationUntilNow, isTimeEntry } from "clockodo";
768
+ function registerEntriesCommands(program2) {
769
+ const entries = program2.command("entries").description("Manage time entries");
770
+ entries.command("list", { isDefault: true }).description("List time entries").option("--since <date>", "Start date (default: today)", "today").option("--until <date>", "End date (default: today)").option("--customer <id>", "Filter by customer ID", parseIntStrict).option("--project <id>", "Filter by project ID", parseIntStrict).option("--service <id>", "Filter by service ID", parseIntStrict).option("--text <search>", "Filter by description text").option(
771
+ "-g, --group <field>",
772
+ "Group by: customer, project, service, text (shows summary table instead)"
773
+ ).action(async (cmdOpts) => {
774
+ const opts = program2.opts();
775
+ const client = getClient();
776
+ const since = parseDateTime(cmdOpts.since);
777
+ const until = cmdOpts.until ? parseDateTime(cmdOpts.until) : toClockodoDateTime(endOfDay(/* @__PURE__ */ new Date()));
778
+ const filter = {};
779
+ if (cmdOpts.customer) filter.customersId = cmdOpts.customer;
780
+ if (cmdOpts.project) filter.projectsId = cmdOpts.project;
781
+ if (cmdOpts.service) filter.servicesId = cmdOpts.service;
782
+ if (cmdOpts.text) filter.text = cmdOpts.text;
783
+ const result = await client.getEntries({
784
+ timeSince: since,
785
+ timeUntil: until,
786
+ ...Object.keys(filter).length > 0 && { filter }
787
+ });
788
+ const entryList = result.entries ?? [];
789
+ const mode = resolveOutputMode(opts);
790
+ if (entryList.length === 0) {
791
+ if (mode !== "human") {
792
+ printResult({ data: [], meta: { count: 0, totalSeconds: 0 } }, opts);
793
+ } else {
794
+ console.log(styleText4("dim", " No entries found for the given period."));
795
+ }
796
+ return;
797
+ }
798
+ const totalSeconds = entryList.reduce((sum, e) => sum + getEntryDurationUntilNow(e), 0);
799
+ if (cmdOpts.group) {
800
+ const groupKey = resolveGroupKey(cmdOpts.group);
801
+ const groups = groupEntries(entryList, groupKey);
802
+ if (mode !== "human") {
803
+ printResult(
804
+ {
805
+ data: {
806
+ groups,
807
+ total: { seconds: totalSeconds, formatted: formatDuration(totalSeconds) }
808
+ },
809
+ meta: { count: entryList.length }
810
+ },
811
+ opts
812
+ );
813
+ return;
814
+ }
815
+ const rows2 = groups.map((g) => [
816
+ g.key,
817
+ String(g.count),
818
+ formatDuration(g.seconds),
819
+ formatDecimalHours(g.seconds)
820
+ ]);
821
+ printTable(["Group", "Entries", "Duration", "Hours"], rows2, opts);
822
+ console.log();
823
+ console.log(
824
+ ` ${styleText4("bold", "Total")}: ${formatDuration(totalSeconds)} (${formatDecimalHours(totalSeconds)}) across ${entryList.length} entries`
825
+ );
826
+ console.log();
827
+ return;
828
+ }
829
+ if (mode !== "human") {
830
+ printResult(
831
+ {
832
+ data: entryList,
833
+ meta: { count: entryList.length, totalSeconds }
834
+ },
835
+ opts
836
+ );
837
+ return;
838
+ }
839
+ const rows = entryList.map((e) => [
840
+ String(e.id),
841
+ formatDate(new Date(e.timeSince)),
842
+ formatTime(e.timeSince),
843
+ isTimeEntry(e) && !e.timeUntil ? styleText4("green", "running") : formatTime(e.timeUntil ?? e.timeSince),
844
+ formatDuration(getEntryDurationUntilNow(e)),
845
+ e.text || styleText4("dim", "\u2014")
846
+ ]);
847
+ printTable(["ID", "Date", "Start", "End", "Duration", "Description"], rows, opts);
848
+ console.log();
849
+ console.log(
850
+ ` ${styleText4("bold", "Total")}: ${formatDuration(totalSeconds)} (${formatDecimalHours(totalSeconds)}) across ${entryList.length} entries`
851
+ );
852
+ console.log();
853
+ });
854
+ entries.command("get <id>").description("Get details of a specific entry").action(async (id) => {
855
+ const opts = program2.opts();
856
+ const client = getClient();
857
+ const result = await client.getEntry({ id: parseId(id) });
858
+ const e = result.entry;
859
+ const mode = resolveOutputMode(opts);
860
+ if (mode !== "human") {
861
+ printResult({ data: e }, opts);
862
+ return;
863
+ }
864
+ const timeUntilDisplay = isTimeEntry(e) && !e.timeUntil ? "running" : formatTime(e.timeUntil ?? e.timeSince);
865
+ const duration = getEntryDurationUntilNow(e);
866
+ printDetail(
867
+ [
868
+ ["ID", e.id],
869
+ ["Date", formatDate(new Date(e.timeSince))],
870
+ ["Start", formatTime(e.timeSince)],
871
+ ["End", timeUntilDisplay],
872
+ ["Duration", formatDuration(duration)],
873
+ ["Description", e.text ?? null],
874
+ ["Customer ID", e.customersId],
875
+ ["Project ID", e.projectsId ?? null],
876
+ ["Service ID", isTimeEntry(e) ? e.servicesId : null],
877
+ ["Billable", e.billable === 1 ? "Yes" : e.billable === 2 ? "Billed" : "No"]
878
+ ],
879
+ opts
880
+ );
881
+ });
882
+ entries.command("create").description("Create a time entry").requiredOption("--from <datetime>", "Start time (e.g., '2024-01-15 09:00' or '09:00')").requiredOption("--to <datetime>", "End time (e.g., '2024-01-15 17:00' or '17:00')").option("-c, --customer <id>", "Customer ID", parseIntStrict).option("-p, --project <id>", "Project ID", parseIntStrict).option("-s, --service <id>", "Service ID", parseIntStrict).option("-t, --text <description>", "Entry description").option("-b, --billable", "Mark as billable").action(async (cmdOpts) => {
883
+ const opts = program2.opts();
884
+ const mode = resolveOutputMode(opts);
885
+ const client = getClient();
886
+ let customersId = cmdOpts.customer ?? getConfigValue("defaultCustomerId");
887
+ let servicesId = cmdOpts.service ?? getConfigValue("defaultServiceId");
888
+ let projectsId = cmdOpts.project ?? getConfigValue("defaultProjectId");
889
+ if ((!customersId || !servicesId) && shouldPrompt(opts, mode)) {
890
+ const selection = await selectCustomerProjectService();
891
+ if (!selection) return;
892
+ customersId ??= selection.customersId;
893
+ servicesId ??= selection.servicesId;
894
+ projectsId ??= selection.projectsId;
895
+ }
896
+ if (!customersId || !servicesId) {
897
+ throw new CliError(
898
+ "Customer ID and Service ID are required.",
899
+ ExitCode.INVALID_ARGS,
900
+ "Use --customer and --service flags, or set defaults via: clockodo config set"
901
+ );
902
+ }
903
+ const result = await client.addEntry({
904
+ customersId,
905
+ servicesId,
906
+ billable: cmdOpts.billable ? 1 : 0,
907
+ timeSince: parseDateTime(cmdOpts.from),
908
+ timeUntil: parseDateTime(cmdOpts.to),
909
+ ...projectsId && { projectsId },
910
+ ...cmdOpts.text && { text: cmdOpts.text }
911
+ });
912
+ if (mode !== "human") {
913
+ printResult({ data: result.entry }, opts);
914
+ return;
915
+ }
916
+ const entry = result.entry;
917
+ const duration = getEntryDurationUntilNow(entry);
918
+ printSuccess(`Entry created (ID: ${entry.id})`);
919
+ console.log(
920
+ ` ${formatTime(entry.timeSince)} \u2014 ${entry.timeUntil ? formatTime(entry.timeUntil) : "?"} (${formatDuration(duration)})`
921
+ );
922
+ });
923
+ entries.command("update <id>").description("Update a time entry").option("--from <datetime>", "New start time").option("--to <datetime>", "New end time").option("-t, --text <description>", "New description").option("-b, --billable", "Mark as billable").option("--no-billable", "Mark as not billable").action(async (id, cmdOpts) => {
924
+ const opts = program2.opts();
925
+ const client = getClient();
926
+ const updates = { id: parseId(id) };
927
+ if (cmdOpts.from) updates.timeSince = parseDateTime(cmdOpts.from);
928
+ if (cmdOpts.to) updates.timeUntil = parseDateTime(cmdOpts.to);
929
+ if (cmdOpts.text !== void 0) updates.text = cmdOpts.text;
930
+ if (cmdOpts.billable !== void 0) updates.billable = cmdOpts.billable ? 1 : 0;
931
+ const result = await client.editEntry(updates);
932
+ const mode = resolveOutputMode(opts);
933
+ if (mode !== "human") {
934
+ printResult({ data: result.entry }, opts);
935
+ return;
936
+ }
937
+ printSuccess(`Entry ${id} updated`);
938
+ });
939
+ entries.command("delete <id>").description("Delete a time entry").option("-f, --force", "Skip confirmation").action(async (id, cmdOpts) => {
940
+ const opts = program2.opts();
941
+ const client = getClient();
942
+ if (!cmdOpts.force && process.stdout.isTTY) {
943
+ const confirm3 = await p4.confirm({
944
+ message: `Delete entry ${id}?`
945
+ });
946
+ if (!confirm3 || p4.isCancel(confirm3)) return;
947
+ }
948
+ const entryId = parseId(id);
949
+ await client.deleteEntry({ id: entryId });
950
+ const mode = resolveOutputMode(opts);
951
+ if (mode !== "human") {
952
+ printResult({ data: { success: true, id: entryId } }, opts);
953
+ return;
954
+ }
955
+ printSuccess(`Entry ${id} deleted`);
956
+ });
957
+ }
958
+ function resolveGroupKey(input) {
959
+ const aliases = {
960
+ customer: "customersId",
961
+ customers: "customersId",
962
+ customers_id: "customersId",
963
+ project: "projectsId",
964
+ projects: "projectsId",
965
+ projects_id: "projectsId",
966
+ service: "servicesId",
967
+ services: "servicesId",
968
+ services_id: "servicesId",
969
+ text: "text",
970
+ description: "text"
971
+ };
972
+ const resolved = aliases[input.toLowerCase()];
973
+ if (!resolved) {
974
+ throw new CliError(
975
+ `Unknown group field: "${input}". Valid options: customer, project, service, text`,
976
+ ExitCode.INVALID_ARGS
977
+ );
978
+ }
979
+ return resolved;
980
+ }
981
+ function groupEntries(entries, key) {
982
+ const map = /* @__PURE__ */ new Map();
983
+ for (const e of entries) {
984
+ let groupValue;
985
+ if (key === "text") {
986
+ groupValue = e.text || "(no description)";
987
+ } else {
988
+ const val = e[key];
989
+ groupValue = val != null ? String(val) : "(none)";
990
+ }
991
+ const existing = map.get(groupValue);
992
+ const duration = getEntryDurationUntilNow(e);
993
+ if (existing) {
994
+ existing.count++;
995
+ existing.seconds += duration;
996
+ } else {
997
+ map.set(groupValue, { count: 1, seconds: duration });
998
+ }
999
+ }
1000
+ return [...map.entries()].map(([k, v]) => ({ key: k, ...v })).sort((a, b) => b.seconds - a.seconds);
1001
+ }
1002
+
1003
+ // src/commands/projects.ts
1004
+ function registerProjectsCommands(program2) {
1005
+ const projects = program2.command("projects").description("Manage projects");
1006
+ projects.command("list", { isDefault: true }).description("List projects").option("--customer <id>", "Filter by customer ID", parseIntStrict).option("--active", "Show only active projects").option("--search <text>", "Search by name").action(async (cmdOpts) => {
1007
+ const opts = program2.opts();
1008
+ const client = getClient();
1009
+ const filter = {};
1010
+ if (cmdOpts.customer) filter.customersId = cmdOpts.customer;
1011
+ if (cmdOpts.active) filter.active = true;
1012
+ if (cmdOpts.search) filter.fulltext = cmdOpts.search;
1013
+ const result = await client.getProjects(
1014
+ Object.keys(filter).length > 0 ? { filter } : void 0
1015
+ );
1016
+ const items = result.data ?? [];
1017
+ const mode = resolveOutputMode(opts);
1018
+ if (mode !== "human") {
1019
+ printResult({ data: items, meta: { count: items.length } }, opts);
1020
+ return;
1021
+ }
1022
+ const rows = items.map((p5) => [
1023
+ String(p5.id),
1024
+ p5.name,
1025
+ p5.number ?? "\u2014",
1026
+ String(p5.customersId),
1027
+ p5.active ? "Yes" : "No",
1028
+ p5.completed ? "Yes" : "No"
1029
+ ]);
1030
+ printTable(["ID", "Name", "Number", "Customer", "Active", "Completed"], rows, opts);
1031
+ });
1032
+ projects.command("get <id>").description("Get project details").action(async (id) => {
1033
+ const opts = program2.opts();
1034
+ const client = getClient();
1035
+ const result = await client.getProject({ id: parseId(id) });
1036
+ const p5 = result.data;
1037
+ const mode = resolveOutputMode(opts);
1038
+ if (mode !== "human") {
1039
+ printResult({ data: p5 }, opts);
1040
+ return;
1041
+ }
1042
+ printDetail(
1043
+ [
1044
+ ["ID", p5.id],
1045
+ ["Name", p5.name],
1046
+ ["Number", p5.number ?? null],
1047
+ ["Customer ID", p5.customersId],
1048
+ ["Active", p5.active],
1049
+ ["Completed", p5.completed],
1050
+ ["Budget", p5.budget?.amount ?? null],
1051
+ ["Budget Type", p5.budget?.monetary ? "Money" : "Hours"],
1052
+ ["Note", p5.note ?? null]
1053
+ ],
1054
+ opts
1055
+ );
1056
+ });
1057
+ }
1058
+
1059
+ // src/commands/report.ts
1060
+ import { styleText as styleText5 } from "util";
1061
+ async function runReport(program2, since, until, cmdOpts) {
1062
+ const opts = program2.opts();
1063
+ const client = getClient();
1064
+ const grouping = [cmdOpts.group ?? "projects_id"];
1065
+ const result = await client.getEntryGroups({
1066
+ timeSince: toClockodoDateTime(since),
1067
+ timeUntil: toClockodoDateTime(until),
1068
+ grouping
1069
+ });
1070
+ const groups = result.groups ?? [];
1071
+ const totalSeconds = groups.reduce((sum, g) => sum + (g.duration ?? 0), 0);
1072
+ const mode = resolveOutputMode(opts);
1073
+ if (mode !== "human") {
1074
+ printResult(
1075
+ {
1076
+ data: {
1077
+ period: { since: formatDate(since), until: formatDate(until) },
1078
+ groups,
1079
+ total: { seconds: totalSeconds, formatted: formatDuration(totalSeconds) }
1080
+ }
1081
+ },
1082
+ opts
1083
+ );
1084
+ return;
1085
+ }
1086
+ console.log();
1087
+ console.log(` ${styleText5("bold", "Report")}: ${formatDate(since)} \u2014 ${formatDate(until)}`);
1088
+ console.log();
1089
+ if (groups.length === 0) {
1090
+ console.log(styleText5("dim", " No entries found for this period."));
1091
+ return;
1092
+ }
1093
+ const rows = groups.map((g) => [
1094
+ g.name || g.group || "Unknown",
1095
+ formatDuration(g.duration ?? 0),
1096
+ formatDecimalHours(g.duration ?? 0)
1097
+ ]);
1098
+ printTable(["Name", "Duration", "Hours"], rows, opts);
1099
+ console.log();
1100
+ console.log(
1101
+ ` ${styleText5("bold", "Total")}: ${formatDuration(totalSeconds)} (${formatDecimalHours(totalSeconds)})`
1102
+ );
1103
+ console.log();
1104
+ }
1105
+ function registerReportCommands(program2) {
1106
+ const report = program2.command("report").description("Aggregated time reports");
1107
+ report.command("today", { isDefault: true }).description("Today's summary").option(
1108
+ "-g, --group <field>",
1109
+ "Group by: projects_id, customers_id, services_id",
1110
+ "projects_id"
1111
+ ).action(async (cmdOpts) => {
1112
+ const now = /* @__PURE__ */ new Date();
1113
+ await runReport(program2, startOfDay(now), endOfDay(now), cmdOpts);
1114
+ });
1115
+ report.command("week").description("This week's summary (Mon-Sun)").option("-g, --group <field>", "Group by field", "projects_id").action(async (cmdOpts) => {
1116
+ const now = /* @__PURE__ */ new Date();
1117
+ await runReport(program2, startOfWeek(now), endOfWeek(now), cmdOpts);
1118
+ });
1119
+ report.command("month").description("This month's summary").option("-g, --group <field>", "Group by field", "projects_id").action(async (cmdOpts) => {
1120
+ const now = /* @__PURE__ */ new Date();
1121
+ await runReport(program2, startOfMonth(now), endOfMonth(now), cmdOpts);
1122
+ });
1123
+ report.command("custom").description("Custom date range report").requiredOption("--since <date>", "Start date").requiredOption("--until <date>", "End date").option("-g, --group <field>", "Group by field", "projects_id").action(async (cmdOpts) => {
1124
+ const since = new Date(parseDateTime(cmdOpts.since));
1125
+ const until = new Date(parseDateTime(cmdOpts.until));
1126
+ await runReport(program2, since, until, cmdOpts);
1127
+ });
1128
+ }
1129
+
1130
+ // src/commands/schema.ts
1131
+ function commandToSchema(cmd) {
1132
+ const node = {
1133
+ name: cmd.name(),
1134
+ description: cmd.description()
1135
+ };
1136
+ const options = cmd.options.map((opt) => ({
1137
+ flags: opt.flags,
1138
+ description: opt.description,
1139
+ required: opt.required,
1140
+ ...opt.defaultValue !== void 0 && { defaultValue: opt.defaultValue }
1141
+ }));
1142
+ if (options.length > 0) {
1143
+ node.options = options;
1144
+ }
1145
+ const subcommands = cmd.commands.map(commandToSchema);
1146
+ if (subcommands.length > 0) {
1147
+ node.subcommands = subcommands;
1148
+ }
1149
+ return node;
1150
+ }
1151
+ function registerSchemaCommand(program2) {
1152
+ program2.command("schema").description("Output machine-readable CLI structure (for AI agents)").action(() => {
1153
+ const schema2 = {
1154
+ schemaVersion: 1,
1155
+ cli: commandToSchema(program2)
1156
+ };
1157
+ console.log(JSON.stringify(schema2, null, 2));
1158
+ });
1159
+ }
1160
+
1161
+ // src/commands/services.ts
1162
+ function registerServicesCommands(program2) {
1163
+ const services = program2.command("services").description("Manage services");
1164
+ services.command("list", { isDefault: true }).description("List services").option("--active", "Show only active services").option("--search <text>", "Search by name").action(async (cmdOpts) => {
1165
+ const opts = program2.opts();
1166
+ const client = getClient();
1167
+ const filter = {};
1168
+ if (cmdOpts.active) filter.active = true;
1169
+ if (cmdOpts.search) filter.fulltext = cmdOpts.search;
1170
+ const result = await client.getServices(
1171
+ Object.keys(filter).length > 0 ? { filter } : void 0
1172
+ );
1173
+ const items = result.data ?? [];
1174
+ const mode = resolveOutputMode(opts);
1175
+ if (mode !== "human") {
1176
+ printResult({ data: items, meta: { count: items.length } }, opts);
1177
+ return;
1178
+ }
1179
+ const rows = items.map((s) => [
1180
+ String(s.id),
1181
+ s.name,
1182
+ s.number ?? "\u2014",
1183
+ s.active ? "Yes" : "No"
1184
+ ]);
1185
+ printTable(["ID", "Name", "Number", "Active"], rows, opts);
1186
+ });
1187
+ services.command("get <id>").description("Get service details").action(async (id) => {
1188
+ const opts = program2.opts();
1189
+ const client = getClient();
1190
+ const result = await client.getService({ id: parseId(id) });
1191
+ const s = result.data;
1192
+ const mode = resolveOutputMode(opts);
1193
+ if (mode !== "human") {
1194
+ printResult({ data: s }, opts);
1195
+ return;
1196
+ }
1197
+ printDetail(
1198
+ [
1199
+ ["ID", s.id],
1200
+ ["Name", s.name],
1201
+ ["Number", s.number ?? null],
1202
+ ["Active", s.active],
1203
+ ["Note", s.note ?? null]
1204
+ ],
1205
+ opts
1206
+ );
1207
+ });
1208
+ }
1209
+
1210
+ // src/commands/status.ts
1211
+ import { styleText as styleText6 } from "util";
1212
+ import { isTimeEntry as isTimeEntry2 } from "clockodo";
1213
+ function registerStatusCommand(program2) {
1214
+ program2.command("status").description("Show running clock and today's summary").action(async () => {
1215
+ const opts = program2.opts();
1216
+ const client = getClient();
1217
+ const mode = resolveOutputMode(opts);
1218
+ const now = /* @__PURE__ */ new Date();
1219
+ const [clockResult, entriesResult] = await Promise.all([
1220
+ client.getClock(),
1221
+ client.getEntries({
1222
+ timeSince: toClockodoDateTime(startOfDay(now)),
1223
+ timeUntil: toClockodoDateTime(endOfDay(now))
1224
+ })
1225
+ ]);
1226
+ const running = clockResult.running;
1227
+ const entries = entriesResult.entries ?? [];
1228
+ const totalSeconds = entries.reduce(
1229
+ (sum, e) => sum + (isTimeEntry2(e) ? e.duration ?? 0 : 0),
1230
+ 0
1231
+ );
1232
+ const runningSeconds = running ? elapsedSince(running.timeSince) : 0;
1233
+ if (mode !== "human") {
1234
+ printResult(
1235
+ {
1236
+ data: {
1237
+ running: running ? {
1238
+ id: running.id,
1239
+ text: running.text,
1240
+ customersId: running.customersId,
1241
+ projectsId: running.projectsId,
1242
+ servicesId: running.servicesId,
1243
+ timeSince: running.timeSince,
1244
+ elapsed: runningSeconds
1245
+ } : null,
1246
+ today: {
1247
+ entries: entries.length,
1248
+ totalSeconds: totalSeconds + runningSeconds,
1249
+ totalFormatted: formatDuration(totalSeconds + runningSeconds)
1250
+ }
1251
+ }
1252
+ },
1253
+ opts
1254
+ );
1255
+ return;
1256
+ }
1257
+ console.log();
1258
+ if (running) {
1259
+ const elapsed = formatDuration(runningSeconds);
1260
+ const desc = running.text || styleText6("dim", "(no description)");
1261
+ console.log(
1262
+ ` ${styleText6("green", "\u25CF")} Running: ${styleText6("bold", desc)} (${elapsed})`
1263
+ );
1264
+ console.log(` Started at ${formatTime(running.timeSince)}`);
1265
+ } else {
1266
+ console.log(` ${styleText6("dim", "\u25CB")} No clock running`);
1267
+ }
1268
+ console.log();
1269
+ console.log(
1270
+ ` Today: ${styleText6("bold", formatDuration(totalSeconds + runningSeconds))} tracked across ${entries.length} entries`
1271
+ );
1272
+ console.log();
1273
+ });
1274
+ }
1275
+
1276
+ // src/commands/userreports.ts
1277
+ import { styleText as styleText7 } from "util";
1278
+ import { UserReportType } from "clockodo";
1279
+ var DETAIL_TYPE_MAP = {
1280
+ months: UserReportType.YearAndMonths,
1281
+ weeks: UserReportType.YearMonthsAndWeeks,
1282
+ days: UserReportType.YearMonthsWeeksAndDays
1283
+ };
1284
+ function registerUserReportCommands(program2) {
1285
+ program2.command("userreport").description("Show user report (overtime, holidays, absences) for a year").option("-u, --user <id>", "User ID (defaults to current user)", parseIntStrict).option("-y, --year <year>", "Year to report on (defaults to current year)", parseIntStrict).option("-d, --detail <level>", "Detail level: months, weeks, or days").action(async (cmdOpts) => {
1286
+ const opts = program2.opts();
1287
+ const client = getClient();
1288
+ const usersId = cmdOpts.user ?? (await client.getMe()).data.id;
1289
+ const year = cmdOpts.year ?? (/* @__PURE__ */ new Date()).getFullYear();
1290
+ let type;
1291
+ if (cmdOpts.detail) {
1292
+ type = DETAIL_TYPE_MAP[cmdOpts.detail];
1293
+ if (type === void 0) {
1294
+ throw new CliError(
1295
+ `Unknown detail level: "${cmdOpts.detail}". Valid options: months, weeks, days`,
1296
+ ExitCode.INVALID_ARGS
1297
+ );
1298
+ }
1299
+ }
1300
+ const result = await client.getUserReport({
1301
+ usersId,
1302
+ year,
1303
+ ...type !== void 0 && { type }
1304
+ });
1305
+ const report = result.userreport;
1306
+ const mode = resolveOutputMode(opts);
1307
+ if (mode !== "human") {
1308
+ printResult({ data: report }, opts);
1309
+ return;
1310
+ }
1311
+ const sumTarget = report.sumTarget ?? 0;
1312
+ const holidaysRemaining = report.holidaysQuota + report.holidaysCarry - report.sumAbsence.regularHolidays;
1313
+ console.log();
1314
+ console.log(` ${styleText7("bold", "User Report")}: ${report.usersName} (${year})`);
1315
+ console.log();
1316
+ printDetail(
1317
+ [
1318
+ ["Name", report.usersName],
1319
+ ["Year", year],
1320
+ ["Target hours", formatDuration(sumTarget)],
1321
+ ["Worked hours", formatDuration(report.sumHours)],
1322
+ ["Overtime", formatDuration(report.diff)],
1323
+ [
1324
+ "Holidays",
1325
+ `${holidaysRemaining} remaining (${report.holidaysQuota} quota + ${report.holidaysCarry} carry - ${report.sumAbsence.regularHolidays} used)`
1326
+ ],
1327
+ ["Sick days", report.sumAbsence.sickSelf],
1328
+ ["Home office days", report.sumAbsence.homeOffice]
1329
+ ],
1330
+ opts
1331
+ );
1332
+ console.log();
1333
+ });
1334
+ program2.command("userreports").description("Show user reports for all users in a year").option("-y, --year <year>", "Year to report on (defaults to current year)", parseIntStrict).action(async (cmdOpts) => {
1335
+ const opts = program2.opts();
1336
+ const client = getClient();
1337
+ const year = cmdOpts.year ?? (/* @__PURE__ */ new Date()).getFullYear();
1338
+ const result = await client.getUserReports({ year });
1339
+ const reports = result.userreports ?? [];
1340
+ const mode = resolveOutputMode(opts);
1341
+ if (mode !== "human") {
1342
+ printResult({ data: reports, meta: { count: reports.length } }, opts);
1343
+ return;
1344
+ }
1345
+ console.log();
1346
+ console.log(` ${styleText7("bold", "User Reports")} (${year}): ${reports.length} users`);
1347
+ console.log();
1348
+ if (reports.length === 0) {
1349
+ console.log(styleText7("dim", " No reports found."));
1350
+ return;
1351
+ }
1352
+ const rows = reports.map((r) => {
1353
+ const sumTarget = r.sumTarget ?? 0;
1354
+ const holidaysRemaining = r.holidaysQuota + r.holidaysCarry - r.sumAbsence.regularHolidays;
1355
+ return [
1356
+ r.usersName,
1357
+ formatDuration(sumTarget),
1358
+ formatDuration(r.sumHours),
1359
+ formatDuration(r.diff),
1360
+ String(holidaysRemaining),
1361
+ String(r.sumAbsence.sickSelf)
1362
+ ];
1363
+ });
1364
+ printTable(
1365
+ ["Name", "Target", "Worked", "Overtime", "Holidays Remaining", "Sick Days"],
1366
+ rows,
1367
+ opts
1368
+ );
1369
+ console.log();
1370
+ });
1371
+ }
1372
+
1373
+ // src/commands/users.ts
1374
+ function registerUsersCommands(program2) {
1375
+ const users = program2.command("users").description("User management");
1376
+ users.command("me").description("Show current user info").action(async () => {
1377
+ const opts = program2.opts();
1378
+ const client = getClient();
1379
+ const result = await client.getMe();
1380
+ const u = result.data;
1381
+ const mode = resolveOutputMode(opts);
1382
+ if (mode !== "human") {
1383
+ printResult({ data: u }, opts);
1384
+ return;
1385
+ }
1386
+ printDetail(
1387
+ [
1388
+ ["ID", u.id],
1389
+ ["Name", u.name],
1390
+ ["Email", u.email],
1391
+ ["Role", u.role],
1392
+ ["Active", u.active]
1393
+ ],
1394
+ opts
1395
+ );
1396
+ });
1397
+ users.command("list").description("List all users").action(async () => {
1398
+ const opts = program2.opts();
1399
+ const client = getClient();
1400
+ const result = await client.getUsers();
1401
+ const items = result.data ?? [];
1402
+ const mode = resolveOutputMode(opts);
1403
+ if (mode !== "human") {
1404
+ printResult({ data: items, meta: { count: items.length } }, opts);
1405
+ return;
1406
+ }
1407
+ const rows = items.map((u) => [
1408
+ String(u.id),
1409
+ u.name,
1410
+ u.email,
1411
+ u.role ?? "\u2014",
1412
+ u.active ? "Yes" : "No"
1413
+ ]);
1414
+ printTable(["ID", "Name", "Email", "Role", "Active"], rows, opts);
1415
+ });
1416
+ }
1417
+
1418
+ // src/commands/worktimes.ts
1419
+ import { styleText as styleText8 } from "util";
1420
+ function computeDayStats(day) {
1421
+ if (day.intervals.length === 0) {
1422
+ return {
1423
+ startTime: null,
1424
+ endTime: null,
1425
+ totalSeconds: 0,
1426
+ breakSeconds: 0,
1427
+ intervalCount: 0
1428
+ };
1429
+ }
1430
+ const first = day.intervals[0];
1431
+ const last = day.intervals[day.intervals.length - 1];
1432
+ let totalSeconds = 0;
1433
+ let breakSeconds = 0;
1434
+ let prevEnd = null;
1435
+ for (const interval of day.intervals) {
1436
+ const start = new Date(interval.timeSince).getTime();
1437
+ const end = interval.timeUntil ? new Date(interval.timeUntil).getTime() : Date.now();
1438
+ totalSeconds += (end - start) / 1e3;
1439
+ if (prevEnd !== null) {
1440
+ breakSeconds += (start - prevEnd) / 1e3;
1441
+ }
1442
+ prevEnd = end;
1443
+ }
1444
+ return {
1445
+ startTime: first?.timeSince ?? null,
1446
+ endTime: last?.timeUntil ?? null,
1447
+ totalSeconds,
1448
+ breakSeconds,
1449
+ intervalCount: day.intervals.length
1450
+ };
1451
+ }
1452
+ function registerWorktimesCommands(program2) {
1453
+ program2.command("worktimes").description("Show work time intervals").option("--since <date>", "Start date (default: Monday of current week)").option("--until <date>", "End date (default: Sunday of current week)").option("--user <id>", "Filter by user ID", parseIntStrict).action(async (cmdOpts) => {
1454
+ const opts = program2.opts();
1455
+ const client = getClient();
1456
+ const now = /* @__PURE__ */ new Date();
1457
+ const dateSince = cmdOpts.since ?? formatDate(startOfWeek(now));
1458
+ const dateUntil = cmdOpts.until ?? formatDate(endOfWeek(now));
1459
+ const result = await client.getWorkTimes({
1460
+ dateSince,
1461
+ dateUntil,
1462
+ ...cmdOpts.user && { usersId: cmdOpts.user }
1463
+ });
1464
+ const workTimeDays = result.workTimeDays ?? [];
1465
+ const daysWithStats = workTimeDays.map((day) => ({
1466
+ ...day,
1467
+ stats: computeDayStats(day)
1468
+ }));
1469
+ const totalSeconds = daysWithStats.reduce((sum, d) => sum + d.stats.totalSeconds, 0);
1470
+ const mode = resolveOutputMode(opts);
1471
+ if (mode !== "human") {
1472
+ printResult(
1473
+ {
1474
+ data: daysWithStats,
1475
+ meta: { count: daysWithStats.length, totalSeconds }
1476
+ },
1477
+ opts
1478
+ );
1479
+ return;
1480
+ }
1481
+ console.log();
1482
+ console.log(` ${styleText8("bold", "Work Times")}: ${dateSince} \u2014 ${dateUntil}`);
1483
+ console.log();
1484
+ if (daysWithStats.length === 0) {
1485
+ console.log(styleText8("dim", " No work time data found for this period."));
1486
+ return;
1487
+ }
1488
+ const rows = daysWithStats.map((day) => {
1489
+ const stats = day.stats;
1490
+ return [
1491
+ String(day.date),
1492
+ stats.startTime ? formatTime(stats.startTime) : "-",
1493
+ stats.endTime ? formatTime(stats.endTime) : "-",
1494
+ formatDuration(stats.totalSeconds),
1495
+ stats.breakSeconds > 0 ? formatDuration(stats.breakSeconds) : "-",
1496
+ String(stats.intervalCount)
1497
+ ];
1498
+ });
1499
+ printTable(["Date", "Start", "End", "Hours", "Break", "Intervals"], rows, opts);
1500
+ console.log();
1501
+ console.log(` ${styleText8("bold", "Total")}: ${formatDuration(totalSeconds)}`);
1502
+ console.log();
1503
+ });
1504
+ }
1505
+
1506
+ // src/index.ts
1507
+ var program = new Command();
1508
+ program.name("clockodo").description("AI-friendly CLI for the Clockodo time tracking API").version("0.1.0").option("-j, --json", "Output as JSON").option("-p, --plain", "Output as plain text (no colors)").option("--no-color", "Disable colors").option("--no-input", "Disable interactive prompts").option("-v, --verbose", "Verbose output");
1509
+ registerConfigCommands(program);
1510
+ registerStatusCommand(program);
1511
+ registerClockCommands(program);
1512
+ registerEntriesCommands(program);
1513
+ registerCustomersCommands(program);
1514
+ registerProjectsCommands(program);
1515
+ registerServicesCommands(program);
1516
+ registerUsersCommands(program);
1517
+ registerReportCommands(program);
1518
+ registerAbsencesCommands(program);
1519
+ registerWorktimesCommands(program);
1520
+ registerUserReportCommands(program);
1521
+ registerSchemaCommand(program);
1522
+ program.exitOverride();
1523
+ async function main() {
1524
+ try {
1525
+ await program.parseAsync(process.argv);
1526
+ } catch (error) {
1527
+ if (error instanceof CommanderError) {
1528
+ process.exit(error.exitCode);
1529
+ }
1530
+ const opts = program.opts();
1531
+ handleError(error, opts);
1532
+ }
1533
+ }
1534
+ main();
1535
+ //# sourceMappingURL=index.js.map