gsd-pi 2.70.1-dev.bef631a → 2.70.1-dev.ec24142

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 (89) hide show
  1. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +127 -30
  2. package/dist/resources/extensions/get-secrets-from-user.js +17 -1
  3. package/dist/web/standalone/.next/BUILD_ID +1 -1
  4. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  5. package/dist/web/standalone/.next/build-manifest.json +2 -2
  6. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  7. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  8. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  9. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  11. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  12. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  16. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/index.html +1 -1
  24. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  31. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  32. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  33. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  34. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  35. package/package.json +1 -1
  36. package/packages/mcp-server/dist/env-writer.d.ts +39 -0
  37. package/packages/mcp-server/dist/env-writer.d.ts.map +1 -0
  38. package/packages/mcp-server/dist/env-writer.js +158 -0
  39. package/packages/mcp-server/dist/env-writer.js.map +1 -0
  40. package/packages/mcp-server/dist/server.d.ts +11 -2
  41. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  42. package/packages/mcp-server/dist/server.js +102 -2
  43. package/packages/mcp-server/dist/server.js.map +1 -1
  44. package/packages/mcp-server/src/env-writer.test.ts +280 -0
  45. package/packages/mcp-server/src/env-writer.ts +183 -0
  46. package/packages/mcp-server/src/secure-env-collect.test.ts +265 -0
  47. package/packages/mcp-server/src/server.ts +137 -3
  48. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.d.ts +2 -0
  49. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.d.ts.map +1 -0
  50. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +187 -0
  51. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -0
  52. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  53. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -0
  56. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  57. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +1 -0
  58. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
  59. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  60. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +78 -21
  61. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  62. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  63. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +1 -1
  65. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  66. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts +1 -0
  67. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  68. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.js.map +1 -1
  69. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +220 -0
  70. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  71. package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +2 -0
  72. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +102 -27
  73. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  74. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +1 -1
  75. package/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts +1 -0
  76. package/packages/pi-tui/dist/components/__tests__/input.test.js +9 -0
  77. package/packages/pi-tui/dist/components/__tests__/input.test.js.map +1 -1
  78. package/packages/pi-tui/dist/components/input.d.ts +2 -0
  79. package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
  80. package/packages/pi-tui/dist/components/input.js +7 -4
  81. package/packages/pi-tui/dist/components/input.js.map +1 -1
  82. package/packages/pi-tui/src/components/__tests__/input.test.ts +11 -0
  83. package/packages/pi-tui/src/components/input.ts +7 -4
  84. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +164 -31
  85. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +112 -0
  86. package/src/resources/extensions/get-secrets-from-user.ts +24 -1
  87. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +45 -0
  88. /package/dist/web/standalone/.next/static/{UlX0WGGZ8aBPN0uSZ5Ki4 → 20e8bFnNjxQJflHNodEve}/_buildManifest.js +0 -0
  89. /package/dist/web/standalone/.next/static/{UlX0WGGZ8aBPN0uSZ5Ki4 → 20e8bFnNjxQJflHNodEve}/_ssgManifest.js +0 -0
