stego-cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,8 +56,22 @@ stego check-stage --project fiction-example --stage revise
56
56
  stego export --project fiction-example --format md
57
57
  ```
58
58
 
59
+ `stego new` also supports `--i <prefix>` for numeric prefix override and `--filename <name>` for an explicit manuscript filename.
60
+
59
61
  Projects also include local npm scripts so you can work from inside a project directory.
60
62
 
63
+ ## Advanced integration command
64
+
65
+ `stego comments add` is a machine-facing command for editor/tool integrations.
66
+
67
+ ```bash
68
+ stego comments add manuscript/100-scene.md --message "Could this transition be clearer?"
69
+ stego comments add manuscript/100-scene.md --input payload.json --format json
70
+ stego comments add manuscript/100-scene.md --input - --format json <<'JSON'
71
+ {"message":"Could this transition be clearer?","range":{"start":{"line":10,"col":4},"end":{"line":10,"col":32}}}
72
+ JSON
73
+ ```
74
+
61
75
  ## VS Code workflow
62
76
 
63
77
  When actively working on one project, open that project directory directly in VS Code (for example `projects/fiction-example`).
@@ -0,0 +1,177 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CommentsCommandError } from "./errors.js";
4
+ const COMMENT_HEADING_REGEX = /^###\s+(CMT-(\d{4,}))\s*$/;
5
+ const START_SENTINEL = "<!-- stego-comments:start -->";
6
+ const END_SENTINEL = "<!-- stego-comments:end -->";
7
+ /** Adds one stego comment entry to a manuscript and writes the updated file. */
8
+ export function addCommentToManuscript(request) {
9
+ const absolutePath = path.resolve(request.cwd, request.manuscriptPath);
10
+ if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
11
+ throw new CommentsCommandError("INVALID_USAGE", 2, `Manuscript file not found: ${request.manuscriptPath}`);
12
+ }
13
+ const raw = fs.readFileSync(absolutePath, "utf8");
14
+ const lineEnding = raw.includes("\r\n") ? "\r\n" : "\n";
15
+ const lines = raw.split(/\r?\n/);
16
+ const sentinelState = resolveSentinelState(lines);
17
+ const hasYamlFrontmatter = raw.startsWith(`---${lineEnding}`) || raw.startsWith("---\n");
18
+ if (!sentinelState.hasSentinels && !hasYamlFrontmatter) {
19
+ throw new CommentsCommandError("NOT_STEGO_MANUSCRIPT", 3, "File is not recognized as a Stego manuscript (missing frontmatter and comments sentinels).");
20
+ }
21
+ const commentId = getNextCommentId(lines);
22
+ const createdAt = new Date().toISOString();
23
+ const meta = buildMetaPayload(request.range, request.sourceMeta);
24
+ const meta64 = Buffer.from(JSON.stringify(meta), "utf8").toString("base64url");
25
+ const entryLines = buildCommentEntryLines(commentId, createdAt, request.author, request.message, meta64);
26
+ const nextLines = applyCommentEntry(lines, sentinelState, entryLines);
27
+ const nextContent = `${nextLines.join(lineEnding)}${lineEnding}`;
28
+ try {
29
+ fs.writeFileSync(absolutePath, nextContent, "utf8");
30
+ }
31
+ catch (error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ throw new CommentsCommandError("WRITE_FAILURE", 6, `Failed to update manuscript: ${message}`);
34
+ }
35
+ const result = {
36
+ ok: true,
37
+ manuscript: absolutePath,
38
+ commentId,
39
+ status: "open",
40
+ anchor: request.range
41
+ ? {
42
+ type: "selection",
43
+ excerptStartLine: request.range.startLine,
44
+ excerptStartCol: request.range.startCol,
45
+ excerptEndLine: request.range.endLine,
46
+ excerptEndCol: request.range.endCol
47
+ }
48
+ : { type: "file" },
49
+ createdAt
50
+ };
51
+ return result;
52
+ }
53
+ function buildMetaPayload(range, sourceMeta) {
54
+ const payload = {
55
+ status: "open"
56
+ };
57
+ if (range) {
58
+ payload.anchor = "selection";
59
+ payload.excerpt = {
60
+ start_line: range.startLine,
61
+ start_col: range.startCol,
62
+ end_line: range.endLine,
63
+ end_col: range.endCol
64
+ };
65
+ }
66
+ if (sourceMeta && Object.keys(sourceMeta).length > 0) {
67
+ payload.signature = sourceMeta;
68
+ }
69
+ return payload;
70
+ }
71
+ function buildCommentEntryLines(commentId, timestamp, author, message, meta64) {
72
+ const safeAuthor = author.trim() || "Saurus";
73
+ const normalizedMessageLines = message
74
+ .replace(/\r\n/g, "\n")
75
+ .split("\n")
76
+ .map((line) => line.trimEnd());
77
+ return [
78
+ `### ${commentId}`,
79
+ `<!-- meta64: ${meta64} -->`,
80
+ `> _${timestamp} | ${safeAuthor}_`,
81
+ ">",
82
+ ...normalizedMessageLines.map((line) => `> ${line}`)
83
+ ];
84
+ }
85
+ function applyCommentEntry(lines, sentinelState, entryLines) {
86
+ if (!sentinelState.hasSentinels) {
87
+ const baseLines = trimTrailingBlankLines(lines);
88
+ return [
89
+ ...baseLines,
90
+ "",
91
+ START_SENTINEL,
92
+ "",
93
+ ...entryLines,
94
+ "",
95
+ END_SENTINEL
96
+ ];
97
+ }
98
+ const startIndex = sentinelState.startIndex;
99
+ const endIndex = sentinelState.endIndex;
100
+ const block = lines.slice(startIndex + 1, endIndex);
101
+ const trimmedBlock = trimOuterBlankLines(block);
102
+ const nextBlock = trimmedBlock.length > 0
103
+ ? [...trimmedBlock, "", ...entryLines]
104
+ : [...entryLines];
105
+ return [
106
+ ...lines.slice(0, startIndex + 1),
107
+ ...nextBlock,
108
+ ...lines.slice(endIndex)
109
+ ];
110
+ }
111
+ function resolveSentinelState(lines) {
112
+ const startIndexes = findTrimmedLineIndexes(lines, START_SENTINEL);
113
+ const endIndexes = findTrimmedLineIndexes(lines, END_SENTINEL);
114
+ const hasAnySentinel = startIndexes.length > 0 || endIndexes.length > 0;
115
+ if (!hasAnySentinel) {
116
+ return {
117
+ hasSentinels: false,
118
+ startIndex: -1,
119
+ endIndex: -1
120
+ };
121
+ }
122
+ if (startIndexes.length !== 1 || endIndexes.length !== 1) {
123
+ throw new CommentsCommandError("COMMENT_APPENDIX_INVALID", 5, `Expected exactly one '${START_SENTINEL}' and one '${END_SENTINEL}' marker.`);
124
+ }
125
+ const startIndex = startIndexes[0];
126
+ const endIndex = endIndexes[0];
127
+ if (endIndex <= startIndex) {
128
+ throw new CommentsCommandError("COMMENT_APPENDIX_INVALID", 5, `'${END_SENTINEL}' must appear after '${START_SENTINEL}'.`);
129
+ }
130
+ return {
131
+ hasSentinels: true,
132
+ startIndex,
133
+ endIndex
134
+ };
135
+ }
136
+ function findTrimmedLineIndexes(lines, target) {
137
+ const indexes = [];
138
+ for (let index = 0; index < lines.length; index += 1) {
139
+ if (lines[index].trim() === target) {
140
+ indexes.push(index);
141
+ }
142
+ }
143
+ return indexes;
144
+ }
145
+ function getNextCommentId(lines) {
146
+ let maxId = 0;
147
+ for (const line of lines) {
148
+ const match = line.trim().match(COMMENT_HEADING_REGEX);
149
+ if (!match) {
150
+ continue;
151
+ }
152
+ const numeric = Number.parseInt(match[2], 10);
153
+ if (Number.isFinite(numeric) && numeric > maxId) {
154
+ maxId = numeric;
155
+ }
156
+ }
157
+ const next = maxId + 1;
158
+ return `CMT-${String(next).padStart(4, "0")}`;
159
+ }
160
+ function trimTrailingBlankLines(lines) {
161
+ const copy = [...lines];
162
+ while (copy.length > 0 && copy[copy.length - 1].trim().length === 0) {
163
+ copy.pop();
164
+ }
165
+ return copy;
166
+ }
167
+ function trimOuterBlankLines(lines) {
168
+ let start = 0;
169
+ let end = lines.length;
170
+ while (start < end && lines[start].trim().length === 0) {
171
+ start += 1;
172
+ }
173
+ while (end > start && lines[end - 1].trim().length === 0) {
174
+ end -= 1;
175
+ }
176
+ return lines.slice(start, end);
177
+ }
@@ -0,0 +1,207 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { addCommentToManuscript } from "./add-comment.js";
4
+ import { CommentsCommandError } from "./errors.js";
5
+ /** Handles `stego comments ...` command group. */
6
+ export async function runCommentsCommand(options, cwd) {
7
+ const [subcommand, manuscriptArg] = options._;
8
+ if (subcommand !== "add") {
9
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Unknown comments subcommand. Use: stego comments add <manuscript> [--message <text> | --input <path|->].");
10
+ }
11
+ if (!manuscriptArg) {
12
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Manuscript path is required. Use: stego comments add <manuscript> ...");
13
+ }
14
+ const outputFormat = parseOutputFormat(readString(options, "format"));
15
+ const messageOption = readString(options, "message");
16
+ const inputOption = readString(options, "input");
17
+ if (messageOption && inputOption) {
18
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Use exactly one payload mode: --message <text> OR --input <path|->.", outputFormat);
19
+ }
20
+ if (!messageOption && !inputOption) {
21
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Missing payload. Provide --message <text> or --input <path|->.", outputFormat);
22
+ }
23
+ const payload = inputOption ? readInputPayload(inputOption, cwd, outputFormat) : undefined;
24
+ const payloadMessage = coerceNonEmptyString(payload?.message, "Input payload 'message' must be a non-empty string.", outputFormat);
25
+ const payloadAuthor = coerceOptionalString(payload?.author, "Input payload 'author' must be a string if provided.", outputFormat);
26
+ const payloadRange = payload?.range !== undefined
27
+ ? parsePayloadRange(payload.range, outputFormat)
28
+ : undefined;
29
+ const payloadMeta = payload?.meta !== undefined
30
+ ? coerceOptionalObject(payload.meta, "Input payload 'meta' must be an object if provided.", outputFormat)
31
+ : undefined;
32
+ const optionRange = parseRangeFromOptions(options, outputFormat);
33
+ const finalMessage = (messageOption ?? payloadMessage ?? "").trim();
34
+ if (!finalMessage) {
35
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Comment message cannot be empty.", outputFormat);
36
+ }
37
+ const finalAuthor = (readString(options, "author") ?? payloadAuthor ?? "Saurus").trim() || "Saurus";
38
+ const finalRange = optionRange ?? payloadRange;
39
+ let result;
40
+ try {
41
+ result = addCommentToManuscript({
42
+ manuscriptPath: manuscriptArg,
43
+ cwd,
44
+ message: finalMessage,
45
+ author: finalAuthor,
46
+ range: finalRange,
47
+ sourceMeta: payloadMeta
48
+ });
49
+ }
50
+ catch (error) {
51
+ if (error instanceof CommentsCommandError && outputFormat === "json" && error.outputFormat !== "json") {
52
+ throw new CommentsCommandError(error.code, error.exitCode, error.message, outputFormat);
53
+ }
54
+ throw error;
55
+ }
56
+ if (outputFormat === "json") {
57
+ console.log(JSON.stringify(result, null, 2));
58
+ return;
59
+ }
60
+ const relativePath = path.relative(cwd, result.manuscript) || result.manuscript;
61
+ if (result.anchor.type === "selection") {
62
+ console.log(`Added ${result.commentId} to ${relativePath} (selection anchor).`);
63
+ return;
64
+ }
65
+ console.log(`Added ${result.commentId} to ${relativePath} (file-level anchor).`);
66
+ }
67
+ function readString(options, key) {
68
+ const value = options[key];
69
+ return typeof value === "string" ? value : undefined;
70
+ }
71
+ function parseOutputFormat(raw) {
72
+ if (!raw || raw === "text") {
73
+ return "text";
74
+ }
75
+ if (raw === "json") {
76
+ return "json";
77
+ }
78
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Invalid --format value. Use 'text' or 'json'.");
79
+ }
80
+ function readInputPayload(inputPath, cwd, outputFormat) {
81
+ let rawJson = "";
82
+ try {
83
+ rawJson = inputPath === "-"
84
+ ? fs.readFileSync(0, "utf8")
85
+ : fs.readFileSync(path.resolve(cwd, inputPath), "utf8");
86
+ }
87
+ catch (error) {
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, `Unable to read input payload: ${message}`, outputFormat);
90
+ }
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(rawJson);
94
+ }
95
+ catch {
96
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload is not valid JSON.", outputFormat);
97
+ }
98
+ if (!isPlainObject(parsed)) {
99
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload must be a JSON object.", outputFormat);
100
+ }
101
+ return parsed;
102
+ }
103
+ function parseRangeFromOptions(options, outputFormat) {
104
+ const rawStartLine = readString(options, "start-line");
105
+ const rawStartCol = readString(options, "start-col");
106
+ const rawEndLine = readString(options, "end-line");
107
+ const rawEndCol = readString(options, "end-col");
108
+ const values = [rawStartLine, rawStartCol, rawEndLine, rawEndCol];
109
+ const hasAny = values.some((value) => value !== undefined);
110
+ const hasAll = values.every((value) => value !== undefined);
111
+ if (!hasAny) {
112
+ return undefined;
113
+ }
114
+ if (!hasAll) {
115
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Range options must include all of --start-line, --start-col, --end-line, --end-col.", outputFormat);
116
+ }
117
+ const range = {
118
+ startLine: parseNonNegativeInt(rawStartLine, "--start-line", outputFormat),
119
+ startCol: parseNonNegativeInt(rawStartCol, "--start-col", outputFormat),
120
+ endLine: parseNonNegativeInt(rawEndLine, "--end-line", outputFormat),
121
+ endCol: parseNonNegativeInt(rawEndCol, "--end-col", outputFormat)
122
+ };
123
+ validateRange(range, outputFormat);
124
+ return range;
125
+ }
126
+ function parsePayloadRange(raw, outputFormat) {
127
+ if (!isPlainObject(raw)) {
128
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload 'range' must be an object.", outputFormat);
129
+ }
130
+ const parsed = raw;
131
+ const start = parsed.start;
132
+ const end = parsed.end;
133
+ if (!isPlainObject(start) || !isPlainObject(end)) {
134
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload 'range' must include 'start' and 'end' objects.", outputFormat);
135
+ }
136
+ const normalized = {
137
+ start: {
138
+ line: parseJsonNonNegativeInt(start.line, "range.start.line", outputFormat),
139
+ col: parseJsonNonNegativeInt(start.col, "range.start.col", outputFormat)
140
+ },
141
+ end: {
142
+ line: parseJsonNonNegativeInt(end.line, "range.end.line", outputFormat),
143
+ col: parseJsonNonNegativeInt(end.col, "range.end.col", outputFormat)
144
+ }
145
+ };
146
+ const range = {
147
+ startLine: normalized.start.line,
148
+ startCol: normalized.start.col,
149
+ endLine: normalized.end.line,
150
+ endCol: normalized.end.col
151
+ };
152
+ validateRange(range, outputFormat);
153
+ return range;
154
+ }
155
+ function validateRange(range, outputFormat) {
156
+ const startsBeforeEnd = range.startLine < range.endLine
157
+ || (range.startLine === range.endLine && range.startCol < range.endCol);
158
+ if (!startsBeforeEnd) {
159
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Range end must be after range start.", outputFormat);
160
+ }
161
+ }
162
+ function parseNonNegativeInt(raw, label, outputFormat) {
163
+ if (!/^\d+$/.test(raw)) {
164
+ throw new CommentsCommandError("INVALID_USAGE", 2, `${label} must be a non-negative integer.`, outputFormat);
165
+ }
166
+ const value = Number.parseInt(raw, 10);
167
+ if (!Number.isFinite(value)) {
168
+ throw new CommentsCommandError("INVALID_USAGE", 2, `${label} must be a non-negative integer.`, outputFormat);
169
+ }
170
+ return value;
171
+ }
172
+ function parseJsonNonNegativeInt(raw, label, outputFormat) {
173
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
174
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, `${label} must be a non-negative integer.`, outputFormat);
175
+ }
176
+ return raw;
177
+ }
178
+ function coerceNonEmptyString(value, message, outputFormat) {
179
+ if (value === undefined) {
180
+ return undefined;
181
+ }
182
+ if (typeof value !== "string" || value.trim().length === 0) {
183
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, message, outputFormat);
184
+ }
185
+ return value;
186
+ }
187
+ function coerceOptionalString(value, message, outputFormat) {
188
+ if (value === undefined) {
189
+ return undefined;
190
+ }
191
+ if (typeof value !== "string") {
192
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, message, outputFormat);
193
+ }
194
+ return value;
195
+ }
196
+ function coerceOptionalObject(value, message, outputFormat) {
197
+ if (value === undefined) {
198
+ return undefined;
199
+ }
200
+ if (!isPlainObject(value)) {
201
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, message, outputFormat);
202
+ }
203
+ return value;
204
+ }
205
+ function isPlainObject(value) {
206
+ return typeof value === "object" && value !== null && !Array.isArray(value);
207
+ }
@@ -0,0 +1,20 @@
1
+ /** Error raised for machine-facing comments command failures. */
2
+ export class CommentsCommandError extends Error {
3
+ code;
4
+ exitCode;
5
+ outputFormat;
6
+ constructor(code, exitCode, message, outputFormat = "text") {
7
+ super(message);
8
+ this.name = "CommentsCommandError";
9
+ this.code = code;
10
+ this.exitCode = exitCode;
11
+ this.outputFormat = outputFormat;
12
+ }
13
+ toJson() {
14
+ return {
15
+ ok: false,
16
+ code: this.code,
17
+ message: this.message
18
+ };
19
+ }
20
+ }
package/dist/stego-cli.js CHANGED
@@ -8,6 +8,8 @@ import { createInterface } from "node:readline/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { markdownExporter } from "./exporters/markdown-exporter.js";
10
10
  import { createPandocExporter } from "./exporters/pandoc-exporter.js";
