mdboard 1.1.0 → 1.3.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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * mdboard — Agent configuration scanner
3
+ *
4
+ * Scans projectDir for agent/AI configuration files and returns
5
+ * suggestions for autocomplete in the AI properties UI.
6
+ *
7
+ * Supported agents:
8
+ * Claude Code, Codex CLI, Gemini CLI, Cursor, VS Code Copilot,
9
+ * Windsurf, Cline, KiloCode, OpenClaw
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ function safeReadFile(filePath) {
16
+ try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
17
+ }
18
+
19
+ function safeDirEntries(dir) {
20
+ try { return fs.readdirSync(dir).filter(e => !e.startsWith('.')); } catch { return []; }
21
+ }
22
+
23
+ function safeDirEntriesRecursive(dir, prefix) {
24
+ const results = [];
25
+ for (const entry of safeDirEntries(dir)) {
26
+ const full = path.join(dir, entry);
27
+ const rel = prefix ? prefix + '/' + entry : entry;
28
+ try {
29
+ if (fs.statSync(full).isDirectory()) {
30
+ if (fs.existsSync(path.join(full, 'SKILL.md'))) {
31
+ results.push(rel);
32
+ }
33
+ results.push(...safeDirEntriesRecursive(full, rel));
34
+ } else if (entry.endsWith('.md')) {
35
+ results.push(rel);
36
+ }
37
+ } catch { /* skip */ }
38
+ }
39
+ return results;
40
+ }
41
+
42
+ /** Push value to array if not already present */
43
+ function pushUnique(arr, value) {
44
+ if (arr.indexOf(value) === -1) arr.push(value);
45
+ }
46
+
47
+ /** Collect .md files from a directory into an array (non-recursive) */
48
+ function collectMdFiles(dir, arr, prefix) {
49
+ for (const entry of safeDirEntries(dir)) {
50
+ if (entry.endsWith('.md') || entry.endsWith('.mdc') || entry.endsWith('.agent.md')) {
51
+ pushUnique(arr, prefix ? prefix + '/' + entry : entry);
52
+ }
53
+ }
54
+ }
55
+
56
+ /** Collect all files from a directory into an array (non-recursive) */
57
+ function collectAllFiles(dir, arr, prefix) {
58
+ for (const entry of safeDirEntries(dir)) {
59
+ const full = path.join(dir, entry);
60
+ try {
61
+ if (!fs.statSync(full).isDirectory()) {
62
+ pushUnique(arr, prefix ? prefix + '/' + entry : entry);
63
+ }
64
+ } catch { /* skip */ }
65
+ }
66
+ }
67
+
68
+ /** Extract mcpServers keys from a JSON file */
69
+ function extractMcpServersFromJson(filePath, arr) {
70
+ const content = safeReadFile(filePath);
71
+ if (!content) return;
72
+ try {
73
+ const parsed = JSON.parse(content);
74
+ const servers = parsed.mcpServers || parsed;
75
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) {
76
+ for (const key of Object.keys(servers)) {
77
+ if (key !== '$schema' && key !== 'mcpServers') pushUnique(arr, key);
78
+ }
79
+ }
80
+ } catch { /* invalid JSON */ }
81
+ }
82
+
83
+ /** Check if a file exists and push to context */
84
+ function checkContextFile(projectDir, relativePath, arr) {
85
+ if (fs.existsSync(path.join(projectDir, relativePath))) {
86
+ pushUnique(arr, relativePath);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Scan projectDir for agent configuration files across all supported agents.
92
+ *
93
+ * @param {string} projectDir - Root of the repo
94
+ * @returns {{ skills: string[], agents: string[], mcps: string[], commands: string[], context: string[] }}
95
+ */
96
+ function scanAgentConfigs(projectDir) {
97
+ const result = { skills: [], agents: [], mcps: [], commands: [], context: [] };
98
+
99
+ // ─── SKILLS ───────────────────────────────────────────────
100
+
101
+ // Claude Code: .claude/skills/ — .md files and subdirs with SKILL.md
102
+ result.skills = safeDirEntriesRecursive(path.join(projectDir, '.claude', 'skills'), '');
103
+
104
+ // Codex CLI: .codex/skills/, .agents/skills/
105
+ for (const dir of ['.codex/skills', '.agents/skills']) {
106
+ const entries = safeDirEntriesRecursive(path.join(projectDir, ...dir.split('/')), '');
107
+ for (const e of entries) pushUnique(result.skills, e);
108
+ }
109
+
110
+ // ─── AGENTS ───────────────────────────────────────────────
111
+
112
+ // Claude Code: .claude/agents/*.md
113
+ collectMdFiles(path.join(projectDir, '.claude', 'agents'), result.agents, '');
114
+
115
+ // Cursor: .cursor/agents/*.md
116
+ collectMdFiles(path.join(projectDir, '.cursor', 'agents'), result.agents, '');
117
+
118
+ // VS Code Copilot: .github/agents/*.md
119
+ collectMdFiles(path.join(projectDir, '.github', 'agents'), result.agents, '');
120
+
121
+ // ─── MCP SERVERS ──────────────────────────────────────────
122
+
123
+ // Claude Code: .mcp.json (project-level)
124
+ extractMcpServersFromJson(path.join(projectDir, '.mcp.json'), result.mcps);
125
+
126
+ // Claude Code: .claude/settings.local.json — enabledMcpjsonServers
127
+ const settingsJson = safeReadFile(path.join(projectDir, '.claude', 'settings.local.json'));
128
+ if (settingsJson) {
129
+ try {
130
+ const parsed = JSON.parse(settingsJson);
131
+ if (Array.isArray(parsed.enabledMcpjsonServers)) {
132
+ for (const srv of parsed.enabledMcpjsonServers) {
133
+ if (typeof srv === 'string') pushUnique(result.mcps, srv);
134
+ }
135
+ }
136
+ } catch { /* invalid JSON */ }
137
+ }
138
+
139
+ // Cursor: .cursor/mcp.json
140
+ extractMcpServersFromJson(path.join(projectDir, '.cursor', 'mcp.json'), result.mcps);
141
+
142
+ // VS Code Copilot: .vscode/mcp.json
143
+ extractMcpServersFromJson(path.join(projectDir, '.vscode', 'mcp.json'), result.mcps);
144
+
145
+ // KiloCode: .kilocode/mcp.json
146
+ extractMcpServersFromJson(path.join(projectDir, '.kilocode', 'mcp.json'), result.mcps);
147
+
148
+ // Gemini CLI: .gemini/settings.json — mcpServers
149
+ const geminiSettings = safeReadFile(path.join(projectDir, '.gemini', 'settings.json'));
150
+ if (geminiSettings) {
151
+ try {
152
+ const parsed = JSON.parse(geminiSettings);
153
+ if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
154
+ for (const key of Object.keys(parsed.mcpServers)) {
155
+ pushUnique(result.mcps, key);
156
+ }
157
+ }
158
+ } catch { /* invalid JSON */ }
159
+ }
160
+
161
+ // Codex CLI: .codex/config.toml — [mcp_servers.<name>]
162
+ const codexConfig = safeReadFile(path.join(projectDir, '.codex', 'config.toml'));
163
+ if (codexConfig) {
164
+ const mcpMatches = codexConfig.matchAll(/\[mcp_servers\.(\w[\w-]*)\]/g);
165
+ for (const m of mcpMatches) pushUnique(result.mcps, m[1]);
166
+ }
167
+
168
+ // ─── COMMANDS ─────────────────────────────────────────────
169
+
170
+ // Claude Code: .claude/commands/
171
+ collectAllFiles(path.join(projectDir, '.claude', 'commands'), result.commands, '');
172
+
173
+ // Gemini CLI: .gemini/commands/ (.toml files)
174
+ collectAllFiles(path.join(projectDir, '.gemini', 'commands'), result.commands, '');
175
+
176
+ // VS Code Copilot: .github/prompts/ (.prompt.md files)
177
+ collectAllFiles(path.join(projectDir, '.github', 'prompts'), result.commands, '');
178
+
179
+ // Windsurf: .windsurf/workflows/
180
+ collectAllFiles(path.join(projectDir, '.windsurf', 'workflows'), result.commands, '');
181
+
182
+ // CLAUDE.md — parse dev commands section
183
+ const claudeMd = safeReadFile(path.join(projectDir, 'CLAUDE.md'))
184
+ || safeReadFile(path.join(projectDir, '.claude', 'CLAUDE.md'));
185
+ if (claudeMd) {
186
+ const lines = claudeMd.split('\n');
187
+ let inCommands = false;
188
+ for (const line of lines) {
189
+ if (/^#+\s*.*(command|script|dev)/i.test(line)) {
190
+ inCommands = true;
191
+ continue;
192
+ }
193
+ if (inCommands && /^#+\s/.test(line)) {
194
+ inCommands = false;
195
+ continue;
196
+ }
197
+ if (inCommands) {
198
+ const match = line.match(/^[-*]\s*`([^`]+)`/);
199
+ if (match) pushUnique(result.commands, match[1]);
200
+ }
201
+ }
202
+ }
203
+
204
+ // ─── CONTEXT / RULES ─────────────────────────────────────
205
+
206
+ // Claude Code
207
+ checkContextFile(projectDir, 'CLAUDE.md', result.context);
208
+ checkContextFile(projectDir, '.claude/CLAUDE.md', result.context);
209
+ checkContextFile(projectDir, 'CLAUDE.local.md', result.context);
210
+ collectMdFiles(path.join(projectDir, '.claude', 'rules'), result.context, '.claude/rules');
211
+
212
+ // Cursor
213
+ checkContextFile(projectDir, '.cursorrules', result.context);
214
+ for (const entry of safeDirEntries(path.join(projectDir, '.cursor', 'rules'))) {
215
+ pushUnique(result.context, '.cursor/rules/' + entry);
216
+ }
217
+
218
+ // Codex CLI
219
+ checkContextFile(projectDir, 'AGENTS.md', result.context);
220
+ checkContextFile(projectDir, 'AGENTS.override.md', result.context);
221
+
222
+ // Gemini CLI
223
+ checkContextFile(projectDir, 'GEMINI.md', result.context);
224
+
225
+ // Windsurf
226
+ checkContextFile(projectDir, '.windsurfrules', result.context);
227
+ for (const entry of safeDirEntries(path.join(projectDir, '.windsurf', 'rules'))) {
228
+ pushUnique(result.context, '.windsurf/rules/' + entry);
229
+ }
230
+
231
+ // Cline
232
+ checkContextFile(projectDir, '.clinerules', result.context);
233
+ // .clinerules/ as directory
234
+ for (const entry of safeDirEntries(path.join(projectDir, '.clinerules'))) {
235
+ pushUnique(result.context, '.clinerules/' + entry);
236
+ }
237
+
238
+ // KiloCode
239
+ checkContextFile(projectDir, '.kilocoderules', result.context);
240
+ for (const entry of safeDirEntries(path.join(projectDir, '.kilocode', 'rules'))) {
241
+ pushUnique(result.context, '.kilocode/rules/' + entry);
242
+ }
243
+
244
+ // VS Code Copilot
245
+ checkContextFile(projectDir, '.github/copilot-instructions.md', result.context);
246
+ for (const entry of safeDirEntries(path.join(projectDir, '.github', 'instructions'))) {
247
+ pushUnique(result.context, '.github/instructions/' + entry);
248
+ }
249
+
250
+ // OpenClaw
251
+ checkContextFile(projectDir, 'SOUL.md', result.context);
252
+ checkContextFile(projectDir, 'IDENTITY.md', result.context);
253
+ checkContextFile(projectDir, 'USER.md', result.context);
254
+ checkContextFile(projectDir, 'TOOLS.md', result.context);
255
+ checkContextFile(projectDir, 'MEMORY.md', result.context);
256
+
257
+ return result;
258
+ }
259
+
260
+ module.exports = { scanAgentConfigs };
@@ -13,7 +13,7 @@ const fs = require('fs');
13
13
  const path = require('path');
14
14
  const os = require('os');
15
15
 
16
- const defaults = require('./defaults.json');
16
+ const defaults = require('../../defaults.json');
17
17
 
18
18
  function deepMerge(target, source) {
19
19
  const result = { ...target };
@@ -70,4 +70,29 @@ function loadConfig(projectDir, explicitPath) {
70
70
  return config;
71
71
  }
72
72
 
73
- module.exports = { loadConfig };
73
+ function resolveTheme(projectDir, explicitPath) {
74
+ const candidates = [];
75
+
76
+ if (explicitPath) {
77
+ candidates.push(path.resolve(explicitPath));
78
+ }
79
+
80
+ if (projectDir) {
81
+ candidates.push(path.join(projectDir, 'project', 'mdboard.json'));
82
+ candidates.push(path.join(projectDir, 'mdboard.json'));
83
+ }
84
+
85
+ const globalPath = path.join(os.homedir(), '.config', 'mdboard', 'mdboard.json');
86
+ candidates.push(globalPath);
87
+
88
+ for (const p of candidates) {
89
+ const data = tryReadJson(p);
90
+ if (data && data.theme) {
91
+ return data.theme;
92
+ }
93
+ }
94
+
95
+ return null;
96
+ }
97
+
98
+ module.exports = { loadConfig, deepMerge, resolveTheme };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * mdboard — Project History
3
+ *
4
+ * Persistent history of visited projects stored in
5
+ * ~/.config/mdboard/history.json. Allows switching between
6
+ * projects without restarting the server.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+
13
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'mdboard');
14
+ const HISTORY_FILE = path.join(CONFIG_DIR, 'history.json');
15
+ const MAX_ENTRIES = 50;
16
+
17
+ /**
18
+ * Read the history file.
19
+ * @returns {Array<{path:string, name:string, lastOpened:string}>}
20
+ */
21
+ function readHistory() {
22
+ try {
23
+ const raw = fs.readFileSync(HISTORY_FILE, 'utf-8');
24
+ const data = JSON.parse(raw);
25
+ return Array.isArray(data) ? data : [];
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Write history to disk.
33
+ * @param {Array} entries
34
+ */
35
+ function writeHistory(entries) {
36
+ try {
37
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
38
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
39
+ } catch {
40
+ // Non-critical — silently ignore write failures
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Register (or update) a project in the history.
46
+ * Moves the entry to the front of the list.
47
+ *
48
+ * @param {string} projectDir - Absolute path to the project directory
49
+ * @param {string} name - Human-friendly project name
50
+ */
51
+ function registerProject(projectDir, name) {
52
+ const absPath = path.resolve(projectDir);
53
+ let entries = readHistory();
54
+
55
+ // Remove existing entry for this path
56
+ entries = entries.filter(e => e.path !== absPath);
57
+
58
+ // Prepend new entry
59
+ entries.unshift({
60
+ path: absPath,
61
+ name: name || path.basename(absPath),
62
+ lastOpened: new Date().toISOString(),
63
+ });
64
+
65
+ // Enforce limit
66
+ if (entries.length > MAX_ENTRIES) {
67
+ entries = entries.slice(0, MAX_ENTRIES);
68
+ }
69
+
70
+ writeHistory(entries);
71
+ }
72
+
73
+ /**
74
+ * Get the history list with a `isCurrent` flag.
75
+ *
76
+ * @param {string} currentProjectDir - The currently-active project dir
77
+ * @returns {Array<{path:string, name:string, lastOpened:string, isCurrent:boolean}>}
78
+ */
79
+ function getHistory(currentProjectDir) {
80
+ const absCurrentDir = currentProjectDir ? path.resolve(currentProjectDir) : null;
81
+ const entries = readHistory();
82
+
83
+ return entries.map(e => ({
84
+ ...e,
85
+ isCurrent: e.path === absCurrentDir,
86
+ }));
87
+ }
88
+
89
+ /**
90
+ * Detect whether a directory looks like a valid mdboard project.
91
+ * A project has either a `project/` subdirectory or a `workspace.json`
92
+ * with sources.
93
+ *
94
+ * @param {string} dir - Directory to check
95
+ * @returns {boolean}
96
+ */
97
+ function isValidProject(dir) {
98
+ try {
99
+ const absDir = path.resolve(dir);
100
+
101
+ // Check for project/ sub-directory
102
+ if (fs.existsSync(path.join(absDir, 'project'))) return true;
103
+
104
+ // Check for workspace.json with sources
105
+ const wsPath = path.join(absDir, 'workspace.json');
106
+ if (fs.existsSync(wsPath)) {
107
+ const raw = fs.readFileSync(wsPath, 'utf-8');
108
+ const ws = JSON.parse(raw);
109
+ if (ws && Array.isArray(ws.sources) && ws.sources.length > 0) return true;
110
+ }
111
+
112
+ return false;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Remove an entry from the history.
120
+ *
121
+ * @param {string} projectPath - Absolute path to remove
122
+ */
123
+ function removeFromHistory(projectPath) {
124
+ const absPath = path.resolve(projectPath);
125
+ let entries = readHistory();
126
+ entries = entries.filter(e => e.path !== absPath);
127
+ writeHistory(entries);
128
+ }
129
+
130
+ module.exports = { readHistory, registerProject, getHistory, isValidProject, removeFromHistory };