@prompts-gpt/client 0.2.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,17 +10,24 @@ 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;
@@ -28,27 +35,27 @@ export class PromptsGptClient {
28
35
  timeoutMs;
29
36
  constructor(options) {
30
37
  this.apiUrl = safeNormalizeApiUrl(options.apiUrl);
31
- this.token = options.token;
38
+ this.token = options.token?.trim() || null;
32
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));
45
- }
46
- const qs = params.toString();
46
+ async pullPrompts(query = {}, options = {}) {
47
+ const qs = serializePromptQuery(query);
47
48
  const suffix = qs ? `?${qs}` : "";
48
- const data = await this.request(`/api/sdk/v1/prompts${suffix}`);
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
+ });
55
+ }
49
56
  return data.prompts;
50
57
  }
51
- async generatePrompt(input) {
58
+ async generatePrompt(input, options = {}) {
52
59
  const trimmedGoal = input.goal?.trim() ?? "";
53
60
  if (trimmedGoal.length < 8) {
54
61
  throw new PromptsGptApiError("Goal must be at least 8 characters.", { code: "VALIDATION_ERROR" });
@@ -60,9 +67,19 @@ export class PromptsGptClient {
60
67
  if (trimmedContext.length > 1600) {
61
68
  throw new PromptsGptApiError("Context must be 1600 characters or fewer.", { code: "VALIDATION_ERROR" });
62
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
+ }
63
78
  const data = await this.request("/api/sdk/v1/prompts/generate", {
64
79
  method: "POST",
65
80
  body: input,
81
+ timeoutMs: options.timeoutMs ?? DEFAULT_GENERATE_TIMEOUT_MS,
82
+ ...options,
66
83
  });
67
84
  return data.prompt;
68
85
  }
@@ -71,7 +88,7 @@ export class PromptsGptClient {
71
88
  throw new PromptsGptApiError("Project token is missing.", {
72
89
  status: 401,
73
90
  code: "AUTH_ERROR",
74
- 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.",
75
92
  });
76
93
  }
77
94
  if (!this.token.startsWith("pgpt_")) {
@@ -87,58 +104,98 @@ export class PromptsGptClient {
87
104
  });
88
105
  }
89
106
  const controller = new AbortController();
90
- 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";
91
112
  try {
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
+ }
92
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
+ }
93
133
  const response = await fetchFn(url, {
94
- method: options.method ?? "GET",
95
- headers: {
96
- authorization: `Bearer ${this.token}`,
97
- accept: "application/json",
98
- "user-agent": `prompts-gpt-client/${getClientVersion()}`,
99
- ...(options.body ? { "content-type": "application/json" } : {}),
100
- },
134
+ method,
135
+ headers,
101
136
  body: options.body ? JSON.stringify(options.body) : undefined,
102
137
  signal: controller.signal,
103
138
  });
139
+ const requestId = response.headers?.get?.("x-request-id") ?? outgoingRequestId;
140
+ const retryAfterMs = parseRetryAfterHeader(response.headers?.get?.("retry-after"));
104
141
  const contentType = response.headers?.get?.("content-type") ?? "";
105
- if (!contentType.includes("application/json") && response.status !== 204) {
142
+ if (!isJsonContentType(contentType) && response.status !== 204) {
106
143
  throw new PromptsGptApiError(`Unexpected response content-type: ${contentType || "none"}`, {
107
144
  status: response.status,
108
145
  code: "INVALID_RESPONSE",
109
146
  recovery: "Verify the API URL is correct and the server is accessible.",
147
+ requestId,
148
+ retryAfterMs,
110
149
  });
111
150
  }
112
- const payload = await response.json().catch(() => null);
113
- if (!response.ok || !payload?.ok) {
114
- if (retryCount < MAX_RETRIES && RETRYABLE_STATUS_CODES.has(response.status)) {
115
- const retryAfter = parseInt(response.headers?.get?.("retry-after") ?? "", 10);
116
- const baseDelay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : (retryCount + 1) * 1000;
117
- const jitter = Math.random() * 500;
118
- await sleep(Math.min(baseDelay + jitter, 30_000));
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));
119
165
  return this.request(pathname, options, retryCount + 1);
120
166
  }
121
- const error = payload?.error;
167
+ const error = !apiResponse.ok ? apiResponse.error : null;
122
168
  throw new PromptsGptApiError(error?.message ?? `Prompts-GPT request failed with ${response.status}.`, {
123
169
  status: response.status,
124
170
  code: error?.code,
125
171
  recovery: error?.recovery,
172
+ requestId,
173
+ retryAfterMs,
174
+ fieldErrors: error?.fieldErrors,
126
175
  });
127
176
  }
