wiggum-cli 0.11.13 → 0.11.15

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 (67) hide show
  1. package/.claude/settings.local.json +10 -1
  2. package/dist/commands/config.d.ts.map +1 -1
  3. package/dist/commands/config.js +9 -17
  4. package/dist/commands/config.js.map +1 -1
  5. package/dist/context/convert.d.ts +29 -0
  6. package/dist/context/convert.d.ts.map +1 -0
  7. package/dist/context/convert.js +123 -0
  8. package/dist/context/convert.js.map +1 -0
  9. package/dist/context/index.d.ts +4 -0
  10. package/dist/context/index.d.ts.map +1 -0
  11. package/dist/context/index.js +3 -0
  12. package/dist/context/index.js.map +1 -0
  13. package/dist/context/storage.d.ts +28 -0
  14. package/dist/context/storage.d.ts.map +1 -0
  15. package/dist/context/storage.js +100 -0
  16. package/dist/context/storage.js.map +1 -0
  17. package/dist/context/types.d.ts +50 -0
  18. package/dist/context/types.d.ts.map +1 -0
  19. package/dist/context/types.js +6 -0
  20. package/dist/context/types.js.map +1 -0
  21. package/dist/repl/command-parser.d.ts +5 -0
  22. package/dist/repl/command-parser.d.ts.map +1 -1
  23. package/dist/repl/command-parser.js +5 -0
  24. package/dist/repl/command-parser.js.map +1 -1
  25. package/dist/templates/root/.gitignore.tmpl +2 -0
  26. package/dist/tui/hooks/index.d.ts +2 -0
  27. package/dist/tui/hooks/index.d.ts.map +1 -1
  28. package/dist/tui/hooks/index.js +1 -0
  29. package/dist/tui/hooks/index.js.map +1 -1
  30. package/dist/tui/hooks/useInit.js +1 -1
  31. package/dist/tui/hooks/useInit.js.map +1 -1
  32. package/dist/tui/hooks/useSync.d.ts +15 -0
  33. package/dist/tui/hooks/useSync.d.ts.map +1 -0
  34. package/dist/tui/hooks/useSync.js +52 -0
  35. package/dist/tui/hooks/useSync.js.map +1 -0
  36. package/dist/tui/screens/InitScreen.d.ts.map +1 -1
  37. package/dist/tui/screens/InitScreen.js +22 -26
  38. package/dist/tui/screens/InitScreen.js.map +1 -1
  39. package/dist/tui/screens/InterviewScreen.d.ts.map +1 -1
  40. package/dist/tui/screens/InterviewScreen.js +110 -84
  41. package/dist/tui/screens/InterviewScreen.js.map +1 -1
  42. package/dist/tui/screens/MainShell.d.ts.map +1 -1
  43. package/dist/tui/screens/MainShell.js +37 -2
  44. package/dist/tui/screens/MainShell.js.map +1 -1
  45. package/dist/utils/env.d.ts +16 -1
  46. package/dist/utils/env.d.ts.map +1 -1
  47. package/dist/utils/env.js +55 -4
  48. package/dist/utils/env.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/commands/config.test.ts +225 -0
  51. package/src/commands/config.ts +9 -18
  52. package/src/context/convert.test.ts +129 -0
  53. package/src/context/convert.ts +159 -0
  54. package/src/context/index.ts +19 -0
  55. package/src/context/storage.test.ts +173 -0
  56. package/src/context/storage.ts +117 -0
  57. package/src/context/types.ts +52 -0
  58. package/src/repl/command-parser.ts +5 -0
  59. package/src/templates/root/.gitignore.tmpl +2 -0
  60. package/src/tui/hooks/index.ts +3 -0
  61. package/src/tui/hooks/useInit.ts +1 -1
  62. package/src/tui/hooks/useSync.ts +80 -0
  63. package/src/tui/screens/InitScreen.tsx +27 -27
  64. package/src/tui/screens/InterviewScreen.tsx +116 -75
  65. package/src/tui/screens/MainShell.tsx +39 -2
  66. package/src/utils/env.test.ts +220 -20
  67. package/src/utils/env.ts +60 -4
