geekbot-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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +517 -0
  3. package/package.json +50 -0
  4. package/scripts/postinstall.mjs +27 -0
  5. package/skills/geekbot/SKILL.md +281 -0
  6. package/skills/geekbot/check-cli.sh +36 -0
  7. package/skills/geekbot/cli-commands.md +382 -0
  8. package/skills/geekbot/error-recovery.md +95 -0
  9. package/skills/geekbot/manager-workflows.md +408 -0
  10. package/skills/geekbot/reporter-workflows.md +275 -0
  11. package/skills/geekbot/standup-templates.json +244 -0
  12. package/src/auth/keychain.ts +38 -0
  13. package/src/auth/resolver.ts +44 -0
  14. package/src/cli/commands/auth.ts +56 -0
  15. package/src/cli/commands/me.ts +34 -0
  16. package/src/cli/commands/poll.ts +91 -0
  17. package/src/cli/commands/report.ts +66 -0
  18. package/src/cli/commands/standup.ts +234 -0
  19. package/src/cli/commands/team.ts +40 -0
  20. package/src/cli/globals.ts +31 -0
  21. package/src/cli/index.ts +94 -0
  22. package/src/errors/cli-error.ts +16 -0
  23. package/src/errors/error-handler.ts +63 -0
  24. package/src/errors/exit-codes.ts +14 -0
  25. package/src/errors/not-found-helper.ts +86 -0
  26. package/src/handlers/auth-handlers.ts +152 -0
  27. package/src/handlers/me-handlers.ts +27 -0
  28. package/src/handlers/poll-handlers.ts +187 -0
  29. package/src/handlers/report-handlers.ts +87 -0
  30. package/src/handlers/standup-handlers.ts +534 -0
  31. package/src/handlers/team-handlers.ts +38 -0
  32. package/src/http/authenticated-client.ts +17 -0
  33. package/src/http/client.ts +138 -0
  34. package/src/http/errors.ts +134 -0
  35. package/src/output/envelope.ts +50 -0
  36. package/src/output/formatter.ts +12 -0
  37. package/src/schemas/common.ts +13 -0
  38. package/src/schemas/poll.ts +89 -0
  39. package/src/schemas/report.ts +124 -0
  40. package/src/schemas/standup.ts +64 -0
  41. package/src/schemas/team.ts +11 -0
  42. package/src/schemas/user.ts +70 -0
  43. package/src/types.ts +30 -0
  44. package/src/utils/constants.ts +24 -0
  45. package/src/utils/input-parsers.ts +234 -0
  46. package/src/utils/receipt.ts +94 -0
  47. package/src/utils/validation.ts +128 -0
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+
3
+ /** Full user object (standups, polls, teams) -- API returns snake_case here */
4
+ export const FullUserSchema = z.object({
5
+ id: z.string(),
6
+ role: z.enum(["admin", "billing_admin", "member"]),
7
+ email: z.string(),
8
+ username: z.string(),
9
+ realname: z.string().nullable(),
10
+ profile_img: z.string(),
11
+ });
12
+
13
+ export type FullUser = z.output<typeof FullUserSchema>;
14
+
15
+ /** Compact user object (report members) -- API returns camelCase profileImg */
16
+ const CompactUserRawSchema = z.object({
17
+ id: z.string(),
18
+ username: z.string(),
19
+ realname: z.string().nullable(),
20
+ profileImg: z.string(),
21
+ });
22
+
23
+ export const CompactUserSchema = CompactUserRawSchema.transform((raw) => ({
24
+ id: raw.id,
25
+ username: raw.username,
26
+ realname: raw.realname,
27
+ profile_img: raw.profileImg, // NORM-01: camelCase -> snake_case
28
+ }));
29
+
30
+ export type CompactUser = z.output<typeof CompactUserSchema>;
31
+
32
+ /** Extended user from GET /v1/me response - has extra fields beyond FullUser */
33
+ export const MeUserSchema = z.object({
34
+ id: z.string(),
35
+ username: z.string(),
36
+ realname: z.string().nullable(),
37
+ firstname: z.string(),
38
+ email: z.string(),
39
+ profile_img: z.string(),
40
+ timezone: z.string(),
41
+ is_admin: z.boolean(),
42
+ is_billing_admin: z.boolean(),
43
+ role: z.enum(["admin", "billing_admin", "member"]),
44
+ });
45
+ export type MeUser = z.output<typeof MeUserSchema>;
46
+
47
+ /** GET /v1/me returns nested {user, team} */
48
+ export const MeResponseSchema = z.object({
49
+ user: MeUserSchema,
50
+ team: z.object({
51
+ id: z.number(),
52
+ name: z.string(),
53
+ }),
54
+ });
55
+ export type MeResponse = z.output<typeof MeResponseSchema>;
56
+
57
+ /** Single team in GET /v1/me/teams response */
58
+ export const MeTeamSchema = z.object({
59
+ id: z.number(),
60
+ name: z.string(),
61
+ is_admin: z.boolean(),
62
+ standup_count: z.number(),
63
+ });
64
+ export type MeTeam = z.output<typeof MeTeamSchema>;
65
+
66
+ /** GET /v1/me/teams returns {teams: [...]} */
67
+ export const MeTeamsResponseSchema = z.object({
68
+ teams: z.array(MeTeamSchema),
69
+ });
70
+ export type MeTeamsResponse = z.output<typeof MeTeamsResponseSchema>;
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ export interface ErrorObject {
2
+ code: string; // machine-readable: "standup_not_found"
3
+ message: string; // human-readable
4
+ retryable: boolean;
5
+ suggestion: string | null;
6
+ }
7
+
8
+ export interface MetadataObject {
9
+ timestamp: string; // ISO 8601
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ export interface OutputEnvelope<T> {
14
+ ok: boolean;
15
+ data: T | null;
16
+ error: ErrorObject | null;
17
+ metadata: MetadataObject;
18
+ }
19
+
20
+ export interface SuccessEnvelope<T> extends OutputEnvelope<T> {
21
+ ok: true;
22
+ data: T;
23
+ error: null;
24
+ }
25
+
26
+ export interface FailureEnvelope extends OutputEnvelope<null> {
27
+ ok: false;
28
+ data: null;
29
+ error: ErrorObject;
30
+ }
@@ -0,0 +1,24 @@
1
+ import packageJson from "../../package.json";
2
+ import { CliError } from "../errors/cli-error.ts";
3
+ import { ExitCode } from "../errors/exit-codes.ts";
4
+
5
+ export const APP_NAME = "geekbot";
6
+ export const APP_VERSION: string = packageJson.version;
7
+
8
+ export function resolveApiBaseUrl(envValue?: string): string {
9
+ if (envValue === undefined) {
10
+ return "https://api.geekbot.com";
11
+ }
12
+ if (!envValue.startsWith("https://")) {
13
+ throw new CliError(
14
+ "GEEKBOT_API_BASE_URL must use HTTPS to prevent API key exfiltration.",
15
+ "validation_error",
16
+ ExitCode.VALIDATION,
17
+ false,
18
+ "Set GEEKBOT_API_BASE_URL to an https:// URL.",
19
+ );
20
+ }
21
+ return envValue;
22
+ }
23
+
24
+ export const API_BASE_URL = resolveApiBaseUrl(process.env.GEEKBOT_API_BASE_URL);
@@ -0,0 +1,234 @@
1
+ import { CliError } from "../errors/cli-error.ts";
2
+ import { ExitCode } from "../errors/exit-codes.ts";
3
+
4
+ /**
5
+ * Parse JSON input for --questions flag.
6
+ * Accepts an array of strings (simple) or objects with question config (full).
7
+ * String items are converted to {question: text} objects.
8
+ */
9
+ export function parseQuestionsInput(raw: string): Array<Record<string, unknown>> {
10
+ let parsed: unknown;
11
+ try {
12
+ parsed = JSON.parse(raw);
13
+ } catch {
14
+ throw new CliError(
15
+ "Invalid JSON for --questions input.",
16
+ "json_parse_error",
17
+ ExitCode.VALIDATION,
18
+ false,
19
+ 'Expected a JSON array. Example: --questions \'["What did you do?", "Any blockers?"]\'',
20
+ );
21
+ }
22
+
23
+ if (!Array.isArray(parsed)) {
24
+ throw new CliError(
25
+ "--questions input must be a JSON array.",
26
+ "validation_error",
27
+ ExitCode.VALIDATION,
28
+ false,
29
+ 'Expected a JSON array. Example: --questions \'["What did you do?", "Any blockers?"]\'',
30
+ );
31
+ }
32
+
33
+ return parsed.map((item: unknown, index: number) => {
34
+ if (typeof item === "string") {
35
+ return { question: item };
36
+ }
37
+ if (typeof item === "object" && item !== null && !Array.isArray(item)) {
38
+ const obj = item as Record<string, unknown>;
39
+ if ("question" in obj) {
40
+ if (typeof obj.question !== "string") {
41
+ throw new CliError(
42
+ `Invalid question at index ${index}. The "question" property must be a string, got ${typeof obj.question}.`,
43
+ "validation_error",
44
+ ExitCode.VALIDATION,
45
+ false,
46
+ 'Expected a JSON array. Example: --questions \'["What did you do?", "Any blockers?"]\'',
47
+ );
48
+ }
49
+ return obj;
50
+ }
51
+ if ("text" in obj) {
52
+ if (typeof obj.text !== "string") {
53
+ throw new CliError(
54
+ `Invalid question at index ${index}. The "text" property must be a string, got ${typeof obj.text}.`,
55
+ "validation_error",
56
+ ExitCode.VALIDATION,
57
+ false,
58
+ 'Expected a JSON array. Example: --questions \'["What did you do?", "Any blockers?"]\'',
59
+ );
60
+ }
61
+ const { text, ...rest } = obj;
62
+ return { question: text, ...rest };
63
+ }
64
+ }
65
+ throw new CliError(
66
+ `Invalid question at index ${index}. Each item must be a string or an object with a "question" or "text" property.`,
67
+ "validation_error",
68
+ ExitCode.VALIDATION,
69
+ false,
70
+ 'Expected a JSON array. Example: --questions \'["What did you do?", "Any blockers?"]\'',
71
+ );
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Parse JSON input for --answers flag.
77
+ * Accepts an object keyed by question ID with string values (shorthand)
78
+ * or {text: string} objects (full).
79
+ */
80
+ export function parseAnswersInput(raw: string): Record<string, { text: string }> {
81
+ let parsed: unknown;
82
+ try {
83
+ parsed = JSON.parse(raw);
84
+ } catch {
85
+ throw new CliError(
86
+ "Invalid JSON for --answers input.",
87
+ "json_parse_error",
88
+ ExitCode.VALIDATION,
89
+ false,
90
+ 'Example: --answers \'{"101": "Done X", "102": "Working on Y"}\'',
91
+ );
92
+ }
93
+
94
+ if (Array.isArray(parsed) || typeof parsed !== "object" || parsed === null) {
95
+ throw new CliError(
96
+ "--answers input must be a JSON object (not an array).",
97
+ "validation_error",
98
+ ExitCode.VALIDATION,
99
+ false,
100
+ 'Example: --answers \'{"101": "Done X", "102": "Working on Y"}\'',
101
+ );
102
+ }
103
+
104
+ const result: Record<string, { text: string }> = {};
105
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
106
+ if (typeof value === "string") {
107
+ result[key] = { text: value };
108
+ } else if (
109
+ typeof value === "object" &&
110
+ value !== null &&
111
+ !Array.isArray(value) &&
112
+ "text" in value &&
113
+ typeof (value as Record<string, unknown>).text === "string"
114
+ ) {
115
+ result[key] = value as { text: string };
116
+ } else {
117
+ throw new CliError(
118
+ `Invalid answer for question "${key}". Each value must be a string or an object with a "text" property.`,
119
+ "validation_error",
120
+ ExitCode.VALIDATION,
121
+ false,
122
+ 'Example: --answers \'{"101": "Done X", "102": "Working on Y"}\'',
123
+ );
124
+ }
125
+ }
126
+
127
+ return result;
128
+ }
129
+
130
+ /**
131
+ * Parse JSON input for --choices flag.
132
+ * Accepts a JSON array of 2-20 strings.
133
+ */
134
+ export function parseChoicesInput(raw: string): string[] {
135
+ let parsed: unknown;
136
+ try {
137
+ parsed = JSON.parse(raw);
138
+ } catch {
139
+ throw new CliError(
140
+ "Invalid JSON for --choices input.",
141
+ "json_parse_error",
142
+ ExitCode.VALIDATION,
143
+ false,
144
+ 'Expected a JSON array of strings. Example: --choices \'["Pizza", "Sushi", "Tacos"]\'',
145
+ );
146
+ }
147
+
148
+ if (!Array.isArray(parsed)) {
149
+ throw new CliError(
150
+ "--choices input must be a JSON array.",
151
+ "validation_error",
152
+ ExitCode.VALIDATION,
153
+ false,
154
+ 'Expected a JSON array of strings. Example: --choices \'["Pizza", "Sushi", "Tacos"]\'',
155
+ );
156
+ }
157
+
158
+ if (parsed.length < 2 || parsed.length > 20) {
159
+ throw new CliError(
160
+ `--choices must have 2-20 items, got ${parsed.length}.`,
161
+ "validation_error",
162
+ ExitCode.VALIDATION,
163
+ false,
164
+ "Provide between 2 and 20 choices.",
165
+ );
166
+ }
167
+
168
+ for (const [i, item] of parsed.entries()) {
169
+ if (typeof item !== "string") {
170
+ throw new CliError(
171
+ `Invalid choice at index ${i}: must be a string.`,
172
+ "validation_error",
173
+ ExitCode.VALIDATION,
174
+ false,
175
+ 'Each choice must be a string. Example: --choices \'["Option A", "Option B"]\'',
176
+ );
177
+ }
178
+ }
179
+
180
+ return parsed as string[];
181
+ }
182
+
183
+ /**
184
+ * Parse date filter input for --before/--after flags.
185
+ * Accepts ISO 8601 dates (2024-01-15) or unix timestamps (1705276800).
186
+ */
187
+ export function parseDateFilter(raw: string, label: string): string {
188
+ // If all digits, treat as unix timestamp passthrough
189
+ if (/^\d+$/.test(raw)) {
190
+ return raw;
191
+ }
192
+
193
+ // Enforce strict ISO 8601 date format (YYYY-MM-DD) to reject ambiguous formats
194
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
195
+ throw new CliError(
196
+ `Invalid date for ${label}: "${raw}".`,
197
+ "validation_error",
198
+ ExitCode.VALIDATION,
199
+ false,
200
+ "Accepted formats: ISO 8601 (2024-01-15) or unix timestamp (1705276800)",
201
+ );
202
+ }
203
+
204
+ const date = new Date(raw);
205
+ if (Number.isNaN(date.getTime())) {
206
+ throw new CliError(
207
+ `Invalid date for ${label}: "${raw}".`,
208
+ "validation_error",
209
+ ExitCode.VALIDATION,
210
+ false,
211
+ "Accepted formats: ISO 8601 (2024-01-15) or unix timestamp (1705276800)",
212
+ );
213
+ }
214
+
215
+ // Validate that the Date object matches the input components.
216
+ // new Date() auto-corrects impossible dates (e.g., Feb 31 -> Mar 3),
217
+ // so we must check the parsed components against the original input.
218
+ const [inputYear, inputMonth, inputDay] = raw.split("-").map(Number);
219
+ if (
220
+ date.getUTCFullYear() !== inputYear ||
221
+ date.getUTCMonth() + 1 !== inputMonth ||
222
+ date.getUTCDate() !== inputDay
223
+ ) {
224
+ throw new CliError(
225
+ `Invalid date for ${label}: "${raw}" is not a valid calendar date.`,
226
+ "validation_error",
227
+ ExitCode.VALIDATION,
228
+ false,
229
+ "Accepted formats: ISO 8601 (2024-01-15) or unix timestamp (1705276800)",
230
+ );
231
+ }
232
+
233
+ return String(Math.floor(date.getTime() / 1000));
234
+ }
@@ -0,0 +1,94 @@
1
+ import type { Standup } from "../schemas/standup.ts";
2
+
3
+ /**
4
+ * Escape a string for safe inclusion in a shell command.
5
+ * Wraps in single quotes and escapes any embedded single quotes.
6
+ */
7
+ export function shellEscape(value: string): string {
8
+ return `'${value.replace(/'/g, "'\\''")}'`;
9
+ }
10
+
11
+ export type MutationOperation = "created" | "updated" | "deleted" | "duplicated" | "started";
12
+
13
+ /**
14
+ * Build a mutation receipt with operation type and undo command.
15
+ */
16
+ export function buildReceipt(
17
+ operation: MutationOperation,
18
+ undo: string | null,
19
+ ): { operation: MutationOperation; undo: string | null } {
20
+ return { operation, undo };
21
+ }
22
+
23
+ /**
24
+ * Build an undo command that reconstructs a create command from a deleted standup.
25
+ * Used by delete operations to provide a reversal hint in the receipt.
26
+ */
27
+ export function buildDeleteUndoCommand(standup: Standup): string {
28
+ const parts: string[] = [
29
+ `geekbot standup create --name ${shellEscape(standup.name)} --channel ${shellEscape(standup.channel)}`,
30
+ ];
31
+
32
+ if (standup.time) {
33
+ parts.push(`--time ${shellEscape(standup.time.slice(0, 5))}`);
34
+ }
35
+
36
+ if (standup.timezone) {
37
+ parts.push(`--timezone ${shellEscape(standup.timezone)}`);
38
+ }
39
+
40
+ if (standup.days.length > 0) {
41
+ parts.push(`--days ${shellEscape(standup.days.join(","))}`);
42
+ }
43
+
44
+ if (standup.wait_time > 0) {
45
+ parts.push(`--wait-time ${standup.wait_time}`);
46
+ }
47
+
48
+ if (standup.questions.length > 0) {
49
+ parts.push(`--questions ${shellEscape(JSON.stringify(standup.questions.map((q) => q.text)))}`);
50
+ }
51
+
52
+ return parts.join(" ");
53
+ }
54
+
55
+ /** Map of standup field names to CLI flag names */
56
+ const FIELD_TO_FLAG: Record<string, string> = {
57
+ name: "--name",
58
+ channel: "--channel",
59
+ time: "--time",
60
+ timezone: "--timezone",
61
+ days: "--days",
62
+ wait_time: "--wait-time",
63
+ };
64
+
65
+ /**
66
+ * Build an undo command that reverts only the changed fields of an update.
67
+ * Uses the previous state to reconstruct the original values.
68
+ */
69
+ export function buildUpdateUndoCommand(
70
+ id: number,
71
+ previousState: Standup,
72
+ changedFields: Record<string, unknown>,
73
+ ): string {
74
+ const parts: string[] = [`geekbot standup update ${id}`];
75
+
76
+ for (const key of Object.keys(changedFields)) {
77
+ const flag = FIELD_TO_FLAG[key];
78
+ if (!flag) continue;
79
+
80
+ const prevValue = previousState[key as keyof Standup];
81
+
82
+ if (key === "time" && typeof prevValue === "string") {
83
+ parts.push(`${flag} ${shellEscape(prevValue.slice(0, 5))}`);
84
+ } else if (key === "days" && Array.isArray(prevValue)) {
85
+ parts.push(`${flag} ${shellEscape(prevValue.join(","))}`);
86
+ } else if (typeof prevValue === "number") {
87
+ parts.push(`${flag} ${prevValue}`);
88
+ } else if (typeof prevValue === "string") {
89
+ parts.push(`${flag} ${shellEscape(prevValue)}`);
90
+ }
91
+ }
92
+
93
+ return parts.join(" ");
94
+ }
@@ -0,0 +1,128 @@
1
+ import { CliError } from "../errors/cli-error.ts";
2
+ import { ExitCode } from "../errors/exit-codes.ts";
3
+
4
+ /**
5
+ * Validate that a string is a valid numeric ID (positive integer).
6
+ * Rejects before API call to give a clear local error (CLI-09).
7
+ */
8
+ export function validateNumericId(value: string, label: string = "ID"): number {
9
+ const num = Number(value);
10
+ if (!Number.isSafeInteger(num) || num <= 0) {
11
+ throw new CliError(
12
+ `Invalid ${label}: "${value}". Must be a positive integer.`,
13
+ "validation_error",
14
+ ExitCode.VALIDATION,
15
+ false,
16
+ `Provide a numeric ${label}, e.g.: geekbot standup get 123`,
17
+ );
18
+ }
19
+ return num;
20
+ }
21
+
22
+ /**
23
+ * Validate time format (HH:MM in 24-hour format).
24
+ */
25
+ export function validateTimeFormat(value: string): string {
26
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
27
+ if (!match) {
28
+ throw new CliError(
29
+ `Invalid time format: "${value}". Expected HH:MM (24-hour).`,
30
+ "validation_error",
31
+ ExitCode.VALIDATION,
32
+ false,
33
+ 'Use 24-hour format, e.g.: --time "09:30" or --time "14:00"',
34
+ );
35
+ }
36
+ return value;
37
+ }
38
+
39
+ /**
40
+ * Validate that a string is a Slack-style user ID (e.g. "UHNM44125", "U08LXSA31BJ").
41
+ * Slack IDs start with an uppercase letter followed by uppercase alphanumeric characters.
42
+ */
43
+ export function validateSlackId(value: string, label: string = "user ID"): string {
44
+ if (!/^[A-Z][A-Z0-9]+$/.test(value)) {
45
+ throw new CliError(
46
+ `Invalid ${label}: "${value}". Must be a Slack-style ID (e.g. U08LXSA31BJ).`,
47
+ "validation_error",
48
+ ExitCode.VALIDATION,
49
+ false,
50
+ `Provide a Slack-style ${label}, e.g.: --user-id U08LXSA31BJ`,
51
+ );
52
+ }
53
+ return value;
54
+ }
55
+
56
+ /**
57
+ * Validate a comma-separated list of Slack-style user IDs.
58
+ */
59
+ export function validateSlackIdList(value: string, label: string): string[] {
60
+ const parts = value.split(",");
61
+ const result: string[] = [];
62
+ for (const part of parts) {
63
+ result.push(validateSlackId(part.trim(), label));
64
+ }
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Validate that a string is a valid non-negative integer (for wait_time etc.).
70
+ */
71
+ export function validateWaitTime(value: string): number {
72
+ const num = Number(value);
73
+ if (!Number.isSafeInteger(num) || num < 0) {
74
+ throw new CliError(
75
+ `Invalid wait time: "${value}". Must be a non-negative integer.`,
76
+ "validation_error",
77
+ ExitCode.VALIDATION,
78
+ false,
79
+ `Provide a numeric value in minutes, e.g.: --wait-time 15`,
80
+ );
81
+ }
82
+ return num;
83
+ }
84
+
85
+ /**
86
+ * Validate that a string is a valid positive integer for use as a limit.
87
+ */
88
+ export function validateLimit(value: string): number {
89
+ const num = Number(value);
90
+ if (!Number.isSafeInteger(num) || num < 1) {
91
+ throw new CliError(
92
+ `Invalid limit: "${value}" — must be a positive integer`,
93
+ "validation_error",
94
+ ExitCode.VALIDATION,
95
+ false,
96
+ "Example: --limit 10",
97
+ );
98
+ }
99
+ return num;
100
+ }
101
+
102
+ /**
103
+ * Validate day abbreviations (Mon, Tue, Wed, Thu, Fri, Sat, Sun).
104
+ */
105
+ export function validateDayAbbreviations(values: string[]): string[] {
106
+ const validMap = new Map([
107
+ ["mon", "Mon"],
108
+ ["tue", "Tue"],
109
+ ["wed", "Wed"],
110
+ ["thu", "Thu"],
111
+ ["fri", "Fri"],
112
+ ["sat", "Sat"],
113
+ ["sun", "Sun"],
114
+ ]);
115
+ return values.map((day) => {
116
+ const normalized = validMap.get(day.toLowerCase());
117
+ if (!normalized) {
118
+ throw new CliError(
119
+ `Invalid day abbreviation: "${day}". Valid values: Mon, Tue, Wed, Thu, Fri, Sat, Sun.`,
120
+ "validation_error",
121
+ ExitCode.VALIDATION,
122
+ false,
123
+ 'Use three-letter abbreviations: --days "Mon,Wed,Fri"',
124
+ );
125
+ }
126
+ return normalized;
127
+ });
128
+ }