stego-cli 0.3.3 → 0.4.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.
Files changed (78) hide show
  1. package/README.md +10 -0
  2. package/dist/comments/comment-domain.js +919 -0
  3. package/dist/comments/comments-command.js +356 -64
  4. package/dist/metadata/metadata-command.js +127 -0
  5. package/dist/metadata/metadata-domain.js +209 -0
  6. package/dist/spine/spine-command.js +129 -0
  7. package/dist/spine/spine-domain.js +274 -0
  8. package/dist/stego-cli.js +205 -426
  9. package/package.json +3 -2
  10. package/projects/fiction-example/spine/characters/CHAR-AGNES.md +17 -0
  11. package/projects/fiction-example/spine/characters/CHAR-ETIENNE.md +17 -0
  12. package/projects/fiction-example/spine/characters/CHAR-MATTHAEUS.md +17 -0
  13. package/projects/fiction-example/spine/characters/CHAR-RAOUL.md +17 -0
  14. package/projects/fiction-example/spine/characters/_category.md +6 -0
  15. package/projects/fiction-example/spine/locations/LOC-CHARNEL.md +17 -0
  16. package/projects/fiction-example/spine/locations/LOC-COLLEGE.md +17 -0
  17. package/projects/fiction-example/spine/locations/LOC-HOTELDIEU.md +17 -0
  18. package/projects/fiction-example/spine/locations/LOC-QUAY.md +17 -0
  19. package/projects/fiction-example/spine/locations/_category.md +6 -0
  20. package/projects/fiction-example/spine/sources/SRC-CONJUNCTION.md +20 -0
  21. package/projects/fiction-example/spine/sources/SRC-GALEN.md +20 -0
  22. package/projects/fiction-example/spine/sources/SRC-WARD-DATA.md +20 -0
  23. package/projects/fiction-example/spine/sources/_category.md +6 -0
  24. package/projects/fiction-example/stego-project.json +1 -18
  25. package/projects/stego-docs/manuscript/500-project-configuration.md +3 -3
  26. package/projects/stego-docs/spine/commands/CMD-BUILD.md +11 -0
  27. package/projects/stego-docs/spine/commands/CMD-CHECK-STAGE.md +11 -0
  28. package/projects/stego-docs/spine/commands/CMD-EXPORT.md +11 -0
  29. package/projects/stego-docs/spine/commands/CMD-INIT.md +11 -0
  30. package/projects/stego-docs/spine/commands/CMD-LIST-PROJECTS.md +10 -0
  31. package/projects/stego-docs/spine/commands/CMD-NEW-PROJECT.md +10 -0
  32. package/projects/stego-docs/spine/commands/CMD-NEW.md +11 -0
  33. package/projects/stego-docs/spine/commands/CMD-VALIDATE.md +11 -0
  34. package/projects/stego-docs/spine/commands/_category.md +6 -0
  35. package/projects/stego-docs/spine/concepts/CON-COMPILE-STRUCTURE.md +9 -0
  36. package/projects/stego-docs/spine/concepts/CON-DIST.md +9 -0
  37. package/projects/stego-docs/spine/concepts/CON-MANUSCRIPT.md +9 -0
  38. package/projects/stego-docs/spine/concepts/CON-METADATA.md +9 -0
  39. package/projects/stego-docs/spine/concepts/CON-NOTES.md +9 -0
  40. package/projects/stego-docs/spine/concepts/CON-PROJECT.md +9 -0
  41. package/projects/stego-docs/spine/concepts/CON-SPINE-CATEGORY.md +11 -0
  42. package/projects/stego-docs/spine/concepts/CON-SPINE.md +9 -0
  43. package/projects/stego-docs/spine/concepts/CON-STAGE-GATE.md +10 -0
  44. package/projects/stego-docs/spine/concepts/CON-WORKSPACE.md +9 -0
  45. package/projects/stego-docs/spine/concepts/_category.md +6 -0
  46. package/projects/stego-docs/spine/configuration/CFG-ALLOWED-STATUSES.md +9 -0
  47. package/projects/stego-docs/spine/configuration/CFG-COMPILE-LEVELS.md +9 -0
  48. package/projects/stego-docs/spine/configuration/CFG-COMPILE-STRUCTURE.md +9 -0
  49. package/projects/stego-docs/spine/configuration/CFG-REQUIRED-METADATA.md +9 -0
  50. package/projects/stego-docs/spine/configuration/CFG-SPINE-CATEGORIES.md +9 -0
  51. package/projects/stego-docs/spine/configuration/CFG-STAGE-POLICIES.md +9 -0
  52. package/projects/stego-docs/spine/configuration/CFG-STEGO-CONFIG.md +9 -0
  53. package/projects/stego-docs/spine/configuration/CFG-STEGO-PROJECT.md +9 -0
  54. package/projects/stego-docs/spine/configuration/_category.md +6 -0
  55. package/projects/stego-docs/spine/integrations/INT-CSPELL.md +9 -0
  56. package/projects/stego-docs/spine/integrations/INT-MARKDOWNLINT.md +9 -0
  57. package/projects/stego-docs/spine/integrations/INT-PANDOC.md +9 -0
  58. package/projects/stego-docs/spine/integrations/INT-SAURUS-EXTENSION.md +9 -0
  59. package/projects/stego-docs/spine/integrations/INT-STEGO-EXTENSION.md +9 -0
  60. package/projects/stego-docs/spine/integrations/INT-VSCODE.md +9 -0
  61. package/projects/stego-docs/spine/integrations/_category.md +6 -0
  62. package/projects/stego-docs/spine/workflows/FLOW-BUILD-EXPORT.md +10 -0
  63. package/projects/stego-docs/spine/workflows/FLOW-DAILY-WRITING.md +10 -0
  64. package/projects/stego-docs/spine/workflows/FLOW-INIT-WORKSPACE.md +9 -0
  65. package/projects/stego-docs/spine/workflows/FLOW-NEW-PROJECT.md +10 -0
  66. package/projects/stego-docs/spine/workflows/FLOW-PROOF-RELEASE.md +10 -0
  67. package/projects/stego-docs/spine/workflows/FLOW-STAGE-PROMOTION.md +10 -0
  68. package/projects/stego-docs/spine/workflows/_category.md +6 -0
  69. package/projects/stego-docs/stego-project.json +1 -28
  70. package/dist/comments/add-comment.js +0 -382
  71. package/projects/fiction-example/spine/characters.md +0 -35
  72. package/projects/fiction-example/spine/locations.md +0 -37
  73. package/projects/fiction-example/spine/sources.md +0 -31
  74. package/projects/stego-docs/spine/commands.md +0 -71
  75. package/projects/stego-docs/spine/concepts.md +0 -72
  76. package/projects/stego-docs/spine/configuration.md +0 -57
  77. package/projects/stego-docs/spine/integrations.md +0 -43
  78. package/projects/stego-docs/spine/workflows.md +0 -48
