brainctl 0.1.19 → 0.1.20

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.
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawnSync } from 'node:child_process';
2
2
  import { startMcpServer } from '../mcp-server.js';
3
3
  import { createUpdateCheckService } from '../services/platform/update-check-service.js';
4
4
  export function registerMcpCommand(program) {
@@ -6,39 +6,52 @@ export function registerMcpCommand(program) {
6
6
  .command('mcp')
7
7
  .description('Start the brainctl MCP server (stdio transport)')
8
8
  .action(async () => {
9
+ killPriorMcpServers();
10
+ process.stdin.on('end', () => process.exit(0));
11
+ process.stdin.on('close', () => process.exit(0));
12
+ await startMcpServer({ cwd: process.cwd() });
9
13
  if (!process.env.BRAINCTL_NO_UPDATE_CHECK) {
10
- await autoUpdateIfNeeded();
14
+ // Fire-and-forget after the server is up so cold-start isn't blocked
15
+ // on a network round-trip (or `npm install brainctl@latest`).
16
+ void notifyIfOutdated();
11
17
  }
12
- await startMcpServer({ cwd: process.cwd() });
13
18
  });
14
19
  }
15
- async function autoUpdateIfNeeded() {
20
+ function killPriorMcpServers() {
21
+ const self = process.pid;
22
+ const ppid = process.ppid;
16
23
  try {
17
- const service = createUpdateCheckService();
18
- const check = await service.check();
19
- if (!check.isOutdated)
20
- return;
21
- const result = await service.selfUpdate();
22
- if (result.success) {
23
- // Re-exec with the updated binary
24
- const child = spawn(process.execPath, process.argv.slice(1), {
25
- stdio: 'inherit',
26
- });
27
- await new Promise((resolve) => {
28
- child.on('exit', (code) => {
29
- process.exit(code ?? 0);
30
- });
31
- child.on('error', () => {
32
- resolve(); // fall through to current version
33
- });
34
- });
24
+ const result = spawnSync('pgrep', ['-f', 'brainctl/dist/cli\\.js mcp'], {
25
+ encoding: 'utf8',
26
+ });
27
+ if (result.status !== 0 || !result.stdout)
35
28
  return;
29
+ const pids = result.stdout
30
+ .split('\n')
31
+ .map((line) => Number.parseInt(line.trim(), 10))
32
+ .filter((pid) => Number.isFinite(pid) && pid !== self && pid !== ppid);
33
+ for (const pid of pids) {
34
+ try {
35
+ process.kill(pid, 'SIGTERM');
36
+ }
37
+ catch {
38
+ // already gone
39
+ }
36
40
  }
37
- if (result.error) {
38
- process.stderr.write(`brainctl: auto-update failed: ${result.error}\n`);
41
+ }
42
+ catch {
43
+ // pgrep unavailable — skip
44
+ }
45
+ }
46
+ async function notifyIfOutdated() {
47
+ try {
48
+ const service = createUpdateCheckService();
49
+ const check = await service.check();
50
+ if (check.isOutdated) {
51
+ process.stderr.write(`brainctl: a newer version is available (${check.latest}). Run \`npm i -g brainctl@latest\` to update.\n`);
39
52
  }
40
53
  }
41
54
  catch {
42
- // Update check failed entirely continue silently
55
+ // Update check failed — stay silent
43
56
  }
44
57
  }
@@ -1,3 +1,3 @@
1
- import type { PortablePluginSnapshot, PortableUserSkillSnapshot } from '../../types.js';
2
- export declare function installPlugin(sourceDir: string, plugin: PortablePluginSnapshot): Promise<void>;
3
- export declare function installUserSkill(sourceDir: string, skill: PortableUserSkillSnapshot): Promise<void>;
1
+ import type { AgentName, PortablePluginSnapshot, PortableUserSkillSnapshot } from '../../types.js';
2
+ export declare function installPlugin(sourceDir: string, plugin: PortablePluginSnapshot, targetAgent?: AgentName): Promise<void>;
3
+ export declare function installUserSkill(sourceDir: string, skill: PortableUserSkillSnapshot, targetAgent?: PortableUserSkillSnapshot['agent']): Promise<void>;
@@ -2,14 +2,22 @@ import { copyFile, cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node
2
2
  import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { ProfileError } from '../../errors.js';
5
+ import { defaultReadInstalledPluginBundle, isAgentInstallableOnTarget, isCommandInstallableOnTarget, } from '../plugin/plugin-install-bundle.js';
6
+ import { defaultCopySkillDirectory, defaultInstallAgent, defaultInstallCommand, } from '../plugin/plugin-install-fs.js';
7
+ import { writeManagedPluginInstall } from '../sync/managed-plugin-registry.js';
5
8
  import { formatTimestamp } from '../sync/agent-writer.js';
6
- export async function installPlugin(sourceDir, plugin) {
9
+ export async function installPlugin(sourceDir, plugin, targetAgent) {
7
10
  try {
8
11
  await stat(sourceDir);
9
12
  }
10
13
  catch {
11
14
  throw new ProfileError(`Bundled plugin "${plugin.name}" source missing at ${sourceDir}.`);
12
15
  }
16
+ const target = targetAgent ?? plugin.agent;
17
+ if (target !== plugin.agent) {
18
+ await installPluginCrossAgent(sourceDir, plugin, target);
19
+ return;
20
+ }
13
21
  if (plugin.agent === 'gemini') {
14
22
  return;
15
23
  }
@@ -34,14 +42,52 @@ export async function installPlugin(sourceDir, plugin) {
34
42
  });
35
43
  }
36
44
  }
37
- export async function installUserSkill(sourceDir, skill) {
45
+ async function installPluginCrossAgent(sourceDir, plugin, targetAgent) {
46
+ const bundle = await defaultReadInstalledPluginBundle(sourceDir);
47
+ for (const skillName of bundle.skills) {
48
+ await defaultCopySkillDirectory({
49
+ sourceInstallPath: sourceDir,
50
+ skillName,
51
+ targetAgent,
52
+ });
53
+ }
54
+ const installedAgents = [];
55
+ if (isAgentInstallableOnTarget(targetAgent)) {
56
+ for (const agent of bundle.agents) {
57
+ await defaultInstallAgent({ targetAgent, agent });
58
+ installedAgents.push(agent.name);
59
+ }
60
+ }
61
+ const installedCommands = [];
62
+ if (isCommandInstallableOnTarget(targetAgent)) {
63
+ for (const command of bundle.commands) {
64
+ await defaultInstallCommand({ targetAgent, command });
65
+ installedCommands.push(command.name);
66
+ }
67
+ }
68
+ await writeManagedPluginInstall({
69
+ agent: targetAgent,
70
+ plugin: {
71
+ name: plugin.name,
72
+ kind: 'plugin',
73
+ managed: true,
74
+ source: plugin.source,
75
+ pluginSkills: bundle.skills,
76
+ pluginMcps: Object.keys(bundle.mcps),
77
+ pluginAgents: installedAgents,
78
+ pluginCommands: installedCommands,
79
+ },
80
+ });
81
+ }
82
+ export async function installUserSkill(sourceDir, skill, targetAgent) {
38
83
  try {
39
84
  await stat(sourceDir);
40
85
  }
41
86
  catch {
42
87
  throw new ProfileError(`Bundled user skill "${skill.name}" source missing at ${sourceDir}.`);
43
88
  }
44
- const targetDir = path.join(homedir(), `.${skill.agent}`, 'skills', skill.name);
89
+ const agent = targetAgent ?? skill.agent;
90
+ const targetDir = path.join(homedir(), `.${agent}`, 'skills', skill.name);
45
91
  await rm(targetDir, { recursive: true, force: true });
46
92
  await mkdir(path.dirname(targetDir), { recursive: true });
47
93
  await cp(sourceDir, targetDir, { recursive: true });
@@ -205,8 +205,8 @@ function collectAgentExtras(agentConfig) {
205
205
  const source = skill.source;
206
206
  if (!installPath || !source)
207
207
  continue;
208
- const safeName = sanitizePackName(`${skill.name}--${source}`);
209
- const archivePath = `plugins/${agentConfig.agent}/${safeName}`;
208
+ const safeName = sanitizePackName(skill.name);
209
+ const archivePath = `plugins/${safeName}`;
210
210
  bundledPlugins.set(archivePath, installPath);
211
211
  plugins.push({
212
212
  agent: agentConfig.agent,
@@ -225,7 +225,7 @@ function collectAgentExtras(agentConfig) {
225
225
  }
226
226
  if (skill.kind === 'skill' && skill.source === 'local') {
227
227
  const localSkillDir = path.join(homedir(), `.${agentConfig.agent}`, 'skills', skill.name);
228
- const archivePath = `skills/${agentConfig.agent}/${skill.name}`;
228
+ const archivePath = `skills/${sanitizePackName(skill.name)}`;
229
229
  bundledUserSkills.set(archivePath, localSkillDir);
230
230
  userSkills.push({
231
231
  agent: agentConfig.agent,
@@ -285,7 +285,7 @@ async function redactAndNormalizeProfile(profile, cwd, source, extras) {
285
285
  const hasExtras = extras !== undefined && (extras.plugins.length > 0 || extras.userSkills.length > 0);
286
286
  return {
287
287
  manifest: {
288
- schemaVersion: hasExtras ? 2 : 1,
288
+ schemaVersion: hasExtras ? 3 : 1,
289
289
  profileName: profile.name,
290
290
  createdBy: {
291
291
  tool: 'brainctl',
@@ -59,15 +59,32 @@ export function createProfileApplyService(deps = {}) {
59
59
  mcpResult = { configPath: '', backedUpTo: null };
60
60
  }
61
61
  const pluginsInstalled = [];
62
- for (const plugin of (manifest?.plugins ?? []).filter((p) => p.agent === agent && wantPlugin(p.name))) {
62
+ const pluginsByName = new Map();
63
+ for (const p of manifest?.plugins ?? []) {
64
+ if (!wantPlugin(p.name))
65
+ continue;
66
+ const existing = pluginsByName.get(p.name);
67
+ if (!existing || p.agent === agent)
68
+ pluginsByName.set(p.name, p);
69
+ }
70
+ for (const plugin of pluginsByName.values()) {
63
71
  const sourceDir = path.join(folder, plugin.archivePath);
64
- await installPlugin(sourceDir, plugin);
72
+ await installPlugin(sourceDir, plugin, agent);
65
73
  pluginsInstalled.push(plugin.name);
66
74
  }
67
75
  const userSkillsInstalled = [];
68
- for (const skill of (manifest?.userSkills ?? []).filter((s) => s.agent === agent && wantSkill(s.name))) {
76
+ const skillsByName = new Map();
77
+ for (const s of manifest?.userSkills ?? []) {
78
+ if (!wantSkill(s.name))
79
+ continue;
80
+ if (!skillsByName.has(s.name))
81
+ skillsByName.set(s.name, s);
82
+ else if (s.agent === agent)
83
+ skillsByName.set(s.name, s); // prefer matching source if available
84
+ }
85
+ for (const skill of skillsByName.values()) {
69
86
  const sourceDir = path.join(folder, skill.archivePath);
70
- await installUserSkill(sourceDir, skill);
87
+ await installUserSkill(sourceDir, skill, agent);
71
88
  userSkillsInstalled.push(skill.name);
72
89
  }
73
90
  applied.push({
@@ -268,7 +268,7 @@ async function readPortableManifest(extractDir) {
268
268
  throw new ProfileError('Portable profile manifest has invalid structure.');
269
269
  }
270
270
  const manifest = parsed;
271
- if (manifest.schemaVersion !== 1 && manifest.schemaVersion !== 2) {
271
+ if (manifest.schemaVersion !== 1 && manifest.schemaVersion !== 2 && manifest.schemaVersion !== 3) {
272
272
  throw new ProfileError(`Unsupported portable profile schema version: ${String(manifest.schemaVersion)}.`);
273
273
  }
274
274
  if (typeof manifest.profileName !== 'string' || manifest.profileName.trim().length === 0) {
@@ -23,6 +23,11 @@ export interface ProfileService {
23
23
  name: string;
24
24
  config: ProfileConfig;
25
25
  }): Promise<void>;
26
+ rename(options: {
27
+ cwd?: string;
28
+ oldName: string;
29
+ newName: string;
30
+ }): Promise<void>;
26
31
  delete(options: {
27
32
  cwd?: string;
28
33
  name: string;
@@ -25,6 +25,7 @@ async function migrateLegacyProfile(cwd, name) {
25
25
  await mkdir(folder, { recursive: true });
26
26
  await rename(legacy, newFile);
27
27
  }
28
+ const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
28
29
  export function createProfileService() {
29
30
  return {
30
31
  async list(options = {}) {
@@ -68,14 +69,18 @@ export function createProfileService() {
68
69
  },
69
70
  async create(options) {
70
71
  const cwd = options.cwd ?? process.cwd();
71
- const folder = profileDir(cwd, options.name);
72
- const filePath = profileFile(cwd, options.name);
72
+ const trimmed = options.name.trim();
73
+ if (!PROFILE_NAME_PATTERN.test(trimmed)) {
74
+ throw new ProfileError(`Invalid profile name "${trimmed}". Use letters, numbers, ".", "_", or "-".`);
75
+ }
76
+ const folder = profileDir(cwd, trimmed);
77
+ const filePath = profileFile(cwd, trimmed);
73
78
  if ((await pathExists(filePath)) ||
74
- (await pathExists(legacyProfileFile(cwd, options.name)))) {
75
- throw new ProfileError(`Profile "${options.name}" already exists.`);
79
+ (await pathExists(legacyProfileFile(cwd, trimmed)))) {
80
+ throw new ProfileError(`Profile "${trimmed}" already exists.`);
76
81
  }
77
82
  const scaffold = {
78
- name: options.name,
83
+ name: trimmed,
79
84
  description: options.description ?? '',
80
85
  mcps: {},
81
86
  };
@@ -98,6 +103,44 @@ export function createProfileService() {
98
103
  };
99
104
  await writeFile(filePath, YAML.stringify(data), 'utf8');
100
105
  },
106
+ async rename(options) {
107
+ const cwd = options.cwd ?? process.cwd();
108
+ const trimmedNew = options.newName.trim();
109
+ if (!PROFILE_NAME_PATTERN.test(trimmedNew)) {
110
+ throw new ProfileError(`Invalid profile name "${trimmedNew}". Use letters, numbers, ".", "_", or "-".`);
111
+ }
112
+ if (trimmedNew === options.oldName)
113
+ return;
114
+ await migrateLegacyProfile(cwd, options.oldName);
115
+ const oldFolder = profileDir(cwd, options.oldName);
116
+ const oldFile = profileFile(cwd, options.oldName);
117
+ const newFolder = profileDir(cwd, trimmedNew);
118
+ const newFile = profileFile(cwd, trimmedNew);
119
+ if (!(await pathExists(oldFile))) {
120
+ throw new ProfileNotFoundError(`Profile "${options.oldName}" not found.`);
121
+ }
122
+ if ((await pathExists(newFolder)) ||
123
+ (await pathExists(legacyProfileFile(cwd, trimmedNew)))) {
124
+ throw new ProfileError(`Profile "${trimmedNew}" already exists.`);
125
+ }
126
+ await rename(oldFolder, newFolder);
127
+ const profileSource = await readFile(newFile, 'utf8');
128
+ const parsed = YAML.parse(profileSource) ?? {};
129
+ parsed.name = trimmedNew;
130
+ await writeFile(newFile, YAML.stringify(parsed), 'utf8');
131
+ const manifestPath = path.join(newFolder, 'manifest.yaml');
132
+ if (await pathExists(manifestPath)) {
133
+ try {
134
+ const manifestSource = await readFile(manifestPath, 'utf8');
135
+ const manifest = YAML.parse(manifestSource) ?? {};
136
+ manifest.profileName = trimmedNew;
137
+ await writeFile(manifestPath, YAML.stringify(manifest), 'utf8');
138
+ }
139
+ catch {
140
+ // Manifest is best-effort; pack-time will rewrite it on next export.
141
+ }
142
+ }
143
+ },
101
144
  async delete(options) {
102
145
  const cwd = options.cwd ?? process.cwd();
103
146
  await migrateLegacyProfile(cwd, options.name);
package/dist/types.d.ts CHANGED
@@ -39,7 +39,7 @@ export interface PortableUserSkillSnapshot {
39
39
  archivePath: string;
40
40
  }
41
41
  export interface PortableProfileManifest {
42
- schemaVersion: 1 | 2;
42
+ schemaVersion: 1 | 2 | 3;
43
43
  profileName: string;
44
44
  createdBy?: {
45
45
  tool: string;
package/dist/ui/routes.js CHANGED
@@ -13,6 +13,7 @@ import { createProfileApplyService, } from '../services/profile/profile-apply-se
13
13
  import { createProfileSnapshotService, defaultBackupProfileName, } from '../services/profile/profile-snapshot-service.js';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { spawn } from 'node:child_process';
16
17
  const uiAssetRoot = resolveUiAssetRoot();
17
18
  export function createUiRouteHandler(dependencies) {
18
19
  const statusService = dependencies.statusService ?? createStatusService();
@@ -162,6 +163,32 @@ export function createUiRouteHandler(dependencies) {
162
163
  }
163
164
  }
164
165
  default: {
166
+ // Open profile folder in OS file explorer: POST /api/profiles/:name/open-folder
167
+ const openFolderMatch = url.pathname.match(/^\/api\/profiles\/([^/]+)\/open-folder$/);
168
+ if (openFolderMatch) {
169
+ if (request.method !== 'POST') {
170
+ return sendJson(response, 405, { error: 'Method not allowed' });
171
+ }
172
+ const profileName = decodeURIComponent(openFolderMatch[1]);
173
+ const folderPath = path.join(dependencies.cwd, '.brainctl', 'profiles', profileName);
174
+ if (!existsSync(folderPath)) {
175
+ return sendJson(response, 404, { error: `Profile folder not found: ${folderPath}` });
176
+ }
177
+ const opener = process.platform === 'darwin'
178
+ ? 'open'
179
+ : process.platform === 'win32'
180
+ ? 'explorer'
181
+ : 'xdg-open';
182
+ try {
183
+ spawn(opener, [folderPath], { detached: true, stdio: 'ignore' }).unref();
184
+ return sendJson(response, 200, { ok: true, path: folderPath });
185
+ }
186
+ catch (error) {
187
+ return sendJson(response, 500, {
188
+ error: error instanceof Error ? error.message : 'Failed to open folder',
189
+ });
190
+ }
191
+ }
165
192
  // Profile apply: POST /api/profiles/:name/apply
166
193
  const applyMatch = url.pathname.match(/^\/api\/profiles\/([^/]+)\/apply$/);
167
194
  if (applyMatch) {
@@ -441,6 +468,32 @@ export function createUiRouteHandler(dependencies) {
441
468
  return sendProfileError(response, error);
442
469
  }
443
470
  }
471
+ const profileRenameMatch = url.pathname.match(/^\/api\/profiles\/([^/]+)\/rename$/);
472
+ if (profileRenameMatch) {
473
+ const oldName = decodeURIComponent(profileRenameMatch[1]);
474
+ if (request.method !== 'POST') {
475
+ return sendJson(response, 405, { error: 'Method not allowed' });
476
+ }
477
+ const body = await readJsonBody(request);
478
+ if (!body.ok) {
479
+ return sendJson(response, 400, { error: 'Invalid JSON body' });
480
+ }
481
+ const data = body.value;
482
+ if (typeof data.newName !== 'string' || data.newName.trim().length === 0) {
483
+ return sendJson(response, 400, { error: 'Missing newName' });
484
+ }
485
+ try {
486
+ await profileService.rename({
487
+ cwd: dependencies.cwd,
488
+ oldName,
489
+ newName: data.newName.trim(),
490
+ });
491
+ return sendJson(response, 200, { ok: true, name: data.newName.trim() });
492
+ }
493
+ catch (error) {
494
+ return sendProfileError(response, error);
495
+ }
496
+ }
444
497
  const profileMatch = url.pathname.match(/^\/api\/profiles\/([^/]+)$/);
445
498
  if (profileMatch) {
446
499
  const name = decodeURIComponent(profileMatch[1]);