gsd-pi 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +7 -1
  3. package/dist/loader.js +21 -3
  4. package/dist/logo.d.ts +3 -3
  5. package/dist/logo.js +2 -2
  6. package/package.json +1 -1
  7. package/src/resources/extensions/get-secrets-from-user.ts +331 -59
  8. package/src/resources/extensions/gsd/auto.ts +80 -18
  9. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  10. package/src/resources/extensions/gsd/doctor.ts +23 -4
  11. package/src/resources/extensions/gsd/files.ts +115 -1
  12. package/src/resources/extensions/gsd/git-service.ts +67 -105
  13. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  14. package/src/resources/extensions/gsd/guided-flow.ts +6 -3
  15. package/src/resources/extensions/gsd/preferences.ts +8 -0
  16. package/src/resources/extensions/gsd/prompts/complete-slice.md +7 -5
  17. package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
  18. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -6
  19. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
  20. package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
  21. package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
  22. package/src/resources/extensions/gsd/session-forensics.ts +19 -6
  23. package/src/resources/extensions/gsd/templates/plan.md +8 -10
  24. package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
  25. package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
  26. package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +196 -0
  27. package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +469 -0
  28. package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +170 -0
  29. package/src/resources/extensions/gsd/tests/git-service.test.ts +106 -0
  30. package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
  31. package/src/resources/extensions/gsd/tests/parsers.test.ts +401 -65
  32. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
  33. package/src/resources/extensions/gsd/types.ts +27 -0
package/README.md CHANGED
@@ -224,6 +224,7 @@ On first run, GSD launches a branded setup wizard that walks you through LLM pro
224
224
  | `Ctrl+Alt+V` | Toggle voice transcription |
225
225
  | `Ctrl+Alt+B` | Show background shell processes |
226
226
  | `gsd config` | Re-run the setup wizard (LLM provider + tool keys) |
227
+ | `gsd --continue` (`-c`) | Resume the most recent session for the current directory |
227
228
 
228
229
  ---
229
230
 
package/dist/cli.js CHANGED
@@ -20,6 +20,9 @@ function parseCliArgs(argv) {
20
20
  else if (arg === '--print' || arg === '-p') {
21
21
  flags.print = true;
22
22
  }
23
+ else if (arg === '--continue' || arg === '-c') {
24
+ flags.continue = true;
25
+ }
23
26
  else if (arg === '--no-session') {
24
27
  flags.noSession = true;
25
28
  }
@@ -45,6 +48,7 @@ function parseCliArgs(argv) {
45
48
  process.stdout.write('Options:\n');
46
49
  process.stdout.write(' --mode <text|json|rpc> Output mode (default: interactive)\n');
47
50
  process.stdout.write(' --print, -p Single-shot print mode\n');
51
+ process.stdout.write(' --continue, -c Resume the most recent session\n');
48
52
  process.stdout.write(' --model <id> Override model (e.g. claude-opus-4-6)\n');
49
53
  process.stdout.write(' --no-session Disable session persistence\n');
50
54
  process.stdout.write(' --extension <path> Load additional extension\n');
@@ -200,7 +204,9 @@ if (existsSync(sessionsDir)) {
200
204
  // Non-fatal — don't block startup if migration fails
201
205
  }
202
206
  }
203
- const sessionManager = SessionManager.create(cwd, projectSessionsDir);
207
+ const sessionManager = cliFlags.continue
208
+ ? SessionManager.continueRecent(cwd, projectSessionsDir)
209
+ : SessionManager.create(cwd, projectSessionsDir);
204
210
  initResources(agentDir);
205
211
  const resourceLoader = buildResourceLoader(agentDir);
206
212
  await resourceLoader.reload();
package/dist/loader.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from 'url';
3
3
  import { dirname, resolve, join } from 'path';
4
- import { readFileSync } from 'fs';
5
- import { agentDir } from './app-paths.js';
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { agentDir, appRoot } from './app-paths.js';
6
+ import { renderLogo } from './logo.js';
6
7
  // pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's
7
8
  // theme assets (dist/modes/interactive/theme/) without a src/ directory.
8
9
  // This allows config.js to:
@@ -14,7 +15,24 @@ const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'pkg');
14
15
  process.env.PI_PACKAGE_DIR = pkgDir;
15
16
  process.env.PI_SKIP_VERSION_CHECK = '1'; // GSD ships its own update check — suppress pi's
