@ssobig/writer-cli 0.2.2 → 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.
Files changed (28) hide show
  1. package/README.md +12 -0
  2. package/config.js +2 -1
  3. package/package.json +1 -1
  4. package/templates/mystery-v1/authoring-view-preference.js +22 -5
  5. package/templates/mystery-v1/character-perspective-preview.js +3 -3
  6. package/templates/mystery-v1/codemirror6-runtime.min.js +28 -0
  7. package/templates/mystery-v1/component-asset-operations.js +5 -4
  8. package/templates/mystery-v1/component-catalog-contract.js +26 -35
  9. package/templates/mystery-v1/component-draft-operations.js +19 -8
  10. package/templates/mystery-v1/component-field-contracts.js +29 -49
  11. package/templates/mystery-v1/component-id-policy.js +1 -1
  12. package/templates/mystery-v1/component-manager.js +21 -11
  13. package/templates/mystery-v1/component-navigation-counts.js +3 -8
  14. package/templates/mystery-v1/component-registry.js +11 -5
  15. package/templates/mystery-v1/component-renderers.js +12 -8
  16. package/templates/mystery-v1/markdown-live-editor.js +350 -0
  17. package/templates/mystery-v1/page-header.js +2 -1
  18. package/tools/writer-cli/package-lock.json +2 -2
  19. package/tools/writer-cli/package.json +1 -1
  20. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +1 -1
  21. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +1 -1
  22. package/tools/writer-cli/src/agent-service.cjs +3 -1
  23. package/tools/writer-cli/src/command-registry.cjs +3 -0
  24. package/tools/writer-cli/src/commands.cjs +85 -3
  25. package/tools/writer-cli/src/domain.cjs +250 -1
  26. package/tools/writer-cli/src/gateway.cjs +14 -0
  27. package/tools/writer-cli/src/mutations.cjs +167 -1
  28. package/tools/writer-cli/src/project-import.cjs +12 -21
@@ -10,7 +10,6 @@
10
10
  aiResponse: root.WriterWorkbenchAiResponseRenderer,
11
11
  timeline: root.WriterWorkbenchTimelineRenderer,
12
12
  ending: root.WriterWorkbenchEndingRenderer,
13
- postgame: root.WriterWorkbenchPostgameRenderer,
14
13
  authorNotes: root.WriterWorkbenchAuthorNotesRenderer,
15
14
  investigationBoard: root.WriterWorkbenchInvestigationBoardRenderer,
16
15
  documents: root.WriterWorkbenchDocumentRenderers
@@ -26,7 +25,6 @@
26
25
  aiResponse: require("./renderers/ai-response.js"),
27
26
  timeline: require("./renderers/timeline.js"),
28
27
  ending: require("./renderers/ending.js"),
29
- postgame: require("./renderers/postgame.js"),
30
28
  authorNotes: require("./renderers/author-notes.js"),
31
29
  investigationBoard: require("./renderers/investigation-board.js"),
32
30
  documents: require("./renderers/documents.js")
@@ -36,8 +34,8 @@
36
34
  if (root) root.WriterWorkbenchComponentRenderers = api;