11
+ import { runCommentsCommand } from "./comments/comments-command.js";
12
+ import { CommentsCommandError } from "./comments/errors.js";
11
13
  const STATUS_RANK = {
12
14
  draft: 0,
13
15
  revise: 1,
@@ -128,7 +130,7 @@ async function main() {
128
130
  case "new": {
129
131
  activateWorkspace(options);
130
132
  const project = resolveProject(readStringOption(options, "project"));
131
- const createdPath = createNewManuscript(project, readStringOption(options, "i"));
133
+ const createdPath = createNewManuscript(project, readStringOption(options, "i"), readStringOption(options, "filename"));
132
134
  logLine(`Created manuscript: ${createdPath}`);
133
135
  return;
134
136
  }
@@ -196,11 +198,23 @@ async function main() {
196
198
  logLine(`Export output: ${outputPath}`);
197
199
  return;
198
200
  }
201
+ case "comments":
202
+ await runCommentsCommand(options, process.cwd());
203
+ return;
199
204
  default:
200
205
  throw new Error(`Unknown command '${command}'. Run with 'help' for usage.`);
201
206
  }
202
207
  }
203
208
  catch (error) {
209
+ if (error instanceof CommentsCommandError) {
210
+ if (error.outputFormat === "json") {
211
+ console.error(JSON.stringify(error.toJson(), null, 2));
212
+ }
213
+ else {
214
+ console.error(`ERROR: ${error.message}`);
215
+ }
216
+ process.exit(error.exitCode);
217
+ }
204
218
  if (error instanceof Error) {
205
219
  console.error(`ERROR: ${error.message}`);
206
220
  }
@@ -372,7 +386,7 @@ function resolveCompileStructure(project) {
372
386
  issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].titleKey must match /^[a-z][a-z0-9_-]*$/.`, projectFile));
373
387
  continue;
374
388
  }
375
- const pageBreakRaw = typeof entry.pageBreak === "string" ? entry.pageBreak.trim() : "none";
389
+ const pageBreakRaw = typeof entry.pageBreak === "string" ? entry.pageBreak.trim() : "between-groups";
376
390
  if (pageBreakRaw !== "none" && pageBreakRaw !== "between-groups") {
377
391
  issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].pageBreak must be 'none' or 'between-groups'.`, projectFile));