@@ -0,0 +1,183 @@
1
+ // @gsd-build/mcp-server — Environment variable write utilities
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+ //
4
+ // Shared helpers for writing env vars to .env files, detecting project
5
+ // destinations, and checking existing keys. Used by secure_env_collect
6
+ // MCP tool. No TUI dependencies — pure filesystem + process.env operations.
7
+
8
+ import { readFile, writeFile } from "node:fs/promises";
9
+ import { existsSync, statSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // checkExistingEnvKeys
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Check which keys already exist in a .env file or process.env.
18
+ * Returns the subset of `keys` that are already set.
19
+ */
20
+ export async function checkExistingEnvKeys(keys: string[], envFilePath: string): Promise<string[]> {
21
+ let fileContent = "";
22
+ try {
23
+ fileContent = await readFile(envFilePath, "utf8");
24
+ } catch {
25
+ // ENOENT or other read error — proceed with empty content
26
+ }
27
+
28
+ const existing: string[] = [];
29
+ for (const key of keys) {
30
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ const regex = new RegExp(`^${escaped}\\s*=`, "m");
32
+ if (regex.test(fileContent) || key in process.env) {
33
+ existing.push(key);
34
+ }
35
+ }
36
+ return existing;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // detectDestination
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Detect the write destination based on project files in basePath.
45
+ * Priority: vercel.json → convex/ dir → fallback "dotenv".
46
+ */
47
+ export function detectDestination(basePath: string): "dotenv" | "vercel" | "convex" {
48
+ if (existsSync(resolve(basePath, "vercel.json"))) {
49
+ return "vercel";
50
+ }
51
+ const convexPath = resolve(basePath, "convex");
52
+ try {
53
+ if (existsSync(convexPath) && statSync(convexPath).isDirectory()) {
54
+ return "convex";
55
+ }
56
+ } catch {
57
+ // stat error — treat as not found
58
+ }
59
+ return "dotenv";
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // writeEnvKey
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Write a single key=value pair to a .env file.
68
+ * Updates existing keys in-place, appends new ones at the end.
69
+ */
70
+ export async function writeEnvKey(filePath: string, key: string, value: string): Promise<void> {
71
+ if (typeof value !== "string") {
72
+ throw new TypeError(`writeEnvKey expects a string value for key "${key}", got ${typeof value}`);
73
+ }
74
+ let content = "";
75
+ try {
76
+ content = await readFile(filePath, "utf8");
77
+ } catch {
78
+ content = "";
79
+ }
80
+ const escaped = value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "");
81
+ const line = `${key}=${escaped}`;
82
+ const regex = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=.*$`, "m");
83
+ if (regex.test(content)) {
84
+ content = content.replace(regex, line);
85
+ } else {
86
+ if (content.length > 0 && !content.endsWith("\n")) content += "\n";
87
+ content += `${line}\n`;
88
+ }
89
+ await writeFile(filePath, content, "utf8");
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Validation helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export function isSafeEnvVarKey(key: string): boolean {
97
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
98
+ }
99
+
100
+ export function isSupportedDeploymentEnvironment(env: string): boolean {
101
+ return env === "development" || env === "preview" || env === "production";
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Shell helpers (for vercel/convex CLI)
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export function shellEscapeSingle(value: string): string {
109
+ return `'${value.replace(/'/g, `'\\''`)}'`;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // applySecrets
114
+ // ---------------------------------------------------------------------------
115
+
116
+ interface ApplyResult {
117
+ applied: string[];
118
+ errors: string[];
119
+ }
120
+
121
+ /**
122
+ * Apply collected secrets to the target destination.
123
+ * Dotenv writes are handled directly; vercel/convex shell out via execFn.
124
+ */
125
+ export async function applySecrets(
126
+ provided: Array<{ key: string; value: string }>,
127
+ destination: "dotenv" | "vercel" | "convex",
128
+ opts: {
129
+ envFilePath: string;
130
+ environment?: string;
131
+ execFn?: (cmd: string, args: string[]) => Promise<{ code: number; stderr: string }>;
132
+ },
133
+ ): Promise<ApplyResult> {
134
+ const applied: string[] = [];
135
+ const errors: string[] = [];
136
+
137
+ if (destination === "dotenv") {
138
+ for (const { key, value } of provided) {
139
+ try {
140
+ await writeEnvKey(opts.envFilePath, key, value);
141
+ applied.push(key);
142
+ // Hydrate process.env so the current session sees the new value
143
+ process.env[key] = value;
144
+ } catch (err: unknown) {
145
+ const msg = err instanceof Error ? err.message : String(err);
146
+ errors.push(`${key}: ${msg}`);
147
+ }
148
+ }
149
+ }
150
+
151
+ if ((destination === "vercel" || destination === "convex") && opts.execFn) {
152
+ const env = opts.environment ?? "development";
153
+ if (!isSupportedDeploymentEnvironment(env)) {
154
+ errors.push(`environment: unsupported target environment "${env}"`);
155
+ return { applied, errors };
156
+ }
157
+ for (const { key, value } of provided) {
158
+ if (!isSafeEnvVarKey(key)) {
159
+ errors.push(`${key}: invalid environment variable name`);
160
+ continue;
161
+ }
162
+ const cmd = destination === "vercel"
163
+ ? `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}`
164
+ : "";
165
+ try {
166
+ const result = destination === "vercel"
167
+ ? await opts.execFn("sh", ["-c", cmd])
168
+ : await opts.execFn("npx", ["convex", "env", "set", key, value]);
169
+ if (result.code !== 0) {
170
+ errors.push(`${key}: ${result.stderr.slice(0, 200)}`);
171
+ } else {
172
+ applied.push(key);
173
+ process.env[key] = value;
174
+ }
175
+ } catch (err: unknown) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ errors.push(`${key}: ${msg}`);
178
+ }
179
+ }
180
+ }
181
+
182
+ return { applied, errors };
183
+ }
@@ -0,0 +1,265 @@
1
+ // @gsd-build/mcp-server — Tests for secure_env_collect MCP tool
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+ //
4
+ // Tests the secure_env_collect tool registered in createMcpServer.
5
+ // Uses a mock MCP server to intercept tool registration and elicitInput calls.
6
+
7
+ import { describe, it, beforeEach } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { createMcpServer } from './server.js';
14
+ import { SessionManager } from './session-manager.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Mock infrastructure
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * We intercept McpServer construction by monkey-patching the dynamic import.
22
+ * Instead, we'll test the tool handler indirectly through the exported
23
+ * createMcpServer function — capturing the registered tool handlers.
24
+ *
25
+ * Since createMcpServer dynamically imports McpServer, we need to test at
26
+ * a level that exercises the tool handler logic. We do this by extracting
27
+ * the tool handler through the server.tool() calls.
28
+ */
29
+
30
+ interface RegisteredTool {
31
+ name: string;
32
+ description: string;
33
+ params: Record<string, unknown>;
34
+ handler: (args: Record<string, unknown>) => Promise<unknown>;
35
+ }
36
+
37
+ interface ToolResult {
38
+ content?: Array<{ type: string; text: string }>;
39
+ isError?: boolean;
40
+ }
41
+
42
+ /**
43
+ * Mock McpServer that captures tool registrations and provides
44
+ * a controllable elicitInput response.
45
+ */
46
+ class MockMcpServer {
47
+ registeredTools: RegisteredTool[] = [];
48
+ elicitResponse: { action: string; content?: Record<string, unknown> } = { action: 'accept', content: {} };
49
+
50
+ server = {
51
+ elicitInput: async (_params: unknown) => {
52
+ return this.elicitResponse;
53
+ },
54
+ };
55
+
56
+ tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>) {
57
+ this.registeredTools.push({ name, description, params, handler });
58
+ }
59
+
60
+ async connect(_transport: unknown) { /* no-op */ }
61
+ async close() { /* no-op */ }
62
+
63
+ getToolHandler(name: string): ((args: Record<string, unknown>) => Promise<unknown>) | undefined {
64
+ return this.registeredTools.find((t) => t.name === name)?.handler;
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Helper to create a mock MCP server with secure_env_collect registered
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Since createMcpServer uses dynamic import for McpServer, we can't easily
74
+ * mock it. Instead, we test the env-writer utilities directly (in env-writer.test.ts)
75
+ * and test the tool integration by verifying:
76
+ * 1. The tool exists in the registered tools list
77
+ * 2. The handler produces correct results with mock data
78
+ *
79
+ * For handler-level testing, we create a standalone test that replicates
80
+ * the tool handler logic with a controllable mock.
81
+ */
82
+
83
+ function makeTempDir(prefix: string): string {
84
+ return mkdtempSync(join(tmpdir(), `${prefix}-`));
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Integration test — verify tool is registered
89
+ // ---------------------------------------------------------------------------
90
+
91
+ describe('secure_env_collect tool registration', () => {
92
+ it('createMcpServer registers secure_env_collect tool', async () => {
93
+ // This test verifies the tool exists — createMcpServer internally calls
94
+ // server.tool('secure_env_collect', ...) which we can't intercept without
95
+ // module mocking, but we can verify the server creates successfully
96
+ const sm = new SessionManager();
97
+ try {
98
+ const { server } = await createMcpServer(sm);
99
+ assert.ok(server, 'server should be created');
100
+ // The McpServer internally tracks registered tools — we verify no error
101
+ } finally {
102
+ await sm.cleanup();
103
+ }
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Handler logic tests — using env-writer directly to test the flow
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe('secure_env_collect handler logic', () => {
112
+ it('skips keys that already exist in .env', async () => {
113
+ const tmp = makeTempDir('sec-collect');
114
+ try {
115
+ const envPath = join(tmp, '.env');
116
+ writeFileSync(envPath, 'ALREADY_SET=existing-value\n');
117
+
118
+ // Import the utility directly to test the pre-check logic
119
+ const { checkExistingEnvKeys } = await import('./env-writer.js');
120
+ const existing = await checkExistingEnvKeys(['ALREADY_SET', 'NEW_KEY'], envPath);
121
+ assert.deepStrictEqual(existing, ['ALREADY_SET']);
122
+ } finally {
123
+ rmSync(tmp, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ it('writes collected values to .env without returning secret values', async () => {
128
+ const tmp = makeTempDir('sec-collect');
129
+ try {
130
+ const envPath = join(tmp, '.env');
131
+ const savedKey = process.env.SEC_COLLECT_TEST_KEY;
132
+
133
+ const { applySecrets } = await import('./env-writer.js');
134
+ const { applied, errors } = await applySecrets(
135
+ [{ key: 'SEC_COLLECT_TEST_KEY', value: 'super-secret-value' }],
136
+ 'dotenv',
137
+ { envFilePath: envPath },
138
+ );
139
+
140
+ assert.deepStrictEqual(applied, ['SEC_COLLECT_TEST_KEY']);
141
+ assert.deepStrictEqual(errors, []);
142
+
143
+ // Verify the value was written
144
+ const content = readFileSync(envPath, 'utf8');
145
+ assert.ok(content.includes('SEC_COLLECT_TEST_KEY=super-secret-value'));
146
+
147
+ // Verify process.env was hydrated
148
+ assert.equal(process.env.SEC_COLLECT_TEST_KEY, 'super-secret-value');
149
+
150
+ // Cleanup
151
+ if (savedKey === undefined) delete process.env.SEC_COLLECT_TEST_KEY;
152
+ else process.env.SEC_COLLECT_TEST_KEY = savedKey;
153
+ } finally {
154
+ rmSync(tmp, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ it('auto-detects vercel destination from vercel.json', async () => {
159
+ const tmp = makeTempDir('sec-collect');
160
+ try {
161
+ writeFileSync(join(tmp, 'vercel.json'), '{}');
162
+ const { detectDestination } = await import('./env-writer.js');
163
+ assert.equal(detectDestination(tmp), 'vercel');
164
+ } finally {
165
+ rmSync(tmp, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ it('handles empty form values as skipped', async () => {
170
+ // Simulate what happens when user leaves a field empty in the form
171
+ const formContent: Record<string, string> = {
172
+ 'API_KEY': 'provided-value',
173
+ 'OPTIONAL_KEY': '', // empty = skip
174
+ };
175
+
176
+ const provided: Array<{ key: string; value: string }> = [];
177
+ const skipped: string[] = [];
178
+
179
+ for (const [key, raw] of Object.entries(formContent)) {
180
+ const value = typeof raw === 'string' ? raw.trim() : '';
181
+ if (value.length > 0) {
182
+ provided.push({ key, value });
183
+ } else {
184
+ skipped.push(key);
185
+ }
186
+ }
187
+
188
+ assert.deepStrictEqual(provided, [{ key: 'API_KEY', value: 'provided-value' }]);
189
+ assert.deepStrictEqual(skipped, ['OPTIONAL_KEY']);
190
+ });
191
+
192
+ it('result text never contains secret values', async () => {
193
+ const tmp = makeTempDir('sec-collect');
194
+ try {
195
+ const envPath = join(tmp, '.env');
196
+ const savedKey = process.env.RESULT_TEXT_TEST;
197
+
198
+ const { applySecrets } = await import('./env-writer.js');
199
+ const { applied } = await applySecrets(
200
+ [{ key: 'RESULT_TEXT_TEST', value: 'sk-super-secret-abc123' }],
201
+ 'dotenv',
202
+ { envFilePath: envPath },
203
+ );
204
+
205
+ // Simulate building result text (same logic as the tool handler)
206
+ const lines: string[] = [
207
+ 'destination: dotenv (auto-detected)',
208
+ ...applied.map((k) => `✓ ${k}: applied`),
209
+ ];
210
+ const resultText = lines.join('\n');
211
+
212
+ // The result MUST NOT contain the secret value
213
+ assert.ok(!resultText.includes('sk-super-secret-abc123'), 'result text must not contain secret value');
214
+ assert.ok(resultText.includes('RESULT_TEXT_TEST'), 'result text should contain key name');
215
+
216
+ // Cleanup
217
+ if (savedKey === undefined) delete process.env.RESULT_TEXT_TEST;
218
+ else process.env.RESULT_TEXT_TEST = savedKey;
219
+ } finally {
220
+ rmSync(tmp, { recursive: true, force: true });
221
+ }
222
+ });
223
+
224
+ it('handles multiple keys with mixed existing/new/skipped', async () => {
225
+ const tmp = makeTempDir('sec-collect');
226
+ try {
227
+ const envPath = join(tmp, '.env');
228
+ writeFileSync(envPath, 'EXISTING_A=already-here\n');
229
+ const savedB = process.env.NEW_B;
230
+ const savedC = process.env.SKIP_C;
231
+
232
+ const { checkExistingEnvKeys, applySecrets } = await import('./env-writer.js');
233
+
234
+ const allKeys = ['EXISTING_A', 'NEW_B', 'SKIP_C'];
235
+ const existing = await checkExistingEnvKeys(allKeys, envPath);
236
+ assert.deepStrictEqual(existing, ['EXISTING_A']);
237
+
238
+ // Simulate form response: NEW_B has value, SKIP_C is empty
239
+ const formContent = { NEW_B: 'new-value', SKIP_C: '' };
240
+ const provided: Array<{ key: string; value: string }> = [];
241
+ const skipped: string[] = [];
242
+
243
+ for (const key of allKeys.filter((k) => !existing.includes(k))) {
244
+ const raw = formContent[key as keyof typeof formContent] ?? '';
245
+ if (raw.trim().length > 0) provided.push({ key, value: raw.trim() });
246
+ else skipped.push(key);
247
+ }
248
+
249
+ const { applied, errors } = await applySecrets(provided, 'dotenv', { envFilePath: envPath });
250
+
251
+ assert.deepStrictEqual(applied, ['NEW_B']);
252
+ assert.deepStrictEqual(skipped, ['SKIP_C']);
253
+ assert.deepStrictEqual(errors, []);
254
+ assert.deepStrictEqual(existing, ['EXISTING_A']);
255
+
256
+ // Cleanup
257
+ if (savedB === undefined) delete process.env.NEW_B;
258
+ else process.env.NEW_B = savedB;
259
+ if (savedC === undefined) delete process.env.SKIP_C;
260
+ else process.env.SKIP_C = savedC;
261
+ } finally {
262
+ rmSync(tmp, { recursive: true, force: true });
263
+ }
264
+ });
265
+ });
@@ -2,7 +2,7 @@
2
2
  * MCP Server — registers GSD orchestration, project-state, and workflow tools.
3
3
  *
4
4
  * Session tools (6): gsd_execute, gsd_status, gsd_result, gsd_cancel, gsd_query, gsd_resolve_blocker
5
- * Interactive tools (1): ask_user_questions via MCP form elicitation
5
+ * Interactive tools (2): ask_user_questions, secure_env_collect via MCP form elicitation
6
6
  * Read-only tools (6): gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures, gsd_knowledge
7
7
  * Workflow tools (29): headless-safe planning, metadata persistence, replanning, completion, validation, reassessment, gate result, status, and journal tools
8
8
  *
@@ -22,6 +22,7 @@ import { readCaptures } from './readers/captures.js';
22
22
  import { readKnowledge } from './readers/knowledge.js';
23
23
  import { runDoctorLite } from './readers/doctor-lite.js';
24
24
  import { registerWorkflowTools } from './workflow-tools.js';
25
+ import { applySecrets, checkExistingEnvKeys, detectDestination } from './env-writer.js';
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Constants
@@ -112,11 +113,26 @@ async function fileExists(path: string): Promise<boolean> {
112
113
  // MCP Server type — minimal interface for the dynamically-imported McpServer
113
114
  // ---------------------------------------------------------------------------
114
115
 
116
+ interface ElicitResult {
117
+ action: 'accept' | 'decline' | 'cancel';
118
+ content?: Record<string, string | number | boolean | string[]>;
119
+ }
120
+
121
+ interface ElicitRequestFormParams {
122
+ mode?: 'form';
123
+ message: string;
124
+ requestedSchema: {
125
+ type: 'object';
126
+ properties: Record<string, Record<string, unknown>>;
127
+ required?: string[];
128
+ };
129
+ }
130
+
115
131
  interface McpServerInstance {
116
132
  tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>): unknown;
117
133
  server: {
118
134
  elicitInput(
119
- params: AskUserQuestionsElicitRequest,
135
+ params: AskUserQuestionsElicitRequest | ElicitRequestFormParams,
120
136
  options?: unknown,
121
137
  ): Promise<AskUserQuestionsElicitResult>;
122
138
  };
@@ -282,7 +298,7 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
282
298
 
283
299
  const server: McpServerInstance = new McpServer(
284
300
  { name: SERVER_NAME, version: SERVER_VERSION },
285
- { capabilities: { tools: {} } },
301
+ { capabilities: { tools: {}, elicitation: {} } },
286
302
  );
287
303
 
288
304
  // -----------------------------------------------------------------------
@@ -472,6 +488,124 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
472
488
  },
473
489
  );
474
490
 
491
+ // -----------------------------------------------------------------------
492
+ // secure_env_collect — collect secrets via MCP form elicitation
493
+ // -----------------------------------------------------------------------
494
+ server.tool(
495
+ 'secure_env_collect',
496
+ 'Collect environment variables securely via form input. Values are written directly to .env (or Vercel/Convex) and NEVER appear in tool output — only key names and applied/skipped status are returned. Use this instead of asking users to manually edit .env files or paste secrets into chat.',
497
+ {
498
+ projectDir: z.string().describe('Absolute path to the project directory'),
499
+ keys: z.array(z.object({
500
+ key: z.string().describe('Env var name, e.g. OPENAI_API_KEY'),
501
+ hint: z.string().optional().describe('Format hint shown to user, e.g. "starts with sk-"'),
502
+ guidance: z.array(z.string()).optional().describe('Step-by-step instructions for obtaining this key'),
503
+ })).min(1).describe('Environment variables to collect'),
504
+ destination: z.enum(['dotenv', 'vercel', 'convex']).optional().describe('Where to write secrets. Auto-detected from project files if omitted.'),
505
+ envFilePath: z.string().optional().describe('Path to .env file (dotenv only). Defaults to .env in projectDir.'),
506
+ environment: z.enum(['development', 'preview', 'production']).optional().describe('Target environment (vercel/convex only)'),
507
+ },
508
+ async (args: Record<string, unknown>) => {
509
+ const { projectDir, keys, destination, envFilePath, environment } = args as {
510
+ projectDir: string;
511
+ keys: Array<{ key: string; hint?: string; guidance?: string[] }>;
512
+ destination?: 'dotenv' | 'vercel' | 'convex';
513
+ envFilePath?: string;
514
+ environment?: 'development' | 'preview' | 'production';
515
+ };
516
+
517
+ try {
518
+ const resolvedProjectDir = resolve(projectDir);
519
+ const resolvedEnvPath = resolve(resolvedProjectDir, envFilePath ?? '.env');
520
+
521
+ // (1) Check which keys already exist
522
+ const allKeyNames = keys.map((k) => k.key);
523
+ const existingKeys = await checkExistingEnvKeys(allKeyNames, resolvedEnvPath);
524
+ const existingSet = new Set(existingKeys);
525
+ const pendingKeys = keys.filter((k) => !existingSet.has(k.key));
526
+
527
+ // If all keys already exist, return immediately
528
+ if (pendingKeys.length === 0) {
529
+ const lines = existingKeys.map((k) => `• ${k}: already set`);
530
+ return textContent(`All ${existingKeys.length} key(s) already set.\n${lines.join('\n')}`);
531
+ }
532
+
533
+ // (2) Build elicitation form — one string field per pending key
534
+ const properties: Record<string, Record<string, unknown>> = {};
535
+ const required: string[] = [];
536
+
537
+ for (const item of pendingKeys) {
538
+ const descParts: string[] = [];
539
+ if (item.hint) descParts.push(`Format: ${item.hint}`);
540
+ if (item.guidance && item.guidance.length > 0) {
541
+ descParts.push('How to get this:');
542
+ item.guidance.forEach((step, i) => descParts.push(`${i + 1}. ${step}`));
543
+ }
544
+ descParts.push('Leave empty to skip.');
545
+
546
+ properties[item.key] = {
547
+ type: 'string',
548
+ title: item.key,
549
+ description: descParts.join('\n'),
550
+ };
551
+ // Don't mark as required — empty string = skip
552
+ }
553
+
554
+ // (3) Elicit input from the MCP client
555
+ const elicitation = await server.server.elicitInput({
556
+ message: `Enter values for ${pendingKeys.length} environment variable(s). Values are written directly to the project and never shown to the AI.`,
557
+ requestedSchema: {
558
+ type: 'object',
559
+ properties,
560
+ required,
561
+ },
562
+ });
563
+
564
+ if (elicitation.action !== 'accept' || !elicitation.content) {
565
+ return textContent('secure_env_collect was cancelled by user.');
566
+ }
567
+
568
+ // (4) Separate provided vs skipped from form response
569
+ const provided: Array<{ key: string; value: string }> = [];
570
+ const skipped: string[] = [];
571
+
572
+ for (const item of pendingKeys) {
573
+ const raw = elicitation.content[item.key];
574
+ const value = typeof raw === 'string' ? raw.trim() : '';
575
+ if (value.length > 0) {
576
+ provided.push({ key: item.key, value });
577
+ } else {
578
+ skipped.push(item.key);
579
+ }
580
+ }
581
+
582
+ // (5) Auto-detect destination if not specified
583
+ const resolvedDestination = destination ?? detectDestination(resolvedProjectDir);
584
+
585
+ // (6) Write secrets to destination
586
+ const { applied, errors } = await applySecrets(provided, resolvedDestination, {
587
+ envFilePath: resolvedEnvPath,
588
+ environment,
589
+ });
590
+
591
+ // (7) Build result — NEVER include secret values
592
+ const lines: string[] = [
593
+ `destination: ${resolvedDestination}${!destination ? ' (auto-detected)' : ''}${environment ? ` (${environment})` : ''}`,
594
+ ];
595
+ for (const k of applied) lines.push(`✓ ${k}: applied`);
596
+ for (const k of skipped) lines.push(`• ${k}: skipped`);
597
+ for (const k of existingKeys) lines.push(`• ${k}: already set`);
598
+ for (const e of errors) lines.push(`✗ ${e}`);
599
+
600
+ return errors.length > 0 && applied.length === 0
601
+ ? errorContent(lines.join('\n'))
602
+ : textContent(lines.join('\n'));
603
+ } catch (err) {
604
+ return errorContent(err instanceof Error ? err.message : String(err));
605
+ }
606
+ },
607
+ );
608
+
475
609
  // =======================================================================
476
610
  // READ-ONLY TOOLS — no session required, pure filesystem reads
477
611
  // =======================================================================
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=chat-controller-ordering.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-controller-ordering.test.d.ts","sourceRoot":"","sources":["../../src/core/chat-controller-ordering.test.ts"],"names":[],"mappings":""}