@geminilight/mindos 0.2.1 → 0.4.0

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 (82) hide show
  1. package/app/app/api/init/route.ts +7 -41
  2. package/app/app/api/mcp/agents/route.ts +72 -0
  3. package/app/app/api/mcp/install/route.ts +95 -0
  4. package/app/app/api/mcp/status/route.ts +47 -0
  5. package/app/app/api/settings/route.ts +3 -0
  6. package/app/app/api/setup/generate-token/route.ts +23 -0
  7. package/app/app/api/setup/route.ts +81 -0
  8. package/app/app/api/skills/route.ts +208 -0
  9. package/app/app/api/sync/route.ts +54 -3
  10. package/app/app/api/update-check/route.ts +52 -0
  11. package/app/app/globals.css +12 -0
  12. package/app/app/layout.tsx +4 -2
  13. package/app/app/login/page.tsx +20 -13
  14. package/app/app/page.tsx +22 -2
  15. package/app/app/setup/page.tsx +9 -0
  16. package/app/app/view/[...path]/ViewPageClient.tsx +47 -21
  17. package/app/app/view/[...path]/loading.tsx +1 -1
  18. package/app/app/view/[...path]/not-found.tsx +101 -0
  19. package/app/components/AskFab.tsx +1 -1
  20. package/app/components/AskModal.tsx +1 -1
  21. package/app/components/Backlinks.tsx +1 -1
  22. package/app/components/Breadcrumb.tsx +13 -3
  23. package/app/components/CsvView.tsx +5 -6
  24. package/app/components/DirView.tsx +42 -21
  25. package/app/components/FindInPage.tsx +211 -0
  26. package/app/components/HomeContent.tsx +97 -44
  27. package/app/components/JsonView.tsx +1 -2
  28. package/app/components/MarkdownEditor.tsx +1 -2
  29. package/app/components/OnboardingView.tsx +6 -7
  30. package/app/components/SettingsModal.tsx +5 -2
  31. package/app/components/SetupWizard.tsx +479 -0
  32. package/app/components/Sidebar.tsx +1 -1
  33. package/app/components/UpdateBanner.tsx +101 -0
  34. package/app/components/renderers/{AgentInspectorRenderer.tsx → agent-inspector/AgentInspectorRenderer.tsx} +13 -11
  35. package/app/components/renderers/agent-inspector/manifest.ts +14 -0
  36. package/app/components/renderers/{BacklinksRenderer.tsx → backlinks/BacklinksRenderer.tsx} +6 -6
  37. package/app/components/renderers/backlinks/manifest.ts +14 -0
  38. package/app/components/renderers/config/manifest.ts +14 -0
  39. package/app/components/renderers/csv/BoardView.tsx +12 -12
  40. package/app/components/renderers/csv/ConfigPanel.tsx +7 -8
  41. package/app/components/renderers/{CsvRenderer.tsx → csv/CsvRenderer.tsx} +8 -9
  42. package/app/components/renderers/csv/GalleryView.tsx +3 -3
  43. package/app/components/renderers/csv/TableView.tsx +4 -5
  44. package/app/components/renderers/csv/manifest.ts +14 -0
  45. package/app/components/renderers/{DiffRenderer.tsx → diff/DiffRenderer.tsx} +10 -9
  46. package/app/components/renderers/diff/manifest.ts +14 -0
  47. package/app/components/renderers/{GraphRenderer.tsx → graph/GraphRenderer.tsx} +4 -5
  48. package/app/components/renderers/graph/manifest.ts +14 -0
  49. package/app/components/renderers/{SummaryRenderer.tsx → summary/SummaryRenderer.tsx} +6 -6
  50. package/app/components/renderers/summary/manifest.ts +14 -0
  51. package/app/components/renderers/{TimelineRenderer.tsx → timeline/TimelineRenderer.tsx} +6 -6
  52. package/app/components/renderers/timeline/manifest.ts +14 -0
  53. package/app/components/renderers/{TodoRenderer.tsx → todo/TodoRenderer.tsx} +2 -2
  54. package/app/components/renderers/todo/manifest.ts +14 -0
  55. package/app/components/renderers/{WorkflowRenderer.tsx → workflow/WorkflowRenderer.tsx} +13 -13
  56. package/app/components/renderers/workflow/manifest.ts +14 -0
  57. package/app/components/settings/McpTab.tsx +549 -0
  58. package/app/components/settings/SyncTab.tsx +139 -50
  59. package/app/components/settings/types.ts +1 -1
  60. package/app/data/pages/home.png +0 -0
  61. package/app/lib/i18n.ts +270 -10
  62. package/app/lib/renderers/index.ts +20 -89
  63. package/app/lib/renderers/registry.ts +4 -1
  64. package/app/lib/settings.ts +15 -1
  65. package/app/lib/template.ts +45 -0
  66. package/app/package.json +1 -0
  67. package/app/types/semver.d.ts +8 -0
  68. package/bin/cli.js +137 -24
  69. package/bin/lib/build.js +53 -18
  70. package/bin/lib/colors.js +3 -1
  71. package/bin/lib/config.js +4 -0
  72. package/bin/lib/constants.js +2 -0
  73. package/bin/lib/debug.js +10 -0
  74. package/bin/lib/startup.js +21 -20
  75. package/bin/lib/stop.js +41 -3
  76. package/bin/lib/sync.js +65 -53
  77. package/bin/lib/update-check.js +94 -0
  78. package/bin/lib/utils.js +2 -2
  79. package/package.json +1 -1
  80. package/scripts/gen-renderer-index.js +57 -0
  81. package/scripts/setup.js +117 -1
  82. /package/app/components/renderers/{ConfigRenderer.tsx → config/ConfigRenderer.tsx} +0 -0