@@ -1,68 +1,173 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { addCommentToManuscript } from "./add-comment.js";
3
+ import { addCommentToState, clearResolvedInState, deleteCommentInState, ensureNoParseErrors, loadCommentDocumentState, renderStateDocument, replyToCommentInState, serializeLoadedState, setCommentStatusInState, syncAnchorsInState } from "./comment-domain.js";
4
4
  import { CommentsCommandError } from "./errors.js";
5
5
  /** Handles `stego comments ...` command group. */
6
6
  export async function runCommentsCommand(options, cwd) {
7
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|->].");
8
+ if (!subcommand) {
9
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Comments subcommand is required. Use: read, add, reply, set-status, delete, clear-resolved, sync-anchors.");
10
10
  }
11
11
  if (!manuscriptArg) {
12
- throw new CommentsCommandError("INVALID_USAGE", 2, "Manuscript path is required. Use: stego comments add <manuscript> ...");
12
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Manuscript path is required. Use: stego comments <subcommand> <manuscript> ...");
13
13
  }
14
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);
15
+ const manuscriptPath = path.resolve(cwd, manuscriptArg);
16
+ const raw = readManuscript(manuscriptPath, manuscriptArg);
17
+ let state = loadCommentDocumentState(raw);
18
+ switch (subcommand) {
19
+ case "read": {
20
+ emitSuccess({
21
+ operation: "read",
22
+ manuscript: manuscriptPath,
23
+ state: serializeLoadedState(state)
24
+ }, outputFormat, `Read comments for ${path.relative(cwd, manuscriptPath) || manuscriptPath}.`);
25
+ return;
26
+ }
27
+ case "add": {
28
+ ensureMutableState(state, outputFormat);
29
+ const addInput = resolveAddInput(options, cwd, outputFormat);
30
+ const result = runMutation(manuscriptPath, state, outputFormat, () => addCommentToState(raw, state, addInput), (mutationResult) => mutationResult.comments);
31
+ state = result.state;
32
+ emitSuccess({
33
+ operation: "add",
34
+ manuscript: manuscriptPath,
35
+ commentId: result.meta.commentId,
36
+ state: serializeLoadedState(state)
37
+ }, outputFormat, `Added ${result.meta.commentId} to ${path.relative(cwd, manuscriptPath) || manuscriptPath}.`);
38
+ return;
39
+ }
40
+ case "reply": {
41
+ ensureMutableState(state, outputFormat);
42
+ const commentId = requireCommentId(options, outputFormat);
43
+ const { message, author } = resolveReplyInput(options, cwd, outputFormat);
44
+ const result = runMutation(manuscriptPath, state, outputFormat, () => replyToCommentInState(state, { commentId, message, author }), (mutationResult) => mutationResult.comments);
45
+ state = result.state;
46
+ emitSuccess({
47
+ operation: "reply",
48
+ manuscript: manuscriptPath,
49
+ commentId: result.meta.commentId,
50
+ state: serializeLoadedState(state)
51
+ }, outputFormat, `Added reply ${result.meta.commentId} in ${path.relative(cwd, manuscriptPath) || manuscriptPath}.`);
52
+ return;
53
+ }
54
+ case "set-status": {
55
+ ensureMutableState(state, outputFormat);
56
+ const commentId = requireCommentId(options, outputFormat);
57
+ const status = parseStatus(readString(options, "status"), outputFormat);
58
+ const thread = options.thread === true;
59
+ const result = runMutation(manuscriptPath, state, outputFormat, () => setCommentStatusInState(state, { commentId, status, thread }), (mutationResult) => mutationResult.comments);
60
+ state = result.state;
61
+ emitSuccess({
62
+ operation: "set-status",
63
+ manuscript: manuscriptPath,
64
+ status,
65
+ changedIds: result.meta.changedIds,
66
+ state: serializeLoadedState(state)
67
+ }, outputFormat, `Updated ${result.meta.changedIds.length} comment(s) to '${status}'.`);
68
+ return;
69
+ }
70
+ case "delete": {
71
+ ensureMutableState(state, outputFormat);
72
+ const commentId = requireCommentId(options, outputFormat);
73
+ const result = runMutation(manuscriptPath, state, outputFormat, () => deleteCommentInState(state, commentId), (mutationResult) => mutationResult.comments);
74
+ state = result.state;
75
+ emitSuccess({
76
+ operation: "delete",
77
+ manuscript: manuscriptPath,
78
+ removed: result.meta.removed,
79
+ state: serializeLoadedState(state)
80
+ }, outputFormat, `Deleted ${result.meta.removed} comment(s).`);
81
+ return;
82
+ }
83
+ case "clear-resolved": {
84
+ ensureMutableState(state, outputFormat);
85
+ const result = runMutation(manuscriptPath, state, outputFormat, () => clearResolvedInState(state), (mutationResult) => mutationResult.comments);
86
+ state = result.state;
87
+ emitSuccess({
88
+ operation: "clear-resolved",
89
+ manuscript: manuscriptPath,
90
+ removed: result.meta.removed,
91
+ state: serializeLoadedState(state)
92
+ }, outputFormat, `Cleared ${result.meta.removed} resolved comment(s).`);
93
+ return;
94
+ }
95
+ case "sync-anchors": {
96
+ ensureMutableState(state, outputFormat);
97
+ const payload = readInputPayload(requireInputPath(options, outputFormat), cwd, outputFormat);
98
+ const syncInput = parseSyncInput(payload, outputFormat);
99
+ const result = runMutation(manuscriptPath, state, outputFormat, () => syncAnchorsInState(raw, state, syncInput), (mutationResult) => mutationResult.comments);
100
+ state = result.state;
101
+ emitSuccess({
102
+ operation: "sync-anchors",
103
+ manuscript: manuscriptPath,
104
+ updatedCount: result.meta.updatedCount,
105
+ deletedCount: result.meta.deletedCount,
106
+ state: serializeLoadedState(state)
107
+ }, outputFormat, `Synced anchors (${result.meta.updatedCount} updated, ${result.meta.deletedCount} deleted).`);
108
+ return;
109
+ }
110
+ default:
111
+ throw new CommentsCommandError("INVALID_USAGE", 2, `Unknown comments subcommand '${subcommand}'. Use: read, add, reply, set-status, delete, clear-resolved, sync-anchors.`, outputFormat);
19
112
  }