@@ -11,12 +11,27 @@
11
11
  * - Strips matching surrounding quotes (single or double) from values.
12
12
  */
13
13
  export declare function parseEnvContent(content: string): Record<string, string>;
14
+ /**
15
+ * Write API keys to an env file, preserving existing content.
16
+ *
17
+ * - Merges keys into existing file content (preserves other keys).
18
+ * - Replaces existing key values if the key already exists.
19
+ * - Creates parent directories if they don't exist.
20
+ * - Creates the file if it doesn't exist.
21
+ * - Skips empty values (keys with empty strings are ignored).
22
+ *
23
+ * @param filePath - Absolute path to the .env.local file
24
+ * @param keys - Record of environment variable names to values
25
+ */
26
+ export declare function writeKeysToEnvFile(filePath: string, keys: Record<string, string>): void;
14
27
  /**
15
28
  * Load known AI provider API keys from .ralph/.env.local into process.env.
16
29
  *
30
+ * - Prefers .ralph/.env.local as the canonical source (if it exists).
31
+ * - Falls back to root .env.local if .ralph/.env.local does not exist (backward compatibility).
17
32
  * - Only keys in KNOWN_API_KEYS are loaded; all others are ignored.
18
33
  * - File values override existing process.env values (file takes precedence).
19
- * - If the file does not exist or cannot be read, this is a silent no-op.
34
+ * - If neither file exists or cannot be read, this is a silent no-op.
20
35
  * - Malformed lines are skipped without aborting.
21
36
  */
22
37
  export declare function loadApiKeysFromEnvLocal(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../src/utils/env.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA2BvE;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAgB9C"}
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../src/utils/env.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA2BvE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CA8BvF;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CA0B9C"}
package/dist/utils/env.js CHANGED
@@ -39,18 +39,69 @@ export function parseEnvContent(content) {
39
39
  }
40
40
  return result;
41
41
  }
42
+ /**
43
+ * Write API keys to an env file, preserving existing content.
44
+ *
45
+ * - Merges keys into existing file content (preserves other keys).
46
+ * - Replaces existing key values if the key already exists.
47
+ * - Creates parent directories if they don't exist.
48
+ * - Creates the file if it doesn't exist.
49
+ * - Skips empty values (keys with empty strings are ignored).
50
+ *
51
+ * @param filePath - Absolute path to the .env.local file
52
+ * @param keys - Record of environment variable names to values
53
+ */
54
+ export function writeKeysToEnvFile(filePath, keys) {
55
+ // Ensure parent directory exists
56
+ const dir = path.dirname(filePath);
57
+ if (!fs.existsSync(dir)) {
58
+ fs.mkdirSync(dir, { recursive: true });
59
+ }
60
+ // Read existing content if file exists
61
+ let envContent = '';
62
+ if (fs.existsSync(filePath)) {
63
+ envContent = fs.readFileSync(filePath, 'utf-8');
64
+ }
65
+ // Merge keys into content
66
+ const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ for (const [envVar, value] of Object.entries(keys)) {
68
+ if (!value)
69
+ continue; // Skip empty values
70
+ const keyRegex = new RegExp(`^${escapeRegex(envVar)}=.*$`, 'm');
71
+ if (keyRegex.test(envContent)) {
72
+ // Replace existing key
73
+ envContent = envContent.replace(keyRegex, `${envVar}=${value}`);
74
+ }
75
+ else {
76
+ // Append new key
77
+ envContent = envContent.trimEnd() + (envContent ? '\n' : '') + `${envVar}=${value}\n`;
78
+ }
79
+ }
80
+ fs.writeFileSync(filePath, envContent);
81
+ }
42
82
  /**
43
83
  * Load known AI provider API keys from .ralph/.env.local into process.env.
44
84
  *
85
+ * - Prefers .ralph/.env.local as the canonical source (if it exists).
86
+ * - Falls back to root .env.local if .ralph/.env.local does not exist (backward compatibility).
45
87
  * - Only keys in KNOWN_API_KEYS are loaded; all others are ignored.
46
88
  * - File values override existing process.env values (file takes precedence).
47
- * - If the file does not exist or cannot be read, this is a silent no-op.
89
+ * - If neither file exists or cannot be read, this is a silent no-op.
48
90
  * - Malformed lines are skipped without aborting.
49
91
  */
