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.
- package/.agentvibes/config.json +3 -38
- package/.claude/commands/agent-vibes/provider.md +0 -0
- package/.claude/config/audio-effects.cfg +1 -1
- package/.claude/config/background-music-position.txt +6 -8
- package/.claude/config/reverb-level.txt +0 -0
- package/.claude/github-star-reminder.txt +1 -1
- package/.claude/hooks/bmad-tts-injector.sh +49 -21
- package/.claude/hooks/migrate-to-agentvibes.sh +24 -16
- package/.claude/hooks/personality-manager.sh +15 -2
- package/.claude/hooks/play-tts.sh +6 -0
- package/.claude/hooks/provider-commands.sh +16 -4
- package/.claude/hooks/provider-manager.sh +38 -0
- package/.claude/hooks/stop.sh +2 -27
- package/.claude/hooks/voice-manager.sh +50 -2
- package/.claude/hooks-windows/play-tts.ps1 +34 -1
- package/.claude/hooks-windows/tts-watcher.ps1 +122 -0
- package/.claude/piper-voices-dir.txt +1 -1
- package/.mcp.json +13 -33
- package/README.md +6 -8
- package/RELEASE_NOTES.md +32 -0
- package/bin/agent-vibes +39 -39
- package/package.json +1 -1
- package/src/bmad-detector.js +85 -71
- package/src/cli/list-personalities.js +110 -110
- package/src/cli/list-voices.js +114 -114
- package/src/commands/bmad-voices.js +394 -394
- package/src/commands/install-mcp.js +476 -476
- package/src/console/brand-colors.js +13 -13
- package/src/console/constants/personalities.js +44 -44
- package/src/console/tabs/help-tab.js +314 -314
- package/src/console/tabs/readme-tab.js +272 -272
- package/src/console/widgets/destroy-list.js +25 -25
- package/src/console/widgets/notice.js +55 -55
- package/src/console/widgets/personality-picker.js +213 -213
- package/src/i18n/de.js +202 -202
- package/src/i18n/es.js +202 -202
- package/src/i18n/fr.js +202 -202
- package/src/i18n/hi.js +202 -202
- package/src/i18n/ja.js +202 -202
- package/src/i18n/ko.js +202 -202
- package/src/i18n/pt.js +202 -202
- package/src/i18n/strings.js +54 -54
- package/src/i18n/zh-CN.js +202 -202
- package/src/installer/language-screen.js +31 -31
- package/src/installer/music-file-input.js +304 -304
- package/src/installer.js +330 -64
- package/src/services/agent-voice-store.js +59 -12
- package/src/services/config-service.js +264 -264
- package/src/services/language-service.js +47 -47
- package/src/services/llm-provider-service.js +57 -12
- package/src/services/provider-service.js +143 -143
- package/src/utils/audio-duration-validator.js +298 -298
- package/src/utils/audio-format-validator.js +277 -277
- package/src/utils/dependency-checker.js +469 -469
- package/src/utils/file-ownership-verifier.js +358 -358
- package/src/utils/list-formatter.js +194 -194
- package/src/utils/music-file-validator.js +285 -285
- package/src/utils/preview-list-prompt.js +136 -136
- package/src/utils/secure-music-storage.js +412 -412
- package/.agentvibes/LITE-MODE.md +0 -236
- package/.agentvibes/README.md +0 -136
- package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +0 -141
- package/.agentvibes/backups/agents/analyst_20260204_144958.md +0 -78
- package/.agentvibes/backups/agents/architect_20260204_144958.md +0 -72
- package/.agentvibes/backups/agents/dev_20260204_144958.md +0 -74
- package/.agentvibes/backups/agents/pm_20260204_144958.md +0 -72
- package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +0 -64
- package/.agentvibes/backups/agents/sm_20260204_144958.md +0 -87
- package/.agentvibes/backups/agents/tea_20260204_144958.md +0 -79
- package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +0 -82
- package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +0 -80
- package/.agentvibes/config/README-personality-defaults.md +0 -162
- package/.agentvibes/config/agentvibes.json +0 -1
- package/.agentvibes/config/mode.txt +0 -1
- package/.agentvibes/config/personality-voice-defaults.default.json +0 -21
- package/.agentvibes/config/save-audio.txt +0 -1
- package/.agentvibes/config/voice-metadata.json +0 -160
- package/.agentvibes/hooks/help.sh +0 -191
- package/.agentvibes/hooks/post-tool-use-lite.sh +0 -111
- package/.agentvibes/hooks/save-audio-manager.sh +0 -162
- package/.agentvibes/hooks/session-start-full-optimized.sh +0 -102
- package/.agentvibes/hooks/session-start-full.sh +0 -142
- package/.agentvibes/hooks/session-start-lite-v2.sh +0 -34
- package/.agentvibes/hooks/session-start-lite.sh +0 -29
- package/.agentvibes/hooks/stop-lite.sh +0 -115
- package/.agentvibes/hooks/switch-mode.sh +0 -215
- package/.agentvibes/output-styles/audio-summary.md +0 -30
- package/.claude/audio/voice-samples/piper/alan.wav +0 -0
- package/.claude/audio/voice-samples/piper/amy.wav +0 -0
- package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
- package/.claude/audio/voice-samples/piper/joe.wav +0 -0
- package/.claude/audio/voice-samples/piper/john.wav +0 -0
- package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
- package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
- package/.claude/audio/voice-samples/piper/linda.wav +0 -0
- package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
- package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
- package/.claude/hooks/post-response.sh +0 -41
- 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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
236
|
-
if (fs.existsSync(manifestPath)) return true;
|
|
264
|
+
const homeDir = os.homedir();
|
|
237
265
|
|
|
238
|
-
//
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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;
|