@@ -1,56 +1,22 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
- import fs from 'fs';
3
- import path from 'path';
4
2
  import { getMindRoot } from '@/lib/fs';
5
-
6
- function copyRecursive(src: string, dest: string) {
7
- const stat = fs.statSync(src);
8
- if (stat.isDirectory()) {
9
- if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
10
- for (const entry of fs.readdirSync(src)) {
11
- copyRecursive(path.join(src, entry), path.join(dest, entry));
12
- }
13
- } else {
14
- // Skip if file already exists
15
- if (fs.existsSync(dest)) return;
16
- // Ensure parent directory exists
17
- const dir = path.dirname(dest);
18
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
19
- fs.copyFileSync(src, dest);
20
- }
21
- }
3
+ import { applyTemplate } from '@/lib/template';
22
4
 
23
5
  export async function POST(req: NextRequest) {
24
6
  try {
25
7
  const body = await req.json();
26
8
  const template = body.template as string;
27
9
 
28
- if (!['en', 'zh', 'empty'].includes(template)) {
29
- return NextResponse.json({ error: 'Invalid template' }, { status: 400 });
30
- }
31
-
32
- // Resolve template source directory
33
- // templates/ is at the repo root (sibling of app/)
34
- const repoRoot = path.resolve(process.cwd(), '..');
35
- const templateDir = path.join(repoRoot, 'templates', template);
36
-
37
- if (!fs.existsSync(templateDir)) {
38
- return NextResponse.json({ error: `Template "${template}" not found` }, { status: 404 });
39
- }
40
-
41
10
  const mindRoot = getMindRoot();
42
- if (!fs.existsSync(mindRoot)) {
43
- fs.mkdirSync(mindRoot, { recursive: true });
44
- }
45
-
46
- copyRecursive(templateDir, mindRoot);
11
+ applyTemplate(template, mindRoot);
47
12
 
48
13
  return NextResponse.json({ ok: true, template });
49
14
  } catch (e) {
50
15
  console.error('[/api/init] Error:', e);
51
- return NextResponse.json(
52
- { error: e instanceof Error ? e.message : String(e) },
53
- { status: 500 },
54
- );
16
+ const msg = e instanceof Error ? e.message : String(e);
17
+ const status = msg.startsWith('Invalid template') ? 400
18
+ : msg.includes('not found') ? 404
19
+ : 500;
20
+ return NextResponse.json({ error: msg }, { status });
55
21
  }
56
22
  }
