@creately/rdm-mcp 0.1.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.
- package/package.json +24 -0
- package/src/index.ts +97 -0
- package/src/tools/rdm_from_csv.ts +316 -0
- package/src/tools/rdm_init.ts +146 -0
- package/src/tools/rdm_read.ts +41 -0
- package/src/tools/rdm_render.ts +58 -0
- package/src/tools/rdm_schema.ts +135 -0
- package/src/tools/rdm_summary.ts +82 -0
- package/src/tools/rdm_validate.ts +60 -0
- package/src/tools/rdm_write.ts +107 -0
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@creately/rdm-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for RDM — parse, validate, render, and manipulate diagrams via AI agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"rdm-mcp": "src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "bun run src/index.ts",
|
|
12
|
+
"dev": "bun run --watch src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
16
|
+
"@creately/rdm-core": "workspace:*"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/creately/rdm",
|
|
22
|
+
"directory": "packages/rdm-mcp"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RDM MCP Server
|
|
3
|
+
*
|
|
4
|
+
* Provides 8 tools for agents to interact with RDM diagrams:
|
|
5
|
+
* rdm_read — Full .rdm text (rich context)
|
|
6
|
+
* rdm_summary — Lightweight: node count, types, relationships
|
|
7
|
+
* rdm_write — Write/update .rdm file
|
|
8
|
+
* rdm_render — Get SVG of current diagram
|
|
9
|
+
* rdm_validate — Check for errors with line numbers
|
|
10
|
+
* rdm_schema — Domain schema: valid fields, relationships, examples
|
|
11
|
+
* rdm_from_csv — Generate .rdm from structured data file
|
|
12
|
+
* rdm_init — Analyze project, generate conventions
|
|
13
|
+
*
|
|
14
|
+
* Supports both stdio and HTTP transport.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* bun run packages/rdm-mcp/src/index.ts # stdio mode
|
|
18
|
+
* bun run packages/rdm-mcp/src/index.ts --http 3100 # HTTP mode
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
22
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
23
|
+
import {
|
|
24
|
+
CallToolRequestSchema,
|
|
25
|
+
ListToolsRequestSchema,
|
|
26
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
27
|
+
|
|
28
|
+
import { rdmReadTool, executeRdmRead } from './tools/rdm_read.js';
|
|
29
|
+
import { rdmSummaryTool, executeRdmSummary } from './tools/rdm_summary.js';
|
|
30
|
+
import { rdmWriteTool, executeRdmWrite } from './tools/rdm_write.js';
|
|
31
|
+
import { rdmRenderTool, executeRdmRender } from './tools/rdm_render.js';
|
|
32
|
+
import { rdmValidateTool, executeRdmValidate } from './tools/rdm_validate.js';
|
|
33
|
+
import { rdmSchemaTool, executeRdmSchema } from './tools/rdm_schema.js';
|
|
34
|
+
import { rdmFromCsvTool, executeRdmFromCsv } from './tools/rdm_from_csv.js';
|
|
35
|
+
import { rdmInitTool, executeRdmInit } from './tools/rdm_init.js';
|
|
36
|
+
|
|
37
|
+
const ALL_TOOLS = [
|
|
38
|
+
rdmReadTool,
|
|
39
|
+
rdmSummaryTool,
|
|
40
|
+
rdmWriteTool,
|
|
41
|
+
rdmRenderTool,
|
|
42
|
+
rdmValidateTool,
|
|
43
|
+
rdmSchemaTool,
|
|
44
|
+
rdmFromCsvTool,
|
|
45
|
+
rdmInitTool,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const TOOL_EXECUTORS: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
49
|
+
rdm_read: executeRdmRead,
|
|
50
|
+
rdm_summary: executeRdmSummary,
|
|
51
|
+
rdm_write: executeRdmWrite,
|
|
52
|
+
rdm_render: executeRdmRender,
|
|
53
|
+
rdm_validate: executeRdmValidate,
|
|
54
|
+
rdm_schema: executeRdmSchema,
|
|
55
|
+
rdm_from_csv: executeRdmFromCsv,
|
|
56
|
+
rdm_init: executeRdmInit,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
async function main() {
|
|
60
|
+
const server = new Server(
|
|
61
|
+
{ name: 'rdm-mcp', version: '0.1.0' },
|
|
62
|
+
{ capabilities: { tools: {} } }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
66
|
+
tools: ALL_TOOLS,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
70
|
+
const { name, arguments: args } = request.params;
|
|
71
|
+
const executor = TOOL_EXECUTORS[name];
|
|
72
|
+
|
|
73
|
+
if (!executor) {
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
76
|
+
isError: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const result = await executor(args ?? {});
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }],
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
|
|
88
|
+
isError: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const transport = new StdioServerTransport();
|
|
94
|
+
await server.connect(transport);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdm_from_csv — Generate .rdm from CSV data
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
5
|
+
import { resolve, dirname, basename } from 'path';
|
|
6
|
+
|
|
7
|
+
export const rdmFromCsvTool = {
|
|
8
|
+
name: 'rdm_from_csv',
|
|
9
|
+
description:
|
|
10
|
+
'Generate an .rdm file from a CSV data file. Automatically maps columns ' +
|
|
11
|
+
'to RDM fields based on the target domain (orgchart, genogram, process).',
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: 'object' as const,
|
|
14
|
+
properties: {
|
|
15
|
+
csvPath: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Path to the CSV file',
|
|
18
|
+
},
|
|
19
|
+
domain: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
enum: ['orgchart', 'genogram', 'process'],
|
|
22
|
+
description: 'Target diagram domain',
|
|
23
|
+
},
|
|
24
|
+
outputPath: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Output .rdm file path (defaults to same directory as CSV)',
|
|
27
|
+
},
|
|
28
|
+
columnMapping: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
description:
|
|
31
|
+
'Optional manual column mapping: { csvColumn: rdmField }. ' +
|
|
32
|
+
'If not provided, columns are auto-mapped by name.',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ['csvPath', 'domain'],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export async function executeRdmFromCsv(args: Record<string, unknown>): Promise<unknown> {
|
|
40
|
+
const csvPath = resolve(String(args.csvPath ?? ''));
|
|
41
|
+
const domain = String(args.domain ?? 'orgchart');
|
|
42
|
+
const columnMapping = (args.columnMapping as Record<string, string>) ?? {};
|
|
43
|
+
|
|
44
|
+
if (!existsSync(csvPath)) {
|
|
45
|
+
return { error: `CSV file not found: ${csvPath}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const csvContent = readFileSync(csvPath, 'utf-8');
|
|
49
|
+
const rows = parseSimpleCsv(csvContent);
|
|
50
|
+
|
|
51
|
+
if (rows.length < 2) {
|
|
52
|
+
return { error: 'CSV must have at least a header row and one data row' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const headers = rows[0];
|
|
56
|
+
const data = rows.slice(1);
|
|
57
|
+
|
|
58
|
+
// Auto-map columns if no explicit mapping
|
|
59
|
+
const mapping = Object.keys(columnMapping).length > 0
|
|
60
|
+
? columnMapping
|
|
61
|
+
: autoMapColumns(headers, domain);
|
|
62
|
+
|
|
63
|
+
let rdmText: string;
|
|
64
|
+
|
|
65
|
+
switch (domain) {
|
|
66
|
+
case 'orgchart':
|
|
67
|
+
rdmText = generateOrgchartRdm(headers, data, mapping, csvPath);
|
|
68
|
+
break;
|
|
69
|
+
case 'genogram':
|
|
70
|
+
rdmText = generateGenogramRdm(headers, data, mapping, csvPath);
|
|
71
|
+
break;
|
|
72
|
+
case 'process':
|
|
73
|
+
rdmText = generateProcessRdm(headers, data, mapping, csvPath);
|
|
74
|
+
break;
|
|
75
|
+
default:
|
|
76
|
+
return { error: `Unsupported domain: ${domain}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const outputPath = args.outputPath
|
|
80
|
+
? resolve(String(args.outputPath))
|
|
81
|
+
: csvPath.replace(/\.csv$/i, '.rdm');
|
|
82
|
+
|
|
83
|
+
const dir = dirname(outputPath);
|
|
84
|
+
if (!existsSync(dir)) {
|
|
85
|
+
mkdirSync(dir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
writeFileSync(outputPath, rdmText, 'utf-8');
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
outputPath,
|
|
92
|
+
domain,
|
|
93
|
+
rowCount: data.length,
|
|
94
|
+
columnMapping: mapping,
|
|
95
|
+
written: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseSimpleCsv(content: string): string[][] {
|
|
100
|
+
return content
|
|
101
|
+
.trim()
|
|
102
|
+
.split('\n')
|
|
103
|
+
.map((line) =>
|
|
104
|
+
line.split(',').map((cell) => cell.trim().replace(/^["']|["']$/g, ''))
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function autoMapColumns(headers: string[], domain: string): Record<string, string> {
|
|
109
|
+
const mapping: Record<string, string> = {};
|
|
110
|
+
const normalized = headers.map((h) => h.toLowerCase().trim());
|
|
111
|
+
|
|
112
|
+
if (domain === 'orgchart') {
|
|
113
|
+
const nameCol = normalized.findIndex((h) => ['name', 'employee', 'person', 'full_name', 'fullname'].includes(h));
|
|
114
|
+
const titleCol = normalized.findIndex((h) => ['title', 'role', 'job_title', 'position'].includes(h));
|
|
115
|
+
const deptCol = normalized.findIndex((h) => ['department', 'dept', 'division', 'team'].includes(h));
|
|
116
|
+
const managerCol = normalized.findIndex((h) => ['manager', 'reports_to', 'reportsto', 'supervisor', 'boss'].includes(h));
|
|
117
|
+
const levelCol = normalized.findIndex((h) => ['level', 'rank', 'grade'].includes(h));
|
|
118
|
+
|
|
119
|
+
if (nameCol >= 0) mapping[headers[nameCol]] = 'name';
|
|
120
|
+
if (titleCol >= 0) mapping[headers[titleCol]] = 'title';
|
|
121
|
+
if (deptCol >= 0) mapping[headers[deptCol]] = 'department';
|
|
122
|
+
if (managerCol >= 0) mapping[headers[managerCol]] = 'reportsTo';
|
|
123
|
+
if (levelCol >= 0) mapping[headers[levelCol]] = 'level';
|
|
124
|
+
} else if (domain === 'genogram') {
|
|
125
|
+
const nameCol = normalized.findIndex((h) => ['name', 'person', 'full_name'].includes(h));
|
|
126
|
+
const sexCol = normalized.findIndex((h) => ['sex', 'gender'].includes(h));
|
|
127
|
+
const bornCol = normalized.findIndex((h) => ['born', 'birth', 'birthday', 'dob', 'birth_date'].includes(h));
|
|
128
|
+
const statusCol = normalized.findIndex((h) => ['status', 'alive'].includes(h));
|
|
129
|
+
|
|
130
|
+
if (nameCol >= 0) mapping[headers[nameCol]] = 'name';
|
|
131
|
+
if (sexCol >= 0) mapping[headers[sexCol]] = 'sex';
|
|
132
|
+
if (bornCol >= 0) mapping[headers[bornCol]] = 'born';
|
|
133
|
+
if (statusCol >= 0) mapping[headers[statusCol]] = 'status';
|
|
134
|
+
} else if (domain === 'process') {
|
|
135
|
+
const nameCol = normalized.findIndex((h) => ['step', 'task', 'name', 'activity'].includes(h));
|
|
136
|
+
const assigneeCol = normalized.findIndex((h) => ['assignee', 'owner', 'responsible', 'role'].includes(h));
|
|
137
|
+
const durationCol = normalized.findIndex((h) => ['duration', 'days', 'time'].includes(h));
|
|
138
|
+
|
|
139
|
+
if (nameCol >= 0) mapping[headers[nameCol]] = 'name';
|
|
140
|
+
if (assigneeCol >= 0) mapping[headers[assigneeCol]] = 'assignee';
|
|
141
|
+
if (durationCol >= 0) mapping[headers[durationCol]] = 'duration';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return mapping;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function toId(name: string): string {
|
|
148
|
+
return name
|
|
149
|
+
.toLowerCase()
|
|
150
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
151
|
+
.replace(/^_|_$/g, '')
|
|
152
|
+
.slice(0, 30);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function generateOrgchartRdm(
|
|
156
|
+
headers: string[],
|
|
157
|
+
data: string[][],
|
|
158
|
+
mapping: Record<string, string>,
|
|
159
|
+
csvPath: string
|
|
160
|
+
): string {
|
|
161
|
+
const reverseMap: Record<string, number> = {};
|
|
162
|
+
for (const [col, field] of Object.entries(mapping)) {
|
|
163
|
+
const idx = headers.indexOf(col);
|
|
164
|
+
if (idx >= 0) reverseMap[field] = idx;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const title = basename(csvPath, '.csv').replace(/[-_]/g, ' ');
|
|
168
|
+
const lines: string[] = [
|
|
169
|
+
'---',
|
|
170
|
+
'type: orgchart',
|
|
171
|
+
`title: "${title}"`,
|
|
172
|
+
'---',
|
|
173
|
+
'',
|
|
174
|
+
`org ${title.replace(/[^a-zA-Z0-9]/g, '')} {`,
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
// Build name→id map for reportsTo references
|
|
178
|
+
const nameToId: Record<string, string> = {};
|
|
179
|
+
for (const row of data) {
|
|
180
|
+
const name = reverseMap.name !== undefined ? row[reverseMap.name] : row[0];
|
|
181
|
+
if (name) nameToId[name] = toId(name);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const row of data) {
|
|
185
|
+
const name = reverseMap.name !== undefined ? row[reverseMap.name] : row[0];
|
|
186
|
+
if (!name) continue;
|
|
187
|
+
|
|
188
|
+
const id = toId(name);
|
|
189
|
+
const props: string[] = [];
|
|
190
|
+
|
|
191
|
+
if (reverseMap.title !== undefined && row[reverseMap.title]) {
|
|
192
|
+
props.push(` title: "${row[reverseMap.title]}"`);
|
|
193
|
+
}
|
|
194
|
+
if (reverseMap.department !== undefined && row[reverseMap.department]) {
|
|
195
|
+
props.push(` department: "${row[reverseMap.department]}"`);
|
|
196
|
+
}
|
|
197
|
+
if (reverseMap.level !== undefined && row[reverseMap.level]) {
|
|
198
|
+
props.push(` level: ${row[reverseMap.level]}`);
|
|
199
|
+
}
|
|
200
|
+
if (reverseMap.reportsTo !== undefined && row[reverseMap.reportsTo]) {
|
|
201
|
+
const managerId = nameToId[row[reverseMap.reportsTo]] || toId(row[reverseMap.reportsTo]);
|
|
202
|
+
props.push(` reportsTo: ${managerId}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
lines.push(` position ${id} "${name}" {`);
|
|
206
|
+
lines.push(...props);
|
|
207
|
+
lines.push(' }');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
lines.push('}');
|
|
211
|
+
return lines.join('\n') + '\n';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function generateGenogramRdm(
|
|
215
|
+
headers: string[],
|
|
216
|
+
data: string[][],
|
|
217
|
+
mapping: Record<string, string>,
|
|
218
|
+
csvPath: string
|
|
219
|
+
): string {
|
|
220
|
+
const reverseMap: Record<string, number> = {};
|
|
221
|
+
for (const [col, field] of Object.entries(mapping)) {
|
|
222
|
+
const idx = headers.indexOf(col);
|
|
223
|
+
if (idx >= 0) reverseMap[field] = idx;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const title = basename(csvPath, '.csv').replace(/[-_]/g, ' ');
|
|
227
|
+
const lines: string[] = [
|
|
228
|
+
'---',
|
|
229
|
+
'type: genogram',
|
|
230
|
+
`title: "${title}"`,
|
|
231
|
+
'---',
|
|
232
|
+
'',
|
|
233
|
+
`genogram ${title.replace(/[^a-zA-Z0-9]/g, '')} {`,
|
|
234
|
+
' generation 1 {',
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
for (const row of data) {
|
|
238
|
+
const name = reverseMap.name !== undefined ? row[reverseMap.name] : row[0];
|
|
239
|
+
if (!name) continue;
|
|
240
|
+
|
|
241
|
+
const id = toId(name);
|
|
242
|
+
const props: string[] = [];
|
|
243
|
+
|
|
244
|
+
if (reverseMap.sex !== undefined && row[reverseMap.sex]) {
|
|
245
|
+
props.push(` sex: ${row[reverseMap.sex].toLowerCase()}`);
|
|
246
|
+
}
|
|
247
|
+
if (reverseMap.born !== undefined && row[reverseMap.born]) {
|
|
248
|
+
props.push(` born: ${row[reverseMap.born]}`);
|
|
249
|
+
}
|
|
250
|
+
if (reverseMap.status !== undefined && row[reverseMap.status]) {
|
|
251
|
+
props.push(` status: ${row[reverseMap.status].toLowerCase()}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
lines.push(` person ${id} "${name}" {`);
|
|
255
|
+
lines.push(...props.map((p) => ' ' + p));
|
|
256
|
+
lines.push(' }');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
lines.push(' }');
|
|
260
|
+
lines.push('}');
|
|
261
|
+
return lines.join('\n') + '\n';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function generateProcessRdm(
|
|
265
|
+
headers: string[],
|
|
266
|
+
data: string[][],
|
|
267
|
+
mapping: Record<string, string>,
|
|
268
|
+
csvPath: string
|
|
269
|
+
): string {
|
|
270
|
+
const reverseMap: Record<string, number> = {};
|
|
271
|
+
for (const [col, field] of Object.entries(mapping)) {
|
|
272
|
+
const idx = headers.indexOf(col);
|
|
273
|
+
if (idx >= 0) reverseMap[field] = idx;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const title = basename(csvPath, '.csv').replace(/[-_]/g, ' ');
|
|
277
|
+
const lines: string[] = [
|
|
278
|
+
'---',
|
|
279
|
+
'type: process',
|
|
280
|
+
`title: "${title}"`,
|
|
281
|
+
'---',
|
|
282
|
+
'',
|
|
283
|
+
`process ${title.replace(/[^a-zA-Z0-9]/g, '')} {`,
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
const taskIds: string[] = [];
|
|
287
|
+
|
|
288
|
+
for (const row of data) {
|
|
289
|
+
const name = reverseMap.name !== undefined ? row[reverseMap.name] : row[0];
|
|
290
|
+
if (!name) continue;
|
|
291
|
+
|
|
292
|
+
const id = toId(name);
|
|
293
|
+
taskIds.push(id);
|
|
294
|
+
const props: string[] = [' type: task'];
|
|
295
|
+
|
|
296
|
+
if (reverseMap.assignee !== undefined && row[reverseMap.assignee]) {
|
|
297
|
+
props.push(` assignee: "${row[reverseMap.assignee]}"`);
|
|
298
|
+
}
|
|
299
|
+
if (reverseMap.duration !== undefined && row[reverseMap.duration]) {
|
|
300
|
+
props.push(` duration: ${row[reverseMap.duration]}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
lines.push(` ${id} "${name}" {`);
|
|
304
|
+
lines.push(...props);
|
|
305
|
+
lines.push(' }');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Add flow connections
|
|
309
|
+
if (taskIds.length > 1) {
|
|
310
|
+
lines.push('');
|
|
311
|
+
lines.push(' ' + taskIds.join(' -> '));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
lines.push('}');
|
|
315
|
+
return lines.join('\n') + '\n';
|
|
316
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdm_init — Analyze project and generate RDM conventions
|
|
3
|
+
*/
|
|
4
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
|
|
5
|
+
import { resolve, join, extname, relative } from 'path';
|
|
6
|
+
|
|
7
|
+
export const rdmInitTool = {
|
|
8
|
+
name: 'rdm_init',
|
|
9
|
+
description:
|
|
10
|
+
'Analyze the current project directory to discover existing .rdm files, ' +
|
|
11
|
+
'CSV data sources, and configuration. Generates project-level RDM conventions ' +
|
|
12
|
+
'suitable for appending to CLAUDE.md.',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object' as const,
|
|
15
|
+
properties: {
|
|
16
|
+
projectDir: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Project root directory to analyze (defaults to cwd)',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function executeRdmInit(args: Record<string, unknown>): Promise<unknown> {
|
|
25
|
+
const projectDir = resolve(String(args.projectDir ?? process.cwd()));
|
|
26
|
+
|
|
27
|
+
if (!existsSync(projectDir)) {
|
|
28
|
+
return { error: `Directory not found: ${projectDir}` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Scan for .rdm files
|
|
32
|
+
const rdmFiles = findFiles(projectDir, '.rdm', 5);
|
|
33
|
+
|
|
34
|
+
// Scan for CSV files
|
|
35
|
+
const csvFiles = findFiles(projectDir, '.csv', 3);
|
|
36
|
+
|
|
37
|
+
// Analyze existing .rdm files
|
|
38
|
+
const rdmAnalysis = rdmFiles.map((f) => {
|
|
39
|
+
const content = readFileSync(f, 'utf-8');
|
|
40
|
+
const typeMatch = content.match(/type:\s*(\w+)/);
|
|
41
|
+
const titleMatch = content.match(/title:\s*"([^"]+)"/);
|
|
42
|
+
return {
|
|
43
|
+
path: relative(projectDir, f),
|
|
44
|
+
type: typeMatch?.[1] ?? 'unknown',
|
|
45
|
+
title: titleMatch?.[1] ?? 'untitled',
|
|
46
|
+
lines: content.split('\n').length,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Detect dominant diagram type
|
|
51
|
+
const typeCounts: Record<string, number> = {};
|
|
52
|
+
for (const r of rdmAnalysis) {
|
|
53
|
+
typeCounts[r.type] = (typeCounts[r.type] || 0) + 1;
|
|
54
|
+
}
|
|
55
|
+
const dominantType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'orgchart';
|
|
56
|
+
|
|
57
|
+
// Generate conventions markdown
|
|
58
|
+
const conventions = generateConventions(rdmAnalysis, csvFiles.map((f) => relative(projectDir, f)), dominantType);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
projectDir,
|
|
62
|
+
rdmFileCount: rdmFiles.length,
|
|
63
|
+
csvFileCount: csvFiles.length,
|
|
64
|
+
rdmFiles: rdmAnalysis,
|
|
65
|
+
csvFiles: csvFiles.map((f) => relative(projectDir, f)),
|
|
66
|
+
dominantDiagramType: dominantType,
|
|
67
|
+
typeCounts,
|
|
68
|
+
conventions,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findFiles(dir: string, ext: string, maxDepth: number, depth = 0): string[] {
|
|
73
|
+
if (depth > maxDepth) return [];
|
|
74
|
+
|
|
75
|
+
const results: string[] = [];
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const entries = readdirSync(dir);
|
|
79
|
+
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist') continue;
|
|
82
|
+
|
|
83
|
+
const fullPath = join(dir, entry);
|
|
84
|
+
try {
|
|
85
|
+
const stat = statSync(fullPath);
|
|
86
|
+
if (stat.isDirectory()) {
|
|
87
|
+
results.push(...findFiles(fullPath, ext, maxDepth, depth + 1));
|
|
88
|
+
} else if (extname(entry) === ext) {
|
|
89
|
+
results.push(fullPath);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Permission error, skip
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Directory not readable
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function generateConventions(
|
|
103
|
+
rdmFiles: Array<{ path: string; type: string; title: string }>,
|
|
104
|
+
csvFiles: string[],
|
|
105
|
+
dominantType: string
|
|
106
|
+
): string {
|
|
107
|
+
const lines: string[] = [
|
|
108
|
+
'## RDM Conventions',
|
|
109
|
+
'',
|
|
110
|
+
`Primary diagram type: \`${dominantType}\``,
|
|
111
|
+
'',
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
if (rdmFiles.length > 0) {
|
|
115
|
+
lines.push('### Existing Diagrams');
|
|
116
|
+
for (const f of rdmFiles.slice(0, 10)) {
|
|
117
|
+
lines.push(`- \`${f.path}\` — ${f.type}: ${f.title}`);
|
|
118
|
+
}
|
|
119
|
+
if (rdmFiles.length > 10) {
|
|
120
|
+
lines.push(`- ... and ${rdmFiles.length - 10} more`);
|
|
121
|
+
}
|
|
122
|
+
lines.push('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (csvFiles.length > 0) {
|
|
126
|
+
lines.push('### Data Sources');
|
|
127
|
+
for (const f of csvFiles.slice(0, 5)) {
|
|
128
|
+
lines.push(`- \`${f}\` — potential diagram data source`);
|
|
129
|
+
}
|
|
130
|
+
lines.push('');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
lines.push('### Naming Rules');
|
|
134
|
+
lines.push('- File names: kebab-case with .rdm extension');
|
|
135
|
+
lines.push('- Node IDs: snake_case (e.g., `vp_eng`, `dad`)');
|
|
136
|
+
lines.push('- Display names: Quoted strings with proper names');
|
|
137
|
+
lines.push('');
|
|
138
|
+
|
|
139
|
+
lines.push('### Workflow');
|
|
140
|
+
lines.push('1. Generate .rdm file with `/rdm` skill');
|
|
141
|
+
lines.push('2. View with `bun run rdm-canvas <file.rdm>`');
|
|
142
|
+
lines.push('3. Edit in browser or text editor — changes sync bidirectionally');
|
|
143
|
+
lines.push('');
|
|
144
|
+
|
|
145
|
+
return lines.join('\n');
|
|
146
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdm_read — Read full .rdm file content
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, existsSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
|
|
7
|
+
export const rdmReadTool = {
|
|
8
|
+
name: 'rdm_read',
|
|
9
|
+
description:
|
|
10
|
+
'Read the full content of an .rdm file. Returns the complete RDM markup ' +
|
|
11
|
+
'including frontmatter, structure, layout, and style blocks. ' +
|
|
12
|
+
'Use rdm_summary for a lightweight overview instead.',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object' as const,
|
|
15
|
+
properties: {
|
|
16
|
+
path: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Path to the .rdm file to read',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ['path'],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function executeRdmRead(args: Record<string, unknown>): Promise<unknown> {
|
|
26
|
+
const filePath = resolve(String(args.path ?? ''));
|
|
27
|
+
|
|
28
|
+
if (!existsSync(filePath)) {
|
|
29
|
+
return { error: `File not found: ${filePath}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
33
|
+
const lines = content.split('\n').length;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
path: filePath,
|
|
37
|
+
content,
|
|
38
|
+
lines,
|
|
39
|
+
sizeBytes: Buffer.byteLength(content, 'utf-8'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdm_render — Render an .rdm file to SVG
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, existsSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { RdmService } from '@creately/rdm-core';
|
|
7
|
+
|
|
8
|
+
const rdmService = new RdmService();
|
|
9
|
+
|
|
10
|
+
export const rdmRenderTool = {
|
|
11
|
+
name: 'rdm_render',
|
|
12
|
+
description:
|
|
13
|
+
'Render an .rdm file to SVG. Returns the SVG markup for visual validation. ' +
|
|
14
|
+
'Use this to check what a diagram looks like.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: 'object' as const,
|
|
17
|
+
properties: {
|
|
18
|
+
path: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Path to the .rdm file to render',
|
|
21
|
+
},
|
|
22
|
+
rdmText: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'RDM text to render directly (alternative to path)',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function executeRdmRender(args: Record<string, unknown>): Promise<unknown> {
|
|
31
|
+
let rdmText: string;
|
|
32
|
+
|
|
33
|
+
if (args.rdmText) {
|
|
34
|
+
rdmText = String(args.rdmText);
|
|
35
|
+
} else if (args.path) {
|
|
36
|
+
const filePath = resolve(String(args.path));
|
|
37
|
+
if (!existsSync(filePath)) {
|
|
38
|
+
return { error: `File not found: ${filePath}` };
|
|
39
|
+
}
|
|
40
|
+
rdmText = readFileSync(filePath, 'utf-8');
|
|
41
|
+
} else {
|
|
42
|
+
return { error: 'Either path or rdmText must be provided' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result = rdmService.render(rdmText);
|
|
46
|
+
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
return {
|
|
49
|
+
error: 'Render failed',
|
|
50
|
+
errors: result.errors,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
svg: result.svg,
|
|
56
|
+
svgLength: result.svg?.length ?? 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdm_schema — Domain schema: valid fields, relationships, examples
|
|
3
|
+
*
|
|
4
|
+
* Returns structured schema information for a given RDM domain type.
|
|
5
|
+
* Agents use this to generate correct RDM without guessing field names.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface FieldDef {
|
|
9
|
+
name: string;
|
|
10
|
+
type: string;
|
|
11
|
+
values?: string[];
|
|
12
|
+
description?: string;
|
|
13
|
+
required?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DomainSchema {
|
|
17
|
+
domain: string;
|
|
18
|
+
description: string;
|
|
19
|
+
nodeTypes: Record<string, { fields: FieldDef[] }>;
|
|
20
|
+
relationships: Array<{ syntax: string; description: string }>;
|
|
21
|
+
structure: string;
|
|
22
|
+
example: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SCHEMAS: Record<string, DomainSchema> = {
|
|
26
|
+
genogram: {
|
|
27
|
+
domain: 'genogram',
|
|
28
|
+
description: 'Family relationship diagrams for clinical, genealogical, or social contexts.',
|
|
29
|
+
nodeTypes: {
|
|
30
|
+
person: {
|
|
31
|
+
fields: [
|
|
32
|
+
{ name: 'sex', type: 'enum', values: ['male', 'female'], required: true },
|
|
33
|
+
{ name: 'born', type: 'date', description: 'Birth date (YYYY or YYYY-MM-DD)' },
|
|
34
|
+
{ name: 'died', type: 'date', description: 'Death date' },
|
|
35
|
+
{ name: 'status', type: 'enum', values: ['living', 'deceased'] },
|
|
36
|
+
{ name: 'health', type: 'array', description: 'List of health conditions' },
|
|
37
|
+
{ name: 'occupation', type: 'string' },
|
|
38
|
+
{ name: 'education', type: 'string' },
|
|
39
|
+
{ name: 'causeOfDeath', type: 'string' },
|
|
40
|
+
{ name: 'adopted', type: 'boolean' },
|
|
41
|
+
{ name: 'twin', type: 'enum', values: ['identical', 'fraternal'] },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
relationships: [
|
|
46
|
+
{ syntax: 'married person1 -- person2 { since: YYYY }', description: 'Marriage' },
|
|
47
|
+
{ syntax: 'divorced person1 -- person2 { since: YYYY }', description: 'Divorce' },
|
|
48
|
+
{ syntax: 'child parent1, parent2 -> child_id', description: 'Parent-child (two parents)' },
|
|
49
|
+
{ syntax: 'child parent1 -> child_id', description: 'Parent-child (single parent)' },
|
|
50
|
+
{ syntax: 'twins monozygotic parent1, parent2 -> twin1, twin2', description: 'Identical twins' },
|
|
51
|
+
{ syntax: 'twins dizygotic parent1, parent2 -> twin1, twin2', description: 'Fraternal twins' },
|
|
52
|
+
{ syntax: 'close person1 -- person2', description: 'Emotional: close relationship' },
|
|
53
|
+
{ syntax: 'conflict person1 -- person2', description: 'Emotional: conflict' },
|
|
54
|
+
{ syntax: 'cutoff person1 -- person2', description: 'Emotional: cutoff' },
|
|
55
|
+
],
|
|
56
|
+
structure: 'genogram Name {\n generation N {\n person id "Name" { sex: male, born: 1980 }\n married p1 -- p2 { since: 2005 }\n child p1, p2 -> child_id\n }\n}',
|
|
57
|
+
example: '---\ntype: genogram\ntitle: "Sample Family"\n---\n\ngenogram Sample {\n generation 1 {\n person gf "John" { sex: male, born: 1940 }\n person gm "Mary" { sex: female, born: 1942 }\n married gf -- gm { since: 1962 }\n }\n generation 2 {\n person dad "Bob" { sex: male, born: 1965 }\n child gf, gm -> dad\n }\n}',
|
|
58
|
+
},
|
|
59
|
+
orgchart: {
|
|
60
|
+
domain: 'orgchart',
|
|
61
|
+
description: 'Organizational hierarchy diagrams with positions and reporting relationships.',
|
|
62
|
+
nodeTypes: {
|
|
63
|
+
position: {
|
|
64
|
+
fields: [
|
|
65
|
+
{ name: 'title', type: 'string', description: 'Job title', required: true },
|
|
66
|
+
{ name: 'department', type: 'string' },
|
|
67
|
+
{ name: 'level', type: 'number', description: 'Hierarchy level (1 = top)' },
|
|
68
|
+
{ name: 'reportsTo', type: 'reference', description: 'ID of reporting-to position' },
|
|
69
|
+
{ name: 'location', type: 'string' },
|
|
70
|
+
{ name: 'status', type: 'enum', values: ['active', 'vacant', 'planned'] },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
relationships: [
|
|
75
|
+
{ syntax: 'reportsTo: parent_id', description: 'Reporting relationship (in position props)' },
|
|
76
|
+
{ syntax: 'team name under parent_id { ... }', description: 'Team grouping' },
|
|
77
|
+
],
|
|
78
|
+
structure: 'org Name {\n position id "Display Name" {\n title: "...", department: "...", level: N\n reportsTo: parent_id\n }\n team name under parent_id { ... }\n}',
|
|
79
|
+
example: '---\ntype: orgchart\ntitle: "Engineering"\n---\n\norg Engineering {\n position ceo "Alice" { title: "CEO", level: 1 }\n position vp "Bob" { title: "VP Eng", level: 2, reportsTo: ceo }\n}',
|
|
80
|
+
},
|
|
81
|
+
process: {
|
|
82
|
+
domain: 'process',
|
|
83
|
+
description: 'Business process and workflow diagrams with tasks and flows.',
|
|
84
|
+
nodeTypes: {
|
|
85
|
+
task: {
|
|
86
|
+
fields: [
|
|
87
|
+
{ name: 'type', type: 'enum', values: ['task', 'start', 'end', 'decision', 'gateway'], required: true },
|
|
88
|
+
{ name: 'assignee', type: 'string', description: 'Role or person responsible' },
|
|
89
|
+
{ name: 'duration', type: 'number', description: 'Duration in days' },
|
|
90
|
+
{ name: 'sla', type: 'number', description: 'SLA deadline in days' },
|
|
91
|
+
{ name: 'priority', type: 'enum', values: ['low', 'medium', 'high', 'critical'] },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
relationships: [
|
|
96
|
+
{ syntax: 'task1 -> task2', description: 'Sequential flow' },
|
|
97
|
+
{ syntax: 'task1 -> task2 -> task3', description: 'Chained flow' },
|
|
98
|
+
],
|
|
99
|
+
structure: 'process Name {\n task_id "Display Name" { type: task, assignee: "Role" }\n task1 -> task2 -> task3\n}',
|
|
100
|
+
example: '---\ntype: process\ntitle: "Approval"\n---\n\nprocess Approval {\n submit "Submit" { type: task, assignee: "Requester" }\n review "Review" { type: task, assignee: "Manager", sla: 2 }\n submit -> review\n}',
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const rdmSchemaTool = {
|
|
105
|
+
name: 'rdm_schema',
|
|
106
|
+
description:
|
|
107
|
+
'Get the RDM schema for a diagram domain (genogram, orgchart, process). ' +
|
|
108
|
+
'Returns valid node types, fields, relationships, and syntax examples. ' +
|
|
109
|
+
'Use this before generating RDM to ensure correct syntax.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: 'object' as const,
|
|
112
|
+
properties: {
|
|
113
|
+
domain: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
enum: ['genogram', 'orgchart', 'process'],
|
|
116
|
+
description: 'The diagram domain to get schema for',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
required: ['domain'],
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export async function executeRdmSchema(args: Record<string, unknown>): Promise<unknown> {
|
|
124
|
+
const domain = String(args.domain ?? '').toLowerCase();
|
|
125
|
+
const schema = SCHEMAS[domain];
|
|
126
|
+
|
|
127
|
+
if (!schema) {
|
|
128
|
+
return {
|
|
129
|
+
error: `Unknown domain "${domain}". Valid: ${Object.keys(SCHEMAS).join(', ')}`,
|
|
130
|
+
availableDomains: Object.keys(SCHEMAS),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return schema;
|
|
135
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdm_summary — Lightweight summary of an .rdm file (~200 tokens)
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, existsSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { RdmService } from '@creately/rdm-core';
|
|
7
|
+
|
|
8
|
+
const rdmService = new RdmService();
|
|
9
|
+
|
|
10
|
+
export const rdmSummaryTool = {
|
|
11
|
+
name: 'rdm_summary',
|
|
12
|
+
description:
|
|
13
|
+
'Get a lightweight summary of an .rdm file (~200 tokens). ' +
|
|
14
|
+
'Returns node count, edge count, diagram type, and top-level structure. ' +
|
|
15
|
+
'Use this first to understand what a diagram contains before reading the full content.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object' as const,
|
|
18
|
+
properties: {
|
|
19
|
+
path: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Path to the .rdm file',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ['path'],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function executeRdmSummary(args: Record<string, unknown>): Promise<unknown> {
|
|
29
|
+
const filePath = resolve(String(args.path ?? ''));
|
|
30
|
+
|
|
31
|
+
if (!existsSync(filePath)) {
|
|
32
|
+
return { error: `File not found: ${filePath}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
36
|
+
const result = rdmService.parseAndValidate(content);
|
|
37
|
+
|
|
38
|
+
if (!result.success || !result.document) {
|
|
39
|
+
return {
|
|
40
|
+
path: filePath,
|
|
41
|
+
valid: false,
|
|
42
|
+
errorCount: result.errors.length,
|
|
43
|
+
errors: result.errors.slice(0, 3),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const doc = result.document;
|
|
48
|
+
const nodeCount = countType(doc.structure.declarations, 'node');
|
|
49
|
+
const edgeCount = countType(doc.structure.declarations, 'edge');
|
|
50
|
+
const groupCount = countType(doc.structure.declarations, 'group');
|
|
51
|
+
const genCount = countType(doc.structure.declarations, 'generation');
|
|
52
|
+
|
|
53
|
+
// Collect top-level IDs
|
|
54
|
+
const topIds: string[] = [];
|
|
55
|
+
for (const decl of doc.structure.declarations) {
|
|
56
|
+
if ('id' in decl && decl.id) topIds.push(decl.id as string);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
path: filePath,
|
|
61
|
+
valid: true,
|
|
62
|
+
type: doc.meta.type,
|
|
63
|
+
title: doc.meta.title,
|
|
64
|
+
nodeCount,
|
|
65
|
+
edgeCount,
|
|
66
|
+
groupCount,
|
|
67
|
+
generationCount: genCount,
|
|
68
|
+
warningCount: result.warnings.length,
|
|
69
|
+
topLevelIds: topIds.slice(0, 20),
|
|
70
|
+
hasLayout: (doc.layout?.length ?? 0) > 0,
|
|
71
|
+
hasStyle: (doc.style?.length ?? 0) > 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function countType(declarations: Array<{ type: string; declarations?: any[] }>, type: string): number {
|
|
76
|
+
let count = 0;
|
|
77
|
+
for (const d of declarations) {
|
|
78
|
+
if (d.type === type) count++;
|
|
79
|
+
if (d.declarations) count += countType(d.declarations, type);
|
|
80
|
+
}
|
|
81
|
+
return count;
|
|
82
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdm_validate — Validate RDM text and return diagnostics
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, existsSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { RdmService } from '@creately/rdm-core';
|
|
7
|
+
|
|
8
|
+
const rdmService = new RdmService();
|
|
9
|
+
|
|
10
|
+
export const rdmValidateTool = {
|
|
11
|
+
name: 'rdm_validate',
|
|
12
|
+
description:
|
|
13
|
+
'Validate an .rdm file or RDM text. Returns parse errors and warnings ' +
|
|
14
|
+
'with line numbers. Use this to check correctness before applying changes.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: 'object' as const,
|
|
17
|
+
properties: {
|
|
18
|
+
path: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Path to the .rdm file to validate',
|
|
21
|
+
},
|
|
22
|
+
rdmText: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'RDM text to validate directly (alternative to path)',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function executeRdmValidate(args: Record<string, unknown>): Promise<unknown> {
|
|
31
|
+
let rdmText: string;
|
|
32
|
+
|
|
33
|
+
if (args.rdmText) {
|
|
34
|
+
rdmText = String(args.rdmText);
|
|
35
|
+
} else if (args.path) {
|
|
36
|
+
const filePath = resolve(String(args.path));
|
|
37
|
+
if (!existsSync(filePath)) {
|
|
38
|
+
return { error: `File not found: ${filePath}` };
|
|
39
|
+
}
|
|
40
|
+
rdmText = readFileSync(filePath, 'utf-8');
|
|
41
|
+
} else {
|
|
42
|
+
return { error: 'Either path or rdmText must be provided' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result = rdmService.parseAndValidate(rdmText);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
valid: result.success,
|
|
49
|
+
errors: result.errors.map((e) => ({
|
|
50
|
+
code: e.code,
|
|
51
|
+
message: e.message,
|
|
52
|
+
line: e.line,
|
|
53
|
+
column: e.column,
|
|
54
|
+
})),
|
|
55
|
+
warnings: result.warnings.map((w) => ({
|
|
56
|
+
code: w.code,
|
|
57
|
+
message: w.message,
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rdm_write — Write or update an .rdm file
|
|
3
|
+
*/
|
|
4
|
+
import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
|
|
5
|
+
import { resolve, dirname } from 'path';
|
|
6
|
+
import { RdmService } from '@creately/rdm-core';
|
|
7
|
+
|
|
8
|
+
const rdmService = new RdmService();
|
|
9
|
+
|
|
10
|
+
export const rdmWriteTool = {
|
|
11
|
+
name: 'rdm_write',
|
|
12
|
+
description:
|
|
13
|
+
'Write or update an .rdm file. Can write a full document or merge a fragment ' +
|
|
14
|
+
'into an existing file. Validates the content before writing.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: 'object' as const,
|
|
17
|
+
properties: {
|
|
18
|
+
path: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Path to write the .rdm file',
|
|
21
|
+
},
|
|
22
|
+
content: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Full RDM document text (with frontmatter) to write',
|
|
25
|
+
},
|
|
26
|
+
fragment: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description:
|
|
29
|
+
'RDM fragment to merge into an existing file. ' +
|
|
30
|
+
'If both content and fragment are provided, content takes priority.',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
required: ['path'],
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export async function executeRdmWrite(args: Record<string, unknown>): Promise<unknown> {
|
|
38
|
+
const filePath = resolve(String(args.path ?? ''));
|
|
39
|
+
const content = args.content as string | undefined;
|
|
40
|
+
const fragment = args.fragment as string | undefined;
|
|
41
|
+
|
|
42
|
+
if (!content && !fragment) {
|
|
43
|
+
return { error: 'Either content or fragment must be provided' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let rdmText: string;
|
|
47
|
+
|
|
48
|
+
if (content) {
|
|
49
|
+
rdmText = content;
|
|
50
|
+
} else {
|
|
51
|
+
// Merge fragment into existing file
|
|
52
|
+
if (!existsSync(filePath)) {
|
|
53
|
+
return { error: `File not found for fragment merge: ${filePath}. Use content param for new files.` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const existing = readFileSync(filePath, 'utf-8');
|
|
57
|
+
const parsed = rdmService.parseAndValidate(existing);
|
|
58
|
+
if (!parsed.success || !parsed.document) {
|
|
59
|
+
return { error: `Existing file has parse errors: ${parsed.errors.map((e) => e.message).join('; ')}` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Simple merge: insert fragment before the closing brace of the structure block
|
|
63
|
+
const lastBrace = existing.lastIndexOf('}');
|
|
64
|
+
if (lastBrace === -1) {
|
|
65
|
+
return { error: 'Cannot find structure block closing brace in existing file' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
rdmText = existing.slice(0, lastBrace) + '\n ' + fragment!.trim() + '\n' + existing.slice(lastBrace);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate before writing
|
|
72
|
+
const validation = rdmService.parseAndValidate(rdmText);
|
|
73
|
+
if (!validation.success) {
|
|
74
|
+
return {
|
|
75
|
+
error: 'Validation failed — not written',
|
|
76
|
+
errors: validation.errors,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Ensure directory exists
|
|
81
|
+
const dir = dirname(filePath);
|
|
82
|
+
if (!existsSync(dir)) {
|
|
83
|
+
mkdirSync(dir, { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
writeFileSync(filePath, rdmText, 'utf-8');
|
|
87
|
+
|
|
88
|
+
const nodeCount = countType(validation.document!.structure.declarations, 'node');
|
|
89
|
+
const edgeCount = countType(validation.document!.structure.declarations, 'edge');
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
path: filePath,
|
|
93
|
+
written: true,
|
|
94
|
+
nodeCount,
|
|
95
|
+
edgeCount,
|
|
96
|
+
sizeBytes: Buffer.byteLength(rdmText, 'utf-8'),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function countType(declarations: Array<{ type: string; declarations?: any[] }>, type: string): number {
|
|
101
|
+
let count = 0;
|
|
102
|
+
for (const d of declarations) {
|
|
103
|
+
if (d.type === type) count++;
|
|
104
|
+
if (d.declarations) count += countType(d.declarations, type);
|
|
105
|
+
}
|
|
106
|
+
return count;
|
|
107
|
+
}
|