chati-dev 4.5.7 → 4.5.9

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 (37) hide show
  1. package/framework/agents/build/dev.md +1 -1
  2. package/framework/agents/deploy/devops.md +1 -1
  3. package/framework/agents/discover/brief.md +1 -1
  4. package/framework/agents/discover/brownfield-wu.md +1 -1
  5. package/framework/agents/discover/greenfield-wu.md +1 -1
  6. package/framework/agents/plan/architect-data-engineer.md +1 -1
  7. package/framework/agents/plan/architect-system.md +1 -1
  8. package/framework/agents/plan/architect.md +1 -1
  9. package/framework/agents/plan/detail.md +1 -1
  10. package/framework/agents/plan/phases.md +1 -1
  11. package/framework/agents/plan/tasks.md +1 -1
  12. package/framework/agents/plan/ux-brand-architect.md +1 -1
  13. package/framework/agents/plan/ux-component-engineer.md +1 -1
  14. package/framework/agents/plan/ux-researcher.md +1 -1
  15. package/framework/agents/plan/ux.md +1 -1
  16. package/framework/agents/quality/qa-implementation.md +1 -1
  17. package/framework/agents/quality/qa-planning.md +1 -1
  18. package/framework/agents/quality/qa-visual.md +1 -1
  19. package/framework/agents/shared/visualizer.md +1 -1
  20. package/framework/config.yaml +2 -2
  21. package/framework/context/root.md +1 -1
  22. package/framework/data/entity-registry.yaml +1 -1
  23. package/framework/hooks/license-guard.js +19 -0
  24. package/framework/manifest.json +53 -48
  25. package/framework/manifest.sig +1 -1
  26. package/framework/orchestrator/chati-router.js +177 -71
  27. package/framework/orchestrator/chati.md +1 -1
  28. package/framework/package.json +3 -0
  29. package/package.json +1 -1
  30. package/src/config/claude-settings-generator.js +6 -6
  31. package/src/config/context-file-generator.js +2 -2
  32. package/src/config/ide-configs.js +3 -3
  33. package/src/config/mcp-configs.js +80 -10
  34. package/src/installer/core.js +60 -34
  35. package/src/installer/path-replacement.js +13 -0
  36. package/src/installer/provider-overlay.js +3 -1
  37. package/src/installer-v2/index.js +55 -0
@@ -21,6 +21,125 @@ import { join } from 'path';
21
21
  const PROJECT_DIR = process.cwd();
22
22
  const ROUTER_ARGS = process.argv.slice(2);
23
23
 