@@ -0,0 +1,72 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextResponse } from 'next/server';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ function expandHome(p: string): string {
8
+ return p.startsWith('~/') ? path.resolve(os.homedir(), p.slice(2)) : p;
9
+ }
10
+
11
+ interface AgentDef {
12
+ name: string;
13
+ project: string | null;
14
+ global: string;
15
+ key: string;
16
+ }
17
+
18
+ const MCP_AGENTS: Record<string, AgentDef> = {
19
+ 'claude-code': { name: 'Claude Code', project: '.mcp.json', global: '~/.claude.json', key: 'mcpServers' },
20
+ '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' },
21
+ 'cursor': { name: 'Cursor', project: '.cursor/mcp.json', global: '~/.cursor/mcp.json', key: 'mcpServers' },
22
+ 'windsurf': { name: 'Windsurf', project: null, global: '~/.codeium/windsurf/mcp_config.json', key: 'mcpServers' },
23
+ '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' },
24
+ 'trae': { name: 'Trae', project: '.trae/mcp.json', global: '~/.trae/mcp.json', key: 'mcpServers' },
25
+ 'gemini-cli': { name: 'Gemini CLI', project: '.gemini/settings.json', global: '~/.gemini/settings.json', key: 'mcpServers' },
26
+ 'openclaw': { name: 'OpenClaw', project: null, global: '~/.openclaw/mcp.json', key: 'mcpServers' },
27
+ 'codebuddy': { name: 'CodeBuddy', project: null, global: '~/.claude-internal/.claude.json', key: 'mcpServers' },
28
+ };
29
+
30
+ function detectInstalled(agentKey: string): { installed: boolean; scope?: string; transport?: string; configPath?: string } {
31
+ const agent = MCP_AGENTS[agentKey];
32
+ if (!agent) return { installed: false };
33
+
34
+ // Check global first, then project
35
+ for (const [scope, cfgPath] of [['global', agent.global], ['project', agent.project]] as const) {
36
+ if (!cfgPath) continue;
37
+ const absPath = expandHome(cfgPath);
38
+ if (!fs.existsSync(absPath)) continue;
39
+ try {
40
+ const config = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
41
+ const servers = config[agent.key];
42
+ if (servers?.mindos) {
43
+ const entry = servers.mindos;
44
+ const transport = entry.type === 'stdio' ? 'stdio' : entry.url ? 'http' : 'unknown';
45
+ return { installed: true, scope, transport, configPath: cfgPath };
46
+ }
47
+ } catch { /* ignore parse errors */ }
48
+ }
49
+
50
+ return { installed: false };
51
+ }
52
+
53
+ export async function GET() {
54
+ try {
55
+ const agents = Object.entries(MCP_AGENTS).map(([key, agent]) => {
56
+ const status = detectInstalled(key);
57
+ return {
58
+ key,
59
+ name: agent.name,
60
+ installed: status.installed,
61
+ scope: status.scope,
62
+ transport: status.transport,
63
+ configPath: status.configPath,
64
+ hasProjectScope: !!agent.project,
65
+ hasGlobalScope: !!agent.global,
66
+ };
67
+ });
68
+ return NextResponse.json({ agents });
69
+ } catch (err) {
70
+ return NextResponse.json({ error: String(err) }, { status: 500 });
71
+ }
72
+ }
@@ -0,0 +1,95 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ function expandHome(p: string): string {
8
+ return p.startsWith('~/') ? path.resolve(os.homedir(), p.slice(2)) : p;
9
+ }
10
+
11
+ interface AgentDef {
12
+ name: string;
13
+ project: string | null;
14
+ global: string;
15
+ key: string;
16
+ }
17
+
18
+ const MCP_AGENTS: Record<string, AgentDef> = {
19
+ 'claude-code': { name: 'Claude Code', project: '.mcp.json', global: '~/.claude.json', key: 'mcpServers' },
20
+ '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' },
21
+ 'cursor': { name: 'Cursor', project: '.cursor/mcp.json', global: '~/.cursor/mcp.json', key: 'mcpServers' },
22
+ 'windsurf': { name: 'Windsurf', project: null, global: '~/.codeium/windsurf/mcp_config.json', key: 'mcpServers' },
23
+ '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' },
24
+ 'trae': { name: 'Trae', project: '.trae/mcp.json', global: '~/.trae/mcp.json', key: 'mcpServers' },
25
+ 'gemini-cli': { name: 'Gemini CLI', project: '.gemini/settings.json', global: '~/.gemini/settings.json', key: 'mcpServers' },
26
+ 'openclaw': { name: 'OpenClaw', project: null, global: '~/.openclaw/mcp.json', key: 'mcpServers' },
27
+ 'codebuddy': { name: 'CodeBuddy', project: null, global: '~/.claude-internal/.claude.json', key: 'mcpServers' },
28
+ };
29
+
30
+ interface InstallRequest {
31
+ agents: Array<{ key: string; scope: 'project' | 'global' }>;
32
+ transport: 'stdio' | 'http';
33
+ url?: string;
34
+ token?: string;
35
+ }
36
+
37
+ function buildEntry(transport: string, url?: string, token?: string) {
38
+ if (transport === 'stdio') {
39
+ return { type: 'stdio', command: 'mindos', args: ['mcp'], env: { MCP_TRANSPORT: 'stdio' } };
40
+ }
41
+ const entry: Record<string, unknown> = { url: url || 'http://localhost:8787/mcp' };
42
+ if (token) entry.headers = { Authorization: `Bearer ${token}` };
43
+ return entry;
44
+ }
45
+
46
+ export async function POST(req: NextRequest) {
47
+ try {
48
+ const body = (await req.json()) as InstallRequest;
49
+ const { agents, transport, url, token } = body;
50
+ const entry = buildEntry(transport, url, token);
51
+ const results: Array<{ agent: string; status: string; path?: string; message?: string }> = [];
52
+
53
+ for (const { key, scope } of agents) {
54
+ const agent = MCP_AGENTS[key];
55
+ if (!agent) {
56
+ results.push({ agent: key, status: 'error', message: `Unknown agent: ${key}` });
57
+ continue;
58
+ }
59
+
60
+ const isGlobal = scope === 'global';
61
+ const configPath = isGlobal ? agent.global : agent.project;
62
+ if (!configPath) {
63
+ results.push({ agent: key, status: 'error', message: `${agent.name} does not support ${scope} scope` });
64
+ continue;
65
+ }
66
+
67
+ const absPath = expandHome(configPath);
68
+
69
+ try {
70
+ // Read existing config
71
+ let config: Record<string, unknown> = {};
72
+ if (fs.existsSync(absPath)) {
73
+ config = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
74
+ }
75
+
76
+ // Merge — only touch mcpServers.mindos
77
+ if (!config[agent.key]) config[agent.key] = {};
78
+ (config[agent.key] as Record<string, unknown>).mindos = entry;
79
+
80
+ // Write
81
+ const dir = path.dirname(absPath);
82
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
83
+ fs.writeFileSync(absPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
84
+
85
+ results.push({ agent: key, status: 'ok', path: configPath });
86
+ } catch (err) {
87
+ results.push({ agent: key, status: 'error', message: String(err) });
88
+ }
89
+ }
90
+
91
+ return NextResponse.json({ results });
92
+ } catch (err) {
93
+ return NextResponse.json({ error: String(err) }, { status: 500 });
94
+ }
95
+ }
@@ -0,0 +1,47 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextResponse } from 'next/server';
3
+ import { readSettings } from '@/lib/settings';
4
+
5
+ export async function GET() {
6
+ try {
7
+ const settings = readSettings();
8
+ const port = settings.mcpPort ?? 8787;
9
+ const endpoint = `http://127.0.0.1:${port}/mcp`;
10
+ const authConfigured = !!settings.authToken;
11
+
12
+ // Check if MCP server is running
13
+ let running = false;
14
+ let toolCount = 0;
15
+ try {
16
+ const controller = new AbortController();
17
+ const timeout = setTimeout(() => controller.abort(), 2000);
18
+ const res = await fetch(endpoint, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
22
+ signal: controller.signal,
23
+ });
24
+ clearTimeout(timeout);
25
+ if (res.ok) {
26
+ running = true;
27
+ try {
28
+ const data = await res.json();
29
+ if (data?.result?.tools) toolCount = data.result.tools.length;
30
+ } catch { /* non-JSON response — still running */ }
31
+ }
32
+ } catch {
33
+ // Connection refused or timeout — not running
34
+ }
35
+
36
+ return NextResponse.json({
37
+ running,
38
+ transport: 'http',
39
+ endpoint,
40
+ port,
41
+ toolCount,
42
+ authConfigured,
43
+ });
44
+ } catch (err) {
45
+ return NextResponse.json({ error: String(err) }, { status: 500 });
46
+ }
47
+ }
@@ -112,6 +112,9 @@ export async function POST(req: NextRequest) {
112
112
  mindRoot: body.mindRoot ?? current.mindRoot,
113
113
  webPassword: resolvedWebPassword,
114
114
  authToken: resolvedAuthToken,
115
+ port: typeof body.port === 'number' ? body.port : current.port,
116
+ mcpPort: typeof body.mcpPort === 'number' ? body.mcpPort : current.mcpPort,
117
+ startMode: body.startMode ?? current.startMode,
115
118
  };