37
35
  })(typeof globalThis !== "undefined" ? globalThis : this, function (dependencies) {
38
36
  "use strict";
39
- const { appearance, shared, basic, common, character, clues, clueCombinations, aiResponse, timeline, ending, postgame, authorNotes, investigationBoard, documents } = dependencies;
40
- if (!appearance || !shared || !basic || !common || !character || !clues || !clueCombinations || !aiResponse || !timeline || !ending || !postgame || !authorNotes || !investigationBoard || !documents) throw new Error("Component renderer modules are incomplete");
37
+ const { appearance, shared, basic, common, character, clues, clueCombinations, aiResponse, timeline, ending, authorNotes, investigationBoard, documents } = dependencies;
38
+ if (!appearance || !shared || !basic || !common || !character || !clues || !clueCombinations || !aiResponse || !timeline || !ending || !authorNotes || !investigationBoard || !documents) throw new Error("Component renderer modules are incomplete");
41
39
 
42
40
  const renderers = new Map();
43
41
  function register(renderer) {
@@ -45,9 +43,10 @@
45
43
  if (renderers.has(key)) throw new Error(`중복 Component 렌더러입니다: ${key}`);
46
44
  renderers.set(key, Object.freeze({ ...renderer }));
47
45
  }
48
- [basic, common, { ...common, templateId: "ssobig.choice" }, character, clues, clueCombinations, aiResponse, timeline, ending, postgame, authorNotes, investigationBoard].forEach(register);
46
+ [basic, common, character, clues, clueCombinations, aiResponse, timeline, ending, authorNotes, investigationBoard].forEach(register);
49
47
  Object.entries(documents.definitions).forEach(([templateId, definition]) => register({
50
48
  templateId,
49
+ unifiedAuthoring: definition.unifiedAuthoring === true,
51
50
  renderAuthoring: documents.renderAuthoring,
52
51
  renderPreview: documents.renderPreview,
53
52
  bind: documents.bind
@@ -71,6 +70,8 @@
71
70
  return renderer;
72
71
  }
73
72
  function defaultView() { return "preview"; }
73
+ function usesUnifiedAuthoring(instance) { return resolve(instance).unifiedAuthoring === true; }
74
+ function usesUnifiedAuthoringView(viewModel) { return resolveView(viewModel).unifiedAuthoring === true; }
74
75
  function context(instance, data, extra = {}) {
75
76
  return { instance, data, ...extra, view: shared.rendererView(extra, defaultView(instance)) };
76
77
  }
@@ -90,8 +91,9 @@
90
91
  .replace(/data-renderer-view="(?:input|preview)"/, `data-renderer-view="${view}"`);
91
92
  }
92
93
  function renderAuthoring(instance, data, extra) {
93
- const rendererContext = context(instance, data, extra);
94
- return applyRendererView(resolve(instance).renderAuthoring(rendererContext), rendererContext.view);
94
+ const renderer = resolve(instance);
95
+ const rendererContext = context(instance, data, renderer.unifiedAuthoring === true ? { ...extra, view: "preview" } : extra);
96
+ return applyRendererView(renderer.renderAuthoring(rendererContext), rendererContext.view);
95
97
  }
96
98
  function renderPreview(instance, data = instance?.data, extra) { return resolve(instance).renderPreview(context(instance, data, extra)); }
97
99
  function bind(instance, root, data, extra) {
@@ -102,7 +104,7 @@
102
104
  }
103
105
  function renderAuthoringView(viewModel, extra) {
104
106
  const renderer = resolveView(viewModel);
105
- const rendererContext = viewContext(viewModel, extra);
107
+ const rendererContext = viewContext(viewModel, renderer.unifiedAuthoring === true ? { ...extra, view: "preview" } : extra);
106
108
  return applyRendererView(renderer.renderAuthoring(rendererContext), rendererContext.view);
107
109
  }
108
110
  function renderPreviewView(viewModel, extra) {
@@ -119,6 +121,8 @@
119
121
  resolve,
120
122
  resolveView,
121
123
  defaultView,
124
+ usesUnifiedAuthoring,
125
+ usesUnifiedAuthoringView,
122
126
  renderAuthoring,
123
127
  renderPreview,
124
128
  bind,
@@ -0,0 +1,350 @@
1
+ (function (root, factory) {
2
+ const api = factory(root?.WriterCodeMirror6);
3
+ if (typeof module === "object" && module.exports) module.exports = api;
4
+ if (root) root.WriterMarkdownLiveEditor = api;
5
+ })(typeof globalThis !== "undefined" ? globalThis : this, function (CodeMirror6) {
6
+ "use strict";
7
+
8
+ const INLINE_MARKERS = Object.freeze([
9
+ { marker: "++", type: "brand" },
10
+ { marker: "**", type: "strong" },
11
+ { marker: "~~", type: "strikethrough" },
12
+ { marker: "==", type: "highlight" },
13
+ { marker: "`", type: "code" }
14
+ ]);
15
+ function lineStarts(source) {
16
+ const starts = [0];
17
+ for (let index = 0; index < source.length; index += 1) {
18
+ if (source[index] === "\n") starts.push(index + 1);
19
+ }
20
+ return starts;
21
+ }
22
+
23
+ function lineAt(starts, offset) {
24
+ let low = 0;
25
+ let high = starts.length - 1;
26
+ while (low <= high) {
27
+ const middle = (low + high) >> 1;
28
+ if (starts[middle] <= offset) low = middle + 1;
29
+ else high = middle - 1;
30
+ }
31
+ return Math.max(0, high);
32
+ }
33
+
34
+ function inlineTokens(source) {
35
+ const tokens = [];
36
+ let index = 0;
37
+ while (index < source.length) {
38
+ const definition = INLINE_MARKERS.find(item => source.startsWith(item.marker, index));
39
+ if (!definition || (index > 0 && source[index - 1] === "\\")) {
40
+ index += 1;
41
+ continue;
42
+ }
43
+ const contentFrom = index + definition.marker.length;
44
+ const closeFrom = source.indexOf(definition.marker, contentFrom);
45
+ const crossesBlockBoundary = closeFrom > contentFrom && /\n[ \t]*\n/.test(source.slice(contentFrom, closeFrom));
46
+ if (closeFrom <= contentFrom || crossesBlockBoundary || (closeFrom > 0 && source[closeFrom - 1] === "\\")) {
47
+ index += definition.marker.length;
48
+ continue;
49
+ }
50
+ const to = closeFrom + definition.marker.length;
51
+ tokens.push({
52
+ type: definition.type,
53
+ marker: definition.marker,
54
+ from: index,
55
+ openTo: contentFrom,
56
+ contentFrom,
57
+ contentTo: closeFrom,
58
+ closeFrom,
59
+ to
60
+ });
61
+ index = to;
62
+ }
63
+ return tokens;
64
+ }
65
+
66
+ function normalizeSelections(selections, sourceLength) {
67
+ const values = Array.isArray(selections) ? selections : [{ anchor: 0, head: 0 }];
68
+ return values.map(selection => {
69
+ const anchor = Math.max(0, Math.min(sourceLength, Number(selection?.anchor) || 0));
70
+ const head = Math.max(0, Math.min(sourceLength, Number(selection?.head) || 0));
71
+ return { anchor, head, from: Math.min(anchor, head), to: Math.max(anchor, head) };
72
+ });
73
+ }
74
+
75
+ function selectionTouchesRange(selection, from, to) {
76
+ return selection.from === selection.to
77
+ ? selection.head >= from && selection.head <= to
78
+ : selection.to >= from && selection.from <= to;
79
+ }
80
+
81
+ function lineSyntax(text) {
82
+ const heading = text.match(/^( {0,3})(#{1,4})(?:[ \t]+|$)/);
83
+ const quote = text.match(/^( {0,3})>[ \t]?/);
84
+ const unordered = text.match(/^([-*])[ \t]+(.+)$/);
85
+ const ordered = text.match(/^(\d+[.)])[ \t]+(.+)$/);
86
+ return {
87
+ headingLevel: heading ? heading[2].length : 0,
88
+ headingMarkerFrom: heading ? heading[1].length : -1,
89
+ headingMarkerTo: heading ? heading[0].length : -1,
90
+ quoteMarkerFrom: quote ? quote[1].length : -1,
91
+ quoteMarkerTo: quote ? quote[0].length : -1,
92
+ listType: unordered ? "unordered" : ordered ? "ordered" : "",
93
+ empty: !text.trim()
94
+ };
95
+ }
96
+
97
+ function layoutLines(source, starts) {
98
+ const lines = source.split("\n").map((text, index) => ({
99
+ index,
100
+ text,
101
+ from: starts[index],
102
+ to: starts[index] + text.length,
103
+ ...lineSyntax(text),
104
+ blockId: -1,
105
+ blockType: "",
106
+ blockStart: false,
107
+ blockEnd: false,
108
+ nestedQuote: false,
109
+ firstListItem: false
110
+ }));
111
+ const blocks = [];
112
+ let paragraph = null;
113
+ let quote = null;
114
+ let listType = "";
115
+ let listGroup = 0;
116
+ let nextBlockId = 0;
117
+ const openBlock = (type, line, extra = {}) => {
118
+ const block = { id: nextBlockId++, type, lines: [], ...extra };
119
+ blocks.push(block);
120
+ line.blockId = block.id;
121
+ line.blockType = type;
122
+ line.blockStart = true;
123
+ block.lines.push(line.index);
124
+ return block;
125
+ };
126
+ const appendBlock = (block, line) => {
127
+ line.blockId = block.id;
128
+ line.blockType = block.type;
129
+ block.lines.push(line.index);
130
+ };
131
+
132
+ lines.forEach(line => {
133
+ if (line.quoteMarkerFrom >= 0) {
134
+ paragraph = null;
135
+ const nestedQuote = Boolean(listType);
136
+ if (!quote || quote.nestedQuote !== nestedQuote || quote.listGroup !== listGroup) {
137
+ quote = openBlock("quote", line, { nestedQuote, listGroup: nestedQuote ? listGroup : 0 });
138
+ } else appendBlock(quote, line);
139
+ line.nestedQuote = nestedQuote;
140
+ return;
141
+ }
142
+ quote = null;
143
+ if (line.empty) {
144
+ paragraph = null;
145
+ listType = "";
146
+ return;
147
+ }
148
+ if (line.headingLevel) {
149
+ paragraph = null;
150
+ listType = "";
151
+ openBlock(`heading${line.headingLevel}`, line);
152
+ return;
153
+ }
154
+ if (line.listType) {
155
+ paragraph = null;
156
+ const firstListItem = line.listType !== listType;
157
+ if (firstListItem) listGroup += 1;
158
+ listType = line.listType;
159
+ line.firstListItem = firstListItem;
160
+ openBlock("listItem", line, { listGroup, firstListItem });
161
+ return;
162
+ }
163
+ listType = "";
164
+ if (!paragraph) paragraph = openBlock("paragraph", line);
165
+ else appendBlock(paragraph, line);
166
+ });
167
+
168
+ blocks.forEach(block => {
169
+ const lastLine = lines[block.lines.at(-1)];
170
+ if (lastLine) lastLine.blockEnd = true;
171
+ });
172
+ const listGroups = new Map();
173
+ blocks.forEach(block => {
174
+ if (!block.listGroup) return;
175
+ if (!listGroups.has(block.listGroup)) listGroups.set(block.listGroup, []);
176
+ listGroups.get(block.listGroup).push(block);
177
+ });
178
+ listGroups.forEach(group => {
179
+ const last = group.at(-1);
180
+ if (last) last.listEnd = true;
181
+ });
182
+
183
+ return { lines, blocks };
184
+ }
185
+
186
+ function previewModel(value, selections = [{ anchor: 0, head: 0 }]) {
187
+ const source = String(value ?? "").replace(/\r\n?/g, "\n");
188
+ const starts = lineStarts(source);
189
+ const normalizedSelections = normalizeSelections(selections, source.length);
190
+ const activeLines = new Set();
191
+ normalizedSelections.forEach(selection => {
192
+ const first = lineAt(starts, selection.from);
193
+ const last = lineAt(starts, selection.to);
194
+ for (let line = first; line <= last; line += 1) activeLines.add(line);
195
+ });
196
+ const layout = layoutLines(source, starts);
197
+ layout.lines.forEach(line => { line.active = activeLines.has(line.index); });
198
+ const tokens = inlineTokens(source).map(token => ({
199
+ ...token,
200
+ active: normalizedSelections.some(selection => selectionTouchesRange(selection, token.from, token.to))
201
+ }));
202
+ return { source, starts, lines: layout.lines, blocks: layout.blocks, tokens };
203
+ }
204
+
205
+ function classNames(line) {
206
+ const values = ["writer-md-line"];
207
+ if (line.empty) values.push("writer-md-empty-line");
208
+ if (line.active) values.push("writer-md-active-line");
209
+ if (line.blockStart) values.push("writer-md-block-start");
210
+ if (line.blockEnd) values.push("writer-md-block-end");
211
+ if (line.blockType) {
212
+ const blockClass = line.blockType
213
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
214
+ .replace(/(\d+)$/, "-$1")
215
+ .toLowerCase();
216
+ values.push(`writer-md-${blockClass}`);
217
+ }
218
+ if (line.nestedQuote) values.push("writer-md-nested-quote");
219
+ if (line.firstListItem) values.push("writer-md-first-list-item");
220
+ return values.join(" ");
221
+ }
222
+
223
+ function mount(options = {}) {
224
+ const cm = CodeMirror6;
225
+ if (!cm?.EditorState || !cm?.StateEffect || !cm?.EditorView || !cm?.Decoration || !cm?.StateField) {
226
+ throw new Error("Markdown 편집기를 초기화하지 못했습니다.");
227
+ }
228
+ if (!options.parent) throw new Error("Markdown 편집기 표시 영역이 없습니다.");
229
+ let destroyed = false;
230
+ const ownerDocument = options.parent.ownerDocument || document;
231
+ const focusEffect = cm.StateEffect.define();
232
+ const focusField = cm.StateField.define({
233
+ create() { return false; },
234
+ update(focused, transaction) {
235
+ for (const effect of transaction.effects) {
236
+ if (effect.is(focusEffect)) focused = Boolean(effect.value);
237
+ }
238
+ return focused;
239
+ }
240
+ });
241
+ const buildDecorations = state => {
242
+ const selections = state.field(focusField)
243
+ ? state.selection.ranges.map(range => ({ anchor: range.anchor, head: range.head }))
244
+ : [];
245
+ const model = previewModel(state.doc.toString(), selections);
246
+ const ranges = [];
247
+ const syntaxDecoration = active => cm.Decoration.mark({
248
+ class: active ? "writer-md-syntax" : "writer-md-syntax-hidden"
249
+ });
250
+ model.lines.forEach(line => {
251
+ ranges.push(cm.Decoration.line({ attributes: { class: classNames(line) } }).range(line.from));
252
+ if (line.headingMarkerFrom >= 0) {
253
+ const from = line.from + line.headingMarkerFrom;
254
+ const to = line.from + line.headingMarkerTo;
255
+ ranges.push(syntaxDecoration(line.active).range(from, to));
256
+ } else if (line.quoteMarkerFrom >= 0) {
257
+ const from = line.from + line.quoteMarkerFrom;
258
+ const to = line.from + line.quoteMarkerTo;
259
+ ranges.push(syntaxDecoration(line.active).range(from, to));
260
+ }
261
+ });
262
+ model.tokens.forEach(token => {
263
+ ranges.push(cm.Decoration.mark({ class: `writer-md-${token.type}` }).range(token.contentFrom, token.contentTo));
264
+ const markerDecoration = syntaxDecoration(token.active);
265
+ ranges.push(markerDecoration.range(token.from, token.openTo));
266
+ ranges.push(markerDecoration.range(token.closeFrom, token.to));
267
+ });
268
+ return cm.Decoration.set(ranges, true);
269
+ };
270
+ const previewField = cm.StateField.define({
271
+ create(state) {
272
+ return buildDecorations(state);
273
+ },
274
+ update(decorations, transaction) {
275
+ const focusChanged = transaction.effects.some(effect => effect.is(focusEffect));
276
+ return transaction.docChanged || transaction.selection || focusChanged ? buildDecorations(transaction.state) : decorations;
277
+ },
278
+ provide: field => cm.EditorView.decorations.from(field)
279
+ });
280
+ const state = cm.EditorState.create({
281
+ doc: String(options.value ?? "").replace(/\r\n?/g, "\n"),
282
+ extensions: [
283
+ cm.EditorView.editorAttributes.of({
284
+ class: "writer-markdown-live-editor",
285
+ ...(options.placeholder ? { "data-placeholder": String(options.placeholder) } : {})
286
+ }),
287
+ cm.history(),
288
+ cm.keymap.of([
289
+ { key: "Escape", run() { options.onEscape?.(); return true; } },
290
+ ...cm.defaultKeymap,
291
+ ...cm.historyKeymap
292
+ ]),
293
+ cm.markdown(),
294
+ cm.EditorView.lineWrapping,
295
+ focusField,
296
+ previewField,
297
+ cm.EditorView.contentAttributes.of({
298
+ role: "textbox",
299
+ "aria-multiline": "true",
300
+ "aria-label": String(options.ariaLabel || "Markdown 편집")
301
+ }),
302
+ cm.EditorView.updateListener.of(update => {
303
+ if (update.docChanged) options.onChange?.(update.state.doc.toString());
304
+ }),
305
+ cm.EditorView.domEventHandlers({
306
+ focus() {
307
+ view.dispatch({ effects: focusEffect.of(true) });
308
+ },
309
+ blur() {
310
+ view.dispatch({ effects: focusEffect.of(false) });
311
+ setTimeout(() => {
312
+ if (!destroyed && !view.dom.contains(ownerDocument.activeElement)) options.onBlur?.();
313
+ }, 0);
314
+ }
315
+ })
316
+ ]
317
+ });
318
+ const view = new cm.EditorView({ state, parent: options.parent });
319
+ return Object.freeze({
320
+ getValue: () => view.state.doc.toString(),
321
+ getSelection: () => view.state.selection.ranges.map(range => ({ anchor: range.anchor, head: range.head })),
322
+ focusAtPosition(position, options = {}) {
323
+ const anchor = Math.max(0, Math.min(view.state.doc.length, Number(position) || 0));
324
+ view.dispatch({ selection: { anchor }, scrollIntoView: options.scrollIntoView !== false });
325
+ view.focus();
326
+ return anchor;
327
+ },
328
+ focusAtEnd() {
329
+ view.dispatch({ selection: { anchor: view.state.doc.length }, scrollIntoView: true });
330
+ view.focus();
331
+ },
332
+ focusAtClientPoint(clientX, clientY) {
333
+ const position = Number.isFinite(clientX) && Number.isFinite(clientY)
334
+ ? view.posAtCoords({ x: clientX, y: clientY }, false)
335
+ : null;
336
+ const anchor = position == null ? view.state.doc.length : position;
337
+ view.dispatch({ selection: { anchor } });
338
+ view.focus();
339
+ return anchor;
340
+ },
341
+ destroy() {
342
+ if (destroyed) return;
343
+ destroyed = true;
344
+ view.destroy();
345
+ }
346
+ });
347
+ }
348
+
349
+ return Object.freeze({ inlineTokens, lineSyntax, layoutLines, previewModel, mount });
350
+ });
@@ -18,8 +18,9 @@
18
18
  const title = escapeHtml(options.title || "");
19
19
  const description = escapeHtml(options.description || "");
20
20
  const className = ["page-header", options.className].filter(Boolean).join(" ");
21
+ const titleMetaMarkup = String(options.titleMetaMarkup || "");
21
22
  const metaMarkup = String(options.metaMarkup || "");
22
- return `<header class="${escapeHtml(className)}"><div class="page-header-copy"><h2>${title}</h2>${description ? `<p>${description}</p>` : ""}</div>${metaMarkup ? `<div class="page-header-meta">${metaMarkup}</div>` : ""}</header>`;
23
+ return `<header class="${escapeHtml(className)}"><div class="page-header-copy"><div class="page-header-title-row"><h2>${title}</h2>${titleMetaMarkup ? `<div class="page-header-title-meta">${titleMetaMarkup}</div>` : ""}</div>${description ? `<p>${description}</p>` : ""}</div>${metaMarkup ? `<div class="page-header-meta">${metaMarkup}</div>` : ""}</header>`;
23
24
  }
24
25
 
25
26
  return Object.freeze({ escapeHtml, render });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ssobig/writer-cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ssobig/writer-cli",
9
- "version": "0.2.2",
9
+ "version": "0.3.0",
10
10
  "dependencies": {
11
11
  "@supabase/supabase-js": "2.110.9"
12
12
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssobig/writer-cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "Internal agent-safe CLI for SSOBIG WRITER manuscript and asset operations",
6
6
  "type": "commonjs",
@@ -35,4 +35,4 @@ For every production mutation, follow this sequence without shortcuts:
35
35
 
36
36
  A general request to inspect code or explain behavior does not authorize manuscript mutation. Plan and receipt files can contain confidential content; store them in a private ignored directory and never commit them.
37
37
 
38
- `E_CODE_CHANGE_REQUIRED` is a hard stop. It means the requested operation changes shared schemas, Component composition, Renderer/View behavior, identity, or another boundary outside the content CLI. Do not work around it. Report the boundary and separate the request into a reviewed code or migration task.
38
+ `E_CODE_CHANGE_REQUIRED` is a hard stop. Supported Component-management mutations are registered optional Component addition through `component plan-add`, archive/restore through `component plan-set-active`, and registered optional-field selection through `component plan-set-field`. Shared schemas, unregistered composition, reorder behavior, Renderer/View behavior, identity, and other boundaries remain outside the content CLI. Do not work around them.
@@ -2,6 +2,6 @@
2
2
 
3
3
  Project commands expose identity, versions, and reviewed project-level plans. Component commands are the supported path for authoritative manuscript values. Read first, preserve the selected project/version/Instance identity, and use a dedicated `plan-*` command for changes.
4
4
 
5
- Generic Component patching changes only an existing Instance's `data`. It cannot change Component composition, metadata, View bindings, templates, schemas, project identity, or storage paths. If the CLI returns `E_CODE_CHANGE_REQUIRED`, stop and report that a separate code or migration review is required.
5
+ Generic Component patching changes only an existing Instance's `data`. Check `component list` for `addableTemplates` and `archivedComponents`. Use `component plan-add` for a never-created registered optional Component, `component plan-set-active` for its data-preserving archive/restore toggle, and `component plan-set-field` for a registered optional field shown in the Component screen. These commands cannot edit arbitrary composition, metadata, View bindings, templates, schemas, project identity, or storage paths. If the CLI returns `E_CODE_CHANGE_REQUIRED`, stop and report that a separate code or migration review is required.
6
6
 
7
7
  Never apply a generated plan until the user has reviewed a human-readable summary and explicitly authorized the production mutation. Read back the same Component after apply.
@@ -11,7 +11,7 @@ const {
11
11
  verifyPlan,
12
12
  validateLoadedVersion
13
13
  } = require("./domain.cjs");
14
- const { applyProjectCreationPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
14
+ const { applyProjectCreationPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentActivePlan, applyComponentAddPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
15
15
  const { pointerSegments } = require("./json-patch.cjs");
16
16
  const { equalJson, sha256 } = require("./json.cjs");
17
17
  const { cliError, normalizeError } = require("./errors.cjs");
@@ -418,6 +418,8 @@ function createAgentService(options = {}) {
418
418
  if (plan.operation === "project.create") receipt = await applyProjectCreationPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
419
419
  else if (plan.operation === "project.catalog-demo") receipt = await applyCatalogDemoPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
420
420
  else if (plan.operation.startsWith("project.")) receipt = await applyProjectStatusPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
421
+ else if (plan.operation === "component.set-active") receipt = await applyComponentActivePlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
422
+ else if (plan.operation === "component.add") receipt = await applyComponentAddPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
421
423
  else if (plan.operation === "component.patch") receipt = await applyComponentPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
422
424
  else if (plan.operation.startsWith("checkpoint.")) receipt = await applyCheckpointPlan(gateway, plan, context.user.email, { appliedAt: isoNow() });
423
425
  else receipt = await applyAssetPlan(gateway, plan, context.user.email, { appliedAt: isoNow(), now: Date.now });
@@ -62,6 +62,9 @@ const COMMANDS = Object.freeze([
62
62
  command("project.plan-set-catalog-demo", "Create a reviewed catalog-demo status plan.", [project, option("enabled", "<true|false>", "Catalog demo state.", { required: true }), out], ["ssobig-writer project plan-set-catalog-demo --project <slug> --enabled true --out plan.json"], { ...SERVER, ...PLAN }),
63
63
  command("component.list", "List Components in a workspace.", [project, version], ["ssobig-writer component list --project <slug>"], SERVER),
64
64
  command("component.get", "Read one authoritative Component Instance.", [project, version, option("instance", "<id|template-id>", "Component Instance or Template identifier.", { required: true })], ["ssobig-writer component get --project <slug> --instance <template-id>"], SERVER),
65
+ command("component.plan-add", "Create a reviewed plan to add one registered optional Component.", [project, version, option("template", "<template-id>", "Registered optional Data Component Template.", { required: true }), option("instance", "<instance-id>", "Instance identifier; required for multi-instance Templates."), option("label", "<tab-label>", "User-facing Component label."), out], ["ssobig-writer component plan-add --project <slug> --template ssobig.timeline --out plan.json"], { ...SERVER, ...PLAN }),
66
+ command("component.plan-set-active", "Create a reviewed plan to toggle one optional Component.", [project, version, option("instance", "<instance-id>", "Stored optional Component Instance.", { required: true }), option("active", "<true|false>", "Use or archive the Component.", { required: true }), out], ["ssobig-writer component plan-set-active --project <slug> --instance clues --active false --out plan.json"], { ...SERVER, ...PLAN }),
67
+ command("component.plan-set-field", "Create a reviewed plan to toggle one optional Component field.", [project, version, option("instance", "<id|template-id>", "Active Component Instance or Template.", { required: true }), option("field", "<field-key>", "Registered optional field key.", { required: true }), option("enabled", "<true|false>", "Show or hide the optional field.", { required: true }), out, message], ["ssobig-writer component plan-set-field --project <slug> --instance clues --field image --enabled false --out plan.json"], { ...SERVER, ...PLAN }),
65
68
  command("component.plan-patch", "Create a reviewed Component data patch plan.", [project, version, option("instance", "<id|template-id>", "Component Instance or Template identifier.", { required: true }), option("patch", "<file>", "JSON patch document.", { required: true }), out, message], ["ssobig-writer component plan-patch --project <slug> --instance <id> --patch patch.json --out plan.json"], { ...SERVER, ...PLAN }),
66
69
  command("component.plan-layout-investigation-board", "Create a deterministic investigation-board layout plan.", [project, version, option("instance", "<id|template-id>", "Investigation board Component."), option("spec", "<layout-spec.json>", "Validated layout spec.", { required: true }), out, message], ["ssobig-writer component plan-layout-investigation-board --project <slug> --spec layout.json --out plan.json"], { ...SERVER, ...PLAN }),
67
70
  command("component.plan-replace", "Create a reviewed indexed text replacement plan.", [option("match", "<match-id>", "Search match identifier.", { required: true }), option("old", "<text>", "Expected old text.", { required: true }), option("new", "<text>", "Replacement text.", { required: true }), out, option("occurrence", "<zero-based-index>", "Occurrence index."), message], ["ssobig-writer component plan-replace --match <id> --old old --new new --out plan.json"], { ...SERVER, ...PLAN }),
@@ -5,6 +5,8 @@ const fs = require("node:fs");
5
5
  const path = require("node:path");
6
6
  const config = require("../../../config.js");
7
7
  const projectRuntime = require("../../../project-runtime.js");
8
+ const componentCatalog = require("../../../templates/mystery-v1/component-catalog-contract.js");
9
+ const componentRegistry = require("../../../templates/mystery-v1/component-registry.js").createDefaultRegistry();
8
10
  const { createAuthManager } = require("./auth.cjs");
9
11
  const { createSupabaseGateway } = require("./gateway.cjs");
10
12
  const { inspectAssetFile, assertAssetId } = require("./asset-policy.cjs");
@@ -13,6 +15,9 @@ const {
13
15
  componentContract,
14
16
  validateLoadedVersion,
15
17
  findInstance,
18
+ createComponentActivePlan,
19
+ createComponentAddPlan,
20
+ createComponentFieldPlan,
16
21
  createComponentPlan,
17
22
  createInvestigationBoardLayoutPlan,
18
23
  createAssetPlan,
@@ -25,7 +30,7 @@ const {
25
30
  verifyPlan
26
31
  } = require("./domain.cjs");
27
32
  const { loadTrueWriterCase } = require("./project-import.cjs");
28
- const { applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
33
+ const { applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentActivePlan, applyComponentAddPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
29
34
  const { snapshotSummary, diffSnapshots } = require("./checkpoint-diff.cjs");
30
35
  const { cliError, normalizeError, redact } = require("./errors.cjs");
31
36
  const { startDaemon, stopDaemon } = require("./daemon-runner.cjs");
@@ -186,10 +191,43 @@ function publicInstance(instance, includeData = false) {
186
191
  required: instance.required,
187
192
  removable: instance.removable
188
193
  };
194
+ const fields = componentRegistry.get(instance.templateId)?.fieldConfiguration || [];
195
+ if (fields.length) {
196
+ const hasSelection = Array.isArray(instance.data?.enabledOptionalFields);
197
+ const enabled = new Set(hasSelection ? instance.data.enabledOptionalFields : fields.filter(field => !field.required).map(field => field.key));
198
+ result.fieldConfiguration = fields.map(field => ({
199
+ key: field.key,
200
+ label: field.label,
201
+ description: field.description,
202
+ required: field.required,
203
+ enabled: field.required || enabled.has(field.key)
204
+ }));
205
+ }
189
206
  if (includeData) result.data = instance.data;
190
207
  return result;
191
208
  }
192
209
 
210
+ function publicComponentAvailability(validated) {
211
+ const archivedComponents = componentContract.archivedInstances(validated.componentRows);
212
+ const occupiedSingletonTemplates = new Set(validated.componentRows.map(row => String(row.template_id)));
213
+ const addableTemplates = componentCatalog.list()
214
+ .filter(template => !template.required && !template.internal && template.view
215
+ && (template.allowMultiple || !occupiedSingletonTemplates.has(template.templateId)))
216
+ .map(template => ({
217
+ templateId: template.templateId,
218
+ defaultInstanceId: template.defaultInstanceId,
219
+ tabLabel: template.tabLabel,
220
+ displayLabel: template.displayLabel,
221
+ description: template.description,
222
+ allowMultiple: template.allowMultiple,
223
+ requiresInstanceId: template.allowMultiple
224
+ }));
225
+ return {
226
+ archivedComponents: archivedComponents.map(item => publicInstance(item)),
227
+ addableTemplates
228
+ };
229
+ }
230
+
193
231
  function publicCheckpoint(row, projectId, versionNumber) {
194
232
  return normalizeCheckpoint({
195
233
  ...row,
@@ -610,13 +648,53 @@ async function execute(argv, deps) {
610
648
  return { command: "project.plan-set-catalog-demo", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, project: plan.project, target: plan.target } };
611
649
  }
612
650
 
613
- if (command === "component" && ["list", "get", "plan-patch", "plan-layout-investigation-board"].includes(subcommand)) {
651
+ if (command === "component" && ["list", "get", "plan-add", "plan-set-active", "plan-set-field", "plan-patch", "plan-layout-investigation-board"].includes(subcommand)) {
614
652
  assertAllowedOptions(options, `component.${subcommand}`);
615
653
  const projectRef = requireOption(options, "project");
616
654
  const validated = validateLoadedVersion(await gateway.loadLatestVersion(projectRef, optionalVersionOption(options)));
617
655
  const version = validated.versionNumber;
618
656
  if (subcommand === "list") {
619
- return { command: "component.list", data: { project: publicProject(validated.project, validated.instances), versionNumber: version, components: validated.instances.map(item => publicInstance(item)) } };
657
+ return { command: "component.list", data: { project: publicProject(validated.project, validated.instances), versionNumber: version, components: validated.instances.map(item => publicInstance(item)), ...publicComponentAvailability(validated) } };
658
+ }
659
+ if (subcommand === "plan-add") {
660
+ const plan = createComponentAddPlan(validated, {
661
+ templateId: requireOption(options, "template"),
662
+ instanceId: options.instance,
663
+ tabLabel: options.label
664
+ }, user.email, planOptions(deps));
665
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
666
+ return {
667
+ command: "component.plan-add",
668
+ data: {
669
+ planFile: outputPath,
670
+ planId: plan.planId,
671
+ digest: plan.digest,
672
+ operation: plan.operation,
673
+ target: plan.target,
674
+ initialData: plan.initialData,
675
+ initialDataHash: plan.initialDataHash,
676
+ composition: plan.composition
677
+ }
678
+ };
679
+ }
680
+ if (subcommand === "plan-set-active") {
681
+ const activeValue = requireOption(options, "active");
682
+ if (!new Set(["true", "false"]).has(activeValue)) throw cliError("E_USAGE", "--active는 true 또는 false여야 합니다.");
683
+ const plan = createComponentActivePlan(validated, {
684
+ instanceId: requireOption(options, "instance"),
685
+ active: activeValue === "true"
686
+ }, user.email, planOptions(deps));
687
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
688
+ return { command: "component.plan-set-active", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, target: plan.target, composition: plan.composition } };
689
+ }
690
+ if (subcommand === "plan-set-field") {
691
+ const enabledValue = requireOption(options, "enabled");
692
+ if (!new Set(["true", "false"]).has(enabledValue)) throw cliError("E_USAGE", "--enabled는 true 또는 false여야 합니다.");
693
+ const fieldPlanOptions = planOptions(deps);
694
+ if (options.message !== undefined) fieldPlanOptions.checkpointMessage = String(options.message);
695
+ const plan = createComponentFieldPlan(validated, requireOption(options, "instance"), requireOption(options, "field"), enabledValue === "true", user.email, fieldPlanOptions);
696
+ const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
697
+ return { command: "component.plan-set-field", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, checkpointMessage: plan.checkpointMessage, target: plan.target, fieldSelection: plan.fieldSelection, beforeHash: plan.beforeHash, afterHash: plan.afterHash } };
620
698
  }
621
699
  if (subcommand === "plan-layout-investigation-board") {
622
700
  const spec = readJson(requireOption(options, "spec"), "추리 보드 layout spec", deps.fs).value;
@@ -783,6 +861,10 @@ async function execute(argv, deps) {
783
861
  receipt = await applyCatalogDemoPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
784
862
  } else if (plan.operation.startsWith("project.")) {
785
863
  receipt = await applyProjectStatusPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
864
+ } else if (plan.operation === "component.set-active") {
865
+ receipt = await applyComponentActivePlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
866
+ } else if (plan.operation === "component.add") {
867
+ receipt = await applyComponentAddPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
786
868
  } else if (plan.operation === "component.patch") {
787
869
  receipt = await applyComponentPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
788
870
  } else if (plan.operation.startsWith("checkpoint.")) {