sysprom 1.13.1 → 1.14.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/README.md +73 -30
- package/dist/src/cli/commands/init.d.ts +2 -4
- package/dist/src/cli/commands/init.js +16 -11
- package/dist/src/cli/commands/json2md.d.ts +8 -1
- package/dist/src/cli/commands/json2md.js +14 -25
- package/dist/src/cli/commands/md2json.d.ts +7 -1
- package/dist/src/cli/commands/md2json.js +10 -13
- package/dist/src/cli/commands/plan.js +3 -6
- package/dist/src/cli/commands/speckit.js +18 -30
- package/dist/src/cli/commands/sync.d.ts +10 -1
- package/dist/src/cli/commands/sync.js +26 -37
- package/dist/src/cli/shared.d.ts +12 -9
- package/dist/src/cli/shared.js +60 -16
- package/dist/src/json-to-md.js +71 -16
- package/dist/src/mcp/server.js +17 -12
- package/dist/src/md-to-json.js +8 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,44 +17,87 @@ npm install -g github:ExaDev/SysProM
|
|
|
17
17
|
npx sysprom --help
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Both `sysprom` and `spm` are available as commands.
|
|
20
|
+
Both `sysprom` and `spm` are available as commands — use `sysprom` for new projects.
|
|
21
21
|
|
|
22
22
|
## CLI
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
25
|
# Convert between formats
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
sysprom json2md --input .SysProM.json --output ./.SysProM
|
|
27
|
+
sysprom md2json --input ./.SysProM --output output.SysProM.json
|
|
28
28
|
|
|
29
|
-
# Validate and summarise (auto-detects .
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
# Validate and summarise (auto-detects .SysProM.json in current directory)
|
|
30
|
+
sysprom validate
|
|
31
|
+
sysprom stats
|
|
32
32
|
|
|
33
33
|
# Query nodes and relationships
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
sysprom query nodes --type decision
|
|
35
|
+
sysprom query node D1
|
|
36
|
+
sysprom query rels --from D1
|
|
37
|
+
sysprom query trace I1
|
|
38
|
+
sysprom query timeline
|
|
39
|
+
sysprom query state-at 2026-03-22
|
|
40
40
|
|
|
41
41
|
# Add nodes (ID auto-generated from type prefix if --id omitted)
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
sysprom add invariant --name "New Rule" --description "Must hold"
|
|
43
|
+
sysprom add decision --name "Choose X" \
|
|
44
44
|
--option "OPT-A:Use framework X" --option "OPT-B:Use framework Y" \
|
|
45
45
|
--selected OPT-A --rationale "Lower migration effort"
|
|
46
46
|
|
|
47
47
|
# Remove nodes
|
|
48
|
-
|
|
48
|
+
sysprom remove INV23
|
|
49
49
|
|
|
50
50
|
# Update nodes, relationships, and metadata
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
sysprom update node D1 --status deprecated
|
|
52
|
+
sysprom update add-rel D1 affects EL5
|
|
53
|
+
sysprom update remove-rel D1 affects EL5
|
|
54
|
+
sysprom update meta --fields version=2
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
All commands auto-detect the document — they search the current directory for `.
|
|
57
|
+
All commands auto-detect the document — they search the current directory for `.SysProM.json`, `.SysProM.md`, or `.SysProM/` (in that priority order), then fall back to `.spm.json`, `.spm.md`, or `.spm/`. Use `--path` to specify an explicit path. Note: `spm` is an alias for `sysprom` for backwards compatibility.
|
|
58
|
+
|
|
59
|
+
## MCP Server
|
|
60
|
+
|
|
61
|
+
SysProM includes an MCP (Model Context Protocol) server exposing 11 tools over stdio transport. Any MCP-compatible agent — Cursor, Windsurf, VS Code Copilot, Cline, or custom clients — can use it.
|
|
62
|
+
|
|
63
|
+
### Configuration
|
|
64
|
+
|
|
65
|
+
Add the following to your MCP client's configuration (e.g. `.cursor/mcp.json`, `.vscode/mcp.json`, `cline_mcp_settings.json`, or equivalent):
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"sysprom": {
|
|
71
|
+
"command": "npx",
|
|
72
|
+
"args": ["-y", "sysprom", "mcp"]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or via the CLI subcommand (equivalent):
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
sysprom mcp # starts the MCP server on stdio
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Available Tools
|
|
85
|
+
|
|
86
|
+
| Tool | Description |
|
|
87
|
+
|------|-------------|
|
|
88
|
+
| `validate` | Validate a SysProM document and return issues |
|
|
89
|
+
| `stats` | Return document statistics |
|
|
90
|
+
| `query-nodes` | Query nodes by type, status, or text |
|
|
91
|
+
| `query-node` | Retrieve a single node by ID |
|
|
92
|
+
| `query-relationships` | Query relationships by source, target, or type |
|
|
93
|
+
| `trace` | Trace refinement chains from a node |
|
|
94
|
+
| `add-node` | Add a new node to the document |
|
|
95
|
+
| `remove-node` | Remove a node by ID |
|
|
96
|
+
| `update-node` | Update fields on an existing node |
|
|
97
|
+
| `add-relationship` | Add a relationship between nodes |
|
|
98
|
+
| `remove-relationship` | Remove a relationship |
|
|
99
|
+
|
|
100
|
+
All tools accept a `path` parameter to specify the SysProM document location.
|
|
58
101
|
|
|
59
102
|
## Programmatic API
|
|
60
103
|
|
|
@@ -101,7 +144,7 @@ import {
|
|
|
101
144
|
} from "sysprom";
|
|
102
145
|
|
|
103
146
|
// Validate
|
|
104
|
-
const doc = JSON.parse(fs.readFileSync(".
|
|
147
|
+
const doc = JSON.parse(fs.readFileSync(".SysProM.json", "utf8"));
|
|
105
148
|
const result = validate(doc);
|
|
106
149
|
console.log(result.valid, result.issues);
|
|
107
150
|
|
|
@@ -194,7 +237,7 @@ SysProM models systems as directed graphs across abstraction layers — intent,
|
|
|
194
237
|
SysProM is format-agnostic. This repository includes:
|
|
195
238
|
|
|
196
239
|
- **JSON** — validated against `schema.json`, supports recursive subsystems
|
|
197
|
-
- **Markdown** — single file (`.
|
|
240
|
+
- **Markdown** — single file (`.SysProM.md`), multi-document folder, or recursive nested folders with automatic grouping by type
|
|
198
241
|
|
|
199
242
|
Round-trip conversion between JSON and Markdown is supported with zero information loss.
|
|
200
243
|
|
|
@@ -214,29 +257,29 @@ pnpm spm <command> # Run the CLI from source (e.g. pnpm spm validate ...)
|
|
|
214
257
|
|
|
215
258
|
## Self-Description
|
|
216
259
|
|
|
217
|
-
`.
|
|
260
|
+
`.SysProM.json` is SysProM describing itself — the specification, its decisions, invariants, changes, and worked examples are all encoded as a SysProM document. The `./.SysProM/` folder contains the same content as human-readable Markdown.
|
|
218
261
|
|
|
219
|
-
All significant activity — decisions, changes, new capabilities, and invariants — should be recorded in the self-describing document. Updates can be made either by editing the Markdown files in `./.
|
|
262
|
+
All significant activity — decisions, changes, new capabilities, and invariants — should be recorded in the self-describing document. Updates can be made either by editing the Markdown files in `./.SysProM/` directly or by using the CLI:
|
|
220
263
|
|
|
221
264
|
```sh
|
|
222
265
|
# Add a decision via the CLI
|
|
223
|
-
|
|
266
|
+
sysprom add decision --id D23 --name "My Decision" --context "Why this was needed"
|
|
224
267
|
|
|
225
|
-
# Or edit ./.
|
|
226
|
-
|
|
268
|
+
# Or edit ./.SysProM/DECISIONS.md directly, then sync
|
|
269
|
+
sysprom md2json --input ./.SysProM --output .SysProM.json
|
|
227
270
|
```
|
|
228
271
|
|
|
229
272
|
Keep both representations in sync after any change:
|
|
230
273
|
|
|
231
274
|
```sh
|
|
232
275
|
# JSON → Markdown
|
|
233
|
-
|
|
276
|
+
sysprom json2md --input .SysProM.json --output ./.SysProM
|
|
234
277
|
|
|
235
278
|
# Markdown → JSON
|
|
236
|
-
|
|
279
|
+
sysprom md2json --input ./.SysProM --output .SysProM.json
|
|
237
280
|
```
|
|
238
281
|
|
|
239
|
-
> **Important:** Always keep `.
|
|
282
|
+
> **Important:** Always keep `.SysProM.json` and `./.SysProM/` up to date with current activity and in sync with each other. Record all decisions, changes, and new capabilities as they happen. After any change to either representation, run the appropriate conversion command above. Validate with `sysprom validate` before committing.
|
|
240
283
|
|
|
241
284
|
## Claude Code Plugin
|
|
242
285
|
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import type { CommandDef } from "../define-command.js";
|
|
3
|
-
declare const argsSchema: z.ZodObject<{
|
|
4
|
-
path: z.ZodDefault<z.ZodOptional<z.ZodString>>;
|
|
5
|
-
}, z.core.$strip>;
|
|
6
3
|
declare const optsSchema: z.ZodObject<{
|
|
4
|
+
path: z.ZodDefault<z.ZodOptional<z.ZodString>>;
|
|
7
5
|
title: z.ZodOptional<z.ZodString>;
|
|
8
6
|
scope: z.ZodOptional<z.ZodString>;
|
|
9
7
|
format: z.ZodOptional<z.ZodEnum<{
|
|
@@ -12,5 +10,5 @@ declare const optsSchema: z.ZodObject<{
|
|
|
12
10
|
dir: "dir";
|
|
13
11
|
}>>;
|
|
14
12
|
}, z.core.$strict>;
|
|
15
|
-
export declare const initCommand: CommandDef<
|
|
13
|
+
export declare const initCommand: CommandDef<z.ZodObject<z.ZodRawShape>, typeof optsSchema>;
|
|
16
14
|
export {};
|
|
@@ -15,9 +15,9 @@ function formatToIoFormat(fmt) {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
const suffixMap = {
|
|
18
|
-
json: ".
|
|
19
|
-
md: ".
|
|
20
|
-
dir: ".
|
|
18
|
+
json: ".SysProM.json",
|
|
19
|
+
md: ".SysProM.md",
|
|
20
|
+
dir: ".SysProM",
|
|
21
21
|
};
|
|
22
22
|
function resolveInitTarget(pathArg, format) {
|
|
23
23
|
const resolved = resolve(pathArg);
|
|
@@ -30,20 +30,26 @@ function resolveInitTarget(pathArg, format) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
const fmt = format ?? "dir";
|
|
33
|
+
const suffix = suffixMap[fmt];
|
|
34
|
+
// If path already ends with the correct suffix, use it as-is
|
|
35
|
+
if (resolved.endsWith(suffix)) {
|
|
36
|
+
return {
|
|
37
|
+
outputPath: resolved,
|
|
38
|
+
ioFormat: formatToIoFormat(fmt),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
33
41
|
return {
|
|
34
|
-
outputPath: `${resolved}${
|
|
42
|
+
outputPath: `${resolved}${suffix}`,
|
|
35
43
|
ioFormat: formatToIoFormat(fmt),
|
|
36
44
|
};
|
|
37
45
|
}
|
|
38
|
-
const
|
|
46
|
+
const optsSchema = z
|
|
47
|
+
.object({
|
|
39
48
|
path: z
|
|
40
49
|
.string()
|
|
41
50
|
.optional()
|
|
42
51
|
.default(".")
|
|
43
52
|
.describe("Target path (default: current directory)"),
|
|
44
|
-
});
|
|
45
|
-
const optsSchema = z
|
|
46
|
-
.object({
|
|
47
53
|
title: z.string().optional().describe("Document title"),
|
|
48
54
|
scope: z.string().optional().describe("Document scope"),
|
|
49
55
|
format: z
|
|
@@ -56,10 +62,9 @@ export const initCommand = {
|
|
|
56
62
|
name: "init",
|
|
57
63
|
description: initDocumentOp.def.description,
|
|
58
64
|
apiLink: initDocumentOp.def.name,
|
|
59
|
-
args: argsSchema,
|
|
60
65
|
opts: optsSchema,
|
|
61
|
-
action(
|
|
62
|
-
const { outputPath, ioFormat } = resolveInitTarget(
|
|
66
|
+
action(_args, opts) {
|
|
67
|
+
const { outputPath, ioFormat } = resolveInitTarget(opts.path, opts.format);
|
|
63
68
|
if (existsSync(outputPath)) {
|
|
64
69
|
console.error(`Already exists: ${outputPath}`);
|
|
65
70
|
process.exit(1);
|
|
@@ -1,2 +1,9 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
1
2
|
import type { CommandDef } from "../define-command.js";
|
|
2
|
-
|
|
3
|
+
declare const optsSchema: z.ZodObject<{
|
|
4
|
+
input: z.ZodString;
|
|
5
|
+
output: z.ZodString;
|
|
6
|
+
singleFile: z.ZodOptional<z.ZodBoolean>;
|
|
7
|
+
}, z.core.$strict>;
|
|
8
|
+
export declare const json2mdCommand: CommandDef<z.ZodObject<z.ZodRawShape>, typeof optsSchema>;
|
|
9
|
+
export {};
|
|
@@ -4,35 +4,24 @@ import { resolve } from "node:path";
|
|
|
4
4
|
import { SysProMDocument } from "../../schema.js";
|
|
5
5
|
import { jsonToMarkdown } from "../../json-to-md.js";
|
|
6
6
|
import { jsonToMarkdownOp } from "../../operations/index.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
const optsSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
input: z.string().describe("Path to SysProM JSON file"),
|
|
10
|
+
output: z.string().describe("Output path (file or directory)"),
|
|
11
|
+
singleFile: z
|
|
12
|
+
.boolean()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Force single-file output format"),
|
|
15
|
+
})
|
|
16
|
+
.strict();
|
|
13
17
|
export const json2mdCommand = {
|
|
14
18
|
name: "json2md",
|
|
15
19
|
description: jsonToMarkdownOp.def.description,
|
|
16
20
|
apiLink: jsonToMarkdownOp.def.name,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
opts: z
|
|
22
|
-
.object({
|
|
23
|
-
singleFile: z
|
|
24
|
-
.boolean()
|
|
25
|
-
.optional()
|
|
26
|
-
.describe("Force single-file output format"),
|
|
27
|
-
})
|
|
28
|
-
.strict(),
|
|
29
|
-
action(args, opts) {
|
|
30
|
-
if (!isArgs(args))
|
|
31
|
-
throw new Error("Invalid args");
|
|
32
|
-
if (!isOpts(opts))
|
|
33
|
-
throw new Error("Invalid opts");
|
|
34
|
-
const inputPath = resolve(args.input);
|
|
35
|
-
const outputPath = resolve(args.output);
|
|
21
|
+
opts: optsSchema,
|
|
22
|
+
action(_args, opts) {
|
|
23
|
+
const inputPath = resolve(opts.input);
|
|
24
|
+
const outputPath = resolve(opts.output);
|
|
36
25
|
const raw = JSON.parse(readFileSync(inputPath, "utf8"));
|
|
37
26
|
if (!SysProMDocument.is(raw)) {
|
|
38
27
|
const result = SysProMDocument.safeParse(raw);
|
|
@@ -1,2 +1,8 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
1
2
|
import type { CommandDef } from "../define-command.js";
|
|
2
|
-
|
|
3
|
+
declare const optsSchema: z.ZodObject<{
|
|
4
|
+
input: z.ZodString;
|
|
5
|
+
output: z.ZodString;
|
|
6
|
+
}, z.core.$strict>;
|
|
7
|
+
export declare const md2jsonCommand: CommandDef<z.ZodObject<z.ZodRawShape>, typeof optsSchema>;
|
|
8
|
+
export {};
|
|
@@ -4,23 +4,20 @@ import { resolve } from "node:path";
|
|
|
4
4
|
import { markdownToJson } from "../../md-to-json.js";
|
|
5
5
|
import { canonicalise } from "../../canonical-json.js";
|
|
6
6
|
import { markdownToJsonOp } from "../../operations/index.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
const optsSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
input: z.string().describe("Path to SysProM Markdown (file or directory)"),
|
|
10
|
+
output: z.string().describe("Output JSON file path"),
|
|
11
|
+
})
|
|
12
|
+
.strict();
|
|
10
13
|
export const md2jsonCommand = {
|
|
11
14
|
name: "md2json",
|
|
12
15
|
description: markdownToJsonOp.def.description,
|
|
13
16
|
apiLink: markdownToJsonOp.def.name,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
opts: z.object({}).strict(),
|
|
19
|
-
action(args) {
|
|
20
|
-
if (!isArgs(args))
|
|
21
|
-
throw new Error("Invalid args");
|
|
22
|
-
const inputPath = resolve(args.input);
|
|
23
|
-
const outputPath = resolve(args.output);
|
|
17
|
+
opts: optsSchema,
|
|
18
|
+
action(_args, opts) {
|
|
19
|
+
const inputPath = resolve(opts.input);
|
|
20
|
+
const outputPath = resolve(opts.output);
|
|
24
21
|
const doc = markdownToJson(inputPath);
|
|
25
22
|
writeFileSync(outputPath, canonicalise(doc, { indent: "\t" }) + "\n");
|
|
26
23
|
console.log(`Written to ${outputPath}`);
|
|
@@ -6,10 +6,8 @@ import { planInitOp, planAddTaskOp, planStatusOp, planProgressOp, planGateOp, }
|
|
|
6
6
|
// ============================================================================
|
|
7
7
|
// Subcommands
|
|
8
8
|
// ============================================================================
|
|
9
|
-
const initArgs = z.object({
|
|
10
|
-
output: z.string().describe("Path to output SysProM file"),
|
|
11
|
-
});
|
|
12
9
|
const initOpts = z.object({
|
|
10
|
+
output: z.string().describe("Path to output SysProM file"),
|
|
13
11
|
prefix: z.string().describe("Plan prefix (e.g. PLAN)"),
|
|
14
12
|
name: z.string().optional().describe("Plan name (defaults to prefix)"),
|
|
15
13
|
});
|
|
@@ -17,10 +15,9 @@ const initSubcommand = {
|
|
|
17
15
|
name: "init",
|
|
18
16
|
description: planInitOp.def.description,
|
|
19
17
|
apiLink: planInitOp.def.name,
|
|
20
|
-
args: initArgs,
|
|
21
18
|
opts: initOpts,
|
|
22
|
-
action(
|
|
23
|
-
const outputPath =
|
|
19
|
+
action(_args, opts) {
|
|
20
|
+
const outputPath = opts.output;
|
|
24
21
|
const prefix = opts.prefix;
|
|
25
22
|
const name = opts.name ?? prefix;
|
|
26
23
|
if (existsSync(outputPath)) {
|
|
@@ -45,11 +45,9 @@ function compareDocuments(oldDoc, newDoc) {
|
|
|
45
45
|
// ============================================================================
|
|
46
46
|
// Subcommands
|
|
47
47
|
// ============================================================================
|
|
48
|
-
const
|
|
48
|
+
const importOpts = z.object({
|
|
49
49
|
speckitDir: z.string().describe("Path to Spec-Kit feature directory"),
|
|
50
50
|
output: z.string().describe("Path to output SysProM file"),
|
|
51
|
-
});
|
|
52
|
-
const importOpts = z.object({
|
|
53
51
|
prefix: z
|
|
54
52
|
.string()
|
|
55
53
|
.optional()
|
|
@@ -58,11 +56,10 @@ const importOpts = z.object({
|
|
|
58
56
|
const importSubcommand = {
|
|
59
57
|
name: "import",
|
|
60
58
|
description: speckitImportOp.def.description,
|
|
61
|
-
args: importArgs,
|
|
62
59
|
opts: importOpts,
|
|
63
|
-
action(
|
|
64
|
-
const specKitDir = resolve(
|
|
65
|
-
const outputPath = resolve(
|
|
60
|
+
action(_args, opts) {
|
|
61
|
+
const specKitDir = resolve(opts.speckitDir);
|
|
62
|
+
const outputPath = resolve(opts.output);
|
|
66
63
|
if (!existsSync(specKitDir)) {
|
|
67
64
|
console.error(`Error: Spec-Kit directory does not exist: ${specKitDir}`);
|
|
68
65
|
process.exit(1);
|
|
@@ -97,21 +94,18 @@ const importSubcommand = {
|
|
|
97
94
|
console.log(` ${String(nodeCount)} nodes, ${String(relationshipCount)} relationships`);
|
|
98
95
|
},
|
|
99
96
|
};
|
|
100
|
-
const
|
|
97
|
+
const exportOpts = z.object({
|
|
101
98
|
input: z.string().describe("Path to SysProM document"),
|
|
102
99
|
speckitDir: z.string().describe("Path to Spec-Kit output directory"),
|
|
103
|
-
});
|
|
104
|
-
const exportOpts = z.object({
|
|
105
100
|
prefix: z.string().describe("ID prefix identifying nodes to export"),
|
|
106
101
|
});
|
|
107
102
|
const exportSubcommand = {
|
|
108
103
|
name: "export",
|
|
109
104
|
description: speckitExportOp.def.description,
|
|
110
|
-
args: exportArgs,
|
|
111
105
|
opts: exportOpts,
|
|
112
|
-
action(
|
|
113
|
-
const inputPath = resolve(
|
|
114
|
-
const specKitDir = resolve(
|
|
106
|
+
action(_args, opts) {
|
|
107
|
+
const inputPath = resolve(opts.input);
|
|
108
|
+
const specKitDir = resolve(opts.speckitDir);
|
|
115
109
|
if (!opts.prefix) {
|
|
116
110
|
console.error("Error: --prefix flag is required for export (identifies which nodes to export)");
|
|
117
111
|
process.exit(1);
|
|
@@ -125,11 +119,9 @@ const exportSubcommand = {
|
|
|
125
119
|
console.log(` Generated Spec-Kit files with prefix: ${opts.prefix}`);
|
|
126
120
|
},
|
|
127
121
|
};
|
|
128
|
-
const
|
|
122
|
+
const syncSubOpts = z.object({
|
|
129
123
|
input: z.string().describe("Path to SysProM document"),
|
|
130
124
|
speckitDir: z.string().describe("Path to Spec-Kit directory"),
|
|
131
|
-
});
|
|
132
|
-
const syncOpts = z.object({
|
|
133
125
|
prefix: z
|
|
134
126
|
.string()
|
|
135
127
|
.optional()
|
|
@@ -138,11 +130,10 @@ const syncOpts = z.object({
|
|
|
138
130
|
const syncSubcommand = {
|
|
139
131
|
name: "sync",
|
|
140
132
|
description: speckitSyncOp.def.description,
|
|
141
|
-
|
|
142
|
-
opts
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
const specKitDir = resolve(args.speckitDir);
|
|
133
|
+
opts: syncSubOpts,
|
|
134
|
+
action(_args, opts) {
|
|
135
|
+
const inputPath = resolve(opts.input);
|
|
136
|
+
const specKitDir = resolve(opts.speckitDir);
|
|
146
137
|
if (!existsSync(inputPath)) {
|
|
147
138
|
console.error(`Error: Input file does not exist: ${inputPath}`);
|
|
148
139
|
process.exit(1);
|
|
@@ -226,11 +217,9 @@ const syncSubcommand = {
|
|
|
226
217
|
}
|
|
227
218
|
},
|
|
228
219
|
};
|
|
229
|
-
const
|
|
220
|
+
const diffSubOpts = z.object({
|
|
230
221
|
input: z.string().describe("Path to SysProM document"),
|
|
231
222
|
speckitDir: z.string().describe("Path to Spec-Kit directory"),
|
|
232
|
-
});
|
|
233
|
-
const diffOpts = z.object({
|
|
234
223
|
prefix: z
|
|
235
224
|
.string()
|
|
236
225
|
.optional()
|
|
@@ -239,11 +228,10 @@ const diffOpts = z.object({
|
|
|
239
228
|
const diffSubcommand = {
|
|
240
229
|
name: "diff",
|
|
241
230
|
description: speckitDiffOp.def.description,
|
|
242
|
-
|
|
243
|
-
opts
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
const specKitDir = resolve(args.speckitDir);
|
|
231
|
+
opts: diffSubOpts,
|
|
232
|
+
action(_args, opts) {
|
|
233
|
+
const inputPath = resolve(opts.input);
|
|
234
|
+
const specKitDir = resolve(opts.speckitDir);
|
|
247
235
|
if (!existsSync(inputPath)) {
|
|
248
236
|
console.error(`Error: Input file does not exist: ${inputPath}`);
|
|
249
237
|
process.exit(1);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
1
2
|
import type { CommandDef } from "../define-command.js";
|
|
2
3
|
import { type BidirectionalSyncResult, type ConflictStrategy } from "../../operations/index.js";
|
|
3
4
|
interface SyncCommandInput {
|
|
@@ -14,5 +15,13 @@ interface SyncCommandInput {
|
|
|
14
15
|
* const result = syncCommand({ jsonPath: "doc.spm.json", mdPath: "doc.spm.md" });
|
|
15
16
|
*/
|
|
16
17
|
export declare function syncCommand(input: SyncCommandInput): BidirectionalSyncResult;
|
|
17
|
-
|
|
18
|
+
declare const syncOpts: z.ZodObject<{
|
|
19
|
+
input: z.ZodString;
|
|
20
|
+
output: z.ZodString;
|
|
21
|
+
preferJson: z.ZodOptional<z.ZodBoolean>;
|
|
22
|
+
preferMd: z.ZodOptional<z.ZodBoolean>;
|
|
23
|
+
dryRun: z.ZodOptional<z.ZodBoolean>;
|
|
24
|
+
report: z.ZodOptional<z.ZodBoolean>;
|
|
25
|
+
}, z.core.$strict>;
|
|
26
|
+
export declare const syncCommandDef: CommandDef<z.ZodObject<z.ZodRawShape>, typeof syncOpts>;
|
|
18
27
|
export {};
|
|
@@ -47,47 +47,36 @@ export function syncCommand(input) {
|
|
|
47
47
|
}
|
|
48
48
|
return result;
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
const syncOpts = z
|
|
51
|
+
.object({
|
|
52
|
+
input: z.string().describe("Path to JSON file"),
|
|
53
|
+
output: z.string().describe("Path to Markdown file"),
|
|
54
|
+
preferJson: z
|
|
55
|
+
.boolean()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe("Prefer JSON as source of truth in conflicts"),
|
|
58
|
+
preferMd: z
|
|
59
|
+
.boolean()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("Prefer Markdown as source of truth in conflicts"),
|
|
62
|
+
dryRun: z
|
|
63
|
+
.boolean()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Preview changes without writing files"),
|
|
66
|
+
report: z
|
|
67
|
+
.boolean()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe("Report conflicts without resolving"),
|
|
70
|
+
})
|
|
71
|
+
.strict();
|
|
56
72
|
export const syncCommandDef = {
|
|
57
73
|
name: "sync",
|
|
58
74
|
description: "Synchronise JSON and Markdown representations with conflict resolution",
|
|
59
75
|
apiLink: "syncDocuments",
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
opts: z
|
|
65
|
-
.object({
|
|
66
|
-
preferJson: z
|
|
67
|
-
.boolean()
|
|
68
|
-
.optional()
|
|
69
|
-
.describe("Prefer JSON as source of truth in conflicts"),
|
|
70
|
-
preferMd: z
|
|
71
|
-
.boolean()
|
|
72
|
-
.optional()
|
|
73
|
-
.describe("Prefer Markdown as source of truth in conflicts"),
|
|
74
|
-
dryRun: z
|
|
75
|
-
.boolean()
|
|
76
|
-
.optional()
|
|
77
|
-
.describe("Preview changes without writing files"),
|
|
78
|
-
report: z
|
|
79
|
-
.boolean()
|
|
80
|
-
.optional()
|
|
81
|
-
.describe("Report conflicts without resolving"),
|
|
82
|
-
})
|
|
83
|
-
.strict(),
|
|
84
|
-
action(args, opts) {
|
|
85
|
-
if (!isArgs(args))
|
|
86
|
-
throw new Error("Invalid args");
|
|
87
|
-
if (!isOpts(opts))
|
|
88
|
-
throw new Error("Invalid opts");
|
|
89
|
-
const jsonPath = resolve(args.input);
|
|
90
|
-
const mdPath = resolve(args.output);
|
|
76
|
+
opts: syncOpts,
|
|
77
|
+
action(_args, opts) {
|
|
78
|
+
const jsonPath = resolve(opts.input);
|
|
79
|
+
const mdPath = resolve(opts.output);
|
|
91
80
|
// Determine conflict strategy
|
|
92
81
|
let strategy = "json";
|
|
93
82
|
if (opts.preferMd)
|
package/dist/src/cli/shared.d.ts
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { type Format } from "../io.js";
|
|
3
3
|
import type { SysProMDocument } from "../schema.js";
|
|
4
|
-
/** @deprecated Use --path option in readOpts/mutationOpts instead. */
|
|
5
|
-
export declare const inputArg: z.ZodString;
|
|
6
4
|
/** Empty args schema for commands that take no positional arguments. */
|
|
7
5
|
export declare const noArgs: z.ZodObject<{}, z.core.$strict>;
|
|
8
6
|
/**
|
|
9
7
|
* Resolve a SysProM document path. If no explicit path is given, search the
|
|
10
8
|
* working directory by priority:
|
|
11
|
-
* 1. .
|
|
12
|
-
* 4. .
|
|
13
|
-
* 7.
|
|
14
|
-
* 10. *.
|
|
9
|
+
* 1. .SysProM.json 2. .SysProM.md 3. .SysProM/
|
|
10
|
+
* 4. .spm.json 5. .spm.md 6. .spm/
|
|
11
|
+
* 7. .sysprom.json 8. .sysprom.md 9. .sysprom/
|
|
12
|
+
* 10. *.SysProM.json 11. *.SysProM.md 12. *.SysProM/
|
|
13
|
+
* 13. *.spm.json 14. *.spm.md 15. *.spm/
|
|
14
|
+
* 16. *.sysprom.json 17. *.sysprom.md 18. *.sysprom/
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* Matching is case-insensitive for base names and directory matches.
|
|
17
|
+
* Files like .SysProM.json, .sysprom.json, and .SYSPROM.JSON are treated
|
|
18
|
+
* as equivalent and all match the .SysProM.json priority tier.
|
|
19
|
+
* Glob tiers must have exactly one match.
|
|
17
20
|
* @param input - Explicit document path, or undefined for auto-detection.
|
|
18
21
|
* @param cwd - Working directory to search from (defaults to `.`).
|
|
19
22
|
* @returns The resolved document path.
|
|
20
23
|
* @example
|
|
21
24
|
* ```ts
|
|
22
|
-
* resolveInput() // => auto-detects ".
|
|
23
|
-
* resolveInput("my-doc.
|
|
25
|
+
* resolveInput() // => auto-detects ".SysProM.json" in cwd
|
|
26
|
+
* resolveInput("my-doc.SysProM.json") // => "my-doc.SysProM.json"
|
|
24
27
|
* ```
|
|
25
28
|
*/
|
|
26
29
|
export declare function resolveInput(input?: string, cwd?: string): string;
|
package/dist/src/cli/shared.js
CHANGED
|
@@ -6,10 +6,6 @@ import { jsonToMarkdownMultiDoc } from "../json-to-md.js";
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Reusable CLI schemas — shared across all commands
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
|
-
/** @deprecated Use --path option in readOpts/mutationOpts instead. */
|
|
10
|
-
export const inputArg = z
|
|
11
|
-
.string()
|
|
12
|
-
.describe("SysProM document path (JSON, .md, or directory)");
|
|
13
9
|
/** Empty args schema for commands that take no positional arguments. */
|
|
14
10
|
export const noArgs = z.object({}).strict();
|
|
15
11
|
/** Shared --path option for specifying the SysProM document location. */
|
|
@@ -23,27 +19,34 @@ const pathOpt = z
|
|
|
23
19
|
/**
|
|
24
20
|
* Resolve a SysProM document path. If no explicit path is given, search the
|
|
25
21
|
* working directory by priority:
|
|
26
|
-
* 1. .
|
|
27
|
-
* 4. .
|
|
28
|
-
* 7.
|
|
29
|
-
* 10. *.
|
|
22
|
+
* 1. .SysProM.json 2. .SysProM.md 3. .SysProM/
|
|
23
|
+
* 4. .spm.json 5. .spm.md 6. .spm/
|
|
24
|
+
* 7. .sysprom.json 8. .sysprom.md 9. .sysprom/
|
|
25
|
+
* 10. *.SysProM.json 11. *.SysProM.md 12. *.SysProM/
|
|
26
|
+
* 13. *.spm.json 14. *.spm.md 15. *.spm/
|
|
27
|
+
* 16. *.sysprom.json 17. *.sysprom.md 18. *.sysprom/
|
|
30
28
|
*
|
|
31
|
-
*
|
|
29
|
+
* Matching is case-insensitive for base names and directory matches.
|
|
30
|
+
* Files like .SysProM.json, .sysprom.json, and .SYSPROM.JSON are treated
|
|
31
|
+
* as equivalent and all match the .SysProM.json priority tier.
|
|
32
|
+
* Glob tiers must have exactly one match.
|
|
32
33
|
* @param input - Explicit document path, or undefined for auto-detection.
|
|
33
34
|
* @param cwd - Working directory to search from (defaults to `.`).
|
|
34
35
|
* @returns The resolved document path.
|
|
35
36
|
* @example
|
|
36
37
|
* ```ts
|
|
37
|
-
* resolveInput() // => auto-detects ".
|
|
38
|
-
* resolveInput("my-doc.
|
|
38
|
+
* resolveInput() // => auto-detects ".SysProM.json" in cwd
|
|
39
|
+
* resolveInput("my-doc.SysProM.json") // => "my-doc.SysProM.json"
|
|
39
40
|
* ```
|
|
40
41
|
*/
|
|
41
42
|
export function resolveInput(input, cwd) {
|
|
42
43
|
if (input)
|
|
43
44
|
return input;
|
|
44
45
|
const dir = resolve(cwd ?? ".");
|
|
45
|
-
// Exact names to check, in priority order (case-insensitive)
|
|
46
46
|
const exactNames = [
|
|
47
|
+
".SysProM.json",
|
|
48
|
+
".SysProM.md",
|
|
49
|
+
".SysProM",
|
|
47
50
|
".spm.json",
|
|
48
51
|
".spm.md",
|
|
49
52
|
".spm",
|
|
@@ -52,9 +55,42 @@ export function resolveInput(input, cwd) {
|
|
|
52
55
|
".sysprom",
|
|
53
56
|
];
|
|
54
57
|
const entries = readdirSync(dir);
|
|
58
|
+
// Phase 1: Try exact case-sensitive matches first
|
|
59
|
+
for (const name of exactNames) {
|
|
60
|
+
const isDirSuffix = name.endsWith(".SysProM") ||
|
|
61
|
+
name.endsWith(".spm") ||
|
|
62
|
+
name.endsWith(".sysprom");
|
|
63
|
+
const found = entries.filter((e) => e === name);
|
|
64
|
+
if (found.length === 1) {
|
|
65
|
+
// Before returning, check for case-variant collisions on case-sensitive
|
|
66
|
+
// filesystems (e.g. both .spm.json and .SPM.json exist).
|
|
67
|
+
const nameLower = name.toLowerCase();
|
|
68
|
+
const caseVariants = entries.filter((e) => e !== name && e.toLowerCase() === nameLower);
|
|
69
|
+
if (caseVariants.length > 0) {
|
|
70
|
+
throw new Error(`Multiple SysProM documents found: ${[name, ...caseVariants].join(", ")}. Specify one explicitly.`);
|
|
71
|
+
}
|
|
72
|
+
const candidate = join(dir, found[0]);
|
|
73
|
+
if (isDirSuffix) {
|
|
74
|
+
try {
|
|
75
|
+
if (statSync(candidate).isDirectory())
|
|
76
|
+
return candidate;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* skip */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
return candidate;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Phase 2: Try case-insensitive matches (supports case variations like .SPM.JSON)
|
|
55
88
|
for (const name of exactNames) {
|
|
56
|
-
const isDirSuffix = name.endsWith(".
|
|
57
|
-
|
|
89
|
+
const isDirSuffix = name.endsWith(".SysProM") ||
|
|
90
|
+
name.endsWith(".spm") ||
|
|
91
|
+
name.endsWith(".sysprom");
|
|
92
|
+
const nameLower = name.toLowerCase();
|
|
93
|
+
const found = entries.filter((e) => e.toLowerCase() === nameLower);
|
|
58
94
|
if (found.length > 1) {
|
|
59
95
|
throw new Error(`Multiple SysProM documents found: ${found.join(", ")}. Specify one explicitly.`);
|
|
60
96
|
}
|
|
@@ -76,6 +112,9 @@ export function resolveInput(input, cwd) {
|
|
|
76
112
|
}
|
|
77
113
|
// Glob suffixes in priority order (case-insensitive)
|
|
78
114
|
const globSuffixes = [
|
|
115
|
+
".SysProM.json",
|
|
116
|
+
".SysProM.md",
|
|
117
|
+
".SysProM",
|
|
79
118
|
".spm.json",
|
|
80
119
|
".spm.md",
|
|
81
120
|
".spm",
|
|
@@ -84,11 +123,16 @@ export function resolveInput(input, cwd) {
|
|
|
84
123
|
".sysprom",
|
|
85
124
|
];
|
|
86
125
|
for (const suffix of globSuffixes) {
|
|
87
|
-
const isDirSuffix = suffix === ".spm" || suffix === ".sysprom";
|
|
126
|
+
const isDirSuffix = suffix === ".SysProM" || suffix === ".spm" || suffix === ".sysprom";
|
|
127
|
+
const suffixLower = suffix.toLowerCase();
|
|
88
128
|
const matches = entries
|
|
89
129
|
.filter((e) => {
|
|
90
130
|
const lower = e.toLowerCase();
|
|
91
|
-
|
|
131
|
+
// Match files ending with this suffix (case-insensitive) that don't start with "."
|
|
132
|
+
// and are not exact matches (those were already checked)
|
|
133
|
+
return (lower.endsWith(suffixLower) &&
|
|
134
|
+
lower !== suffixLower &&
|
|
135
|
+
!e.startsWith("."));
|
|
92
136
|
})
|
|
93
137
|
.map((e) => join(dir, e))
|
|
94
138
|
.filter((p) => {
|
package/dist/src/json-to-md.js
CHANGED
|
@@ -22,6 +22,58 @@ function renderFrontMatter(fields) {
|
|
|
22
22
|
lines.push("---");
|
|
23
23
|
return lines.join("\n");
|
|
24
24
|
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Node location map (for hyperlinking)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/** GitHub-compatible heading anchor slug. */
|
|
29
|
+
function slugify(text) {
|
|
30
|
+
return text
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^\w\s-]/g, "")
|
|
33
|
+
.replace(/\s/g, "-");
|
|
34
|
+
}
|
|
35
|
+
/** Heading anchor for a node: `id--name` slugified from `### ID — Name`. */
|
|
36
|
+
function nodeAnchor(n) {
|
|
37
|
+
return slugify(`${n.id} — ${n.name}`);
|
|
38
|
+
}
|
|
39
|
+
/** Build a map from node ID to its markdown file and heading anchor. */
|
|
40
|
+
function buildNodeLocationMap(nodes, mode) {
|
|
41
|
+
const map = new Map();
|
|
42
|
+
for (const n of nodes) {
|
|
43
|
+
const anchor = nodeAnchor(n);
|
|
44
|
+
if (mode === "single-file") {
|
|
45
|
+
map.set(n.id, { file: "", anchor });
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const file = fileForNodeType(n.type);
|
|
49
|
+
map.set(n.id, { file, anchor });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return map;
|
|
53
|
+
}
|
|
54
|
+
/** Determine the markdown file a node type belongs to in multi-doc mode. */
|
|
55
|
+
function fileForNodeType(type) {
|
|
56
|
+
for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) {
|
|
57
|
+
if (types.includes(type))
|
|
58
|
+
return `${fileName}.md`;
|
|
59
|
+
}
|
|
60
|
+
return "README.md";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Format a node ID as a markdown hyperlink.
|
|
64
|
+
* In single-file mode: `[ID](#anchor)`
|
|
65
|
+
* In multi-doc mode: `[ID](./FILE.md#anchor)`
|
|
66
|
+
* Falls back to plain ID if the node isn't in the map.
|
|
67
|
+
*/
|
|
68
|
+
function linkNodeId(id, nodeMap, currentFile) {
|
|
69
|
+
const loc = nodeMap.get(id);
|
|
70
|
+
if (!loc)
|
|
71
|
+
return id;
|
|
72
|
+
if (loc.file === "" || loc.file === currentFile) {
|
|
73
|
+
return `[${id}](#${loc.anchor})`;
|
|
74
|
+
}
|
|
75
|
+
return `[${id}](./${loc.file}#${loc.anchor})`;
|
|
76
|
+
}
|
|
25
77
|
function indexRelationshipsFrom(rels) {
|
|
26
78
|
const idx = new Map();
|
|
27
79
|
for (const r of rels) {
|
|
@@ -79,7 +131,7 @@ function renderLifecycle(lifecycle) {
|
|
|
79
131
|
return `- [${checkbox}] ${label}`;
|
|
80
132
|
});
|
|
81
133
|
}
|
|
82
|
-
function renderNodeRelationships(nodeId, fromIdx) {
|
|
134
|
+
function renderNodeRelationships(nodeId, fromIdx, nodeMap, currentFile) {
|
|
83
135
|
const rels = fromIdx.get(nodeId);
|
|
84
136
|
if (!rels || rels.length === 0)
|
|
85
137
|
return [];
|
|
@@ -97,12 +149,12 @@ function renderNodeRelationships(nodeId, fromIdx) {
|
|
|
97
149
|
? RELATIONSHIP_TYPE_LABELS[type]
|
|
98
150
|
: type;
|
|
99
151
|
if (targets.length === 1) {
|
|
100
|
-
lines.push(`- ${label}: ${targets[0]}`);
|
|
152
|
+
lines.push(`- ${label}: ${linkNodeId(targets[0], nodeMap, currentFile)}`);
|
|
101
153
|
}
|
|
102
154
|
else {
|
|
103
155
|
lines.push(`- ${label}:`);
|
|
104
156
|
for (const t of targets) {
|
|
105
|
-
lines.push(` - ${t}`);
|
|
157
|
+
lines.push(` - ${linkNodeId(t, nodeMap, currentFile)}`);
|
|
106
158
|
}
|
|
107
159
|
}
|
|
108
160
|
}
|
|
@@ -123,7 +175,7 @@ function renderExternalReferences(refs) {
|
|
|
123
175
|
}
|
|
124
176
|
return lines;
|
|
125
177
|
}
|
|
126
|
-
function renderNode(n, headingLevel, fromIdx) {
|
|
178
|
+
function renderNode(n, headingLevel, fromIdx, nodeMap, currentFile) {
|
|
127
179
|
const prefix = "#".repeat(headingLevel);
|
|
128
180
|
const lines = [];
|
|
129
181
|
lines.push(`${prefix} ${n.id} — ${n.name}`);
|
|
@@ -132,7 +184,7 @@ function renderNode(n, headingLevel, fromIdx) {
|
|
|
132
184
|
lines.push(renderText(n.description));
|
|
133
185
|
lines.push("");
|
|
134
186
|
}
|
|
135
|
-
const rels = renderNodeRelationships(n.id, fromIdx);
|
|
187
|
+
const rels = renderNodeRelationships(n.id, fromIdx, nodeMap, currentFile);
|
|
136
188
|
if (rels.length > 0) {
|
|
137
189
|
lines.push(...rels);
|
|
138
190
|
lines.push("");
|
|
@@ -207,7 +259,7 @@ function renderNode(n, headingLevel, fromIdx) {
|
|
|
207
259
|
if (n.includes && n.includes.length > 0) {
|
|
208
260
|
lines.push("Includes:");
|
|
209
261
|
for (const inc of n.includes) {
|
|
210
|
-
lines.push(`- ${inc}`);
|
|
262
|
+
lines.push(`- ${linkNodeId(inc, nodeMap, currentFile)}`);
|
|
211
263
|
}
|
|
212
264
|
lines.push("");
|
|
213
265
|
}
|
|
@@ -233,8 +285,9 @@ function renderNode(n, headingLevel, fromIdx) {
|
|
|
233
285
|
const subNodes = n.subsystem.nodes;
|
|
234
286
|
const subRels = n.subsystem.relationships ?? [];
|
|
235
287
|
const subIdx = indexRelationshipsFrom(subRels);
|
|
288
|
+
const subMap = buildNodeLocationMap(subNodes, "single-file");
|
|
236
289
|
for (const sub of subNodes) {
|
|
237
|
-
lines.push(...renderNode(sub, headingLevel + 2, subIdx));
|
|
290
|
+
lines.push(...renderNode(sub, headingLevel + 2, subIdx, subMap));
|
|
238
291
|
}
|
|
239
292
|
}
|
|
240
293
|
return lines;
|
|
@@ -242,7 +295,7 @@ function renderNode(n, headingLevel, fromIdx) {
|
|
|
242
295
|
// ---------------------------------------------------------------------------
|
|
243
296
|
// File generators
|
|
244
297
|
// ---------------------------------------------------------------------------
|
|
245
|
-
function renderNodesGrouped(nodes, types, fromIdx, headingLevel) {
|
|
298
|
+
function renderNodesGrouped(nodes, types, fromIdx, headingLevel, nodeMap, currentFile) {
|
|
246
299
|
const lines = [];
|
|
247
300
|
for (const type of types) {
|
|
248
301
|
const matching = nodes.filter((n) => n.type === type);
|
|
@@ -252,12 +305,12 @@ function renderNodesGrouped(nodes, types, fromIdx, headingLevel) {
|
|
|
252
305
|
lines.push(`${"#".repeat(headingLevel)} ${label}`);
|
|
253
306
|
lines.push("");
|
|
254
307
|
for (const n of matching) {
|
|
255
|
-
lines.push(...renderNode(n, headingLevel + 1, fromIdx));
|
|
308
|
+
lines.push(...renderNode(n, headingLevel + 1, fromIdx, nodeMap, currentFile));
|
|
256
309
|
}
|
|
257
310
|
}
|
|
258
311
|
return lines;
|
|
259
312
|
}
|
|
260
|
-
function generateReadme(doc, fromIdx) {
|
|
313
|
+
function generateReadme(doc, fromIdx, nodeMap) {
|
|
261
314
|
const lines = [];
|
|
262
315
|
const title = doc.metadata?.title ?? "SysProM";
|
|
263
316
|
lines.push(renderFrontMatter({
|
|
@@ -329,7 +382,7 @@ function generateReadme(doc, fromIdx) {
|
|
|
329
382
|
// Views
|
|
330
383
|
const views = doc.nodes.filter((n) => n.type === "view");
|
|
331
384
|
if (views.length > 0) {
|
|
332
|
-
lines.push(...renderNodesGrouped(doc.nodes, ["view"], fromIdx, 2));
|
|
385
|
+
lines.push(...renderNodesGrouped(doc.nodes, ["view"], fromIdx, 2, nodeMap, "README.md"));
|
|
333
386
|
}
|
|
334
387
|
// Graph-level external references
|
|
335
388
|
if (doc.external_references && doc.external_references.length > 0) {
|
|
@@ -347,7 +400,7 @@ function generateReadme(doc, fromIdx) {
|
|
|
347
400
|
}
|
|
348
401
|
return lines.join("\n") + "\n";
|
|
349
402
|
}
|
|
350
|
-
function generateDocFile(doc, fileName, types, fromIdx) {
|
|
403
|
+
function generateDocFile(doc, fileName, types, fromIdx, nodeMap) {
|
|
351
404
|
const lines = [];
|
|
352
405
|
lines.push(renderFrontMatter({
|
|
353
406
|
title: fileName.replace(".md", ""),
|
|
@@ -356,7 +409,7 @@ function generateDocFile(doc, fileName, types, fromIdx) {
|
|
|
356
409
|
lines.push("");
|
|
357
410
|
lines.push(`# ${fileName.replace(".md", "")}`);
|
|
358
411
|
lines.push("");
|
|
359
|
-
lines.push(...renderNodesGrouped(doc.nodes, types, fromIdx, 2));
|
|
412
|
+
lines.push(...renderNodesGrouped(doc.nodes, types, fromIdx, 2, nodeMap, `${fileName}.md`));
|
|
360
413
|
return lines.join("\n") + "\n";
|
|
361
414
|
}
|
|
362
415
|
/**
|
|
@@ -371,6 +424,7 @@ function generateDocFile(doc, fileName, types, fromIdx) {
|
|
|
371
424
|
*/
|
|
372
425
|
export function jsonToMarkdownSingle(doc) {
|
|
373
426
|
const fromIdx = indexRelationshipsFrom(doc.relationships ?? []);
|
|
427
|
+
const nodeMap = buildNodeLocationMap(doc.nodes, "single-file");
|
|
374
428
|
const lines = [];
|
|
375
429
|
const title = doc.metadata?.title ?? "SysProM";
|
|
376
430
|
lines.push(renderFrontMatter({
|
|
@@ -394,7 +448,7 @@ export function jsonToMarkdownSingle(doc) {
|
|
|
394
448
|
"milestone",
|
|
395
449
|
"version",
|
|
396
450
|
];
|
|
397
|
-
lines.push(...renderNodesGrouped(doc.nodes, allTypes, fromIdx, 2));
|
|
451
|
+
lines.push(...renderNodesGrouped(doc.nodes, allTypes, fromIdx, 2, nodeMap));
|
|
398
452
|
// Relationships summary
|
|
399
453
|
if (doc.relationships && doc.relationships.length > 0) {
|
|
400
454
|
lines.push("## Relationships");
|
|
@@ -433,12 +487,13 @@ export function jsonToMarkdownSingle(doc) {
|
|
|
433
487
|
export function jsonToMarkdownMultiDoc(doc, outDir) {
|
|
434
488
|
mkdirSync(outDir, { recursive: true });
|
|
435
489
|
const fromIdx = indexRelationshipsFrom(doc.relationships ?? []);
|
|
436
|
-
|
|
490
|
+
const nodeMap = buildNodeLocationMap(doc.nodes, "multi-doc");
|
|
491
|
+
writeFileSync(join(outDir, "README.md"), generateReadme(doc, fromIdx, nodeMap));
|
|
437
492
|
for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) {
|
|
438
493
|
const hasNodes = doc.nodes.some((n) => types.includes(n.type));
|
|
439
494
|
if (!hasNodes)
|
|
440
495
|
continue;
|
|
441
|
-
writeFileSync(join(outDir, `${fileName}.md`), generateDocFile(doc, fileName, types, fromIdx));
|
|
496
|
+
writeFileSync(join(outDir, `${fileName}.md`), generateDocFile(doc, fileName, types, fromIdx, nodeMap));
|
|
442
497
|
}
|
|
443
498
|
// Subsystem folders or single files
|
|
444
499
|
const subsystemNodes = doc.nodes.filter((n) => n.subsystem);
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
-
import { loadDocument } from "../io.js";
|
|
5
|
+
import { loadDocument, saveDocument } from "../io.js";
|
|
6
6
|
import { NodeType, RelationshipType } from "../schema.js";
|
|
7
7
|
import { validateOp, statsOp, queryNodesOp, queryNodeOp, queryRelationshipsOp, traceFromNodeOp, addNodeOp, removeNodeOp, updateNodeOp, addRelationshipOp, removeRelationshipOp, nextIdOp, } from "../operations/index.js";
|
|
8
8
|
// Create MCP server instance
|
|
@@ -145,14 +145,14 @@ server.registerTool("add-node", {
|
|
|
145
145
|
description: z.string().optional().describe("Node description"),
|
|
146
146
|
}),
|
|
147
147
|
}, ({ path, type, id, name, description }) => {
|
|
148
|
-
const
|
|
148
|
+
const loaded = loadDocument(path);
|
|
149
149
|
const nodeType = NodeType.safeParse(type);
|
|
150
150
|
if (!nodeType.success) {
|
|
151
151
|
throw new Error(`Invalid node type: "${type}". Valid types: ${NodeType.options.join(", ")}`);
|
|
152
152
|
}
|
|
153
|
-
const nodeId = id ?? nextIdOp({ doc, type: nodeType.data });
|
|
153
|
+
const nodeId = id ?? nextIdOp({ doc: loaded.doc, type: nodeType.data });
|
|
154
154
|
const updated = addNodeOp({
|
|
155
|
-
doc,
|
|
155
|
+
doc: loaded.doc,
|
|
156
156
|
node: {
|
|
157
157
|
id: nodeId,
|
|
158
158
|
type: nodeType.data,
|
|
@@ -160,6 +160,7 @@ server.registerTool("add-node", {
|
|
|
160
160
|
...(description && { description }),
|
|
161
161
|
},
|
|
162
162
|
});
|
|
163
|
+
saveDocument(updated, loaded.format, loaded.path);
|
|
163
164
|
return {
|
|
164
165
|
content: [
|
|
165
166
|
{
|
|
@@ -181,8 +182,9 @@ server.registerTool("remove-node", {
|
|
|
181
182
|
id: z.string().describe("Node ID"),
|
|
182
183
|
}),
|
|
183
184
|
}, ({ path, id }) => {
|
|
184
|
-
const
|
|
185
|
-
const result = removeNodeOp({ doc, id });
|
|
185
|
+
const loaded = loadDocument(path);
|
|
186
|
+
const result = removeNodeOp({ doc: loaded.doc, id });
|
|
187
|
+
saveDocument(result.doc, loaded.format, loaded.path);
|
|
186
188
|
return {
|
|
187
189
|
content: [
|
|
188
190
|
{
|
|
@@ -205,7 +207,7 @@ server.registerTool("update-node", {
|
|
|
205
207
|
fields: z.record(z.string(), z.unknown()).describe("Fields to update"),
|
|
206
208
|
}),
|
|
207
209
|
}, ({ path, id, fields }) => {
|
|
208
|
-
const
|
|
210
|
+
const loaded = loadDocument(path);
|
|
209
211
|
// Validate fields are valid node property updates
|
|
210
212
|
const validFields = Object.entries(fields).reduce((acc, [key, value]) => {
|
|
211
213
|
// Allow common node fields; unknown fields are silently ignored
|
|
@@ -231,10 +233,11 @@ server.registerTool("update-node", {
|
|
|
231
233
|
return acc;
|
|
232
234
|
}, {});
|
|
233
235
|
const updated = updateNodeOp({
|
|
234
|
-
doc,
|
|
236
|
+
doc: loaded.doc,
|
|
235
237
|
id,
|
|
236
238
|
fields: validFields,
|
|
237
239
|
});
|
|
240
|
+
saveDocument(updated, loaded.format, loaded.path);
|
|
238
241
|
const node = updated.nodes.find((n) => n.id === id);
|
|
239
242
|
return {
|
|
240
243
|
content: [
|
|
@@ -255,19 +258,20 @@ server.registerTool("add-relationship", {
|
|
|
255
258
|
type: z.string().describe("Relationship type"),
|
|
256
259
|
}),
|
|
257
260
|
}, ({ path, from, to, type }) => {
|
|
258
|
-
const
|
|
261
|
+
const loaded = loadDocument(path);
|
|
259
262
|
const relType = RelationshipType.safeParse(type);
|
|
260
263
|
if (!relType.success) {
|
|
261
264
|
throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
|
|
262
265
|
}
|
|
263
266
|
const updated = addRelationshipOp({
|
|
264
|
-
doc,
|
|
267
|
+
doc: loaded.doc,
|
|
265
268
|
rel: {
|
|
266
269
|
from,
|
|
267
270
|
to,
|
|
268
271
|
type: relType.data,
|
|
269
272
|
},
|
|
270
273
|
});
|
|
274
|
+
saveDocument(updated, loaded.format, loaded.path);
|
|
271
275
|
return {
|
|
272
276
|
content: [
|
|
273
277
|
{
|
|
@@ -290,17 +294,18 @@ server.registerTool("remove-relationship", {
|
|
|
290
294
|
type: z.string().describe("Relationship type"),
|
|
291
295
|
}),
|
|
292
296
|
}, ({ path, from, to, type }) => {
|
|
293
|
-
const
|
|
297
|
+
const loaded = loadDocument(path);
|
|
294
298
|
const relType = RelationshipType.safeParse(type);
|
|
295
299
|
if (!relType.success) {
|
|
296
300
|
throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
|
|
297
301
|
}
|
|
298
302
|
const result = removeRelationshipOp({
|
|
299
|
-
doc,
|
|
303
|
+
doc: loaded.doc,
|
|
300
304
|
from,
|
|
301
305
|
to,
|
|
302
306
|
type: relType.data,
|
|
303
307
|
});
|
|
308
|
+
saveDocument(result.doc, loaded.format, loaded.path);
|
|
304
309
|
return {
|
|
305
310
|
content: [
|
|
306
311
|
{
|
package/dist/src/md-to-json.js
CHANGED
|
@@ -2,6 +2,10 @@ import * as z from "zod";
|
|
|
2
2
|
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import { join, basename } from "node:path";
|
|
4
4
|
import { NODE_FILE_MAP, NODE_LABEL_TO_TYPE, RELATIONSHIP_TYPE_LABELS, RELATIONSHIP_LABEL_TO_TYPE, NodeType, RelationshipType, NodeStatus, ExternalReferenceRole, } from "./schema.js";
|
|
5
|
+
/** Strip markdown link syntax `[text](url)` → `text`. */
|
|
6
|
+
function stripMarkdownLink(s) {
|
|
7
|
+
return s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
8
|
+
}
|
|
5
9
|
const LABEL_TO_TYPE = Object.fromEntries(Object.entries(NODE_LABEL_TO_TYPE).map(([k, v]) => [k.toLowerCase(), v]));
|
|
6
10
|
const operationType = z.enum(["add", "update", "remove", "link"]);
|
|
7
11
|
function parseNodeType(s) {
|
|
@@ -162,18 +166,18 @@ function parseListItems(body, prefix) {
|
|
|
162
166
|
collecting = true;
|
|
163
167
|
const inline = line.slice(prefix.length + 1).trim();
|
|
164
168
|
if (inline) {
|
|
165
|
-
items.push(inline);
|
|
169
|
+
items.push(stripMarkdownLink(inline));
|
|
166
170
|
collecting = false;
|
|
167
171
|
}
|
|
168
172
|
continue;
|
|
169
173
|
}
|
|
170
174
|
if (collecting && line.startsWith(" - ")) {
|
|
171
|
-
items.push(line.slice(4));
|
|
175
|
+
items.push(stripMarkdownLink(line.slice(4)));
|
|
172
176
|
}
|
|
173
177
|
else if (collecting &&
|
|
174
178
|
line.startsWith("- ") &&
|
|
175
179
|
!isRelationshipLabel(line)) {
|
|
176
|
-
items.push(line.slice(2));
|
|
180
|
+
items.push(stripMarkdownLink(line.slice(2)));
|
|
177
181
|
}
|
|
178
182
|
else if (collecting) {
|
|
179
183
|
collecting = false;
|
|
@@ -210,7 +214,7 @@ function parseRelationshipsFromBody(body, nodeId) {
|
|
|
210
214
|
if (items.length === 0) {
|
|
211
215
|
const val = parseSingleValue(body, `- ${label}`);
|
|
212
216
|
if (val) {
|
|
213
|
-
rels.push({ from: nodeId, to: val, type: relType });
|
|
217
|
+
rels.push({ from: nodeId, to: stripMarkdownLink(val), type: relType });
|
|
214
218
|
}
|
|
215
219
|
}
|
|
216
220
|
else {
|