brainctl 0.1.23 → 0.1.26

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,16 @@
1
+ interface FsLike {
2
+ readFile(p: string): Promise<string>;
3
+ writeFile(p: string, content: string): Promise<void>;
4
+ mkdir(p: string, opts?: {
5
+ recursive: boolean;
6
+ }): Promise<unknown>;
7
+ }
8
+ export interface RecentProjectsService {
9
+ read(): Promise<string[]>;
10
+ addRecent(cwd: string): Promise<string[]>;
11
+ }
12
+ export declare function createRecentProjectsService(deps: {
13
+ filePath: string;
14
+ fs?: FsLike;
15
+ }): RecentProjectsService;
16
+ export {};
@@ -0,0 +1,51 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const MAX_RECENTS = 20;
4
+ const FILE_VERSION = 1;
5
+ const defaultFs = {
6
+ readFile: (p) => readFile(p, 'utf8'),
7
+ writeFile: (p, content) => writeFile(p, content, 'utf8'),
8
+ mkdir: (p, opts) => mkdir(p, opts),
9
+ };
10
+ export function createRecentProjectsService(deps) {
11
+ const { filePath, fs: fsImpl = defaultFs } = deps;
12
+ async function readFile_() {
13
+ try {
14
+ const content = await fsImpl.readFile(filePath);
15
+ const parsed = JSON.parse(content);
16
+ if (typeof parsed === 'object' && parsed !== null && 'recents' in parsed) {
17
+ return parsed;
18
+ }
19
+ return null;
20
+ }
21
+ catch (err) {
22
+ if (err.code === 'ENOENT') {
23
+ return null;
24
+ }
25
+ throw err;
26
+ }
27
+ }
28
+ async function persist(recents) {
29
+ const dir = path.dirname(filePath);
30
+ await fsImpl.mkdir(dir, { recursive: true });
31
+ const data = { version: FILE_VERSION, recents };
32
+ await fsImpl.writeFile(filePath, JSON.stringify(data, null, 2));
33
+ }
34
+ async function read() {
35
+ const data = await readFile_();
36
+ if (!data)
37
+ return [];
38
+ return (data.recents ?? []).filter((e) => typeof e === 'string');
39
+ }
40
+ async function addRecent(cwd) {
41
+ if (!path.isAbsolute(cwd)) {
42
+ throw new Error(`cwd must be an absolute path, got: ${cwd}`);
43
+ }
44
+ const current = await read();
45
+ const deduplicated = current.filter((p) => p !== cwd);
46
+ const updated = [cwd, ...deduplicated].slice(0, MAX_RECENTS);
47
+ await persist(updated);
48
+ return updated;
49
+ }
50
+ return { read, addRecent };
51
+ }
@@ -3,6 +3,8 @@ import type { StatusService } from '../services/platform/status-service.js';
3
3
  export interface UiRouteDependencies {
4
4
  cwd: string;
5
5
  statusService?: StatusService;
6
+ recentsFilePath?: string;
7
+ claudeJsonPath?: string;
6
8
  }
7
9
  export type UiRouteHandler = (request: IncomingMessage, response: ServerResponse) => Promise<void>;
8
10
  export declare function createUiRouteHandler(dependencies: UiRouteDependencies): UiRouteHandler;
package/dist/ui/routes.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, readdir, stat } from 'node:fs/promises';
3
3
  import { BrainctlError, ProfileError, ProfileNotFoundError, ValidationError } from '../errors.js';
4
4
  import { createAgentConfigService } from '../services/agent/agent-config-service.js';
5
5
  import { createMcpPreflightService } from '../services/platform/mcp-preflight-service.js';
@@ -11,9 +11,31 @@ import { createSkillPreflightService } from '../services/plugin/skill-preflight-
11
11
  import { createStatusService } from '../services/platform/status-service.js';
12
12
  import { createProfileApplyService, } from '../services/profile/profile-apply-service.js';
13
13
  import { createProfileSnapshotService, defaultBackupProfileName, } from '../services/profile/profile-snapshot-service.js';
