brainctl 0.1.5 → 0.1.6

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.
@@ -0,0 +1,189 @@
1
+ import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { createClaudeReader, createCodexReader, createGeminiReader, } from './sync/agent-reader.js';
5
+ import { formatTimestamp } from './sync/agent-writer.js';
6
+ const readers = {
7
+ claude: createClaudeReader(),
8
+ codex: createCodexReader(),
9
+ gemini: createGeminiReader(),
10
+ };
11
+ export function createAgentConfigService() {
12
+ return {
13
+ async readAll(options) {
14
+ const results = await Promise.all([
15
+ readers.claude.read(options),
16
+ readers.codex.read(options),
17
+ readers.gemini.read(options),
18
+ ]);
19
+ return results;
20
+ },
21
+ async addMcp(options) {
22
+ const { cwd, agent, key, entry } = options;
23
+ if (agent === 'claude') {
24
+ await mutateClaudeConfig(cwd, (servers) => {
25
+ servers[key] = toClaudeEntry(entry);
26
+ });
27
+ }
28
+ else if (agent === 'codex') {
29
+ await mutateCodexConfig((servers) => {
30
+ servers[key] = entry;
31
+ });
32
+ }
33
+ else if (agent === 'gemini') {
34
+ await mutateGeminiConfig(cwd, (servers) => {
35
+ servers[key] = toGeminiEntry(entry);
36
+ });
37
+ }
38
+ },
39
+ async removeMcp(options) {
40
+ const { cwd, agent, key } = options;
41
+ if (agent === 'claude') {
42
+ await mutateClaudeConfig(cwd, (servers) => {
43
+ delete servers[key];
44
+ });
45
+ }
46
+ else if (agent === 'codex') {
47
+ await mutateCodexConfig((servers) => {
48
+ delete servers[key];
49
+ });
50
+ }
51
+ else if (agent === 'gemini') {
52
+ await mutateGeminiConfig(cwd, (servers) => {
53
+ delete servers[key];
54
+ });
55
+ }
56
+ },
57
+ };
58
+ }
59
+ /* ---- Claude: JSON with projects[cwd].mcpServers ---- */
60
+ async function mutateClaudeConfig(cwd, mutate) {
61
+ const configPath = path.join(homedir(), '.claude.json');
62
+ let existing = {};
63
+ try {
64
+ existing = JSON.parse(await readFile(configPath, 'utf8'));
65
+ await backupFile(configPath);
66
+ }
67
+ catch {
68
+ // fresh config
69
+ }
70
+ const projects = (existing.projects ?? {});
71
+ const projectConfig = projects[cwd] ?? {};
72
+ const servers = (projectConfig.mcpServers ?? {});
73
+ mutate(servers);
74
+ projectConfig.mcpServers = servers;
75
+ projects[cwd] = projectConfig;
76
+ existing.projects = projects;
77
+ await atomicWriteJson(configPath, existing);
78
+ }
79
+ function toClaudeEntry(entry) {
80
+ return {
81
+ type: 'stdio',
82
+ command: entry.command,
83
+ args: entry.args ?? [],
84
+ ...(entry.env ? { env: entry.env } : {}),
85
+ };
86
+ }
87
+ /* ---- Codex: TOML with [mcp_servers.*] ---- */
88
+ async function mutateCodexConfig(mutate) {
89
+ const configPath = path.join(homedir(), '.codex', 'config.toml');
90
+ let existingContent = '';
91
+ try {
92
+ existingContent = await readFile(configPath, 'utf8');
93
+ await backupFile(configPath);
94
+ }
95
+ catch {
96
+ // fresh config
97
+ }
98
+ // Read current servers via reader
99
+ const current = await readers.codex.read({ cwd: '' });
100
+ const servers = { ...current.mcpServers };
101
+ mutate(servers);
102
+ // Rebuild: preserve non-mcp content + new mcp sections
103
+ const nonMcp = stripCodexMcpSections(existingContent).trim();
104
+ const mcpToml = buildCodexMcpToml(servers);
105
+ const final = nonMcp.length > 0 ? `${nonMcp}\n\n${mcpToml}` : mcpToml;
106
+ await mkdir(path.dirname(configPath), { recursive: true });
107
+ await atomicWrite(configPath, final + '\n');
108
+ }
109
+ function stripCodexMcpSections(content) {
110
+ const lines = content.split('\n');
111
+ const result = [];
112
+ let inMcp = false;
113
+ for (const line of lines) {
114
+ if (/^\[mcp_servers[\].]/.test(line)) {
115
+ inMcp = true;
116
+ continue;
117
+ }
118
+ if (inMcp && /^\[/.test(line) && !/^\[mcp_servers[\].]/.test(line)) {
119
+ inMcp = false;
120
+ }
121
+ if (!inMcp)
122
+ result.push(line);
123
+ }
124
+ return result.join('\n');
125
+ }
126
+ function buildCodexMcpToml(servers) {
127
+ const lines = [];
128
+ for (const [name, entry] of Object.entries(servers)) {
129
+ lines.push(`[mcp_servers.${name}]`);
130
+ lines.push(`command = ${tomlStr(entry.command)}`);
131
+ if (entry.args && entry.args.length > 0) {
132
+ lines.push(`args = [${entry.args.map(tomlStr).join(', ')}]`);
133
+ }
134
+ if (entry.env && Object.keys(entry.env).length > 0) {
135
+ lines.push('');
136
+ lines.push(`[mcp_servers.${name}.env]`);
137
+ for (const [k, v] of Object.entries(entry.env)) {
138
+ lines.push(`${k} = ${tomlStr(v)}`);
139
+ }
140
+ }
141
+ lines.push('');
142
+ }
143
+ return lines.join('\n').trim();
144
+ }
145
+ function tomlStr(value) {
146
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
147
+ }
148
+ /* ---- Gemini: JSON with mcpServers ---- */
149
+ async function mutateGeminiConfig(_cwd, mutate) {
150
+ const configPath = path.join(homedir(), '.gemini', 'settings.json');
151
+ let existing = {};
152
+ try {
153
+ existing = JSON.parse(await readFile(configPath, 'utf8'));
154
+ await backupFile(configPath);
155
+ }
156
+ catch {
157
+ // fresh config
158
+ }
159
+ const servers = (existing.mcpServers ?? {});
160
+ mutate(servers);
161
+ existing.mcpServers = servers;
162
+ await mkdir(path.dirname(configPath), { recursive: true });
163
+ await atomicWriteJson(configPath, existing);
164
+ }
165
+ function toGeminiEntry(entry) {
166
+ return {
167
+ command: entry.command,
168
+ args: entry.args ?? [],
169
+ ...(entry.env ? { env: entry.env } : {}),
170
+ };
171
+ }
172
+ /* ---- Shared helpers ---- */
173
+ async function backupFile(filePath) {
174
+ const backupPath = `${filePath}.bak.${formatTimestamp()}`;
175
+ try {
176
+ await copyFile(filePath, backupPath);
177
+ }
178
+ catch {
179
+ // File may not exist yet
180
+ }
181
+ }
182
+ async function atomicWriteJson(filePath, data) {
183
+ await atomicWrite(filePath, JSON.stringify(data, null, 2) + '\n');
184
+ }
185
+ async function atomicWrite(filePath, content) {
186
+ const tmpPath = `${filePath}.tmp.${Date.now()}`;
187
+ await writeFile(tmpPath, content, 'utf8');
188
+ await rename(tmpPath, filePath);
189
+ }
@@ -17,6 +17,15 @@ export interface ProfileService {
17
17
  }): Promise<{
18
18
  profilePath: string;
19
19
  }>;
