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,86 @@
1
+ import type { HttpClient } from "../http/client.ts";
2
+
3
+ /**
4
+ * Resource types that support not-found suggestions.
5
+ * Each maps to a list endpoint and a display formatter.
6
+ */
7
+ export type ResourceType = "standup" | "poll";
8
+
9
+ interface ResourceListItem {
10
+ id: number;
11
+ name: string;
12
+ }
13
+
14
+ /**
15
+ * Extract items with id+name from an unknown API response array.
16
+ * Shared across all resource types since the shape is identical.
17
+ */
18
+ function extractItems(data: unknown): ResourceListItem[] {
19
+ if (!Array.isArray(data)) return [];
20
+ return data
21
+ .filter(
22
+ (item): item is { id: number; name: string } =>
23
+ typeof item === "object" &&
24
+ item !== null &&
25
+ typeof item.id === "number" &&
26
+ typeof item.name === "string",
27
+ )
28
+ .map(({ id, name }) => ({ id, name }));
29
+ }
30
+
31
+ /**
32
+ * Resource type configurations for fetching alternatives.
33
+ * Maps each resource to its list endpoint and how to extract id+name.
34
+ */
35
+ const RESOURCE_CONFIG: Record<
36
+ ResourceType,
37
+ {
38
+ listPath: string;
39
+ extractItems: (data: unknown) => ResourceListItem[];
40
+ }
41
+ > = {
42
+ standup: { listPath: "/v1/standups", extractItems },
43
+ poll: { listPath: "/v1/polls", extractItems },
44
+ };
45
+
46
+ /**
47
+ * Build a suggestion string for a 404 error by fetching and listing
48
+ * available alternatives for the given resource type.
49
+ *
50
+ * Returns a suggestion string like:
51
+ * "Available standups: 123 (Daily Standup), 456 (Weekly Review). Run `geekbot standup list` to see all."
52
+ *
53
+ * Returns null if the list fetch fails or returns no items.
54
+ * Silently catches errors so a failed suggestion never blocks the main error flow.
55
+ *
56
+ * @param client - An authenticated HttpClient
57
+ * @param resourceType - The type of resource that was not found
58
+ * @param maxItems - Maximum number of alternatives to show (default: 5)
59
+ */
60
+ export async function buildNotFoundSuggestion(
61
+ client: HttpClient,
62
+ resourceType: ResourceType,
63
+ maxItems = 5,
64
+ ): Promise<string | null> {
65
+ const config = RESOURCE_CONFIG[resourceType];
66
+ if (!config) return null;
67
+
68
+ try {
69
+ const rawData = await client.get<unknown>(config.listPath);
70
+ const items = config.extractItems(rawData);
71
+
72
+ if (items.length === 0) {
73
+ return `No ${resourceType}s found. Run \`geekbot ${resourceType} create\` to create one.`;
74
+ }
75
+
76
+ const shown = items.slice(0, maxItems);
77
+ const formatted = shown.map((item) => `${item.id} (${item.name})`).join(", ");
78
+ const suffix = items.length > maxItems ? ` and ${items.length - maxItems} more` : "";
79
+
80
+ return `Available ${resourceType}s: ${formatted}${suffix}. Run \`geekbot ${resourceType} list\` to see all.`;
81
+ } catch {
82
+ // Suggestion is best-effort. If the list fetch fails (e.g., auth issues),
83
+ // return null and let the original 404 error propagate without a suggestion.
84
+ return null;
85
+ }
86
+ }
@@ -0,0 +1,152 @@
1
+ import { deleteKeychainKey, getKeychainKey, setKeychainKey } from "../auth/keychain.ts";
2
+ import { resolveCredential } from "../auth/resolver.ts";
3
+ import type { GlobalOptions } from "../cli/globals.ts";
4
+ import { CliError } from "../errors/cli-error.ts";
5
+ import { ExitCode } from "../errors/exit-codes.ts";
6
+ import { createHttpClient } from "../http/client.ts";
7
+ import { success } from "../output/envelope.ts";
8
+ import { writeOutput } from "../output/formatter.ts";
9
+ import { MeResponseSchema } from "../schemas/user.ts";
10
+
11
+ // ── Option Interfaces ─────────────────────────────────────────────────
12
+
13
+ export interface AuthSetupOptions {
14
+ apiKey?: string;
15
+ }
16
+
17
+ // ── Handlers ──────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Handle `geekbot auth setup` command.
21
+ * Acquires API key (flag, interactive prompt, or error),
22
+ * verifies it via GET /v1/me, then stores in OS keychain.
23
+ */
24
+ export async function handleAuthSetup(
25
+ options: AuthSetupOptions,
26
+ globalOpts: GlobalOptions,
27
+ ): Promise<void> {
28
+ let key: string;
29
+
30
+ if (options.apiKey) {
31
+ key = options.apiKey.trim();
32
+ } else if (process.stdin.isTTY) {
33
+ const readline = await import("node:readline");
34
+ const { Writable } = await import("node:stream");
35
+ // Use a muted output stream so the API key is not echoed to the terminal
36
+ const mutedOutput = new Writable({
37
+ write(_chunk, _encoding, callback) {
38
+ callback();
39
+ },
40
+ });
41
+ const reader = readline.createInterface({
42
+ input: process.stdin,
43
+ output: mutedOutput,
44
+ terminal: true,
45
+ });
46
+ // Print the prompt ourselves since the muted stream swallows it
47
+ process.stderr.write("Enter your Geekbot API key: ");
48
+ key = await new Promise<string>((resolve) => {
49
+ reader.question("", (answer: string) => {
50
+ resolve(answer.trim());
51
+ reader.close();
52
+ });
53
+ });
54
+ // Print a newline after the user presses Enter (since echo is suppressed)
55
+ process.stderr.write("\n");
56
+ } else {
57
+ throw new CliError(
58
+ "Non-interactive mode requires --api-key flag.",
59
+ "auth_setup_non_interactive",
60
+ ExitCode.USAGE,
61
+ false,
62
+ "Run: geekbot auth setup --api-key YOUR_KEY",
63
+ );
64
+ }
65
+
66
+ // Verify key by calling /v1/me
67
+ const client = createHttpClient(key, { debug: globalOpts.debug });
68
+ const raw = await client.get<unknown>("/v1/me");
69
+ const meResponse = MeResponseSchema.parse(raw);
70
+
71
+ // Check for existing keychain key and warn
72
+ const existing = getKeychainKey();
73
+ if (existing) {
74
+ process.stderr.write("Replacing existing API key in keychain\n");
75
+ }
76
+
77
+ // Store in keychain
78
+ try {
79
+ setKeychainKey(key);
80
+ } catch {
81
+ throw new CliError(
82
+ "Failed to store API key in OS keychain.",
83
+ "keychain_unavailable",
84
+ ExitCode.GENERAL,
85
+ false,
86
+ 'OS keychain may be unavailable. Use GEEKBOT_API_KEY environment variable instead: export GEEKBOT_API_KEY="your-key"',
87
+ );
88
+ }
89
+
90
+ writeOutput(
91
+ success({
92
+ authenticated: true,
93
+ username: meResponse.user.username,
94
+ email: meResponse.user.email,
95
+ }),
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Handle `geekbot auth status` command.
101
+ * Resolves current credential, verifies it, and shows source + profile.
102
+ */
103
+ export async function handleAuthStatus(globalOpts: GlobalOptions): Promise<void> {
104
+ try {
105
+ const { apiKey, source } = await resolveCredential({
106
+ apiKeyFlag: globalOpts.apiKey,
107
+ });
108
+ const client = createHttpClient(apiKey, { debug: globalOpts.debug });
109
+ const raw = await client.get<unknown>("/v1/me");
110
+ const meResponse = MeResponseSchema.parse(raw);
111
+ writeOutput(
112
+ success({
113
+ authenticated: true,
114
+ source,
115
+ username: meResponse.user.username,
116
+ email: meResponse.user.email,
117
+ }),
118
+ );
119
+ } catch (error) {
120
+ if (error instanceof CliError && error.code === "auth_missing") {
121
+ writeOutput(
122
+ success({
123
+ authenticated: false,
124
+ source: null,
125
+ username: null,
126
+ email: null,
127
+ }),
128
+ );
129
+ return;
130
+ }
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Handle `geekbot auth remove` command.
137
+ * Deletes API key from OS keychain.
138
+ */
139
+ export async function handleAuthRemove(_globalOpts: GlobalOptions): Promise<void> {
140
+ try {
141
+ deleteKeychainKey();
142
+ writeOutput(success({ removed: true }));
143
+ } catch {
144
+ throw new CliError(
145
+ "No API key found in OS keychain to remove.",
146
+ "keychain_not_found",
147
+ ExitCode.NOT_FOUND,
148
+ false,
149
+ "Key may not be stored in the OS keychain. Check: geekbot auth status",
150
+ );
151
+ }
152
+ }
@@ -0,0 +1,27 @@
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 { MeResponseSchema, MeTeamsResponseSchema } from "../schemas/user.ts";
6
+
7
+ /**
8
+ * Handle `geekbot me show` command.
9
+ * GET /v1/me returns {user, team}. We extract user portion only.
10
+ */
11
+ export async function handleMeShow(globalOpts: GlobalOptions): Promise<void> {
12
+ const client = await createAuthenticatedClient(globalOpts);
13
+ const raw = await client.get<unknown>("/v1/me");
14
+ const meResponse = MeResponseSchema.parse(raw);
15
+ writeOutput(success(meResponse.user));
16
+ }
17
+
18
+ /**
19
+ * Handle `geekbot me teams` command.
20
+ * GET /v1/me/teams returns {teams: [...]}.
21
+ */
22
+ export async function handleMeTeams(globalOpts: GlobalOptions): Promise<void> {
23
+ const client = await createAuthenticatedClient(globalOpts);
24
+ const raw = await client.get<unknown>("/v1/me/teams");
25
+ const teamsResponse = MeTeamsResponseSchema.parse(raw);
26
+ writeOutput(successList(teamsResponse.teams));
27
+ }
@@ -0,0 +1,187 @@
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 { PollListSchema, PollSchema, PollVotesResponseSchema } from "../schemas/poll.ts";
10
+ import { parseChoicesInput, parseDateFilter } from "../utils/input-parsers.ts";
11
+ import { buildReceipt } from "../utils/receipt.ts";
12
+ import { validateNumericId } from "../utils/validation.ts";
13
+
14
+ // ── Option Interfaces ─────────────────────────────────────────────────
15
+
16
+ export interface PollCreateOptions {
17
+ name: string;
18
+ channel: string;
19
+ question: string;
20
+ choices: string;
21
+ }
22
+
23
+ export interface PollVotesOptions {
24
+ after?: string;
25
+ before?: string;
26
+ }
27
+
28
+ // ── Platform Error Helper ─────────────────────────────────────────────
29
+
30
+ /**
31
+ * Wrap an async handler call to detect non-Slack team 404 errors on poll endpoints.
32
+ * The Geekbot API returns 404 for teams that don't support polls (non-Slack).
33
+ *
34
+ * Layering: handlers that target specific poll IDs nest enrichPollNotFound INSIDE
35
+ * this wrapper. enrichPollNotFound converts specific poll 404s to code "poll_not_found",
36
+ * which passes through the "not_found" check below unchanged. If enrichment returns null,
37
+ * the original "not_found" error propagates here and is caught as "platform_not_supported".
38
+ */
39
+ async function wrapPlatformError(fn: () => Promise<void>): Promise<void> {
40
+ try {
41
+ await fn();
42
+ } catch (error) {
43
+ // Only intercept errors with code "not_found" — enriched "poll_not_found"
44
+ // errors from enrichPollNotFound pass through without being re-mapped.
45
+ if (
46
+ error instanceof CliError &&
47
+ error.code === "not_found" &&
48
+ error.context?.path &&
49
+ String(error.context.path).startsWith("/v1/polls")
50
+ ) {
51
+ throw new CliError(
52
+ "Polls are only available for Slack teams. Your team appears to use a different platform.",
53
+ "platform_not_supported",
54
+ ExitCode.VALIDATION,
55
+ false,
56
+ "Polls require a Slack workspace. Check your team settings at geekbot.com.",
57
+ );
58
+ }
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ // ── Not-Found Enrichment ──────────────────────────────────────────────
64
+
65
+ /**
66
+ * Wrap a poll handler's async work to enrich 404 errors with suggestions.
67
+ * Creates the HttpClient once and passes it to the handler callback.
68
+ * Placed inside wrapPlatformError so platform error detection takes priority.
69
+ */
70
+ async function enrichPollNotFound(
71
+ fn: (client: HttpClient) => Promise<void>,
72
+ globalOpts: GlobalOptions,
73
+ ): Promise<void> {
74
+ const client = await createAuthenticatedClient(globalOpts);
75
+ try {
76
+ await fn(client);
77
+ } catch (error) {
78
+ if (error instanceof CliError && error.code === "not_found") {
79
+ const suggestion = await buildNotFoundSuggestion(client, "poll");
80
+ if (suggestion) {
81
+ throw new CliError(
82
+ error.message,
83
+ "poll_not_found",
84
+ error.exitCode,
85
+ error.retryable,
86
+ suggestion,
87
+ error.context,
88
+ );
89
+ }
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ // ── Handlers ──────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Handle `geekbot poll list` command.
99
+ * Fetches polls from GET /v1/polls.
100
+ */
101
+ export async function handlePollList(globalOpts: GlobalOptions): Promise<void> {
102
+ await wrapPlatformError(async () => {
103
+ const client = await createAuthenticatedClient(globalOpts);
104
+
105
+ const raw = await client.get<unknown>("/v1/polls");
106
+ const polls = PollListSchema.parse(raw);
107
+
108
+ writeOutput(successList(polls));
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Handle `geekbot poll get` command.
114
+ * Fetches a single poll by ID from GET /v1/polls/<id>.
115
+ */
116
+ export async function handlePollGet(id: string, globalOpts: GlobalOptions): Promise<void> {
117
+ const numericId = validateNumericId(id, "poll ID");
118
+
119
+ await wrapPlatformError(async () => {
120
+ await enrichPollNotFound(async (client) => {
121
+ const raw = await client.get<unknown>(`/v1/polls/${numericId}`);
122
+ const poll = PollSchema.parse(raw);
123
+ writeOutput(success(poll));
124
+ }, globalOpts);
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Handle `geekbot poll create` command.
130
+ * Creates a poll via POST /v1/polls.
131
+ */
132
+ export async function handlePollCreate(
133
+ options: PollCreateOptions,
134
+ globalOpts: GlobalOptions,
135
+ ): Promise<void> {
136
+ await wrapPlatformError(async () => {
137
+ const client = await createAuthenticatedClient(globalOpts);
138
+
139
+ const choices = parseChoicesInput(options.choices);
140
+
141
+ const body = {
142
+ name: options.name,
143
+ channel: options.channel,
144
+ question: options.question,
145
+ choices,
146
+ };
147
+
148
+ const raw = await client.post<unknown>("/v1/polls", body);
149
+ const poll = PollSchema.parse(raw);
150
+ const receipt = buildReceipt("created", null);
151
+
152
+ writeOutput(success(poll, receipt));
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Handle `geekbot poll votes` command.
158
+ * Fetches voting results from GET /v1/polls/<id>/votes.
159
+ * Maps CLI --after/--before to API from/to params.
160
+ */
161
+ export async function handlePollVotes(
162
+ id: string,
163
+ options: PollVotesOptions,
164
+ globalOpts: GlobalOptions,
165
+ ): Promise<void> {
166
+ const numericId = validateNumericId(id, "poll ID");
167
+
168
+ await wrapPlatformError(async () => {
169
+ await enrichPollNotFound(async (client) => {
170
+ const params: Record<string, string> = {};
171
+ if (options.after) {
172
+ params.from = parseDateFilter(options.after, "--after");
173
+ }
174
+ if (options.before) {
175
+ params.to = parseDateFilter(options.before, "--before");
176
+ }
177
+
178
+ const raw = await client.get<unknown>(
179
+ `/v1/polls/${numericId}/votes`,
180
+ Object.keys(params).length > 0 ? params : undefined,
181
+ );
182
+ const votesResponse = PollVotesResponseSchema.parse(raw);
183
+
184
+ writeOutput(success(votesResponse));
185
+ }, globalOpts);
186
+ });
187
+ }
@@ -0,0 +1,87 @@
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 { SubmittedReportSchema, TimelineReportListSchema } from "../schemas/report.ts";
6
+ import { parseAnswersInput, parseDateFilter } from "../utils/input-parsers.ts";
7
+ import { buildReceipt } from "../utils/receipt.ts";
8
+ import { validateLimit, validateNumericId, validateSlackId } from "../utils/validation.ts";
9
+
10
+ export interface ReportListOptions {
11
+ standupId?: string;
12
+ userId?: string;
13
+ before?: string;
14
+ after?: string;
15
+ limit?: string;
16
+ }
17
+
18
+ export interface ReportCreateOptions {
19
+ standupId: string;
20
+ answers: string;
21
+ }
22
+
23
+ /**
24
+ * Handle `geekbot report list` command.
25
+ * Fetches reports from GET /v1/reports with optional query filters.
26
+ */
27
+ export async function handleReportList(
28
+ options: ReportListOptions,
29
+ globalOpts: GlobalOptions,
30
+ ): Promise<void> {
31
+ const client = await createAuthenticatedClient(globalOpts);
32
+
33
+ const params: Record<string, string> = {};
34
+
35
+ if (options.standupId) {
36
+ validateNumericId(options.standupId, "standup ID");
37
+ params.standup_id = options.standupId;
38
+ }
39
+
40
+ if (options.userId) {
41
+ validateSlackId(options.userId, "user ID");
42
+ params.user_id = options.userId;
43
+ }
44
+
45
+ if (options.before) {
46
+ params.before = parseDateFilter(options.before, "--before");
47
+ }
48
+
49
+ if (options.after) {
50
+ params.after = parseDateFilter(options.after, "--after");
51
+ }
52
+
53
+ if (options.limit) {
54
+ const limitNum = validateLimit(options.limit);
55
+ params.limit = String(limitNum);
56
+ }
57
+
58
+ const raw = await client.get<unknown>("/v1/reports", params);
59
+ const reports = TimelineReportListSchema.parse(raw);
60
+
61
+ writeOutput(successList(reports));
62
+ }
63
+
64
+ /**
65
+ * Handle `geekbot report create` command.
66
+ * Submits a report via POST /v1/reports with standup_id and normalized answers.
67
+ */
68
+ export async function handleReportCreate(
69
+ options: ReportCreateOptions,
70
+ globalOpts: GlobalOptions,
71
+ ): Promise<void> {
72
+ const client = await createAuthenticatedClient(globalOpts);
73
+
74
+ const numericStandupId = validateNumericId(options.standupId, "standup ID");
75
+ const parsedAnswers = parseAnswersInput(options.answers);
76
+
77
+ const body = {
78
+ standup_id: numericStandupId,
79
+ answers: parsedAnswers,
80
+ };
81
+
82
+ const raw = await client.post<unknown>("/v1/reports", body);
83
+ const report = SubmittedReportSchema.parse(raw);
84
+ const receipt = buildReceipt("created", null);
85
+
86
+ writeOutput(success(report, receipt));
87
+ }