16
17
  process.title = 'gsd';
17
- // First-launch branding is handled by the onboarding wizard (src/onboarding.ts)
18
+ // Print branded banner on first launch (before ~/.gsd/ exists)
19
+ if (!existsSync(appRoot)) {
20
+ const cyan = '\x1b[36m';
21
+ const green = '\x1b[32m';
22
+ const dim = '\x1b[2m';
23
+ const reset = '\x1b[0m';
24
+ const colorCyan = (s) => `${cyan}${s}${reset}`;
25
+ let version = '';
26
+ try {
27
+ const pkgJson = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
28
+ version = pkgJson.version ?? '';
29
+ }
30
+ catch { /* ignore */ }
31
+ process.stderr.write(renderLogo(colorCyan) +
32
+ '\n' +
33
+ ` Get Shit Done ${dim}v${version}${reset}\n` +
34
+ ` ${green}Welcome.${reset} Setting up your environment...\n\n`);
35
+ }
18
36
  // GSD_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.gsd/agent/ instead of ~/.gsd/agent/
19
37
  process.env.GSD_CODING_AGENT_DIR = agentDir;
20
38
  // NODE_PATH — make gsd's own node_modules available to extensions loaded via jiti.
package/dist/logo.d.ts CHANGED
@@ -3,14 +3,14 @@
3
3
  *
4
4
  * Single source of truth — imported by:
5
5
  * - scripts/postinstall.js (via dist/logo.js)
6
- * - src/onboarding.ts (via ./logo.js)
6
+ * - src/loader.ts (via ./logo.js)
7
7
  */
8
8
  /** Raw logo lines — no ANSI codes, no leading newline. */
9
- export declare const GSD_LOGO: string[];
9
+ export declare const GSD_LOGO: readonly string[];
10
10
  /**
11
11
  * Render the logo block with a color function applied to each line.
12
12
  *
13
- * @param color — e.g. picocolors.cyan or `(s) => `\x1b[36m${s}\x1b[0m``
13
+ * @param color — e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or picocolors.cyan
14
14
  * @returns Ready-to-write string with leading/trailing newlines.
15
15
  */
16
16
  export declare function renderLogo(color: (s: string) => string): string;
package/dist/logo.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Single source of truth — imported by:
5
5
  * - scripts/postinstall.js (via dist/logo.js)
6
- * - src/onboarding.ts (via ./logo.js)
6
+ * - src/loader.ts (via ./logo.js)
7
7
  */
8
8
  /** Raw logo lines — no ANSI codes, no leading newline. */