20
+ update(options: {
21
+ cwd?: string;
22
+ name: string;
23
+ config: ProfileConfig;
24
+ }): Promise<void>;
25
+ delete(options: {
26
+ cwd?: string;
27
+ name: string;
28
+ }): Promise<void>;
20
29
  use(options: {
21
30
  cwd?: string;
22
31
  name: string;
@@ -1,4 +1,4 @@
1
- import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
1
+ import { readdir, readFile, writeFile, mkdir, stat, unlink } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import YAML from 'yaml';
4
4
  import { ProfileError, ProfileNotFoundError } from '../errors.js';
@@ -64,6 +64,33 @@ export function createProfileService() {
64
64
  await writeFile(profilePath, YAML.stringify(scaffold), 'utf8');
65
65
  return { profilePath };
66
66
  },
67
+ async update(options) {
68
+ const cwd = options.cwd ?? process.cwd();
69
+ const profilePath = path.join(cwd, PROFILES_DIR, `${options.name}.yaml`);
70
+ if (!(await pathExists(profilePath))) {
71
+ throw new ProfileNotFoundError(`Profile "${options.name}" not found.`);
72
+ }
73
+ const data = {
74
+ name: options.config.name,
75
+ ...(options.config.description ? { description: options.config.description } : {}),
76
+ skills: options.config.skills,
77
+ mcps: options.config.mcps,
78
+ memory: options.config.memory,
79
+ };
80
+ await writeFile(profilePath, YAML.stringify(data), 'utf8');
81
+ },
82
+ async delete(options) {
83
+ const cwd = options.cwd ?? process.cwd();
84
+ const profilePath = path.join(cwd, PROFILES_DIR, `${options.name}.yaml`);
85
+ if (!(await pathExists(profilePath))) {
86
+ throw new ProfileNotFoundError(`Profile "${options.name}" not found.`);
87
+ }
88
+ const meta = await loadMetaConfig(cwd);
89
+ if (meta.active_profile === options.name) {
90
+ throw new ProfileError('Cannot delete the active profile.');
91
+ }
92
+ await unlink(profilePath);
93
+ },
67
94
  async use(options) {
68
95
  const cwd = options.cwd ?? process.cwd();
69
96
  // Validate profile exists
@@ -0,0 +1,25 @@
1
+ import type { AgentName } from '../../types.js';
2
+ export interface AgentMcpEntry {
3
+ command: string;
4
+ args?: string[];
5
+ env?: Record<string, string>;
6
+ }
7
+ export interface AgentSkillEntry {
8
+ name: string;
9
+ source?: string;
10
+ }
11
+ export interface AgentLiveConfig {
12
+ agent: AgentName;
13
+ configPath: string;
14
+ exists: boolean;
15
+ mcpServers: Record<string, AgentMcpEntry>;
16
+ skills: AgentSkillEntry[];
17
+ }
18
+ export interface AgentConfigReader {
19
+ read(options: {
20
+ cwd: string;
21
+ }): Promise<AgentLiveConfig>;
22
+ }
23
+ export declare function createClaudeReader(): AgentConfigReader;
24
+ export declare function createCodexReader(): AgentConfigReader;
25
+ export declare function createGeminiReader(): AgentConfigReader;
@@ -0,0 +1,221 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ export function createClaudeReader() {
5
+ return {
6
+ async read(options) {
7
+ const configPath = path.join(homedir(), '.claude.json');
8
+ try {
9
+ const source = await readFile(configPath, 'utf8');
10
+ const data = JSON.parse(source);
11
+ const projects = (data.projects ?? {});
12
+ const projectConfig = projects[options.cwd] ?? {};
13
+ const rawServers = (projectConfig.mcpServers ?? {});
14
+ const mcpServers = {};
15
+ for (const [name, entry] of Object.entries(rawServers)) {
16
+ mcpServers[name] = {
17
+ command: String(entry.command ?? ''),
18
+ args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
19
+ env: parseEnvObject(entry.env),
20
+ };
21
+ }
22
+ const skills = await readClaudePlugins();
23
+ return { agent: 'claude', configPath, exists: true, mcpServers, skills };
24
+ }
25
+ catch {
26
+ const skills = await readClaudePlugins();
27
+ return { agent: 'claude', configPath, exists: false, mcpServers: {}, skills };
28
+ }
29
+ },
30
+ };
31
+ }
32
+ export function createCodexReader() {
33
+ return {
34
+ async read() {
35
+ const configPath = path.join(homedir(), '.codex', 'config.toml');
36
+ try {
37
+ const source = await readFile(configPath, 'utf8');
38
+ const mcpServers = parseCodexToml(source);
39
+ const skills = await readCodexSkills();
40
+ return { agent: 'codex', configPath, exists: true, mcpServers, skills };
41
+ }
42
+ catch {
43
+ const skills = await readCodexSkills();
44
+ return { agent: 'codex', configPath, exists: false, mcpServers: {}, skills };
45
+ }
46
+ },
47
+ };
48
+ }
49
+ export function createGeminiReader() {
50
+ return {
51
+ async read() {
52
+ const configPath = path.join(homedir(), '.gemini', 'settings.json');
53
+ try {
54
+ const source = await readFile(configPath, 'utf8');
55
+ const data = JSON.parse(source);
56
+ const rawServers = (data.mcpServers ?? {});
57
+ const mcpServers = {};
58
+ for (const [name, entry] of Object.entries(rawServers)) {
59
+ mcpServers[name] = {
60
+ command: String(entry.command ?? ''),
61
+ args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
62
+ env: parseEnvObject(entry.env),
63
+ };
64
+ }
65
+ const skills = await readGeminiSkills();
66
+ return { agent: 'gemini', configPath, exists: true, mcpServers, skills };
67
+ }
68
+ catch {
69
+ const skills = await readGeminiSkills();
70
+ return { agent: 'gemini', configPath, exists: false, mcpServers: {}, skills };
71
+ }
72
+ },
73
+ };
74
+ }
75
+ function parseEnvObject(value) {
76
+ if (!value || typeof value !== 'object' || Array.isArray(value))
77
+ return undefined;
78
+ const result = {};
79
+ for (const [k, v] of Object.entries(value)) {
80
+ result[k] = String(v);
81
+ }
82
+ return Object.keys(result).length > 0 ? result : undefined;
83
+ }
84
+ function parseCodexToml(source) {
85
+ const servers = {};
86
+ const lines = source.split('\n');
87
+ let currentServer = null;
88
+ let inEnv = false;
89
+ let currentEntry = { command: '' };
90
+ let currentEnv = {};
91
+ for (const line of lines) {
92
+ const trimmed = line.trim();
93
+ // Match [mcp_servers.name.env]
94
+ const envMatch = trimmed.match(/^\[mcp_servers\.([^.]+)\.env\]$/);
95
+ if (envMatch) {
96
+ inEnv = true;
97
+ continue;
98
+ }
99
+ // Match [mcp_servers.name]
100
+ const serverMatch = trimmed.match(/^\[mcp_servers\.([^\].]+)\]$/);
101
+ if (serverMatch) {
102
+ // Save previous server
103
+ if (currentServer) {
104
+ if (Object.keys(currentEnv).length > 0)
105
+ currentEntry.env = currentEnv;
106
+ servers[currentServer] = currentEntry;
107
+ }
108
+ currentServer = serverMatch[1];
109
+ currentEntry = { command: '' };
110
+ currentEnv = {};
111
+ inEnv = false;
112
+ continue;
113
+ }
114
+ // New non-mcp section — flush current server
115
+ if (/^\[/.test(trimmed) && !/^\[mcp_servers/.test(trimmed)) {
116
+ if (currentServer) {
117
+ if (Object.keys(currentEnv).length > 0)
118
+ currentEntry.env = currentEnv;
119
+ servers[currentServer] = currentEntry;
120
+ currentServer = null;
121
+ currentEntry = { command: '' };
122
+ currentEnv = {};
123
+ }
124
+ inEnv = false;
125
+ continue;
126
+ }
127
+ // Key-value pair
128
+ const kvMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
129
+ if (!kvMatch || !currentServer)
130
+ continue;
131
+ const [, key, rawValue] = kvMatch;
132
+ if (inEnv) {
133
+ currentEnv[key] = parseTomlValue(rawValue);
134
+ }
135
+ else if (key === 'command') {
136
+ currentEntry.command = parseTomlValue(rawValue);
137
+ }
138
+ else if (key === 'args') {
139
+ currentEntry.args = parseTomlArray(rawValue);
140
+ }
141
+ }
142
+ // Flush last server
143
+ if (currentServer) {
144
+ if (Object.keys(currentEnv).length > 0)
145
+ currentEntry.env = currentEnv;
146
+ servers[currentServer] = currentEntry;
147
+ }
148
+ return servers;
149
+ }
150
+ function parseTomlValue(raw) {
151
+ const trimmed = raw.trim();
152
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
153
+ return trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
154
+ }
155
+ return trimmed;
156
+ }
157
+ function parseTomlArray(raw) {
158
+ const trimmed = raw.trim();
159
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']'))
160
+ return [];
161
+ const inner = trimmed.slice(1, -1);
162
+ const result = [];
163
+ const parts = inner.split(',');
164
+ for (const part of parts) {
165
+ const val = parseTomlValue(part.trim());
166
+ if (val.length > 0)
167
+ result.push(val);
168
+ }
169
+ return result;
170
+ }
171
+ /* ---- Skill readers ---- */
172
+ async function readClaudePlugins() {
173
+ try {
174
+ const pluginsPath = path.join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
175
+ const source = await readFile(pluginsPath, 'utf8');
176
+ const data = JSON.parse(source);
177
+ if (!data.plugins)
178
+ return [];
179
+ return Object.keys(data.plugins).map((key) => {
180
+ const [name, source] = key.split('@');
181
+ return { name, source };
182
+ });
183
+ }
184
+ catch {
185
+ return [];
186
+ }
187
+ }
188
+ async function readCodexSkills() {
189
+ try {
190
+ const skillsDir = path.join(homedir(), '.codex', 'skills');
191
+ return await readSkillDirs(skillsDir);
192
+ }
193
+ catch {
194
+ return [];
195
+ }
196
+ }
197
+ async function readGeminiSkills() {
198
+ try {
199
+ const skillsDir = path.join(homedir(), '.gemini', 'skills');
200
+ return await readSkillDirs(skillsDir);
201
+ }
202
+ catch {
203
+ return [];
204
+ }
205
+ }
206
+ /** Shared: read skill directories (Codex and Gemini use the same SKILL.md convention) */
207
+ async function readSkillDirs(skillsDir) {
208
+ const entries = await readdir(skillsDir, { withFileTypes: true });
209
+ const skills = [];
210
+ for (const entry of entries) {
211
+ if (entry.name.startsWith('.'))
212
+ continue;
213
+ if (entry.isDirectory()) {
214
+ skills.push({ name: entry.name, source: 'local' });
215
+ }
216
+ else if (entry.isSymbolicLink()) {
217
+ skills.push({ name: entry.name, source: 'linked' });
218
+ }
219
+ }
220
+ return skills;
221
+ }