brainctl 0.1.5 → 0.1.7

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/README.md +181 -131
  2. package/dist/executor/resolver.js +1 -38
  3. package/dist/mcp/server.js +183 -0
  4. package/dist/services/agent-config-service.d.ts +35 -0
  5. package/dist/services/agent-config-service.js +222 -0
  6. package/dist/services/mcp-preflight-service.d.ts +25 -0
  7. package/dist/services/mcp-preflight-service.js +84 -0
  8. package/dist/services/plugin-install-service.d.ts +92 -0
  9. package/dist/services/plugin-install-service.js +243 -0
  10. package/dist/services/profile-export-service.js +5 -5
  11. package/dist/services/profile-import-service.js +1 -1
  12. package/dist/services/profile-service.d.ts +10 -0
  13. package/dist/services/profile-service.js +140 -28
  14. package/dist/services/skill-paths.d.ts +2 -0
  15. package/dist/services/skill-paths.js +12 -0
  16. package/dist/services/skill-preflight-service.d.ts +23 -0
  17. package/dist/services/skill-preflight-service.js +40 -0
  18. package/dist/services/sync/agent-reader.d.ts +30 -0
  19. package/dist/services/sync/agent-reader.js +232 -0
  20. package/dist/services/sync/claude-writer.js +4 -1
  21. package/dist/services/sync/codex-writer.js +6 -2
  22. package/dist/services/sync/gemini-writer.js +4 -1
  23. package/dist/services/sync/managed-plugin-registry.d.ts +17 -0
  24. package/dist/services/sync/managed-plugin-registry.js +75 -0
  25. package/dist/services/sync/plugin-skill-reader.d.ts +2 -0
  26. package/dist/services/sync/plugin-skill-reader.js +33 -0
  27. package/dist/services/sync-service.js +5 -0
  28. package/dist/system/executables.d.ts +1 -0
  29. package/dist/system/executables.js +38 -0
  30. package/dist/types.d.ts +15 -5
  31. package/dist/ui/routes.js +423 -1
  32. package/dist/web/assets/index-BCkorugl.css +1 -0
  33. package/dist/web/assets/index-sGnTMhkX.js +16 -0
  34. package/dist/web/index.html +2 -2
  35. package/package.json +7 -1
  36. package/dist/web/assets/index-CRJ6cM0Q.css +0 -1
  37. package/dist/web/assets/index-Cr8gt3VF.js +0 -9