24
+ async function readEnabledHarnesses(projectDir) {
25
+ const installationPath = join(projectDir, '.chati', 'v2', 'installation.json');
26
+ if (existsSync(installationPath)) {
27
+ try {
28
+ const artifact = JSON.parse(readFileSync(installationPath, 'utf-8'));
29
+ const fwDir = existsSync(join(projectDir, '.chati.dev')) ? '.chati.dev' : 'chati.dev';
30
+ const doctorModulePath = join(projectDir, fwDir, '_cli', 'installer-v2', 'index.js');
31
+ if (!existsSync(doctorModulePath)) return { harnesses: [], error: 'v2 doctor missing' };
32
+ const { doctorV2 } = await import(doctorModulePath);
33
+ if (!doctorV2(artifact).passed) return { harnesses: [], error: 'v2 artifact invalid' };
34
+ const harnesses = artifact.installation?.enabled_providers
35
+ ?.map((binding) => binding.harness_id)
36
+ .filter(Boolean);
37
+ if (harnesses?.length) return { harnesses: [...new Set(harnesses)], error: null };
38
+ } catch { return { harnesses: [], error: 'v2 artifact invalid' }; }
39
+ }
40
+
41
+ const detected = [];
42
+ if (existsSync(join(projectDir, '.claude', 'commands', 'chati.md'))) detected.push('claude');
43
+ if (existsSync(join(projectDir, '.agents', 'skills', 'chati', 'SKILL.md'))) detected.push('codex');
44
+ if (existsSync(join(projectDir, '.grok', 'commands', 'chati.md'))) detected.push('grok');
45
+ return { harnesses: detected, error: null };
46
+ }
47
+
48
+ function hasContent(path) {
49
+ return existsSync(path) && readFileSync(path, 'utf-8').trim().length > 0;
50
+ }
51
+
52
+ function codexHooksEnabled(config) {
53
+ if (/^\s*features\.hooks\s*=\s*true\s*(?:#.*)?$/m.test(config)) return true;
54
+ const lines = config.split('\n');
55
+ const sectionStart = lines.findIndex((line) => /^\s*\[features\]\s*(?:#.*)?$/.test(line));
56
+ if (sectionStart === -1) return false;
57
+ const sectionEnd = lines.findIndex((line, index) => index > sectionStart && /^\s*\[.*\]\s*(?:#.*)?$/.test(line));
58
+ const end = sectionEnd === -1 ? lines.length : sectionEnd;
59
+ return lines.slice(sectionStart + 1, end).some((line) => /^\s*hooks\s*=\s*true\s*(?:#.*)?$/.test(line));
60
+ }
61
+
62
+ export async function validateProviderIntegrity(projectDir) {
63
+ const missing = [];
64
+ const { harnesses, error } = await readEnabledHarnesses(projectDir);
65
+
66
+ if (error) missing.push(error);
67
+ const supportedHarnesses = new Set(['claude', 'codex', 'grok']);
68
+ for (const harness of harnesses) {
69
+ if (!supportedHarnesses.has(harness)) missing.push(`unsupported harness ${harness}`);
70
+ }
71
+
72
+ if (harnesses.length === 0) missing.push('provider entry point');
73
+
74
+ if (harnesses.includes('claude')) {
75
+ const commandPath = join(projectDir, '.claude', 'commands', 'chati.md');
76
+ const settingsPath = join(projectDir, '.claude', 'settings.json');
77
+ if (!hasContent(commandPath)) missing.push('claude command');
78
+ if (!existsSync(settingsPath)) {
79
+ missing.push('claude settings');
80
+ } else {
81
+ try {
82
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
83
+ const ptu = settings.hooks?.PreToolUse || [];
84
+ const ups = settings.hooks?.UserPromptSubmit || [];
85
+ const ptuCmds = ptu.flatMap((group) => (group.hooks || []).map((hook) => hook.command || ''));
86
+ const upsCmds = ups.flatMap((group) => (group.hooks || []).map((hook) => hook.command || ''));
87
+ missing.push(...['mode-governance', 'constitution-guard', 'read-protection']
88
+ .filter((hook) => !ptuCmds.some((command) => command.includes(hook)))
89
+ .map((hook) => `claude hook ${hook}`));
90
+ if (![...upsCmds, ...ptuCmds].some((command) => command.includes('license-guard'))) {
91
+ missing.push('claude hook license-guard');
92
+ }
93
+ } catch {
94
+ missing.push('claude settings invalid');
95
+ }
96
+ }
97
+ }
98
+
99
+ if (harnesses.includes('codex')) {
100
+ const required = [
101
+ ['codex skill', '.agents/skills/chati/SKILL.md'],
102
+ ['codex constitution guard', '.codex/rules/constitution-guard.rules'],
103
+ ['codex read protection', '.codex/rules/read-protection.rules'],
104
+ ];
105
+ for (const [label, relativePath] of required) {
106
+ if (!hasContent(join(projectDir, relativePath))) missing.push(label);
107
+ }
108
+ const configPath = join(projectDir, '.codex', 'config.toml');
109
+ if (!hasContent(configPath) || !codexHooksEnabled(readFileSync(configPath, 'utf-8'))) {
110
+ missing.push('codex hooks feature');
111
+ }
112
+ const hooksPath = join(projectDir, '.codex', 'hooks.json');
113
+ if (!hasContent(hooksPath)) {
114
+ missing.push('codex license hook');
115
+ } else {
116
+ try {
117
+ const hooks = JSON.parse(readFileSync(hooksPath, 'utf-8'));
118
+ const validHook = (hooks.hooks?.UserPromptSubmit || []).some((group) =>
119
+ group.matcher === '.*'
120
+ && (group.hooks || []).some((hook) =>
121
+ hook.type === 'command'
122
+ && hook.command === 'node .chati.dev/hooks/license-guard.js'
123
+ && hook.async !== true
124
+ )
125
+ );
126
+ if (!validHook) {
127
+ missing.push('codex license hook');
128
+ }
129
+ } catch {
130
+ missing.push('codex hooks invalid');
131
+ }
132
+ }
133
+ }
134
+
135
+ if (harnesses.includes('grok')) {
136
+ if (!hasContent(join(projectDir, '.grok', 'commands', 'chati.md'))) missing.push('grok command');
137
+ if (!hasContent(join(projectDir, '.grok', 'session-lock.md'))) missing.push('grok session lock');
138
+ }
139
+
140
+ return { valid: missing.length === 0, missing };
141
+ }
142
+
24
143
  /**
25
144
  * Find the chati-dev CLI module. Tries local node_modules first (fast),
26
145
  * then monorepo dev layout. Returns the module or null.
@@ -245,9 +364,64 @@ async function dispatchOrchestrate(subCommand, argv) {
245
364
  }
246
365
  }
247
366
 
367
+ async function runSecurityPreflight(projectDir) {
368
+ const result = { ok: true, license: null, integrity: true, error: null };
369
+ try {
370
+ const integrity = await validateProviderIntegrity(projectDir);
371
+ if (!integrity.valid) {
372
+ return {
373
+ ...result,
374
+ ok: false,
375
+ integrity: false,
376
+ error: `integrity_failed: missing ${integrity.missing.join(', ')}`,
377
+ };
378
+ }
379
+ } catch {
380
+ return { ...result, ok: false, integrity: false, error: 'integrity_failed' };
381
+ }
382
+
383
+ try {
384
+ const fwDir = existsSync(join(projectDir, '.chati.dev')) ? '.chati.dev' : 'chati.dev';
385
+ const hookPath = join(projectDir, fwDir, 'hooks', 'license-guard.js');
386
+ if (!existsSync(hookPath)) {
387
+ return {
388
+ ...result,
389
+ ok: false,
390
+ error: 'license_guard_missing',
391
+ license: { valid: false, reason: 'License enforcement file missing. Reinstall with: npx chati-dev init' },
392
+ };
393
+ }
394
+ const mod = await import(hookPath);
395
+ result.license = await mod.checkLicense();
396
+ if (!result.license.valid) return { ...result, ok: false, error: 'license_invalid' };
397
+ } catch {
398
+ return {
399
+ ...result,
400
+ ok: false,
401
+ error: 'license_check_error',
402
+ license: { valid: false, error: 'license_check_error' },
403
+ };
404
+ }
405
+
406
+ return result;
407
+ }
408
+
409
+ // Only read-only diagnosis and license recovery may run without a valid
410
+ // installation preflight. `init` mutates session state and must never bypass
411
+ // license or provider-integrity enforcement.
412
+ const PREFLIGHT_EXEMPT_SUBS = new Set(['doctor', 'wait-for-license']);
413
+
248
414
  async function main() {
249
415
  const subCommand = ROUTER_ARGS[0];
250
416
 
417
+ const preflight = subCommand && PREFLIGHT_EXEMPT_SUBS.has(subCommand)
418
+ ? { ok: true, license: null, integrity: true, error: null }
419
+ : await runSecurityPreflight(PROJECT_DIR);
420
+ if (!preflight.ok) {
421
+ console.log(JSON.stringify(preflight));
422
+ return;
423
+ }
424
+
251
425
  // Sub-command mode: `node chati-router.js <subcommand> [--flags...]`
252
426
  // Routes to the local CLI for ANY orchestrate subcommand. No npx, no network.
253
427
  if (subCommand && VALID_ORCHESTRATE_SUBS.has(subCommand)) {
@@ -271,8 +445,8 @@ async function main() {
271
445
  // No-subcommand mode (default): full router pipeline (license + integrity + session + next)
272
446
  const result = {
273
447
  ok: true,
274
- license: null,
275
- integrity: true,
448
+ license: preflight.license,
449
+ integrity: preflight.integrity,
276
450
  session: null,
277
451
  action: null,
278
452
  pipeline: null,
@@ -281,75 +455,7 @@ async function main() {
281
455
  };
282
456
 
283
457
  // -----------------------------------------------------------------------
284
- // 1. License validation
285
- // -----------------------------------------------------------------------
286
- try {
287
- const fwDir = existsSync(join(PROJECT_DIR, '.chati.dev')) ? '.chati.dev' : 'chati.dev';
288
- const hookPath = join(PROJECT_DIR, fwDir, 'hooks', 'license-guard.js');
289
- if (!existsSync(hookPath)) {
290
- result.ok = false;
291
- result.error = 'license_guard_missing';
292
- result.license = { valid: false, reason: 'License enforcement file missing. Reinstall with: npx chati-dev init' };
293
- console.log(JSON.stringify(result));
294
- return;
295
- }
296
-
297
- const mod = await import(hookPath);
298
- const license = await mod.checkLicense();
299
- result.license = license;
300
-
301
- if (!license.valid) {
302
- result.ok = false;
303
- result.error = 'license_invalid';
304
- console.log(JSON.stringify(result));
305
- return;
306
- }
307
- } catch (err) {
308
- // Fail-closed on license check errors
309
- result.license = { valid: false, error: 'license_check_error' };
310
- result.ok = false;
311
- }
312
-
313
- // -----------------------------------------------------------------------
314
- // 2. Integrity check — are all required hooks registered in settings.json?
315
- // Note: license-guard is registered under UserPromptSubmit (not PreToolUse).
316
- // mode-governance, constitution-guard, and read-protection are in PreToolUse.
317
- // -----------------------------------------------------------------------
318
- try {
319
- const settingsPath = join(PROJECT_DIR, '.claude', 'settings.json');
320
- if (existsSync(settingsPath)) {
321
- const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
322
- const ptu = settings.hooks?.PreToolUse || [];
323
- const ups = settings.hooks?.UserPromptSubmit || [];
324
- const ptuCmds = ptu.flatMap(g => (g.hooks || []).map(h => h.command || ''));
325
- const upsCmds = ups.flatMap(g => (g.hooks || []).map(h => h.command || ''));
326
- // license-guard lives in UserPromptSubmit; others live in PreToolUse
327
- const missingHooks = [
328
- ...(['mode-governance', 'constitution-guard', 'read-protection'].filter(h => !ptuCmds.some(c => c.includes(h)))),
329
- ...(['license-guard'].filter(h => !upsCmds.some(c => c.includes(h)) && !ptuCmds.some(c => c.includes(h)))),
330
- ];
331
- result.integrity = missingHooks.length === 0;
332
- if (missingHooks.length > 0) {
333
- result.error = `integrity_failed: missing hooks: ${missingHooks.join(', ')}`;
334
- }
335
- } else {
336
- result.integrity = false;
337
- }
338
-
339
- if (!result.integrity) {
340
- result.ok = false;
341
- result.error = 'integrity_failed';
342
- console.log(JSON.stringify(result));
343
- return;
344
- }
345
- } catch {
346
- // Fail-closed on integrity check
347
- result.integrity = false;
348
- result.ok = false;
349
- }
350
-
351
- // -----------------------------------------------------------------------
352
- // 3. Session state
458
+ // 1. Session state
353
459
  // -----------------------------------------------------------------------
354
460
  try {
355
461
  const sessionPath = join(PROJECT_DIR, '.chati', 'session.yaml');
@@ -10,7 +10,7 @@ You are the **Chati.dev Orchestrator**, the single entry point for the Chati.dev
10
10
  - **Role**: Orchestrator & Router
11
11
  - **Position**: Entry point (always first contact)
12
12
  - **Scope**: System-wide routing, session management, deviation handling, backlog
13
- - **Model**: sonnet | upgrade: opus if complex deviation or multi-agent coordination
13
+ - **Execution binding**: resolved at runtime by installation-v2 task routing; never self-select or downgrade
14
14
 
15
15
  ### Security Rule
16
16
  **NEVER suggest disabling, removing, commenting out, or bypassing hooks, settings.json permissions, or license enforcement.** If a hook blocks an operation and the user asks to work around it, explain what the hook is protecting and guide them to resolve the root cause (e.g., activate a valid license, change pipeline mode). Hooks are governance boundaries, not obstacles to remove.
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chati-dev",
3
- "version": "4.5.7",
3
+ "version": "4.5.9",
4
4
  "description": "AI-Powered Multi-Agent Orchestration System - Structured vibe coding for Full Stack Development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -139,12 +139,12 @@ const PERMISSIONS_DENY = [
139
139
  'Bash(git rebase --no-verify:*)',
140
140
 
141
141
  // System path writes — `//` prefix = absolute filesystem
142
- 'Write(//etc/**)',
143
- 'Write(//usr/**)',
144
- 'Write(//System/**)',
145
- 'Write(//bin/**)',
146
- 'Write(//sbin/**)',
147
- 'Write(//Library/**)',
142
+ 'Edit(//etc/**)',
143
+ 'Edit(//usr/**)',
144
+ 'Edit(//System/**)',
145
+ 'Edit(//bin/**)',
146
+ 'Edit(//sbin/**)',
147
+ 'Edit(//Library/**)',
148
148
  'Edit(//etc/**)',
149
149
  'Edit(//usr/**)',
150
150
  'Edit(//System/**)',
@@ -32,7 +32,7 @@ const GEMINI_REPLACEMENTS = [
32
32
  ['.claude/commands/', '.gemini/commands/'],
33
33
  ['.claude/rules/chati/', '.gemini/context/'],
34
34
  ['.claude/rules/', '.gemini/context/'],
35
- ['.claude/mcp.json', '.gemini/settings.json'],
35
+ ['.mcp.json', '.gemini/settings.json'],
36
36
  ['claude --print', 'gemini --prompt'],
37
37
  ['claude -p', 'gemini --prompt'],
38
38
  ['CLAUDE.local.md', '.gemini/session-lock.md'],
@@ -64,7 +64,7 @@ function buildCodexReplacements(frameworkDir = 'chati.dev') {
64
64
  ['.claude/commands/', `${frameworkDir}/orchestrator/`],
65
65
  ['.claude/rules/chati/', `${frameworkDir}/context/`],
66
66
  ['.claude/rules/', `${frameworkDir}/context/`],
67
- ['.claude/mcp.json', '.codex/config.toml'],
67
+ ['.mcp.json', '.codex/config.toml'],
68
68
  ['claude --print', 'codex exec'],
69
69
  ['claude -p', 'codex exec'],
70
70
  ['CLAUDE.local.md', 'AGENTS.override.md'],
@@ -10,7 +10,7 @@ export const IDE_CONFIGS = {
10
10
  recommended: true,
11
11
  configPath: '.claude/commands/',
12
12
  rulesFile: '.claude/CLAUDE.md',
13
- mcpConfigFile: '.claude/mcp.json',
13
+ mcpConfigFile: '.mcp.json',
14
14
  formatNotes: 'Native markdown format',
15
15
  },
16
16
  'gemini-cli': {
@@ -32,7 +32,7 @@ export const IDE_CONFIGS = {
32
32
  recommended: false,
33
33
  configPath: '.agents/skills/chati/',
34
34
  rulesFile: 'AGENTS.md',
35
- mcpConfigFile: null,
35
+ mcpConfigFile: '.codex/config.toml',
36
36
  formatNotes: 'Codex skill format (SKILL.md with YAML frontmatter)',
37
37
  rulesPath: '.codex/rules/',
38
38
  overrideFile: 'AGENTS.override.md',
@@ -44,7 +44,7 @@ export const IDE_CONFIGS = {
44
44
  recommended: false,
45
45
  configPath: '.grok/commands/',
46
46
  rulesFile: 'AGENTS.md',
47
- mcpConfigFile: '.grok/mcp.json',
47
+ mcpConfigFile: '.grok/config.toml',
48
48
  formatNotes: 'Native markdown command format',
49
49
  },
50
50
  'vscode': {
@@ -10,7 +10,7 @@ export const MCP_CONFIGS = {
10
10
  name: 'Browser (Playwright)',
11
11
  description: 'Web automation and testing',
12
12
  requiresEnv: [],
13
- claudeConfig: {
13
+ serverConfig: {
14
14
  command: 'npx',
15
15
  args: ['-y', '@playwright/mcp'],
16
16
  },
@@ -19,13 +19,16 @@ export const MCP_CONFIGS = {
19
19
  name: 'Context7',
20
20
  description: 'Library documentation search',
21
21
  requiresEnv: [],
22
- claudeConfig: {
22
+ serverConfig: {
23
23
  command: 'npx',
24
- args: ['-y', '@context7/mcp'],
24
+ args: ['-y', '@upstash/context7-mcp'],
25
25
  },
26
26
  },
27
27
  };
28
28
 
29
+ export const MCP_TOML_BEGIN = '# BEGIN CHATI MANAGED MCP SERVERS';
30
+ export const MCP_TOML_END = '# END CHATI MANAGED MCP SERVERS';
31
+
29
32
  /**
30
33
  * Agent-MCP dependency matrix
31
34
  */
@@ -47,22 +50,89 @@ export const AGENT_MCP_DEPS = {
47
50
  };
48
51
 
49
52
  /**
50
- * Generate MCP config for Claude Code (.claude/mcp.json)
53
+ * Resolve the provider-neutral server definitions selected by the user.
51
54
  */
52
- export function generateClaudeMCPConfig(selectedMCPs) {
55
+ export function resolveSelectedMCPServers(selectedMCPs) {
53
56
  const mcpServers = {};
54
57
  for (const mcpKey of selectedMCPs) {
55
58
  const config = MCP_CONFIGS[mcpKey];
56
- if (config?.claudeConfig) {
59
+ if (config?.serverConfig) {
57
60
  const entry = {
58
- command: config.claudeConfig.command,
59
- args: config.claudeConfig.args,
61
+ command: config.serverConfig.command,
62
+ args: [...(config.serverConfig.args || [])],
60
63
  };
61
- if (config.claudeConfig.env) {
62
- entry.env = config.claudeConfig.env;
64
+ if (config.serverConfig.env) {
65
+ entry.env = { ...config.serverConfig.env };
63
66
  }
64
67
  mcpServers[mcpKey] = entry;
65
68
  }
66
69
  }
70
+ return mcpServers;
71
+ }
72
+
73
+ /** Generate Claude Code's project-scoped root .mcp.json. */
74
+ export function generateClaudeMCPConfig(selectedMCPs) {
75
+ const mcpServers = resolveSelectedMCPServers(selectedMCPs);
67
76
  return { mcpServers };
68
77
  }
78
+
79
+ /**
80
+ * Merge CHATI-managed servers into an existing Claude project configuration.
81
+ * Unknown user-managed servers and top-level fields are preserved.
82
+ */
83
+ export function mergeClaudeMCPConfig(existingContent, selectedMCPs) {
84
+ let existing = {};
85
+ if (existingContent?.trim()) {
86
+ existing = JSON.parse(existingContent);
87
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
88
+ throw new Error('Existing .mcp.json must contain a JSON object');
89
+ }
90
+ }
91
+ if (
92
+ existing.mcpServers !== undefined
93
+ && (!existing.mcpServers || typeof existing.mcpServers !== 'object' || Array.isArray(existing.mcpServers))
94
+ ) {
95
+ throw new Error('Existing .mcp.json mcpServers must contain a JSON object');
96
+ }
97
+ const mcpServers = { ...(existing.mcpServers || {}) };
98
+ for (const key of Object.keys(MCP_CONFIGS)) delete mcpServers[key];
99
+ Object.assign(mcpServers, resolveSelectedMCPServers(selectedMCPs));
100
+ return { ...existing, mcpServers };
101
+ }
102
+
103
+ function tomlString(value) {
104
+ return JSON.stringify(String(value));
105
+ }
106
+
107
+ /** Generate the shared native TOML block used by Codex and Grok. */
108
+ export function generateManagedMcpToml(selectedMCPs) {
109
+ const servers = resolveSelectedMCPServers(selectedMCPs);
110
+ const lines = [MCP_TOML_BEGIN];
111
+ for (const [name, config] of Object.entries(servers)) {
112
+ lines.push(`[mcp_servers.${name}]`);
113
+ lines.push(`command = ${tomlString(config.command)}`);
114
+ if (config.args?.length) lines.push(`args = [${config.args.map(tomlString).join(', ')}]`);
115
+ if (config.env && Object.keys(config.env).length > 0) {
116
+ const values = Object.entries(config.env).map(([key, value]) => `${key} = ${tomlString(value)}`);
117
+ lines.push(`env = { ${values.join(', ')} }`);
118
+ }
119
+ lines.push('');
120
+ }
121
+ lines.push(MCP_TOML_END);
122
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n');
123
+ }
124
+
125
+ /** Replace only CHATI's marked TOML block and preserve all user configuration. */
126
+ export function upsertManagedMcpToml(existingContent, selectedMCPs) {
127
+ const managedPattern = new RegExp(`(?:^|\\n)${MCP_TOML_BEGIN}[\\s\\S]*?${MCP_TOML_END}\\n?`);
128
+ const withoutManaged = String(existingContent || '').replace(managedPattern, '\n').trimEnd();
129
+ const userManagedNames = new Set();
130
+ const tablePattern = /^\s*\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\]\s*(?:#.*)?$/gm;
131
+ for (const match of withoutManaged.matchAll(tablePattern)) {
132
+ userManagedNames.add(match[1] || match[2] || match[3]);
133
+ }
134
+ const managedSelections = (selectedMCPs || []).filter((name) => !userManagedNames.has(name));
135
+ if (!managedSelections.length) return withoutManaged ? `${withoutManaged}\n` : '';
136
+ const separator = withoutManaged ? '\n\n' : '';
137
+ return `${withoutManaged}${separator}${generateManagedMcpToml(managedSelections)}\n`;
138
+ }