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,534 @@
1
+ import type { GlobalOptions } from "../cli/globals.ts";
2
+ import { CliError } from "../errors/cli-error.ts";
3
+ import { ExitCode } from "../errors/exit-codes.ts";
4
+ import { buildNotFoundSuggestion } from "../errors/not-found-helper.ts";
5
+ import { createAuthenticatedClient } from "../http/authenticated-client.ts";
6
+ import type { HttpClient } from "../http/client.ts";
7
+ import { success, successList } from "../output/envelope.ts";
8
+ import { writeOutput } from "../output/formatter.ts";
9
+ import type { Standup } from "../schemas/standup.ts";
10
+ import { StandupListSchema, StandupSchema } from "../schemas/standup.ts";
11
+ import { MeResponseSchema } from "../schemas/user.ts";
12
+ import { parseQuestionsInput } from "../utils/input-parsers.ts";
13
+ import {
14
+ buildDeleteUndoCommand,
15
+ buildReceipt,
16
+ buildUpdateUndoCommand,
17
+ shellEscape,
18
+ } from "../utils/receipt.ts";
19
+ import {
20
+ validateDayAbbreviations,
21
+ validateLimit,
22
+ validateNumericId,
23
+ validateSlackIdList,
24
+ validateTimeFormat,
25
+ validateWaitTime,
26
+ } from "../utils/validation.ts";
27
+
28
+ // ── Option Interfaces ─────────────────────────────────────────────────
29
+
30
+ export interface StandupListOptions {
31
+ admin?: boolean;
32
+ brief?: boolean;
33
+ name?: string;
34
+ channel?: string;
35
+ mine?: boolean;
36
+ member?: string;
37
+ limit?: string;
38
+ }
39
+
40
+ export interface StandupCreateOptions {
41
+ name: string;
42
+ channel: string;
43
+ time?: string;
44
+ timezone?: string;
45
+ days?: string;
46
+ questions: string;
47
+ users?: string;
48
+ waitTime?: string;
49
+ }
50
+
51
+ export interface StandupUpdateOptions {
52
+ name?: string;
53
+ channel?: string;
54
+ time?: string;
55
+ timezone?: string;
56
+ days?: string;
57
+ questions?: string;
58
+ users?: string;
59
+ waitTime?: string;
60
+ }
61
+
62
+ export interface StandupReplaceOptions {
63
+ name: string;
64
+ channel: string;
65
+ time?: string;
66
+ timezone?: string;
67
+ days?: string;
68
+ questions?: string;
69
+ users?: string;
70
+ waitTime?: string;
71
+ }
72
+
73
+ export interface StandupDeleteOptions {
74
+ yes?: boolean;
75
+ }
76
+
77
+ export interface StandupDuplicateOptions {
78
+ name: string;
79
+ }
80
+
81
+ export interface StandupStartOptions {
82
+ users?: string;
83
+ }
84
+
85
+ // ── Not-Found Enrichment ──────────────────────────────────────────────
86
+
87
+ /**
88
+ * Wrap a handler's async work to enrich 404 errors with suggestions.
89
+ * Creates the HttpClient once and passes it to the handler callback,
90
+ * so the suggestion fetch reuses the same authenticated client.
91
+ */
92
+ async function enrichNotFound(
93
+ fn: (client: HttpClient) => Promise<void>,
94
+ globalOpts: GlobalOptions,
95
+ resourceType: "standup",
96
+ ): Promise<void> {
97
+ const client = await createAuthenticatedClient(globalOpts);
98
+ try {
99
+ await fn(client);
100
+ } catch (error) {
101
+ if (error instanceof CliError && error.code === "not_found") {
102
+ const suggestion = await buildNotFoundSuggestion(client, resourceType);
103
+ if (suggestion) {
104
+ throw new CliError(
105
+ error.message,
106
+ error.code,
107
+ error.exitCode,
108
+ error.retryable,
109
+ suggestion,
110
+ error.context,
111
+ );
112
+ }
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ // ── Handlers ──────────────────────────────────────────────────────────
119
+
120
+ /** Brief standup projection — only essential fields for discovery */
121
+ export interface StandupBrief {
122
+ id: number;
123
+ name: string;
124
+ channel: string;
125
+ }
126
+
127
+ /**
128
+ * Handle `geekbot standup list` command.
129
+ * Fetches standups from GET /v1/standups with optional filters and projection.
130
+ */
131
+ export async function handleStandupList(
132
+ options: StandupListOptions,
133
+ globalOpts: GlobalOptions,
134
+ ): Promise<void> {
135
+ const client = await createAuthenticatedClient(globalOpts);
136
+
137
+ const params: Record<string, string> | undefined = options.admin ? { admin: "true" } : undefined;
138
+
139
+ const raw = await client.get<unknown>("/v1/standups", params);
140
+ let standups = StandupListSchema.parse(raw);
141
+
142
+ // Client-side filters
143
+ if (options.name) {
144
+ const needle = options.name.toLowerCase();
145
+ standups = standups.filter((s) => s.name.toLowerCase().includes(needle));
146
+ }
147
+
148
+ if (options.channel) {
149
+ const needle = options.channel.toLowerCase();
150
+ standups = standups.filter((s) => s.channel.toLowerCase().includes(needle));
151
+ }
152
+
153
+ if (options.mine) {
154
+ const meRaw = await client.get<unknown>("/v1/me");
155
+ const me = MeResponseSchema.parse(meRaw);
156
+ const myId = me.user.id;
157
+ standups = standups.filter((s) => s.users.some((u) => u.id === myId));
158
+ }
159
+
160
+ if (options.member) {
161
+ standups = standups.filter((s) => s.users.some((u) => u.id === options.member));
162
+ }
163
+
164
+ // Limit — cap results after all filters
165
+ if (options.limit) {
166
+ const limitNum = validateLimit(options.limit);
167
+ standups = standups.slice(0, limitNum);
168
+ }
169
+
170
+ // Brief projection — only id, name, channel for fast discovery
171
+ if (options.brief) {
172
+ const brief: StandupBrief[] = standups.map((s) => ({
173
+ id: s.id,
174
+ name: s.name,
175
+ channel: s.channel,
176
+ }));
177
+ writeOutput(successList(brief));
178
+ return;
179
+ }
180
+
181
+ writeOutput(successList(standups));
182
+ }
183
+
184
+ /**
185
+ * Handle `geekbot standup get` command.
186
+ * Fetches a single standup by ID from GET /v1/standups/<id>.
187
+ */
188
+ export async function handleStandupGet(id: string, globalOpts: GlobalOptions): Promise<void> {
189
+ const numericId = validateNumericId(id, "standup ID");
190
+ await enrichNotFound(
191
+ async (client) => {
192
+ const raw = await client.get<unknown>(`/v1/standups/${numericId}`);
193
+ const standup = StandupSchema.parse(raw);
194
+ writeOutput(success(standup));
195
+ },
196
+ globalOpts,
197
+ "standup",
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Handle `geekbot standup create` command.
203
+ * Creates a standup via POST /v1/standups.
204
+ */
205
+ export async function handleStandupCreate(
206
+ options: StandupCreateOptions,
207
+ globalOpts: GlobalOptions,
208
+ ): Promise<void> {
209
+ const client = await createAuthenticatedClient(globalOpts);
210
+
211
+ // Apply sensible defaults for API-required fields
212
+ const time = options.time ?? "10:00";
213
+ const days = options.days ?? "Mon,Tue,Wed,Thu,Fri";
214
+
215
+ validateTimeFormat(time);
216
+ const daysList = validateDayAbbreviations(days.split(","));
217
+
218
+ const body: Record<string, unknown> = {
219
+ name: options.name,
220
+ channel: options.channel,
221
+ time: `${time}:00`,
222
+ days: daysList,
223
+ questions: parseQuestionsInput(options.questions),
224
+ };
225
+
226
+ if (options.timezone !== undefined) {
227
+ body.timezone = options.timezone;
228
+ }
229
+
230
+ if (options.users !== undefined) {
231
+ body.users = validateSlackIdList(options.users, "user ID");
232
+ body.sync_channel_members = false;
233
+ } else {
234
+ body.sync_channel_members = true;
235
+ }
236
+
237
+ if (options.waitTime !== undefined) {
238
+ body.wait_time = validateWaitTime(options.waitTime);
239
+ }
240
+
241
+ const raw = await client.post<unknown>("/v1/standups", body);
242
+ const standup = StandupSchema.parse(raw);
243
+ const receipt = buildReceipt("created", `geekbot standup delete ${standup.id} --yes`);
244
+
245
+ writeOutput(success(standup, receipt));
246
+ }
247
+
248
+ /**
249
+ * Handle `geekbot standup update` command.
250
+ * Pre-fetches current state, sends PATCH with changed fields,
251
+ * returns receipt with undo restoring previous values.
252
+ */
253
+ export async function handleStandupUpdate(
254
+ id: string,
255
+ options: StandupUpdateOptions,
256
+ globalOpts: GlobalOptions,
257
+ ): Promise<void> {
258
+ const numericId = validateNumericId(id, "standup ID");
259
+
260
+ // Early exit: no options means nothing to update
261
+ const hasUpdates =
262
+ options.name !== undefined ||
263
+ options.channel !== undefined ||
264
+ options.time !== undefined ||
265
+ options.timezone !== undefined ||
266
+ options.days !== undefined ||
267
+ options.questions !== undefined ||
268
+ options.users !== undefined ||
269
+ options.waitTime !== undefined;
270
+
271
+ if (!hasUpdates) {
272
+ throw new CliError(
273
+ "No update options provided",
274
+ "validation_error",
275
+ ExitCode.VALIDATION,
276
+ false,
277
+ "Specify at least one option to update (e.g., --name, --channel, --time, --users)",
278
+ );
279
+ }
280
+
281
+ await enrichNotFound(
282
+ async (client) => {
283
+ // Pre-fetch current state for undo
284
+ const prevRaw = await client.get<unknown>(`/v1/standups/${numericId}`);
285
+ const previousStandup = StandupSchema.parse(prevRaw);
286
+
287
+ // Build body from non-undefined options
288
+ const body: Record<string, unknown> = {};
289
+
290
+ if (options.name !== undefined) {
291
+ body.name = options.name;
292
+ }
293
+
294
+ if (options.channel !== undefined) {
295
+ body.channel = options.channel;
296
+ }
297
+
298
+ if (options.time !== undefined) {
299
+ validateTimeFormat(options.time);
300
+ body.time = `${options.time}:00`;
301
+ }
302
+
303
+ if (options.timezone !== undefined) {
304
+ body.timezone = options.timezone;
305
+ }
306
+
307
+ if (options.days !== undefined) {
308
+ body.days = validateDayAbbreviations(options.days.split(","));
309
+ }
310
+
311
+ if (options.questions !== undefined) {
312
+ body.questions = parseQuestionsInput(options.questions);
313
+ }
314
+
315
+ if (options.users !== undefined) {
316
+ body.users = validateSlackIdList(options.users, "user ID");
317
+ body.sync_channel_members = false;
318
+ }
319
+
320
+ if (options.waitTime !== undefined) {
321
+ body.wait_time = validateWaitTime(options.waitTime);
322
+ }
323
+
324
+ const raw = await client.patch<unknown>(`/v1/standups/${numericId}`, body);
325
+ const standup = StandupSchema.parse(raw);
326
+
327
+ const undo = buildUpdateUndoCommand(numericId, previousStandup, body);
328
+ const receipt = buildReceipt("updated", undo);
329
+
330
+ writeOutput(success(standup, receipt));
331
+ },
332
+ globalOpts,
333
+ "standup",
334
+ );
335
+ }
336
+
337
+ /**
338
+ * Handle `geekbot standup replace` command.
339
+ * Pre-fetches current state, sends PUT with full body,
340
+ * returns receipt with undo=replace restoring all previous fields.
341
+ */
342
+ export async function handleStandupReplace(
343
+ id: string,
344
+ options: StandupReplaceOptions,
345
+ globalOpts: GlobalOptions,
346
+ ): Promise<void> {
347
+ const numericId = validateNumericId(id, "standup ID");
348
+ await enrichNotFound(
349
+ async (client) => {
350
+ // Pre-fetch current state for undo
351
+ const prevRaw = await client.get<unknown>(`/v1/standups/${numericId}`);
352
+ const previousStandup = StandupSchema.parse(prevRaw);
353
+
354
+ // Build full body — PUT requires complete representation
355
+ const time = options.time ?? "10:00";
356
+ validateTimeFormat(time);
357
+ const days = validateDayAbbreviations((options.days ?? "Mon,Tue,Wed,Thu,Fri").split(","));
358
+
359
+ const body: Record<string, unknown> = {
360
+ name: options.name,
361
+ channel: options.channel,
362
+ time: `${time}:00`,
363
+ days,
364
+ };
365
+
366
+ if (options.timezone !== undefined) {
367
+ body.timezone = options.timezone;
368
+ }
369
+
370
+ // questions: use provided or carry forward from existing standup
371
+ if (options.questions !== undefined) {
372
+ body.questions = parseQuestionsInput(options.questions);
373
+ } else {
374
+ body.questions = previousStandup.questions;
375
+ }
376
+
377
+ if (options.users !== undefined) {
378
+ body.users = validateSlackIdList(options.users, "user ID");
379
+ body.sync_channel_members = false;
380
+ } else {
381
+ body.sync_channel_members = true;
382
+ }
383
+
384
+ if (options.waitTime !== undefined) {
385
+ body.wait_time = validateWaitTime(options.waitTime);
386
+ }
387
+
388
+ const raw = await client.put<unknown>(`/v1/standups/${numericId}`, body);
389
+ const standup = StandupSchema.parse(raw);
390
+
391
+ // Build undo as replace with all previous fields
392
+ const undo = buildReplaceUndoCommand(numericId, previousStandup);
393
+ const receipt = buildReceipt("updated", undo);
394
+
395
+ writeOutput(success(standup, receipt));
396
+ },
397
+ globalOpts,
398
+ "standup",
399
+ );
400
+ }
401
+
402
+ /**
403
+ * Handle `geekbot standup delete` command.
404
+ * Requires --yes flag for confirmation. Pre-fetches standup for receipt.
405
+ */
406
+ export async function handleStandupDelete(
407
+ id: string,
408
+ options: StandupDeleteOptions,
409
+ globalOpts: GlobalOptions,
410
+ ): Promise<void> {
411
+ const numericId = validateNumericId(id, "standup ID");
412
+
413
+ if (!options.yes) {
414
+ throw new CliError(
415
+ `Delete standup ${numericId}? Add --yes to confirm.`,
416
+ "confirmation_required",
417
+ ExitCode.VALIDATION,
418
+ false,
419
+ `Run: geekbot standup delete ${numericId} --yes`,
420
+ );
421
+ }
422
+
423
+ await enrichNotFound(
424
+ async (client) => {
425
+ // Pre-fetch for undo receipt
426
+ const prevRaw = await client.get<unknown>(`/v1/standups/${numericId}`);
427
+ const standup = StandupSchema.parse(prevRaw);
428
+
429
+ await client.delete(`/v1/standups/${numericId}`);
430
+
431
+ const undo = buildDeleteUndoCommand(standup);
432
+ const receipt = buildReceipt("deleted", undo);
433
+
434
+ writeOutput(success(standup, receipt));
435
+ },
436
+ globalOpts,
437
+ "standup",
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Handle `geekbot standup duplicate` command.
443
+ * Sends POST to /v1/standups/<id>/duplicate.
444
+ */
445
+ export async function handleStandupDuplicate(
446
+ id: string,
447
+ options: StandupDuplicateOptions,
448
+ globalOpts: GlobalOptions,
449
+ ): Promise<void> {
450
+ const numericId = validateNumericId(id, "standup ID");
451
+ await enrichNotFound(
452
+ async (client) => {
453
+ const raw = await client.post<unknown>(`/v1/standups/${numericId}/duplicate`, {
454
+ name: options.name,
455
+ });
456
+ const newStandup = StandupSchema.parse(raw);
457
+
458
+ const receipt = buildReceipt("duplicated", `geekbot standup delete ${newStandup.id} --yes`);
459
+
460
+ writeOutput(success(newStandup, receipt));
461
+ },
462
+ globalOpts,
463
+ "standup",
464
+ );
465
+ }
466
+
467
+ /**
468
+ * Handle `geekbot standup start` command.
469
+ * Pre-fetches standup for receipt data, sends POST to /v1/standups/<id>/start.
470
+ * POST response is not parsed (returns bare "ok" string).
471
+ */
472
+ export async function handleStandupStart(
473
+ id: string,
474
+ options: StandupStartOptions,
475
+ globalOpts: GlobalOptions,
476
+ ): Promise<void> {
477
+ const numericId = validateNumericId(id, "standup ID");
478
+ await enrichNotFound(
479
+ async (client) => {
480
+ // Pre-fetch standup for receipt data
481
+ const prevRaw = await client.get<unknown>(`/v1/standups/${numericId}`);
482
+ const standup = StandupSchema.parse(prevRaw);
483
+
484
+ // Build body
485
+ const body: Record<string, unknown> = {};
486
+ if (options.users !== undefined) {
487
+ body.users = validateSlackIdList(options.users, "user ID");
488
+ }
489
+
490
+ // POST /start -- response is "ok" string, not a standup object
491
+ await client.post<unknown>(`/v1/standups/${numericId}/start`, body);
492
+
493
+ const receipt = buildReceipt("started", null);
494
+
495
+ writeOutput(success(standup, receipt));
496
+ },
497
+ globalOpts,
498
+ "standup",
499
+ );
500
+ }
501
+
502
+ // ── Helpers ───────────────────────────────────────────────────────────
503
+
504
+ /**
505
+ * Build an undo command for replace that restores ALL previous fields.
506
+ */
507
+ function buildReplaceUndoCommand(id: number, prev: Standup): string {
508
+ const parts: string[] = [`geekbot standup replace ${id}`];
509
+
510
+ parts.push(`--name ${shellEscape(prev.name)}`);
511
+ parts.push(`--channel ${shellEscape(prev.channel)}`);
512
+
513
+ if (prev.time) {
514
+ parts.push(`--time ${shellEscape(prev.time.slice(0, 5))}`);
515
+ }
516
+
517
+ if (prev.timezone) {
518
+ parts.push(`--timezone ${shellEscape(prev.timezone)}`);
519
+ }
520
+
521
+ if (prev.days.length > 0) {
522
+ parts.push(`--days ${shellEscape(prev.days.join(","))}`);
523
+ }
524
+
525
+ if (prev.wait_time > 0) {
526
+ parts.push(`--wait-time ${prev.wait_time}`);
527
+ }
528
+
529
+ if (prev.questions.length > 0) {
530
+ parts.push(`--questions ${shellEscape(JSON.stringify(prev.questions.map((q) => q.text)))}`);
531
+ }
532
+
533
+ return parts.join(" ");
534
+ }
@@ -0,0 +1,38 @@
1
+ import type { GlobalOptions } from "../cli/globals.ts";
2
+ import { createAuthenticatedClient } from "../http/authenticated-client.ts";
3
+ import { success, successList } from "../output/envelope.ts";
4
+ import { writeOutput } from "../output/formatter.ts";
5
+ import { TeamResponseSchema } from "../schemas/team.ts";
6
+
7
+ /**
8
+ * Handle `geekbot team list` command.
9
+ * GET /v1/teams returns a SINGLE team object {id, name, users: [...]}, NOT an array.
10
+ * Uses success() not successList() because the API returns one team.
11
+ */
12
+ export async function handleTeamList(globalOpts: GlobalOptions): Promise<void> {
13
+ const client = await createAuthenticatedClient(globalOpts);
14
+ const raw = await client.get<unknown>("/v1/teams");
15
+ const team = TeamResponseSchema.parse(raw);
16
+ writeOutput(success(team));
17
+ }
18
+
19
+ /**
20
+ * Handle `geekbot team search` command.
21
+ * Fetches team members and filters by case-insensitive substring match
22
+ * across username, realname, and email.
23
+ */
24
+ export async function handleTeamSearch(query: string, globalOpts: GlobalOptions): Promise<void> {
25
+ const client = await createAuthenticatedClient(globalOpts);
26
+ const raw = await client.get<unknown>("/v1/teams");
27
+ const team = TeamResponseSchema.parse(raw);
28
+
29
+ const needle = query.toLowerCase();
30
+ const matches = team.users.filter(
31
+ (u) =>
32
+ u.username.toLowerCase().includes(needle) ||
33
+ (u.realname?.toLowerCase().includes(needle) ?? false) ||
34
+ u.email.toLowerCase().includes(needle),
35
+ );
36
+
37
+ writeOutput(successList(matches));
38
+ }
@@ -0,0 +1,17 @@
1
+ import { resolveCredential as _resolveCredential } from "../auth/resolver.ts";
2
+ import type { GlobalOptions } from "../cli/globals.ts";
3
+ import { createHttpClient, type HttpClient } from "./client.ts";
4
+
5
+ /**
6
+ * Resolve credentials and create an authenticated HTTP client.
7
+ * Extracts the repeated resolveCredential + createHttpClient pattern
8
+ * used across all handler modules.
9
+ */
10
+ export async function createAuthenticatedClient(
11
+ globalOpts: GlobalOptions,
12
+ deps?: { resolveCredential?: typeof _resolveCredential },
13
+ ): Promise<HttpClient> {
14
+ const resolve = deps?.resolveCredential ?? _resolveCredential;
15
+ const { apiKey } = await resolve({ apiKeyFlag: globalOpts.apiKey });
16
+ return createHttpClient(apiKey, { debug: globalOpts.debug });
17
+ }