128
- return payload.data;
177
+ return apiResponse.data;
129
178
  }
130
179
  catch (error) {
131
180
  if (error instanceof PromptsGptApiError)
132
181
  throw error;
182
+ if (!options.omitUserAgent && isUserAgentRuntimeError(error)) {
183
+ return this.request(pathname, { ...options, omitUserAgent: true }, retryCount);
184
+ }
133
185
  if (error?.name === "AbortError") {
134
- 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.`, {
135
193
  code: "TIMEOUT",
136
194
  recovery: "Check your network connection or increase the timeout.",
137
195
  });
138
196
  }
139
- if (retryCount < MAX_RETRIES && isNetworkError(error)) {
140
- const jitter = Math.random() * 500;
141
- await sleep(Math.min((retryCount + 1) * 1000 + jitter, 30_000));
197
+ if (retryCount < MAX_RETRIES && method === "GET" && isNetworkError(error)) {
198
+ await sleep(computeRetryDelayMs(retryCount));
142
199
  return this.request(pathname, options, retryCount + 1);
143
200
  }
144
201
  throw new PromptsGptApiError(error?.message ?? "Network request failed.", {
@@ -147,6 +204,7 @@ export class PromptsGptClient {
147
204
  });
148
205
  }
149
206
  finally {
207
+ releaseLinkedAbort();
150
208
  clearTimeout(timeout);
151
209
  }
152
210
  }
@@ -154,12 +212,140 @@ export class PromptsGptClient {
154
212
  function isNetworkError(error) {
155
213
  if (!error)
156
214
  return false;
215
+ const name = String(error.name ?? "").toLowerCase();
157
216
  const message = String(error.message ?? "").toLowerCase();
158
- 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);
159
341
  }
160
342
  function sleep(ms) {
161
343
  return new Promise((resolve) => setTimeout(resolve, ms));
162
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
+ }
163
349
  let cachedVersion = null;
164
350
  function getClientVersion() {
165
351
  if (cachedVersion)
@@ -174,16 +360,21 @@ function getClientVersion() {
174
360
  return cachedVersion;
175
361
  }
176
362
  export async function saveLocalCredentials(input) {
177
- if (!input.token?.trim())
363
+ const trimmedToken = input.token?.trim();
364
+ if (!trimmedToken)
178
365
  throw new Error("Token is required.");
179
- if (input.token.trim().length > 256)
366
+ if (trimmedToken.length > 256)
180
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.");
181
370
  const cwd = input.cwd ?? process.cwd();
182
371
  const outDir = path.resolve(cwd, DEFAULT_PROMPTS_GPT_OUT_DIR);
183
372
  await mkdir(outDir, { recursive: true });
184
- 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);
185
376
  await ensureGitignoreEntry(cwd, `${DEFAULT_PROMPTS_GPT_OUT_DIR}/${PROMPTS_GPT_CREDENTIALS_FILE}`);
186
- return { credentialsPath: path.join(outDir, PROMPTS_GPT_CREDENTIALS_FILE) };
377
+ return { credentialsPath };
187
378
  }
188
379
  export async function loadLocalCredentials(cwd = process.cwd()) {
189
380
  const credentialsPath = path.resolve(cwd, DEFAULT_PROMPTS_GPT_OUT_DIR, PROMPTS_GPT_CREDENTIALS_FILE);
@@ -199,8 +390,9 @@ export async function loadLocalCredentials(cwd = process.cwd()) {
199
390
  }
200
391
  catch { }
201
392
  }
393
+ const rawToken = typeof parsed.token === "string" ? parsed.token.trim() : null;
202
394
  return {
203
- token: typeof parsed.token === "string" ? parsed.token : null,
395
+ token: rawToken || null,
204
396
  apiUrl,
205
397
  };
206
398
  }
@@ -210,7 +402,12 @@ export async function loadLocalCredentials(cwd = process.cwd()) {
210
402
  }
211
403
  export async function syncPrompts(prompts, options = {}) {
212
404
  const markdown = await writePromptMarkdownFiles(prompts, options);
213
- 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
+ });
214
411
  const manifest = await writePromptManifest(prompts, { cwd: options.cwd, outDir: options.outDir });
215
412
  return { markdown, agents, manifest };
216
413
  }
@@ -218,18 +415,31 @@ export async function writePromptMarkdownFiles(prompts, options = {}) {
218
415
  const cwd = options.cwd ?? process.cwd();
219
416
  const outDir = assertSafeOutputDir(cwd, options.outDir ?? DEFAULT_PROMPTS_GPT_OUT_DIR);
220
417
  const overwrite = Boolean(options.overwrite);
418
+ const normalizedPrompts = assertUniquePromptFileStems(prompts);
221
419
  await mkdir(outDir, { recursive: true });
222
420
  const written = [];
223
421
  const skipped = [];
224
- for (const prompt of prompts) {
225
- 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`);
226
424
  assertInside(filePath, outDir);
227
- if (!overwrite && existsSync(filePath)) {
228
- skipped.push(filePath);
229
- 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
+ }
230
442
  }
