agentvibes 5.6.9 → 5.7.1

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 (99) hide show
  1. package/.agentvibes/config.json +3 -38
  2. package/.claude/commands/agent-vibes/provider.md +0 -0
  3. package/.claude/config/audio-effects.cfg +1 -1
  4. package/.claude/config/background-music-position.txt +6 -8
  5. package/.claude/config/reverb-level.txt +0 -0
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/bmad-tts-injector.sh +49 -21
  8. package/.claude/hooks/migrate-to-agentvibes.sh +24 -16
  9. package/.claude/hooks/personality-manager.sh +15 -2
  10. package/.claude/hooks/play-tts.sh +6 -0
  11. package/.claude/hooks/provider-commands.sh +16 -4
  12. package/.claude/hooks/provider-manager.sh +38 -0
  13. package/.claude/hooks/stop.sh +2 -27
  14. package/.claude/hooks/voice-manager.sh +50 -2
  15. package/.claude/hooks-windows/play-tts.ps1 +34 -1
  16. package/.claude/hooks-windows/tts-watcher.ps1 +122 -0
  17. package/.claude/piper-voices-dir.txt +1 -1
  18. package/.mcp.json +13 -33
  19. package/README.md +6 -8
  20. package/RELEASE_NOTES.md +32 -0
  21. package/bin/agent-vibes +39 -39
  22. package/package.json +1 -1
  23. package/src/bmad-detector.js +85 -71
  24. package/src/cli/list-personalities.js +110 -110
  25. package/src/cli/list-voices.js +114 -114
  26. package/src/commands/bmad-voices.js +394 -394
  27. package/src/commands/install-mcp.js +476 -476
  28. package/src/console/brand-colors.js +13 -13
  29. package/src/console/constants/personalities.js +44 -44
  30. package/src/console/tabs/help-tab.js +314 -314
  31. package/src/console/tabs/readme-tab.js +272 -272
  32. package/src/console/widgets/destroy-list.js +25 -25
  33. package/src/console/widgets/notice.js +55 -55
  34. package/src/console/widgets/personality-picker.js +213 -213
  35. package/src/i18n/de.js +202 -202
  36. package/src/i18n/es.js +202 -202
  37. package/src/i18n/fr.js +202 -202
  38. package/src/i18n/hi.js +202 -202
  39. package/src/i18n/ja.js +202 -202
  40. package/src/i18n/ko.js +202 -202
  41. package/src/i18n/pt.js +202 -202
  42. package/src/i18n/strings.js +54 -54
  43. package/src/i18n/zh-CN.js +202 -202
  44. package/src/installer/language-screen.js +31 -31
  45. package/src/installer/music-file-input.js +304 -304
  46. package/src/installer.js +330 -64
  47. package/src/services/agent-voice-store.js +59 -12
  48. package/src/services/config-service.js +264 -264
  49. package/src/services/language-service.js +47 -47
  50. package/src/services/llm-provider-service.js +57 -12
  51. package/src/services/provider-service.js +143 -143
  52. package/src/utils/audio-duration-validator.js +298 -298
  53. package/src/utils/audio-format-validator.js +277 -277
  54. package/src/utils/dependency-checker.js +469 -469
  55. package/src/utils/file-ownership-verifier.js +358 -358
  56. package/src/utils/list-formatter.js +194 -194
  57. package/src/utils/music-file-validator.js +285 -285
  58. package/src/utils/preview-list-prompt.js +136 -136
  59. package/src/utils/secure-music-storage.js +412 -412
  60. package/.agentvibes/LITE-MODE.md +0 -236
  61. package/.agentvibes/README.md +0 -136
  62. package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +0 -141
  63. package/.agentvibes/backups/agents/analyst_20260204_144958.md +0 -78
  64. package/.agentvibes/backups/agents/architect_20260204_144958.md +0 -72
  65. package/.agentvibes/backups/agents/dev_20260204_144958.md +0 -74
  66. package/.agentvibes/backups/agents/pm_20260204_144958.md +0 -72
  67. package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +0 -64
  68. package/.agentvibes/backups/agents/sm_20260204_144958.md +0 -87
  69. package/.agentvibes/backups/agents/tea_20260204_144958.md +0 -79
  70. package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +0 -82
  71. package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +0 -80
  72. package/.agentvibes/config/README-personality-defaults.md +0 -162
  73. package/.agentvibes/config/agentvibes.json +0 -1
  74. package/.agentvibes/config/mode.txt +0 -1
  75. package/.agentvibes/config/personality-voice-defaults.default.json +0 -21
  76. package/.agentvibes/config/save-audio.txt +0 -1
  77. package/.agentvibes/config/voice-metadata.json +0 -160
  78. package/.agentvibes/hooks/help.sh +0 -191
  79. package/.agentvibes/hooks/post-tool-use-lite.sh +0 -111
  80. package/.agentvibes/hooks/save-audio-manager.sh +0 -162
  81. package/.agentvibes/hooks/session-start-full-optimized.sh +0 -102
  82. package/.agentvibes/hooks/session-start-full.sh +0 -142
  83. package/.agentvibes/hooks/session-start-lite-v2.sh +0 -34
  84. package/.agentvibes/hooks/session-start-lite.sh +0 -29
  85. package/.agentvibes/hooks/stop-lite.sh +0 -115
  86. package/.agentvibes/hooks/switch-mode.sh +0 -215
  87. package/.agentvibes/output-styles/audio-summary.md +0 -30
  88. package/.claude/audio/voice-samples/piper/alan.wav +0 -0
  89. package/.claude/audio/voice-samples/piper/amy.wav +0 -0
  90. package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
  91. package/.claude/audio/voice-samples/piper/joe.wav +0 -0
  92. package/.claude/audio/voice-samples/piper/john.wav +0 -0
  93. package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
  94. package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
  95. package/.claude/audio/voice-samples/piper/linda.wav +0 -0
  96. package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
  97. package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
  98. package/.claude/hooks/post-response.sh +0 -41
  99. package/bin/ensure-soprano-running.sh +0 -43