9
9
  export const GSD_LOGO = [
@@ -17,7 +17,7 @@ export const GSD_LOGO = [
17
17
  /**
18
18
  * Render the logo block with a color function applied to each line.
19
19
  *
20
- * @param color — e.g. picocolors.cyan or `(s) => `\x1b[36m${s}\x1b[0m``
20
+ * @param color — e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or picocolors.cyan
21
21
  * @returns Ready-to-write string with leading/trailing newlines.
22
22
  */
23
23
  export function renderLogo(color) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,11 +7,16 @@
7
7
  */
8
8
 
9
9
  import { readFile, writeFile } from "node:fs/promises";
10
+ import { existsSync, statSync } from "node:fs";
10
11
  import { resolve } from "node:path";
11
12
 
12
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
- import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
13
+ import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
14
+ import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
14
15
  import { Type } from "@sinclair/typebox";
16
+ import { makeUI, type ProgressStatus } from "./shared/ui.js";
17
+ import { parseSecretsManifest, formatSecretsManifest } from "./gsd/files.js";
18
+ import { resolveMilestoneFile } from "./gsd/paths.js";
19
+ import type { SecretsManifestEntry } from "./gsd/types.js";
15
20
 
16
21
  // ─── Types ────────────────────────────────────────────────────────────────────
17
22
 
@@ -25,6 +30,8 @@ interface ToolResultDetails {
25
30
  environment?: string;
26
31
  applied: string[];
27
32
  skipped: string[];
33
+ existingSkipped?: string[];
34
+ detectedDestination?: string;
28
35
  }
29
36
 
30
37
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -91,6 +98,52 @@ async function writeEnvKey(filePath: string, key: string, value: string): Promis
91
98
  await writeFile(filePath, content, "utf8");
92
99
  }
93
100
 
101
+ // ─── Exported utilities ───────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Check which keys already exist in the .env file or process.env.
105
+ * Returns the subset of `keys` that are already set.
106
+ * Handles ENOENT gracefully (still checks process.env).
107
+ * Empty-string values count as existing.
108
+ */
109
+ export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise<string[]> {
110
+ let fileContent = "";
111
+ try {
112
+ fileContent = await readFile(envFilePath, "utf8");
113
+ } catch {
114
+ // ENOENT or other read error — proceed with empty content
115
+ }
116
+
117
+ const existing: string[] = [];
118
+ for (const key of keys) {
119
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
+ const regex = new RegExp(`^${escaped}\\s*=`, "m");
121
+ if (regex.test(fileContent) || key in process.env) {
122
+ existing.push(key);
123
+ }
124
+ }
125
+ return existing;
126
+ }
127
+
128
+ /**
129
+ * Detect the write destination based on project files in basePath.
130
+ * Priority: vercel.json → convex/ dir → fallback "dotenv".
131
+ */
132
+ export function detectDestination(basePath: string): "dotenv" | "vercel" | "convex" {
133
+ if (existsSync(resolve(basePath, "vercel.json"))) {
134
+ return "vercel";
135
+ }
136
+ const convexPath = resolve(basePath, "convex");
137
+ try {
138
+ if (existsSync(convexPath) && statSync(convexPath).isDirectory()) {
139
+ return "convex";
140
+ }
141
+ } catch {
142
+ // stat error — treat as not found
143
+ }
144
+ return "dotenv";
145
+ }
146
+
94
147
  // ─── Paged secure input UI ────────────────────────────────────────────────────
95
148
 
96
149
  /**
@@ -103,6 +156,7 @@ async function collectOneSecret(
103
156
  totalPages: number,
104
157
  keyName: string,
105
158
  hint: string | undefined,
159
+ guidance?: string[],
106
160
  ): Promise<string | null> {
107
161
  if (!ctx.hasUI) return null;
108
162
 
@@ -160,6 +214,21 @@ async function collectOneSecret(
160
214
  if (hint) {
161
215
  add(theme.fg("muted", ` ${hint}`));
162
216
  }
217
+
218
+ // Guidance steps (numbered, dim, wrapped for long URLs)
219
+ if (guidance && guidance.length > 0) {
220
+ lines.push("");
221
+ for (let g = 0; g < guidance.length; g++) {
222
+ const prefix = ` ${g + 1}. `;
223
+ const step = guidance[g] as string;
224
+ const wrappedLines = wrapTextWithAnsi(step, width - 4);
225
+ for (let w = 0; w < wrappedLines.length; w++) {
226
+ const indent = w === 0 ? prefix : " ".repeat(prefix.length);
227
+ lines.push(theme.fg("dim", `${indent}${wrappedLines[w]}`));
228
+ }
229
+ }
230
+ }
231
+
163
232
  lines.push("");
164
233
 
165
234
  // Masked preview
@@ -190,6 +259,248 @@ async function collectOneSecret(
190
259
  });
191
260
  }
192
261
 
262
+ /**
263
+ * Exported wrapper around collectOneSecret for testing.
264
+ * Exposes the same interface with guidance parameter for test verification.
265
+ */
266
+ export const collectOneSecretWithGuidance = collectOneSecret;
267
+
268
+ // ─── Summary Screen ───────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Read-only summary screen showing all manifest entries with status indicators.
272
+ * Follows the confirm-ui.ts pattern: render → any key → done.
273
+ *
274
+ * Status mapping:
275
+ * - collected → done
276
+ * - pending → pending
277
+ * - skipped → skipped
278
+ * - existing keys (in existingKeys) → done with "already set" annotation
279
+ */
280
+ export async function showSecretsSummary(
281
+ ctx: { ui: any; hasUI: boolean },
282
+ entries: SecretsManifestEntry[],
283
+ existingKeys: string[],
284
+ ): Promise<void> {
285
+ if (!ctx.hasUI) return;
286
+
287
+ const existingSet = new Set(existingKeys);
288
+
289
+ await ctx.ui.custom<void>((tui: any, theme: Theme, _kb: any, done: () => void) => {
290
+ let cachedLines: string[] | undefined;
291
+
292
+ function handleInput(_data: string) {
293
+ // Any key dismisses
294
+ done();
295
+ }
296
+
297
+ function render(width: number): string[] {
298
+ if (cachedLines) return cachedLines;
299
+
300
+ const ui = makeUI(theme, width);
301
+ const lines: string[] = [];
302
+ const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
303
+
304
+ push(ui.bar());
305
+ push(ui.blank());
306
+ push(ui.header(" Secrets Summary"));
307
+ push(ui.blank());
308
+
309
+ for (const entry of entries) {
310
+ let status: ProgressStatus;
311
+ let detail: string | undefined;
312
+
313
+ if (existingSet.has(entry.key)) {
314
+ status = "done";
315
+ detail = "already set";
316
+ } else if (entry.status === "collected") {
317
+ status = "done";
318
+ } else if (entry.status === "skipped") {
319
+ status = "skipped";
320
+ } else {
321
+ status = "pending";
322
+ }
323
+
324
+ push(ui.progressItem(entry.key, status, { detail }));
325
+ }
326
+
327
+ push(ui.blank());
328
+ push(ui.hints(["any key to continue"]));
329
+ push(ui.bar());
330
+
331
+ cachedLines = lines;
332
+ return lines;
333
+ }
334
+
335
+ return {
336
+ render,
337
+ invalidate: () => { cachedLines = undefined; },
338
+ handleInput,
339
+ };
340
+ });
341
+ }
342
+
343
+ // ─── Destination Write Helper ─────────────────────────────────────────────────
344
+
345
+ /**
346
+ * Apply collected secrets to the target destination.
347
+ * Dotenv writes are handled directly; vercel/convex require pi.exec.
348
+ */
349
+ async function applySecrets(
350
+ provided: Array<{ key: string; value: string }>,
351
+ destination: "dotenv" | "vercel" | "convex",
352
+ opts: {
353
+ envFilePath: string;
354
+ environment?: string;
355
+ exec?: (cmd: string, args: string[]) => Promise<{ code: number; stderr: string }>;
356
+ },
357
+ ): Promise<{ applied: string[]; errors: string[] }> {
358
+ const applied: string[] = [];
359
+ const errors: string[] = [];
360
+
361
+ if (destination === "dotenv") {
362
+ for (const { key, value } of provided) {
363
+ try {
364
+ await writeEnvKey(opts.envFilePath, key, value);
365
+ applied.push(key);
366
+ } catch (err: any) {
367
+ errors.push(`${key}: ${err.message}`);
368
+ }
369
+ }
370
+ }
371
+
372
+ if (destination === "vercel" && opts.exec) {
373
+ const env = opts.environment ?? "development";
374
+ for (const { key, value } of provided) {
375
+ try {
376
+ const result = await opts.exec("sh", [
377
+ "-c",
378
+ `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`,
379
+ ]);
380
+ if (result.code !== 0) {
381
+ errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
382
+ } else {
383
+ applied.push(key);
384
+ }
385
+ } catch (err: any) {
386
+ errors.push(`${key}: ${err.message}`);
387
+ }
388
+ }
389
+ }
390
+
391
+ if (destination === "convex" && opts.exec) {
392
+ for (const { key, value } of provided) {
393
+ try {
394
+ const result = await opts.exec("sh", [
395
+ "-c",
396
+ `npx convex env set ${key} ${shellEscapeSingle(value)}`,
397
+ ]);
398
+ if (result.code !== 0) {
399
+ errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
400
+ } else {
401
+ applied.push(key);
402
+ }
403
+ } catch (err: any) {
404
+ errors.push(`${key}: ${err.message}`);
405
+ }
406
+ }
407
+ }
408
+
409
+ return { applied, errors };
410
+ }
411
+
412
+ // ─── Manifest Orchestrator ────────────────────────────────────────────────────
413
+
414
+ /**
415
+ * Full orchestrator: reads manifest, checks env, shows summary, collects
416
+ * only pending keys (with guidance + hint), updates manifest statuses,
417
+ * writes back, and applies collected values to the destination.
418
+ *
419
+ * Returns a structured result matching the tool result shape.
420
+ */
421
+ export async function collectSecretsFromManifest(
422
+ base: string,
423
+ milestoneId: string,
424
+ ctx: { ui: any; hasUI: boolean; cwd: string },
425
+ ): Promise<{ applied: string[]; skipped: string[]; existingSkipped: string[] }> {
426
+ // (a) Resolve manifest path
427
+ const manifestPath = resolveMilestoneFile(base, milestoneId, "SECRETS");
428
+ if (!manifestPath) {
429
+ throw new Error(`Secrets manifest not found for milestone ${milestoneId} in ${base}`);
430
+ }
431
+
432
+ // (b) Read and parse manifest
433
+ const content = await readFile(manifestPath, "utf8");
434
+ const manifest = parseSecretsManifest(content);
435
+
436
+ // (c) Check existing keys
437
+ const envPath = resolve(base, ".env");
438
+ const allKeys = manifest.entries.map((e) => e.key);
439
+ const existingKeys = await checkExistingEnvKeys(allKeys, envPath);
440
+ const existingSet = new Set(existingKeys);
441
+
442
+ // (d) Build categorization
443
+ const existingSkipped: string[] = [];
444
+ const alreadySkipped: string[] = [];
445
+ const pendingEntries: SecretsManifestEntry[] = [];
446
+
447
+ for (const entry of manifest.entries) {
448
+ if (existingSet.has(entry.key)) {
449
+ existingSkipped.push(entry.key);
450
+ } else if (entry.status === "skipped") {
451
+ alreadySkipped.push(entry.key);
452
+ } else if (entry.status === "pending") {
453
+ pendingEntries.push(entry);
454
+ }
455
+ // collected entries that are not in env are left as-is
456
+ }
457
+
458
+ // (e) Show summary screen
459
+ await showSecretsSummary(ctx, manifest.entries, existingKeys);
460
+
461
+ // (f) Detect destination
462
+ const destination = detectDestination(ctx.cwd);
463
+
464
+ // (g) Collect only pending keys that are not already existing
465
+ const collected: CollectedSecret[] = [];
466
+ for (let i = 0; i < pendingEntries.length; i++) {
467
+ const entry = pendingEntries[i] as SecretsManifestEntry;
468
+ const value = await collectOneSecret(
469
+ ctx,
470
+ i,
471
+ pendingEntries.length,
472
+ entry.key,
473
+ entry.formatHint || undefined,
474
+ entry.guidance.length > 0 ? entry.guidance : undefined,
475
+ );
476
+ collected.push({ key: entry.key, value });
477
+ }
478
+
479
+ // (h) Update manifest entry statuses
480
+ for (const { key, value } of collected) {
481
+ const entry = manifest.entries.find((e) => e.key === key);
482
+ if (entry) {
483
+ entry.status = value !== null ? "collected" : "skipped";
484
+ }
485
+ }
486
+
487
+ // (i) Write manifest back to disk
488
+ await writeFile(manifestPath, formatSecretsManifest(manifest), "utf8");
489
+
490
+ // (j) Apply collected values to destination
491
+ const provided = collected.filter((c) => c.value !== null) as Array<{ key: string; value: string }>;
492
+ const { applied } = await applySecrets(provided, destination, {
493
+ envFilePath: resolve(ctx.cwd, ".env"),
494
+ });
495
+
496
+ const skipped = [
497
+ ...alreadySkipped,
498
+ ...collected.filter((c) => c.value === null).map((c) => c.key),
499
+ ];
500
+
501
+ return { applied, skipped, existingSkipped };
502
+ }
503
+
193
504
  // ─── Extension ────────────────────────────────────────────────────────────────
194
505
 
195
506
  export default function secureEnv(pi: ExtensionAPI) {
@@ -209,16 +520,17 @@ export default function secureEnv(pi: ExtensionAPI) {
209
520
  "Never echo, log, or repeat secret values in your responses. Only report key names and applied/skipped status.",
210
521
  ],
211
522
  parameters: Type.Object({
212
- destination: Type.Union([
523
+ destination: Type.Optional(Type.Union([
213
524
  Type.Literal("dotenv"),
214
525
  Type.Literal("vercel"),
215
526
  Type.Literal("convex"),
216
- ], { description: "Where to write the collected secrets" }),
527
+ ], { description: "Where to write the collected secrets" })),
217
528
  keys: Type.Array(
218
529
  Type.Object({
219
530
  key: Type.String({ description: "Env var name, e.g. OPENAI_API_KEY" }),
220
531
  hint: Type.Optional(Type.String({ description: "Format hint shown to user, e.g. 'starts with sk-'" })),
221
532
  required: Type.Optional(Type.Boolean()),
533
+ guidance: Type.Optional(Type.Array(Type.String(), { description: "Step-by-step guidance for finding this key" })),
222
534
  }),
223
535
  { minItems: 1 },
224
536
  ),
@@ -240,79 +552,39 @@ export default function secureEnv(pi: ExtensionAPI) {
240
552
  };
241
553
  }
242
554
 
555
+ // Auto-detect destination when not provided
556
+ const destinationAutoDetected = params.destination == null;
557
+ const destination = params.destination ?? detectDestination(ctx.cwd);
558
+
243
559
  const collected: CollectedSecret[] = [];
244
560
 
245
561
  // Collect one key per page
246
562
  for (let i = 0; i < params.keys.length; i++) {
247
563
  const item = params.keys[i];
248
- const value = await collectOneSecret(ctx, i, params.keys.length, item.key, item.hint);
564
+ const value = await collectOneSecret(ctx, i, params.keys.length, item.key, item.hint, item.guidance);
249
565
  collected.push({ key: item.key, value });
250
566
  }
251
567
 
252
568
  const provided = collected.filter((c) => c.value !== null) as Array<{ key: string; value: string }>;
253
569
  const skipped = collected.filter((c) => c.value === null).map((c) => c.key);
254
- const applied: string[] = [];
255
- const errors: string[] = [];
256
-
257
- // Apply to destination
258
- if (params.destination === "dotenv") {
259
- const filePath = resolve(ctx.cwd, params.envFilePath ?? ".env");
260
- for (const { key, value } of provided) {
261
- try {
262
- await writeEnvKey(filePath, key, value);
263
- applied.push(key);
264
- } catch (err: any) {
265
- errors.push(`${key}: ${err.message}`);
266
- }
267
- }
268
- }
269
570
 
270
- if (params.destination === "vercel") {
271
- const env = params.environment ?? "development";
272
- for (const { key, value } of provided) {
273
- try {
274
- const result = await pi.exec("sh", [
275
- "-c",
276
- `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`,
277
- ]);
278
- if (result.code !== 0) {
279
- errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
280
- } else {
281
- applied.push(key);
282
- }
283
- } catch (err: any) {
284
- errors.push(`${key}: ${err.message}`);
285
- }
286
- }
287
- }
288
-
289
- if (params.destination === "convex") {
290
- for (const { key, value } of provided) {
291
- try {
292
- const result = await pi.exec("sh", [
293
- "-c",
294
- `npx convex env set ${key} ${shellEscapeSingle(value)}`,
295
- ]);
296
- if (result.code !== 0) {
297
- errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
298
- } else {
299
- applied.push(key);
300
- }
301
- } catch (err: any) {
302
- errors.push(`${key}: ${err.message}`);
303
- }
304
- }
305
- }
571
+ // Apply to destination via shared helper
572
+ const { applied, errors } = await applySecrets(provided, destination, {
573
+ envFilePath: resolve(ctx.cwd, params.envFilePath ?? ".env"),
574
+ environment: params.environment,
575
+ exec: (cmd, args) => pi.exec(cmd, args),
576
+ });
306
577
 
307
578
  const details: ToolResultDetails = {
308
- destination: params.destination,
579
+ destination,
309
580
  environment: params.environment,
310
581
  applied,
311
582
  skipped,
583
+ ...(destinationAutoDetected ? { detectedDestination: destination } : {}),
312
584
  };
313
585
 
314
586
  const lines = [
315
- `destination: ${params.destination}${params.environment ? ` (${params.environment})` : ""}`,
587
+ `destination: ${destination}${destinationAutoDetected ? " (auto-detected)" : ""}${params.environment ? ` (${params.environment})` : ""}`,
316
588
  ...applied.map((k) => `✓ ${k}: applied`),
317
589
  ...skipped.map((k) => `• ${k}: skipped`),
318
590
  ...errors.map((e) => `✗ ${e}`),
@@ -329,7 +601,7 @@ export default function secureEnv(pi: ExtensionAPI) {
329
601
  const count = Array.isArray(args.keys) ? args.keys.length : 0;
330
602
  return new Text(
331
603
  theme.fg("toolTitle", theme.bold("secure_env_collect ")) +
332
- theme.fg("muted", `→ ${args.destination}`) +
604
+ theme.fg("muted", `→ ${args.destination ?? "auto"}`) +
333
605
  theme.fg("dim", ` ${count} key${count !== 1 ? "s" : ""}`),
334
606
  0, 0,
335
607
  );