231
- await writeFile(filePath, formatPromptMarkdown(prompt), { flag: overwrite ? "w" : "wx" });
232
- written.push(filePath);
233
443
  }
234
444
  await writePromptIndex(prompts, { outDir });
235
445
  return { outDir, written, skipped };
@@ -237,45 +447,72 @@ export async function writePromptMarkdownFiles(prompts, options = {}) {
237
447
  export async function writeAgentFiles(prompts, options = {}) {
238
448
  const cwd = options.cwd ?? process.cwd();
239
449
  const targets = normalizeAgentTargets(options.agent ?? options.agents ?? "all");
240
- const overwrite = Boolean(options.overwriteAgentFiles ?? true);
450
+ const overwrite = Boolean(options.overwriteAgentFiles);
241
451
  const written = [];
242
452
  const skipped = [];
243
- 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);
244
456
  const dirSet = new Set(allFiles.map((file) => path.dirname(assertSafeProjectFile(cwd, file.path))));
245
457
  await Promise.all([...dirSet].map((dir) => mkdir(dir, { recursive: true })));
246
- await Promise.all(allFiles.map(async (file) => {
458
+ for (const file of allFiles) {
247
459
  const filePath = assertSafeProjectFile(cwd, file.path);
248
460
  if (file.managedBlock) {
249
- const existing = existsSync(filePath) ? await readFile(filePath, "utf8") : "";
461
+ let existing = "";
462
+ try {
463
+ existing = await readFile(filePath, "utf8");
464
+ }
465
+ catch { }
250
466
  await writeFile(filePath, upsertManagedBlock(existing, file.content));
251
467
  written.push(filePath);
252
- return;
468
+ continue;
469
+ }
470
+ if (overwrite) {
471
+ await writeFile(filePath, file.content);
472
+ written.push(filePath);
253
473
  }
254
- if (!overwrite && existsSync(filePath)) {
255
- skipped.push(filePath);
256
- 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
+ }
257
487
  }
258
- await writeFile(filePath, file.content, { flag: overwrite ? "w" : "wx" });
259
- written.push(filePath);
260
- }));
488
+ }
261
489
  return { written, skipped, targets };
262
490
  }