116
119
 
117
120
  writeSettings(next);
@@ -0,0 +1,23 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextResponse } from 'next/server';
3
+ import { randomBytes, createHash } from 'crypto';
4
+
5
+ export async function POST(req: Request) {
6
+ try {
7
+ const { seed } = await req.json().catch(() => ({} as { seed?: string }));
8
+ let raw: string;
9
+ if (seed && typeof seed === 'string' && seed.trim()) {
10
+ raw = createHash('sha256').update(seed.trim()).digest('hex').slice(0, 24);
11
+ } else {
12
+ raw = randomBytes(12).toString('hex'); // 24 hex chars
13
+ }
14
+ // Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
15
+ const token = raw.match(/.{4}/g)!.join('-');
16
+ return NextResponse.json({ token });
17
+ } catch (e) {
18
+ return NextResponse.json(
19
+ { error: e instanceof Error ? e.message : String(e) },
20
+ { status: 500 },
21
+ );
22
+ }
23
+ }
@@ -0,0 +1,81 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import { readSettings, writeSettings, ServerSettings } from '@/lib/settings';
6
+ import { applyTemplate } from '@/lib/template';
7
+
8
+ function expandHome(p: string): string {
9
+ if (p.startsWith('~/')) return p.replace('~', os.homedir());
10
+ if (p === '~') return os.homedir();
11
+ return p;
12
+ }
13
+
14
+ export async function POST(req: NextRequest) {
15
+ try {
16
+ const body = await req.json();
17
+ const { mindRoot, template, port, mcpPort, authToken, webPassword, ai } = body;
18
+
19
+ // Validate required fields
20
+ if (!mindRoot || typeof mindRoot !== 'string') {
21
+ return NextResponse.json({ error: 'mindRoot is required' }, { status: 400 });
22
+ }
23
+
24
+ const resolvedRoot = expandHome(mindRoot.trim());
25
+
26
+ // Validate ports
27
+ const webPort = typeof port === 'number' ? port : 3000;
28
+ const mcpPortNum = typeof mcpPort === 'number' ? mcpPort : 8787;
29
+ if (webPort < 1024 || webPort > 65535) {
30
+ return NextResponse.json({ error: `Invalid web port: ${webPort}` }, { status: 400 });
31
+ }
32
+ if (mcpPortNum < 1024 || mcpPortNum > 65535) {
33
+ return NextResponse.json({ error: `Invalid MCP port: ${mcpPortNum}` }, { status: 400 });
34
+ }
35
+
36
+ // Apply template if mindRoot doesn't exist or is empty
37
+ const dirExists = fs.existsSync(resolvedRoot);
38
+ let dirEmpty = true;
39
+ if (dirExists) {
40
+ try {
41
+ const entries = fs.readdirSync(resolvedRoot).filter(e => !e.startsWith('.'));
42
+ dirEmpty = entries.length === 0;
43
+ } catch { /* treat as empty */ }
44
+ }
45
+
46
+ if (template && (!dirExists || dirEmpty)) {
47
+ applyTemplate(template, resolvedRoot);
48
+ } else if (!dirExists) {
49
+ fs.mkdirSync(resolvedRoot, { recursive: true });
50
+ }
51
+
52
+ // Read current running port for portChanged detection
53
+ const current = readSettings();
54
+ const currentPort = current.port ?? 3000;
55
+
56
+ // Build config
57
+ const config: ServerSettings = {
58
+ ai: ai ?? current.ai,
59
+ mindRoot: resolvedRoot,
60
+ port: webPort,
61
+ mcpPort: mcpPortNum,
62
+ authToken: authToken ?? current.authToken,
63
+ webPassword: webPassword ?? '',
64
+ startMode: current.startMode,
65
+ setupPending: false, // clear the flag
66
+ };
67
+
68
+ writeSettings(config);
69
+
70
+ return NextResponse.json({
71
+ ok: true,
72
+ portChanged: webPort !== currentPort,
73
+ });
74
+ } catch (e) {
75
+ console.error('[/api/setup] Error:', e);
76
+ return NextResponse.json(
77
+ { error: e instanceof Error ? e.message : String(e) },
78
+ { status: 500 },
79
+ );
80
+ }
81
+ }
@@ -0,0 +1,208 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { readSettings, writeSettings } from '@/lib/settings';
7
+
8
+ const PROJECT_ROOT = path.resolve(process.cwd(), '..');
9
+
10
+ function getMindRoot(): string {
11
+ const s = readSettings();
12
+ return s.mindRoot || process.env.MIND_ROOT || path.join(os.homedir(), 'MindOS');
13
+ }
14
+
15
+ interface SkillInfo {
16
+ name: string;
17
+ description: string;
18
+ path: string;
19
+ source: 'builtin' | 'user';
20
+ enabled: boolean;
21
+ editable: boolean;
22
+ }
23
+
24
+ function parseSkillMd(content: string): { name: string; description: string } {
25
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
26
+ if (!match) return { name: '', description: '' };
27
+ const yaml = match[1];
28
+ const nameMatch = yaml.match(/^name:\s*(.+)/m);
29
+ const descMatch = yaml.match(/^description:\s*>?\s*\n?([\s\S]*?)(?=\n\w|\n---)/m);
30
+ const name = nameMatch ? nameMatch[1].trim() : '';
31
+ let description = '';
32
+ if (descMatch) {
33
+ description = descMatch[1].trim().split('\n').map(l => l.trim()).join(' ').slice(0, 200);
34
+ } else {
35
+ const simpleDesc = yaml.match(/^description:\s*(.+)/m);
36
+ if (simpleDesc) description = simpleDesc[1].trim().slice(0, 200);
37
+ }
38
+ return { name, description };
39
+ }
40
+
41
+ function scanSkillDirs(disabledSkills: string[]): SkillInfo[] {
42
+ const skills: SkillInfo[] = [];
43
+ const seen = new Set<string>();
44
+
45
+ // 1. app/data/skills/ — builtin
46
+ const builtinDir = path.join(PROJECT_ROOT, 'app', 'data', 'skills');
47
+ if (fs.existsSync(builtinDir)) {
48
+ for (const entry of fs.readdirSync(builtinDir, { withFileTypes: true })) {
49
+ if (!entry.isDirectory()) continue;
50
+ const skillFile = path.join(builtinDir, entry.name, 'SKILL.md');
51
+ if (!fs.existsSync(skillFile)) continue;
52
+ const content = fs.readFileSync(skillFile, 'utf-8');
53
+ const { name, description } = parseSkillMd(content);
54
+ const skillName = name || entry.name;
55
+ seen.add(skillName);
56
+ skills.push({
57
+ name: skillName,
58
+ description,
59
+ path: `app/data/skills/${entry.name}/SKILL.md`,
60
+ source: 'builtin',
61
+ enabled: !disabledSkills.includes(skillName),
62
+ editable: false,
63
+ });
64
+ }
65
+ }
66
+
67
+ // 2. skills/ — project root builtin
68
+ const skillsDir = path.join(PROJECT_ROOT, 'skills');
69
+ if (fs.existsSync(skillsDir)) {
70
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
71
+ if (!entry.isDirectory()) continue;
72
+ const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
73
+ if (!fs.existsSync(skillFile)) continue;
74
+ const content = fs.readFileSync(skillFile, 'utf-8');
75
+ const { name, description } = parseSkillMd(content);
76
+ const skillName = name || entry.name;
77
+ if (seen.has(skillName)) continue; // already listed from app/data/skills/
78
+ seen.add(skillName);
79
+ skills.push({
80
+ name: skillName,
81
+ description,
82
+ path: `skills/${entry.name}/SKILL.md`,
83
+ source: 'builtin',
84
+ enabled: !disabledSkills.includes(skillName),
85
+ editable: false,
86
+ });
87
+ }
88
+ }
89
+
90
+ // 3. {mindRoot}/.skills/ — user custom
91
+ const mindRoot = getMindRoot();
92
+ const userSkillsDir = path.join(mindRoot, '.skills');
93
+ if (fs.existsSync(userSkillsDir)) {
94
+ for (const entry of fs.readdirSync(userSkillsDir, { withFileTypes: true })) {
95
+ if (!entry.isDirectory()) continue;
96
+ const skillFile = path.join(userSkillsDir, entry.name, 'SKILL.md');
97
+ if (!fs.existsSync(skillFile)) continue;
98
+ const content = fs.readFileSync(skillFile, 'utf-8');
99
+ const { name, description } = parseSkillMd(content);
100
+ const skillName = name || entry.name;
101
+ if (seen.has(skillName)) continue;
102
+ seen.add(skillName);
103
+ skills.push({
104
+ name: skillName,
105
+ description,
106
+ path: `{mindRoot}/.skills/${entry.name}/SKILL.md`,
107
+ source: 'user',
108
+ enabled: !disabledSkills.includes(skillName),
109
+ editable: true,
110
+ });
111
+ }
112
+ }
113
+
114
+ return skills;
115
+ }
116
+
117
+ export async function GET() {
118
+ try {
119
+ const settings = readSettings();
120
+ const disabledSkills = settings.disabledSkills ?? [];
121
+ const skills = scanSkillDirs(disabledSkills);
122
+ return NextResponse.json({ skills });
123
+ } catch (err) {
124
+ return NextResponse.json({ error: String(err) }, { status: 500 });
125
+ }
126
+ }
127
+
128
+ export async function POST(req: NextRequest) {
129
+ try {
130
+ const body = await req.json();
131
+ const { action, name, description, content, enabled } = body as {
132
+ action: 'create' | 'update' | 'delete' | 'toggle';
133
+ name?: string;
134
+ description?: string;
135
+ content?: string;
136
+ enabled?: boolean;
137
+ };
138
+
139
+ const settings = readSettings();
140
+ const mindRoot = getMindRoot();
141
+ const userSkillsDir = path.join(mindRoot, '.skills');
142
+
143
+ // Validate skill name — prevent path traversal
144
+ if (name && !/^[a-z0-9][a-z0-9-]*$/.test(name)) {
145
+ return NextResponse.json({ error: 'Invalid skill name. Use lowercase letters, numbers, and hyphens only.' }, { status: 400 });
146
+ }
147
+
148
+ switch (action) {
149
+ case 'toggle': {
150
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
151
+ const disabled = settings.disabledSkills ?? [];
152
+ if (enabled === false) {
153
+ if (!disabled.includes(name)) disabled.push(name);
154
+ } else {
155
+ const idx = disabled.indexOf(name);
156
+ if (idx >= 0) disabled.splice(idx, 1);
157
+ }
158
+ writeSettings({ ...settings, disabledSkills: disabled });
159
+ return NextResponse.json({ ok: true });
160
+ }
161
+
162
+ case 'create': {
163
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
164
+ // Check for conflicts with builtin
165
+ const builtinDir = path.join(PROJECT_ROOT, 'app', 'data', 'skills', name);
166
+ const skillsRootDir = path.join(PROJECT_ROOT, 'skills', name);
167
+ if (fs.existsSync(builtinDir) || fs.existsSync(skillsRootDir)) {
168
+ return NextResponse.json({ error: 'A built-in skill with this name already exists' }, { status: 409 });
169
+ }
170
+ const skillDir = path.join(userSkillsDir, name);
171
+ if (fs.existsSync(skillDir)) {
172
+ return NextResponse.json({ error: 'A skill with this name already exists' }, { status: 409 });
173
+ }
174
+ fs.mkdirSync(skillDir, { recursive: true });
175
+ const frontmatter = `---\nname: ${name}\ndescription: ${description || name}\n---\n\n${content || ''}`;
176
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), frontmatter, 'utf-8');
177
+ return NextResponse.json({ ok: true });
178
+ }
179
+
180
+ case 'update': {
181
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
182
+ const skillDir = path.join(userSkillsDir, name);
183
+ if (!fs.existsSync(skillDir)) {
184
+ return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
185
+ }
186
+ if (content !== undefined) {
187
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
188
+ }
189
+ return NextResponse.json({ ok: true });
190
+ }
191
+
192
+ case 'delete': {
193
+ if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
194
+ const skillDir = path.join(userSkillsDir, name);
195
+ if (!fs.existsSync(skillDir)) {
196
+ return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
197
+ }
198
+ fs.rmSync(skillDir, { recursive: true, force: true });
199
+ return NextResponse.json({ ok: true });
200
+ }
201
+
202
+ default:
203
+ return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
204
+ }
205
+ } catch (err) {
206
+ return NextResponse.json({ error: String(err) }, { status: 500 });
207
+ }
208
+ }
@@ -1,8 +1,8 @@
1
1
  export const dynamic = 'force-dynamic';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