378
392
  continue;
@@ -747,7 +761,7 @@ function writeInitRootPackageJson(targetRoot) {
747
761
  fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
748
762
  }
749
763
  function printUsage() {
750
- console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--root <path>]\n new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]\n validate --project <project-id> [--file <project-relative-manuscript-path>] [--root <path>]\n build --project <project-id> [--root <path>]\n check-stage --project <project-id> --stage <draft|revise|line-edit|proof|final> [--file <project-relative-manuscript-path>] [--root <path>]\n lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n`);
764
+ console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--root <path>]\n new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]\n validate --project <project-id> [--file <project-relative-manuscript-path>] [--root <path>]\n build --project <project-id> [--root <path>]\n check-stage --project <project-id> --stage <draft|revise|line-edit|proof|final> [--file <project-relative-manuscript-path>] [--root <path>]\n lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n comments add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--format <text|json>]\n`);
751
765
  }
752
766
  function listProjects() {
753
767
  const ids = getProjectIds();
@@ -791,7 +805,7 @@ async function createProject(projectIdOption, titleOption) {
791
805
  titleKey: "chapter_title",
792
806
  injectHeading: true,
793
807
  headingTemplate: "{label} {value}: {title}",
794
- pageBreak: "none"
808
+ pageBreak: "between-groups"
795
809
  }
796
810
  ]
