pi-producthunt 0.1.1

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.
@@ -0,0 +1,295 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { getPost, getPostComments, getPosts, getViewer, researchTopic, searchPosts } from "../lib/client.ts";
4
+ import { PRODUCTHUNT_TOKEN_ENV, authStatusText, clearStoredAccessToken, saveStoredAccessToken } from "../lib/config.ts";
5
+ import { formatComments, formatDigest, formatPostDetails, formatPostList, formatResearch, formatStatus } from "../lib/format.ts";
6
+ import { todayIsoDate, yesterdayIsoDate } from "../lib/identity.ts";
7
+ import { StringEnum } from "../lib/schema.ts";
8
+ import type { ResearchTopicResult } from "../lib/types.ts";
9
+
10
+ const emptyParameters = Type.Object({});
11
+
12
+ const limit = Type.Optional(Type.Integer({ description: "Maximum items to return", minimum: 1, maximum: 50 }));
13
+
14
+ const getPostsParameters = Type.Object({
15
+ date: Type.Optional(Type.String({ description: "UTC date in YYYY-MM-DD format" })),
16
+ topic: Type.Optional(Type.String({ description: "Product Hunt topic slug" })),
17
+ featured: Type.Optional(Type.Boolean({ description: "Whether to request featured posts" })),
18
+ order: Type.Optional(StringEnum(["RANKING", "NEWEST"], { description: "Product Hunt posts order" })),
19
+ limit,
20
+ });
21
+
22
+ const searchPostsParameters = Type.Object({
23
+ query: Type.String({ description: "Search keyword or phrase" }),
24
+ limit,
25
+ searchPool: Type.Optional(Type.Integer({ description: "Number of ranked posts to scan before filtering", minimum: 1, maximum: 100 })),
26
+ });
27
+
28
+ const postRefParameters = Type.Object({
29
+ ref: Type.String({ description: "Product Hunt post slug, numeric ID, or Product Hunt post URL" }),
30
+ });
31
+
32
+ const commentsParameters = Type.Object({
33
+ ref: Type.String({ description: "Product Hunt post slug, numeric ID, or Product Hunt post URL" }),
34
+ limit,
35
+ order: Type.Optional(StringEnum(["RANKING", "NEWEST"], { description: "Product Hunt comments order" })),
36
+ });
37
+
38
+ const researchParameters = Type.Object({
39
+ query: Type.String({ description: "Research topic or keyword" }),
40
+ limit,
41
+ commentsPerPost: Type.Optional(Type.Integer({ description: "Comments to collect for each matched post", minimum: 0, maximum: 10 })),
42
+ });
43
+
44
+ const digestParameters = Type.Object({
45
+ date: Type.Optional(Type.String({ description: "UTC date in YYYY-MM-DD format. Defaults to today." })),
46
+ limit,
47
+ commentsPerPost: Type.Optional(Type.Integer({ description: "Comments to collect for top posts", minimum: 0, maximum: 10 })),
48
+ });
49
+
50
+ export default function (pi: ExtensionAPI) {
51
+ registerTools(pi);
52
+ registerCommands(pi);
53
+ }
54
+
55
+ function registerTools(pi: ExtensionAPI) {
56
+ pi.registerTool({
57
+ name: "producthunt_status",
58
+ label: "Product Hunt Status",
59
+ description: "Check Product Hunt API authentication status using PRODUCTHUNT_ACCESS_TOKEN",
60
+ promptSnippet: "producthunt_status: check Product Hunt API authentication status",
61
+ promptGuidelines: ["Use producthunt_status before Product Hunt research when authentication may be missing or stale."],
62
+ parameters: emptyParameters,
63
+ async execute(_toolCallId, _params, signal) {
64
+ const result = await getViewer({ signal });
65
+ return { content: [{ type: "text", text: formatStatus(result) }], details: result };
66
+ },
67
+ });
68
+
69
+ pi.registerTool({
70
+ name: "producthunt_get_posts",
71
+ label: "Product Hunt Posts",
72
+ description: "Get Product Hunt posts by date, topic, featured flag, or order",
73
+ promptSnippet: "producthunt_get_posts: fetch Product Hunt launch lists for trend scans",
74
+ promptGuidelines: ["Use producthunt_get_posts for daily Product Hunt launch scans and digest source material."],
75
+ parameters: getPostsParameters,
76
+ async execute(_toolCallId, params, signal) {
77
+ const result = await getPosts(params, { signal });
78
+ return { content: [{ type: "text", text: formatPostList("Product Hunt Posts", result) }], details: result };
79
+ },
80
+ });
81
+
82
+ pi.registerTool({
83
+ name: "producthunt_search_posts",
84
+ label: "Product Hunt Search",
85
+ description: "Search ranked Product Hunt posts by product name, tagline, or topic",
86
+ promptSnippet: "producthunt_search_posts: search Product Hunt launches by keyword",
87
+ promptGuidelines: ["Use producthunt_search_posts for Product Hunt competitor or topic research."],
88
+ parameters: searchPostsParameters,
89
+ async execute(_toolCallId, params, signal) {
90
+ const result = await searchPosts(params, { signal });
91
+ return { content: [{ type: "text", text: formatPostList(`Product Hunt Search: ${params.query}`, result) }], details: result };
92
+ },
93
+ });
94
+
95
+ pi.registerTool({
96
+ name: "producthunt_get_post",
97
+ label: "Product Hunt Post",
98
+ description: "Get one Product Hunt post by slug, numeric ID, or Product Hunt URL",
99
+ promptSnippet: "producthunt_get_post: inspect one Product Hunt post in detail",
100
+ promptGuidelines: ["Use producthunt_get_post before summarizing a specific Product Hunt launch."],
101
+ parameters: postRefParameters,
102
+ async execute(_toolCallId, params, signal) {
103
+ const post = await getPost(params.ref, { signal });
104
+ return { content: [{ type: "text", text: formatPostDetails(post) }], details: post };
105
+ },
106
+ });
107
+
108
+ pi.registerTool({
109
+ name: "producthunt_get_post_comments",
110
+ label: "Product Hunt Comments",
111
+ description: "Get comments for one Product Hunt post by slug, numeric ID, or Product Hunt URL",
112
+ promptSnippet: "producthunt_get_post_comments: collect user reactions from a Product Hunt post",
113
+ promptGuidelines: ["Use producthunt_get_post_comments to extract praise, complaints, questions, and pricing concerns."],
114
+ parameters: commentsParameters,
115
+ async execute(_toolCallId, params, signal) {
116
+ const result = await getPostComments(params, { signal });
117
+ return { content: [{ type: "text", text: formatComments(result) }], details: result };
118
+ },
119
+ });
120
+
121
+ pi.registerTool({
122
+ name: "producthunt_research_topic",
123
+ label: "Product Hunt Research",
124
+ description: "Search Product Hunt and collect comment signals for a research topic",
125
+ promptSnippet: "producthunt_research_topic: gather Product Hunt posts plus comments for research",
126
+ promptGuidelines: ["Use producthunt_research_topic when the user asks for Product Hunt market, competitor, or trend research."],
127
+ parameters: researchParameters,
128
+ async execute(_toolCallId, params, signal) {
129
+ const result = await researchTopic(params, { signal });
130
+ return { content: [{ type: "text", text: formatResearch(result) }], details: result };
131
+ },
132
+ });
133
+
134
+ pi.registerTool({
135
+ name: "producthunt_digest",
136
+ label: "Product Hunt Digest",
137
+ description: "Create digest-ready Markdown for Product Hunt launches on a date",
138
+ promptSnippet: "producthunt_digest: prepare Product Hunt daily digest source material",
139
+ promptGuidelines: ["Use producthunt_digest when the user wants a Product Hunt daily digest or note material."],
140
+ parameters: digestParameters,
141
+ async execute(_toolCallId, params, signal) {
142
+ const date = params.date ?? todayIsoDate();
143
+ const digest = await buildDigest(date, params.limit ?? 10, params.commentsPerPost ?? 3, signal);
144
+ return { content: [{ type: "text", text: formatDigest(date, digest) }], details: { date, ...digest } };
145
+ },
146
+ });
147
+ }
148
+
149
+ function registerCommands(pi: ExtensionAPI) {
150
+ pi.registerCommand("producthunt:status", {
151
+ description: "Check Product Hunt API authentication status",
152
+ handler: async (_args, ctx) =>
153
+ runCommand(pi, ctx, "status", async () => {
154
+ const auth = authStatusText();
155
+ const viewer = await getViewer({ signal: ctx.signal });
156
+ return `${auth}\n\n${formatStatus(viewer)}`;
157
+ }),
158
+ });
159
+
160
+ pi.registerCommand("producthunt:login", {
161
+ description: "Enter and store Product Hunt access token via Pi UI",
162
+ handler: async (_args, ctx) => {
163
+ const entered = await ctx.ui.input("Product Hunt access token:", "paste access token here");
164
+ const accessToken = String(entered ?? "").trim();
165
+ if (!accessToken) {
166
+ ctx.ui.notify("Product Hunt token was not saved.", "warning");
167
+ return;
168
+ }
169
+
170
+ saveStoredAccessToken(accessToken);
171
+ ctx.ui.notify(
172
+ "Saved Product Hunt token for pi-producthunt. The token was handled by the extension UI and not sent to the model.",
173
+ "info",
174
+ );
175
+ },
176
+ });
177
+
178
+ pi.registerCommand("producthunt:logout", {
179
+ description: "Remove stored Product Hunt access token from pi-producthunt auth file",
180
+ handler: async (_args, ctx) => {
181
+ clearStoredAccessToken();
182
+ ctx.ui.notify(
183
+ `${PRODUCTHUNT_TOKEN_ENV} environment variable is unchanged. Removed stored pi-producthunt token.`,
184
+ "info",
185
+ );
186
+ },
187
+ });
188
+
189
+ pi.registerCommand("producthunt:today", {
190
+ description: "Show today's Product Hunt launches",
191
+ handler: async (_args, ctx) =>
192
+ runCommand(pi, ctx, "today", async () => {
193
+ const date = todayIsoDate();
194
+ return formatPostList(`Product Hunt Today ${date}`, await getPosts({ date, limit: 10 }, { signal: ctx.signal }));
195
+ }),
196
+ });
197
+
198
+ pi.registerCommand("producthunt:search", {
199
+ description: "Interactively search Product Hunt posts",
200
+ handler: async (_args, ctx) =>
201
+ runCommand(pi, ctx, "search", async () => {
202
+ const query = await requiredInput(ctx, "Product Hunt search", "Search keyword or phrase");
203
+ return formatPostList(`Product Hunt Search: ${query}`, await searchPosts({ query, limit: 10, searchPool: 75 }, { signal: ctx.signal }));
204
+ }),
205
+ });
206
+
207
+ pi.registerCommand("producthunt:post", {
208
+ description: "Interactively inspect one Product Hunt post",
209
+ handler: async (_args, ctx) =>
210
+ runCommand(pi, ctx, "post", async () => {
211
+ const ref = await requiredInput(ctx, "Product Hunt post", "Slug, ID, or URL");
212
+ return formatPostDetails(await getPost(ref, { signal: ctx.signal }));
213
+ }),
214
+ });
215
+
216
+ pi.registerCommand("producthunt:comments", {
217
+ description: "Interactively collect Product Hunt post comments",
218
+ handler: async (_args, ctx) =>
219
+ runCommand(pi, ctx, "comments", async () => {
220
+ const ref = await requiredInput(ctx, "Product Hunt comments", "Post slug, ID, or URL");
221
+ return formatComments(await getPostComments({ ref, limit: 10 }, { signal: ctx.signal }));
222
+ }),
223
+ });
224
+
225
+ pi.registerCommand("producthunt:digest", {
226
+ description: "Create Product Hunt digest source material",
227
+ handler: async (_args, ctx) =>
228
+ runCommand(pi, ctx, "digest", async () => {
229
+ const date = await chooseDate(ctx);
230
+ return formatDigest(date, await buildDigest(date, 10, 3, ctx.signal));
231
+ }),
232
+ });
233
+
234
+ pi.registerCommand("producthunt:research", {
235
+ description: "Interactively research a Product Hunt topic",
236
+ handler: async (_args, ctx) =>
237
+ runCommand(pi, ctx, "research", async () => {
238
+ const query = await requiredInput(ctx, "Product Hunt research", "Research topic or keyword");
239
+ return formatResearch(await researchTopic({ query, limit: 5, commentsPerPost: 3 }, { signal: ctx.signal }));
240
+ }),
241
+ });
242
+ }
243
+
244
+ async function buildDigest(
245
+ date: string,
246
+ limit: number,
247
+ commentsPerPost: number,
248
+ signal: AbortSignal | undefined,
249
+ ): Promise<ResearchTopicResult> {
250
+ const posts = await getPosts({ date, limit, order: "RANKING" }, { signal });
251
+ const withComments: ResearchTopicResult["posts"] = [];
252
+
253
+ for (const post of posts.posts) {
254
+ if (commentsPerPost <= 0) {
255
+ withComments.push(post);
256
+ continue;
257
+ }
258
+
259
+ try {
260
+ const comments = await getPostComments({ ref: post.slug, limit: commentsPerPost }, { signal });
261
+ withComments.push({ ...post, comments: comments.comments });
262
+ } catch {
263
+ withComments.push(post);
264
+ }
265
+ }
266
+
267
+ return { query: date, posts: withComments, rateLimit: posts.rateLimit };
268
+ }
269
+
270
+ async function runCommand(pi: ExtensionAPI, ctx: ExtensionCommandContext, kind: string, work: () => Promise<string>) {
271
+ try {
272
+ const markdown = await work();
273
+ pi.sendMessage({ customType: "producthunt", content: markdown, display: true, details: { kind } });
274
+ } catch (error) {
275
+ const message = error instanceof Error ? error.message : String(error);
276
+ ctx.ui.notify(`Product Hunt error: ${message}`, "error");
277
+ pi.sendMessage({ customType: "producthunt", content: `Product Hunt error: ${message}`, display: true, details: { kind, error: true } });
278
+ }
279
+ }
280
+
281
+ async function requiredInput(ctx: ExtensionCommandContext, title: string, placeholder: string): Promise<string> {
282
+ if (!ctx.hasUI) throw new Error(`${title} requires interactive input.`);
283
+ const input = await ctx.ui.input(title, placeholder);
284
+ const value = input?.trim();
285
+ if (!value) throw new Error("Input cancelled or empty.");
286
+ return value;
287
+ }
288
+
289
+ async function chooseDate(ctx: ExtensionCommandContext): Promise<string> {
290
+ if (!ctx.hasUI) return todayIsoDate();
291
+ const choice = await ctx.ui.select("Product Hunt digest date", ["today", "yesterday", "custom"]);
292
+ if (choice === "yesterday") return yesterdayIsoDate();
293
+ if (choice === "custom") return requiredInput(ctx, "Product Hunt digest date", "YYYY-MM-DD");
294
+ return todayIsoDate();
295
+ }
package/lib/api.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { PRODUCTHUNT_GRAPHQL_ENDPOINT, getProductHuntToken, redactProductHuntToken } from "./config.ts";
2
+ import type { RateLimitInfo } from "./types.ts";
3
+
4
+ export class ProductHuntApiError extends Error {
5
+ readonly status?: number;
6
+ readonly rateLimit?: RateLimitInfo;
7
+ readonly graphQLErrors?: unknown[];
8
+
9
+ constructor(message: string, options: { status?: number; rateLimit?: RateLimitInfo; graphQLErrors?: unknown[] } = {}) {
10
+ super(message);
11
+ this.name = "ProductHuntApiError";
12
+ this.status = options.status;
13
+ this.rateLimit = options.rateLimit;
14
+ this.graphQLErrors = options.graphQLErrors;
15
+ }
16
+ }
17
+
18
+ interface GraphQLResponse<T> {
19
+ data?: T | null;
20
+ errors?: unknown[];
21
+ }
22
+
23
+ export interface GraphQLResult<T> {
24
+ data: T;
25
+ rateLimit: RateLimitInfo;
26
+ }
27
+
28
+ export async function executeProductHuntGraphQL<T>(
29
+ query: string,
30
+ variables: Record<string, unknown> = {},
31
+ options: { signal?: AbortSignal; token?: string } = {},
32
+ ): Promise<GraphQLResult<T>> {
33
+ const token = options.token ?? getProductHuntToken();
34
+
35
+ const response = await fetch(PRODUCTHUNT_GRAPHQL_ENDPOINT, {
36
+ method: "POST",
37
+ signal: options.signal,
38
+ headers: {
39
+ Accept: "application/json",
40
+ "Content-Type": "application/json",
41
+ Authorization: `Bearer ${token}`,
42
+ },
43
+ body: JSON.stringify({ query, variables }),
44
+ });
45
+
46
+ const rateLimit = extractRateLimit(response.headers);
47
+ const text = await response.text();
48
+ let payload: GraphQLResponse<T> | undefined;
49
+
50
+ try {
51
+ payload = text ? (JSON.parse(text) as GraphQLResponse<T>) : undefined;
52
+ } catch {
53
+ throw new ProductHuntApiError(redactProductHuntToken(`Product Hunt returned non-JSON response: ${text}`, token), {
54
+ status: response.status,
55
+ rateLimit,
56
+ });
57
+ }
58
+
59
+ if (!response.ok) {
60
+ const message = formatGraphQLErrors(payload?.errors) || `Product Hunt request failed with HTTP ${response.status}`;
61
+ throw new ProductHuntApiError(redactProductHuntToken(message, token), {
62
+ status: response.status,
63
+ rateLimit,
64
+ graphQLErrors: payload?.errors,
65
+ });
66
+ }
67
+
68
+ if (payload?.errors?.length) {
69
+ throw new ProductHuntApiError(redactProductHuntToken(formatGraphQLErrors(payload.errors), token), {
70
+ status: response.status,
71
+ rateLimit,
72
+ graphQLErrors: payload.errors,
73
+ });
74
+ }
75
+
76
+ if (!payload || payload.data == null) {
77
+ throw new ProductHuntApiError("Product Hunt response did not include data.", { status: response.status, rateLimit });
78
+ }
79
+
80
+ return { data: payload.data, rateLimit };
81
+ }
82
+
83
+ function extractRateLimit(headers: Headers): RateLimitInfo {
84
+ return {
85
+ limit: headers.get("x-rate-limit-limit") ?? undefined,
86
+ remaining: headers.get("x-rate-limit-remaining") ?? undefined,
87
+ reset: headers.get("x-rate-limit-reset") ?? undefined,
88
+ };
89
+ }
90
+
91
+ function formatGraphQLErrors(errors: unknown[] | undefined): string {
92
+ if (!errors?.length) return "";
93
+ return errors
94
+ .map((error) => {
95
+ if (typeof error === "string") return error;
96
+ if (isRecord(error)) {
97
+ const description = error.error_description;
98
+ const message = error.message;
99
+ const code = error.error;
100
+ return [code, description ?? message].filter((part): part is string => typeof part === "string").join(": ");
101
+ }
102
+ return JSON.stringify(error);
103
+ })
104
+ .join("; ");
105
+ }
106
+
107
+ function isRecord(value: unknown): value is Record<string, unknown> {
108
+ return typeof value === "object" && value !== null;
109
+ }
110
+
@@ -0,0 +1,68 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export const AUTH_FILE_NAME = "pi-producthunt-auth.json";
6
+
7
+ export interface ProductHuntAuthRecord {
8
+ type: "access_token";
9
+ accessToken: string;
10
+ updatedAt: string;
11
+ }
12
+
13
+ export function getAgentDir(): string {
14
+ const override = process.env.PI_PRODUCTHUNT_AGENT_DIR?.trim();
15
+ if (override) return override;
16
+ return join(homedir(), ".pi", "agent");
17
+ }
18
+
19
+ export function getAuthFilePath(): string {
20
+ return join(getAgentDir(), AUTH_FILE_NAME);
21
+ }
22
+
23
+ function ensureAgentDir(): void {
24
+ try {
25
+ mkdirSync(getAgentDir(), { recursive: true });
26
+ } catch {
27
+ // ignore
28
+ }
29
+ }
30
+
31
+ export function loadStoredAccessToken(): string | undefined {
32
+ const file = getAuthFilePath();
33
+ try {
34
+ if (!existsSync(file)) return undefined;
35
+ const parsed = JSON.parse(readFileSync(file, "utf-8")) as Partial<ProductHuntAuthRecord>;
36
+ if (parsed.type !== "access_token" || typeof parsed.accessToken !== "string") return undefined;
37
+ const trimmed = parsed.accessToken.trim();
38
+ return trimmed ? trimmed : undefined;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ export function saveStoredAccessToken(accessToken: string): void {
45
+ ensureAgentDir();
46
+ const file = getAuthFilePath();
47
+ const record: ProductHuntAuthRecord = {
48
+ type: "access_token",
49
+ accessToken: accessToken.trim(),
50
+ updatedAt: new Date().toISOString(),
51
+ };
52
+ writeFileSync(file, JSON.stringify(record, null, 2), "utf-8");
53
+ try {
54
+ chmodSync(file, 0o600);
55
+ } catch {
56
+ // ignore chmod errors on platforms without POSIX modes
57
+ }
58
+ }
59
+
60
+ export function clearStoredAccessToken(): void {
61
+ const file = getAuthFilePath();
62
+ try {
63
+ if (existsSync(file)) rmSync(file);
64
+ } catch {
65
+ // ignore
66
+ }
67
+ }
68
+