@@ -109,9 +109,17 @@ export function isSingleVoiceProvider(provider) {
109
109
  */
110
110
  export function parseBmadManifest(projectRoot) {
111
111
  const safeRoot = path.resolve(projectRoot ?? process.cwd());
112
- const manifestPath = path.resolve(safeRoot, '_bmad', '_config', 'agent-manifest.csv');
113
-
114
- if (!fs.existsSync(manifestPath)) return [];
112
+ // Check project-local first, then home dir (global BMAD install)
113
+ const roots = [safeRoot];
114
+ const homeDir = os.homedir();
115
+ if (safeRoot !== homeDir) roots.push(homeDir);
116
+
117
+ let manifestPath = null;
118
+ for (const root of roots) {
119
+ const candidate = path.resolve(root, '_bmad', '_config', 'agent-manifest.csv');
120
+ if (fs.existsSync(candidate)) { manifestPath = candidate; break; }
121
+ }
122
+ if (!manifestPath) return [];
115
123
 
116
124
  try {
117
125
  const raw = fs.readFileSync(manifestPath, 'utf8');
@@ -196,11 +204,32 @@ export function scanBmadAgents(projectRoot) {
196
204
  const fromManifest = parseBmadManifest(projectRoot);
197
205
  if (fromManifest.length > 0) return fromManifest;
198
206
 
199
- // Fallback: directory scan
207
+ // Fallback: directory scan — check project-local then home dir
200
208
  const safeRoot = path.resolve(projectRoot ?? process.cwd());
209
+ const homeDir2 = os.homedir();
210
+
211
+ // v6.6+: agents under .claude/skills/*/agents/ — collect all such dirs
212
+ const skillsAgentDirs = [];
213
+ for (const root of (safeRoot !== homeDir2 ? [safeRoot, homeDir2] : [safeRoot])) {
214
+ const skillsDir = path.resolve(root, '.claude', 'skills');
215
+ if (fs.existsSync(skillsDir)) {
216
+ try {
217
+ for (const skill of fs.readdirSync(skillsDir)) {
218
+ const agentsDir = path.resolve(skillsDir, skill, 'agents');
219
+ if (fs.existsSync(agentsDir)) skillsAgentDirs.push(agentsDir);
220
+ }
221
+ } catch { /* skip */ }
222
+ }
223
+ }
224
+
201
225
  const candidateDirs = [
202
226
  path.resolve(safeRoot, '_bmad', 'bmm', 'agents'),
203
227
  path.resolve(safeRoot, '.bmad', 'agents'),
228
+ ...(safeRoot !== homeDir2 ? [
229
+ path.resolve(homeDir2, '_bmad', 'bmm', 'agents'),
230
+ path.resolve(homeDir2, '.bmad', 'agents'),
231
+ ] : []),
232
+ ...skillsAgentDirs,
204
233
  ];
205
234
 
206
235
  for (const dir of candidateDirs) {
@@ -232,15 +261,33 @@ export function scanBmadAgents(projectRoot) {
232
261
  */
233
262
  export function isBmadDetected(projectRoot) {
234
263
  const safeRoot = path.resolve(projectRoot ?? process.cwd());
235
- const manifestPath = path.resolve(safeRoot, '_bmad', '_config', 'agent-manifest.csv');
236
- if (fs.existsSync(manifestPath)) return true;
264
+ const homeDir = os.homedir();
237
265
 
238
- // Fallback checks
239
- const dirs = [
240
- path.resolve(safeRoot, '_bmad', 'bmm', 'agents'),
241
- path.resolve(safeRoot, '.bmad', 'agents'),
242
- ];
243
- return dirs.some(d => fs.existsSync(d));
266
+ // Check project-local first, then home-dir (BMAD can be installed globally at ~/_bmad)
267
+ const roots = safeRoot !== homeDir ? [safeRoot, homeDir] : [safeRoot];
268
+
269
+ for (const root of roots) {
270
+ // v6.x: agent-manifest.csv; v6.6+: manifest.yaml only (no agent-manifest.csv)
271
+ if (fs.existsSync(path.resolve(root, '_bmad', '_config', 'agent-manifest.csv'))) return true;
272
+ if (fs.existsSync(path.resolve(root, '_bmad', '_config', 'manifest.yaml'))) return true;
273
+
274
+ const dirs = [
275
+ path.resolve(root, '_bmad', 'bmm', 'agents'),
276
+ path.resolve(root, '.bmad', 'agents'),
277
+ ];
278
+ if (dirs.some(d => fs.existsSync(d))) return true;
279
+
280
+ // v6.6+: agents live under .claude/skills/*/agents/
281
+ const skillsDir = path.resolve(root, '.claude', 'skills');
282
+ if (fs.existsSync(skillsDir)) {
283
+ try {
284
+ const skills = fs.readdirSync(skillsDir);
285
+ if (skills.some(s => fs.existsSync(path.resolve(skillsDir, s, 'agents')))) return true;
286
+ } catch { /* skip */ }
287
+ }
288
+ }
289
+
290
+ return false;
244
291
  }
245
292
 
246
293
  // ---------------------------------------------------------------------------
@@ -1,264 +1,264 @@
1
- /**
2
- * AgentVibes Config Service
3
- * Story 6.5: Command Routing & Entry Points (isInstalled, isBmadDetected stubs)
4
- * Story 7.1: Provider & Voice Settings Group (full read/write implementation)
5
- *
6
- * Provides config hierarchy: global (~/.agentvibes/config.json)
7
- * + project-level overrides (<projectRoot>/.agentvibes/config.json).
8
- * All path operations use path.resolve() to prevent traversal.
9
- * Config writes are atomic (write .tmp → rename) with chmod 600.
10
- */
11
-
12
- import fs from 'node:fs';
13
- import os from 'node:os';
14
- import path from 'node:path';
15
-
16
- export class ConfigService {
17
- /**
18
- * @param {object} [opts]
19
- * @param {string} [opts.projectRoot] - Project root for hook/config detection. Defaults to process.cwd().
20
- * @param {string} [opts.homeDir] - User home dir for global config. Defaults to os.homedir().
21
- */
22
- constructor(opts = {}) {
23
- // path.resolve() on all roots prevents path-traversal at the source
24
- this._projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
25
- this._homeDir = path.resolve(opts.homeDir ?? os.homedir());
26
- }
27
-
28
- /** Returns the resolved project root path. */
29
- getProjectRoot() {
30
- return this._projectRoot;
31
- }
32
-
33
- // ---------------------------------------------------------------------------
34
- // Detection (story 6.5)
35
-
36
- /**
37
- * Returns true if AgentVibes hooks are installed in this project.
38
- * Detection: .claude/hooks/play-tts.sh exists in projectRoot.
39
- */
40
- isInstalled() {
41
- const hooksFile = path.resolve(this._projectRoot, '.claude', 'hooks', 'play-tts.sh');
42
- return fs.existsSync(hooksFile);
43
- }
44
-
45
- /**
46
- * Returns true if BMAD framework is detected.
47
- * Checks: _bmad/ or .bmad/ in projectRoot, OR global voice map.
48
- */
49
- isBmadDetected() {
50
- const checks = [
51
- path.resolve(this._projectRoot, '_bmad'),
52
- path.resolve(this._projectRoot, '.bmad'),
53
- path.resolve(this._homeDir, '.agentvibes', 'bmad-voice-map.json'),
54
- ];
55
- return checks.some(p => fs.existsSync(p));
56
- }
57
-
58
- // ---------------------------------------------------------------------------
59
- // Read (story 7.1)
60
-
61
- /**
62
- * Returns global config (~/.agentvibes/config.json).
63
- * Returns {} if file does not exist.
64
- */
65
- getGlobalConfig() {
66
- const filePath = path.resolve(this._homeDir, '.agentvibes', 'config.json');
67
- return this._readConfigFile(filePath) ?? {};
68
- }
69
-
70
- /**
71
- * Returns project config (<projectRoot>/.agentvibes/config.json) or null if not present.
72
- */
73
- getProjectConfig() {
74
- const dir = path.resolve(this._projectRoot, '.agentvibes');
75
- if (!fs.existsSync(dir)) return null;
76
- const filePath = path.resolve(dir, 'config.json');
77
- return this._readConfigFile(filePath);
78
- }
79
-
80
- /**
81
- * Returns merged config: global + project overrides.
82
- * Project config keys win on conflict (deep merge for nested objects).
83
- */
84
- getConfig() {
85
- const global = this.getGlobalConfig();
86
- const project = this.getProjectConfig();
87
- return this._deepMerge(global, project);
88
- }
89
-
90
- /**
91
- * Returns config schema version from merged config.
92
- */
93
- getVersion() {
94
- return this.getConfig().version ?? '1.0';
95
- }
96
-
97
- // ---------------------------------------------------------------------------
98
- // Path helpers
99
-
100
- /** Returns the resolved global config file path. */
101
- getGlobalConfigPath() {
102
- return path.resolve(this._homeDir, '.agentvibes', 'config.json');
103
- }
104
-
105
- /** Returns the resolved local project config file path (regardless of whether it exists). */
106
- getLocalConfigPath() {
107
- return path.resolve(this._projectRoot, '.agentvibes', 'config.json');
108
- }
109
-
110
- /** Returns true if a local project config file exists. */
111
- hasLocalConfig() {
112
- return fs.existsSync(this.getLocalConfigPath());
113
- }
114
-
115
- // ---------------------------------------------------------------------------
116
- // Bulk save
117
-
118
- /**
119
- * Overwrites the ENTIRE global config with the given object.
120
- * Atomic write (tmp → rename). Creates dir if needed. chmod 600.
121
- * @param {object} data
122
- */
123
- saveAllToGlobal(data) {
124
- this._writeConfigAtomic(this.getGlobalConfigPath(), data);
125
- this._syncToTextFiles(path.resolve(this._homeDir, '.claude'), data);
126
- }
127
-
128
- /**
129
- * Overwrites the ENTIRE local project config with the given object.
130
- * Atomic write (tmp → rename). Creates dir if needed. chmod 600.
131
- * @param {object} data
132
- */
133
- saveAllToLocal(data) {
134
- this._writeConfigAtomic(this.getLocalConfigPath(), data);
135
- this._syncToTextFiles(path.resolve(this._projectRoot, '.claude'), data);
136
- }
137
-
138
- // ---------------------------------------------------------------------------
139
- // Write (story 7.1)
140
-
141
- /**
142
- * Writes a key to project config if <projectRoot>/.agentvibes/ exists,
143
- * otherwise writes to global config. Atomic write (tmp → rename). chmod 600.
144
- * @param {string} key
145
- * @param {*} value
146
- */
147
- set(key, value) {
148
- const projDir = path.resolve(this._projectRoot, '.agentvibes');
149
- if (fs.existsSync(projDir)) {
150
- const filePath = path.resolve(projDir, 'config.json');
151
- const current = this._readConfigFile(filePath) ?? {};
152
- current[key] = value;
153
- this._writeConfigAtomic(filePath, current);
154
- } else {
155
- this.setGlobal(key, value);
156
- }
157
- }
158
-
159
- /**
160
- * Always writes a key to global config (~/.agentvibes/config.json).
161
- * Atomic write (tmp → rename). chmod 600. Creates dir if needed.
162
- * @param {string} key
163
- * @param {*} value
164
- */
165
- setGlobal(key, value) {
166
- const filePath = path.resolve(this._homeDir, '.agentvibes', 'config.json');
167
- const current = this._readConfigFile(filePath) ?? {};
168
- current[key] = value;
169
- this._writeConfigAtomic(filePath, current);
170
- }
171
-
172
- // ---------------------------------------------------------------------------
173
- // Private helpers
174
-
175
- /**
176
- * Sync config.json values to .claude/ text files that TTS scripts read.
177
- * Only writes files that the config has values for. Silently ignores errors.
178
- * @param {string} claudeDir - Path to .claude/ directory
179
- * @param {object} data - Config data object
180
- */
181
- _syncToTextFiles(claudeDir, data) {
182
- if (!claudeDir || !data) return;
183
- try {
184
- if (!fs.existsSync(claudeDir)) return;
185
- if (data.voice) {
186
- fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), String(data.voice));
187
- }
188
- if (data.provider) {
189
- fs.writeFileSync(path.join(claudeDir, 'tts-provider.txt'), String(data.provider));
190
- }
191
- if (data.verbosity) {
192
- fs.writeFileSync(path.join(claudeDir, 'tts-verbosity.txt'), String(data.verbosity));
193
- }
194
- } catch { /* best-effort sync */ }
195
- }
196
-
197
- /**
198
- * Read and parse a JSON config file.
199
- * Returns null if the file does not exist or is not a regular file.
200
- * @param {string} filePath
201
- * @returns {object|null}
202
- */
203
- _readConfigFile(filePath) {
204
- if (!fs.existsSync(filePath)) return null;
205
- try {
206
- const raw = fs.readFileSync(filePath, 'utf8');
207
- return JSON.parse(raw);
208
- } catch {
209
- // Corrupt or unreadable — treat as missing
210
- return null;
211
- }
212
- }
213
-
214
- /**
215
- * Deep-merge two plain objects. Values from `override` win on conflict.
216
- * Arrays are replaced (not merged). null override → returns copy of base.
217
- * @param {object} base
218
- * @param {object|null} override
219
- * @returns {object}
220
- */
221
- _deepMerge(base, override) {
222
- if (!override || typeof override !== 'object') return { ...base };
223
- const result = { ...base };
224
- for (const [k, v] of Object.entries(override)) {
225
- if (
226
- v !== null &&
227
- typeof v === 'object' &&
228
- !Array.isArray(v) &&
229
- typeof result[k] === 'object' &&
230
- result[k] !== null &&
231
- !Array.isArray(result[k])
232
- ) {
233
- result[k] = this._deepMerge(result[k], v);
234
- } else {
235
- result[k] = v;
236
- }
237
- }
238
- return result;
239
- }
240
-
241
- /**
242
- * Atomically write data as JSON to filePath.
243
- * Writes to {filePath}.tmp then renames — safe against partial reads.
244
- * Creates parent directory if needed. Sets file permissions to 0o600.
245
- * @param {string} filePath
246
- * @param {object} data
247
- */
248
- _writeConfigAtomic(filePath, data) {
249
- const dir = path.dirname(filePath);
250
- fs.mkdirSync(dir, { recursive: true });
251
-
252
- const tmpPath = `${filePath}.tmp`;
253
- const json = JSON.stringify(data, null, 2);
254
-
255
- // Write to tmp, then atomic rename
256
- fs.writeFileSync(tmpPath, json, { encoding: 'utf8', mode: 0o600 });
257
- fs.renameSync(tmpPath, filePath);
258
-
259
- // Ensure correct permissions on final file
260
- fs.chmodSync(filePath, 0o600);
261
- }
262
- }
263
-
264
- export default ConfigService;
1
+ /**
2
+ * AgentVibes Config Service
3
+ * Story 6.5: Command Routing & Entry Points (isInstalled, isBmadDetected stubs)
4
+ * Story 7.1: Provider & Voice Settings Group (full read/write implementation)
5
+ *
6
+ * Provides config hierarchy: global (~/.agentvibes/config.json)
7
+ * + project-level overrides (<projectRoot>/.agentvibes/config.json).
8
+ * All path operations use path.resolve() to prevent traversal.
9
+ * Config writes are atomic (write .tmp → rename) with chmod 600.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ export class ConfigService {
17
+ /**
18
+ * @param {object} [opts]
19
+ * @param {string} [opts.projectRoot] - Project root for hook/config detection. Defaults to process.cwd().
20
+ * @param {string} [opts.homeDir] - User home dir for global config. Defaults to os.homedir().
21
+ */
22
+ constructor(opts = {}) {
23
+ // path.resolve() on all roots prevents path-traversal at the source
24
+ this._projectRoot = path.resolve(opts.projectRoot ?? process.cwd());
25
+ this._homeDir = path.resolve(opts.homeDir ?? os.homedir());
26
+ }
27
+
28
+ /** Returns the resolved project root path. */
29
+ getProjectRoot() {
30
+ return this._projectRoot;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Detection (story 6.5)
35
+
36
+ /**
37
+ * Returns true if AgentVibes hooks are installed in this project.
38
+ * Detection: .claude/hooks/play-tts.sh exists in projectRoot.
39
+ */
40
+ isInstalled() {
41
+ const hooksFile = path.resolve(this._projectRoot, '.claude', 'hooks', 'play-tts.sh');
42
+ return fs.existsSync(hooksFile);
43
+ }
44
+
45
+ /**
46
+ * Returns true if BMAD framework is detected.
47
+ * Checks: _bmad/ or .bmad/ in projectRoot, OR global voice map.
48
+ */
49
+ isBmadDetected() {
50
+ const checks = [
51
+ path.resolve(this._projectRoot, '_bmad'),
52
+ path.resolve(this._projectRoot, '.bmad'),
53
+ path.resolve(this._homeDir, '.agentvibes', 'bmad-voice-map.json'),
54
+ ];
55
+ return checks.some(p => fs.existsSync(p));
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Read (story 7.1)
60
+
61
+ /**
62
+ * Returns global config (~/.agentvibes/config.json).
63
+ * Returns {} if file does not exist.
64
+ */
65
+ getGlobalConfig() {
66
+ const filePath = path.resolve(this._homeDir, '.agentvibes', 'config.json');
67
+ return this._readConfigFile(filePath) ?? {};
68
+ }
69
+
70
+ /**
71
+ * Returns project config (<projectRoot>/.agentvibes/config.json) or null if not present.
72
+ */
73
+ getProjectConfig() {
74
+ const dir = path.resolve(this._projectRoot, '.agentvibes');
75
+ if (!fs.existsSync(dir)) return null;
76
+ const filePath = path.resolve(dir, 'config.json');
77
+ return this._readConfigFile(filePath);
78
+ }
79
+
80
+ /**
81
+ * Returns merged config: global + project overrides.
82
+ * Project config keys win on conflict (deep merge for nested objects).
83
+ */
84
+ getConfig() {
85
+ const global = this.getGlobalConfig();
86
+ const project = this.getProjectConfig();
87
+ return this._deepMerge(global, project);
88
+ }
89
+
90
+ /**
91
+ * Returns config schema version from merged config.
92
+ */
93
+ getVersion() {
94
+ return this.getConfig().version ?? '1.0';
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Path helpers
99
+
100
+ /** Returns the resolved global config file path. */
101
+ getGlobalConfigPath() {
102
+ return path.resolve(this._homeDir, '.agentvibes', 'config.json');
103
+ }
104
+
105
+ /** Returns the resolved local project config file path (regardless of whether it exists). */
106
+ getLocalConfigPath() {
107
+ return path.resolve(this._projectRoot, '.agentvibes', 'config.json');
108
+ }
109
+
110
+ /** Returns true if a local project config file exists. */
111
+ hasLocalConfig() {
112
+ return fs.existsSync(this.getLocalConfigPath());
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Bulk save
117
+
118
+ /**
119
+ * Overwrites the ENTIRE global config with the given object.
120
+ * Atomic write (tmp → rename). Creates dir if needed. chmod 600.
121
+ * @param {object} data
122
+ */
123
+ saveAllToGlobal(data) {
124
+ this._writeConfigAtomic(this.getGlobalConfigPath(), data);
125
+ this._syncToTextFiles(path.resolve(this._homeDir, '.claude'), data);
126
+ }
127
+
128
+ /**
129
+ * Overwrites the ENTIRE local project config with the given object.
130
+ * Atomic write (tmp → rename). Creates dir if needed. chmod 600.
131
+ * @param {object} data
132
+ */
133
+ saveAllToLocal(data) {
134
+ this._writeConfigAtomic(this.getLocalConfigPath(), data);
135
+ this._syncToTextFiles(path.resolve(this._projectRoot, '.claude'), data);
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Write (story 7.1)
140
+
141
+ /**
142
+ * Writes a key to project config if <projectRoot>/.agentvibes/ exists,
143
+ * otherwise writes to global config. Atomic write (tmp → rename). chmod 600.
144
+ * @param {string} key
145
+ * @param {*} value
146
+ */
147
+ set(key, value) {
148
+ const projDir = path.resolve(this._projectRoot, '.agentvibes');
149
+ if (fs.existsSync(projDir)) {
150
+ const filePath = path.resolve(projDir, 'config.json');
151
+ const current = this._readConfigFile(filePath) ?? {};
152
+ current[key] = value;
153
+ this._writeConfigAtomic(filePath, current);
154
+ } else {
155
+ this.setGlobal(key, value);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Always writes a key to global config (~/.agentvibes/config.json).
161
+ * Atomic write (tmp → rename). chmod 600. Creates dir if needed.
162
+ * @param {string} key
163
+ * @param {*} value
164
+ */
165
+ setGlobal(key, value) {
166
+ const filePath = path.resolve(this._homeDir, '.agentvibes', 'config.json');
167
+ const current = this._readConfigFile(filePath) ?? {};
168
+ current[key] = value;
169
+ this._writeConfigAtomic(filePath, current);
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Private helpers
174
+
175
+ /**
176
+ * Sync config.json values to .claude/ text files that TTS scripts read.
177
+ * Only writes files that the config has values for. Silently ignores errors.
178
+ * @param {string} claudeDir - Path to .claude/ directory
179
+ * @param {object} data - Config data object
180
+ */
181
+ _syncToTextFiles(claudeDir, data) {
182
+ if (!claudeDir || !data) return;
183
+ try {
184
+ if (!fs.existsSync(claudeDir)) return;
185
+ if (data.voice) {
186
+ fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), String(data.voice));
187
+ }
188
+ if (data.provider) {
189
+ fs.writeFileSync(path.join(claudeDir, 'tts-provider.txt'), String(data.provider));
190
+ }
191
+ if (data.verbosity) {
192
+ fs.writeFileSync(path.join(claudeDir, 'tts-verbosity.txt'), String(data.verbosity));
193
+ }
194
+ } catch { /* best-effort sync */ }
195
+ }
196
+
197
+ /**
198
+ * Read and parse a JSON config file.
199
+ * Returns null if the file does not exist or is not a regular file.
200
+ * @param {string} filePath
201
+ * @returns {object|null}
202
+ */
203
+ _readConfigFile(filePath) {
204
+ if (!fs.existsSync(filePath)) return null;
205
+ try {
206
+ const raw = fs.readFileSync(filePath, 'utf8');
207
+ return JSON.parse(raw);
208
+ } catch {
209
+ // Corrupt or unreadable — treat as missing
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Deep-merge two plain objects. Values from `override` win on conflict.
216
+ * Arrays are replaced (not merged). null override → returns copy of base.
217
+ * @param {object} base
218
+ * @param {object|null} override
219
+ * @returns {object}
220
+ */
221
+ _deepMerge(base, override) {
222
+ if (!override || typeof override !== 'object') return { ...base };
223
+ const result = { ...base };
224
+ for (const [k, v] of Object.entries(override)) {
225
+ if (
226
+ v !== null &&
227
+ typeof v === 'object' &&
228
+ !Array.isArray(v) &&
229
+ typeof result[k] === 'object' &&
230
+ result[k] !== null &&
231
+ !Array.isArray(result[k])
232
+ ) {
233
+ result[k] = this._deepMerge(result[k], v);
234
+ } else {
235
+ result[k] = v;
236
+ }
237
+ }
238
+ return result;
239
+ }
240
+
241
+ /**
242
+ * Atomically write data as JSON to filePath.
243
+ * Writes to {filePath}.tmp then renames — safe against partial reads.
244
+ * Creates parent directory if needed. Sets file permissions to 0o600.
245
+ * @param {string} filePath
246
+ * @param {object} data
247
+ */
248
+ _writeConfigAtomic(filePath, data) {
249
+ const dir = path.dirname(filePath);
250
+ fs.mkdirSync(dir, { recursive: true });
251
+
252
+ const tmpPath = `${filePath}.tmp`;
253
+ const json = JSON.stringify(data, null, 2);
254
+
255
+ // Write to tmp, then atomic rename
256
+ fs.writeFileSync(tmpPath, json, { encoding: 'utf8', mode: 0o600 });
257
+ fs.renameSync(tmpPath, filePath);
258
+
259
+ // Ensure correct permissions on final file
260
+ fs.chmodSync(filePath, 0o600);
261
+ }
262
+ }
263
+
264
+ export default ConfigService;