- import { execSync } from 'child_process';
3
+ import { execSync, execFile } from 'child_process';
4
4
  import { existsSync, readFileSync, writeFileSync } from 'fs';
5
- import { join } from 'path';
5
+ import { join, resolve } from 'path';
6
6
  import { homedir } from 'os';
7
7
 
8
8
  const MINDOS_DIR = join(homedir(), '.mindos');
@@ -68,7 +68,7 @@ export async function GET() {
68
68
 
69
69
  export async function POST(req: NextRequest) {
70
70
  try {
71
- const body = await req.json() as { action: string };
71
+ const body = await req.json() as { action: string; remote?: string; branch?: string; token?: string };
72
72
  const config = loadConfig();
73
73
  const mindRoot = config.mindRoot;
74
74
 
@@ -77,6 +77,57 @@ export async function POST(req: NextRequest) {
77
77
  }
78
78
 
79
79
  switch (body.action) {
80
+ case 'init': {
81
+ const remote = body.remote?.trim();
82
+ if (!remote) {
83
+ return NextResponse.json({ error: 'Remote URL is required' }, { status: 400 });
84
+ }
85
+
86
+ // Validate URL format
87
+ const isHTTPS = remote.startsWith('https://');
88
+ const isSSH = /^git@[\w.-]+:.+/.test(remote);
89
+ if (!isHTTPS && !isSSH) {
90
+ return NextResponse.json({ error: 'Invalid remote URL — must be HTTPS or SSH format' }, { status: 400 });
91
+ }
92
+
93
+ // Check if sync is already configured
94
+ if (config.sync?.enabled && isGitRepo(mindRoot) && getRemoteUrl(mindRoot)) {
95
+ return NextResponse.json({ error: 'Sync already configured' }, { status: 400 });
96
+ }
97
+
98
+ // Build the effective remote URL (inject token for HTTPS)
99
+ let effectiveRemote = remote;
100
+ if (isHTTPS && body.token) {
101
+ try {
102
+ const urlObj = new URL(remote);
103
+ urlObj.username = 'oauth2';
104
+ urlObj.password = body.token;
105
+ effectiveRemote = urlObj.toString();
106
+ } catch {
107
+ return NextResponse.json({ error: 'Invalid remote URL' }, { status: 400 });
108
+ }
109
+ }
110
+
111
+ const branch = body.branch?.trim() || 'main';
112
+
113
+ // Call CLI's sync init via execFile (avoids module resolution issues with Turbopack)
114
+ try {
115
+ const cliPath = resolve(process.cwd(), '..', 'bin', 'cli.js');
116
+ const args = ['sync', 'init', '--non-interactive', '--remote', effectiveRemote, '--branch', branch];
117
+
118
+ await new Promise<void>((res, rej) => {
119
+ execFile('node', [cliPath, ...args], { timeout: 30000 }, (err, stdout, stderr) => {
120
+ if (err) rej(new Error(stderr?.trim() || err.message));
121
+ else res();
122
+ });
123
+ });
124
+ return NextResponse.json({ success: true, message: 'Sync initialized' });
125
+ } catch (err: unknown) {
126
+ const errMsg = err instanceof Error ? err.message : String(err);
127
+ return NextResponse.json({ error: errMsg }, { status: 400 });
128
+ }
129
+ }
130
+
80
131
  case 'now': {
81
132
  if (!isGitRepo(mindRoot)) {
82
133
  return NextResponse.json({ error: 'Not a git repository' }, { status: 400 });