@prompts-gpt/client 0.2.0 → 0.2.3

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,54 +1,80 @@
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
+ export { DEFAULT_RUN_ARTIFACTS_DIR, DEFAULT_RUN_CONFIG_PATH, ORCHESTRATION_AGENT_PROFILES, DEFAULT_MODELS, normalizeOrchestrationAgent, loadRunConfig, detectProviders, doctor, initRunConfig, runBatch, runPrompt, resolveRunProvider, resolveTimeoutSeconds, resolveDefaultPromptFile, assertPromptFitsLaunch, executeProviderCommandWithRetries, executeProviderCommand, captureWorktreeStatus, buildWorktreeDelta, buildProviderCommand, formatCombinedOutput, appendFileSafe, validateRunConfig, discoverWorkspaceAssets, isCI, } from "./runtime.js";
5
+ export { sweepPrompt, acquireSweepLock, releaseSweepLock, parseStreamJsonToolCounts, streamJsonHasResult, extractIterationSummary, buildIterationPrompt, runPreFlight, writeSweepManifest, } from "./sweep.js";
4
6
  export const DEFAULT_PROMPTS_GPT_API_URL = "https://prompts-gpt.com";
5
7
  export const DEFAULT_PROMPTS_GPT_OUT_DIR = ".prompts-gpt";
6
8
  export const PROMPTS_GPT_CREDENTIALS_FILE = ".credentials.json";
7
9
  export const PROMPTS_GPT_MANIFEST_FILE = "manifest.json";
8
- export const SUPPORTED_AGENT_TARGETS = ["codex", "cursor", "vscode", "copilot"];
10
+ export const SUPPORTED_AGENT_TARGETS = [
11
+ "codex",
12
+ "claude-code",
13
+ "cursor",
14
+ "vscode",
15
+ "copilot",
16
+ "continue",
17
+ "gemini-cli",
18
+ "windsurf",
19
+ "cline",
20
+ "junie",
21
+ "amp",
22
+ ];
9
23
  export class PromptsGptApiError extends Error {
10
24
  status;
11
25
  code;
12
26
  recovery;
27
+ requestId;
28
+ fieldErrors;
29
+ retryAfterMs;
13
30
  constructor(message, options) {
14
31
  super(message);
15
32
  this.name = "PromptsGptApiError";
16
33
  this.status = options?.status ?? 0;
17
34
  this.code = options?.code ?? "UNKNOWN_ERROR";
18
35
  this.recovery = options?.recovery ?? "Retry the request or create a fresh project token.";
36
+ this.requestId = options?.requestId ?? null;
37
+ this.fieldErrors = options?.fieldErrors ? Object.freeze({ ...options.fieldErrors }) : undefined;
38
+ this.retryAfterMs = options?.retryAfterMs ?? null;
19
39
  }
20
40
  }
21
41
  const DEFAULT_TIMEOUT_MS = 30_000;
42
+ const DEFAULT_GENERATE_TIMEOUT_MS = 60_000;
22
43
  const MAX_RETRIES = 2;
23
- const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
44
+ const RETRYABLE_STATUS_CODES = new Set([408, 429, 502, 503, 504]);
24
45
  export class PromptsGptClient {
25
46
  apiUrl;
26
47
  token;
27
48
  fetchImpl;
28
49
  timeoutMs;
50
+ accountId;
29
51
  constructor(options) {
30
52
  this.apiUrl = safeNormalizeApiUrl(options.apiUrl);
31
- this.token = options.token;
53
+ this.token = options.token?.trim() || null;
32
54
  this.fetchImpl = options.fetch;
33
55
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
56
+ this.accountId = options.accountId?.trim() || null;
57
+ if (this.token && this.apiUrl.startsWith("http://") && !this.apiUrl.includes("localhost") && !this.apiUrl.includes("127.0.0.1")) {
58
+ throw new PromptsGptApiError("Refusing to send credentials over unencrypted HTTP. Use HTTPS or localhost.", { code: "INSECURE_TRANSPORT", recovery: "Change the API URL to use https://." });
59
+ }
34
60
  }
35
- async getProject() {
36
- const data = await this.request("/api/sdk/v1/project");
61
+ async getProject(options = {}) {
62
+ const data = await this.request("/api/sdk/v1/project", options);
37
63
  return data.project;
38
64
  }
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();
65
+ async pullPrompts(query = {}, options = {}) {
66
+ const qs = serializePromptQuery(query);
47
67
  const suffix = qs ? `?${qs}` : "";
48
- const data = await this.request(`/api/sdk/v1/prompts${suffix}`);
68
+ const data = await this.request(`/api/sdk/v1/prompts${suffix}`, options);
69
+ if (!Array.isArray(data.prompts)) {
70
+ throw new PromptsGptApiError("Prompts-GPT returned an invalid prompts payload.", {
71
+ code: "INVALID_RESPONSE",
72
+ recovery: "Retry the request. If it keeps failing, verify the SDK API version.",
73
+ });
74
+ }
49
75
  return data.prompts;
50
76
  }
51
- async generatePrompt(input) {
77
+ async generatePrompt(input, options = {}) {
52
78
  const trimmedGoal = input.goal?.trim() ?? "";
53
79
  if (trimmedGoal.length < 8) {
54
80
  throw new PromptsGptApiError("Goal must be at least 8 characters.", { code: "VALIDATION_ERROR" });
@@ -60,9 +86,19 @@ export class PromptsGptClient {
60
86
  if (trimmedContext.length > 1600) {
61
87
  throw new PromptsGptApiError("Context must be 1600 characters or fewer.", { code: "VALIDATION_ERROR" });
62
88
  }
89
+ const trimmedConstraints = input.constraints?.trim() ?? "";
90
+ if (trimmedConstraints.length > 1600) {
91
+ throw new PromptsGptApiError("Constraints must be 1600 characters or fewer.", { code: "VALIDATION_ERROR" });
92
+ }
93
+ const trimmedDesiredOutput = input.desiredOutput?.trim() ?? "";
94
+ if (trimmedDesiredOutput.length > 1600) {
95
+ throw new PromptsGptApiError("Desired output must be 1600 characters or fewer.", { code: "VALIDATION_ERROR" });
96
+ }
63
97
  const data = await this.request("/api/sdk/v1/prompts/generate", {
64
98
  method: "POST",
65
99
  body: input,
100
+ timeoutMs: options.timeoutMs ?? DEFAULT_GENERATE_TIMEOUT_MS,
101
+ ...options,
66
102
  });
67
103
  return data.prompt;
68
104
  }
@@ -71,7 +107,7 @@ export class PromptsGptClient {
71
107
  throw new PromptsGptApiError("Project token is missing.", {
72
108
  status: 401,
73
109
  code: "AUTH_ERROR",
74
- recovery: "Run `prompts-gpt init --token <token>` or set PROMPTS_GPT_TOKEN.",
110
+ recovery: "Run `prompts-gpt init --token <token>` or pass `--token`, `--token-stdin`, or `--token-prompt` to the CLI command.",
75
111
  });
76
112
  }
77
113
  if (!this.token.startsWith("pgpt_")) {
@@ -87,58 +123,102 @@ export class PromptsGptClient {
87
123
  });
88
124
  }
89
125
  const controller = new AbortController();
90
- const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
126
+ const releaseLinkedAbort = linkAbortSignal(options.signal, controller);
127
+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs ?? this.timeoutMs);
128
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
129
+ timeout.unref?.();
130
+ const method = options.method ?? "GET";
91
131
  try {
132
+ if (options.signal?.aborted) {
133
+ throw new PromptsGptApiError(`Request to ${pathname} was aborted by the caller.`, {
134
+ code: "REQUEST_ABORTED",
135
+ recovery: "Start a new request when you are ready to continue.",
136
+ });
137
+ }
92
138
  const url = new URL(pathname, this.apiUrl);
139
+ const headers = {
140
+ authorization: `Bearer ${this.token}`,
141
+ accept: "application/json",
142
+ "x-prompts-gpt-client": `@prompts-gpt/client/${getClientVersion()}`,
143
+ "x-prompts-gpt-build": getBuildFingerprint(),
144
+ };
145
+ if (this.accountId) {
146
+ headers["x-prompts-gpt-account"] = this.accountId;
147
+ }
148
+ if (!options.omitUserAgent) {
149
+ headers["user-agent"] = `prompts-gpt-client/${getClientVersion()}`;
150
+ }
151
+ const outgoingRequestId = options.requestId?.trim() || generateRequestId();
152
+ headers["x-request-id"] = outgoingRequestId;
153
+ if (options.body) {
154
+ headers["content-type"] = "application/json";
155
+ }
93
156
  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
- },
157
+ method,
158
+ headers,
101
159
  body: options.body ? JSON.stringify(options.body) : undefined,
102
160
  signal: controller.signal,
103
161
  });
162
+ const requestId = response.headers?.get?.("x-request-id") ?? outgoingRequestId;
163
+ const retryAfterMs = parseRetryAfterHeader(response.headers?.get?.("retry-after"));
104
164
  const contentType = response.headers?.get?.("content-type") ?? "";
105
- if (!contentType.includes("application/json") && response.status !== 204) {
165
+ if (!isJsonContentType(contentType) && response.status !== 204) {
106
166
  throw new PromptsGptApiError(`Unexpected response content-type: ${contentType || "none"}`, {
107
167
  status: response.status,
108
168
  code: "INVALID_RESPONSE",
109
169
  recovery: "Verify the API URL is correct and the server is accessible.",
170
+ requestId,
171
+ retryAfterMs,
110
172
  });
111
173
  }
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));
174
+ const payload = await parseJsonResponse(response);
175
+ const apiResponse = parseApiResponse(payload);
176
+ if (!response.ok || !apiResponse || !apiResponse.ok) {
177
+ if (!apiResponse) {
178
+ throw new PromptsGptApiError("Prompts-GPT returned an unexpected API envelope.", {
179
+ status: response.status,
180
+ code: "INVALID_RESPONSE",
181
+ recovery: "Retry the request. If it keeps failing, verify the SDK API version.",
182
+ requestId,
183
+ retryAfterMs,
184
+ });
185
+ }
186
+ if (shouldRetryRequest({ method, status: response.status, retryCount, retryAfterMs })) {
187
+ await sleep(computeRetryDelayMs(retryCount, retryAfterMs));
119
188
  return this.request(pathname, options, retryCount + 1);
120
189
  }
121
- const error = payload?.error;
190
+ const error = !apiResponse.ok ? apiResponse.error : null;
122
191
  throw new PromptsGptApiError(error?.message ?? `Prompts-GPT request failed with ${response.status}.`, {
123
192
  status: response.status,
124
193
  code: error?.code,
125
194
  recovery: error?.recovery,
195
+ requestId,
196
+ retryAfterMs,
197
+ fieldErrors: error?.fieldErrors,
126
198
  });
127
199
  }
