openwriter 0.5.5 → 0.6.1

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.
@@ -8,7 +8,7 @@ import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
9
  import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
10
10
  import { applyTextEditsToNode } from './text-edit.js';
11
- import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
11
+ import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
12
12
  import { snapshotIfNeeded, ensureDocId } from './versions.js';
13
13
  const DEFAULT_DOC = {
14
14
  type: 'doc',
@@ -27,18 +27,18 @@ const listeners = new Set();
27
27
  // ============================================================================
28
28
  // EXTERNAL DOCUMENT REGISTRY
29
29
  // ============================================================================
30
- const EXTERNAL_DOCS_FILE = join(DATA_DIR, 'external-docs.json');
30
+ function getExternalDocsFile() { return join(getDataDir(), 'external-docs.json'); }
31
31
  const externalDocs = new Set();
32
32
  function persistExternalDocs() {
33
33
  try {
34
- atomicWriteFileSync(EXTERNAL_DOCS_FILE, JSON.stringify([...externalDocs]));
34
+ atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
35
35
  }
36
36
  catch { /* best-effort */ }
37
37
  }
38
38
  function loadExternalDocs() {
39
39
  try {
40
- if (existsSync(EXTERNAL_DOCS_FILE)) {
41
- const paths = JSON.parse(readFileSync(EXTERNAL_DOCS_FILE, 'utf-8'));
40
+ if (existsSync(getExternalDocsFile())) {
41
+ const paths = JSON.parse(readFileSync(getExternalDocsFile(), 'utf-8'));
42
42
  for (const p of paths) {
43
43
  if (existsSync(p))
44
44
  externalDocs.add(p);
@@ -125,34 +125,65 @@ export function getPendingChangeCount() {
125
125
  }
126
126
  export function getNodesByIds(ids) {
127
127
  const result = [];
128
+ const idSet = new Set(ids);
128
129
  function scan(nodes) {
129
130
  if (!nodes)
130
131
  return;
131
- for (const node of nodes) {
132
- if (node.attrs?.id && ids.includes(node.attrs.id)) {
132
+ for (let i = 0; i < nodes.length; i++) {
133
+ const node = nodes[i];
134
+ if (node.attrs?.id && idSet.has(node.attrs.id)) {
133
135
  result.push(node);
136
+ // Preserve horizontalRule separators between matched nodes (thread structure)
137
+ if (i + 1 < nodes.length && nodes[i + 1].type === 'horizontalRule') {
138
+ result.push(nodes[i + 1]);
139
+ }
134
140
  }
135
141
  if (node.content)
136
142
  scan(node.content);
137
143
  }
138
144
  }
139
145
  scan(state.document.content);
146
+ // Remove trailing horizontalRule (don't end with separator)
147
+ if (result.length > 0 && result[result.length - 1].type === 'horizontalRule') {
148
+ result.pop();
149
+ }
140
150
  return result;
141
151
  }
142
152
  export function getMetadata() {
143
153
  return state.metadata;
144
154
  }
145
155
  export function setMetadata(updates) {
156
+ // Prevent blogContext contamination: only allow blogContext writes if
157
+ // the incoming update has active:true OR the doc already has active blogContext
158
+ if (updates.blogContext && !updates.blogContext.active && !state.metadata?.blogContext?.active) {
159
+ delete updates.blogContext;
160
+ if (Object.keys(updates).length === 0)
161
+ return;
162
+ }
163
+ // Same guard for newsletterContext
164
+ if (updates.newsletterContext && !updates.newsletterContext.active && !state.metadata?.newsletterContext?.active) {
165
+ delete updates.newsletterContext;
166
+ if (Object.keys(updates).length === 0)
167
+ return;
168
+ }
169
+ // Deep-merge known context objects so partial updates preserve essential fields (active, format, etc.)
170
+ const CONTEXT_KEYS = ['blogContext', 'newsletterContext', 'articleContext', 'tweetContext', 'linkedinContext'];
171
+ for (const key of CONTEXT_KEYS) {
172
+ if (updates[key] && typeof updates[key] === 'object' && state.metadata?.[key] && typeof state.metadata[key] === 'object') {
173
+ updates[key] = { ...state.metadata[key], ...updates[key] };
174
+ }
175
+ }
146
176
  state.metadata = { ...state.metadata, ...updates };
147
177
  if (updates.title)
148
178
  state.title = updates.title;
149
- // Auto-tag: tweetContext / articleContext ↔ "x" + mode tag
150
- for (const key of ['tweetContext', 'articleContext']) {
151
- if (key in updates) {
152
- const filename = state.filePath
153
- ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
154
- : '';
155
- if (filename) {
179
+ // Auto-tag based on context metadata
180
+ const filename = state.filePath
181
+ ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
182
+ : '';
183
+ if (filename) {
184
+ // tweetContext / articleContext → "x" + mode tag
185
+ for (const key of ['tweetContext', 'articleContext']) {
186
+ if (key in updates) {
156
187
  if (updates[key]) {
157
188
  addDocTag(filename, 'x');
158
189
  const mode = updates[key]?.mode || (key === 'articleContext' ? 'article' : undefined);
@@ -164,6 +195,20 @@ export function setMetadata(updates) {
164
195
  }
165
196
  }
166
197
  }
198
+ // blogContext / linkedinContext / newsletterContext → single tag
199
+ const contextTags = {
200
+ blogContext: 'blog',
201
+ linkedinContext: 'linkedin',
202
+ newsletterContext: 'newsletter',
203
+ };
204
+ for (const [key, tag] of Object.entries(contextTags)) {
205
+ if (key in updates) {
206
+ if (updates[key])
207
+ addDocTag(filename, tag);
208
+ else
209
+ removeDocTag(filename, tag);
210
+ }
211
+ }
167
212
  }
168
213
  }
169
214
  export function getStatus() {
@@ -187,61 +232,85 @@ export function updateDocument(doc) {
187
232
  console.error(`[State] BLOCKED destructive updateDocument: ${incomingNodes} nodes would replace ${currentNodes} nodes`);
188
233
  return;
189
234
  }
190
- // Preserve pending attrs that the browser doesn't track in its document model.
191
- // Browser manages pending state as decorations, so its doc-updates lack pendingStatus.
192
- // Without this, browser overwrites server state and pending info is lost on next save.
193
- if (hasPendingChanges()) {
235
+ // Preserve pending attrs from server state incoming browser doc.
236
+ // The browser's PendingAttributes extension tracks pendingStatus in the TipTap
237
+ // document model, but transferPendingAttrs provides a safety net in case the
238
+ // browser's doc-update lost them (e.g. timing edge case, stale transaction).
239
+ const serverHadPending = hasPendingChanges();
240
+ if (serverHadPending) {
194
241
  transferPendingAttrs(state.document, doc);
195
242
  }
196
243
  state.document = doc;
197
244
  state.lastModified = new Date();
245
+ // Validate: if server had pending changes, verify they survived the transfer
246
+ if (serverHadPending && !hasPendingChanges()) {
247
+ console.error('[State] WARNING: pending changes lost after updateDocument — browser doc-update overwrote pending attrs');
248
+ }
198
249
  }
199
250
  /**
200
251
  * Transfer pending attrs from source doc to target doc by matching node IDs.
201
- * Copies pendingStatus, pendingOriginalContent, and pendingTextEdits.
252
+ * Copies all pending-related attrs: status, original content, group ID,
253
+ * selection ranges, and text edits.
202
254
  */
203
255
  function transferPendingAttrs(source, target) {
204
- // Build a map of nodeId → pending attrs from source
256
+ // Build a map of nodeId → all pending attrs from source
205
257
  const pendingMap = new Map();
206
258
  function collectPending(nodes) {
207
259
  if (!nodes)
208
260
  return;
209
261
  for (const node of nodes) {
210
262
  if (node.attrs?.pendingStatus && node.attrs?.id) {
211
- pendingMap.set(node.attrs.id, {
212
- status: node.attrs.pendingStatus,
213
- original: node.attrs.pendingOriginalContent,
214
- textEdits: node.attrs.pendingTextEdits,
215
- });
263
+ const entry = {
264
+ pendingStatus: node.attrs.pendingStatus,
265
+ };
266
+ // Copy all pending-related attrs if present
267
+ if (node.attrs.pendingOriginalContent != null)
268
+ entry.pendingOriginalContent = node.attrs.pendingOriginalContent;
269
+ if (node.attrs.pendingTextEdits != null)
270
+ entry.pendingTextEdits = node.attrs.pendingTextEdits;
271
+ if (node.attrs.pendingGroupId != null)
272
+ entry.pendingGroupId = node.attrs.pendingGroupId;
273
+ if (node.attrs.pendingSelectionFrom != null)
274
+ entry.pendingSelectionFrom = node.attrs.pendingSelectionFrom;
275
+ if (node.attrs.pendingSelectionTo != null)
276
+ entry.pendingSelectionTo = node.attrs.pendingSelectionTo;
277
+ if (node.attrs.pendingOriginalFrom != null)
278
+ entry.pendingOriginalFrom = node.attrs.pendingOriginalFrom;
279
+ if (node.attrs.pendingOriginalTo != null)
280
+ entry.pendingOriginalTo = node.attrs.pendingOriginalTo;
281
+ pendingMap.set(node.attrs.id, entry);
216
282
  }
217
283
  if (node.content)
218
284
  collectPending(node.content);
219
285
  }
220
286
  }
221
287
  collectPending(source.content);
288
+ if (pendingMap.size === 0)
289
+ return;
222
290
  // Apply pending attrs to matching nodes in target
291
+ let transferred = 0;
223
292
  function applyPending(nodes) {
224
293
  if (!nodes)
225
294
  return;
226
295
  for (const node of nodes) {
227
296
  if (node.attrs?.id && pendingMap.has(node.attrs.id)) {
228
297
  const p = pendingMap.get(node.attrs.id);
229
- node.attrs.pendingStatus = p.status;
230
- if (p.original)
231
- node.attrs.pendingOriginalContent = p.original;
232
- if (p.textEdits)
233
- node.attrs.pendingTextEdits = p.textEdits;
298
+ Object.assign(node.attrs, p);
299
+ transferred++;
234
300
  }
235
301
  if (node.content)
236
302
  applyPending(node.content);
237
303
  }
238
304
  }
239
305
  applyPending(target.content);
306
+ if (transferred < pendingMap.size) {
307
+ console.warn(`[State] transferPendingAttrs: ${transferred}/${pendingMap.size} nodes matched — ${pendingMap.size - transferred} pending nodes missing in target doc`);
308
+ }
240
309
  }
241
310
  // ============================================================================
242
311
  // AGENT WRITE LOCK
243
312
  // ============================================================================
244
- const AGENT_LOCK_MS = 1500; // Block browser doc-updates for 1.5s after agent write
313
+ const AGENT_LOCK_MS = 3000; // Block browser doc-updates for 3s after agent write
245
314
  let lastAgentWriteTime = 0;
246
315
  /** Set the agent write lock (called after agent changes). */
247
316
  export function setAgentLock() {
@@ -514,10 +583,10 @@ export function setPendingCacheEntry(filename, count) {
514
583
  function populatePendingCache() {
515
584
  pendingDocCache.clear();
516
585
  try {
517
- const files = readdirSync(DATA_DIR).filter((f) => f.endsWith('.md'));
586
+ const files = readdirSync(getDataDir()).filter((f) => f.endsWith('.md'));
518
587
  for (const f of files) {
519
588
  try {
520
- const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
589
+ const raw = readFileSync(join(getDataDir(), f), 'utf-8');
521
590
  const { data } = matter(raw);
522
591
  if (data.pending && Object.keys(data.pending).length > 0) {
523
592
  pendingDocCache.set(f, Object.keys(data.pending).length);
@@ -602,6 +671,21 @@ function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
602
671
  fileMtime,
603
672
  });
604
673
  }
674
+ /** Reset all in-memory caches. Called on profile switch. */
675
+ export function clearAllCaches() {
676
+ docCache.clear();
677
+ pendingDocCache.clear();
678
+ externalDocs.clear();
679
+ state = {
680
+ document: DEFAULT_DOC,
681
+ title: 'Untitled',
682
+ metadata: { title: 'Untitled' },
683
+ filePath: '',
684
+ isTemp: true,
685
+ lastModified: new Date(),
686
+ docId: '',
687
+ };
688
+ }
605
689
  // ============================================================================
606
690
  // PENDING DOCUMENT STORE OPERATIONS
607
691
  // ============================================================================
@@ -670,7 +754,7 @@ export function getPendingDocInfo() {
670
754
  const stale = [];
671
755
  for (const [filename, count] of pendingDocCache) {
672
756
  // Validate file still exists on disk (prunes ghost entries after server restart)
673
- const filePath = isExternalDoc(filename) ? filename : join(DATA_DIR, filename);
757
+ const filePath = isExternalDoc(filename) ? filename : join(getDataDir(), filename);
674
758
  if (!existsSync(filePath)) {
675
759
  stale.push(filename);
676
760
  continue;
@@ -742,10 +826,10 @@ export function load() {
742
826
  // Clean up empty temp files from previous sessions
743
827
  cleanupEmptyTempFiles();
744
828
  // Find most recently modified .md file
745
- const files = readdirSync(DATA_DIR)
829
+ const files = readdirSync(getDataDir())
746
830
  .filter((f) => f.endsWith('.md'))
747
831
  .map((f) => {
748
- const fullPath = join(DATA_DIR, f);
832
+ const fullPath = join(getDataDir(), f);
749
833
  const stat = statSync(fullPath);
750
834
  return { name: f, path: fullPath, mtime: stat.mtimeMs };
751
835
  })
@@ -795,11 +879,11 @@ export function load() {
795
879
  /** Migrate legacy .sw.json files to .md format */
796
880
  function migrateSwJsonFiles() {
797
881
  try {
798
- const jsonFiles = readdirSync(DATA_DIR).filter((f) => f.endsWith('.sw.json'));
882
+ const jsonFiles = readdirSync(getDataDir()).filter((f) => f.endsWith('.sw.json'));
799
883
  for (const f of jsonFiles) {
800
- const jsonPath = join(DATA_DIR, f);
884
+ const jsonPath = join(getDataDir(), f);
801
885
  const mdName = f.replace(/\.sw\.json$/, '.md');
802
- const mdPath = join(DATA_DIR, mdName);
886
+ const mdPath = join(getDataDir(), mdName);
803
887
  // Skip if .md already exists
804
888
  if (existsSync(mdPath)) {
805
889
  try {
@@ -834,7 +918,7 @@ function migrateSwJsonFiles() {
834
918
  function getWorkspaceReferencedFiles() {
835
919
  const referenced = new Set();
836
920
  try {
837
- const wsDir = join(DATA_DIR, '_workspaces');
921
+ const wsDir = join(getDataDir(), '_workspaces');
838
922
  if (!existsSync(wsDir))
839
923
  return referenced;
840
924
  const manifests = readdirSync(wsDir).filter((f) => f.endsWith('.json'));
@@ -871,12 +955,12 @@ function getWorkspaceReferencedFiles() {
871
955
  function cleanupEmptyTempFiles() {
872
956
  try {
873
957
  const wsRefs = getWorkspaceReferencedFiles();
874
- const files = readdirSync(DATA_DIR).filter((f) => f.startsWith(TEMP_PREFIX) && f.endsWith('.md'));
958
+ const files = readdirSync(getDataDir()).filter((f) => f.startsWith(TEMP_PREFIX) && f.endsWith('.md'));
875
959
  for (const f of files) {
876
960
  // Never delete temp files that are referenced by a workspace
877
961
  if (wsRefs.has(f))
878
962
  continue;
879
- const fullPath = join(DATA_DIR, f);
963
+ const fullPath = join(getDataDir(), f);
880
964
  try {
881
965
  const raw = readFileSync(fullPath, 'utf-8');
882
966
  const parsed = markdownToTiptap(raw);
@@ -6,7 +6,7 @@
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, statSync } from 'fs';
7
7
  import { join } from 'path';
8
8
  import { createHash } from 'crypto';
9
- import { VERSIONS_DIR } from './helpers.js';
9
+ import { getVersionsDir } from './helpers.js';
10
10
  import { markdownToTiptap } from './markdown.js';
11
11
  // ============================================================================
12
12
  // DEDUP STATE
@@ -16,6 +16,10 @@ const MIN_INTERVAL_MS = 30_000; // 30 seconds between snapshots of same content
16
16
  function contentHash(markdown) {
17
17
  return createHash('sha256').update(markdown).digest('hex').slice(0, 16);
18
18
  }
19
+ /** Clear dedup state. Called on profile switch. */
20
+ export function clearVersionsCache() {
21
+ lastSnapshot.clear();
22
+ }
19
23
  // ============================================================================
20
24
  // DOC ID
21
25
  // ============================================================================
@@ -38,7 +42,7 @@ export function ensureDocId(metadata) {
38
42
  // SNAPSHOT
39
43
  // ============================================================================
40
44
  function docDir(docId) {
41
- return join(VERSIONS_DIR, docId);
45
+ return join(getVersionsDir(), docId);
42
46
  }
43
47
  function ensureDocDir(docId) {
44
48
  const dir = docDir(docId);
@@ -8,16 +8,16 @@ import { join } from 'path';
8
8
  import { randomUUID } from 'crypto';
9
9
  import matter from 'gray-matter';
10
10
  import trash from 'trash';
11
- import { WORKSPACES_DIR, ensureWorkspacesDir, sanitizeFilename, resolveDocPath, isExternalDoc } from './helpers.js';
11
+ import { getWorkspacesDir, ensureWorkspacesDir, sanitizeFilename, resolveDocPath, isExternalDoc } from './helpers.js';
12
12
  import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
13
- const ORDER_FILE = join(WORKSPACES_DIR, '_order.json');
13
+ function getOrderFile() { return join(getWorkspacesDir(), '_order.json'); }
14
14
  import { isV1, migrateV1toV2 } from './workspace-types.js';
15
15
  import { addDocToContainer, addContainer as addContainerToTree, removeNode, moveNode, reorderNode, findContainer, collectAllFiles, countDocs, findDocNode } from './workspace-tree.js';
16
16
  // ============================================================================
17
17
  // INTERNAL HELPERS
18
18
  // ============================================================================
19
19
  function workspacePath(filename) {
20
- return join(WORKSPACES_DIR, filename);
20
+ return join(getWorkspacesDir(), filename);
21
21
  }
22
22
  /**
23
23
  * Migrate workspace-level tags into document frontmatter.
@@ -75,16 +75,16 @@ function writeWorkspace(filename, workspace) {
75
75
  }
76
76
  function readOrder() {
77
77
  try {
78
- if (!existsSync(ORDER_FILE))
78
+ if (!existsSync(getOrderFile()))
79
79
  return [];
80
- return JSON.parse(readFileSync(ORDER_FILE, 'utf-8'));
80
+ return JSON.parse(readFileSync(getOrderFile(), 'utf-8'));
81
81
  }
82
82
  catch {
83
83
  return [];
84
84
  }
85
85
  }
86
86
  function writeOrder(order) {
87
- writeFileSync(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8');
87
+ writeFileSync(getOrderFile(), JSON.stringify(order, null, 2), 'utf-8');
88
88
  }
89
89
  function readDocFrontmatter(filename) {
90
90
  try {
@@ -104,7 +104,7 @@ function readDocFrontmatter(filename) {
104
104
  // ============================================================================
105
105
  export function listWorkspaces() {
106
106
  ensureWorkspacesDir();
107
- const files = readdirSync(WORKSPACES_DIR).filter((f) => f.endsWith('.json') && f !== '_order.json');
107
+ const files = readdirSync(getWorkspacesDir()).filter((f) => f.endsWith('.json') && f !== '_order.json');
108
108
  const infos = files.map((f) => {
109
109
  try {
110
110
  const ws = readWorkspace(f);
package/dist/server/ws.js CHANGED
@@ -160,6 +160,12 @@ export function setupWebSocket(server) {
160
160
  title = 'Quote Tweet';
161
161
  else if (tmpl === 'article')
162
162
  title = 'Article';
163
+ else if (tmpl === 'linkedin')
164
+ title = 'LinkedIn Post';
165
+ else if (tmpl === 'newsletter')
166
+ title = 'Newsletter';
167
+ else if (tmpl === 'blog')
168
+ title = 'Blog Post';
163
169
  const result = createDocument(title);
164
170
  // Set template-specific metadata
165
171
  if (tmpl === 'tweet') {
@@ -174,6 +180,15 @@ export function setupWebSocket(server) {
174
180
  else if (tmpl === 'article') {
175
181
  setMetadata({ articleContext: { active: true } });
176
182
  }
183
+ else if (tmpl === 'linkedin') {
184
+ setMetadata({ linkedinContext: { active: true } });
185
+ }
186
+ else if (tmpl === 'newsletter') {
187
+ setMetadata({ newsletterContext: { active: true } });
188
+ }
189
+ else if (tmpl === 'blog') {
190
+ setMetadata({ blogContext: { active: true } });
191
+ }
177
192
  save();
178
193
  broadcastDocumentSwitched(result.document, getTitle(), result.filename, getMetadata());
179
194
  broadcastDocumentsChanged();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.5.5",
3
+ "version": "0.6.1",
4
4
  "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/skill/SKILL.md CHANGED
@@ -3,9 +3,10 @@ name: openwriter
3
3
  description: |
4
4
  OpenWriter — the writing surface for AI agents. A markdown-native rich text
5
5
  editor where agents write via MCP tools and users accept or reject changes
6
- in-browser. 31 MCP tools for document editing, multi-doc workspaces, and
7
- organization. Tweet compose mode for drafting replies/QTs with pixel-accurate
8
- X/Twitter UI. Plain .md files on disk no database, no lock-in.
6
+ in-browser. 36 core MCP tools for document editing, multi-doc workspaces,
7
+ and organization, plus 21 publish platform tools for newsletter, social
8
+ posting, and scheduling. Tweet compose mode for drafting replies/QTs with
9
+ pixel-accurate X/Twitter UI. Plain .md files on disk — no database, no lock-in.
9
10
 
10
11
  Use when user says: "open writer", "openwriter", "write in openwriter",
11
12
  "edit my document", "review my writing", "check the pad", "write me a doc",
@@ -14,7 +15,7 @@ description: |
14
15
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
15
16
  metadata:
16
17
  author: travsteward
17
- version: "0.1.1"
18
+ version: "0.2.0"
18
19
  repository: https://github.com/travsteward/openwriter
19
20
  license: MIT
20
21
  ---
@@ -35,30 +36,31 @@ Check whether the `openwriter` MCP tools are available (e.g. `read_pad`, `write_
35
36
 
36
37
  ### MCP tools ARE available (ready to use)
37
38
 
38
- The user already has OpenWriter configured — either they ran `npx openwriter install-skill` (which installed this skill) and added the MCP server, or they set it up manually. You're good to go.
39
+ The user already has OpenWriter configured. You're good to go.
39
40
 
40
41
  **First action:** Share the browser URL:
41
42
  > OpenWriter is at **http://localhost:5050** — open it in your browser to see and review changes.
42
43
 
43
44
  Skip to [Writing Strategy](#writing-strategy) below.
44
45
 
45
- ### MCP tools are NOT available (skill-first install)
46
+ ### MCP tools are NOT available (needs setup)
46
47
 
47
- The user installed this skill from a directory but hasn't set up the MCP server yet. OpenWriter needs an MCP server to provide the 31 editing tools.
48
+ The user has this skill but hasn't set up the MCP server yet. One command does everything:
48
49
 
49
- **Step 1:** Tell the user to install globally and add the MCP server:
50
+ ```bash
51
+ npx openwriter install-skill
52
+ ```
53
+
54
+ This installs openwriter globally, configures the MCP server for Claude Code, and copies this skill — all in one step. After it finishes, the user just needs to restart their Claude Code session.
55
+
56
+ **Fallback (if the command above fails):** Do it manually:
50
57
 
51
58
  ```bash
52
- # Install globally for instant startup (no npx resolution delay)
53
59
  npm install -g openwriter
54
-
55
- # Add the OpenWriter MCP server to Claude Code
56
60
  claude mcp add -s user openwriter -- openwriter --no-open
57
61
  ```
58
62
 
59
- Then restart the Claude Code session. The MCP tools become available on next launch.
60
-
61
- **Step 2 (if the user can't run the command above):** Edit `~/.claude.json` directly. Add `openwriter` as the **first entry** in the `mcpServers` object — MCP servers load sequentially, so first in config = first to load:
63
+ If `claude mcp add` can't run (e.g. nested session error), edit `~/.claude.json` directly. Add `openwriter` as the **first entry** in `mcpServers`:
62
64
 
63
65
  ```json
64
66
  {
@@ -71,14 +73,10 @@ Then restart the Claude Code session. The MCP tools become available on next lau
71
73
  }
72
74
  ```
73
75
 
74
- **Why first?** Claude Code loads MCP servers sequentially in config order. If `openwriter` is last, it waits for every other server to finish connecting first. Putting it first makes it available instantly.
75
-
76
- After editing, tell the user:
76
+ After setup, tell the user:
77
77
  1. Restart your Claude Code session (MCP servers load on startup)
78
78
  2. Open http://localhost:5050 in your browser
79
79
 
80
- **Note:** You cannot run `claude mcp add` from inside a session (nested session error). That's why we edit the JSON directly when configuring from within Claude Code. Also, `claude mcp add` appends to the end — always verify the entry is first after adding.
81
-
82
80
  ## Document Identity: Titles vs DocIds
83
81
 
84
82
  Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its YAML frontmatter. Titles are for human communication and agent reasoning. DocIds are for agent action.
@@ -87,7 +85,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
87
85
  - All doc-targeting tools take `docId` as their parameter (not filename)
88
86
  - Two documents can have the same title — the docId disambiguates
89
87
 
90
- ## MCP Tools Reference (32 tools)
88
+ ## MCP Tools Reference (36 core + 21 publish platform)
91
89
 
92
90
  ### Document Operations
93
91
 
@@ -110,6 +108,8 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
110
108
  | `create_document` | `title?`, ... | Create a new empty document — response includes docId |
111
109
  | `open_file` | `path` | Open an existing .md file from any location on disk |
112
110
  | `delete_document` | `docId` | Delete a document file (moves to OS trash, recoverable) |
111
+ | `archive_document` | `docId` | Archive a document (hides from sidebar, keeps on disk) |
112
+ | `unarchive_document` | `docId` | Restore an archived document back to the sidebar |
113
113
 
114
114
  ### Import
115
115
 
@@ -132,11 +132,11 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
132
132
 
133
133
  | Tool | Description |
134
134
  |------|-------------|
135
- | `add_doc` | Add a document to a workspace (optional container placement) |
136
135
  | `create_container` | Create a folder inside a workspace (max depth: 3) |
137
- | `tag_doc` | Add a tag to a document (stored in doc frontmatter) |
138
- | `untag_doc` | Remove a tag from a document (stored in doc frontmatter) |
139
- | `move_doc` | Move a document to a different container or root level |
136
+ | `delete_container` | Delete a container from a workspace (doc files stay on disk) |
137
+ | `tag_doc` | Add a tag to a document by docId (stored in doc frontmatter) |
138
+ | `untag_doc` | Remove a tag from a document by docId |
139
+ | `move_doc` | Add a doc to a workspace, or move it within the workspace (by docId) |
140
140
  | `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
141
141
 
142
142
  ### Agent Marks
@@ -157,6 +157,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
157
157
  | Tool | Description |
158
158
  |------|-------------|
159
159
  | `generate_image` | Generate an image via Gemini Nano Banana 2 — optionally set as article cover (requires GEMINI_API_KEY) |
160
+ | `insert_image` | Insert an image into the document at a specific position (from URL or local path) |
160
161
 
161
162
  ### Version Management
162
163
 
@@ -215,7 +216,7 @@ create_document({
215
216
  - **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
216
217
  - Both are optional — omit for standalone docs outside any workspace.
217
218
 
218
- This eliminates the need for separate `create_workspace`, `create_container`, and `add_doc` calls when building up a workspace.
219
+ This eliminates the need for separate `create_workspace`, `create_container`, and `move_doc` calls when building up a workspace.
219
220
 
220
221
  ## Workflow
221
222
 
@@ -370,6 +371,86 @@ Users set their X handle by clicking the avatar circle in the compose area. The
370
371
  5. **Respect pending changes.** If `pendingChanges > 0`, wait for the user
371
372
  6. **Watch for the review signal.** When `userSignaledReview` is true, the user is asking for your input — reading status clears it (one-shot)
372
373
 
374
+ ## Publish Platform (21 tools)
375
+
376
+ Requires authentication via `request_login_code` + `verify_login`. All publish tools are provided by the `@openwriter/plugin-publish` plugin.
377
+
378
+ ### Authentication
379
+
380
+ | Tool | Description |
381
+ |------|-------------|
382
+ | `request_login_code` | Send a 6-digit login code to an email address (signup or key recovery) |
383
+ | `verify_login` | Verify the code → API key issued + auto-saved to plugin config |
384
+
385
+ ```
386
+ 1. request_login_code({ email: "user@example.com" }) → 6-digit code sent to email
387
+ 2. User reads code from inbox (or agent reads via gmail skill)
388
+ 3. verify_login({ email: "user@example.com", code: "123456" })
389
+ → API key issued + auto-saved to plugin config
390
+ ```
391
+
392
+ - **Agents with email access** (e.g. gmail skill) can fully automate this — zero user involvement
393
+ - **Key recovery:** Same flow. Old keys are automatically revoked when a new one is issued
394
+ - Codes expire in 10 minutes, max 3 attempts per code, rate-limited to 1 request per 60 seconds
395
+
396
+ ### Custom Domains
397
+
398
+ | Tool | Description |
399
+ |------|-------------|
400
+ | `setup_custom_domain` | Configure a custom domain + from_email for newsletter sending |
401
+ | `check_domain_status` | Check DNS and sender verification status |
402
+ | `resend_domain_verification` | Re-send the SendGrid sender verification email |
403
+
404
+ **Setup flow:**
405
+ 1. Call `setup_custom_domain` with domain + from_email
406
+ 2. Cloudflare domains: DNS auto-added. Non-CF: show DNS records for manual setup
407
+ 3. User checks email for SendGrid sender verification
408
+ 4. Wait ~30-60s, call `check_domain_status` to confirm
409
+ 5. Both `dns_verified` + `sender_verified` = domain ready
410
+
411
+ ### Social Posting & Connections
412
+
413
+ | Tool | Description |
414
+ |------|-------------|
415
+ | `list_connections` | List connected social accounts (X, LinkedIn, etc.) |
416
+ | `post_to_x` | Post current document to X/Twitter |
417
+ | `post_to_linkedin` | Post current document to LinkedIn |
418
+
419
+ ### Scheduling
420
+
421
+ | Tool | Description |
422
+ |------|-------------|
423
+ | `schedule_post` | Schedule a post for a specific time |
424
+ | `list_schedule` | List all scheduled posts |
425
+ | `manage_schedule` | Update or cancel a scheduled post |
426
+ | `list_slots` | List recurring time slots |
427
+ | `create_slot` | Create a recurring posting slot |
428
+ | `edit_slot` | Modify an existing slot |
429
+ | `delete_slot` | Remove a recurring slot |
430
+
431
+ ### Newsletter
432
+
433
+ | Tool | Key Params | Description |
434
+ |------|-----------|-------------|
435
+ | `send_newsletter` | `subject?`, `format?`, `test_email?`, `subscriber_ids?`, `exclude_issue_id?` | Send current document as newsletter to all subscribers, a subset, or a test address |
436
+ | `list_subscribers` | `limit?`, `offset?` | List newsletter subscribers with IDs, emails, names |
437
+ | `add_subscriber` | `email`, `name?` | Add a single subscriber |
438
+ | `import_subscribers` | `file?`, `csv_text?` | Bulk import from CSV (auto-detects ConvertKit, Mailchimp, Substack, Beehiiv formats) |
439
+ | `list_newsletter_issues` | `limit?` | List past sends with open/click stats — returns issue IDs |
440
+ | `get_newsletter_analytics` | `issue_id` | Detailed drill-down: delivery stats, per-subscriber events, recipient list |
441
+
442
+ **Subscriber selection** — `send_newsletter` supports targeting:
443
+ - **All subscribers** (default) — omit both params
444
+ - **Specific subscribers** — pass `subscriber_ids: ["id1", "id2"]` (use `list_subscribers` for IDs)
445
+ - **Send to remaining** — pass `exclude_issue_id: "..."` to send to everyone who did NOT receive that issue (use `list_newsletter_issues` for issue IDs)
446
+
447
+ **Analytics workflow:**
448
+ ```
449
+ 1. list_newsletter_issues() → see past sends with open/click counts
450
+ 2. get_newsletter_analytics({ issue_id }) → drill into a specific send
451
+ → returns: stats (delivered, opens, clicks, bounces), per-subscriber events, recipient list
452
+ ```
453
+
373
454
  ## Troubleshooting
374
455
 
375
456
  **MCP tools not available** — The OpenWriter MCP server isn't configured yet. Follow the [setup instructions](#mcp-tools-are-not-available-skill-first-install) above. After adding the MCP config, the user must restart their Claude Code session.