50
92
  export function loadApiKeysFromEnvLocal() {
51
93
  try {
52
- const envPath = path.join(process.cwd(), '.ralph', '.env.local');
53
- if (!fs.existsSync(envPath))
94
+ const ralphEnvPath = path.join(process.cwd(), '.ralph', '.env.local');
95
+ const rootEnvPath = path.join(process.cwd(), '.env.local');
96
+ // Prefer .ralph/.env.local, fall back to root .env.local
97
+ let envPath = null;
98
+ if (fs.existsSync(ralphEnvPath)) {
99
+ envPath = ralphEnvPath;
100
+ }
101
+ else if (fs.existsSync(rootEnvPath)) {
102
+ envPath = rootEnvPath;
103
+ }
104
+ if (!envPath)
54
105
  return;
55
106
  const content = fs.readFileSync(envPath, 'utf8');
56
107
  const parsed = parseEnvContent(content);
@@ -61,7 +112,7 @@ export function loadApiKeysFromEnvLocal() {
61
112
  }
62
113
  }
63
114
  catch (err) {
64
- logger.debug(`Failed to load .ralph/.env.local: ${err instanceof Error ? err.message : String(err)}`);
115
+ logger.debug(`Failed to load env file: ${err instanceof Error ? err.message : String(err)}`);
65
116
  }
66
117
  }
67
118
  //# sourceMappingURL=env.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"env.js","sourceRoot":"","sources":["../../src/utils/env.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,OAAe;IAC7C,MAAM,MAAM,GAA2B,EAAE,CAAC;IAE1C,KAAK,MAAM,OAAO,IAAI,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QAC7C,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QAE5C,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,GAAG,KAAK,CAAC,CAAC;YAAE,SAAS;QAEzB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;QACtC,IAAI,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAEvC,oCAAoC;QACpC,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;YACtB,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACvB,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;YACrC,IAAI,CAAC,KAAK,KAAK,GAAG,IAAI,IAAI,KAAK,GAAG,CAAC,IAAI,CAAC,KAAK,KAAK,GAAG,IAAI,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;gBACvE,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;YAC7B,CAAC;QACH,CAAC;QAED,IAAI,CAAC,GAAG;YAAE,SAAS;QACnB,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACtB,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB;IACrC,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC;QACjE,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC;YAAE,OAAO;QAEpC,MAAM,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QACjD,MAAM,MAAM,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;QAExC,KAAK,MAAM,GAAG,IAAI,cAAc,EAAE,CAAC;YACjC,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,SAAS,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC;gBACpD,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,CAAC,KAAK,CAAC,qCAAqC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACxG,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"env.js","sourceRoot":"","sources":["../../src/utils/env.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,OAAe;IAC7C,MAAM,MAAM,GAA2B,EAAE,CAAC;IAE1C,KAAK,MAAM,OAAO,IAAI,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QAC7C,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QAE5C,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,GAAG,KAAK,CAAC,CAAC;YAAE,SAAS;QAEzB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;QACtC,IAAI,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAEvC,oCAAoC;QACpC,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;YACtB,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACvB,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;YACrC,IAAI,CAAC,KAAK,KAAK,GAAG,IAAI,IAAI,KAAK,GAAG,CAAC,IAAI,CAAC,KAAK,KAAK,GAAG,IAAI,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;gBACvE,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;YAC7B,CAAC;QACH,CAAC;QAED,IAAI,CAAC,GAAG;YAAE,SAAS;QACnB,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACtB,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,kBAAkB,CAAC,QAAgB,EAAE,IAA4B;IAC/E,iCAAiC;IACjC,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACxB,EAAE,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACzC,CAAC;IAED,uCAAuC;IACvC,IAAI,UAAU,GAAG,EAAE,CAAC;IACpB,IAAI,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC5B,UAAU,GAAG,EAAE,CAAC,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAClD,CAAC;IAED,0BAA0B;IAC1B,MAAM,WAAW,GAAG,CAAC,KAAa,EAAU,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;IAE5F,KAAK,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACnD,IAAI,CAAC,KAAK;YAAE,SAAS,CAAC,oBAAoB;QAE1C,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAC,IAAI,WAAW,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChE,IAAI,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC;YAC9B,uBAAuB;YACvB,UAAU,GAAG,UAAU,CAAC,OAAO,CAAC,QAAQ,EAAE,GAAG,MAAM,IAAI,KAAK,EAAE,CAAC,CAAC;QAClE,CAAC;aAAM,CAAC;YACN,iBAAiB;YACjB,UAAU,GAAG,UAAU,CAAC,OAAO,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,GAAG,MAAM,IAAI,KAAK,IAAI,CAAC;QACxF,CAAC;IACH,CAAC;IAED,EAAE,CAAC,aAAa,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,uBAAuB;IACrC,IAAI,CAAC;QACH,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC;QACtE,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,YAAY,CAAC,CAAC;QAE3D,yDAAyD;QACzD,IAAI,OAAO,GAAkB,IAAI,CAAC;QAClC,IAAI,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;YAChC,OAAO,GAAG,YAAY,CAAC;QACzB,CAAC;aAAM,IAAI,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;YACtC,OAAO,GAAG,WAAW,CAAC;QACxB,CAAC;QAED,IAAI,CAAC,OAAO;YAAE,OAAO;QAErB,MAAM,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QACjD,MAAM,MAAM,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;QAExC,KAAK,MAAM,GAAG,IAAI,cAAc,EAAE,CAAC;YACjC,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,SAAS,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC;gBACpD,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,CAAC,KAAK,CAAC,4BAA4B,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAC/F,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiggum-cli",