128
- return payload.data;
200
+ return apiResponse.data;
129
201
  }
130
202
  catch (error) {
131
203
  if (error instanceof PromptsGptApiError)
132
204
  throw error;
205
+ if (!options.omitUserAgent && isUserAgentRuntimeError(error)) {
206
+ return this.request(pathname, { ...options, omitUserAgent: true }, retryCount);
207
+ }
133
208
  if (error?.name === "AbortError") {
134
- throw new PromptsGptApiError(`Request to ${pathname} timed out after ${this.timeoutMs}ms.`, {
209
+ if (options.signal?.aborted) {
210
+ throw new PromptsGptApiError(`Request to ${pathname} was aborted by the caller.`, {
211
+ code: "REQUEST_ABORTED",
212
+ recovery: "Start a new request when you are ready to continue.",
213
+ });
214
+ }
215
+ throw new PromptsGptApiError(`Request to ${pathname} timed out after ${timeoutMs}ms.`, {
135
216
  code: "TIMEOUT",
136
217
  recovery: "Check your network connection or increase the timeout.",
137
218
  });
138
219
  }
139
- if (retryCount < MAX_RETRIES && isNetworkError(error)) {
140
- const jitter = Math.random() * 500;
141
- await sleep(Math.min((retryCount + 1) * 1000 + jitter, 30_000));
220
+ if (retryCount < MAX_RETRIES && method === "GET" && isNetworkError(error)) {
221
+ await sleep(computeRetryDelayMs(retryCount));
142
222
  return this.request(pathname, options, retryCount + 1);
143
223
  }
144
224
  throw new PromptsGptApiError(error?.message ?? "Network request failed.", {
@@ -147,6 +227,7 @@ export class PromptsGptClient {
147
227
  });
148
228
  }
149
229
  finally {
230
+ releaseLinkedAbort();
150
231
  clearTimeout(timeout);
151
232
  }
152
233
  }
@@ -154,11 +235,167 @@ export class PromptsGptClient {
154
235
  function isNetworkError(error) {
155
236
  if (!error)
156
237
  return false;
238
+ const name = String(error.name ?? "").toLowerCase();
157
239
  const message = String(error.message ?? "").toLowerCase();
158
- return message.includes("fetch") || message.includes("network") || message.includes("econnrefused") || message.includes("enotfound");
240
+ return (name === "typeerror" ||
241
+ message.includes("fetch") ||
242
+ message.includes("network") ||
243
+ message.includes("econnrefused") ||
244
+ message.includes("enotfound") ||
245
+ message.includes("eai_again") ||
246
+ message.includes("ecancelled") ||
247
+ message.includes("socket"));
248
+ }
249
+ function isUserAgentRuntimeError(error) {
250
+ const message = String(error?.message ?? "").toLowerCase();
251
+ return message.includes("user-agent") && message.includes("not allowed");
252
+ }
253
+ function linkAbortSignal(signal, controller) {
254
+ if (!signal)
255
+ return () => undefined;
256
+ if (signal.aborted) {
257
+ controller.abort(signal.reason);
258
+ return () => undefined;
259
+ }
260
+ const abort = () => controller.abort(signal.reason);
261
+ signal.addEventListener("abort", abort, { once: true });
262
+ return () => signal.removeEventListener("abort", abort);
263
+ }
264
+ function normalizeTimeoutMs(value) {
265
+ if (!Number.isFinite(value) || value <= 0) {
266
+ throw new PromptsGptApiError("Timeout must be a positive number of milliseconds.", {
267
+ code: "VALIDATION_ERROR",
268
+ recovery: "Provide a timeout greater than 0.",
269
+ });
270
+ }
271
+ const MAX_API_TIMEOUT_MS = 600_000;
272
+ const capped = Math.min(Math.trunc(value), MAX_API_TIMEOUT_MS);
273
+ return capped;
274
+ }
275
+ function serializePromptQuery(query) {
276
+ const params = new URLSearchParams();
277
+ const promptQuery = typeof query.query === "string" && query.query.trim() ? query.query : query.q;
278
+ if (typeof promptQuery === "string" && promptQuery.trim()) {
279
+ params.set("q", promptQuery.trim());
280
+ }
281
+ for (const [key, value] of Object.entries({
282
+ category: query.category,
283
+ tool: query.tool,
284
+ outputType: query.outputType,
285
+ })) {
286
+ if (typeof value === "string" && value.trim()) {
287
+ params.set(key, value.trim());
288
+ }
289
+ }
290
+ if (query.limit !== undefined && query.limit !== null && query.limit !== "") {
291
+ const limit = typeof query.limit === "string" ? Number(query.limit) : query.limit;
292
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
293
+ throw new PromptsGptApiError("Prompt limit must be an integer between 1 and 100.", {
294
+ code: "VALIDATION_ERROR",
295
+ recovery: "Choose a limit between 1 and 100.",
296
+ });
297
+ }
298
+ params.set("limit", String(limit));
299
+ }
300
+ return params.toString();
301
+ }
302
+ function isJsonContentType(contentType) {
303
+ const normalized = contentType.toLowerCase();
304
+ return normalized.includes("application/json") || normalized.includes("+json");
305
+ }
306
+ async function parseJsonResponse(response) {
307
+ if (response.status === 204)
308
+ return { ok: true, data: {} };
309
+ const rawBody = await response.text();
310
+ if (!rawBody.trim())
311
+ return null;
312
+ try {
313
+ return JSON.parse(rawBody);
314
+ }
315
+ catch {
316
+ throw new PromptsGptApiError("Prompts-GPT returned malformed JSON.", {
317
+ status: response.status,
318
+ code: "INVALID_RESPONSE",
319
+ recovery: "Retry the request. If it keeps failing, verify the API URL and server health.",
320
+ requestId: response.headers?.get?.("x-request-id") ?? null,
321
+ });
322
+ }
323
+ }
324
+ function parseApiResponse(payload) {
325
+ if (!payload || typeof payload !== "object" || !("ok" in payload))
326
+ return null;
327
+ if (payload.ok === true && "data" in payload) {
328
+ return payload;
329
+ }
330
+ if (payload.ok === false && "error" in payload) {
331
+ return payload;
332
+ }
333
+ return null;
334
+ }
335
+ function shouldRetryRequest({ method, status, retryCount, retryAfterMs, }) {
336
+ if (retryCount >= MAX_RETRIES)
337
+ return false;
338
+ if (method !== "GET")
339
+ return false;
340
+ if (!RETRYABLE_STATUS_CODES.has(status))
341
+ return false;
342
+ if (retryAfterMs !== null && retryAfterMs > 300_000)
343
+ return false;
344
+ return true;
345
+ }
346
+ function computeRetryDelayMs(retryCount, retryAfterMs = null) {
347
+ const baseDelay = retryAfterMs && retryAfterMs > 0 ? retryAfterMs : (retryCount + 1) * 1_000;
348
+ const jitter = Math.random() * 500;
349
+ return Math.min(baseDelay + jitter, 30_000);
350
+ }
351
+ function parseRetryAfterHeader(value) {
352
+ if (!value)
353
+ return null;
354
+ const trimmed = value.trim();
355
+ if (!trimmed)
356
+ return null;
357
+ const seconds = Number(trimmed);
358
+ if (Number.isFinite(seconds) && seconds >= 0) {
359
+ const ms = seconds * 1_000;
360
+ return Math.min(ms, 600_000);
361
+ }
362
+ const retryAt = Date.parse(trimmed);
363
+ if (Number.isNaN(retryAt))
364
+ return null;
365
+ return Math.min(Math.max(retryAt - Date.now(), 0), 600_000);
159
366
  }
160
367
  function sleep(ms) {
161
- return new Promise((resolve) => setTimeout(resolve, ms));
368
+ return new Promise((resolve) => {
369
+ setTimeout(resolve, ms);
370
+ });
371
+ }
372
+ function generateRequestId() {
373
+ try {
374
+ const crypto = globalThis.crypto;
375
+ const bytes = new Uint8Array(8);
376
+ crypto.getRandomValues(bytes);
377
+ return `pgcli_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
378
+ }
379
+ catch {
380
+ const hex = Array.from({ length: 8 }, () => Math.floor(Math.random() * 256).toString(16).padStart(2, "0")).join("");
381
+ return `pgcli_${hex}`;
382
+ }
383
+ }
384
+ const BUILD_TS = "dev";
385
+ const BUILD_ACCOUNT_ID = "unattributed";
386
+ let cachedBuildFingerprint = null;
387
+ function getBuildFingerprint() {
388
+ if (cachedBuildFingerprint)
389
+ return cachedBuildFingerprint;
390
+ cachedBuildFingerprint = `${getClientVersion()}/${BUILD_TS}/${BUILD_ACCOUNT_ID}`;
391
+ return cachedBuildFingerprint;
392
+ }
393
+ export function getAttribution() {
394
+ return {
395
+ version: getClientVersion(),
396
+ buildTs: BUILD_TS,
397
+ accountId: BUILD_ACCOUNT_ID,
398
+ };
162
399
  }
163
400
  let cachedVersion = null;
164
401
  function getClientVersion() {
@@ -174,33 +411,47 @@ function getClientVersion() {
174
411
  return cachedVersion;
175
412
  }
176
413
  export async function saveLocalCredentials(input) {
177
- if (!input.token?.trim())
414
+ const trimmedToken = input.token?.trim();
415
+ if (!trimmedToken)
178
416
  throw new Error("Token is required.");
179
- if (input.token.trim().length > 256)
417
+ if (trimmedToken.length > 256)
180
418
  throw new Error("Token value is too long.");
419
+ if (!trimmedToken.startsWith("pgpt_"))
420
+ throw new Error("Token must start with the 'pgpt_' prefix.");
181
421
  const cwd = input.cwd ?? process.cwd();
182
422
  const outDir = path.resolve(cwd, DEFAULT_PROMPTS_GPT_OUT_DIR);
183
423
  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 });
424
+ 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 });
425
+ const credentialsPath = path.join(outDir, PROMPTS_GPT_CREDENTIALS_FILE);
426
+ await chmod(credentialsPath, 0o600).catch(() => undefined);
185
427
  await ensureGitignoreEntry(cwd, `${DEFAULT_PROMPTS_GPT_OUT_DIR}/${PROMPTS_GPT_CREDENTIALS_FILE}`);
186
- return { credentialsPath: path.join(outDir, PROMPTS_GPT_CREDENTIALS_FILE) };
428
+ return { credentialsPath };
187
429
  }
188
430
  export async function loadLocalCredentials(cwd = process.cwd()) {
189
431
  const credentialsPath = path.resolve(cwd, DEFAULT_PROMPTS_GPT_OUT_DIR, PROMPTS_GPT_CREDENTIALS_FILE);
190
432
  if (!existsSync(credentialsPath))
191
433
  return null;
192
434
  try {
193
- const parsed = JSON.parse(await readFile(credentialsPath, "utf8"));
435
+ const raw = await readFile(credentialsPath, "utf8");
436
+ const parsed = JSON.parse(raw);
437
+ if (!parsed || typeof parsed !== "object")
438
+ return null;
194
439
  let apiUrl = DEFAULT_PROMPTS_GPT_API_URL;
195
440
  if (typeof parsed.apiUrl === "string") {
196
441
  try {
197
- new URL(parsed.apiUrl);
198
- apiUrl = parsed.apiUrl;
442
+ const url = new URL(parsed.apiUrl);
443
+ if (url.protocol === "https:" || url.protocol === "http:") {
444
+ apiUrl = parsed.apiUrl;
445
+ }
199
446
  }
200
- catch { }
447
+ catch { /* invalid URL — use default */ }
448
+ }
449
+ const rawToken = typeof parsed.token === "string" ? parsed.token.trim() : null;
450
+ if (rawToken && !rawToken.startsWith("pgpt_")) {
451
+ return { token: null, apiUrl };
201
452
  }
202
453
  return {
203
- token: typeof parsed.token === "string" ? parsed.token : null,
454
+ token: rawToken || null,
204
455
  apiUrl,
205
456
  };
206
457
  }
@@ -210,7 +461,12 @@ export async function loadLocalCredentials(cwd = process.cwd()) {
210
461
  }
211
462
  export async function syncPrompts(prompts, options = {}) {
212
463
  const markdown = await writePromptMarkdownFiles(prompts, options);
213
- const agents = await writeAgentFiles(prompts, options);
464
+ const agents = await writeAgentFiles(prompts, {
465
+ cwd: options.cwd,
466
+ agent: options.agent,
467
+ agents: options.agents,
468
+ overwriteAgentFiles: options.overwrite,
469
+ });
214
470
  const manifest = await writePromptManifest(prompts, { cwd: options.cwd, outDir: options.outDir });
215
471
  return { markdown, agents, manifest };
216
472
  }
@@ -218,64 +474,111 @@ export async function writePromptMarkdownFiles(prompts, options = {}) {
218
474
  const cwd = options.cwd ?? process.cwd();
219
475
  const outDir = assertSafeOutputDir(cwd, options.outDir ?? DEFAULT_PROMPTS_GPT_OUT_DIR);
220
476
  const overwrite = Boolean(options.overwrite);
477
+ const normalizedPrompts = assertUniquePromptFileStems(prompts);
221
478
  await mkdir(outDir, { recursive: true });
222
479
  const written = [];
223
480
  const skipped = [];
224
- for (const prompt of prompts) {
225
- const filePath = path.join(outDir, `${safeSlug(prompt.slug || prompt.title)}.md`);
481
+ for (const { prompt, stem } of normalizedPrompts) {
482
+ const filePath = path.join(outDir, `${stem}.md`);
226
483
  assertInside(filePath, outDir);
227
- if (!overwrite && existsSync(filePath)) {
228
- skipped.push(filePath);
229
- continue;
484
+ if (overwrite) {
485
+ await writeFile(filePath, formatPromptMarkdown(prompt));
486
+ written.push(filePath);
487
+ }
488
+ else {
489
+ try {
490
+ await writeFile(filePath, formatPromptMarkdown(prompt), { flag: "wx" });
491
+ written.push(filePath);
492
+ }
493
+ catch (err) {
494
+ if (err.code === "EEXIST") {
495
+ skipped.push(filePath);
496
+ }
497
+ else {
498
+ throw err;
499
+ }
500
+ }
230
501
  }
231
- await writeFile(filePath, formatPromptMarkdown(prompt), { flag: overwrite ? "w" : "wx" });
232
- written.push(filePath);
233
502
  }
234
503
  await writePromptIndex(prompts, { outDir });
235
504
  return { outDir, written, skipped };
236
505
  }
237
506
  export async function writeAgentFiles(prompts, options = {}) {
238
507
  const cwd = options.cwd ?? process.cwd();
508
+ if (!existsSync(cwd)) {
509
+ throw new Error(`Working directory does not exist: ${cwd}`);
510
+ }
239
511
  const targets = normalizeAgentTargets(options.agent ?? options.agents ?? "all");
240
- const overwrite = Boolean(options.overwriteAgentFiles ?? true);
512
+ const overwrite = Boolean(options.overwriteAgentFiles);
241
513
  const written = [];
242
514
  const skipped = [];
243
- const allFiles = targets.flatMap((target) => buildAgentFiles(target, prompts).map((file) => ({ ...file, target })));
515
+ const normalizedPrompts = assertUniquePromptFileStems(prompts);
516
+ const allFiles = targets.flatMap((target) => buildAgentFiles(target, normalizedPrompts.filter(({ prompt }) => promptSupportsAgentTarget(prompt, target))).map((file) => ({ ...file, target })));
517
+ assertUniqueAgentFilePaths(allFiles);
244
518
  const dirSet = new Set(allFiles.map((file) => path.dirname(assertSafeProjectFile(cwd, file.path))));
245
519
  await Promise.all([...dirSet].map((dir) => mkdir(dir, { recursive: true })));
246
- await Promise.all(allFiles.map(async (file) => {
520
+ for (const file of allFiles) {
247
521
  const filePath = assertSafeProjectFile(cwd, file.path);
248
522
  if (file.managedBlock) {
249
- const existing = existsSync(filePath) ? await readFile(filePath, "utf8") : "";
523
+ let existing = "";
524
+ try {
525
+ existing = await readFile(filePath, "utf8");
526
+ }
527
+ catch { }
250
528
  await writeFile(filePath, upsertManagedBlock(existing, file.content));
251
529
  written.push(filePath);
252
- return;
530
+ continue;
531
+ }
532
+ if (overwrite) {
533
+ await writeFile(filePath, file.content);
534
+ written.push(filePath);
253
535
  }
254
- if (!overwrite && existsSync(filePath)) {
255
- skipped.push(filePath);
256
- return;
536
+ else {
537
+ try {
538
+ await writeFile(filePath, file.content, { flag: "wx" });
539
+ written.push(filePath);
540
+ }
541
+ catch (err) {
542
+ if (err.code === "EEXIST") {
543
+ skipped.push(filePath);
544
+ }
545
+ else {
546
+ throw err;
547
+ }
548
+ }
257
549
  }
258
- await writeFile(filePath, file.content, { flag: overwrite ? "w" : "wx" });
259
- written.push(filePath);
260
- }));
550
+ }
261
551
  return { written, skipped, targets };
262
552
  }
263
553
  export async function writePromptManifest(prompts, options = {}) {
264
554
  const cwd = options.cwd ?? process.cwd();
265
555
  const outDir = assertSafeOutputDir(cwd, options.outDir ?? DEFAULT_PROMPTS_GPT_OUT_DIR);
556
+ const normalizedPrompts = assertUniquePromptFileStems(prompts);
266
557
  await mkdir(outDir, { recursive: true });
267
558
  const manifestPath = path.join(outDir, PROMPTS_GPT_MANIFEST_FILE);
559
+ const attribution = getAttribution();
268
560
  const payload = {
269
561
  version: 1,
270
562
  generatedAt: new Date().toISOString(),
271
- count: prompts.length,
272
- prompts: prompts.map((prompt) => ({
273
- slug: safeSlug(prompt.slug || prompt.title),
563
+ generatedBy: `@prompts-gpt/client@${attribution.version}`,
564
+ accountId: attribution.accountId,
565
+ buildFingerprint: `${attribution.version}/${attribution.buildTs}/${attribution.accountId}`,
566
+ count: normalizedPrompts.length,
567
+ prompts: normalizedPrompts.map(({ prompt, stem }) => ({
568
+ slug: stem,
274
569
  title: prompt.title,
570
+ summary: prompt.summary ?? "",
275
571
  source: prompt.source ?? "library",
276
572
  category: prompt.category ?? "Prompt Library",
573
+ difficulty: prompt.difficulty ?? "Intermediate",
574
+ outputType: prompt.outputType ?? "Text",
277
575
  supportedTools: prompt.supportedTools ?? [],
278
- file: `${safeSlug(prompt.slug || prompt.title)}.md`,
576
+ agentTargets: normalizePromptAgentTargets(prompt),
577
+ variables: prompt.variables ?? [],
578
+ tags: prompt.tags ?? [],
579
+ recommendedPath: prompt.recommendedPath ?? null,
580
+ file: `${stem}.md`,
581
+ files: buildDiscoverablePromptFiles(stem, prompt),
279
582
  })),
280
583
  };
281
584
  await writeFile(manifestPath, `${JSON.stringify(payload, null, 2)}\n`);
@@ -302,11 +605,8 @@ export function formatPromptMarkdown(prompt) {
302
605
  "",
303
606
  prompt.promptText ?? "",
304
607
  "",
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 ?? "",
608
+ ...(prompt.variables?.length ? ["## Variables", "", ...prompt.variables.map((variable) => `- \`${variable}\``), ""] : []),
609
+ ...(prompt.usageNotes ? ["## Usage Notes", "", prompt.usageNotes] : []),
310
610
  "",
311
611
  ].reduce((acc, line, index, list) => {
312
612
  if (line === "" && index > 0 && list[index - 1] === "" && (index < 2 || list[index - 2] === ""))
@@ -323,10 +623,27 @@ function buildAgentFiles(target, prompts) {
323
623
  content: [
324
624
  "# Prompts-GPT Agent Instructions",
325
625
  "",
326
- "Prompts synced by `prompts-gpt sync` live in `.prompts-gpt/`. Use the manifest and prompt files before starting related work.",
626
+ "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.",
627
+ "",
628
+ "## Available Prompt Packs",
629
+ ...prompts.map(({ prompt, stem }) => `- [${prompt.title}](.prompts-gpt/${stem}.md)`),
630
+ "",
631
+ "When a prompt pack is relevant, load it, adapt variables to the current task, and keep verification tied to the prompt's acceptance criteria.",
632
+ "",
633
+ ].join("\n"),
634
+ }];
635
+ }
636
+ if (target === "claude-code") {
637
+ return [{
638
+ path: "CLAUDE.md",
639
+ managedBlock: true,
640
+ content: [
641
+ "# Prompts-GPT Claude Code Instructions",
642
+ "",
643
+ "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
644
  "",
328
645
  "## Available Prompt Packs",
329
- ...prompts.map((prompt) => `- ${prompt.title}: .prompts-gpt/${safeSlug(prompt.slug || prompt.title)}.md`),
646
+ ...prompts.map(({ prompt, stem }) => `- [${prompt.title}](.prompts-gpt/${stem}.md)`),
330
647
  "",
331
648
  "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
649
  "",
@@ -334,23 +651,29 @@ function buildAgentFiles(target, prompts) {
334
651
  }];
335
652
  }
336
653
  if (target === "cursor") {
337
- return prompts.map((prompt) => ({
338
- path: `.cursor/rules/prompts-gpt-${safeSlug(prompt.slug || prompt.title)}.mdc`,
339
- content: [
340
- "---",
341
- `description: ${prompt.summary || prompt.title}`,
342
- "globs:",
343
- "alwaysApply: false",
344
- "---",
345
- "",
346
- `# ${prompt.title}`,
347
- "",
348
- prompt.promptText,
349
- "",
350
- prompt.usageNotes ? `Usage notes: ${prompt.usageNotes}` : "",
351
- "",
352
- ].filter(Boolean).join("\n"),
353
- }));
654
+ return prompts.flatMap(({ prompt, stem }) => ([
655
+ {
656
+ path: `.cursor/rules/prompts-gpt-${stem}.mdc`,
657
+ content: [
658
+ "---",
659
+ `description: ${yamlScalar(prompt.summary || prompt.title)}`,
660
+ "globs: []",
661
+ "alwaysApply: false",
662
+ "---",
663
+ "",
664
+ `# ${prompt.title}`,
665
+ "",
666
+ prompt.promptText,
667
+ "",
668
+ prompt.usageNotes ? `Usage notes: ${prompt.usageNotes}` : "",
669
+ "",
670
+ ].filter(Boolean).join("\n"),
671
+ },
672
+ {
673
+ path: `.cursor/commands/prompts-gpt-${stem}.md`,
674
+ content: formatCursorCommandMarkdown(prompt, stem),
675
+ },
676
+ ]));
354
677
  }
355
678
  if (target === "vscode") {
356
679
  return [
@@ -360,9 +683,26 @@ function buildAgentFiles(target, prompts) {
360
683
  content: [
361
684
  "# Prompts-GPT Copilot Instructions",
362
685
  "",
363
- "Use `.prompts-gpt/manifest.json` and `.prompts-gpt/*.md` as reusable prompt packs for this repository.",
686
+ "Use [../.prompts-gpt/manifest.json](../.prompts-gpt/manifest.json) and the linked `.prompts-gpt/*.md` prompt packs as reusable repository context.",
687
+ "",
688
+ ...prompts.map(({ prompt, stem }) => `- [${prompt.title}](../.prompts-gpt/${stem}.md)`),
689
+ "",
690
+ ].join("\n"),
691
+ },
692
+ {
693
+ path: ".github/instructions/prompts-gpt.instructions.md",
694
+ content: [
695
+ "---",
696
+ 'applyTo: "AGENTS.md,.prompts-gpt/**/*.md,.github/copilot-instructions.md,.github/prompts/**/*.prompt.md,.cursor/rules/**/*.mdc,.cursor/commands/**/*.md,.vscode/prompts-gpt.code-snippets"',
697
+ "---",
698
+ "",
699
+ "# Prompts-GPT managed artifacts",
364
700
  "",
365
- ...prompts.map((prompt) => `- ${prompt.title}: .prompts-gpt/${safeSlug(prompt.slug || prompt.title)}.md`),
701
+ "These files are generated or refreshed by `prompts-gpt sync`.",
702
+ "",
703
+ "- Treat `.prompts-gpt/manifest.json` as the source of truth for discoverable prompt packs and generated agent files.",
704
+ "- Prefer updating the upstream prompt pack or rerunning sync instead of manually editing generated agent artifacts.",
705
+ "- Preserve the managed `prompts-gpt` blocks inside `AGENTS.md` and `.github/copilot-instructions.md`.",
366
706
  "",
367
707
  ].join("\n"),
368
708
  },
@@ -373,27 +713,266 @@ function buildAgentFiles(target, prompts) {
373
713
  ];
374
714
  }
375
715
  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),
716
+ return prompts.map(({ prompt, stem }) => ({
717
+ path: `.github/prompts/prompts-gpt-${stem}.prompt.md`,
718
+ content: formatCopilotPromptMarkdown(prompt, stem),
719
+ }));
720
+ }
721
+ if (target === "continue") {
722
+ return prompts.map(({ prompt, stem }) => ({
723
+ path: `.continue/rules/prompts-gpt-${stem}.md`,
724
+ content: [
725
+ `# ${prompt.title}`,
726
+ "",
727
+ `[Canonical prompt pack](../../.prompts-gpt/${stem}.md)`,
728
+ "",
729
+ prompt.summary ?? "",
730
+ "",
731
+ prompt.promptText ?? "",
732
+ "",
733
+ prompt.usageNotes ? `Usage notes: ${prompt.usageNotes}` : "",
734
+ "",
735
+ ].filter(Boolean).join("\n"),
736
+ }));
737
+ }
738
+ if (target === "gemini-cli") {
739
+ return [{
740
+ path: "GEMINI.md",
741
+ managedBlock: true,
742
+ content: [
743
+ "# Prompts-GPT Gemini CLI Instructions",
744
+ "",
745
+ "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.",
746
+ "",
747
+ "## Available Prompt Packs",
748
+ ...prompts.map(({ prompt, stem }) => `- [${prompt.title}](.prompts-gpt/${stem}.md)`),
749
+ "",
750
+ "When a prompt pack is relevant, load it, adapt variables to the current task, and keep verification tied to the prompt's acceptance criteria.",
751
+ "",
752
+ ].join("\n"),
753
+ }];
754
+ }
755
+ if (target === "windsurf") {
756
+ return prompts.map(({ prompt, stem }) => ({
757
+ path: `.windsurf/rules/prompts-gpt-${stem}.md`,
758
+ content: [
759
+ `# ${prompt.title}`,
760
+ "",
761
+ `[Canonical prompt pack](../../.prompts-gpt/${stem}.md)`,
762
+ "",
763
+ prompt.summary ?? "",
764
+ "",
765
+ prompt.promptText ?? "",
766
+ "",
767
+ "Use this as a workspace rule for Cascade or Devin Local when the prompt pack matches the task.",
768
+ prompt.usageNotes ? `Usage notes: ${prompt.usageNotes}` : "",
769
+ "",
770
+ ].filter(Boolean).join("\n"),
771
+ }));
772
+ }
773
+ if (target === "cline") {
774
+ return prompts.map(({ prompt, stem }) => ({
775
+ path: `.clinerules/prompts-gpt-${stem}.md`,
776
+ content: [
777
+ `# ${prompt.title}`,
778
+ "",
779
+ `[Canonical prompt pack](../.prompts-gpt/${stem}.md)`,
780
+ "",
781
+ prompt.summary ?? "",
782
+ "",
783
+ prompt.promptText ?? "",
784
+ "",
785
+ "Enable this rule when the current Cline task matches the linked prompt pack.",
786
+ prompt.usageNotes ? `Usage notes: ${prompt.usageNotes}` : "",
787
+ "",
788
+ ].filter(Boolean).join("\n"),
379
789
  }));
380
790
  }
791
+ if (target === "junie") {
792
+ return [{
793
+ path: ".junie/guidelines.md",
794
+ managedBlock: true,
795
+ content: [
796
+ "# Prompts-GPT Junie Guidelines",
797
+ "",
798
+ "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.",
799
+ "",
800
+ "## Available Prompt Packs",
801
+ ...prompts.map(({ prompt, stem }) => `- [${prompt.title}](../.prompts-gpt/${stem}.md)`),
802
+ "",
803
+ "When a prompt pack is relevant, load it, adapt variables to the current task, and keep verification tied to the prompt's acceptance criteria.",
804
+ "",
805
+ ].join("\n"),
806
+ }];
807
+ }
808
+ if (target === "amp") {
809
+ return [{
810
+ path: "AGENT.md",
811
+ managedBlock: true,
812
+ content: [
813
+ "# Prompts-GPT Amp Instructions",
814
+ "",
815
+ "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.",
816
+ "",
817
+ "## Available Prompt Packs",
818
+ ...prompts.map(({ prompt, stem }) => `- [${prompt.title}](.prompts-gpt/${stem}.md)`),
819
+ "",
820
+ "When a prompt pack is relevant, load it, adapt variables to the current task, and keep verification tied to the prompt's acceptance criteria.",
821
+ "",
822
+ ].join("\n"),
823
+ }];
824
+ }
381
825
  return [];
382
826
  }
383
827
  function buildVsCodeSnippets(prompts) {
384
- return prompts.reduce((snippets, prompt) => {
828
+ return prompts.reduce((snippets, { prompt, stem }) => {
385
829
  snippets[`Prompts-GPT: ${prompt.title}`] = {
386
- prefix: `pgpt-${safeSlug(prompt.slug || prompt.title)}`,
830
+ prefix: `pgpt-${stem}`,
387
831
  description: prompt.summary || prompt.title,
388
832
  body: String(prompt.promptText || "").split(/\r?\n/),
389
833
  };
390
834
  return snippets;
391
835
  }, {});
392
836
  }
837
+ function normalizePromptAgentTargets(prompt) {
838
+ const validSet = new Set(SUPPORTED_AGENT_TARGETS);
839
+ const declaredTargets = Array.isArray(prompt.agentTargets)
840
+ ? prompt.agentTargets.map((target) => String(target).trim().toLowerCase()).filter(Boolean)
841
+ : [];
842
+ return [...new Set(declaredTargets.filter((target) => validSet.has(target)))];
843
+ }
844
+ function promptSupportsAgentTarget(prompt, target) {
845
+ const declaredTargets = normalizePromptAgentTargets(prompt);
846
+ return declaredTargets.length === 0 || declaredTargets.includes(target);
847
+ }
848
+ function assertUniquePromptFileStems(prompts) {
849
+ const byStem = new Map();
850
+ const normalizedPrompts = prompts.map((prompt) => {
851
+ const stem = safeSlug(prompt.slug || prompt.title);
852
+ const matches = byStem.get(stem) ?? [];
853
+ matches.push(prompt);
854
+ byStem.set(stem, matches);
855
+ return { prompt, stem };
856
+ });
857
+ const collisions = [...byStem.entries()].filter(([, items]) => items.length > 1);
858
+ if (collisions.length > 0) {
859
+ const details = collisions
860
+ .map(([stem, items]) => `${stem} <- ${items.map((item) => item.title || item.slug || "Untitled prompt").join(", ")}`)
861
+ .join("; ");
862
+ throw new Error(`Refusing to sync prompts with colliding file names after slug normalization: ${details}`);
863
+ }
864
+ return normalizedPrompts;
865
+ }
866
+ function assertUniqueAgentFilePaths(files) {
867
+ const pathCounts = new Map();
868
+ for (const file of files) {
869
+ const targets = pathCounts.get(file.path) ?? [];
870
+ targets.push(file.target);
871
+ pathCounts.set(file.path, targets);
872
+ }
873
+ const collisions = [...pathCounts.entries()].filter(([, targets]) => targets.length > 1 && !targets.every((target) => target === targets[0]));
874
+ if (collisions.length > 0) {
875
+ const details = collisions.map(([filePath, targets]) => `${filePath} <- ${targets.join(", ")}`).join("; ");
876
+ throw new Error(`Refusing to sync duplicate agent file paths: ${details}`);
877
+ }
878
+ }
879
+ function buildDiscoverablePromptFiles(stem, prompt) {
880
+ const agentTargets = new Set(normalizePromptAgentTargets(prompt));
881
+ const supports = (target) => agentTargets.size === 0 || agentTargets.has(target);
882
+ return {
883
+ markdown: `.prompts-gpt/${stem}.md`,
884
+ codexInstructions: supports("codex") ? "AGENTS.md" : null,
885
+ claudeCodeInstructions: supports("claude-code") ? "CLAUDE.md" : null,
886
+ cursorRule: supports("cursor") ? `.cursor/rules/prompts-gpt-${stem}.mdc` : null,
887
+ cursorCommand: supports("cursor") ? `.cursor/commands/prompts-gpt-${stem}.md` : null,
888
+ vscodeInstructions: supports("vscode") ? ".github/copilot-instructions.md" : null,
889
+ copilotPathInstructions: supports("vscode") ? ".github/instructions/prompts-gpt.instructions.md" : null,
890
+ vscodeSnippets: supports("vscode") ? ".vscode/prompts-gpt.code-snippets" : null,
891
+ copilotPrompt: supports("copilot") ? `.github/prompts/prompts-gpt-${stem}.prompt.md` : null,
892
+ continueRule: supports("continue") ? `.continue/rules/prompts-gpt-${stem}.md` : null,
893
+ geminiInstructions: supports("gemini-cli") ? "GEMINI.md" : null,
894
+ windsurfRule: supports("windsurf") ? `.windsurf/rules/prompts-gpt-${stem}.md` : null,
895
+ clineRule: supports("cline") ? `.clinerules/prompts-gpt-${stem}.md` : null,
896
+ junieGuidelines: supports("junie") ? ".junie/guidelines.md" : null,
897
+ ampInstructions: supports("amp") ? "AGENT.md" : null,
898
+ };
899
+ }
900
+ function formatCursorCommandMarkdown(prompt, stem) {
901
+ const lines = [
902
+ `# ${prompt.title}`,
903
+ "",
904
+ `[Canonical prompt pack](../../.prompts-gpt/${stem}.md)`,
905
+ "",
906
+ prompt.summary ?? "",
907
+ "",
908
+ "## Task",
909
+ "",
910
+ prompt.promptText ?? "",
911
+ ];
912
+ if (prompt.variables?.length) {
913
+ lines.push("", "## Inputs", "");
914
+ for (const variable of prompt.variables) {
915
+ lines.push(`- ${String(variable).replace(/[{}$]/g, "")}`);
916
+ }
917
+ }
918
+ if (prompt.usageNotes) {
919
+ lines.push("", "## Usage Notes", "", prompt.usageNotes);
920
+ }
921
+ lines.push("", "Verify the output against `.prompts-gpt/manifest.json` and the linked canonical prompt pack.", "");
922
+ return lines
923
+ .reduce((acc, line, index, list) => {
924
+ if (line === "" && index > 0 && list[index - 1] === "" && (index < 2 || list[index - 2] === ""))
925
+ return acc;
926
+ acc.push(line);
927
+ return acc;
928
+ }, [])
929
+ .join("\n");
930
+ }
931
+ function formatCopilotPromptMarkdown(prompt, stem) {
932
+ const sections = [
933
+ "---",
934
+ "mode: agent",
935
+ `description: ${yamlScalar(prompt.summary || prompt.title)}`,
936
+ "---",
937
+ "",
938
+ `# ${prompt.title}`,
939
+ "",
940
+ `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).`,
941
+ "",
942
+ prompt.summary ?? "",
943
+ "",
944
+ "## Task",
945
+ "",
946
+ prompt.promptText ?? "",
947
+ ];
948
+ if (prompt.variables?.length) {
949
+ sections.push("", "## Inputs", "");
950
+ for (const variable of prompt.variables) {
951
+ const sanitizedName = safeSlug(variable) || "value";
952
+ const sanitizedLabel = String(variable).replace(/[{}$]/g, "");
953
+ sections.push(`- ${sanitizedLabel}: \${input:${sanitizedName}:Provide ${sanitizedLabel}}`);
954
+ }
955
+ }
956
+ if (prompt.usageNotes) {
957
+ sections.push("", "## Usage Notes", "", prompt.usageNotes);
958
+ }
959
+ sections.push("");
960
+ return sections
961
+ .reduce((acc, line, index, list) => {
962
+ if (line === "" && index > 0 && list[index - 1] === "" && (index < 2 || list[index - 2] === ""))
963
+ return acc;
964
+ acc.push(line);
965
+ return acc;
966
+ }, [])
967
+ .join("\n");
968
+ }
393
969
  function upsertManagedBlock(existing, content) {
394
970
  const start = "<!-- prompts-gpt:start -->";
395
971
  const end = "<!-- prompts-gpt:end -->";
396
- const block = `${start}\n${content.trim()}\n${end}`;
972
+ const sanitizedContent = content.trim()
973
+ .replace(/<!-- prompts-gpt:start -->/g, "")
974
+ .replace(/<!-- prompts-gpt:end -->/g, "");
975
+ const block = `${start}\n${sanitizedContent}\n${end}`;
397
976
  const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`);
398
977
  if (pattern.test(existing)) {
399
978
  return `${existing.replace(pattern, block).trimEnd()}\n`;
@@ -404,25 +983,41 @@ function upsertManagedBlock(existing, content) {
404
983
  function escapeRegExp(value) {
405
984
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
406
985
  }
986
+ function escapeMarkdownLinkText(value) {
987
+ return value.replace(/[[\]]/g, "\\$&");
988
+ }
407
989
  async function writePromptIndex(prompts, { outDir }) {
408
- const indexPath = path.join(outDir, "README.md");
409
- const content = [
990
+ const indexPath = path.resolve(outDir, "README.md");
991
+ assertInside(indexPath, path.resolve(outDir));
992
+ const managedContent = [
410
993
  "# Prompts-GPT Prompt Packs",
411
994
  "",
412
995
  "These prompts were synced by `prompts-gpt`. Re-run `prompts-gpt sync` to refresh Markdown and agent files.",
413
996
  "",
414
997
  "## Prompts",
415
- ...prompts.map((prompt) => `- [${prompt.title}](./${safeSlug(prompt.slug || prompt.title)}.md) - ${prompt.summary ?? ""}`),
998
+ ...prompts.map((prompt) => {
999
+ const agentTargets = normalizePromptAgentTargets(prompt);
1000
+ const suffix = agentTargets.length ? ` Targets: ${agentTargets.join(", ")}.` : "";
1001
+ const escapedTitle = escapeMarkdownLinkText(prompt.title);
1002
+ return `- [${escapedTitle}](./${safeSlug(prompt.slug || prompt.title)}.md) - ${prompt.summary ?? ""}${suffix}`;
1003
+ }),
416
1004
  "",
417
1005
  ].join("\n");
418
- assertInside(indexPath, outDir);
419
- await writeFile(indexPath, content);
1006
+ let existing = "";
1007
+ try {
1008
+ existing = await readFile(indexPath, "utf8");
1009
+ }
1010
+ catch { }
1011
+ await writeFile(indexPath, upsertManagedBlock(existing, managedContent));
420
1012
  }
421
1013
  function normalizeAgentTargets(value) {
422
1014
  const raw = Array.isArray(value) ? value.join(",") : String(value ?? "all");
423
1015
  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)];
1016
+ if (targets.includes("all"))
1017
+ return [...SUPPORTED_AGENT_TARGETS];
1018
+ const unique = [...new Set(targets)];
1019
+ if (unique.length === 0)
1020
+ return [...SUPPORTED_AGENT_TARGETS];
426
1021
  const validSet = new Set(SUPPORTED_AGENT_TARGETS);
427
1022
  const invalid = unique.filter((target) => !validSet.has(target));
428
1023
  if (invalid.length)
@@ -431,6 +1026,9 @@ function normalizeAgentTargets(value) {
431
1026
  }
432
1027
  function normalizeApiUrl(value) {
433
1028
  const url = new URL(value);
1029
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
1030
+ throw new Error(`API URL must use https or http, got ${url.protocol}`);
1031
+ }
434
1032
  url.pathname = "/";
435
1033
  url.search = "";
436
1034
  url.hash = "";
@@ -447,10 +1045,14 @@ function safeNormalizeApiUrl(value) {
447
1045
  });
448
1046
  }
449
1047
  }
1048
+ function isInsideDirectory(child, parent) {
1049
+ const normalizedParent = parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`;
1050
+ return child.startsWith(normalizedParent) || child === parent;
1051
+ }
450
1052
  function assertSafeOutputDir(cwd, outDir) {
451
1053
  const root = path.resolve(cwd);
452
1054
  const resolved = path.resolve(root, outDir);
453
- if (!resolved.startsWith(`${root}${path.sep}`)) {
1055
+ if (!isInsideDirectory(resolved, root) || resolved === root) {
454
1056
  throw new Error("Output directory must be a subdirectory of the current project.");
455
1057
  }
456
1058
  return resolved;
@@ -458,33 +1060,56 @@ function assertSafeOutputDir(cwd, outDir) {
458
1060
  function assertSafeProjectFile(cwd, filePath) {
459
1061
  const root = path.resolve(cwd);
460
1062
  const resolved = path.resolve(root, filePath);
461
- if (!resolved.startsWith(`${root}${path.sep}`)) {
1063
+ if (!isInsideDirectory(resolved, root) || resolved === root) {
462
1064
  throw new Error("Agent file path must stay inside the current project.");
463
1065
  }
464
1066
  return resolved;
465
1067
  }
466
1068
  function assertInside(filePath, directory) {
467
- if (!filePath.startsWith(`${directory}${path.sep}`)) {
1069
+ if (!isInsideDirectory(filePath, directory) || filePath === directory) {
468
1070
  throw new Error("Refusing to write outside the prompt output directory.");
469
1071
  }
470
1072
  }
471
1073
  function safeSlug(value) {
472
- return String(value ?? "prompt")
1074
+ const raw = String(value ?? "").trim();
1075
+ if (!raw)
1076
+ return "prompt";
1077
+ const slug = raw
473
1078
  .toLowerCase()
1079
+ .normalize("NFKD")
1080
+ .replace(/[\u0300-\u036f]/g, "")
474
1081
  .replace(/[^a-z0-9]+/g, "-")
475
1082
  .replace(/^-+|-+$/g, "")
476
- .slice(0, 90) || "prompt";
1083
+ .slice(0, 90);
1084
+ if (!slug || slug === "-")
1085
+ return "prompt";
1086
+ return slug;
477
1087
  }
478
1088
  function yamlScalar(value) {
479
- return JSON.stringify(String(value ?? ""));
1089
+ const s = String(value ?? "");
1090
+ if (s.includes("\n") || s.includes("\r")) {
1091
+ return JSON.stringify(s.replace(/\r\n?/g, "\n"));
1092
+ }
1093
+ return JSON.stringify(s);
480
1094
  }
481
1095
  async function ensureGitignoreEntry(cwd, entry) {
482
1096
  const gitignorePath = path.resolve(cwd, ".gitignore");
483
1097
  const existing = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
1098
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
484
1099
  const lines = existing.split(/\r?\n/).map((line) => line.trim());
485
1100
  if (lines.includes(entry))
486
1101
  return;
487
- const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
488
- await writeFile(gitignorePath, `${existing}${prefix}${entry}\n`);
1102
+ const needsLeadingNewline = existing.length > 0 && !existing.endsWith("\n") && !existing.endsWith("\r\n");
1103
+ const prefix = needsLeadingNewline ? eol : "";
1104
+ await writeFile(gitignorePath, `${existing}${prefix}${entry}${eol}`);
1105
+ // Re-read to verify entry was written (guards against concurrent writers)
1106
+ const verify = await readFile(gitignorePath, "utf8");
1107
+ const verifyLines = verify.split(/\r?\n/).map((line) => line.trim());
1108
+ const count = verifyLines.filter((l) => l === entry).length;
1109
+ if (count > 1) {
1110
+ // Deduplicate if a concurrent call wrote the same entry
1111
+ const deduped = verifyLines.filter((l, i) => l !== entry || verifyLines.indexOf(entry) === i);
1112
+ await writeFile(gitignorePath, deduped.join(eol) + (verify.endsWith("\n") || verify.endsWith("\r\n") ? eol : ""));
1113
+ }
489
1114
  }
490
1115
  //# sourceMappingURL=index.js.map