@@ -0,0 +1,35 @@
1
+ import type { AgentName } from '../types.js';
2
+ import { type AgentLiveConfig, type AgentMcpEntry } from './sync/agent-reader.js';
3
+ import { type McpPreflightService } from './mcp-preflight-service.js';
4
+ import { type SkillPreflightService } from './skill-preflight-service.js';
5
+ export type { AgentLiveConfig, AgentMcpEntry, AgentSkillEntry } from './sync/agent-reader.js';
6
+ export interface AgentConfigService {
7
+ readAll(options: {
8
+ cwd: string;
9
+ }): Promise<AgentLiveConfig[]>;
10
+ addMcp(options: {
11
+ cwd: string;
12
+ agent: AgentName;
13
+ key: string;
14
+ entry: AgentMcpEntry;
15
+ }): Promise<void>;
16
+ removeMcp(options: {
17
+ cwd: string;
18
+ agent: AgentName;
19
+ key: string;
20
+ }): Promise<void>;
21
+ copySkill(options: {
22
+ sourceAgent: AgentName;
23
+ targetAgent: AgentName;
24
+ skillName: string;
25
+ }): Promise<void>;
26
+ removeSkill(options: {
27
+ agent: AgentName;
28
+ skillName: string;
29
+ }): Promise<void>;
30
+ }
31
+ interface AgentConfigServiceDependencies {
32
+ mcpPreflightService?: McpPreflightService;
33
+ skillPreflightService?: SkillPreflightService;
34
+ }
35
+ export declare function createAgentConfigService(dependencies?: AgentConfigServiceDependencies): AgentConfigService;
@@ -0,0 +1,222 @@
1
+ import { copyFile, cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { ValidationError } from '../errors.js';
5
+ import { createClaudeReader, createCodexReader, createGeminiReader, } from './sync/agent-reader.js';
6
+ import { formatTimestamp } from './sync/agent-writer.js';
7
+ import { createMcpPreflightService } from './mcp-preflight-service.js';
8
+ import { createSkillPreflightService } from './skill-preflight-service.js';
9
+ import { getSkillDir } from './skill-paths.js';
10
+ const readers = {
11
+ claude: createClaudeReader(),
12
+ codex: createCodexReader(),
13
+ gemini: createGeminiReader(),
14
+ };
15
+ export function createAgentConfigService(dependencies = {}) {
16
+ const mcpPreflightService = dependencies.mcpPreflightService ?? createMcpPreflightService();
17
+ const skillPreflightService = dependencies.skillPreflightService ?? createSkillPreflightService();
18
+ return {
19
+ async readAll(options) {
20
+ const results = await Promise.all([
21
+ readers.claude.read(options),
22
+ readers.codex.read(options),
23
+ readers.gemini.read(options),
24
+ ]);
25
+ return results;
26
+ },
27
+ async addMcp(options) {
28
+ const { cwd, agent, key, entry } = options;
29
+ const preflight = await mcpPreflightService.execute({ cwd, agent, key, entry });
30
+ const firstError = preflight.checks.find((check) => check.status === 'error');
31
+ if (firstError) {
32
+ throw new ValidationError(`MCP "${key}" cannot be added to ${agent}: ${firstError.message}`);
33
+ }
34
+ if (agent === 'claude') {
35
+ await mutateClaudeConfig(cwd, (servers) => {
36
+ servers[key] = toClaudeEntry(entry);
37
+ });
38
+ }
39
+ else if (agent === 'codex') {
40
+ await mutateCodexConfig((servers) => {
41
+ servers[key] = entry;
42
+ });
43
+ }
44
+ else if (agent === 'gemini') {
45
+ await mutateGeminiConfig(cwd, (servers) => {
46
+ servers[key] = toGeminiEntry(entry);
47
+ });
48
+ }
49
+ },
50
+ async removeMcp(options) {
51
+ const { cwd, agent, key } = options;
52
+ if (agent === 'claude') {
53
+ await mutateClaudeConfig(cwd, (servers) => {
54
+ delete servers[key];
55
+ });
56
+ }
57
+ else if (agent === 'codex') {
58
+ await mutateCodexConfig((servers) => {
59
+ delete servers[key];
60
+ });
61
+ }
62
+ else if (agent === 'gemini') {
63
+ await mutateGeminiConfig(cwd, (servers) => {
64
+ delete servers[key];
65
+ });
66
+ }
67
+ },
68
+ async copySkill(options) {
69
+ const { sourceAgent, targetAgent, skillName } = options;
70
+ const preflight = await skillPreflightService.execute({
71
+ sourceAgent,
72
+ targetAgent,
73
+ skillName,
74
+ source: 'local',
75
+ });
76
+ const firstError = preflight.checks.find((check) => check.status === 'error');
77
+ if (firstError) {
78
+ throw new ValidationError(`Skill "${skillName}" cannot be copied from ${sourceAgent} to ${targetAgent}: ${firstError.message}`);
79
+ }
80
+ const sourceDir = getSkillDir(sourceAgent, skillName);
81
+ const targetDir = getSkillDir(targetAgent, skillName);
82
+ await mkdir(path.dirname(targetDir), { recursive: true });
83
+ await cp(sourceDir, targetDir, { recursive: true });
84
+ },
85
+ async removeSkill(options) {
86
+ const { agent, skillName } = options;
87
+ const skillDir = getSkillDir(agent, skillName);
88
+ await rm(skillDir, { recursive: true, force: true });
89
+ },
90
+ };
91
+ }
92
+ /* ---- Claude: JSON with projects[cwd].mcpServers ---- */
93
+ async function mutateClaudeConfig(cwd, mutate) {
94
+ const configPath = path.join(homedir(), '.claude.json');
95
+ let existing = {};
96
+ try {
97
+ existing = JSON.parse(await readFile(configPath, 'utf8'));
98
+ await backupFile(configPath);
99
+ }
100
+ catch {
101
+ // fresh config
102
+ }
103
+ const projects = (existing.projects ?? {});
104
+ const projectConfig = projects[cwd] ?? {};
105
+ const servers = (projectConfig.mcpServers ?? {});
106
+ mutate(servers);
107
+ projectConfig.mcpServers = servers;
108
+ projects[cwd] = projectConfig;
109
+ existing.projects = projects;
110
+ await atomicWriteJson(configPath, existing);
111
+ }
112
+ function toClaudeEntry(entry) {
113
+ return {
114
+ type: 'stdio',
115
+ command: entry.command,
116
+ args: entry.args ?? [],
117
+ ...(entry.env ? { env: entry.env } : {}),
118
+ };
119
+ }
120
+ /* ---- Codex: TOML with [mcp_servers.*] ---- */
121
+ async function mutateCodexConfig(mutate) {
122
+ const configPath = path.join(homedir(), '.codex', 'config.toml');
123
+ let existingContent = '';
124
+ try {
125
+ existingContent = await readFile(configPath, 'utf8');
126
+ await backupFile(configPath);
127
+ }
128
+ catch {
129
+ // fresh config
130
+ }
131
+ // Read current servers via reader
132
+ const current = await readers.codex.read({ cwd: '' });
133
+ const servers = { ...current.mcpServers };
134
+ mutate(servers);
135
+ // Rebuild: preserve non-mcp content + new mcp sections
136
+ const nonMcp = stripCodexMcpSections(existingContent).trim();
137
+ const mcpToml = buildCodexMcpToml(servers);
138
+ const final = nonMcp.length > 0 ? `${nonMcp}\n\n${mcpToml}` : mcpToml;
139
+ await mkdir(path.dirname(configPath), { recursive: true });
140
+ await atomicWrite(configPath, final + '\n');
141
+ }
142
+ function stripCodexMcpSections(content) {
143
+ const lines = content.split('\n');
144
+ const result = [];
145
+ let inMcp = false;
146
+ for (const line of lines) {
147
+ if (/^\[mcp_servers[\].]/.test(line)) {
148
+ inMcp = true;
149
+ continue;
150
+ }
151
+ if (inMcp && /^\[/.test(line) && !/^\[mcp_servers[\].]/.test(line)) {
152
+ inMcp = false;
153
+ }
154
+ if (!inMcp)
155
+ result.push(line);
156
+ }
157
+ return result.join('\n');
158
+ }
159
+ function buildCodexMcpToml(servers) {
160
+ const lines = [];
161
+ for (const [name, entry] of Object.entries(servers)) {
162
+ lines.push(`[mcp_servers.${name}]`);
163
+ lines.push(`command = ${tomlStr(entry.command)}`);
164
+ if (entry.args && entry.args.length > 0) {
165
+ lines.push(`args = [${entry.args.map(tomlStr).join(', ')}]`);
166
+ }
167
+ if (entry.env && Object.keys(entry.env).length > 0) {
168
+ lines.push('');
169
+ lines.push(`[mcp_servers.${name}.env]`);
170
+ for (const [k, v] of Object.entries(entry.env)) {
171
+ lines.push(`${k} = ${tomlStr(v)}`);
172
+ }
173
+ }
174
+ lines.push('');
175
+ }
176
+ return lines.join('\n').trim();
177
+ }
178
+ function tomlStr(value) {
179
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
180
+ }
181
+ /* ---- Gemini: JSON with mcpServers ---- */
182
+ async function mutateGeminiConfig(_cwd, mutate) {
183
+ const configPath = path.join(homedir(), '.gemini', 'settings.json');
184
+ let existing = {};
185
+ try {
186
+ existing = JSON.parse(await readFile(configPath, 'utf8'));
187
+ await backupFile(configPath);
188
+ }
189
+ catch {
190
+ // fresh config
191
+ }
192
+ const servers = (existing.mcpServers ?? {});
193
+ mutate(servers);
194
+ existing.mcpServers = servers;
195
+ await mkdir(path.dirname(configPath), { recursive: true });
196
+ await atomicWriteJson(configPath, existing);
197
+ }
198
+ function toGeminiEntry(entry) {
199
+ return {
200
+ command: entry.command,
201
+ args: entry.args ?? [],
202
+ ...(entry.env ? { env: entry.env } : {}),
203
+ };
204
+ }
205
+ /* ---- Shared helpers ---- */
206
+ async function backupFile(filePath) {
207
+ const backupPath = `${filePath}.bak.${formatTimestamp()}`;
208
+ try {
209
+ await copyFile(filePath, backupPath);
210
+ }
211
+ catch {
212
+ // File may not exist yet
213
+ }
214
+ }
215
+ async function atomicWriteJson(filePath, data) {
216
+ await atomicWrite(filePath, JSON.stringify(data, null, 2) + '\n');
217
+ }
218
+ async function atomicWrite(filePath, content) {
219
+ const tmpPath = `${filePath}.tmp.${Date.now()}`;
220
+ await writeFile(tmpPath, content, 'utf8');
221
+ await rename(tmpPath, filePath);
222
+ }
@@ -0,0 +1,25 @@
1
+ import type { AgentName } from '../types.js';
2
+ import type { AgentMcpEntry } from './agent-config-service.js';
3
+ export interface McpPreflightCheck {
4
+ label: string;
5
+ status: 'ok' | 'warn' | 'error';
6
+ message: string;
7
+ }
8
+ export interface McpPreflightResult {
9
+ ok: boolean;
10
+ checks: McpPreflightCheck[];
11
+ }
12
+ export interface McpPreflightService {
13
+ execute(options: {
14
+ cwd: string;
15
+ agent: AgentName;
16
+ key: string;
17
+ entry: AgentMcpEntry;
18
+ }): Promise<McpPreflightResult>;
19
+ }
20
+ interface McpPreflightDependencies {
21
+ resolveExecutable?: (command: string) => Promise<string | null>;
22
+ pathExists?: (targetPath: string) => Promise<boolean>;
23
+ }
24
+ export declare function createMcpPreflightService(dependencies?: McpPreflightDependencies): McpPreflightService;
25
+ export {};
@@ -0,0 +1,84 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { findExecutable } from '../system/executables.js';
4
+ const SCRIPT_RUNNERS = new Set(['node', 'nodejs', 'python', 'python3', 'bash', 'sh', 'zsh', 'deno', 'bun']);
5
+ export function createMcpPreflightService(dependencies = {}) {
6
+ const resolveExecutable = dependencies.resolveExecutable ?? findExecutable;
7
+ const pathExists = dependencies.pathExists ?? defaultPathExists;
8
+ return {
9
+ async execute(options) {
10
+ const checks = [];
11
+ const resolvedCommand = await resolveExecutable(options.entry.command);
12
+ if (!resolvedCommand) {
13
+ checks.push({
14
+ label: 'Command',
15
+ status: 'error',
16
+ message: `Command "${options.entry.command}" is not available on PATH.`,
17
+ });
18
+ return { ok: false, checks };
19
+ }
20
+ checks.push({
21
+ label: 'Command',
22
+ status: 'ok',
23
+ message: `Command "${options.entry.command}" resolved to ${resolvedCommand}.`,
24
+ });
25
+ if (options.entry.command === 'npx') {
26
+ const nonFlagArg = options.entry.args?.find((arg) => !arg.startsWith('-'));
27
+ if (!nonFlagArg) {
28
+ checks.push({
29
+ label: 'Package',
30
+ status: 'error',
31
+ message: 'npx-based MCP entries must include a package or executable argument.',
32
+ });
33
+ }
34
+ else {
35
+ checks.push({
36
+ label: 'Package',
37
+ status: 'ok',
38
+ message: `npx will attempt to launch ${nonFlagArg}.`,
39
+ });
40
+ }
41
+ }
42
+ const entrypointPath = resolveEntrypointPath(options.cwd, options.entry);
43
+ if (entrypointPath) {
44
+ const exists = await pathExists(entrypointPath);
45
+ checks.push({
46
+ label: 'Entrypoint',
47
+ status: exists ? 'ok' : 'error',
48
+ message: exists
49
+ ? `Entrypoint script was found: ${entrypointPath}`
50
+ : `Entrypoint script was not found: ${entrypointPath}`,
51
+ });
52
+ }
53
+ return {
54
+ ok: checks.every((check) => check.status !== 'error'),
55
+ checks,
56
+ };
57
+ },
58
+ };
59
+ }
60
+ function resolveEntrypointPath(cwd, entry) {
61
+ if (!SCRIPT_RUNNERS.has(entry.command)) {
62
+ return null;
63
+ }
64
+ const firstArg = entry.args?.[0];
65
+ if (!firstArg || !looksLikeLocalPath(firstArg)) {
66
+ return null;
67
+ }
68
+ return path.isAbsolute(firstArg) ? firstArg : path.resolve(cwd, firstArg);
69
+ }
70
+ function looksLikeLocalPath(value) {
71
+ return (value.startsWith('.') ||
72
+ value.startsWith('/') ||
73
+ value.includes(path.sep) ||
74
+ /\.(cjs|cts|js|json|jsx|mjs|mts|py|sh|ts|tsx)$/i.test(value));
75
+ }
76
+ async function defaultPathExists(targetPath) {
77
+ try {
78
+ await stat(targetPath);
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
@@ -0,0 +1,92 @@
1
+ import type { AgentName } from '../types.js';
2
+ import type { AgentLiveConfig, AgentMcpEntry, AgentSkillEntry } from './agent-config-service.js';
3
+ export interface PluginInstallCheck {
4
+ label: string;
5
+ status: 'ok' | 'warn' | 'error';
6
+ message: string;
7
+ }
8
+ export interface PluginInstallPlan {
9
+ ok: boolean;
10
+ checks: PluginInstallCheck[];
11
+ skills: string[];
12
+ mcps: Record<string, AgentMcpEntry>;
13
+ }
14
+ export interface PluginInstallResult {
15
+ installedSkills: string[];
16
+ installedMcps: string[];
17
+ }
18
+ export interface PluginUninstallPlan {
19
+ ok: boolean;
20
+ checks: PluginInstallCheck[];
21
+ skills: string[];
22
+ mcps: string[];
23
+ }
24
+ export interface PluginUninstallResult {
25
+ removedSkills: string[];
26
+ removedMcps: string[];
27
+ }
28
+ export interface PluginInstallService {
29
+ plan(options: {
30
+ cwd: string;
31
+ targetAgent: AgentName;
32
+ sourceAgent: AgentName;
33
+ plugin: AgentSkillEntry;
34
+ }): Promise<PluginInstallPlan>;
35
+ execute(options: {
36
+ cwd: string;
37
+ targetAgent: AgentName;
38
+ sourceAgent: AgentName;
39
+ plugin: AgentSkillEntry;
40
+ }): Promise<PluginInstallResult>;
41
+ planRemoval(options: {
42
+ cwd: string;
43
+ targetAgent: AgentName;
44
+ plugin: AgentSkillEntry;
45
+ }): Promise<PluginUninstallPlan>;
46
+ remove(options: {
47
+ cwd: string;
48
+ targetAgent: AgentName;
49
+ plugin: AgentSkillEntry;
50
+ }): Promise<PluginUninstallResult>;
51
+ }
52
+ interface PluginBundle {
53
+ skills: string[];
54
+ mcps: Record<string, AgentMcpEntry>;
55
+ }
56
+ interface PluginInstallDependencies {
57
+ readInstalledPluginBundle?: (installPath: string) => Promise<PluginBundle>;
58
+ readTargetState?: (options: {
59
+ cwd: string;
60
+ agent: AgentName;
61
+ }) => Promise<Pick<AgentLiveConfig, 'skills' | 'mcpServers'>>;
62
+ copySkillDirectory?: (options: {
63
+ sourceInstallPath: string;
64
+ skillName: string;
65
+ targetAgent: AgentName;
66
+ }) => Promise<void>;
67
+ addMcpEntry?: (options: {
68
+ cwd: string;
69
+ agent: AgentName;
70
+ key: string;
71
+ entry: AgentMcpEntry;
72
+ }) => Promise<void>;
73
+ recordManagedPluginInstall?: (options: {
74
+ agent: AgentName;
75
+ plugin: AgentSkillEntry;
76
+ }) => Promise<void>;
77
+ removeSkillDirectory?: (options: {
78
+ targetAgent: AgentName;
79
+ skillName: string;
80
+ }) => Promise<void>;
81
+ removeMcpEntry?: (options: {
82
+ cwd: string;
83
+ agent: AgentName;
84
+ key: string;
85
+ }) => Promise<void>;
86
+ removeManagedPluginInstall?: (options: {
87
+ agent: AgentName;
88
+ pluginName: string;
89
+ }) => Promise<void>;
90
+ }
91
+ export declare function createPluginInstallService(dependencies?: PluginInstallDependencies): PluginInstallService;
92
+ export {};