3
- "version": "0.11.13",
3
+ "version": "0.11.15",
4
4
  "description": "AI-powered feature development loop CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,225 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { handleConfigCommand } from './config.js';
5
+ import type { SessionState } from '../repl/session-state.js';
6
+
7
+ // Mock logger to suppress console output during tests
8
+ vi.mock('../utils/logger.js', () => ({
9
+ logger: {
10
+ error: vi.fn(),
11
+ success: vi.fn(),
12
+ },
13
+ }));
14
+
15
+ // Mock colors to avoid picocolors output during tests
16
+ vi.mock('../utils/colors.js', () => ({
17
+ simpson: {
18
+ yellow: (s: string) => s,
19
+ },
20
+ }));
21
+
22
+ describe('handleConfigCommand - init guard', () => {
23
+ const mockState: SessionState = {
24
+ projectRoot: '/fake/project',
25
+ provider: 'openai',
26
+ model: 'gpt-4',
27
+ conversationHistory: [],
28
+ };
29
+
30
+ beforeEach(() => {
31
+ vi.restoreAllMocks();
32
+ // Clear process.env
33
+ delete process.env.TAVILY_API_KEY;
34
+ delete process.env.CONTEXT7_API_KEY;
35
+ delete process.env.BRAINTRUST_API_KEY;
36
+ });
37
+
38
+ it('throws error when .ralph/ does not exist', async () => {
39
+ const ralphDir = path.join(mockState.projectRoot, '.ralph');
40
+
41
+ vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
42
+ // .ralph/ directory does not exist
43
+ return p !== ralphDir;
44
+ });
45
+
46
+ const writeSpy = vi.spyOn(fs, 'writeFileSync');
47
+ const mkdirSpy = vi.spyOn(fs, 'mkdirSync');
48
+
49
+ const args = ['set', 'tavily', 'tvly-test-key-1234567890'];
50
+
51
+ await handleConfigCommand(args, mockState);
52
+
53
+ // Should not create .ralph/ directory
54
+ expect(mkdirSpy).not.toHaveBeenCalled();
55
+
56
+ // Should not write to .env.local
57
+ expect(writeSpy).not.toHaveBeenCalled();
58
+
59
+ // Should not set environment variable
60
+ expect(process.env.TAVILY_API_KEY).toBeUndefined();
61
+ });
62
+
63
+ it('writes to .ralph/.env.local when .ralph/ exists', async () => {
64
+ const ralphDir = path.join(mockState.projectRoot, '.ralph');
65
+ const envLocalPath = path.join(ralphDir, '.env.local');
66
+
67
+ vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
68
+ // .ralph/ directory exists
69
+ if (p === ralphDir) return true;
70
+ // .env.local does not exist yet
71
+ if (p === envLocalPath) return false;
72
+ return false;
73
+ });
74
+
75
+ vi.spyOn(fs, 'statSync').mockReturnValue({
76
+ isDirectory: () => true,
77
+ } as any);
78
+
79
+ vi.spyOn(fs, 'readFileSync').mockReturnValue('');
80
+ vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
81
+ const writeSpy = vi.spyOn(fs, 'writeFileSync').mockReturnValue(undefined);
82
+
83
+ const args = ['set', 'tavily', 'tvly-test-key-1234567890'];
84
+
85
+ await handleConfigCommand(args, mockState);
86
+
87
+ // Should write to .ralph/.env.local
88
+ expect(writeSpy).toHaveBeenCalledWith(
89
+ envLocalPath,
90
+ 'TAVILY_API_KEY=tvly-test-key-1234567890\n'
91
+ );
92
+
93
+ // Should set environment variable
94
+ expect(process.env.TAVILY_API_KEY).toBe('tvly-test-key-1234567890');
95
+ });
96
+
97
+ it('merges new key into existing .ralph/.env.local', async () => {
98
+ const ralphDir = path.join(mockState.projectRoot, '.ralph');
99
+ const envLocalPath = path.join(ralphDir, '.env.local');
100
+ const existingContent = 'CONTEXT7_API_KEY=c7-existing-123\n';
101
+
102
+ vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
103
+ // Both .ralph/ and .env.local exist
104
+ if (p === ralphDir) return true;
105
+ if (p === envLocalPath) return true;
106
+ return false;
107
+ });
108
+
109
+ vi.spyOn(fs, 'statSync').mockReturnValue({
110
+ isDirectory: () => true,
111
+ } as any);
112
+
113
+ vi.spyOn(fs, 'readFileSync').mockReturnValue(existingContent);
114
+ const writeSpy = vi.spyOn(fs, 'writeFileSync').mockReturnValue(undefined);
115
+
116
+ const args = ['set', 'tavily', 'tvly-test-key-1234567890'];
117
+
118
+ await handleConfigCommand(args, mockState);
119
+
120
+ const writtenContent = (writeSpy as any).mock.calls[0][1];
121
+
122
+ // Should preserve existing key
123
+ expect(writtenContent).toContain('CONTEXT7_API_KEY=c7-existing-123');
124
+
125
+ // Should add new key
126
+ expect(writtenContent).toContain('TAVILY_API_KEY=tvly-test-key-1234567890');
127
+ });
128
+
129
+ it('replaces existing key value in .ralph/.env.local', async () => {
130
+ const ralphDir = path.join(mockState.projectRoot, '.ralph');
131
+ const envLocalPath = path.join(ralphDir, '.env.local');
132
+ const existingContent = 'TAVILY_API_KEY=old-key-value\n';
133
+
134
+ vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
135
+ if (p === ralphDir) return true;
136
+ if (p === envLocalPath) return true;
137
+ return false;
138
+ });
139
+
140
+ vi.spyOn(fs, 'statSync').mockReturnValue({
141
+ isDirectory: () => true,
142
+ } as any);
143
+
144
+ vi.spyOn(fs, 'readFileSync').mockReturnValue(existingContent);
145
+ const writeSpy = vi.spyOn(fs, 'writeFileSync').mockReturnValue(undefined);
146
+
147
+ const args = ['set', 'tavily', 'tvly-new-key-9876543210'];
148
+
149
+ await handleConfigCommand(args, mockState);
150
+
151
+ const writtenContent = (writeSpy as any).mock.calls[0][1];
152
+
153
+ // Should replace old value
154
+ expect(writtenContent).not.toContain('old-key-value');
155
+ expect(writtenContent).toContain('TAVILY_API_KEY=tvly-new-key-9876543210');
156
+ });
157
+
158
+ it('validates API key length', async () => {
159
+ const ralphDir = path.join(mockState.projectRoot, '.ralph');
160
+
161
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true);
162
+ vi.spyOn(fs, 'statSync').mockReturnValue({
163
+ isDirectory: () => true,
164
+ } as any);
165
+
166
+ const writeSpy = vi.spyOn(fs, 'writeFileSync');
167
+
168
+ // Key too short (< 10 chars)
169
+ const args = ['set', 'tavily', 'short'];
170
+
171
+ await handleConfigCommand(args, mockState);
172
+
173
+ // Should not write
174
+ expect(writeSpy).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('rejects unknown service names', async () => {
178
+ const ralphDir = path.join(mockState.projectRoot, '.ralph');
179
+
180
+ vi.spyOn(fs, 'existsSync').mockReturnValue(true);
181
+ vi.spyOn(fs, 'statSync').mockReturnValue({
182
+ isDirectory: () => true,
183
+ } as any);
184
+
185
+ const writeSpy = vi.spyOn(fs, 'writeFileSync');
186
+
187
+ const args = ['set', 'unknown-service', 'some-key-1234567890'];
188
+
189
+ await handleConfigCommand(args, mockState);
190
+
191
+ // Should not write
192
+ expect(writeSpy).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it('handles all supported services', async () => {
196
+ const ralphDir = path.join(mockState.projectRoot, '.ralph');
197
+ const envLocalPath = path.join(ralphDir, '.env.local');
198
+
199
+ vi.spyOn(fs, 'existsSync').mockImplementation((p) => {
200
+ if (p === ralphDir) return true;
201
+ if (p === envLocalPath) return false;
202
+ return false;
203
+ });
204
+
205
+ vi.spyOn(fs, 'statSync').mockReturnValue({
206
+ isDirectory: () => true,
207
+ } as any);
208
+
209
+ vi.spyOn(fs, 'readFileSync').mockReturnValue('');
210
+ vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
211
+ const writeSpy = vi.spyOn(fs, 'writeFileSync').mockReturnValue(undefined);
212
+
213
+ // Test tavily
214
+ await handleConfigCommand(['set', 'tavily', 'tvly-key-1234567890'], mockState);
215
+ expect(writeSpy).toHaveBeenLastCalledWith(envLocalPath, 'TAVILY_API_KEY=tvly-key-1234567890\n');
216
+
217
+ // Test context7
218
+ await handleConfigCommand(['set', 'context7', 'c7-key-1234567890'], mockState);
219
+ expect(writeSpy).toHaveBeenLastCalledWith(envLocalPath, 'CONTEXT7_API_KEY=c7-key-1234567890\n');
220
+
221
+ // Test braintrust
222
+ await handleConfigCommand(['set', 'braintrust', 'bt-key-1234567890'], mockState);
223
+ expect(writeSpy).toHaveBeenLastCalledWith(envLocalPath, 'BRAINTRUST_API_KEY=bt-key-1234567890\n');
224
+ });
225
+ });
@@ -10,6 +10,7 @@ import { logger } from '../utils/logger.js';
10
10
  import { simpson } from '../utils/colors.js';
11
11
  import type { SessionState } from '../repl/session-state.js';
12
12
  import { getAvailableProvider, AVAILABLE_MODELS } from '../ai/providers.js';
13
+ import { writeKeysToEnvFile } from '../utils/env.js';
13
14
 
14
15
  /**
15
16
  * Supported services for API key configuration
@@ -40,27 +41,17 @@ function isServiceConfigured(service: ConfigurableService): boolean {
40
41
  }
41
42
 
42
43
  /**
43
- * Save an API key to .env.local file
44
+ * Save an API key to .ralph/.env.local file
44
45
  */
45
46
  function saveKeyToEnvLocal(projectRoot: string, envVar: string, value: string): void {
46
- const envLocalPath = path.join(projectRoot, '.env.local');
47
- let envContent = '';
48
-
49
- // Read existing content if file exists
50
- if (fs.existsSync(envLocalPath)) {
51
- envContent = fs.readFileSync(envLocalPath, 'utf-8');
52
- }
53
-
54
- const keyRegex = new RegExp(`^${envVar}=.*$`, 'm');
55
- if (keyRegex.test(envContent)) {
56
- // Replace existing key
57
- envContent = envContent.replace(keyRegex, `${envVar}=${value}`);
58
- } else {
59
- // Append new key
60
- envContent = envContent.trimEnd() + (envContent ? '\n' : '') + `${envVar}=${value}\n`;
47
+ // Check that .ralph/ exists (project is initialized)
48
+ const ralphDir = path.join(projectRoot, '.ralph');
49
+ if (!fs.existsSync(ralphDir) || !fs.statSync(ralphDir).isDirectory()) {
50
+ throw new Error('This project is not initialized. Run \'ralph init\' to set up .ralph/ before using \'ralph config set\'.');
61
51
  }
62
52
 
63
- fs.writeFileSync(envLocalPath, envContent);
53
+ const envLocalPath = path.join(ralphDir, '.env.local');
54
+ writeKeysToEnvFile(envLocalPath, { [envVar]: value });
64
55
  }
65
56
 
66
57
  /**
@@ -160,7 +151,7 @@ export async function handleConfigCommand(
160
151
  // Also set in current process environment
161
152
  process.env[envVar] = apiKey;
162
153
 
163
- logger.success(`${envVar} saved to .env.local`);
154
+ logger.success(`${envVar} saved to .ralph/.env.local`);
164
155
  console.log(pc.dim('Restart Wiggum to apply changes to tool availability.'));
165
156
  console.log('');
166
157
  } catch (error) {
@@ -0,0 +1,129 @@
1
+ // src/context/convert.test.ts
2
+ import { describe, it, expect } from 'vitest';
3
+ import {
4
+ toPersistedScanResult,
5
+ toPersistedAIAnalysis,
6
+ toScanResultFromPersisted,
7
+ } from './convert.js';
8
+ import type { ScanResult } from '../scanner/types.js';
9
+ import type { AIAnalysisResult } from '../ai/enhancer.js';
10
+
11
+ describe('context/convert', () => {
12
+ describe('toPersistedScanResult', () => {
13
+ it('maps DetectedStack fields to flat persisted format', () => {
14
+ const scanResult: ScanResult = {
15
+ projectRoot: '/tmp/test',
16
+ scanTime: 100,
17
+ stack: {
18
+ framework: { name: 'Next.js', version: '14.0.0', variant: 'app-router', confidence: 95, evidence: [] },
19
+ packageManager: { name: 'pnpm', confidence: 90, evidence: [] },
20
+ testing: {
21
+ unit: { name: 'Vitest', confidence: 85, evidence: [] },
22
+ e2e: { name: 'Playwright', confidence: 80, evidence: [] },
23
+ },
24
+ styling: { name: 'Tailwind CSS', confidence: 90, evidence: [] },
25
+ database: { name: 'Supabase', confidence: 75, evidence: [] },
26
+ orm: { name: 'Prisma', confidence: 70, evidence: [] },
27
+ auth: { name: 'NextAuth', confidence: 65, evidence: [] },
28
+ },
29
+ };
30
+
31
+ const result = toPersistedScanResult(scanResult);
32
+
33
+ expect(result.framework).toBe('Next.js');
34
+ expect(result.frameworkVersion).toBe('14.0.0');
35
+ expect(result.frameworkVariant).toBe('app-router');
36
+ expect(result.packageManager).toBe('pnpm');
37
+ expect(result.testing?.unit).toBe('Vitest');
38
+ expect(result.testing?.e2e).toBe('Playwright');
39
+ expect(result.styling).toBe('Tailwind CSS');
40
+ expect(result.database).toBe('Supabase');
41
+ expect(result.orm).toBe('Prisma');
42
+ expect(result.auth).toBe('NextAuth');
43
+ });
44
+
45
+ it('handles missing optional fields gracefully', () => {
46
+ const scanResult: ScanResult = {
47
+ projectRoot: '/tmp/test',
48
+ scanTime: 50,
49
+ stack: {},
50
+ };
51
+
52
+ const result = toPersistedScanResult(scanResult);
53
+
54
+ expect(result.framework).toBeUndefined();
55
+ expect(result.packageManager).toBeUndefined();
56
+ expect(result.testing?.unit).toBeNull();
57
+ expect(result.testing?.e2e).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe('toPersistedAIAnalysis', () => {
62
+ it('maps AIAnalysisResult to persisted format', () => {
63
+ const analysis: AIAnalysisResult = {
64
+ projectContext: {
65
+ entryPoints: ['src/index.ts'],
66
+ keyDirectories: { 'src/api': 'API routes' },
67
+ namingConventions: 'camelCase',
68
+ },
69
+ commands: { test: 'npm test', build: 'npm run build' },
70
+ implementationGuidelines: ['Use TypeScript strict mode'],
71
+ technologyPractices: {
72
+ projectType: 'Web App',
73
+ practices: ['SSR first'],
74
+ antiPatterns: ['No inline styles'],
75
+ },
76
+ };
77
+
78
+ const result = toPersistedAIAnalysis(analysis);
79
+
80
+ expect(result.projectContext?.entryPoints).toEqual(['src/index.ts']);
81
+ expect(result.projectContext?.keyDirectories).toEqual({ 'src/api': 'API routes' });
82
+ expect(result.commands?.test).toBe('npm test');
83
+ expect(result.implementationGuidelines).toEqual(['Use TypeScript strict mode']);
84
+ expect(result.technologyPractices?.projectType).toBe('Web App');
85
+ });
86
+
87
+ it('returns empty object for undefined analysis', () => {
88
+ const result = toPersistedAIAnalysis(undefined);
89
+ expect(result).toEqual({});
90
+ });
91
+ });
92
+
93
+ describe('toScanResultFromPersisted', () => {
94
+ it('rehydrates minimal ScanResult for codebase summary', () => {
95
+ const persisted = {
96
+ framework: 'Next.js',
97
+ frameworkVersion: '14.0.0',
98
+ frameworkVariant: 'app-router',
99
+ packageManager: 'pnpm',
100
+ testing: { unit: 'Vitest', e2e: 'Playwright' },
101
+ styling: 'Tailwind CSS',
102
+ database: 'Supabase',
103
+ orm: 'Prisma',
104
+ auth: 'NextAuth',
105
+ };
106
+
107
+ const scan = toScanResultFromPersisted(persisted, '/tmp/project');
108
+
109
+ expect(scan.projectRoot).toBe('/tmp/project');
110
+ expect(scan.stack.framework?.name).toBe('Next.js');
111
+ expect(scan.stack.framework?.version).toBe('14.0.0');
112
+ expect(scan.stack.framework?.variant).toBe('app-router');
113
+ expect(scan.stack.packageManager?.name).toBe('pnpm');
114
+ expect(scan.stack.testing?.unit?.name).toBe('Vitest');
115
+ expect(scan.stack.testing?.e2e?.name).toBe('Playwright');
116
+ expect(scan.stack.styling?.name).toBe('Tailwind CSS');
117
+ expect(scan.stack.database?.name).toBe('Supabase');
118
+ expect(scan.stack.orm?.name).toBe('Prisma');
119
+ expect(scan.stack.auth?.name).toBe('NextAuth');
120
+ });
121
+
122
+ it('handles empty persisted fields without crashing', () => {
123
+ const scan = toScanResultFromPersisted({}, '/tmp/project');
124
+ expect(scan.projectRoot).toBe('/tmp/project');
125
+ expect(scan.stack.framework).toBeUndefined();
126
+ expect(scan.stack.testing).toBeUndefined();
127
+ });
128
+ });
129
+ });