263
491
  export async function writePromptManifest(prompts, options = {}) {
264
492
  const cwd = options.cwd ?? process.cwd();
265
493
  const outDir = assertSafeOutputDir(cwd, options.outDir ?? DEFAULT_PROMPTS_GPT_OUT_DIR);
494
+ const normalizedPrompts = assertUniquePromptFileStems(prompts);
266
495
  await mkdir(outDir, { recursive: true });
267
496
  const manifestPath = path.join(outDir, PROMPTS_GPT_MANIFEST_FILE);
268
497
  const payload = {
269
498
  version: 1,
270
499
  generatedAt: new Date().toISOString(),
271
- count: prompts.length,
272
- prompts: prompts.map((prompt) => ({
273
- slug: safeSlug(prompt.slug || prompt.title),
500
+ count: normalizedPrompts.length,
501
+ prompts: normalizedPrompts.map(({ prompt, stem }) => ({
502
+ slug: stem,
274
503
  title: prompt.title,
504
+ summary: prompt.summary ?? "",
275
505
  source: prompt.source ?? "library",
276
506
  category: prompt.category ?? "Prompt Library",
507
+ difficulty: prompt.difficulty ?? "Intermediate",
508
+ outputType: prompt.outputType ?? "Text",
277
509
  supportedTools: prompt.supportedTools ?? [],
278
- 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),
279
516
  })),
280
517
  };
281
518
  await writeFile(manifestPath, `${JSON.stringify(payload, null, 2)}\n`);
@@ -302,11 +539,8 @@ export function formatPromptMarkdown(prompt) {
302
539
  "",
303
540
  prompt.promptText ?? "",
304
541
  "",
305
- prompt.variables?.length ? "## Variables" : "",
306
- ...(prompt.variables?.length ? prompt.variables.map((variable) => `- \`${variable}\``) : []),
307
- prompt.variables?.length ? "" : "",
308
- prompt.usageNotes ? "## Usage Notes" : "",
309
- prompt.usageNotes ?? "",
542
+ ...(prompt.variables?.length ? ["## Variables", "", ...prompt.variables.map((variable) => `- \`${variable}\``), ""] : []),
543
+ ...(prompt.usageNotes ? ["## Usage Notes", "", prompt.usageNotes] : []),
310
544
  "",
311
545
  ].reduce((acc, line, index, list) => {
312
546
  if (line === "" && index > 0 && list[index - 1] === "" && (index < 2 || list[index - 2] === ""))
@@ -323,10 +557,10 @@ function buildAgentFiles(target, prompts) {
323
557
  content: [
324
558
  "# Prompts-GPT Agent Instructions",
325
559
  "",
326
- "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.",
327
561
  "",
328
562
  "## Available Prompt Packs",
329
- ...prompts.map((prompt) => `- ${prompt.title}: .prompts-gpt/${safeSlug(prompt.slug || prompt.title)}.md`),
563
+ ...prompts.map(({ prompt, stem }) => `- [${prompt.title}](.prompts-gpt/${stem}.md)`),
330
564
  "",
331
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.",
332
566
  "",
@@ -334,12 +568,12 @@ function buildAgentFiles(target, prompts) {
334
568
  }];
335
569
  }
336
570
  if (target === "cursor") {
337
- return prompts.map((prompt) => ({
338
- 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`,
339
573
  content: [
340
574
  "---",
341
- `description: ${prompt.summary || prompt.title}`,
342
- "globs:",
575
+ `description: ${yamlScalar(prompt.summary || prompt.title)}`,
576
+ "globs: []",
343
577
  "alwaysApply: false",
344
578
  "---",
345
579
  "",
@@ -360,9 +594,26 @@ function buildAgentFiles(target, prompts) {
360
594
  content: [
361
595
  "# Prompts-GPT Copilot Instructions",
362
596
  "",
363
- "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`.",
364
613
  "",
365
- ...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`.",
366
617
  "",
367
618
  ].join("\n"),
368
619
  },
@@ -373,27 +624,123 @@ function buildAgentFiles(target, prompts) {
373
624
  ];
374
625
  }
375
626
  if (target === "copilot") {
376
- return prompts.map((prompt) => ({
377
- path: `.github/prompts/prompts-gpt-${safeSlug(prompt.slug || prompt.title)}.prompt.md`,
378
- content: formatPromptMarkdown(prompt),
627
+ return prompts.map(({ prompt, stem }) => ({
628
+ path: `.github/prompts/prompts-gpt-${stem}.prompt.md`,
629
+ content: formatCopilotPromptMarkdown(prompt, stem),
379
630
  }));
380
631
  }
381
632
  return [];
382
633
  }
383
634
  function buildVsCodeSnippets(prompts) {
384
- return prompts.reduce((snippets, prompt) => {
635
+ return prompts.reduce((snippets, { prompt, stem }) => {
385
636
  snippets[`Prompts-GPT: ${prompt.title}`] = {
386
- prefix: `pgpt-${safeSlug(prompt.slug || prompt.title)}`,
637
+ prefix: `pgpt-${stem}`,
387
638
  description: prompt.summary || prompt.title,
388
639
  body: String(prompt.promptText || "").split(/\r?\n/),
389
640
  };
390
641
  return snippets;
391
642
  }, {});
392
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
+ }
393
737
  function upsertManagedBlock(existing, content) {
394
738
  const start = "<!-- prompts-gpt:start -->";
395
739
  const end = "<!-- prompts-gpt:end -->";
396
- 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}`;
397
744
  const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`);
398
745
  if (pattern.test(existing)) {
399
746
  return `${existing.replace(pattern, block).trimEnd()}\n`;
@@ -404,6 +751,9 @@ function upsertManagedBlock(existing, content) {
404
751
  function escapeRegExp(value) {
405
752
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
406
753
  }
754
+ function escapeMarkdownLinkText(value) {
755
+ return value.replace(/[[\]]/g, "\\$&");
756
+ }
407
757
  async function writePromptIndex(prompts, { outDir }) {
408
758
  const indexPath = path.join(outDir, "README.md");
409
759
  const content = [
@@ -412,7 +762,12 @@ async function writePromptIndex(prompts, { outDir }) {
412
762
  "These prompts were synced by `prompts-gpt`. Re-run `prompts-gpt sync` to refresh Markdown and agent files.",
413
763
  "",
414
764
  "## Prompts",
415
- ...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
+ }),
416
771
  "",
417
772
  ].join("\n");
418
773
  assertInside(indexPath, outDir);
@@ -421,8 +776,11 @@ async function writePromptIndex(prompts, { outDir }) {
421
776
  function normalizeAgentTargets(value) {
422
777
  const raw = Array.isArray(value) ? value.join(",") : String(value ?? "all");
423
778
  const targets = raw.split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
424
- const expanded = targets.includes("all") ? [...SUPPORTED_AGENT_TARGETS] : targets;
425
- 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];
426
784
  const validSet = new Set(SUPPORTED_AGENT_TARGETS);
427
785
  const invalid = unique.filter((target) => !validSet.has(target));
428
786
  if (invalid.length)
@@ -431,6 +789,9 @@ function normalizeAgentTargets(value) {
431
789
  }
432
790
  function normalizeApiUrl(value) {
433
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
+ }
434
795
  url.pathname = "/";
435
796
  url.search = "";
436
797
  url.hash = "";
@@ -447,10 +808,14 @@ function safeNormalizeApiUrl(value) {
447
808
  });
448
809
  }
449
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
+ }
450
815
  function assertSafeOutputDir(cwd, outDir) {
451
816
  const root = path.resolve(cwd);
452
817
  const resolved = path.resolve(root, outDir);
453
- if (!resolved.startsWith(`${root}${path.sep}`)) {
818
+ if (!isInsideDirectory(resolved, root) || resolved === root) {
454
819
  throw new Error("Output directory must be a subdirectory of the current project.");
455
820
  }
456
821
  return resolved;
@@ -458,33 +823,45 @@ function assertSafeOutputDir(cwd, outDir) {
458
823
  function assertSafeProjectFile(cwd, filePath) {
459
824
  const root = path.resolve(cwd);
460
825
  const resolved = path.resolve(root, filePath);
461
- if (!resolved.startsWith(`${root}${path.sep}`)) {
826
+ if (!isInsideDirectory(resolved, root) || resolved === root) {
462
827
  throw new Error("Agent file path must stay inside the current project.");
463
828
  }
464
829
  return resolved;
465
830
  }
466
831
  function assertInside(filePath, directory) {
467
- if (!filePath.startsWith(`${directory}${path.sep}`)) {
832
+ if (!isInsideDirectory(filePath, directory) || filePath === directory) {
468
833
  throw new Error("Refusing to write outside the prompt output directory.");
469
834
  }
470
835
  }
471
836
  function safeSlug(value) {
472
- return String(value ?? "prompt")
837
+ const raw = String(value ?? "");
838
+ if (!raw.trim())
839
+ return "prompt";
840
+ const slug = raw
473
841
  .toLowerCase()
842
+ .normalize("NFKD")
843
+ .replace(/[\u0300-\u036f]/g, "")
474
844
  .replace(/[^a-z0-9]+/g, "-")
475
845
  .replace(/^-+|-+$/g, "")
476
- .slice(0, 90) || "prompt";
846
+ .slice(0, 90);
847
+ return slug || "prompt";
477
848
  }
478
849
  function yamlScalar(value) {
479
- 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);
480
855
  }
481
856
  async function ensureGitignoreEntry(cwd, entry) {
482
857
  const gitignorePath = path.resolve(cwd, ".gitignore");
483
858
  const existing = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
859
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
484
860
  const lines = existing.split(/\r?\n/).map((line) => line.trim());
485
861
  if (lines.includes(entry))
486
862
  return;
487
- const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
488
- 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}`);
489
866
  }
490
867
  //# sourceMappingURL=index.js.map