@geminilight/mindos 0.5.5 → 0.5.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.
package/app/proxy.ts CHANGED
@@ -16,8 +16,9 @@ export async function proxy(req: NextRequest) {
16
16
 
17
17
  // --- API protection (AUTH_TOKEN) ---
18
18
  if (pathname.startsWith('/api/')) {
19
- // /api/auth handles its own password validation — never block it
20
- if (pathname === '/api/auth') return NextResponse.next();
19
+ // /api/auth handles its own password validation — never block it.
20
+ // /api/health is unauthenticated so check-port can detect this MindOS instance.
21
+ if (pathname === '/api/auth' || pathname === '/api/health') return NextResponse.next();
21
22
 
22
23
  if (!authToken) return NextResponse.next();
23
24
 
@@ -3,7 +3,7 @@ import path from 'path';
3
3
 
4
4
  export default defineConfig({
5
5
  test: {
6
- include: ['__tests__/**/*.test.ts'],
6
+ include: ['__tests__/**/*.test.ts', '../tests/unit/**/*.test.ts'],
7
7
  setupFiles: ['__tests__/setup.ts'],
8
8
  },
9
9
  resolve: {
Binary file
package/bin/cli.js CHANGED
@@ -317,8 +317,41 @@ const commands = {
317
317
  stop: () => stopMindos(),
318
318
 
319
319
  restart: async () => {
320
- stopMindos();
321
- await new Promise((r) => setTimeout(r, 1500));
320
+ // Capture old ports BEFORE loadConfig overwrites env vars, so we can
321
+ // clean up processes that are still listening on the previous ports
322
+ // (e.g. user changed ports in the GUI and config was already saved).
323
+ // Sources: (1) MINDOS_OLD_* set by /api/restart when it strips the
324
+ // current env, (2) current MINDOS_*_PORT env vars.
325
+ const oldWebPort = process.env.MINDOS_OLD_WEB_PORT || process.env.MINDOS_WEB_PORT;
326
+ const oldMcpPort = process.env.MINDOS_OLD_MCP_PORT || process.env.MINDOS_MCP_PORT;
327
+
328
+ loadConfig();
329
+
330
+ // After loadConfig, env vars reflect the NEW config (or old if unchanged).
331
+ const newWebPort = Number(process.env.MINDOS_WEB_PORT || '3000');
332
+ const newMcpPort = Number(process.env.MINDOS_MCP_PORT || '8787');
333
+
334
+ // Collect old ports that differ from new ones — processes may still be
335
+ // listening there even though config already points to the new ports.
336
+ const extraPorts = [];
337
+ if (oldWebPort && Number(oldWebPort) !== newWebPort) extraPorts.push(oldWebPort);
338
+ if (oldMcpPort && Number(oldMcpPort) !== newMcpPort) extraPorts.push(oldMcpPort);
339
+
340
+ stopMindos({ extraPorts });
341
+
342
+ // Wait until ALL ports (old + new) are actually free (up to 15s)
343
+ const allPorts = new Set([newWebPort, newMcpPort]);
344
+ for (const p of extraPorts) allPorts.add(Number(p));
345
+
346
+ const deadline = Date.now() + 15_000;
347
+ while (Date.now() < deadline) {
348
+ let anyBusy = false;
349
+ for (const p of allPorts) {
350
+ if (await isPortInUse(p)) { anyBusy = true; break; }
351
+ }
352
+ if (!anyBusy) break;
353
+ await new Promise((r) => setTimeout(r, 500));
354
+ }
322
355
  await commands[getStartMode()]();
323
356
  },
324
357
 
@@ -1,16 +1,119 @@
1
1
  /**
2
2
  * Shared MCP agent definitions for CLI tools.
3
3
  * Mirrors app/lib/mcp-agents.ts — keep in sync manually.
4
+ *
5
+ * Each agent entry includes presenceCli / presenceDirs for detecting
6
+ * whether the agent is installed on the user's machine. To add a new
7
+ * agent, add a single entry here — no separate table needed.
4
8
  */
5
9
 
10
+ import { existsSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+ import { execSync } from 'node:child_process';
14
+
15
+ function expandHome(p) {
16
+ return p.startsWith('~/') ? resolve(homedir(), p.slice(2)) : p;
17
+ }
18
+
6
19
  export const MCP_AGENTS = {
7
- 'claude-code': { name: 'Claude Code', project: '.mcp.json', global: '~/.claude.json', key: 'mcpServers', preferredTransport: 'stdio' },
8
- 'claude-desktop': { name: 'Claude Desktop', project: null, global: process.platform === 'darwin' ? '~/Library/Application Support/Claude/claude_desktop_config.json' : '~/.config/Claude/claude_desktop_config.json', key: 'mcpServers', preferredTransport: 'http' },
9
- 'cursor': { name: 'Cursor', project: '.cursor/mcp.json', global: '~/.cursor/mcp.json', key: 'mcpServers', preferredTransport: 'stdio' },
10
- 'windsurf': { name: 'Windsurf', project: null, global: '~/.codeium/windsurf/mcp_config.json', key: 'mcpServers', preferredTransport: 'stdio' },
11
- 'cline': { name: 'Cline', project: null, global: process.platform === 'darwin' ? '~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json' : '~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json', key: 'mcpServers', preferredTransport: 'stdio' },
12
- 'trae': { name: 'Trae', project: '.trae/mcp.json', global: '~/.trae/mcp.json', key: 'mcpServers', preferredTransport: 'stdio' },
13
- 'gemini-cli': { name: 'Gemini CLI', project: '.gemini/settings.json', global: '~/.gemini/settings.json', key: 'mcpServers', preferredTransport: 'stdio' },
14
- 'openclaw': { name: 'OpenClaw', project: null, global: '~/.openclaw/mcp.json', key: 'mcpServers', preferredTransport: 'stdio' },
15
- 'codebuddy': { name: 'CodeBuddy', project: null, global: '~/.claude-internal/.claude.json', key: 'mcpServers', preferredTransport: 'stdio' },
20
+ 'claude-code': {
21
+ name: 'Claude Code',
22
+ project: '.mcp.json',
23
+ global: '~/.claude.json',
24
+ key: 'mcpServers',
25
+ preferredTransport: 'stdio',
26
+ presenceCli: 'claude',
27
+ presenceDirs: ['~/.claude/'],
28
+ },
29
+ 'claude-desktop': {
30
+ name: 'Claude Desktop',
31
+ project: null,
32
+ global: process.platform === 'darwin'
33
+ ? '~/Library/Application Support/Claude/claude_desktop_config.json'
34
+ : '~/.config/Claude/claude_desktop_config.json',
35
+ key: 'mcpServers',
36
+ preferredTransport: 'http',
37
+ presenceDirs: ['~/Library/Application Support/Claude/', '~/.config/Claude/'],
38
+ },
39
+ 'cursor': {
40
+ name: 'Cursor',
41
+ project: '.cursor/mcp.json',
42
+ global: '~/.cursor/mcp.json',
43
+ key: 'mcpServers',
44
+ preferredTransport: 'stdio',
45
+ presenceDirs: ['~/.cursor/'],
46
+ },
47
+ 'windsurf': {
48
+ name: 'Windsurf',
49
+ project: null,
50
+ global: '~/.codeium/windsurf/mcp_config.json',
51
+ key: 'mcpServers',
52
+ preferredTransport: 'stdio',
53
+ presenceDirs: ['~/.codeium/windsurf/'],
54
+ },
55
+ 'cline': {
56
+ name: 'Cline',
57
+ project: null,
58
+ global: process.platform === 'darwin'
59
+ ? '~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json'
60
+ : '~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json',
61
+ key: 'mcpServers',
62
+ preferredTransport: 'stdio',
63
+ presenceDirs: [
64
+ '~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/',
65
+ '~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/',
66
+ ],
67
+ },
68
+ 'trae': {
69
+ name: 'Trae',
70
+ project: '.trae/mcp.json',
71
+ global: '~/.trae/mcp.json',
72
+ key: 'mcpServers',
73
+ preferredTransport: 'stdio',
74
+ presenceDirs: ['~/.trae/'],
75
+ },
76
+ 'gemini-cli': {
77
+ name: 'Gemini CLI',
78
+ project: '.gemini/settings.json',
79
+ global: '~/.gemini/settings.json',
80
+ key: 'mcpServers',
81
+ preferredTransport: 'stdio',
82
+ presenceCli: 'gemini',
83
+ presenceDirs: ['~/.gemini/'],
84
+ },
85
+ 'openclaw': {
86
+ name: 'OpenClaw',
87
+ project: null,
88
+ global: '~/.openclaw/mcp.json',
89
+ key: 'mcpServers',
90
+ preferredTransport: 'stdio',
91
+ presenceCli: 'openclaw',
92
+ presenceDirs: ['~/.openclaw/'],
93
+ },
94
+ 'codebuddy': {
95
+ name: 'CodeBuddy',
96
+ project: null,
97
+ global: '~/.claude-internal/.claude.json',
98
+ key: 'mcpServers',
99
+ preferredTransport: 'stdio',
100
+ presenceCli: 'claude-internal',
101
+ presenceDirs: ['~/.claude-internal/'],
102
+ },
16
103
  };
104
+
105
+ export function detectAgentPresence(agentKey) {
106
+ const agent = MCP_AGENTS[agentKey];
107
+ if (!agent) return false;
108
+ if (agent.presenceCli) {
109
+ try {
110
+ execSync(
111
+ process.platform === 'win32' ? `where ${agent.presenceCli}` : `which ${agent.presenceCli}`,
112
+ { stdio: 'pipe' },
113
+ );
114
+ return true;
115
+ } catch { /* not found */ }
116
+ }
117
+ if (agent.presenceDirs?.some(d => existsSync(expandHome(d)))) return true;
118
+ return false;
119
+ }
package/bin/lib/stop.js CHANGED
@@ -6,60 +6,113 @@ import { CONFIG_PATH } from './constants.js';
6
6
 
7
7
  /**
8
8
  * Kill processes listening on the given port.
9
+ * Tries lsof first, then falls back to parsing `ss` output.
9
10
  * Returns number of processes killed.
10
11
  */
11
12
  function killByPort(port) {
12
- let killed = 0;
13
+ const pidsToKill = new Set();
14
+
15
+ // Method 1: lsof
13
16
  try {
14
17
  const output = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: 'utf-8' }).trim();
15
18
  if (output) {
16
19
  for (const p of output.split('\n')) {
17
20
  const pid = Number(p);
18
- if (pid > 0) {
19
- try { process.kill(pid, 'SIGTERM'); killed++; } catch {}
20
- }
21
+ if (pid > 0) pidsToKill.add(pid);
21
22
  }
22
23
  }
23
24
  } catch {
24
25
  // lsof not available or no processes found
25
26
  }
27
+
28
+ // Method 2: ss -tlnp (fallback — works when lsof can't see the process)
29
+ if (pidsToKill.size === 0) {
30
+ try {
31
+ const output = execSync(`ss -tlnp 2>/dev/null`, { encoding: 'utf-8' });
32
+ // Match lines like: LISTEN ... *:3003 ... users:(("next-server",pid=12345,fd=21))
33
+ // Match `:PORT` followed by a non-digit to avoid partial matches
34
+ // (e.g. port 80 must not match :8080)
35
+ const portRe = new RegExp(`:${port}(?!\\d)`);
36
+ for (const line of output.split('\n')) {
37
+ if (!portRe.test(line)) continue;
38
+ const pidMatch = line.match(/pid=(\d+)/g);
39
+ if (pidMatch) {
40
+ for (const m of pidMatch) {
41
+ const pid = Number(m.slice(4));
42
+ if (pid > 0) pidsToKill.add(pid);
43
+ }
44
+ }
45
+ }
46
+ } catch {
47
+ // ss not available
48
+ }
49
+ }
50
+
51
+ let killed = 0;
52
+ for (const pid of pidsToKill) {
53
+ try { process.kill(pid, 'SIGTERM'); killed++; } catch {}
54
+ }
26
55
  return killed;
27
56
  }
28
57
 
29
- export function stopMindos() {
58
+ /**
59
+ * Kill a process and all its children (process group).
60
+ */
61
+ function killTree(pid) {
62
+ // Try to kill the entire process group first
63
+ try { process.kill(-pid, 'SIGTERM'); return true; } catch {}
64
+ // Fallback: kill individual process
65
+ try { process.kill(pid, 'SIGTERM'); return true; } catch {}
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * Stop MindOS processes.
71
+ * @param {Object} [opts] - Optional overrides.
72
+ * @param {string[]} [opts.extraPorts] - Additional ports to clean up (e.g. old
73
+ * ports before a config change). These are cleaned in addition to the ports
74
+ * read from the current config file.
75
+ */
76
+ export function stopMindos(opts = {}) {
77
+ // Read ports from config for port-based cleanup
78
+ let webPort = '3000', mcpPort = '8787';
79
+ try {
80
+ const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
81
+ if (config.port) webPort = String(config.port);
82
+ if (config.mcpPort) mcpPort = String(config.mcpPort);
83
+ } catch {}
84
+
30
85
  const pids = loadPids();
31
86
  if (!pids.length) {
32
87
  console.log(yellow('No PID file found, trying port-based stop...'));
33
- // Read ports from config
34
- let webPort = '3000', mcpPort = '8787';
35
- try {
36
- const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
37
- if (config.port) webPort = String(config.port);
38
- if (config.mcpPort) mcpPort = String(config.mcpPort);
39
- } catch {}
88
+ } else {
89
+ // Kill saved PIDs (parent process + MCP) and their child processes
40
90
  let stopped = 0;
41
- for (const port of [webPort, mcpPort]) {
42
- stopped += killByPort(port);
91
+ for (const pid of pids) {
92
+ if (killTree(pid)) stopped++;
43
93
  }
44
- if (stopped === 0) {
45
- // Fallback: pkill pattern match (for envs without lsof)
46
- try { execSync('pkill -f "next start|next dev" 2>/dev/null || true', { stdio: 'inherit' }); } catch {}
47
- try { execSync('pkill -f "mcp/src/index" 2>/dev/null || true', { stdio: 'inherit' }); } catch {}
48
- }
49
- console.log(green('\u2714 Done'));
50
- return;
94
+ clearPids();
95
+ if (stopped) console.log(green(`\u2714 Stopped ${stopped} process${stopped > 1 ? 'es' : ''}`));
51
96
  }
52
- let stopped = 0;
53
- for (const pid of pids) {
54
- try {
55
- process.kill(pid, 'SIGTERM');
56
- stopped++;
57
- } catch {
58
- // process already gone ignore
59
- }
97
+
98
+ // Always do port-based cleanup — Next.js spawns worker processes whose PIDs
99
+ // are not recorded in the PID file and would otherwise become orphaned.
100
+ // Include any extra ports (e.g. old ports from before a config change).
101
+ const portsToClean = new Set([webPort, mcpPort]);
102
+ if (opts.extraPorts) {
103
+ for (const p of opts.extraPorts) portsToClean.add(String(p));
104
+ }
105
+
106
+ let portKilled = 0;
107
+ for (const port of portsToClean) {
108
+ portKilled += killByPort(port);
109
+ }
110
+
111
+ if (!pids.length && portKilled === 0) {
112
+ // Last resort: pattern match (for envs without lsof)
113
+ try { execSync('pkill -f "next start|next dev" 2>/dev/null || true', { stdio: 'inherit' }); } catch {}
114
+ try { execSync('pkill -f "mcp/src/index" 2>/dev/null || true', { stdio: 'inherit' }); } catch {}
60
115
  }
61
- clearPids();
62
- console.log(stopped
63
- ? green(`\u2714 Stopped ${stopped} process${stopped > 1 ? 'es' : ''}`)
64
- : dim('No running processes found'));
116
+
117
+ if (!pids.length) console.log(green('\u2714 Done'));
65
118
  }
package/mcp/src/index.ts CHANGED
@@ -484,6 +484,11 @@ async function main() {
484
484
 
485
485
  const expressApp = createMcpExpressApp({ host: MCP_HOST });
486
486
 
487
+ // Health endpoint — allows check-port to detect this is a MindOS MCP instance
488
+ expressApp.get("/api/health", (_req, res) => {
489
+ res.json({ ok: true, service: "mindos" });
490
+ });
491
+
487
492
  // Auth middleware
488
493
  if (AUTH_TOKEN) {
489
494
  expressApp.use(MCP_ENDPOINT, (req, res, next) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geminilight/mindos",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "MindOS — Human-Agent Collaborative Mind System. Local-first knowledge base that syncs your mind to all AI Agents via MCP.",
5
5
  "keywords": [
6
6
  "mindos",
@@ -20,6 +20,13 @@ const dirs = fs.readdirSync(renderersDir, { withFileTypes: true })
20
20
  .map(d => d.name)
21
21
  .sort();
22
22
 
23
+ // Opt-in-only renderers (match: () => false) go last for consistent ordering.
24
+ const LAST = new Set(['graph']);
25
+ const sorted = [
26
+ ...dirs.filter(d => !LAST.has(d)),
27
+ ...dirs.filter(d => LAST.has(d)),
28
+ ];
29
+
23
30
  if (dirs.length === 0) {
24
31
  console.error('No manifest.ts files found in', renderersDir);
25
32
  process.exit(1);
@@ -30,12 +37,12 @@ function toCamel(s) {
30
37
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
31
38
  }
32
39
 
33
- const imports = dirs.map(dir => {
40
+ const imports = sorted.map(dir => {
34
41
  const varName = toCamel(dir);
35
42
  return `import { manifest as ${varName} } from '@/components/renderers/${dir}/manifest';`;
36
43
  }).join('\n');
37
44
 
38
- const varNames = dirs.map(toCamel);
45
+ const varNames = sorted.map(toCamel);
39
46
 
40
47
  const code = `/**
41
48
  * AUTO-GENERATED by scripts/gen-renderer-index.js — do not edit manually.
package/scripts/setup.js CHANGED
@@ -30,7 +30,7 @@ import { execSync, spawn } from 'node:child_process';
30
30
  import { randomBytes, createHash } from 'node:crypto';
31
31
  import { createConnection } from 'node:net';
32
32
  import http from 'node:http';
33
- import { MCP_AGENTS } from '../bin/lib/mcp-agents.js';
33
+ import { MCP_AGENTS, detectAgentPresence } from '../bin/lib/mcp-agents.js';
34
34
 
35
35
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
36
  const ROOT = resolve(__dirname, '..');
@@ -616,14 +616,15 @@ function isAgentInstalled(agentKey) {
616
616
  async function runMcpInstallStep(mcpPort, authToken) {
617
617
  const keys = Object.keys(MCP_AGENTS);
618
618
 
619
- // Build options with installed status shown as hint
619
+ // Build options with installed/detected status shown as hint
620
620
  const options = keys.map(k => {
621
621
  const installed = isAgentInstalled(k);
622
+ const present = detectAgentPresence(k);
622
623
  return {
623
624
  label: MCP_AGENTS[k].name,
624
- hint: installed ? (uiLang === 'zh' ? '已安装' : 'installed') : (uiLang === 'zh' ? '未安装' : 'not installed'),
625
+ hint: installed ? (uiLang === 'zh' ? '已配置' : 'configured') : present ? (uiLang === 'zh' ? '已检测' : 'detected') : (uiLang === 'zh' ? '未找到' : 'not found'),
625
626
  value: k,
626
- preselect: installed,
627
+ preselect: installed || present,
627
628
  };
628
629
  });
629
630
 
@@ -746,34 +747,41 @@ function runSkillInstallStep(template, selectedAgents) {
746
747
  }
747
748
 
748
749
  const skillName = template === 'zh' ? 'mindos-zh' : 'mindos';
749
- const source = resolve(ROOT, 'skills');
750
+ const localSource = resolve(ROOT, 'skills');
751
+ const githubSource = 'GeminiLight/MindOS';
750
752
 
751
753
  // Filter to non-universal, skill-capable agents
752
754
  const additionalAgents = selectedAgents
753
755
  .filter(key => !UNIVERSAL_AGENTS.has(key) && !SKILL_UNSUPPORTED.has(key))
754
756
  .map(key => AGENT_NAME_MAP[key] || key);
755
757
 
756
- let cmd;
757
- if (additionalAgents.length > 0) {
758
- cmd = `npx skills add "${source}" -s ${skillName} -a ${additionalAgents.join(',')} -g -y`;
759
- } else {
760
- cmd = `npx skills add "${source}" -s ${skillName} -a universal -g -y`;
761
- }
758
+ // Each agent needs its own -a flag (skills CLI does NOT accept comma-separated)
759
+ const agentFlags = additionalAgents.length > 0
760
+ ? additionalAgents.map(a => `-a ${a}`).join(' ')
761
+ : '-a universal';
762
+
763
+ // Try GitHub source first, fall back to local path
764
+ const sources = [githubSource, localSource];
762
765
 
763
766
  write(tf('skillInstalling', skillName) + '\n');
764
767
 
765
- try {
766
- execSync(cmd, {
767
- encoding: 'utf-8',
768
- timeout: 30_000,
769
- env: { ...process.env, NODE_ENV: 'production' },
770
- stdio: 'pipe',
771
- });
772
- write(tf('skillInstallOk', skillName) + '\n');
773
- } catch (err) {
774
- const msg = err.stderr || err.message || 'Unknown error';
775
- write(tf('skillInstallFail', skillName, msg.split('\n')[0]) + '\n');
768
+ for (const source of sources) {
769
+ // Quote local paths for shell safety
770
+ const quotedSource = /[/\\]/.test(source) ? `"${source}"` : source;
771
+ const cmd = `npx skills add ${quotedSource} --skill ${skillName} ${agentFlags} -g -y`;
772
+ try {
773
+ execSync(cmd, {
774
+ encoding: 'utf-8',
775
+ timeout: 30_000,
776
+ env: { ...process.env, NODE_ENV: 'production' },
777
+ stdio: 'pipe',
778
+ });
779
+ write(tf('skillInstallOk', skillName) + '\n');
780
+ return;
781
+ } catch { /* try next source */ }
776
782
  }
783
+
784
+ write(tf('skillInstallFail', skillName, 'All sources failed') + '\n');
777
785
  }
778
786
 
779
787
  // ── GUI Setup ─────────────────────────────────────────────────────────────────
@@ -1187,7 +1195,9 @@ async function finish(mindDir, startMode = 'start', mcpPort = 8787, authToken =
1187
1195
  const doRestart = await askYesNoDefault('restartNow');
1188
1196
  if (doRestart) {
1189
1197
  const cliPath = resolve(__dirname, '../bin/cli.js');
1190
- execSync(`node "${cliPath}" start`, { stdio: 'inherit' });
1198
+ // Use 'restart' (stop start) instead of bare 'start' which would
1199
+ // fail assertPortFree because the old process is still running.
1200
+ execSync(`node "${cliPath}" restart`, { stdio: 'inherit' });
1191
1201
  } else {
1192
1202
  write(c.dim(t('restartManual') + '\n'));
1193
1203
  }