14
+ import { createRecentProjectsService } from '../services/platform/recent-projects-service.js';
14
15
  import path from 'node:path';
16
+ import os from 'node:os';
15
17
  import { fileURLToPath } from 'node:url';
16
18
  import { spawn } from 'node:child_process';
19
+ function revealInFileManager(targetPath) {
20
+ if (process.platform === 'darwin') {
21
+ spawn('open', ['-R', targetPath], { detached: true, stdio: 'ignore' }).unref();
22
+ return;
23
+ }
24
+ if (process.platform === 'win32') {
25
+ spawn('explorer', [`/select,${targetPath}`], { detached: true, stdio: 'ignore' }).unref();
26
+ return;
27
+ }
28
+ spawn('xdg-open', [path.dirname(targetPath)], { detached: true, stdio: 'ignore' }).unref();
29
+ }
30
+ function resolveCwd(req, fallback) {
31
+ const url = new URL(req.url ?? '/', 'http://localhost');
32
+ const raw = url.searchParams.get('cwd');
33
+ if (!raw)
34
+ return fallback;
35
+ if (!path.isAbsolute(raw))
36
+ return fallback;
37
+ return raw;
38
+ }
17
39
  const uiAssetRoot = resolveUiAssetRoot();
18
40
  export function createUiRouteHandler(dependencies) {
19
41
  const statusService = dependencies.statusService ?? createStatusService();
@@ -26,6 +48,19 @@ export function createUiRouteHandler(dependencies) {
26
48
  const mcpPreflightService = createMcpPreflightService();
27
49
  const pluginInstallService = createPluginInstallService();
28
50
  const skillPreflightService = createSkillPreflightService();
51
+ const recentsFilePath = dependencies.recentsFilePath ?? path.join(os.homedir(), '.brainctl', 'recents.json');
52
+ const claudeJsonPath = dependencies.claudeJsonPath ?? path.join(os.homedir(), '.claude.json');
53
+ const recentProjectsService = createRecentProjectsService({ filePath: recentsFilePath });
54
+ async function readClaudeProjectPaths() {
55
+ try {
56
+ const raw = await readFile(claudeJsonPath, 'utf8');
57
+ const data = JSON.parse(raw);
58
+ return Object.keys(data.projects ?? {}).sort();
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ }
29
64
  return async (request, response) => {
30
65
  const url = new URL(request.url ?? '/', 'http://localhost');
31
66
  switch (url.pathname) {
@@ -47,7 +82,7 @@ export function createUiRouteHandler(dependencies) {
47
82
  if (request.method !== 'GET') {
48
83
  return sendJson(response, 405, { error: 'Method not allowed' });
49
84
  }
50
- const configs = await agentConfigService.readAll({ cwd: dependencies.cwd });
85
+ const configs = await agentConfigService.readAll({ cwd: resolveCwd(request, dependencies.cwd) });
51
86
  return sendJson(response, 200, configs);
52
87
  }
53
88
  case '/api/open-folder': {
@@ -65,13 +100,8 @@ export function createUiRouteHandler(dependencies) {
65
100
  if (!existsSync(folderPath)) {
66
101
  return sendJson(response, 404, { error: `Path not found: ${folderPath}` });
67
102
  }
68
- const opener = process.platform === 'darwin'
69
- ? 'open'
70
- : process.platform === 'win32'
71
- ? 'explorer'
72
- : 'xdg-open';
73
103
  try {
74
- spawn(opener, [folderPath], { detached: true, stdio: 'ignore' }).unref();
104
+ revealInFileManager(folderPath);
75
105
  return sendJson(response, 200, { ok: true, path: folderPath });
76
106
  }
77
107
  catch (error) {
@@ -167,6 +197,79 @@ export function createUiRouteHandler(dependencies) {
167
197
  return sendProfileError(response, error);
168
198
  }
169
199
  }
200
+ case '/api/projects': {
201
+ if (request.method !== 'GET') {
202
+ return sendJson(response, 405, { error: 'Method not allowed' });
203
+ }
204
+ const [claudeProjects, recents] = await Promise.all([
205
+ readClaudeProjectPaths(),
206
+ recentProjectsService.read(),
207
+ ]);
208
+ return sendJson(response, 200, {
209
+ current: dependencies.cwd,
210
+ claudeProjects,
211
+ recents,
212
+ });
213
+ }
214
+ case '/api/projects/recent': {
215
+ if (request.method !== 'POST') {
216
+ return sendJson(response, 405, { error: 'Method not allowed' });
217
+ }
218
+ const body = await readJsonBody(request);
219
+ if (!body.ok) {
220
+ return sendJson(response, 400, { error: 'Invalid JSON body' });
221
+ }
222
+ const value = body.value;
223
+ const cwd = typeof value?.cwd === 'string' ? value.cwd : '';
224
+ if (!path.isAbsolute(cwd)) {
225
+ return sendJson(response, 400, { error: 'cwd must be an absolute path' });
226
+ }
227
+ const recents = await recentProjectsService.addRecent(cwd);
228
+ return sendJson(response, 200, { recents });
229
+ }
230
+ case '/api/fs/browse': {
231
+ if (request.method !== 'GET') {
232
+ return sendJson(response, 405, { error: 'Method not allowed' });
233
+ }
234
+ const raw = url.searchParams.get('path');
235
+ const mode = url.searchParams.get('mode') === 'file' ? 'file' : 'dir';
236
+ const startPath = raw && path.isAbsolute(raw) ? raw : os.homedir();
237
+ try {
238
+ const stats = await stat(startPath);
239
+ if (!stats.isDirectory()) {
240
+ return sendJson(response, 400, { error: 'Path is not a directory' });
241
+ }
242
+ const entries = await readdir(startPath, { withFileTypes: true });
243
+ const items = entries
244
+ .filter((entry) => !entry.name.startsWith('.'))
245
+ .filter((entry) => {
246
+ if (mode === 'file')
247
+ return entry.isDirectory() || entry.isFile();
248
+ return entry.isDirectory();
249
+ })
250
+ .map((entry) => ({
251
+ name: entry.name,
252
+ isDir: entry.isDirectory(),
253
+ }))
254
+ .sort((a, b) => {
255
+ if (a.isDir !== b.isDir)
256
+ return a.isDir ? -1 : 1;
257
+ return a.name.localeCompare(b.name);
258
+ });
259
+ const parent = path.dirname(startPath);
260
+ return sendJson(response, 200, {
261
+ path: startPath,
262
+ parent: parent === startPath ? null : parent,
263
+ home: os.homedir(),
264
+ entries: items,
265
+ });
266
+ }
267
+ catch (error) {
268
+ return sendJson(response, 404, {
269
+ error: error instanceof Error ? error.message : 'Cannot read path',
270
+ });
271
+ }
272
+ }
170
273
  case '/api/profiles/snapshot': {
171
274
  if (request.method !== 'POST') {
172
275
  return sendJson(response, 405, { error: 'Method not allowed' });
@@ -204,13 +307,8 @@ export function createUiRouteHandler(dependencies) {
204
307
  if (!existsSync(folderPath)) {
205
308
  return sendJson(response, 404, { error: `Profile folder not found: ${folderPath}` });
206
309
  }
207
- const opener = process.platform === 'darwin'
208
- ? 'open'
209
- : process.platform === 'win32'
210
- ? 'explorer'
211
- : 'xdg-open';
212
310
  try {
213
- spawn(opener, [folderPath], { detached: true, stdio: 'ignore' }).unref();
311
+ revealInFileManager(folderPath);
214
312
  return sendJson(response, 200, { ok: true, path: folderPath });
215
313
  }
216
314
  catch (error) {
@@ -261,7 +359,7 @@ export function createUiRouteHandler(dependencies) {
261
359
  return sendJson(response, 400, { error: 'Missing key and MCP payload' });
262
360
  }
263
361
  const result = await mcpPreflightService.execute({
264
- cwd: dependencies.cwd,
362
+ cwd: resolveCwd(request, dependencies.cwd),
265
363
  agent: agentName,
266
364
  key: data.key,
267
365
  entry: data.entry,
@@ -284,7 +382,7 @@ export function createUiRouteHandler(dependencies) {
284
382
  }
285
383
  try {
286
384
  await agentConfigService.addMcp({
287
- cwd: dependencies.cwd,
385
+ cwd: resolveCwd(request, dependencies.cwd),
288
386
  agent: agentName,
289
387
  key: data.key,
290
388
  entry: data.entry,
@@ -301,7 +399,7 @@ export function createUiRouteHandler(dependencies) {
301
399
  const scope = url.searchParams.get('scope') === 'project' ? 'project' : 'global';
302
400
  try {
303
401
  await agentConfigService.removeMcp({
304
- cwd: dependencies.cwd,
402
+ cwd: resolveCwd(request, dependencies.cwd),
305
403
  agent: agentName,
306
404
  key: mcpKey,
307
405
  scope,
@@ -400,7 +498,7 @@ export function createUiRouteHandler(dependencies) {
400
498
  if (!data.name || !data.sourceAgent) {
401
499
  return sendJson(response, 400, { error: 'Missing name or sourceAgent' });
402
500
  }
403
- const sourcePlugin = await resolveSourcePlugin(agentConfigService, dependencies.cwd, {
501
+ const sourcePlugin = await resolveSourcePlugin(agentConfigService, resolveCwd(request, dependencies.cwd), {
404
502
  sourceAgent: data.sourceAgent,
405
503
  name: data.name,
406
504
  });
@@ -410,7 +508,7 @@ export function createUiRouteHandler(dependencies) {
410
508
  });
411
509
  }
412
510
  const result = await pluginInstallService.plan({
413
- cwd: dependencies.cwd,
511
+ cwd: resolveCwd(request, dependencies.cwd),
414
512
  targetAgent: agentName,
415
513
  sourceAgent: data.sourceAgent,
416
514
  plugin: sourcePlugin,
@@ -430,7 +528,7 @@ export function createUiRouteHandler(dependencies) {
430
528
  if (!data.name || !data.sourceAgent) {
431
529
  return sendJson(response, 400, { error: 'Missing name or sourceAgent' });
432
530
  }
433
- const sourcePlugin = await resolveSourcePlugin(agentConfigService, dependencies.cwd, {
531
+ const sourcePlugin = await resolveSourcePlugin(agentConfigService, resolveCwd(request, dependencies.cwd), {
434
532
  sourceAgent: data.sourceAgent,
435
533
  name: data.name,
436
534
  });
@@ -441,7 +539,7 @@ export function createUiRouteHandler(dependencies) {
441
539
  }
442
540
  try {
443
541
  const result = await pluginInstallService.execute({
444
- cwd: dependencies.cwd,
542
+ cwd: resolveCwd(request, dependencies.cwd),
445
543
  targetAgent: agentName,
446
544
  sourceAgent: data.sourceAgent,
447
545
  plugin: sourcePlugin,
@@ -453,7 +551,7 @@ export function createUiRouteHandler(dependencies) {
453
551
  }
454
552
  }
455
553
  if (request.method === 'DELETE' && pluginName) {
456
- const targetPlugin = await resolveTargetPlugin(agentConfigService, dependencies.cwd, {
554
+ const targetPlugin = await resolveTargetPlugin(agentConfigService, resolveCwd(request, dependencies.cwd), {
457
555
  targetAgent: agentName,
458
556
  name: pluginName,
459
557
  });
@@ -464,7 +562,7 @@ export function createUiRouteHandler(dependencies) {
464
562
  }
465
563
  try {
466
564
  const result = await pluginInstallService.remove({
467
- cwd: dependencies.cwd,
565
+ cwd: resolveCwd(request, dependencies.cwd),
468
566
  targetAgent: agentName,
469
567
  plugin: targetPlugin,
470
568
  });