797
811
  },
@@ -849,7 +863,7 @@ Start writing here.
849
863
  logLine(`- ${path.relative(repoRoot, projectSettingsPath)}`);
850
864
  }
851
865
  }
852
- function createNewManuscript(project, requestedPrefixRaw) {
866
+ function createNewManuscript(project, requestedPrefixRaw, requestedFilenameRaw) {
853
867
  fs.mkdirSync(project.manuscriptDir, { recursive: true });
854
868
  const requiredMetadataState = resolveRequiredMetadata(project, config);
855
869
  const requiredMetadataErrors = requiredMetadataState.issues
@@ -860,13 +874,33 @@ function createNewManuscript(project, requestedPrefixRaw) {
860
874
  }
861
875
  const existingEntries = listManuscriptOrderEntries(project.manuscriptDir);
862
876
  const explicitPrefix = parseManuscriptPrefix(requestedPrefixRaw);
863
- const nextPrefix = explicitPrefix ?? inferNextManuscriptPrefix(existingEntries);
864
- const collision = existingEntries.find((entry) => entry.order === nextPrefix);
865
- if (collision) {
866
- throw new Error(`Manuscript prefix '${nextPrefix}' is already used by '${collision.filename}'. Re-run with --i <number> to choose an unused prefix.`);
877
+ const requestedFilename = parseRequestedManuscriptFilename(requestedFilenameRaw);
878
+ if (requestedFilename && explicitPrefix != null) {
879
+ throw new Error("Options --filename and --i/-i cannot be used together.");
880
+ }
881
+ let filename;
882
+ if (requestedFilename) {
883
+ const requestedOrder = parseOrderFromManuscriptFilename(requestedFilename);
884
+ if (requestedOrder != null) {
885
+ const collision = existingEntries.find((entry) => entry.order === requestedOrder);
886
+ if (collision) {
887
+ throw new Error(`Manuscript prefix '${requestedOrder}' is already used by '${collision.filename}'. Choose a different filename prefix.`);
888
+ }
889
+ }
890
+ filename = requestedFilename;
891
+ }
892
+ else {
893
+ const nextPrefix = explicitPrefix ?? inferNextManuscriptPrefix(existingEntries);
894
+ const collision = existingEntries.find((entry) => entry.order === nextPrefix);
895
+ if (collision) {
896
+ throw new Error(`Manuscript prefix '${nextPrefix}' is already used by '${collision.filename}'. Re-run with --i <number> to choose an unused prefix.`);
897
+ }
898
+ filename = `${nextPrefix}-${DEFAULT_NEW_MANUSCRIPT_SLUG}.md`;
867
899
  }
868
- const filename = `${nextPrefix}-${DEFAULT_NEW_MANUSCRIPT_SLUG}.md`;
869
900
  const manuscriptPath = path.join(project.manuscriptDir, filename);
901
+ if (fs.existsSync(manuscriptPath)) {
902
+ throw new Error(`Manuscript already exists: ${filename}`);
903
+ }
870
904
  const content = renderNewManuscriptTemplate(requiredMetadataState.requiredMetadata);
871
905
  fs.writeFileSync(manuscriptPath, content, "utf8");
872
906
  return path.relative(repoRoot, manuscriptPath);
@@ -913,6 +947,33 @@ function parseManuscriptPrefix(raw) {
913
947
  }
914
948
  return parsed;
915
949
  }
950
+ function parseRequestedManuscriptFilename(raw) {
951
+ if (raw == null) {
952
+ return undefined;
953
+ }
954
+ const normalized = raw.trim();
955
+ if (!normalized) {
956
+ throw new Error("Option --filename requires a value.");
957
+ }
958
+ if (/[\\/]/.test(normalized)) {
959
+ throw new Error(`Invalid filename '${raw}'. Use a filename only (no directory separators).`);
960
+ }
961
+ const withExtension = normalized.toLowerCase().endsWith(".md")
962
+ ? normalized
963
+ : `${normalized}.md`;
964
+ const stem = withExtension.slice(0, -3).trim();
965
+ if (!stem) {
966
+ throw new Error(`Invalid filename '${raw}'.`);
967
+ }
968
+ return withExtension;
969
+ }
970
+ function parseOrderFromManuscriptFilename(filename) {
971
+ const match = filename.match(/^(\d+)[-_]/);
972
+ if (!match) {
973
+ return undefined;
974
+ }
975
+ return Number(match[1]);
976
+ }
916
977
  function inferNextManuscriptPrefix(entries) {
917
978
  if (entries.length === 0) {
918
979
  return 100;
@@ -936,7 +997,7 @@ function renderNewManuscriptTemplate(requiredMetadata) {
936
997
  seenKeys.add(normalized);
937
998
  lines.push(`${normalized}:`);
938
999
  }
939
- lines.push("---", "", "# New Document", "");
1000
+ lines.push("---", "");
940
1001
  return `${lines.join("\n")}\n`;
941
1002
  }
942
1003
  function getProjectIds() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stego-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Installable CLI for the Stego writing monorepo workflow.",
6
6
  "license": "Apache-2.0",
@@ -50,7 +50,8 @@
50
50
  "check-stage": "node --experimental-strip-types tools/stego-cli.ts check-stage",
51
51
  "export": "node --experimental-strip-types tools/stego-cli.ts export",
52
52
  "test:compile-structure": "node --test tools/test/compile-structure.test.mjs",
53
- "test": "npm run test:compile-structure"
53
+ "test:comments": "node --test tools/test/comments-add.test.mjs",
54
+ "test": "npm run test:compile-structure && npm run test:comments"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@changesets/cli": "^2.29.8",
@@ -21,7 +21,7 @@ Matthaeus and Etienne recorded onset dates, households, trades, parish boundarie
21
21
 
22
22
  Her map was simple. Neighborhoods tied to the grain quay sickened first. Adjacent streets followed within days. The rest of the city came after. The pattern was consistent across three months of data.
23
23
 
24
- At the quay she showed them torn sackcloth from a riverside storehouse and asked what his conjunction tables would have predicted for this street, this month. Matthaeus answered in the terms his system provided: commerce might distribute the material substrate, but the disposition of bodies to receive corruption remained celestial. Agnes said that might be true and still leave the wrong people dead first.
24
+ At the quay she showed them torn sackcloth from a riverside storehouse and asked what his conjunction tables would have predicted for this street, this month. Matthaeus answered in the terms his system provided: commerce might distribute corrupted matter, but the disposition of bodies to receive corruption remained celestial. Agnes said that might be true and still leave the wrong people dead first.
25
25
 
26
26
  He wrote her observation in the second ledger. He expected to reconcile it later. The framework had room. It had been designed to have room.
27
27
 
@@ -17,7 +17,7 @@ sources:
17
17
 
18
18
  The faculty hearing began as a technical disputation and ended as an inquiry into method. Three masters objected to Matthaeus from different directions: one said he conceded too much to natural causes, another said he conceded too much to celestial judgment, a third said he imported suspect languages from Arabic traditions into Christian medicine. The objections were mutually exclusive, which should have cancelled them out but instead compounded them, because the real charge was not error but imprudence.
19
19
 
20
- Raoul asked to see all sources. John presented the conjunction tables and the Galenic commentaries openly. He withheld the quire until asked a second time. When he produced it, the room changed temperature.
20
+ Raoul asked to see all sources. Matthaeus presented the conjunction tables and the Galenic commentaries openly. He withheld the quire until asked a second time. When he produced it, the room changed temperature.
21
21
 
22
22
  No one accused him of sorcery. They accused him of carelessness: in a season of mass death, even method could scandalize if its lineage looked foreign or secretive or insufficiently authorized. The problem was not what the quire said. The problem was that Matthaeus had kept it at all — that he had judged for himself which sources were useful and which were not, without waiting to be told.
23
23
 
@@ -26,9 +26,9 @@ label: stego new-project --project <project-id> [--title <title>] [--root <path>
26
26
  - Related concepts: CON-PROJECT, CON-MANUSCRIPT, CON-NOTES, CON-DIST.
27
27
 
28
28
  ## CMD-NEW
29
- label: stego new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]
29
+ label: stego new --project <project-id> [--i <prefix>|-i <prefix>] [--filename <name>] [--root <path>]
30
30
 
31
- - `stego new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]`
31
+ - `stego new --project <project-id> [--i <prefix>|-i <prefix>] [--filename <name>] [--root <path>]`
32
32
  - Create a new manuscript file with an inferred numeric prefix and draft frontmatter.
33
33
  - Related workflows: FLOW-DAILY-WRITING.
34
34
  - Related concepts: CON-MANUSCRIPT, CON-METADATA.