20
- if (!messageOption && !inputOption) {
21
- throw new CommentsCommandError("INVALID_USAGE", 2, "Missing payload. Provide --message <text> or --input <path|->.", outputFormat);
113
+ }
114
+ function runMutation(manuscriptPath, state, outputFormat, run, getComments) {
115
+ let mutationResult;
116
+ try {
117
+ mutationResult = run();
22
118
  }
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);
119
+ catch (error) {
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, message, outputFormat);
36
122
  }
37
- const finalAuthor = (readString(options, "author") ?? payloadAuthor ?? "Saurus").trim() || "Saurus";
38
- const finalRange = optionRange ?? payloadRange;
39
- let result;
123
+ const nextComments = getComments(mutationResult);
124
+ const nextText = renderStateDocument(state, nextComments);
40
125
  try {
41
- result = addCommentToManuscript({
42
- manuscriptPath: manuscriptArg,
43
- cwd,
44
- message: finalMessage,
45
- author: finalAuthor,
46
- range: finalRange,
47
- sourceMeta: payloadMeta
48
- });
126
+ fs.writeFileSync(manuscriptPath, nextText, "utf8");
49
127
  }
50
128
  catch (error) {
51
- if (error instanceof CommentsCommandError && outputFormat === "json" && error.outputFormat !== "json") {
52
- throw new CommentsCommandError(error.code, error.exitCode, error.message, outputFormat);
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ throw new CommentsCommandError("WRITE_FAILURE", 6, `Failed to update manuscript: ${message}`, outputFormat);
131
+ }
132
+ const nextState = loadCommentDocumentState(nextText);
133
+ return {
134
+ meta: mutationResult,
135
+ state: nextState
136
+ };
137
+ }
138
+ function ensureMutableState(state, outputFormat) {
139
+ try {
140
+ ensureNoParseErrors(state);
141
+ }
142
+ catch (error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ throw new CommentsCommandError("COMMENT_APPENDIX_INVALID", 5, message, outputFormat);
145
+ }
146
+ }
147
+ function readManuscript(absolutePath, originalArg) {
148
+ try {
149
+ const stat = fs.statSync(absolutePath);
150
+ if (!stat.isFile()) {
151
+ throw new CommentsCommandError("INVALID_USAGE", 2, `Manuscript file not found: ${originalArg}`);
53
152
  }
54
- throw error;
55
153
  }
56
- if (outputFormat === "json") {
57
- console.log(JSON.stringify(result, null, 2));
58
- return;
154
+ catch {
155
+ throw new CommentsCommandError("INVALID_USAGE", 2, `Manuscript file not found: ${originalArg}`);
156
+ }
157
+ try {
158
+ return fs.readFileSync(absolutePath, "utf8");
59
159
  }
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).`);
160
+ catch (error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ throw new CommentsCommandError("INVALID_USAGE", 2, `Unable to read manuscript: ${message}`);
163
+ }
164
+ }
165
+ function emitSuccess(payload, outputFormat, textMessage) {
166
+ if (outputFormat === "json") {
167
+ console.log(JSON.stringify({ ok: true, ...payload }, null, 2));
63
168
  return;
64
169
  }
65
- console.log(`Added ${result.commentId} to ${relativePath} (file-level anchor).`);
170
+ console.log(textMessage);
66
171
  }
67
172
  function readString(options, key) {
68
173
  const value = options[key];
@@ -77,6 +182,13 @@ function parseOutputFormat(raw) {
77
182
  }
78
183
  throw new CommentsCommandError("INVALID_USAGE", 2, "Invalid --format value. Use 'text' or 'json'.");
79
184
  }
185
+ function requireInputPath(options, outputFormat) {
186
+ const value = readString(options, "input");
187
+ if (!value) {
188
+ throw new CommentsCommandError("INVALID_USAGE", 2, "--input <path|-> is required for this command.", outputFormat);
189
+ }
190
+ return value;
191
+ }
80
192
  function readInputPayload(inputPath, cwd, outputFormat) {
81
193
  let rawJson = "";
82
194
  try {
@@ -100,6 +212,160 @@ function readInputPayload(inputPath, cwd, outputFormat) {
100
212
  }
101
213
  return parsed;
102
214
  }
215
+ function resolveAddInput(options, cwd, outputFormat) {
216
+ const messageOption = readString(options, "message");
217
+ const inputOption = readString(options, "input");
218
+ if (messageOption && inputOption) {
219
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Use exactly one payload mode: --message <text> OR --input <path|->.", outputFormat);
220
+ }
221
+ if (!messageOption && !inputOption) {
222
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Missing payload. Provide --message <text> or --input <path|->.", outputFormat);
223
+ }
224
+ const payload = inputOption ? readInputPayload(inputOption, cwd, outputFormat) : undefined;
225
+ const payloadMessage = coerceNonEmptyString(payload?.message, "Input payload 'message' must be a non-empty string.", outputFormat);
226
+ const payloadAuthor = coerceOptionalString(payload?.author, "Input payload 'author' must be a string if provided.", outputFormat);
227
+ const payloadMeta = payload?.meta !== undefined
228
+ ? coerceOptionalObject(payload.meta, "Input payload 'meta' must be an object if provided.", outputFormat)
229
+ : undefined;
230
+ const optionRange = parseRangeFromOptions(options, outputFormat);
231
+ const optionCursorLine = parseCursorLineOption(readString(options, "cursor-line"), outputFormat);
232
+ const payloadRange = payload?.range !== undefined
233
+ ? parsePayloadRange(payload.range, outputFormat)
234
+ : undefined;
235
+ const payloadAnchor = payload?.anchor !== undefined
236
+ ? parseAnchorPayload(payload.anchor, outputFormat)
237
+ : undefined;
238
+ const finalMessage = (messageOption ?? payloadMessage ?? "").trim();
239
+ if (!finalMessage) {
240
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Comment message cannot be empty.", outputFormat);
241
+ }
242
+ const finalAuthor = (readString(options, "author") ?? payloadAuthor ?? "Saurus").trim() || "Saurus";
243
+ const anchor = {
244
+ range: optionRange ?? payloadAnchor?.range ?? payloadRange,
245
+ cursorLine: optionCursorLine ?? payloadAnchor?.cursorLine,
246
+ excerpt: payloadAnchor?.excerpt
247
+ };
248
+ const resolvedAnchor = anchor.range || anchor.cursorLine !== undefined || anchor.excerpt
249
+ ? anchor
250
+ : undefined;
251
+ return {
252
+ message: finalMessage,
253
+ author: finalAuthor,
254
+ anchor: resolvedAnchor,
255
+ meta: payloadMeta
256
+ };
257
+ }
258
+ function resolveReplyInput(options, cwd, outputFormat) {
259
+ const messageOption = readString(options, "message");
260
+ const inputOption = readString(options, "input");
261
+ if (messageOption && inputOption) {
262
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Use exactly one payload mode: --message <text> OR --input <path|->.", outputFormat);
263
+ }
264
+ if (!messageOption && !inputOption) {
265
+ throw new CommentsCommandError("INVALID_USAGE", 2, "Missing payload. Provide --message <text> or --input <path|->.", outputFormat);
266
+ }
267
+ const payload = inputOption ? readInputPayload(inputOption, cwd, outputFormat) : undefined;
268
+ const payloadMessage = coerceNonEmptyString(payload?.message, "Input payload 'message' must be a non-empty string.", outputFormat);
269
+ const payloadAuthor = coerceOptionalString(payload?.author, "Input payload 'author' must be a string if provided.", outputFormat);
270
+ const finalMessage = (messageOption ?? payloadMessage ?? "").trim();
271
+ if (!finalMessage) {
272
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Reply cannot be empty.", outputFormat);
273
+ }
274
+ const finalAuthor = (readString(options, "author") ?? payloadAuthor ?? "Saurus").trim() || "Saurus";
275
+ return {
276
+ message: finalMessage,
277
+ author: finalAuthor
278
+ };
279
+ }
280
+ function parseSyncInput(payload, outputFormat) {
281
+ const candidate = payload;
282
+ const updates = candidate.updates === undefined
283
+ ? []
284
+ : parseAnchorUpdates(candidate.updates, outputFormat);
285
+ const deleteIdsRaw = candidate.delete_ids ?? candidate.deleteIds;
286
+ const deleteIds = deleteIdsRaw === undefined
287
+ ? []
288
+ : parseDeleteIds(deleteIdsRaw, outputFormat);
289
+ return {
290
+ updates,
291
+ deleteIds
292
+ };
293
+ }
294
+ function parseAnchorUpdates(raw, outputFormat) {
295
+ if (!Array.isArray(raw)) {
296
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload 'updates' must be an array.", outputFormat);
297
+ }
298
+ const updates = [];
299
+ for (const candidate of raw) {
300
+ if (!isPlainObject(candidate)) {
301
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Each update must be an object.", outputFormat);
302
+ }
303
+ const id = typeof candidate.id === "string" ? candidate.id.trim().toUpperCase() : "";
304
+ if (!/^CMT-\d{4,}$/.test(id)) {
305
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Each update.id must be a comment id like CMT-0001.", outputFormat);
306
+ }
307
+ const start = isPlainObject(candidate.start) ? candidate.start : undefined;
308
+ const end = isPlainObject(candidate.end) ? candidate.end : undefined;
309
+ if (!start || !end) {
310
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Each update must include start and end positions.", outputFormat);
311
+ }
312
+ const parsed = {
313
+ id,
314
+ start: {
315
+ line: parseJsonPositiveInt(start.line, "updates[].start.line", outputFormat),
316
+ col: parseJsonNonNegativeInt(start.col, "updates[].start.col", outputFormat)
317
+ },
318
+ end: {
319
+ line: parseJsonPositiveInt(end.line, "updates[].end.line", outputFormat),
320
+ col: parseJsonNonNegativeInt(end.col, "updates[].end.col", outputFormat)
321
+ }
322
+ };
323
+ const valid = parsed.start.line < parsed.end.line
324
+ || (parsed.start.line === parsed.end.line && parsed.start.col < parsed.end.col);
325
+ if (!valid) {
326
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Each update range end must be after start.", outputFormat);
327
+ }
328
+ updates.push(parsed);
329
+ }
330
+ return updates;
331
+ }
332
+ function parseDeleteIds(raw, outputFormat) {
333
+ if (!Array.isArray(raw)) {
334
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload 'delete_ids' must be an array.", outputFormat);
335
+ }
336
+ const ids = [];
337
+ for (const value of raw) {
338
+ if (typeof value !== "string") {
339
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "delete_ids values must be strings.", outputFormat);
340
+ }
341
+ const normalized = value.trim().toUpperCase();
342
+ if (!/^CMT-\d{4,}$/.test(normalized)) {
343
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, `Invalid delete_ids entry '${value}'.`, outputFormat);
344
+ }
345
+ ids.push(normalized);
346
+ }
347
+ return ids;
348
+ }
349
+ function parseAnchorPayload(raw, outputFormat) {
350
+ if (!isPlainObject(raw)) {
351
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload 'anchor' must be an object.", outputFormat);
352
+ }
353
+ const parsed = raw;
354
+ const range = parsed.range !== undefined
355
+ ? parsePayloadRange(parsed.range, outputFormat)
356
+ : undefined;
357
+ const cursorLine = parsed.cursor_line !== undefined
358
+ ? parseJsonPositiveInt(parsed.cursor_line, "anchor.cursor_line", outputFormat)
359
+ : undefined;
360
+ const excerpt = parsed.excerpt !== undefined
361
+ ? coerceOptionalString(parsed.excerpt, "Input payload 'anchor.excerpt' must be a string if provided.", outputFormat)
362
+ : undefined;
363
+ return {
364
+ range,
365
+ cursorLine,
366
+ excerpt
367
+ };
368
+ }
103
369
  function parseRangeFromOptions(options, outputFormat) {
104
370
  const rawStartLine = readString(options, "start-line");
105
371
  const rawStartCol = readString(options, "start-col");
@@ -115,43 +381,53 @@ function parseRangeFromOptions(options, outputFormat) {
115
381
  throw new CommentsCommandError("INVALID_USAGE", 2, "Range options must include all of --start-line, --start-col, --end-line, --end-col.", outputFormat);
116
382
  }
117
383
  const range = {
118
- startLine: parseNonNegativeInt(rawStartLine, "--start-line", outputFormat),
384
+ startLine: parsePositiveInt(rawStartLine, "--start-line", outputFormat),
119
385
  startCol: parseNonNegativeInt(rawStartCol, "--start-col", outputFormat),
120
- endLine: parseNonNegativeInt(rawEndLine, "--end-line", outputFormat),
386
+ endLine: parsePositiveInt(rawEndLine, "--end-line", outputFormat),
121
387
  endCol: parseNonNegativeInt(rawEndCol, "--end-col", outputFormat)
122
388
  };
123
389
  validateRange(range, outputFormat);
124
390
  return range;
125
391
  }
392
+ function parseCursorLineOption(raw, outputFormat) {
393
+ if (raw === undefined) {
394
+ return undefined;
395
+ }
396
+ return parsePositiveInt(raw, "--cursor-line", outputFormat);
397
+ }
126
398
  function parsePayloadRange(raw, outputFormat) {
127
399
  if (!isPlainObject(raw)) {
128
- throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload 'range' must be an object.", outputFormat);
400
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload range must be an object.", outputFormat);
129
401
  }
130
402
  const parsed = raw;
131
403
  const start = parsed.start;
132
404
  const end = parsed.end;
133
405
  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
- };
406
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload range must include start and end objects.", outputFormat);
407
+ }
146
408
  const range = {
147
- startLine: normalized.start.line,
148
- startCol: normalized.start.col,
149
- endLine: normalized.end.line,
150
- endCol: normalized.end.col
409
+ startLine: parseJsonPositiveInt(start.line, "range.start.line", outputFormat),
410
+ startCol: parseJsonNonNegativeInt(start.col, "range.start.col", outputFormat),
411
+ endLine: parseJsonPositiveInt(end.line, "range.end.line", outputFormat),
412
+ endCol: parseJsonNonNegativeInt(end.col, "range.end.col", outputFormat)
151
413
  };
152
414
  validateRange(range, outputFormat);
153
415
  return range;
154
416
  }
417
+ function requireCommentId(options, outputFormat) {
418
+ const commentId = readString(options, "comment-id")?.trim().toUpperCase();
419
+ if (!commentId || !/^CMT-\d{4,}$/.test(commentId)) {
420
+ throw new CommentsCommandError("INVALID_USAGE", 2, "--comment-id CMT-#### is required.", outputFormat);
421
+ }
422
+ return commentId;
423
+ }
424
+ function parseStatus(raw, outputFormat) {
425
+ const normalized = (raw ?? "").trim().toLowerCase();
426
+ if (normalized === "open" || normalized === "resolved") {
427
+ return normalized;
428
+ }
429
+ throw new CommentsCommandError("INVALID_USAGE", 2, "--status must be 'open' or 'resolved'.", outputFormat);
430
+ }
155
431
  function validateRange(range, outputFormat) {
156
432
  const startsBeforeEnd = range.startLine < range.endLine
157
433
  || (range.startLine === range.endLine && range.startCol < range.endCol);
@@ -159,16 +435,32 @@ function validateRange(range, outputFormat) {
159
435
  throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Range end must be after range start.", outputFormat);
160
436
  }
161
437
  }
438
+ function parsePositiveInt(raw, label, outputFormat) {
439
+ if (!/^\d+$/.test(raw)) {
440
+ throw new CommentsCommandError("INVALID_USAGE", 2, `${label} must be a positive integer.`, outputFormat);
441
+ }
442
+ const value = Number.parseInt(raw, 10);
443
+ if (!Number.isFinite(value) || value < 1) {
444
+ throw new CommentsCommandError("INVALID_USAGE", 2, `${label} must be a positive integer.`, outputFormat);
445
+ }
446
+ return value;
447
+ }
162
448
  function parseNonNegativeInt(raw, label, outputFormat) {
163
449
  if (!/^\d+$/.test(raw)) {
164
450
  throw new CommentsCommandError("INVALID_USAGE", 2, `${label} must be a non-negative integer.`, outputFormat);
165
451
  }
166
452
  const value = Number.parseInt(raw, 10);
167
- if (!Number.isFinite(value)) {
453
+ if (!Number.isFinite(value) || value < 0) {
168
454
  throw new CommentsCommandError("INVALID_USAGE", 2, `${label} must be a non-negative integer.`, outputFormat);
169
455
  }
170
456
  return value;
171
457
  }
458
+ function parseJsonPositiveInt(raw, label, outputFormat) {
459
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
460
+ throw new CommentsCommandError("INVALID_PAYLOAD", 4, `${label} must be a positive integer.`, outputFormat);
461
+ }
462
+ return raw;
463
+ }
172
464
  function parseJsonNonNegativeInt(raw, label, outputFormat) {
173
465
  if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
174
466
  throw new CommentsCommandError("INVALID_PAYLOAD", 4, `${label} must be a non-negative integer.`, outputFormat);
@@ -0,0 +1,127 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { normalizeFrontmatterRecord, parseMarkdownDocument, serializeMarkdownDocument } from "./metadata-domain.js";
5
+ export async function runMetadataCommand(options, cwd) {
6
+ const [subcommand, markdownArg] = options._;
7
+ if (!subcommand) {
8
+ throw new Error("Metadata subcommand is required. Use: read, apply.");
9
+ }
10
+ if (!markdownArg) {
11
+ throw new Error("Markdown path is required. Use: stego metadata <subcommand> <path>.");
12
+ }
13
+ const outputFormat = parseOutputFormat(readString(options, "format"));
14
+ const absolutePath = path.resolve(cwd, markdownArg);
15
+ const raw = readFile(absolutePath, markdownArg);
16
+ switch (subcommand) {
17
+ case "read": {
18
+ const parsed = parseMarkdownDocument(raw);
19
+ emit({
20
+ ok: true,
21
+ operation: "read",
22
+ state: {
23
+ path: absolutePath,
24
+ hasFrontmatter: parsed.hasFrontmatter,
25
+ lineEnding: parsed.lineEnding,
26
+ frontmatter: parsed.frontmatter,
27
+ body: parsed.body
28
+ }
29
+ }, outputFormat);
30
+ return;
31
+ }
32
+ case "apply": {
33
+ const inputPath = requireInputPath(options);
34
+ const payload = readInputPayload(inputPath, cwd);
35
+ const frontmatter = normalizeFrontmatterRecord(payload.frontmatter);
36
+ const body = typeof payload.body === "string" ? payload.body : undefined;
37
+ const hasFrontmatter = typeof payload.hasFrontmatter === "boolean" ? payload.hasFrontmatter : undefined;
38
+ const existing = parseMarkdownDocument(raw);
39
+ const next = {
40
+ lineEnding: existing.lineEnding,
41
+ hasFrontmatter: hasFrontmatter ?? (existing.hasFrontmatter || Object.keys(frontmatter).length > 0),
42
+ frontmatter,
43
+ body: body ?? existing.body
44
+ };
45
+ const nextText = serializeMarkdownDocument(next);
46
+ const changed = nextText !== raw;
47
+ if (changed) {
48
+ fs.writeFileSync(absolutePath, nextText, "utf8");
49
+ }
50
+ const reparsed = parseMarkdownDocument(nextText);
51
+ emit({
52
+ ok: true,
53
+ operation: "apply",
54
+ changed,
55
+ state: {
56
+ path: absolutePath,
57
+ hasFrontmatter: reparsed.hasFrontmatter,
58
+ lineEnding: reparsed.lineEnding,
59
+ frontmatter: reparsed.frontmatter,
60
+ body: reparsed.body
61
+ }
62
+ }, outputFormat);
63
+ return;
64
+ }
65
+ default:
66
+ throw new Error(`Unknown metadata subcommand '${subcommand}'. Use: read, apply.`);
67
+ }
68
+ }
69
+ function emit(payload, format) {
70
+ if (format === "json") {
71
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
72
+ return;
73
+ }
74
+ if (payload.operation === "read") {
75
+ process.stdout.write(`Read metadata for ${payload.state.path} (${Object.keys(payload.state.frontmatter).length} keys).\n`);
76
+ return;
77
+ }
78
+ process.stdout.write(`${payload.changed ? "Updated" : "No changes for"} metadata in ${payload.state.path}.\n`);
79
+ }
80
+ function parseOutputFormat(raw) {
81
+ if (!raw || raw === "text") {
82
+ return "text";
83
+ }
84
+ if (raw === "json") {
85
+ return "json";
86
+ }
87
+ throw new Error("Invalid --format value. Use 'text' or 'json'.");
88
+ }
89
+ function readString(options, key) {
90
+ const value = options[key];
91
+ return typeof value === "string" ? value : undefined;
92
+ }
93
+ function requireInputPath(options) {
94
+ const inputPath = readString(options, "input");
95
+ if (!inputPath) {
96
+ throw new Error("--input <path|-> is required for 'metadata apply'.");
97
+ }
98
+ return inputPath;
99
+ }
100
+ function readInputPayload(inputPath, cwd) {
101
+ const raw = inputPath === "-"
102
+ ? fs.readFileSync(process.stdin.fd, "utf8")
103
+ : fs.readFileSync(path.resolve(cwd, inputPath), "utf8");
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(raw);
107
+ }
108
+ catch {
109
+ throw new Error("Input payload is not valid JSON.");
110
+ }
111
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
112
+ throw new Error("Input payload must be a JSON object.");
113
+ }
114
+ return parsed;
115
+ }
116
+ function readFile(filePath, originalArg) {
117
+ try {
118
+ const stat = fs.statSync(filePath);
119
+ if (!stat.isFile()) {
120
+ throw new Error();
121
+ }
122
+ }
123
+ catch {
124
+ throw new Error(`Markdown file not found: ${originalArg}`);
125
+ }
126
+ return fs.readFileSync(filePath, "utf8");
127
+ }