@prompts-gpt/client 0.1.0 → 0.2.2

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 CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  export const DEFAULT_PROMPTS_GPT_API_URL = "https://prompts-gpt.com";
@@ -10,56 +10,76 @@ export class PromptsGptApiError extends Error {
10
10
  status;
11
11
  code;
12
12
  recovery;
13
+ requestId;
14
+ fieldErrors;
15
+ retryAfterMs;
13
16
  constructor(message, options) {
14
17
  super(message);
15
18
  this.name = "PromptsGptApiError";
16
19
  this.status = options?.status ?? 0;
17
20
  this.code = options?.code ?? "UNKNOWN_ERROR";
18
21
  this.recovery = options?.recovery ?? "Retry the request or create a fresh project token.";
22
+ this.requestId = options?.requestId ?? null;
23
+ this.fieldErrors = options?.fieldErrors;
24
+ this.retryAfterMs = options?.retryAfterMs ?? null;
19
25
  }
20
26
  }
21
27
  const DEFAULT_TIMEOUT_MS = 30_000;
28
+ const DEFAULT_GENERATE_TIMEOUT_MS = 60_000;
22
29
  const MAX_RETRIES = 2;
23
- const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
30
+ const RETRYABLE_STATUS_CODES = new Set([408, 429, 502, 503, 504]);
24
31
  export class PromptsGptClient {
25
32
  apiUrl;
26
33
  token;
27
34
  fetchImpl;
28
35
  timeoutMs;
29
- constructor(options = {}) {
30
- this.apiUrl = safeNormalizeApiUrl(options.apiUrl ?? process.env.PROMPTS_GPT_API_URL ?? DEFAULT_PROMPTS_GPT_API_URL);
31
- this.token = options.token ?? process.env.PROMPTS_GPT_TOKEN ?? null;
32
- this.fetchImpl = options.fetch ?? globalThis.fetch;
36
+ constructor(options) {
37
+ this.apiUrl = safeNormalizeApiUrl(options.apiUrl);
38
+ this.token = options.token?.trim() || null;
39
+ this.fetchImpl = options.fetch;
33
40
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
34
41
  }
35
- async getProject() {
36
- const data = await this.request("/api/sdk/v1/project");
42
+ async getProject(options = {}) {
43
+ const data = await this.request("/api/sdk/v1/project", options);
37
44
  return data.project;
38
45
  }
39
- async pullPrompts(query = {}) {
40
- const params = new URLSearchParams();
41
- for (const [key, value] of Object.entries(query)) {
42
- if (value === undefined || value === null || value === "")
43
- continue;
44
- params.set(key === "query" ? "q" : key, String(value));
46
+ async pullPrompts(query = {}, options = {}) {
47
+ const qs = serializePromptQuery(query);
48
+ const suffix = qs ? `?${qs}` : "";
49
+ const data = await this.request(`/api/sdk/v1/prompts${suffix}`, options);
50
+ if (!Array.isArray(data.prompts)) {
51
+ throw new PromptsGptApiError("Prompts-GPT returned an invalid prompts payload.", {
52
+ code: "INVALID_RESPONSE",
53
+ recovery: "Retry the request. If it keeps failing, verify the SDK API version.",
54
+ });
45
55
  }
46
- const suffix = params.toString() ? `?${params.toString()}` : "";
47
- const data = await this.request(`/api/sdk/v1/prompts${suffix}`);
48
56
  return data.prompts;
49
57
  }
50
- async generatePrompt(input) {
51
- if (!input.goal || input.goal.trim().length < 8) {
58
+ async generatePrompt(input, options = {}) {
59
+ const trimmedGoal = input.goal?.trim() ?? "";
60
+ if (trimmedGoal.length < 8) {
52
61
  throw new PromptsGptApiError("Goal must be at least 8 characters.", { code: "VALIDATION_ERROR" });
53
62
  }
54
- if (input.goal.length > 160) {
63
+ if (trimmedGoal.length > 160) {
55
64
  throw new PromptsGptApiError("Goal must be 160 characters or fewer.", { code: "VALIDATION_ERROR" });
56
65
  }
57
- if (input.context && input.context.length > 1600) {
66
+ const trimmedContext = input.context?.trim() ?? "";
67
+ if (trimmedContext.length > 1600) {
58
68
  throw new PromptsGptApiError("Context must be 1600 characters or fewer.", { code: "VALIDATION_ERROR" });
59
69
  }
70
+ const trimmedConstraints = input.constraints?.trim() ?? "";
71
+ if (trimmedConstraints.length > 1600) {
72
+ throw new PromptsGptApiError("Constraints must be 1600 characters or fewer.", { code: "VALIDATION_ERROR" });
73
+ }
74
+ const trimmedDesiredOutput = input.desiredOutput?.trim() ?? "";
75
+ if (trimmedDesiredOutput.length > 1600) {
76
+ throw new PromptsGptApiError("Desired output must be 1600 characters or fewer.", { code: "VALIDATION_ERROR" });
77
+ }
60
78
  const data = await this.request("/api/sdk/v1/prompts/generate", {
61
79
  method: "POST",
62
80
  body: input,
81
+ timeoutMs: options.timeoutMs ?? DEFAULT_GENERATE_TIMEOUT_MS,
82
+ ...options,
63
83
  });
64
84
  return data.prompt;
65
85
  }
@@ -68,7 +88,13 @@ export class PromptsGptClient {
68
88
  throw new PromptsGptApiError("Project token is missing.", {
69
89
  status: 401,
70
90
  code: "AUTH_ERROR",
71
- recovery: "Run `prompts-gpt init --token <token>` or set PROMPTS_GPT_TOKEN.",
91
+ recovery: "Run `prompts-gpt init --token <token>` or pass `--token`, `--token-stdin`, or `--token-prompt` to the CLI command.",
92
+ });
93
+ }
94
+ if (!this.token.startsWith("pgpt_")) {
95
+ throw new PromptsGptApiError("Token must start with the 'pgpt_' prefix.", {
96
+ code: "VALIDATION_ERROR",
97
+ recovery: "Ensure the token value is copied correctly from the dashboard.",
72
98
  });
73
99
  }
74
100
  const fetchFn = this.fetchImpl;
@@ -78,48 +104,98 @@ export class PromptsGptClient {
78
104
  });
79
105
  }
80
106
  const controller = new AbortController();
81
- const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
107
+ const releaseLinkedAbort = linkAbortSignal(options.signal, controller);
108
+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs ?? this.timeoutMs);
109
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
110
+ timeout.unref?.();
111
+ const method = options.method ?? "GET";
82
112
  try {
83
- const response = await fetchFn(new URL(pathname, this.apiUrl), {
84
- method: options.method ?? "GET",
85
- headers: {
86
- authorization: `Bearer ${this.token}`,
87
- "user-agent": `prompts-gpt-client/${getClientVersion()}`,
88
- ...(options.body ? { "content-type": "application/json" } : {}),
89
- },
113
+ if (options.signal?.aborted) {
114
+ throw new PromptsGptApiError(`Request to ${pathname} was aborted by the caller.`, {
115
+ code: "REQUEST_ABORTED",
116
+ recovery: "Start a new request when you are ready to continue.",
117
+ });
118
+ }
119
+ const url = new URL(pathname, this.apiUrl);
120
+ const headers = {
121
+ authorization: `Bearer ${this.token}`,
122
+ accept: "application/json",
123
+ "x-prompts-gpt-client": `@prompts-gpt/client/${getClientVersion()}`,
124
+ };
125
+ if (!options.omitUserAgent) {
126
+ headers["user-agent"] = `prompts-gpt-client/${getClientVersion()}`;
127
+ }
128
+ const outgoingRequestId = options.requestId?.trim() || generateRequestId();
129
+ headers["x-request-id"] = outgoingRequestId;
130
+ if (options.body) {
131
+ headers["content-type"] = "application/json";
132
+ }
133
+ const response = await fetchFn(url, {
134
+ method,
135
+ headers,
90
136
  body: options.body ? JSON.stringify(options.body) : undefined,
91
137
  signal: controller.signal,
92
138
  });
93
- const payload = await response.json().catch(() => null);
94
- if (!response.ok || !payload?.ok) {
95
- if (retryCount < MAX_RETRIES && RETRYABLE_STATUS_CODES.has(response.status)) {
96
- const retryAfter = parseInt(response.headers?.get?.("retry-after") ?? "", 10);
97
- const baseDelay = Number.isFinite(retryAfter) ? retryAfter * 1000 : (retryCount + 1) * 1000;
98
- const jitter = Math.random() * 500;
99
- await sleep(baseDelay + jitter);
139
+ const requestId = response.headers?.get?.("x-request-id") ?? outgoingRequestId;
140
+ const retryAfterMs = parseRetryAfterHeader(response.headers?.get?.("retry-after"));
141
+ const contentType = response.headers?.get?.("content-type") ?? "";
142
+ if (!isJsonContentType(contentType) && response.status !== 204) {
143
+ throw new PromptsGptApiError(`Unexpected response content-type: ${contentType || "none"}`, {
144
+ status: response.status,
145
+ code: "INVALID_RESPONSE",
146
+ recovery: "Verify the API URL is correct and the server is accessible.",
147
+ requestId,
148
+ retryAfterMs,
149
+ });
150
+ }
151
+ const payload = await parseJsonResponse(response);
152
+ const apiResponse = parseApiResponse(payload);
153
+ if (!response.ok || !apiResponse || !apiResponse.ok) {
154
+ if (!apiResponse) {
155
+ throw new PromptsGptApiError("Prompts-GPT returned an unexpected API envelope.", {
156
+ status: response.status,
157
+ code: "INVALID_RESPONSE",
158
+ recovery: "Retry the request. If it keeps failing, verify the SDK API version.",
159
+ requestId,
160
+ retryAfterMs,
161
+ });
162
+ }
163
+ if (shouldRetryRequest({ method, status: response.status, retryCount, retryAfterMs })) {
164
+ await sleep(computeRetryDelayMs(retryCount, retryAfterMs));
100
165
  return this.request(pathname, options, retryCount + 1);
101
166
  }
102
- const error = payload?.error;
167
+ const error = !apiResponse.ok ? apiResponse.error : null;
103
168
  throw new PromptsGptApiError(error?.message ?? `Prompts-GPT request failed with ${response.status}.`, {
104
169
  status: response.status,
105
170
  code: error?.code,
106
171
  recovery: error?.recovery,
172
+ requestId,
173
+ retryAfterMs,
174
+ fieldErrors: error?.fieldErrors,
107
175
  });
108
176
  }
109
- return payload.data;
177
+ return apiResponse.data;
110
178
  }
111
179
  catch (error) {
112
180
  if (error instanceof PromptsGptApiError)
113
181
  throw error;
182
+ if (!options.omitUserAgent && isUserAgentRuntimeError(error)) {
183
+ return this.request(pathname, { ...options, omitUserAgent: true }, retryCount);
184
+ }
114
185
  if (error?.name === "AbortError") {
115
- throw new PromptsGptApiError(`Request to ${pathname} timed out after ${this.timeoutMs}ms.`, {
186
+ if (options.signal?.aborted) {
187
+ throw new PromptsGptApiError(`Request to ${pathname} was aborted by the caller.`, {
188
+ code: "REQUEST_ABORTED",
189
+ recovery: "Start a new request when you are ready to continue.",
190
+ });
191
+ }
192
+ throw new PromptsGptApiError(`Request to ${pathname} timed out after ${timeoutMs}ms.`, {
116
193
  code: "TIMEOUT",
117
194
  recovery: "Check your network connection or increase the timeout.",
118
195
  });
119
196
  }
120
- if (retryCount < MAX_RETRIES && isNetworkError(error)) {
121
- const jitter = Math.random() * 500;
122
- await sleep((retryCount + 1) * 1000 + jitter);
197
+ if (retryCount < MAX_RETRIES && method === "GET" && isNetworkError(error)) {
198
+ await sleep(computeRetryDelayMs(retryCount));
123
199
  return this.request(pathname, options, retryCount + 1);
124
200
  }
125
201
  throw new PromptsGptApiError(error?.message ?? "Network request failed.", {
@@ -128,6 +204,7 @@ export class PromptsGptClient {
128
204
  });
129
205
  }
130
206
  finally {
207
+ releaseLinkedAbort();
131
208
  clearTimeout(timeout);
132
209
  }
133
210
  }
@@ -135,12 +212,140 @@ export class PromptsGptClient {
135
212
  function isNetworkError(error) {
136
213
  if (!error)
137
214
  return false;
215
+ const name = String(error.name ?? "").toLowerCase();
138
216
  const message = String(error.message ?? "").toLowerCase();
139
- return message.includes("fetch") || message.includes("network") || message.includes("econnrefused") || message.includes("enotfound");
217
+ return (name === "typeerror" ||
218
+ message.includes("fetch") ||
219
+ message.includes("network") ||
220
+ message.includes("econnrefused") ||
221
+ message.includes("enotfound") ||
222
+ message.includes("eai_again") ||
223
+ message.includes("ecancelled") ||
224
+ message.includes("socket"));
225
+ }
226
+ function isUserAgentRuntimeError(error) {
227
+ const message = String(error?.message ?? "").toLowerCase();
228
+ return message.includes("user-agent") && message.includes("not allowed");
229
+ }
230
+ function linkAbortSignal(signal, controller) {
231
+ if (!signal)
232
+ return () => undefined;
233
+ if (signal.aborted) {
234
+ controller.abort(signal.reason);
235
+ return () => undefined;
236
+ }
237
+ const abort = () => controller.abort(signal.reason);
238
+ signal.addEventListener("abort", abort, { once: true });
239
+ return () => signal.removeEventListener("abort", abort);
240
+ }
241
+ function normalizeTimeoutMs(value) {
242
+ if (!Number.isFinite(value) || value <= 0) {
243
+ throw new PromptsGptApiError("Timeout must be a positive number of milliseconds.", {
244
+ code: "VALIDATION_ERROR",
245
+ recovery: "Provide a timeout greater than 0.",
246
+ });
247
+ }
248
+ return Math.min(Math.trunc(value), 300_000);
249
+ }
250
+ function serializePromptQuery(query) {
251
+ const params = new URLSearchParams();
252
+ const promptQuery = typeof query.query === "string" && query.query.trim() ? query.query : query.q;
253
+ if (typeof promptQuery === "string" && promptQuery.trim()) {
254
+ params.set("q", promptQuery.trim());
255
+ }
256
+ for (const [key, value] of Object.entries({
257
+ category: query.category,
258
+ tool: query.tool,
259
+ outputType: query.outputType,
260
+ })) {
261
+ if (typeof value === "string" && value.trim()) {
262
+ params.set(key, value.trim());
263
+ }
264
+ }
265
+ if (query.limit !== undefined && query.limit !== null && query.limit !== "") {
266
+ const limit = typeof query.limit === "string" ? Number(query.limit) : query.limit;
267
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
268
+ throw new PromptsGptApiError("Prompt limit must be an integer between 1 and 100.", {
269
+ code: "VALIDATION_ERROR",
270
+ recovery: "Choose a limit between 1 and 100.",
271
+ });
272
+ }
273
+ params.set("limit", String(limit));
274
+ }
275
+ return params.toString();
276
+ }
277
+ function isJsonContentType(contentType) {
278
+ const normalized = contentType.toLowerCase();
279
+ return normalized.includes("application/json") || normalized.includes("+json");
280
+ }
281
+ async function parseJsonResponse(response) {
282
+ if (response.status === 204)
283
+ return { ok: true, data: {} };
284
+ const rawBody = await response.text();
285
+ if (!rawBody.trim())
286
+ return null;
287
+ try {
288
+ return JSON.parse(rawBody);
289
+ }
290
+ catch {
291
+ throw new PromptsGptApiError("Prompts-GPT returned malformed JSON.", {
292
+ status: response.status,
293
+ code: "INVALID_RESPONSE",
294
+ recovery: "Retry the request. If it keeps failing, verify the API URL and server health.",
295
+ requestId: response.headers?.get?.("x-request-id") ?? null,
296
+ });
297
+ }
298
+ }
299
+ function parseApiResponse(payload) {
300
+ if (!payload || typeof payload !== "object" || !("ok" in payload))
301
+ return null;
302
+ if (payload.ok === true && "data" in payload) {
303
+ return payload;
304
+ }
305
+ if (payload.ok === false && "error" in payload) {
306
+ return payload;
307
+ }
308
+ return null;
309
+ }
310
+ function shouldRetryRequest({ method, status, retryCount, retryAfterMs, }) {
311
+ if (retryCount >= MAX_RETRIES)
312
+ return false;
313
+ if (method !== "GET")
314
+ return false;
315
+ if (!RETRYABLE_STATUS_CODES.has(status))
316
+ return false;
317
+ if (retryAfterMs !== null && retryAfterMs > 300_000)
318
+ return false;
319
+ return true;
320
+ }
321
+ function computeRetryDelayMs(retryCount, retryAfterMs = null) {
322
+ const baseDelay = retryAfterMs && retryAfterMs > 0 ? retryAfterMs : (retryCount + 1) * 1_000;
323
+ const jitter = Math.random() * 500;
324
+ return Math.min(baseDelay + jitter, 30_000);
325
+ }
326
+ function parseRetryAfterHeader(value) {
327
+ if (!value)
328
+ return null;
329
+ const trimmed = value.trim();
330
+ if (!trimmed)
331
+ return null;
332
+ const seconds = Number(trimmed);
333
+ if (Number.isFinite(seconds) && seconds >= 0) {
334
+ const ms = seconds * 1_000;
335
+ return Math.min(ms, 600_000);
336
+ }
337
+ const retryAt = Date.parse(trimmed);
338
+ if (Number.isNaN(retryAt))
339
+ return null;
340
+ return Math.min(Math.max(retryAt - Date.now(), 0), 600_000);
140
341
  }
141
342
  function sleep(ms) {
142
343
  return new Promise((resolve) => setTimeout(resolve, ms));
143
344
  }
345
+ function generateRequestId() {
346
+ const hex = Array.from({ length: 8 }, () => Math.floor(Math.random() * 256).toString(16).padStart(2, "0")).join("");
347
+ return `pgcli_${hex}`;
348
+ }
144
349
  let cachedVersion = null;
145
350
  function getClientVersion() {
146
351
  if (cachedVersion)
@@ -155,14 +360,21 @@ function getClientVersion() {
155
360
  return cachedVersion;
156
361
  }
157
362
  export async function saveLocalCredentials(input) {
158
- if (!input.token?.trim())
363
+ const trimmedToken = input.token?.trim();
364
+ if (!trimmedToken)
159
365
  throw new Error("Token is required.");
366
+ if (trimmedToken.length > 256)
367
+ throw new Error("Token value is too long.");
368
+ if (!trimmedToken.startsWith("pgpt_"))
369
+ throw new Error("Token must start with the 'pgpt_' prefix.");
160
370
  const cwd = input.cwd ?? process.cwd();
161
371
  const outDir = path.resolve(cwd, DEFAULT_PROMPTS_GPT_OUT_DIR);
162
372
  await mkdir(outDir, { recursive: true });
163
- await writeFile(path.join(outDir, PROMPTS_GPT_CREDENTIALS_FILE), `${JSON.stringify({ token: input.token.trim(), apiUrl: normalizeApiUrl(input.apiUrl ?? DEFAULT_PROMPTS_GPT_API_URL) }, null, 2)}\n`, { mode: 0o600 });
373
+ await writeFile(path.join(outDir, PROMPTS_GPT_CREDENTIALS_FILE), `${JSON.stringify({ token: trimmedToken, apiUrl: normalizeApiUrl(input.apiUrl ?? DEFAULT_PROMPTS_GPT_API_URL) }, null, 2)}\n`, { mode: 0o600 });
374
+ const credentialsPath = path.join(outDir, PROMPTS_GPT_CREDENTIALS_FILE);
375
+ await chmod(credentialsPath, 0o600).catch(() => undefined);
164
376
  await ensureGitignoreEntry(cwd, `${DEFAULT_PROMPTS_GPT_OUT_DIR}/${PROMPTS_GPT_CREDENTIALS_FILE}`);
165
- return { credentialsPath: path.join(outDir, PROMPTS_GPT_CREDENTIALS_FILE) };
377
+ return { credentialsPath };
166
378
  }
167
379
  export async function loadLocalCredentials(cwd = process.cwd()) {
168
380
  const credentialsPath = path.resolve(cwd, DEFAULT_PROMPTS_GPT_OUT_DIR, PROMPTS_GPT_CREDENTIALS_FILE);
@@ -170,9 +382,18 @@ export async function loadLocalCredentials(cwd = process.cwd()) {
170
382
  return null;
171
383
  try {
172
384
  const parsed = JSON.parse(await readFile(credentialsPath, "utf8"));
385
+ let apiUrl = DEFAULT_PROMPTS_GPT_API_URL;
386
+ if (typeof parsed.apiUrl === "string") {
387
+ try {
388
+ new URL(parsed.apiUrl);
389
+ apiUrl = parsed.apiUrl;
390
+ }
391
+ catch { }
392
+ }
393
+ const rawToken = typeof parsed.token === "string" ? parsed.token.trim() : null;
173
394
  return {
174
- token: typeof parsed.token === "string" ? parsed.token : null,
175
- apiUrl: typeof parsed.apiUrl === "string" ? parsed.apiUrl : DEFAULT_PROMPTS_GPT_API_URL,
395
+ token: rawToken || null,
396
+ apiUrl,
176
397
  };
177
398
  }
178
399
  catch {
@@ -181,7 +402,12 @@ export async function loadLocalCredentials(cwd = process.cwd()) {
181
402
  }
182
403
  export async function syncPrompts(prompts, options = {}) {
183
404
  const markdown = await writePromptMarkdownFiles(prompts, options);
184
- const agents = await writeAgentFiles(prompts, options);
405
+ const agents = await writeAgentFiles(prompts, {
406
+ cwd: options.cwd,
407
+ agent: options.agent,
408
+ agents: options.agents,
409
+ overwriteAgentFiles: options.overwrite,
410
+ });
185
411
  const manifest = await writePromptManifest(prompts, { cwd: options.cwd, outDir: options.outDir });
186
412
  return { markdown, agents, manifest };
187
413
  }
@@ -189,18 +415,31 @@ export async function writePromptMarkdownFiles(prompts, options = {}) {
189
415
  const cwd = options.cwd ?? process.cwd();
190
416
  const outDir = assertSafeOutputDir(cwd, options.outDir ?? DEFAULT_PROMPTS_GPT_OUT_DIR);
191
417
  const overwrite = Boolean(options.overwrite);
418
+ const normalizedPrompts = assertUniquePromptFileStems(prompts);
192
419
  await mkdir(outDir, { recursive: true });
193
420
  const written = [];
194
421
  const skipped = [];
195
- for (const prompt of prompts) {
196
- const filePath = path.join(outDir, `${safeSlug(prompt.slug || prompt.title)}.md`);
422
+ for (const { prompt, stem } of normalizedPrompts) {
423
+ const filePath = path.join(outDir, `${stem}.md`);
197
424
  assertInside(filePath, outDir);
198
- if (!overwrite && existsSync(filePath)) {
199
- skipped.push(filePath);
200
- continue;
425
+ if (overwrite) {
426
+ await writeFile(filePath, formatPromptMarkdown(prompt));
427
+ written.push(filePath);
428
+ }
429
+ else {
430
+ try {
431
+ await writeFile(filePath, formatPromptMarkdown(prompt), { flag: "wx" });
432
+ written.push(filePath);
433
+ }
434
+ catch (err) {
435
+ if (err.code === "EEXIST") {
436
+ skipped.push(filePath);
437
+ }
438
+ else {
439
+ throw err;
440
+ }
441
+ }
201
442
  }
202
- await writeFile(filePath, formatPromptMarkdown(prompt), { flag: overwrite ? "w" : "wx" });
203
- written.push(filePath);
204
443
  }
205
444
  await writePromptIndex(prompts, { outDir });
206
445
  return { outDir, written, skipped };
@@ -208,45 +447,72 @@ export async function writePromptMarkdownFiles(prompts, options = {}) {
208
447
  export async function writeAgentFiles(prompts, options = {}) {
209
448
  const cwd = options.cwd ?? process.cwd();
210
449
  const targets = normalizeAgentTargets(options.agent ?? options.agents ?? "all");
211
- const overwrite = Boolean(options.overwriteAgentFiles ?? true);
450
+ const overwrite = Boolean(options.overwriteAgentFiles);
212
451
  const written = [];
213
452
  const skipped = [];
214
- const allFiles = targets.flatMap((target) => buildAgentFiles(target, prompts).map((file) => ({ ...file, target })));
453
+ const normalizedPrompts = assertUniquePromptFileStems(prompts);
454
+ const allFiles = targets.flatMap((target) => buildAgentFiles(target, normalizedPrompts.filter(({ prompt }) => promptSupportsAgentTarget(prompt, target))).map((file) => ({ ...file, target })));
455
+ assertUniqueAgentFilePaths(allFiles);
215
456
  const dirSet = new Set(allFiles.map((file) => path.dirname(assertSafeProjectFile(cwd, file.path))));
216
457
  await Promise.all([...dirSet].map((dir) => mkdir(dir, { recursive: true })));
217
- await Promise.all(allFiles.map(async (file) => {
458
+ for (const file of allFiles) {
218
459
  const filePath = assertSafeProjectFile(cwd, file.path);
219
460
  if (file.managedBlock) {
220
- const existing = existsSync(filePath) ? await readFile(filePath, "utf8") : "";
461
+ let existing = "";
462
+ try {
463
+ existing = await readFile(filePath, "utf8");
464
+ }
465
+ catch { }
221
466
  await writeFile(filePath, upsertManagedBlock(existing, file.content));
222
467
  written.push(filePath);
223
- return;
468
+ continue;
469
+ }
470
+ if (overwrite) {
471
+ await writeFile(filePath, file.content);
472
+ written.push(filePath);
224
473
  }
225
- if (!overwrite && existsSync(filePath)) {
226
- skipped.push(filePath);
227
- return;
474
+ else {
475
+ try {
476
+ await writeFile(filePath, file.content, { flag: "wx" });
477
+ written.push(filePath);
478
+ }
479
+ catch (err) {
480
+ if (err.code === "EEXIST") {
481
+ skipped.push(filePath);
482
+ }
483
+ else {
484
+ throw err;
485
+ }
486
+ }
228
487
  }
229
- await writeFile(filePath, file.content, { flag: overwrite ? "w" : "wx" });
230
- written.push(filePath);
231
- }));
488
+ }
232
489
  return { written, skipped, targets };
233
490
  }
234
491
  export async function writePromptManifest(prompts, options = {}) {
235
492
  const cwd = options.cwd ?? process.cwd();
236
493
  const outDir = assertSafeOutputDir(cwd, options.outDir ?? DEFAULT_PROMPTS_GPT_OUT_DIR);
494
+ const normalizedPrompts = assertUniquePromptFileStems(prompts);
237
495
  await mkdir(outDir, { recursive: true });
238
496
  const manifestPath = path.join(outDir, PROMPTS_GPT_MANIFEST_FILE);
239
497
  const payload = {
240
498
  version: 1,
241
499
  generatedAt: new Date().toISOString(),
242
- count: prompts.length,
243
- prompts: prompts.map((prompt) => ({
244
- slug: safeSlug(prompt.slug || prompt.title),
500
+ count: normalizedPrompts.length,
501
+ prompts: normalizedPrompts.map(({ prompt, stem }) => ({
502
+ slug: stem,
245
503
  title: prompt.title,
504
+ summary: prompt.summary ?? "",
246
505
  source: prompt.source ?? "library",
247
506
  category: prompt.category ?? "Prompt Library",
507
+ difficulty: prompt.difficulty ?? "Intermediate",
508
+ outputType: prompt.outputType ?? "Text",
248
509
  supportedTools: prompt.supportedTools ?? [],
249
- file: `${safeSlug(prompt.slug || prompt.title)}.md`,
510
+ agentTargets: normalizePromptAgentTargets(prompt),
511
+ variables: prompt.variables ?? [],
512
+ tags: prompt.tags ?? [],
513
+ recommendedPath: prompt.recommendedPath ?? null,
514
+ file: `${stem}.md`,
515
+ files: buildDiscoverablePromptFiles(stem, prompt),
250
516
  })),
251
517
  };
252
518
  await writeFile(manifestPath, `${JSON.stringify(payload, null, 2)}\n`);
@@ -273,11 +539,8 @@ export function formatPromptMarkdown(prompt) {
273
539
  "",
274
540
  prompt.promptText ?? "",
275
541
  "",
276
- prompt.variables?.length ? "## Variables" : "",
277
- ...(prompt.variables?.length ? prompt.variables.map((variable) => `- \`${variable}\``) : []),
278
- prompt.variables?.length ? "" : "",
279
- prompt.usageNotes ? "## Usage Notes" : "",
280
- prompt.usageNotes ?? "",
542
+ ...(prompt.variables?.length ? ["## Variables", "", ...prompt.variables.map((variable) => `- \`${variable}\``), ""] : []),
543
+ ...(prompt.usageNotes ? ["## Usage Notes", "", prompt.usageNotes] : []),
281
544
  "",
282
545
  ].reduce((acc, line, index, list) => {
283
546
  if (line === "" && index > 0 && list[index - 1] === "" && (index < 2 || list[index - 2] === ""))
@@ -294,10 +557,10 @@ function buildAgentFiles(target, prompts) {
294
557
  content: [
295
558
  "# Prompts-GPT Agent Instructions",
296
559
  "",
297
- "Prompts synced by `prompts-gpt sync` live in `.prompts-gpt/`. Use the manifest and prompt files before starting related work.",
560
+ "Prompts synced by `prompts-gpt sync` live in `.prompts-gpt/`. Start with [.prompts-gpt/manifest.json](.prompts-gpt/manifest.json), then open the prompt packs linked below before starting related work.",
298
561
  "",
299
562
  "## Available Prompt Packs",
300
- ...prompts.map((prompt) => `- ${prompt.title}: .prompts-gpt/${safeSlug(prompt.slug || prompt.title)}.md`),
563
+ ...prompts.map(({ prompt, stem }) => `- [${prompt.title}](.prompts-gpt/${stem}.md)`),
301
564
  "",
302
565
  "When a prompt pack is relevant, load it, adapt variables to the current task, and keep verification tied to the prompt's acceptance criteria.",
303
566
  "",
@@ -305,12 +568,12 @@ function buildAgentFiles(target, prompts) {
305
568
  }];
306
569
  }
307
570
  if (target === "cursor") {
308
- return prompts.map((prompt) => ({
309
- path: `.cursor/rules/prompts-gpt-${safeSlug(prompt.slug || prompt.title)}.mdc`,
571
+ return prompts.map(({ prompt, stem }) => ({
572
+ path: `.cursor/rules/prompts-gpt-${stem}.mdc`,
310
573
  content: [
311
574
  "---",
312
- `description: ${prompt.summary || prompt.title}`,
313
- "globs:",
575
+ `description: ${yamlScalar(prompt.summary || prompt.title)}`,
576
+ "globs: []",
314
577
  "alwaysApply: false",
315
578
  "---",
316
579
  "",
@@ -331,9 +594,26 @@ function buildAgentFiles(target, prompts) {
331
594
  content: [
332
595
  "# Prompts-GPT Copilot Instructions",
333
596
  "",
334
- "Use `.prompts-gpt/manifest.json` and `.prompts-gpt/*.md` as reusable prompt packs for this repository.",
597
+ "Use [../.prompts-gpt/manifest.json](../.prompts-gpt/manifest.json) and the linked `.prompts-gpt/*.md` prompt packs as reusable repository context.",
598
+ "",
599
+ ...prompts.map(({ prompt, stem }) => `- [${prompt.title}](../.prompts-gpt/${stem}.md)`),
600
+ "",
601
+ ].join("\n"),
602
+ },
603
+ {
604
+ path: ".github/instructions/prompts-gpt.instructions.md",
605
+ content: [
606
+ "---",
607
+ 'applyTo: "AGENTS.md,.prompts-gpt/**/*.md,.github/copilot-instructions.md,.github/prompts/**/*.prompt.md,.cursor/rules/**/*.mdc,.vscode/prompts-gpt.code-snippets"',
608
+ "---",
609
+ "",
610
+ "# Prompts-GPT managed artifacts",
611
+ "",
612
+ "These files are generated or refreshed by `prompts-gpt sync` and `prompts-gpt install-agents`.",
335
613
  "",
336
- ...prompts.map((prompt) => `- ${prompt.title}: .prompts-gpt/${safeSlug(prompt.slug || prompt.title)}.md`),
614
+ "- Treat `.prompts-gpt/manifest.json` as the source of truth for discoverable prompt packs and generated agent files.",
615
+ "- Prefer updating the upstream prompt pack or rerunning sync instead of manually editing generated agent artifacts.",
616
+ "- Preserve the managed `prompts-gpt` blocks inside `AGENTS.md` and `.github/copilot-instructions.md`.",
337
617
  "",
338
618
  ].join("\n"),
339
619
  },
@@ -344,27 +624,123 @@ function buildAgentFiles(target, prompts) {
344
624
  ];
345
625
  }
346
626
  if (target === "copilot") {
347
- return prompts.map((prompt) => ({
348
- path: `.github/prompts/prompts-gpt-${safeSlug(prompt.slug || prompt.title)}.prompt.md`,
349
- content: formatPromptMarkdown(prompt),
627
+ return prompts.map(({ prompt, stem }) => ({
628
+ path: `.github/prompts/prompts-gpt-${stem}.prompt.md`,
629
+ content: formatCopilotPromptMarkdown(prompt, stem),
350
630
  }));
351
631
  }
352
632
  return [];
353
633
  }
354
634
  function buildVsCodeSnippets(prompts) {
355
- return prompts.reduce((snippets, prompt) => {
635
+ return prompts.reduce((snippets, { prompt, stem }) => {
356
636
  snippets[`Prompts-GPT: ${prompt.title}`] = {
357
- prefix: `pgpt-${safeSlug(prompt.slug || prompt.title)}`,
637
+ prefix: `pgpt-${stem}`,
358
638
  description: prompt.summary || prompt.title,
359
639
  body: String(prompt.promptText || "").split(/\r?\n/),
360
640
  };
361
641
  return snippets;
362
642
  }, {});
363
643
  }
644
+ function normalizePromptAgentTargets(prompt) {
645
+ const validSet = new Set(SUPPORTED_AGENT_TARGETS);
646
+ const declaredTargets = Array.isArray(prompt.agentTargets)
647
+ ? prompt.agentTargets.map((target) => String(target).trim().toLowerCase()).filter(Boolean)
648
+ : [];
649
+ return [...new Set(declaredTargets.filter((target) => validSet.has(target)))];
650
+ }
651
+ function promptSupportsAgentTarget(prompt, target) {
652
+ const declaredTargets = normalizePromptAgentTargets(prompt);
653
+ return declaredTargets.length === 0 || declaredTargets.includes(target);
654
+ }
655
+ function assertUniquePromptFileStems(prompts) {
656
+ const byStem = new Map();
657
+ const normalizedPrompts = prompts.map((prompt) => {
658
+ const stem = safeSlug(prompt.slug || prompt.title);
659
+ const matches = byStem.get(stem) ?? [];
660
+ matches.push(prompt);
661
+ byStem.set(stem, matches);
662
+ return { prompt, stem };
663
+ });
664
+ const collisions = [...byStem.entries()].filter(([, items]) => items.length > 1);
665
+ if (collisions.length > 0) {
666
+ const details = collisions
667
+ .map(([stem, items]) => `${stem} <- ${items.map((item) => item.title || item.slug || "Untitled prompt").join(", ")}`)
668
+ .join("; ");
669
+ throw new Error(`Refusing to sync prompts with colliding file names after slug normalization: ${details}`);
670
+ }
671
+ return normalizedPrompts;
672
+ }
673
+ function assertUniqueAgentFilePaths(files) {
674
+ const pathCounts = new Map();
675
+ for (const file of files) {
676
+ const targets = pathCounts.get(file.path) ?? [];
677
+ targets.push(file.target);
678
+ pathCounts.set(file.path, targets);
679
+ }
680
+ const collisions = [...pathCounts.entries()].filter(([, targets]) => targets.length > 1 && !targets.every((target) => target === targets[0]));
681
+ if (collisions.length > 0) {
682
+ const details = collisions.map(([filePath, targets]) => `${filePath} <- ${targets.join(", ")}`).join("; ");
683
+ throw new Error(`Refusing to sync duplicate agent file paths: ${details}`);
684
+ }
685
+ }
686
+ function buildDiscoverablePromptFiles(stem, prompt) {
687
+ const agentTargets = new Set(normalizePromptAgentTargets(prompt));
688
+ const supports = (target) => agentTargets.size === 0 || agentTargets.has(target);
689
+ return {
690
+ markdown: `.prompts-gpt/${stem}.md`,
691
+ codexInstructions: supports("codex") ? "AGENTS.md" : null,
692
+ cursorRule: supports("cursor") ? `.cursor/rules/prompts-gpt-${stem}.mdc` : null,
693
+ vscodeInstructions: supports("vscode") ? ".github/copilot-instructions.md" : null,
694
+ copilotPathInstructions: supports("vscode") ? ".github/instructions/prompts-gpt.instructions.md" : null,
695
+ vscodeSnippets: supports("vscode") ? ".vscode/prompts-gpt.code-snippets" : null,
696
+ copilotPrompt: supports("copilot") ? `.github/prompts/prompts-gpt-${stem}.prompt.md` : null,
697
+ };
698
+ }
699
+ function formatCopilotPromptMarkdown(prompt, stem) {
700
+ const sections = [
701
+ "---",
702
+ "mode: agent",
703
+ `description: ${yamlScalar(prompt.summary || prompt.title)}`,
704
+ "---",
705
+ "",
706
+ `# ${prompt.title}`,
707
+ "",
708
+ `Use [../../.prompts-gpt/${stem}.md](../../.prompts-gpt/${stem}.md) as the canonical prompt-pack reference and verify generated work against [../../.prompts-gpt/manifest.json](../../.prompts-gpt/manifest.json).`,
709
+ "",
710
+ prompt.summary ?? "",
711
+ "",
712
+ "## Task",
713
+ "",
714
+ prompt.promptText ?? "",
715
+ ];
716
+ if (prompt.variables?.length) {
717
+ sections.push("", "## Inputs", "");
718
+ for (const variable of prompt.variables) {
719
+ const sanitizedName = safeSlug(variable) || "value";
720
+ const sanitizedLabel = String(variable).replace(/[{}$]/g, "");
721
+ sections.push(`- ${sanitizedLabel}: \${input:${sanitizedName}:Provide ${sanitizedLabel}}`);
722
+ }
723
+ }
724
+ if (prompt.usageNotes) {
725
+ sections.push("", "## Usage Notes", "", prompt.usageNotes);
726
+ }
727
+ sections.push("");
728
+ return sections
729
+ .reduce((acc, line, index, list) => {
730
+ if (line === "" && index > 0 && list[index - 1] === "" && (index < 2 || list[index - 2] === ""))
731
+ return acc;
732
+ acc.push(line);
733
+ return acc;
734
+ }, [])
735
+ .join("\n");
736
+ }
364
737
  function upsertManagedBlock(existing, content) {
365
738
  const start = "<!-- prompts-gpt:start -->";
366
739
  const end = "<!-- prompts-gpt:end -->";
367
- const block = `${start}\n${content.trim()}\n${end}`;
740
+ const sanitizedContent = content.trim()
741
+ .replace(/<!-- prompts-gpt:start -->/g, "")
742
+ .replace(/<!-- prompts-gpt:end -->/g, "");
743
+ const block = `${start}\n${sanitizedContent}\n${end}`;
368
744
  const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`);
369
745
  if (pattern.test(existing)) {
370
746
  return `${existing.replace(pattern, block).trimEnd()}\n`;
@@ -375,6 +751,9 @@ function upsertManagedBlock(existing, content) {
375
751
  function escapeRegExp(value) {
376
752
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
377
753
  }
754
+ function escapeMarkdownLinkText(value) {
755
+ return value.replace(/[[\]]/g, "\\$&");
756
+ }
378
757
  async function writePromptIndex(prompts, { outDir }) {
379
758
  const indexPath = path.join(outDir, "README.md");
380
759
  const content = [
@@ -383,7 +762,12 @@ async function writePromptIndex(prompts, { outDir }) {
383
762
  "These prompts were synced by `prompts-gpt`. Re-run `prompts-gpt sync` to refresh Markdown and agent files.",
384
763
  "",
385
764
  "## Prompts",
386
- ...prompts.map((prompt) => `- [${prompt.title}](./${safeSlug(prompt.slug || prompt.title)}.md) - ${prompt.summary ?? ""}`),
765
+ ...prompts.map((prompt) => {
766
+ const agentTargets = normalizePromptAgentTargets(prompt);
767
+ const suffix = agentTargets.length ? ` Targets: ${agentTargets.join(", ")}.` : "";
768
+ const escapedTitle = escapeMarkdownLinkText(prompt.title);
769
+ return `- [${escapedTitle}](./${safeSlug(prompt.slug || prompt.title)}.md) - ${prompt.summary ?? ""}${suffix}`;
770
+ }),
387
771
  "",
388
772
  ].join("\n");
389
773
  assertInside(indexPath, outDir);
@@ -392,8 +776,11 @@ async function writePromptIndex(prompts, { outDir }) {
392
776
  function normalizeAgentTargets(value) {
393
777
  const raw = Array.isArray(value) ? value.join(",") : String(value ?? "all");
394
778
  const targets = raw.split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
395
- const expanded = targets.includes("all") ? [...SUPPORTED_AGENT_TARGETS] : targets;
396
- const unique = [...new Set(expanded)];
779
+ if (targets.includes("all"))
780
+ return [...SUPPORTED_AGENT_TARGETS];
781
+ const unique = [...new Set(targets)];
782
+ if (unique.length === 0)
783
+ return [...SUPPORTED_AGENT_TARGETS];
397
784
  const validSet = new Set(SUPPORTED_AGENT_TARGETS);
398
785
  const invalid = unique.filter((target) => !validSet.has(target));
399
786
  if (invalid.length)
@@ -402,6 +789,9 @@ function normalizeAgentTargets(value) {
402
789
  }
403
790
  function normalizeApiUrl(value) {
404
791
  const url = new URL(value);
792
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
793
+ throw new Error(`API URL must use https or http, got ${url.protocol}`);
794
+ }
405
795
  url.pathname = "/";
406
796
  url.search = "";
407
797
  url.hash = "";
@@ -418,44 +808,60 @@ function safeNormalizeApiUrl(value) {
418
808
  });
419
809
  }
420
810
  }
811
+ function isInsideDirectory(child, parent) {
812
+ const normalizedParent = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`;
813
+ return child.startsWith(normalizedParent) || child === parent;
814
+ }
421
815
  function assertSafeOutputDir(cwd, outDir) {
422
816
  const root = path.resolve(cwd);
423
817
  const resolved = path.resolve(root, outDir);
424
- if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
425
- throw new Error("Output directory must stay inside the current project.");
818
+ if (!isInsideDirectory(resolved, root) || resolved === root) {
819
+ throw new Error("Output directory must be a subdirectory of the current project.");
426
820
  }
427
821
  return resolved;
428
822
  }
429
823
  function assertSafeProjectFile(cwd, filePath) {
430
824
  const root = path.resolve(cwd);
431
825
  const resolved = path.resolve(root, filePath);
432
- if (!resolved.startsWith(`${root}${path.sep}`)) {
826
+ if (!isInsideDirectory(resolved, root) || resolved === root) {
433
827
  throw new Error("Agent file path must stay inside the current project.");
434
828
  }
435
829
  return resolved;
436
830
  }
437
831
  function assertInside(filePath, directory) {
438
- if (!filePath.startsWith(`${directory}${path.sep}`)) {
832
+ if (!isInsideDirectory(filePath, directory) || filePath === directory) {
439
833
  throw new Error("Refusing to write outside the prompt output directory.");
440
834
  }
441
835
  }
442
836
  function safeSlug(value) {
443
- return String(value ?? "prompt")
837
+ const raw = String(value ?? "");
838
+ if (!raw.trim())
839
+ return "prompt";
840
+ const slug = raw
444
841
  .toLowerCase()
842
+ .normalize("NFKD")
843
+ .replace(/[\u0300-\u036f]/g, "")
445
844
  .replace(/[^a-z0-9]+/g, "-")
446
845
  .replace(/^-+|-+$/g, "")
447
- .slice(0, 90) || "prompt";
846
+ .slice(0, 90);
847
+ return slug || "prompt";
448
848
  }
449
849
  function yamlScalar(value) {
450
- return JSON.stringify(String(value ?? ""));
850
+ const s = String(value ?? "");
851
+ if (s.includes("\n") || s.includes("\r")) {
852
+ return JSON.stringify(s.replace(/\r\n?/g, "\n"));
853
+ }
854
+ return JSON.stringify(s);
451
855
  }
452
856
  async function ensureGitignoreEntry(cwd, entry) {
453
857
  const gitignorePath = path.resolve(cwd, ".gitignore");
454
858
  const existing = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
859
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
455
860
  const lines = existing.split(/\r?\n/).map((line) => line.trim());
456
861
  if (lines.includes(entry))
457
862
  return;
458
- const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
459
- await writeFile(gitignorePath, `${existing}${prefix}${entry}\n`);
863
+ const needsLeadingNewline = existing.length > 0 && !existing.endsWith("\n") && !existing.endsWith("\r\n");
864
+ const prefix = needsLeadingNewline ? eol : "";
865
+ await writeFile(gitignorePath, `${existing}${prefix}${entry}${eol}`);
460
866
  }
461
867
  //# sourceMappingURL=index.js.map