@rse/specbook 0.9.2
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 +199 -0
- package/dst/specbook-api.d.ts +47 -0
- package/dst/specbook-api.js +77 -0
- package/dst/specbook-cli.d.ts +2 -0
- package/dst/specbook-cli.js +156 -0
- package/dst/specbook-cmd-describe.d.ts +2 -0
- package/dst/specbook-cmd-describe.js +97 -0
- package/dst/specbook-cmd-edit.d.ts +9 -0
- package/dst/specbook-cmd-edit.js +69 -0
- package/dst/specbook-cmd-export.d.ts +9 -0
- package/dst/specbook-cmd-export.js +78 -0
- package/dst/specbook-cmd-import.d.ts +9 -0
- package/dst/specbook-cmd-import.js +73 -0
- package/dst/specbook-cmd-init.d.ts +7 -0
- package/dst/specbook-cmd-init.js +37 -0
- package/dst/specbook-cmd-lint.d.ts +15 -0
- package/dst/specbook-cmd-lint.js +46 -0
- package/dst/specbook-config.d.ts +6 -0
- package/dst/specbook-config.js +91 -0
- package/dst/specbook-diagnostic.d.ts +8 -0
- package/dst/specbook-diagnostic.js +23 -0
- package/dst/specbook-diagram.d.ts +9 -0
- package/dst/specbook-diagram.js +271 -0
- package/dst/specbook-export-ast.d.ts +4 -0
- package/dst/specbook-export-ast.js +36 -0
- package/dst/specbook-export-common.d.ts +33 -0
- package/dst/specbook-export-common.js +142 -0
- package/dst/specbook-export-html-search.js +272 -0
- package/dst/specbook-export-html.css +259 -0
- package/dst/specbook-export-html.d.ts +9 -0
- package/dst/specbook-export-html.js +452 -0
- package/dst/specbook-export-logo.svg +35 -0
- package/dst/specbook-export-md.d.ts +3 -0
- package/dst/specbook-export-md.js +74 -0
- package/dst/specbook-export-pdf.d.ts +7 -0
- package/dst/specbook-export-pdf.js +198 -0
- package/dst/specbook-link.d.ts +17 -0
- package/dst/specbook-link.js +113 -0
- package/dst/specbook-llm.d.ts +13 -0
- package/dst/specbook-llm.js +61 -0
- package/dst/specbook-mcp.d.ts +1 -0
- package/dst/specbook-mcp.js +163 -0
- package/dst/specbook-parse-common.d.ts +26 -0
- package/dst/specbook-parse-common.js +33 -0
- package/dst/specbook-parse-semantic.d.ts +5 -0
- package/dst/specbook-parse-semantic.js +254 -0
- package/dst/specbook-parse-syntax.d.ts +3 -0
- package/dst/specbook-parse-syntax.js +363 -0
- package/dst/specbook-parse-value.d.ts +19 -0
- package/dst/specbook-parse-value.js +123 -0
- package/dst/specbook-parse.d.ts +8 -0
- package/dst/specbook-parse.js +43 -0
- package/dst/specbook-struct-schema.d.ts +35 -0
- package/dst/specbook-struct-schema.js +47 -0
- package/dst/specbook-struct-spec.d.ts +31 -0
- package/dst/specbook-struct-spec.js +36 -0
- package/dst/specbook-theme.d.ts +16 -0
- package/dst/specbook-theme.js +38 -0
- package/package.json +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
|
|
2
|
+
<img src="https://raw.githubusercontent.com/rse/specbook/master/etc/specbook-logo.svg" width="400" align="right" alt=""/>
|
|
3
|
+
|
|
4
|
+
SpecBook
|
|
5
|
+
========
|
|
6
|
+
|
|
7
|
+
**Markdown-based Specification Format**
|
|
8
|
+
|
|
9
|
+
About
|
|
10
|
+
-----
|
|
11
|
+
|
|
12
|
+
**SpecBook** defines a generic, Markdown-based *specification
|
|
13
|
+
format* which can be configured for the specifications of particular
|
|
14
|
+
contexts through a YAML-based *schema configuration*. SpecBook allows
|
|
15
|
+
specifications to be *initialized*, *linted*, *exported* (JSON, JSON5,
|
|
16
|
+
YAML, TOON, HTML, PDF, and normalized Markdown), *described* to LLMs,
|
|
17
|
+
and LLM-assisted *imported* and *edited* -- through an API class with
|
|
18
|
+
methods `<cmd>()`, a CLI with commands `specbook <cmd>`, and an MCP
|
|
19
|
+
service with tools `specbook_<cmd>()`.
|
|
20
|
+
|
|
21
|
+
**SpecBook** provides the following distinct features:
|
|
22
|
+
|
|
23
|
+
- hierarchical object structure
|
|
24
|
+
- strict Markdown-based input format
|
|
25
|
+
- complex and concise object to Markdown mappings
|
|
26
|
+
- Wiki-style hierarchy-aware object hyperlinks
|
|
27
|
+
- canonical Markdown output format
|
|
28
|
+
- AST-based JSON, JSON5, YAML, and TOON output formats
|
|
29
|
+
- HTML output format for online reading
|
|
30
|
+
- PDF output format for printing and archiving
|
|
31
|
+
- CLI, MCP, and API interfaces
|
|
32
|
+
|
|
33
|
+
Specification Format
|
|
34
|
+
--------------------
|
|
35
|
+
|
|
36
|
+
A specification consists of Markdown *artifact* files, each carrying a
|
|
37
|
+
frontmatter with `Created:` and `Modified:` timestamps and a tree of
|
|
38
|
+
*objects*. Every object has a mandatory *kind*, a mandatory *name*, an
|
|
39
|
+
optional unique anchor *id*, optional *properties* (key/value pairs),
|
|
40
|
+
an optional *description* statement with an optional rationale, and
|
|
41
|
+
optional *child* objects.
|
|
42
|
+
|
|
43
|
+
Three concrete syntaxes exist:
|
|
44
|
+
|
|
45
|
+
### Complex Format
|
|
46
|
+
|
|
47
|
+
Usually used on object hierarchy levels 1-3 (`#`, `##`, `###`).
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
# <kind/>: <name/> (<id/>)
|
|
51
|
+
|
|
52
|
+
- <key/>: <value/>
|
|
53
|
+
- [...]
|
|
54
|
+
|
|
55
|
+
<statement/>, BECAUSE <rationale/>.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Concise Format
|
|
59
|
+
|
|
60
|
+
Usually used on level 4 and deeper, with child objects nested as
|
|
61
|
+
indented list items below their parent item:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
- <kind/>: <name/>; <key/>: <value/>; [...];
|
|
65
|
+
<statement/>, BECAUSE <rationale/>.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Grouped Format
|
|
69
|
+
|
|
70
|
+
A heading carrying just a *kind* and no `<kind/>: <name/>` pair opens a
|
|
71
|
+
*grouping container* instead of an object. The list items below it are
|
|
72
|
+
Concise Format items whose kind comes from that heading, so they start
|
|
73
|
+
with the (optionally backquoted) name directly:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
### <kinds/>
|
|
77
|
+
|
|
78
|
+
- `<name/>`; <key/>: <value/>; [...];
|
|
79
|
+
<statement/>, BECAUSE <rationale/>.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The heading kind is singularized by stripping one trailing `S`, so both
|
|
83
|
+
`### STATES` and `### STATE` group objects of kind `STATE`. The grouped
|
|
84
|
+
objects become childs of the object the heading is nested under.
|
|
85
|
+
|
|
86
|
+
When **SpecBook** itself generates specification Markdown, it emits
|
|
87
|
+
the Complex Format on levels 1-3 and the Concise Format from level 4
|
|
88
|
+
upwards. When **SpecBook** edits existing files, it mirrors the format
|
|
89
|
+
each existing object already uses.
|
|
90
|
+
|
|
91
|
+
Schema Configuration
|
|
92
|
+
--------------------
|
|
93
|
+
|
|
94
|
+
The YAML schema configuration (see
|
|
95
|
+
[`ase.specbook.yaml`](smp/ase.specbook.yaml) for a comprehensive
|
|
96
|
+
example) defines which domain-specific objects are allowed: the artifact
|
|
97
|
+
kinds with their `file` mapping on the first level, the object kinds of
|
|
98
|
+
the deeper levels, and their properties with optional value constraints:
|
|
99
|
+
regular expressions `/xxx/`, object references `[[xxx]]`, enumerations
|
|
100
|
+
`enum(xxx,yyy)`, tag sets `tags(xxx,yyy)`, and lists `list(xxx[, ...])`
|
|
101
|
+
of such alternatives. The `file` field is only allowed on the first
|
|
102
|
+
(artifact) level.
|
|
103
|
+
|
|
104
|
+
An object kind can additionally carry a `diagram` field, which derives a
|
|
105
|
+
[Gradia](https://github.com/rse/gradia) diagram for every object of that
|
|
106
|
+
kind. Its `type` selects the diagram shape (`graph`, `hub`, or `grid`),
|
|
107
|
+
`nodes` and `edges` select the participating objects through
|
|
108
|
+
comma-separated `[[xxx]]` reference patterns (`nodes` defaults to the
|
|
109
|
+
object itself plus all objects below it), `center` names the object a
|
|
110
|
+
`hub` is projected onto (default: `self`), `onlyConnected` drops the
|
|
111
|
+
edge-less nodes of a `graph`, `collapse` (default: `true`) omits a
|
|
112
|
+
degenerated diagram consisting of a single node only, `qualified` labels
|
|
113
|
+
every node with its object kind, and `config` passes arbitrary Gradia
|
|
114
|
+
rendering options (e.g. `grid-columns-max: 5`) through to the diagram.
|
|
115
|
+
|
|
116
|
+
The edges are derived from the `[[xxx]]` references of the node objects
|
|
117
|
+
-- from their property values only, or, with `links: all`, from their
|
|
118
|
+
descriptions as well. As the *nesting* of the objects carries no such
|
|
119
|
+
reference, `hierarchy: true` additionally derives a containment edge
|
|
120
|
+
from every node object to each of its child objects which is part of the
|
|
121
|
+
node set. This is what turns a parent object into the hub of its own
|
|
122
|
+
childs, as the `TIER` objects of the sample schema configuration
|
|
123
|
+
demonstrate:
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
- kind: TIER
|
|
127
|
+
diagram:
|
|
128
|
+
type: hub
|
|
129
|
+
center: self
|
|
130
|
+
hierarchy: true
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A `grid` is edge-less by definition and hence accepts neither `edges`
|
|
134
|
+
nor `hierarchy`.
|
|
135
|
+
|
|
136
|
+
Usage
|
|
137
|
+
-----
|
|
138
|
+
|
|
139
|
+
### CLI
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
$ specbook init [-v] [-c <yaml-file>] [-b <basedir>]
|
|
143
|
+
$ specbook lint [-v] [-c <yaml-file>] [-b <basedir>]
|
|
144
|
+
$ specbook export [-v] [-c <yaml-file>] [-b <basedir>] [-o [<format>:]<output-file>] [...]
|
|
145
|
+
[--max-table-columns <count>]
|
|
146
|
+
$ specbook describe [-v] [-c <yaml-file>] [-o <markdown-file>]
|
|
147
|
+
$ specbook import [-v] [-c <yaml-file>] [-b <basedir>] <input-files...>
|
|
148
|
+
$ specbook edit [-v] [-c <yaml-file>] [-b <basedir>] <query>
|
|
149
|
+
$ specbook mcp [-v]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The base directory `-b`/`--basedir` (default: `.`) is scanned
|
|
153
|
+
recursively for the specification Markdown files, and generated
|
|
154
|
+
specification Markdown files are placed inside it, too.
|
|
155
|
+
|
|
156
|
+
The export output option `-o`/`--output` (default: `-` for stdout) can
|
|
157
|
+
occur multiple times. The format (`json`, `json5`, `yaml`, `toon`,
|
|
158
|
+
`html`, `pdf`, or `md`) is inferred from the filename extension, unless
|
|
159
|
+
it is explicitly given as a `<format>:` prefix, and plain `-` (stdout)
|
|
160
|
+
defaults to JSON.
|
|
161
|
+
|
|
162
|
+
The default value of every CLI option `--xxx` can be overridden by a
|
|
163
|
+
corresponding `SPECBOOK_XXX` environment variable (e.g.
|
|
164
|
+
`SPECBOOK_BASEDIR`, `SPECBOOK_CONFIG`, `SPECBOOK_OUTPUT`,
|
|
165
|
+
`SPECBOOK_VERBOSE`), while an explicitly supplied option always wins.
|
|
166
|
+
|
|
167
|
+
### API
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { SpecBook } from "@rse/specbook"
|
|
171
|
+
|
|
172
|
+
const specbook = new SpecBook({ verbose: (msg) => console.error(msg) })
|
|
173
|
+
const result = await specbook.lint({ config: "smp/ase.specbook.yaml", basedir: "smp/broadcast" })
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### MCP
|
|
177
|
+
|
|
178
|
+
Run `specbook mcp` to serve the tools `specbook_init`,
|
|
179
|
+
`specbook_lint`, `specbook_export`, `specbook_describe`,
|
|
180
|
+
`specbook_import`, and `specbook_edit` over stdio.
|
|
181
|
+
|
|
182
|
+
### AI Configuration
|
|
183
|
+
|
|
184
|
+
The commands `import` and `edit` use an LLM. The environment variables
|
|
185
|
+
`SPECBOOK_AI_PROVIDER` (`anthropic`, `openai`, `openrouter`, or
|
|
186
|
+
`ollama`) and
|
|
187
|
+
`SPECBOOK_AI_MODEL` provide the defaults, which the CLI options
|
|
188
|
+
`--provider`/`--model` (respectively the MCP/API parameters `provider`/
|
|
189
|
+
`model`) override. The provider API keys come from the standard
|
|
190
|
+
environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
|
|
191
|
+
`OPENROUTER_API_KEY`), while `ollama` needs no key and uses the local
|
|
192
|
+
Ollama service.
|
|
193
|
+
|
|
194
|
+
License
|
|
195
|
+
-------
|
|
196
|
+
|
|
197
|
+
Copyright © 2026 Dr. Ralf S. Engelschall (http://engelschall.com/)<br/>
|
|
198
|
+
Licensed under Apache 2.0 (https://spdx.org/licenses/Apache-2.0)
|
|
199
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic } from "./specbook-diagnostic.js";
|
|
2
|
+
import { type LintResult } from "./specbook-cmd-lint.js";
|
|
3
|
+
import { parseOutputSpec, formats, type ExportFormat } from "./specbook-cmd-export.js";
|
|
4
|
+
export { formats, parseOutputSpec, type ExportFormat };
|
|
5
|
+
export { renderDiagnostic, renderDiagnosticVerbose, type Diagnostic };
|
|
6
|
+
export { type LintResult };
|
|
7
|
+
export type { Specification, Artifact, Object, Description, Property } from "./specbook-struct-spec.js";
|
|
8
|
+
export type { SchemaSpecification, SchemaObject, SchemaProperty } from "./specbook-struct-schema.js";
|
|
9
|
+
export interface SpecBookOptions {
|
|
10
|
+
verbose?: (msg: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare class SpecBook {
|
|
13
|
+
private verbose;
|
|
14
|
+
constructor(options?: SpecBookOptions);
|
|
15
|
+
private requireConfig;
|
|
16
|
+
init(options: {
|
|
17
|
+
config?: string;
|
|
18
|
+
basedir?: string;
|
|
19
|
+
}): Promise<string[]>;
|
|
20
|
+
lint(options: {
|
|
21
|
+
config?: string;
|
|
22
|
+
basedir?: string;
|
|
23
|
+
}): Promise<LintResult>;
|
|
24
|
+
export(options: {
|
|
25
|
+
config?: string;
|
|
26
|
+
basedir?: string;
|
|
27
|
+
formats?: ExportFormat[];
|
|
28
|
+
maxTableColumns?: number;
|
|
29
|
+
}): Promise<Buffer[]>;
|
|
30
|
+
describe(options: {
|
|
31
|
+
config?: string;
|
|
32
|
+
}): Promise<string>;
|
|
33
|
+
import(options: {
|
|
34
|
+
config?: string;
|
|
35
|
+
basedir?: string;
|
|
36
|
+
inputs: string[];
|
|
37
|
+
provider?: string;
|
|
38
|
+
model?: string;
|
|
39
|
+
}): Promise<string[]>;
|
|
40
|
+
edit(options: {
|
|
41
|
+
config?: string;
|
|
42
|
+
basedir?: string;
|
|
43
|
+
query: string;
|
|
44
|
+
provider?: string;
|
|
45
|
+
model?: string;
|
|
46
|
+
}): Promise<string[]>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Specification Book (SpecBook)
|
|
3
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import { loadConfig } from "./specbook-config.js";
|
|
7
|
+
import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
|
|
8
|
+
import { initSpecification } from "./specbook-cmd-init.js";
|
|
9
|
+
import { lint } from "./specbook-cmd-lint.js";
|
|
10
|
+
import { exportSpecification, parseOutputSpec, formats } from "./specbook-cmd-export.js";
|
|
11
|
+
import { describeConfiguration } from "./specbook-cmd-describe.js";
|
|
12
|
+
import { importSpecification } from "./specbook-cmd-import.js";
|
|
13
|
+
import { editSpecification } from "./specbook-cmd-edit.js";
|
|
14
|
+
/* re-export the central types for API consumers */
|
|
15
|
+
export { formats, parseOutputSpec };
|
|
16
|
+
export { renderDiagnostic, renderDiagnosticVerbose };
|
|
17
|
+
/* the SpecBook API */
|
|
18
|
+
export class SpecBook {
|
|
19
|
+
verbose;
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.verbose = options.verbose ?? (() => { });
|
|
22
|
+
}
|
|
23
|
+
/* load a mandatory YAML schema configuration, failing on any problem */
|
|
24
|
+
requireConfig(file) {
|
|
25
|
+
if (file === undefined)
|
|
26
|
+
throw new Error("YAML schema configuration required");
|
|
27
|
+
this.verbose(`loading configuration "${file}"`);
|
|
28
|
+
const { config, diagnostics } = loadConfig(file);
|
|
29
|
+
if (config === null)
|
|
30
|
+
throw new Error("invalid configuration:\n" +
|
|
31
|
+
diagnostics.map(renderDiagnostic).join("\n"));
|
|
32
|
+
return config;
|
|
33
|
+
}
|
|
34
|
+
/* initialize the configured specification artifact files
|
|
35
|
+
below the base directory */
|
|
36
|
+
async init(options) {
|
|
37
|
+
return initSpecification({ ...options,
|
|
38
|
+
config: this.requireConfig(options.config), verbose: this.verbose });
|
|
39
|
+
}
|
|
40
|
+
/* lint the specification Markdown files below the base directory */
|
|
41
|
+
async lint(options) {
|
|
42
|
+
return lint({ config: options.config, basedir: options.basedir ?? ".", verbose: this.verbose });
|
|
43
|
+
}
|
|
44
|
+
/* export the specification Markdown files below the base directory
|
|
45
|
+
as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown,
|
|
46
|
+
parsing the input just once and returning one buffer per
|
|
47
|
+
requested format (best-effort: diagnostics do not prevent the
|
|
48
|
+
export, as validation is the concern of lint) */
|
|
49
|
+
async export(options) {
|
|
50
|
+
const result = lint({ config: options.config, basedir: options.basedir ?? ".", verbose: this.verbose });
|
|
51
|
+
for (const diagnostic of result.diagnostics)
|
|
52
|
+
this.verbose(`diagnostic: ${renderDiagnostic(diagnostic)}`);
|
|
53
|
+
if (result.specification.artifacts.length === 0)
|
|
54
|
+
throw new Error("unexportable specification:\n" +
|
|
55
|
+
result.diagnostics.map(renderDiagnostic).join("\n"));
|
|
56
|
+
const buffers = [];
|
|
57
|
+
for (const format of options.formats ?? ["json"])
|
|
58
|
+
buffers.push(await exportSpecification(result.specification, format, this.verbose, options.maxTableColumns, result.config));
|
|
59
|
+
return buffers;
|
|
60
|
+
}
|
|
61
|
+
/* describe the configured specification format as Markdown */
|
|
62
|
+
async describe(options) {
|
|
63
|
+
return describeConfiguration(this.requireConfig(options.config));
|
|
64
|
+
}
|
|
65
|
+
/* import foreign sources into the specification artifact files
|
|
66
|
+
below the base directory */
|
|
67
|
+
async import(options) {
|
|
68
|
+
return importSpecification({ ...options,
|
|
69
|
+
config: this.requireConfig(options.config), verbose: this.verbose });
|
|
70
|
+
}
|
|
71
|
+
/* apply an edit request to the specification artifact files
|
|
72
|
+
below the base directory */
|
|
73
|
+
async edit(options) {
|
|
74
|
+
return editSpecification({ ...options,
|
|
75
|
+
config: this.requireConfig(options.config), verbose: this.verbose });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
** Specification Book (SpecBook)
|
|
4
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
5
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { SpecBook, renderDiagnostic, renderDiagnosticVerbose, parseOutputSpec } from "./specbook-api.js";
|
|
10
|
+
import { serveMcp } from "./specbook-mcp.js";
|
|
11
|
+
/* route verbose messages to stderr, keeping stdout reserved
|
|
12
|
+
for the command outputs and the MCP protocol */
|
|
13
|
+
const verboseOf = (opts) => (msg) => {
|
|
14
|
+
if (opts.verbose)
|
|
15
|
+
process.stderr.write(`specbook: ${msg}\n`);
|
|
16
|
+
};
|
|
17
|
+
/* write a buffer to stdout, awaiting the write callback which only
|
|
18
|
+
fires once the data has been flushed to the underlying pipe, so a
|
|
19
|
+
subsequent "process.exit" cannot truncate the output */
|
|
20
|
+
const writeStdout = (data) => {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
process.stdout.write(data, (err) => {
|
|
23
|
+
if (err)
|
|
24
|
+
reject(err);
|
|
25
|
+
else
|
|
26
|
+
resolve();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
/* write a command result to the output file or stdout */
|
|
31
|
+
const writeOutput = async (output, data, verbose) => {
|
|
32
|
+
if (output === "-")
|
|
33
|
+
await writeStdout(data);
|
|
34
|
+
else {
|
|
35
|
+
await fs.promises.writeFile(output, data);
|
|
36
|
+
verbose(`wrote "${output}" (${data.length} bytes)`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/* determine an option default value, overridable via a
|
|
40
|
+
corresponding SPECBOOK_<OPTION> environment variable */
|
|
41
|
+
const envDefault = (name, fallback) => process.env[`SPECBOOK_${name.toUpperCase().replace(/-/g, "_")}`] ?? fallback;
|
|
42
|
+
/* determine a flag option default value, overridable via a
|
|
43
|
+
corresponding SPECBOOK_<OPTION> environment variable */
|
|
44
|
+
const envDefaultFlag = (name, fallback) => {
|
|
45
|
+
const value = process.env[`SPECBOOK_${name.toUpperCase().replace(/-/g, "_")}`];
|
|
46
|
+
return value !== undefined ? !(/^(?:|0|false|no|off)$/i).test(value) : fallback;
|
|
47
|
+
};
|
|
48
|
+
/* provide the common options of all sub-commands */
|
|
49
|
+
const common = (command) => command
|
|
50
|
+
.option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false))
|
|
51
|
+
.option("-c, --config <yaml-file>", "YAML schema configuration file", envDefault("config"));
|
|
52
|
+
/* parse the command line */
|
|
53
|
+
const program = new Command();
|
|
54
|
+
program.name("specbook")
|
|
55
|
+
.description("Markdown-based Specification Format")
|
|
56
|
+
.version("0.9.0")
|
|
57
|
+
.action(() => {
|
|
58
|
+
program.help();
|
|
59
|
+
});
|
|
60
|
+
program.command("mcp")
|
|
61
|
+
.description("run as MCP stdio server")
|
|
62
|
+
.option("-v, --verbose", "print verbose processing information to stderr", envDefaultFlag("verbose", false))
|
|
63
|
+
.action(async (opts) => {
|
|
64
|
+
verboseOf(opts)("starting MCP server on stdio");
|
|
65
|
+
await serveMcp(verboseOf(opts));
|
|
66
|
+
});
|
|
67
|
+
common(program.command("init"))
|
|
68
|
+
.description("initialize the configured specification artifact files below the base directory")
|
|
69
|
+
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
70
|
+
.action(async (opts) => {
|
|
71
|
+
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
72
|
+
const created = await specbook.init({ config: opts.config, basedir: opts.basedir });
|
|
73
|
+
process.stdout.write(created.length > 0 ?
|
|
74
|
+
`initialized artifact file(s): ${created.join(", ")}\n` :
|
|
75
|
+
"no artifact files were created\n");
|
|
76
|
+
});
|
|
77
|
+
common(program.command("lint"))
|
|
78
|
+
.description("lint the specification Markdown files below the base directory")
|
|
79
|
+
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
80
|
+
.action(async (opts) => {
|
|
81
|
+
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
82
|
+
const result = await specbook.lint({ config: opts.config, basedir: opts.basedir });
|
|
83
|
+
for (const diagnostic of result.diagnostics)
|
|
84
|
+
process.stdout.write(opts.verbose ?
|
|
85
|
+
renderDiagnosticVerbose(diagnostic, process.stdout.isTTY === true) :
|
|
86
|
+
`${renderDiagnostic(diagnostic)}\n`);
|
|
87
|
+
if (result.diagnostics.length > 0)
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
else
|
|
90
|
+
verboseOf(opts)("specification valid");
|
|
91
|
+
});
|
|
92
|
+
common(program.command("export"))
|
|
93
|
+
.description("export the specification Markdown files below the base directory " +
|
|
94
|
+
"as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
|
|
95
|
+
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
96
|
+
.option("-o, --output [<format>:]<output-file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
|
|
97
|
+
"from the filename extension unless explicitly prefixed", (value, previous) => previous.concat(value), [])
|
|
98
|
+
.option("--max-table-columns <count>", "maximum name/property/description columns for the compact table rendering", (value) => parseInt(value, 10), parseInt(envDefault("max-table-columns", "4") ?? "4", 10))
|
|
99
|
+
.action(async (opts) => {
|
|
100
|
+
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
101
|
+
const outputs = (opts.output.length > 0 ? opts.output : [envDefault("output") ?? "-"])
|
|
102
|
+
.map((spec) => parseOutputSpec(spec));
|
|
103
|
+
/* parse the input once and export each distinct format once */
|
|
104
|
+
const distinct = Array.from(new Set(outputs.map(({ format }) => format)));
|
|
105
|
+
const buffers = await specbook.export({ config: opts.config, basedir: opts.basedir,
|
|
106
|
+
formats: distinct, maxTableColumns: opts.maxTableColumns });
|
|
107
|
+
for (const { format, output } of outputs)
|
|
108
|
+
await writeOutput(output, buffers[distinct.indexOf(format)], verboseOf(opts));
|
|
109
|
+
});
|
|
110
|
+
common(program.command("describe"))
|
|
111
|
+
.description("describe the configured specification format as Markdown")
|
|
112
|
+
.option("-o, --output <markdown-file>", "output file (\"-\" for stdout)", envDefault("output", "-"))
|
|
113
|
+
.action(async (opts) => {
|
|
114
|
+
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
115
|
+
const text = await specbook.describe({ config: opts.config });
|
|
116
|
+
await writeOutput(opts.output, text, verboseOf(opts));
|
|
117
|
+
});
|
|
118
|
+
common(program.command("import"))
|
|
119
|
+
.description("import foreign sources into the specification artifact files below the base directory")
|
|
120
|
+
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
121
|
+
.option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
|
|
122
|
+
.option("--model <model>", "AI model", envDefault("model"))
|
|
123
|
+
.argument("<input-files...>", "foreign source files to import")
|
|
124
|
+
.action(async (inputs, opts) => {
|
|
125
|
+
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
126
|
+
const written = await specbook.import({ config: opts.config, basedir: opts.basedir,
|
|
127
|
+
inputs, provider: opts.provider, model: opts.model });
|
|
128
|
+
process.stdout.write(written.length > 0 ?
|
|
129
|
+
`imported into artifact file(s): ${written.join(", ")}\n` :
|
|
130
|
+
"no artifact files were changed\n");
|
|
131
|
+
});
|
|
132
|
+
common(program.command("edit"))
|
|
133
|
+
.description("apply a free-text edit request to the specification artifact files below the base directory")
|
|
134
|
+
.option("-b, --basedir <directory>", "base directory of the specification Markdown files", envDefault("basedir", "."))
|
|
135
|
+
.option("--provider <provider>", "AI provider (\"anthropic\", \"openai\", \"openrouter\" or \"ollama\")", envDefault("provider"))
|
|
136
|
+
.option("--model <model>", "AI model", envDefault("model"))
|
|
137
|
+
.argument("<query>", "free-text edit request")
|
|
138
|
+
.action(async (query, opts) => {
|
|
139
|
+
const specbook = new SpecBook({ verbose: verboseOf(opts) });
|
|
140
|
+
const written = await specbook.edit({ config: opts.config, basedir: opts.basedir,
|
|
141
|
+
query, provider: opts.provider, model: opts.model });
|
|
142
|
+
process.stdout.write(written.length > 0 ?
|
|
143
|
+
`edited artifact file(s): ${written.join(", ")}\n` :
|
|
144
|
+
"no artifact files were changed\n");
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
await program.parseAsync();
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
/* include the cause chain, as e.g. fetch failures
|
|
151
|
+
carry the underlying error only in the cause */
|
|
152
|
+
let msg = err instanceof Error ? err.message : String(err);
|
|
153
|
+
for (let cause = err instanceof Error ? err.cause : undefined; cause instanceof Error; cause = cause.cause)
|
|
154
|
+
msg += `: ${cause.message}`;
|
|
155
|
+
process.stderr.write(`specbook: ERROR: ${msg}\n`, () => process.exit(1));
|
|
156
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Specification Book (SpecBook)
|
|
3
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import textframe from "textframe";
|
|
7
|
+
/* collapse folded YAML prose into a single line */
|
|
8
|
+
const collapse = (text) => (text ?? "").replace(/\s+/g, " ").trim();
|
|
9
|
+
/* the static description of the generic Markdown syntax */
|
|
10
|
+
const syntax = textframe `
|
|
11
|
+
# SpecBook Specification Format
|
|
12
|
+
|
|
13
|
+
A specification consists of Markdown *artifact* files, each carrying a
|
|
14
|
+
frontmatter with \`Created:\` and \`Modified:\` timestamps (format
|
|
15
|
+
\`yyyy-LL-dd HH:mm\`) and a tree of *objects*. Every object has a *kind*,
|
|
16
|
+
a *name*, a unique anchor *id*, optional *properties* (key/value pairs),
|
|
17
|
+
an optional *description* statement with an optional rationale, and
|
|
18
|
+
optional *child* objects. Two concrete syntaxes exist:
|
|
19
|
+
|
|
20
|
+
## Complex Format (used on levels 1-3)
|
|
21
|
+
|
|
22
|
+
\`\`\`
|
|
23
|
+
# <kind>: <name> (<id>)
|
|
24
|
+
|
|
25
|
+
- <key>: <value>
|
|
26
|
+
- [...]
|
|
27
|
+
|
|
28
|
+
<statement>, BECAUSE <rationale>.
|
|
29
|
+
\`\`\`
|
|
30
|
+
|
|
31
|
+
Level 1 (\`#\`) carries the artifact id in parentheses. Levels 2 (\`##\`)
|
|
32
|
+
and 3 (\`###\`) instead carry an HTML anchor \`<a id="<id>"></a>\` after
|
|
33
|
+
the name, where the id uses the artifact id as its prefix.
|
|
34
|
+
|
|
35
|
+
## Concise Format (used on level 4 and deeper)
|
|
36
|
+
|
|
37
|
+
\`\`\`
|
|
38
|
+
- <kind>: <name>; <key>: <value>; [...]; <statement>, BECAUSE <rationale>.
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
Child objects nest as indented list items below their parent item.
|
|
42
|
+
|
|
43
|
+
## Primary Marker
|
|
44
|
+
|
|
45
|
+
In both formats a literal \`(*)\` directly after the \`<name>\` marks the
|
|
46
|
+
object as *primary* (e.g. the primary attribute of an entity). It is
|
|
47
|
+
distinct from an id given as \`(<id>)\` and both may be combined.
|
|
48
|
+
|
|
49
|
+
## Configured Artifacts
|
|
50
|
+
|
|
51
|
+
The following artifacts, object kinds, and properties are configured.
|
|
52
|
+
Object kinds and property names are *case-sensitive* and must be
|
|
53
|
+
written exactly as configured. Properties marked \`required\` must be
|
|
54
|
+
present, and property values must match their given constraint (if
|
|
55
|
+
any, else any value is allowed): \`/xxx/\` requires a match of the
|
|
56
|
+
regular expression, \`[[xxx]]\` requires exactly one Wiki-style link
|
|
57
|
+
reference resolving into the (usually wildcard) reference pattern,
|
|
58
|
+
\`enum(xxx,yyy)\` requires one of the listed members, \`tags(xxx,yyy)\`
|
|
59
|
+
requires a comma-separated set of the listed members (each at most
|
|
60
|
+
once), and \`list(xxx[, ...])\` requires a comma-separated list of
|
|
61
|
+
items, each matching one of the alternative constraints.
|
|
62
|
+
`;
|
|
63
|
+
/* describe a single configured property */
|
|
64
|
+
const describeProperty = (prop, indent) => {
|
|
65
|
+
let line = `${indent}- property \`${prop.name}\` (${prop.optional === true ? "optional" : "required"}`;
|
|
66
|
+
if (prop.value !== undefined)
|
|
67
|
+
line += `, constraint \`${prop.value}\``;
|
|
68
|
+
line += `): ${collapse(prop.desc)}`;
|
|
69
|
+
return line;
|
|
70
|
+
};
|
|
71
|
+
/* describe a single configured object kind (and recursively its childs) */
|
|
72
|
+
const describeObject = (object, level, indent) => {
|
|
73
|
+
const lines = new Array();
|
|
74
|
+
lines.push(`${indent}- object kind \`${object.kind}\` on level ${level}` +
|
|
75
|
+
`${object.optional === true ? " (optional)" : ""}: ${collapse(object.desc)}`);
|
|
76
|
+
for (const prop of object.props ?? [])
|
|
77
|
+
lines.push(describeProperty(prop, `${indent} `));
|
|
78
|
+
for (const child of object.childs ?? [])
|
|
79
|
+
lines.push(describeObject(child, level + 1, `${indent} `));
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
};
|
|
82
|
+
/* describe the configured specification format as Markdown */
|
|
83
|
+
export const describeConfiguration = (config) => {
|
|
84
|
+
const sections = new Array();
|
|
85
|
+
for (const artifact of config) {
|
|
86
|
+
const lines = new Array();
|
|
87
|
+
lines.push(`### ${artifact.kind}: ${artifact.name} (${artifact.kind}-${artifact.id})`);
|
|
88
|
+
lines.push("");
|
|
89
|
+
if (artifact.file !== undefined)
|
|
90
|
+
lines.push(`- file: \`${artifact.file}\``);
|
|
91
|
+
lines.push(`- description: ${collapse(artifact.desc)}`);
|
|
92
|
+
for (const child of artifact.childs ?? [])
|
|
93
|
+
lines.push(describeObject(child, 2, ""));
|
|
94
|
+
sections.push(lines.join("\n"));
|
|
95
|
+
}
|
|
96
|
+
return `${syntax}\n${sections.join("\n\n")}\n`;
|
|
97
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type SchemaSpecification } from "./specbook-struct-schema.js";
|
|
2
|
+
import { type AiOptions } from "./specbook-llm.js";
|
|
3
|
+
export interface EditOptions extends AiOptions {
|
|
4
|
+
config: SchemaSpecification;
|
|
5
|
+
basedir?: string;
|
|
6
|
+
query: string;
|
|
7
|
+
verbose: (msg: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare const editSpecification: (options: EditOptions) => Promise<string[]>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Specification Book (SpecBook)
|
|
3
|
+
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import textframe from "textframe";
|
|
9
|
+
import { describeConfiguration } from "./specbook-cmd-describe.js";
|
|
10
|
+
import { complete, timestamp, renderFileBlock, parseFileBlocks } from "./specbook-llm.js";
|
|
11
|
+
/* the LLM instruction for the edit command */
|
|
12
|
+
const instruction = (description, now) => textframe `
|
|
13
|
+
You are the edit engine of SpecBook, a Markdown-based specification
|
|
14
|
+
format. Apply the given edit request to the Markdown specification
|
|
15
|
+
artifact files of the project. Strictly honor the following rules:
|
|
16
|
+
|
|
17
|
+
- Surgical changes: change only what the edit request actually
|
|
18
|
+
demands; do not rewrite unrelated parts of an artifact.
|
|
19
|
+
- No fabrication: never invent content the edit request does not
|
|
20
|
+
support; leave gaps open instead of guessing.
|
|
21
|
+
- Format conformance: strictly follow the specification format
|
|
22
|
+
described below.
|
|
23
|
+
- Markdown generation: MIRROR the format each existing object already
|
|
24
|
+
uses (keep Complex Format objects complex and Concise Format
|
|
25
|
+
objects concise); for entirely new objects emit the Complex Format
|
|
26
|
+
on levels 1-3 ("#"/"##"/"###") and the Concise Format on level 4
|
|
27
|
+
and deeper.
|
|
28
|
+
- Timestamps: refresh the "Modified:" frontmatter of every changed
|
|
29
|
+
file to "${now}"; keep "Created:" untouched.
|
|
30
|
+
- Files: update ONLY the configured artifact files listed in the
|
|
31
|
+
format description below -- never any other file.
|
|
32
|
+
- Output: return ONLY the actually changed files, each as a block of
|
|
33
|
+
the exact form below, and no other text at all:
|
|
34
|
+
|
|
35
|
+
<<<FILE: <name>>>>
|
|
36
|
+
<content>
|
|
37
|
+
<<<END-FILE>>>
|
|
38
|
+
|
|
39
|
+
${description}
|
|
40
|
+
`;
|
|
41
|
+
/* apply an edit request to the specification artifact files */
|
|
42
|
+
export const editSpecification = async (options) => {
|
|
43
|
+
const basedir = options.basedir ?? ".";
|
|
44
|
+
const files = options.config.map((artifact) => artifact.file).filter((file) => file !== undefined);
|
|
45
|
+
/* gather the existing artifact files */
|
|
46
|
+
const existing = files
|
|
47
|
+
.filter((file) => fs.existsSync(path.join(basedir, file)))
|
|
48
|
+
.map((file) => renderFileBlock(file, fs.readFileSync(path.join(basedir, file), "utf8")));
|
|
49
|
+
if (existing.length === 0)
|
|
50
|
+
throw new Error(`no configured artifact files found in "${basedir}"`);
|
|
51
|
+
/* let the LLM apply the edit request */
|
|
52
|
+
options.verbose(`editing artifact files in "${basedir}"`);
|
|
53
|
+
const system = instruction(describeConfiguration(options.config), timestamp());
|
|
54
|
+
const prompt = "EXISTING ARTIFACT FILES:\n\n" + existing.join("\n\n") +
|
|
55
|
+
"\n\nEDIT REQUEST:\n\n" + options.query;
|
|
56
|
+
const response = await complete(options, system, prompt);
|
|
57
|
+
/* write back all returned artifact files */
|
|
58
|
+
const written = new Array();
|
|
59
|
+
for (const block of parseFileBlocks(response)) {
|
|
60
|
+
if (!files.includes(block.name)) {
|
|
61
|
+
options.verbose(`ignoring unconfigured file "${block.name}"`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
fs.writeFileSync(path.join(basedir, block.name), block.content, "utf8");
|
|
65
|
+
options.verbose(`wrote artifact file "${block.name}"`);
|
|
66
|
+
written.push(block.name);
|
|
67
|
+
}
|
|
68
|
+
return written;
|
|
69
|
+
};
|