@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.
Files changed (59) hide show
  1. package/README.md +199 -0
  2. package/dst/specbook-api.d.ts +47 -0
  3. package/dst/specbook-api.js +77 -0
  4. package/dst/specbook-cli.d.ts +2 -0
  5. package/dst/specbook-cli.js +156 -0
  6. package/dst/specbook-cmd-describe.d.ts +2 -0
  7. package/dst/specbook-cmd-describe.js +97 -0
  8. package/dst/specbook-cmd-edit.d.ts +9 -0
  9. package/dst/specbook-cmd-edit.js +69 -0
  10. package/dst/specbook-cmd-export.d.ts +9 -0
  11. package/dst/specbook-cmd-export.js +78 -0
  12. package/dst/specbook-cmd-import.d.ts +9 -0
  13. package/dst/specbook-cmd-import.js +73 -0
  14. package/dst/specbook-cmd-init.d.ts +7 -0
  15. package/dst/specbook-cmd-init.js +37 -0
  16. package/dst/specbook-cmd-lint.d.ts +15 -0
  17. package/dst/specbook-cmd-lint.js +46 -0
  18. package/dst/specbook-config.d.ts +6 -0
  19. package/dst/specbook-config.js +91 -0
  20. package/dst/specbook-diagnostic.d.ts +8 -0
  21. package/dst/specbook-diagnostic.js +23 -0
  22. package/dst/specbook-diagram.d.ts +9 -0
  23. package/dst/specbook-diagram.js +271 -0
  24. package/dst/specbook-export-ast.d.ts +4 -0
  25. package/dst/specbook-export-ast.js +36 -0
  26. package/dst/specbook-export-common.d.ts +33 -0
  27. package/dst/specbook-export-common.js +142 -0
  28. package/dst/specbook-export-html-search.js +272 -0
  29. package/dst/specbook-export-html.css +259 -0
  30. package/dst/specbook-export-html.d.ts +9 -0
  31. package/dst/specbook-export-html.js +452 -0
  32. package/dst/specbook-export-logo.svg +35 -0
  33. package/dst/specbook-export-md.d.ts +3 -0
  34. package/dst/specbook-export-md.js +74 -0
  35. package/dst/specbook-export-pdf.d.ts +7 -0
  36. package/dst/specbook-export-pdf.js +198 -0
  37. package/dst/specbook-link.d.ts +17 -0
  38. package/dst/specbook-link.js +113 -0
  39. package/dst/specbook-llm.d.ts +13 -0
  40. package/dst/specbook-llm.js +61 -0
  41. package/dst/specbook-mcp.d.ts +1 -0
  42. package/dst/specbook-mcp.js +163 -0
  43. package/dst/specbook-parse-common.d.ts +26 -0
  44. package/dst/specbook-parse-common.js +33 -0
  45. package/dst/specbook-parse-semantic.d.ts +5 -0
  46. package/dst/specbook-parse-semantic.js +254 -0
  47. package/dst/specbook-parse-syntax.d.ts +3 -0
  48. package/dst/specbook-parse-syntax.js +363 -0
  49. package/dst/specbook-parse-value.d.ts +19 -0
  50. package/dst/specbook-parse-value.js +123 -0
  51. package/dst/specbook-parse.d.ts +8 -0
  52. package/dst/specbook-parse.js +43 -0
  53. package/dst/specbook-struct-schema.d.ts +35 -0
  54. package/dst/specbook-struct-schema.js +47 -0
  55. package/dst/specbook-struct-spec.d.ts +31 -0
  56. package/dst/specbook-struct-spec.js +36 -0
  57. package/dst/specbook-theme.d.ts +16 -0
  58. package/dst/specbook-theme.js +38 -0
  59. 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 &copy; 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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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,2 @@
1
+ import { type SchemaSpecification } from "./specbook-struct-schema.js";
2
+ export declare const describeConfiguration: (config: SchemaSpecification) => string;
@@ -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
+ };