openwriter 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/client/assets/index-De-jpZgc.css +1 -0
- package/dist/client/assets/index-FOERHzGc.js +205 -0
- package/dist/client/index.html +2 -2
- package/dist/server/compact.js +1 -2
- package/dist/server/documents.js +6 -5
- package/dist/server/index.js +88 -30
- package/dist/server/link-routes.js +6 -5
- package/dist/server/mcp.js +148 -76
- package/dist/server/plugin-discovery.js +64 -0
- package/dist/server/plugin-manager.js +155 -0
- package/dist/server/state.js +197 -26
- package/dist/server/workspace-routes.js +3 -24
- package/dist/server/workspace-tags.js +0 -3
- package/dist/server/workspace-types.js +0 -8
- package/dist/server/workspaces.js +128 -38
- package/dist/server/ws.js +63 -12
- package/package.json +3 -2
- package/skill/SKILL.md +112 -32
- package/dist/client/assets/index-DNJs7lC-.js +0 -205
- package/dist/client/assets/index-WweytMO1.css +0 -1
|
@@ -1,30 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workspace manifest CRUD for OpenWriter v2.
|
|
3
|
-
* Unified container model: containers
|
|
3
|
+
* Unified container model: containers hold docs in an ordered tree.
|
|
4
4
|
* Manifests live in ~/.openwriter/_workspaces/*.json.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync,
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import matter from 'gray-matter';
|
|
10
|
-
import
|
|
10
|
+
import trash from 'trash';
|
|
11
|
+
import { WORKSPACES_DIR, ensureWorkspacesDir, sanitizeFilename, resolveDocPath, isExternalDoc } from './helpers.js';
|
|
12
|
+
import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
|
|
11
13
|
const ORDER_FILE = join(WORKSPACES_DIR, '_order.json');
|
|
12
14
|
import { isV1, migrateV1toV2 } from './workspace-types.js';
|
|
13
15
|
import { addDocToContainer, addContainer as addContainerToTree, removeNode, moveNode, reorderNode, findContainer, collectAllFiles, countDocs, findDocNode } from './workspace-tree.js';
|
|
14
|
-
import { addTag, removeTag, removeFileFromAllTags, listTagsForFile } from './workspace-tags.js';
|
|
15
16
|
// ============================================================================
|
|
16
17
|
// INTERNAL HELPERS
|
|
17
18
|
// ============================================================================
|
|
18
19
|
function workspacePath(filename) {
|
|
19
20
|
return join(WORKSPACES_DIR, filename);
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Migrate workspace-level tags into document frontmatter.
|
|
24
|
+
* Old format: workspace.tags = { "tag1": ["file1.md", "file2.md"], ... }
|
|
25
|
+
* New format: each doc file has `tags: ["tag1", ...]` in its frontmatter.
|
|
26
|
+
* Returns true if migration occurred and the workspace was modified.
|
|
27
|
+
*/
|
|
28
|
+
function migrateWorkspaceTags(ws) {
|
|
29
|
+
if (!ws.tags || typeof ws.tags !== 'object')
|
|
30
|
+
return false;
|
|
31
|
+
const tagMap = ws.tags;
|
|
32
|
+
const entries = Object.entries(tagMap);
|
|
33
|
+
if (entries.length === 0) {
|
|
34
|
+
delete ws.tags;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
for (const [tagName, files] of entries) {
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
try {
|
|
40
|
+
const targetPath = resolveDocPath(file);
|
|
41
|
+
if (!existsSync(targetPath))
|
|
42
|
+
continue;
|
|
43
|
+
const raw = readFileSync(targetPath, 'utf-8');
|
|
44
|
+
const parsed = markdownToTiptap(raw);
|
|
45
|
+
const tags = Array.isArray(parsed.metadata.tags) ? [...parsed.metadata.tags] : [];
|
|
46
|
+
if (!tags.includes(tagName)) {
|
|
47
|
+
tags.push(tagName);
|
|
48
|
+
parsed.metadata.tags = tags;
|
|
49
|
+
const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
50
|
+
writeFileSync(targetPath, markdown, 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { /* best-effort */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
delete ws.tags;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
21
59
|
function readWorkspace(filename) {
|
|
22
60
|
const raw = readFileSync(workspacePath(filename), 'utf-8');
|
|
23
|
-
|
|
61
|
+
let parsed = JSON.parse(raw);
|
|
24
62
|
if (isV1(parsed)) {
|
|
25
|
-
|
|
26
|
-
writeWorkspace(filename,
|
|
27
|
-
return
|
|
63
|
+
parsed = migrateV1toV2(parsed);
|
|
64
|
+
writeWorkspace(filename, parsed);
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
// Migrate workspace-level tags to doc frontmatter
|
|
68
|
+
if (migrateWorkspaceTags(parsed)) {
|
|
69
|
+
writeWorkspace(filename, parsed);
|
|
28
70
|
}
|
|
29
71
|
return parsed;
|
|
30
72
|
}
|
|
@@ -96,7 +138,7 @@ export function createWorkspace(options) {
|
|
|
96
138
|
const { title, voiceProfileId = null } = options;
|
|
97
139
|
const slug = sanitizeFilename(title).toLowerCase().replace(/\s+/g, '-');
|
|
98
140
|
const filename = `${slug}-${randomUUID().slice(0, 8)}.json`;
|
|
99
|
-
const workspace = { version: 2, title, voiceProfileId, root: []
|
|
141
|
+
const workspace = { version: 2, title, voiceProfileId, root: [] };
|
|
100
142
|
writeWorkspace(filename, workspace);
|
|
101
143
|
// Append to order
|
|
102
144
|
const order = readOrder();
|
|
@@ -104,12 +146,27 @@ export function createWorkspace(options) {
|
|
|
104
146
|
writeOrder(order);
|
|
105
147
|
return { filename, title, docCount: 0 };
|
|
106
148
|
}
|
|
107
|
-
export function deleteWorkspace(filename) {
|
|
149
|
+
export async function deleteWorkspace(filename) {
|
|
108
150
|
ensureWorkspacesDir();
|
|
109
151
|
const p = workspacePath(filename);
|
|
110
152
|
if (!existsSync(p))
|
|
111
153
|
throw new Error(`Workspace not found: ${filename}`);
|
|
112
|
-
|
|
154
|
+
const ws = readWorkspace(filename);
|
|
155
|
+
const files = collectAllFiles(ws.root);
|
|
156
|
+
const deletedFiles = [];
|
|
157
|
+
const skippedExternal = [];
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
if (isExternalDoc(file)) {
|
|
160
|
+
skippedExternal.push(file);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const filePath = resolveDocPath(file);
|
|
164
|
+
if (existsSync(filePath)) {
|
|
165
|
+
await trash(filePath);
|
|
166
|
+
deletedFiles.push(file);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
await trash(p);
|
|
113
170
|
// Remove from order
|
|
114
171
|
const order = readOrder();
|
|
115
172
|
const idx = order.indexOf(filename);
|
|
@@ -117,6 +174,7 @@ export function deleteWorkspace(filename) {
|
|
|
117
174
|
order.splice(idx, 1);
|
|
118
175
|
writeOrder(order);
|
|
119
176
|
}
|
|
177
|
+
return { deletedFiles, skippedExternal };
|
|
120
178
|
}
|
|
121
179
|
export function reorderWorkspaces(orderedFilenames) {
|
|
122
180
|
ensureWorkspacesDir();
|
|
@@ -134,7 +192,6 @@ export function addDoc(wsFile, containerId, file, title, afterFile) {
|
|
|
134
192
|
export function removeDoc(wsFile, file) {
|
|
135
193
|
const ws = getWorkspace(wsFile);
|
|
136
194
|
removeNode(ws.root, file);
|
|
137
|
-
removeFileFromAllTags(ws.tags, file);
|
|
138
195
|
writeWorkspace(wsFile, ws);
|
|
139
196
|
return ws;
|
|
140
197
|
}
|
|
@@ -164,11 +221,7 @@ export function removeContainer(wsFile, containerId) {
|
|
|
164
221
|
const found = findContainer(ws.root, containerId);
|
|
165
222
|
if (!found)
|
|
166
223
|
throw new Error(`Container "${containerId}" not found`);
|
|
167
|
-
// Collect files in container to clean up tags
|
|
168
|
-
const files = collectAllFiles(found.node.items || []);
|
|
169
224
|
removeNode(ws.root, containerId);
|
|
170
|
-
for (const file of files)
|
|
171
|
-
removeFileFromAllTags(ws.tags, file);
|
|
172
225
|
writeWorkspace(wsFile, ws);
|
|
173
226
|
return ws;
|
|
174
227
|
}
|
|
@@ -188,27 +241,6 @@ export function reorderContainer(wsFile, containerId, afterIdentifier) {
|
|
|
188
241
|
return ws;
|
|
189
242
|
}
|
|
190
243
|
// ============================================================================
|
|
191
|
-
// TAG OPERATIONS
|
|
192
|
-
// ============================================================================
|
|
193
|
-
export function tagDoc(wsFile, file, tag) {
|
|
194
|
-
const ws = getWorkspace(wsFile);
|
|
195
|
-
if (!findDocNode(ws.root, file))
|
|
196
|
-
throw new Error(`Document "${file}" not in workspace`);
|
|
197
|
-
addTag(ws.tags, tag, file);
|
|
198
|
-
writeWorkspace(wsFile, ws);
|
|
199
|
-
return ws;
|
|
200
|
-
}
|
|
201
|
-
export function untagDoc(wsFile, file, tag) {
|
|
202
|
-
const ws = getWorkspace(wsFile);
|
|
203
|
-
removeTag(ws.tags, tag, file);
|
|
204
|
-
writeWorkspace(wsFile, ws);
|
|
205
|
-
return ws;
|
|
206
|
-
}
|
|
207
|
-
export function getDocTags(wsFile, file) {
|
|
208
|
-
const ws = getWorkspace(wsFile);
|
|
209
|
-
return listTagsForFile(ws.tags, file);
|
|
210
|
-
}
|
|
211
|
-
// ============================================================================
|
|
212
244
|
// CONTEXT
|
|
213
245
|
// ============================================================================
|
|
214
246
|
export function updateWorkspaceContext(wsFile, context) {
|
|
@@ -222,7 +254,9 @@ export function getItemContext(wsFile, docFile) {
|
|
|
222
254
|
const found = findDocNode(ws.root, docFile);
|
|
223
255
|
if (!found)
|
|
224
256
|
throw new Error(`Document "${docFile}" not found in workspace`);
|
|
225
|
-
|
|
257
|
+
// Read tags from document frontmatter (not workspace manifest)
|
|
258
|
+
const fm = readDocFrontmatter(docFile);
|
|
259
|
+
const tags = Array.isArray(fm?.tags) ? fm.tags : [];
|
|
226
260
|
return {
|
|
227
261
|
workspaceTitle: ws.title,
|
|
228
262
|
workspaceContext: ws.context || {},
|
|
@@ -230,8 +264,64 @@ export function getItemContext(wsFile, docFile) {
|
|
|
230
264
|
};
|
|
231
265
|
}
|
|
232
266
|
// ============================================================================
|
|
267
|
+
// FIND-OR-CREATE HELPERS
|
|
268
|
+
// ============================================================================
|
|
269
|
+
/** Find an existing workspace by title (case-insensitive). Returns null if not found. */
|
|
270
|
+
export function findWorkspaceByTitle(title) {
|
|
271
|
+
const all = listWorkspaces();
|
|
272
|
+
const lower = title.toLowerCase();
|
|
273
|
+
return all.find((w) => w.title.toLowerCase() === lower) || null;
|
|
274
|
+
}
|
|
275
|
+
/** Find a container by name in a workspace. Returns its ID, or null if not found. */
|
|
276
|
+
export function findContainerByName(wsFile, name) {
|
|
277
|
+
const ws = getWorkspace(wsFile);
|
|
278
|
+
const lower = name.toLowerCase();
|
|
279
|
+
function scan(nodes) {
|
|
280
|
+
for (const n of nodes) {
|
|
281
|
+
if (n.type === 'container') {
|
|
282
|
+
if (n.name.toLowerCase() === lower)
|
|
283
|
+
return n.id;
|
|
284
|
+
const found = scan(n.items);
|
|
285
|
+
if (found)
|
|
286
|
+
return found;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
return scan(ws.root);
|
|
292
|
+
}
|
|
293
|
+
/** Find workspace by title or create it. Returns workspace filename. */
|
|
294
|
+
export function findOrCreateWorkspace(title) {
|
|
295
|
+
const existing = findWorkspaceByTitle(title);
|
|
296
|
+
if (existing)
|
|
297
|
+
return { filename: existing.filename, created: false };
|
|
298
|
+
const info = createWorkspace({ title });
|
|
299
|
+
return { filename: info.filename, created: true };
|
|
300
|
+
}
|
|
301
|
+
/** Find container by name in workspace, or create it. Returns container ID. */
|
|
302
|
+
export function findOrCreateContainer(wsFile, name) {
|
|
303
|
+
const existing = findContainerByName(wsFile, name);
|
|
304
|
+
if (existing)
|
|
305
|
+
return { containerId: existing, created: false };
|
|
306
|
+
const result = addContainerToWorkspace(wsFile, null, name);
|
|
307
|
+
return { containerId: result.containerId, created: true };
|
|
308
|
+
}
|
|
309
|
+
// ============================================================================
|
|
233
310
|
// CROSS-WORKSPACE QUERIES
|
|
234
311
|
// ============================================================================
|
|
312
|
+
/** Remove a document from every workspace that references it. */
|
|
313
|
+
export function removeDocFromAllWorkspaces(file) {
|
|
314
|
+
const workspaces = listWorkspaces();
|
|
315
|
+
for (const info of workspaces) {
|
|
316
|
+
try {
|
|
317
|
+
const ws = readWorkspace(info.filename);
|
|
318
|
+
if (collectAllFiles(ws.root).includes(file)) {
|
|
319
|
+
removeDoc(info.filename, file);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch { /* skip corrupt manifests */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
235
325
|
export function getWorkspaceAssignedFiles() {
|
|
236
326
|
const assigned = new Set();
|
|
237
327
|
const workspaces = listWorkspaces();
|
package/dist/server/ws.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
-
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, setMetadata, save, onChanges, isAgentLocked, getPendingDocFilenames, getPendingDocCounts, stripPendingAttrs, } from './state.js';
|
|
6
|
-
import { switchDocument, createDocument, deleteDocument } from './documents.js';
|
|
5
|
+
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, getPendingDocFilenames, getPendingDocCounts, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
6
|
+
import { switchDocument, createDocument, deleteDocument, getActiveFilename } from './documents.js';
|
|
7
|
+
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
7
8
|
const clients = new Set();
|
|
8
9
|
let currentAgentConnected = false;
|
|
9
10
|
// Debounced auto-save: persist to disk 2s after last doc-update
|
|
@@ -57,13 +58,18 @@ export function setupWebSocket(server) {
|
|
|
57
58
|
counts: getPendingDocCounts(),
|
|
58
59
|
},
|
|
59
60
|
}));
|
|
60
|
-
ws.on('message', (data) => {
|
|
61
|
+
ws.on('message', async (data) => {
|
|
61
62
|
try {
|
|
62
63
|
const msg = JSON.parse(data.toString());
|
|
63
64
|
if (msg.type === 'doc-update' && msg.document) {
|
|
64
65
|
if (isAgentLocked()) {
|
|
65
66
|
// Agent write in progress — ignore browser doc-updates
|
|
66
67
|
}
|
|
68
|
+
else if (msg.filename && msg.filename !== getActiveFilename()) {
|
|
69
|
+
// Browser sent a doc-update for a different document (race: server switched away).
|
|
70
|
+
// Save directly to that file on disk instead of corrupting the active doc.
|
|
71
|
+
saveDocToFile(msg.filename, msg.document);
|
|
72
|
+
}
|
|
67
73
|
else {
|
|
68
74
|
updateDocument(msg.document);
|
|
69
75
|
debouncedSave();
|
|
@@ -109,19 +115,46 @@ export function setupWebSocket(server) {
|
|
|
109
115
|
if (msg.type === 'pending-resolved' && msg.filename) {
|
|
110
116
|
const action = msg.action; // 'accept' or 'reject'
|
|
111
117
|
const resolvedFilename = msg.filename;
|
|
112
|
-
|
|
118
|
+
const isActiveDoc = resolvedFilename === getActiveFilename();
|
|
119
|
+
// Get metadata from the correct source (active state or disk file)
|
|
120
|
+
const metadata = isActiveDoc ? getMetadata() : null;
|
|
121
|
+
if (action === 'reject' && metadata?.agentCreated) {
|
|
113
122
|
// Agent-created doc with all content rejected → delete the file
|
|
123
|
+
// Cancel debounced save (doc-update may have queued one for the now-empty doc)
|
|
124
|
+
if (saveTimer) {
|
|
125
|
+
clearTimeout(saveTimer);
|
|
126
|
+
saveTimer = null;
|
|
127
|
+
}
|
|
114
128
|
try {
|
|
115
|
-
|
|
129
|
+
// Remove from any workspace manifests before deleting the file
|
|
130
|
+
removeDocFromAllWorkspaces(resolvedFilename);
|
|
131
|
+
const result = await deleteDocument(resolvedFilename);
|
|
132
|
+
if (result.switched && result.newDoc) {
|
|
133
|
+
broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
|
|
134
|
+
}
|
|
135
|
+
broadcastDocumentsChanged();
|
|
136
|
+
broadcastWorkspacesChanged();
|
|
137
|
+
broadcastPendingDocsChanged();
|
|
138
|
+
return; // File deleted — no strip/save needed
|
|
116
139
|
}
|
|
117
140
|
catch (err) {
|
|
118
141
|
console.error('[WS] Failed to delete rejected agent doc:', err.message);
|
|
142
|
+
// Fall through to normal strip+save (e.g. only doc remaining)
|
|
119
143
|
}
|
|
120
144
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
145
|
+
if (isActiveDoc) {
|
|
146
|
+
// Normal path: resolved doc is the active one
|
|
147
|
+
if (action === 'accept' && metadata?.agentCreated) {
|
|
148
|
+
delete metadata.agentCreated;
|
|
149
|
+
}
|
|
150
|
+
stripPendingAttrs();
|
|
151
|
+
save();
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// Race path: resolved doc is NOT the active one (server switched away).
|
|
155
|
+
// Strip pending attrs directly from the file on disk.
|
|
156
|
+
stripPendingAttrsFromFile(resolvedFilename, action === 'accept');
|
|
157
|
+
}
|
|
125
158
|
broadcastPendingDocsChanged();
|
|
126
159
|
}
|
|
127
160
|
}
|
|
@@ -187,6 +220,13 @@ export function broadcastPendingDocsChanged() {
|
|
|
187
220
|
}
|
|
188
221
|
}, PENDING_DOCS_DEBOUNCE_MS);
|
|
189
222
|
}
|
|
223
|
+
export function broadcastPluginsChanged() {
|
|
224
|
+
const msg = JSON.stringify({ type: 'plugins-changed' });
|
|
225
|
+
for (const ws of clients) {
|
|
226
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
227
|
+
ws.send(msg);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
190
230
|
export function broadcastAgentStatus(connected) {
|
|
191
231
|
currentAgentConnected = connected;
|
|
192
232
|
const msg = JSON.stringify({ type: 'agent-status', agentConnected: connected });
|
|
@@ -197,6 +237,20 @@ export function broadcastAgentStatus(connected) {
|
|
|
197
237
|
}
|
|
198
238
|
}
|
|
199
239
|
let lastSyncStatus = null;
|
|
240
|
+
export function broadcastWritingStarted(title, target) {
|
|
241
|
+
const msg = JSON.stringify({ type: 'writing-started', title, target: target || null });
|
|
242
|
+
for (const ws of clients) {
|
|
243
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
244
|
+
ws.send(msg);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
export function broadcastWritingFinished() {
|
|
248
|
+
const msg = JSON.stringify({ type: 'writing-finished' });
|
|
249
|
+
for (const ws of clients) {
|
|
250
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
251
|
+
ws.send(msg);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
200
254
|
export function broadcastSyncStatus(status) {
|
|
201
255
|
lastSyncStatus = status;
|
|
202
256
|
const msg = JSON.stringify({ type: 'sync-status', ...status });
|
|
@@ -206,6 +260,3 @@ export function broadcastSyncStatus(status) {
|
|
|
206
260
|
}
|
|
207
261
|
}
|
|
208
262
|
}
|
|
209
|
-
export function getLastSyncStatus() {
|
|
210
|
-
return lastSyncStatus;
|
|
211
|
-
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
7
7
|
"author": "Travis Steward",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"open": "^10.1.0",
|
|
67
67
|
"react": "^18.3.1",
|
|
68
68
|
"react-dom": "^18.3.1",
|
|
69
|
+
"trash": "^10.1.0",
|
|
69
70
|
"ws": "^8.18.0",
|
|
70
71
|
"zod": "^3.25.76"
|
|
71
72
|
},
|
package/skill/SKILL.md
CHANGED
|
@@ -1,39 +1,53 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: openwriter
|
|
3
3
|
description: |
|
|
4
|
-
OpenWriter —
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
OpenWriter — the writing surface for AI agents. A markdown-native rich text
|
|
5
|
+
editor where agents write via MCP tools and users accept or reject changes
|
|
6
|
+
in-browser. 26 MCP tools for document editing, multi-doc workspaces, and
|
|
7
|
+
organization. Plain .md files on disk — no database, no lock-in.
|
|
8
8
|
|
|
9
9
|
Use when user says: "open writer", "openwriter", "write in openwriter",
|
|
10
|
-
"edit my document", "review my writing", "check the pad".
|
|
10
|
+
"edit my document", "review my writing", "check the pad", "write me a doc".
|
|
11
11
|
|
|
12
12
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
13
|
+
metadata:
|
|
14
|
+
author: travsteward
|
|
15
|
+
version: "0.2.0"
|
|
16
|
+
repository: https://github.com/travsteward/openwriter
|
|
17
|
+
license: MIT
|
|
13
18
|
---
|
|
14
19
|
|
|
15
|
-
# OpenWriter
|
|
20
|
+
# OpenWriter Skill
|
|
16
21
|
|
|
17
|
-
You are a writing collaborator.
|
|
22
|
+
You are a writing collaborator. You read documents and make edits **exclusively via MCP tools**. Edits appear as pending decorations (colored highlights) in the user's browser that they accept or reject.
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
> OpenWriter is at **http://localhost:5050**
|
|
24
|
+
## Setup — Which Path?
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
Check whether the `open-writer` MCP tools are available (e.g. `read_pad`, `write_to_pad`). This determines setup state:
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
### MCP tools ARE available (ready to use)
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
**First action:** Share the browser URL:
|
|
33
|
+
> OpenWriter is at **http://localhost:5050** — open it in your browser to see and review changes.
|
|
34
|
+
|
|
35
|
+
Skip to [Writing Strategy](#writing-strategy) below.
|
|
36
|
+
|
|
37
|
+
### MCP tools are NOT available (skill-first install)
|
|
38
|
+
|
|
39
|
+
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 24 editing tools.
|
|
40
|
+
|
|
41
|
+
**Step 1:** Tell the user to install the npm package and MCP server:
|
|
27
42
|
|
|
28
43
|
```bash
|
|
44
|
+
# Add the OpenWriter MCP server to Claude Code
|
|
29
45
|
claude mcp add -s user open-writer -- npx openwriter --no-open
|
|
30
46
|
```
|
|
31
47
|
|
|
32
|
-
Then restart the Claude Code session. The MCP tools become available
|
|
33
|
-
|
|
34
|
-
### Option B: Agent configures it (when user asks you to set it up)
|
|
48
|
+
Then restart the Claude Code session. The MCP tools become available on next launch.
|
|
35
49
|
|
|
36
|
-
Edit `~/.claude.json`
|
|
50
|
+
**Step 2 (if the user can't run the command above):** Edit `~/.claude.json` directly. Add to the `mcpServers` object:
|
|
37
51
|
|
|
38
52
|
```json
|
|
39
53
|
"open-writer": {
|
|
@@ -42,7 +56,7 @@ Edit `~/.claude.json` and add to the `mcpServers` object:
|
|
|
42
56
|
}
|
|
43
57
|
```
|
|
44
58
|
|
|
45
|
-
The `mcpServers` key is at the top level of `~/.claude.json`. If it doesn't exist, create it
|
|
59
|
+
The `mcpServers` key is at the top level of `~/.claude.json`. If it doesn't exist, create it:
|
|
46
60
|
|
|
47
61
|
```json
|
|
48
62
|
{
|
|
@@ -56,12 +70,12 @@ The `mcpServers` key is at the top level of `~/.claude.json`. If it doesn't exis
|
|
|
56
70
|
```
|
|
57
71
|
|
|
58
72
|
After editing, tell the user:
|
|
59
|
-
1. Restart your Claude Code session (
|
|
73
|
+
1. Restart your Claude Code session (MCP servers load on startup)
|
|
60
74
|
2. Open http://localhost:5050 in your browser
|
|
61
75
|
|
|
62
|
-
**Note:** You cannot run `claude mcp add` from inside a session (nested session error). That's why we edit the JSON directly.
|
|
76
|
+
**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.
|
|
63
77
|
|
|
64
|
-
## MCP Tools Reference (
|
|
78
|
+
## MCP Tools Reference (26 tools)
|
|
65
79
|
|
|
66
80
|
### Document Operations
|
|
67
81
|
|
|
@@ -69,6 +83,7 @@ After editing, tell the user:
|
|
|
69
83
|
|------|-------------|
|
|
70
84
|
| `read_pad` | Read the current document (compact tagged-line format) |
|
|
71
85
|
| `write_to_pad` | Apply edits as pending decorations (rewrite, insert, delete) |
|
|
86
|
+
| `populate_document` | Populate an empty doc with content (two-step creation flow) |
|
|
72
87
|
| `get_pad_status` | Lightweight poll: word count, pending changes, userSignaledReview |
|
|
73
88
|
| `get_nodes` | Fetch specific nodes by ID |
|
|
74
89
|
| `get_metadata` | Get frontmatter metadata for the active document |
|
|
@@ -80,14 +95,14 @@ After editing, tell the user:
|
|
|
80
95
|
|------|-------------|
|
|
81
96
|
| `list_documents` | List all documents with filename, word count, active status |
|
|
82
97
|
| `switch_document` | Switch to a different document by filename |
|
|
83
|
-
| `create_document` | Create a new document (optional
|
|
98
|
+
| `create_document` | Create a new empty document (optional workspace + container placement) |
|
|
84
99
|
| `open_file` | Open an existing .md file from any location on disk |
|
|
100
|
+
| `delete_document` | Delete a document file (moves to OS trash, recoverable) |
|
|
85
101
|
|
|
86
102
|
### Import
|
|
87
103
|
|
|
88
104
|
| Tool | Description |
|
|
89
105
|
|------|-------------|
|
|
90
|
-
| `replace_document` | Import external content into a new/blank document |
|
|
91
106
|
| `import_gdoc` | Import a Google Doc (auto-splits multi-chapter docs) |
|
|
92
107
|
|
|
93
108
|
### Workspace Management
|
|
@@ -96,6 +111,7 @@ After editing, tell the user:
|
|
|
96
111
|
|------|-------------|
|
|
97
112
|
| `list_workspaces` | List all workspaces with title and doc count |
|
|
98
113
|
| `create_workspace` | Create a new workspace |
|
|
114
|
+
| `delete_workspace` | Delete a workspace and all its document files (moves to OS trash) |
|
|
99
115
|
| `get_workspace_structure` | Get full workspace tree: containers, docs, tags, context |
|
|
100
116
|
| `get_item_context` | Get progressive disclosure context for a doc in a workspace |
|
|
101
117
|
| `update_workspace_context` | Update workspace context (characters, settings, rules) |
|
|
@@ -106,8 +122,8 @@ After editing, tell the user:
|
|
|
106
122
|
|------|-------------|
|
|
107
123
|
| `add_doc` | Add a document to a workspace (optional container placement) |
|
|
108
124
|
| `create_container` | Create a folder inside a workspace (max depth: 3) |
|
|
109
|
-
| `tag_doc` | Add a tag to a document in
|
|
110
|
-
| `untag_doc` | Remove a tag from a document |
|
|
125
|
+
| `tag_doc` | Add a tag to a document (stored in doc frontmatter) |
|
|
126
|
+
| `untag_doc` | Remove a tag from a document (stored in doc frontmatter) |
|
|
111
127
|
| `move_doc` | Move a document to a different container or root level |
|
|
112
128
|
|
|
113
129
|
### Text Operations
|
|
@@ -118,15 +134,54 @@ After editing, tell the user:
|
|
|
118
134
|
|
|
119
135
|
## Writing Strategy
|
|
120
136
|
|
|
121
|
-
**
|
|
137
|
+
OpenWriter has two distinct modes: **editing** existing documents and **creating** new content. Use the right approach for each.
|
|
138
|
+
|
|
139
|
+
### Editing (write_to_pad)
|
|
140
|
+
|
|
141
|
+
For making changes to existing documents — rewrites, insertions, deletions:
|
|
122
142
|
|
|
123
|
-
- Use `write_to_pad` for all edits
|
|
143
|
+
- Use `write_to_pad` for all edits
|
|
124
144
|
- Send **3-8 changes per call** for a responsive, streaming feel
|
|
125
|
-
- Always `read_pad` before
|
|
145
|
+
- Always `read_pad` before editing to get fresh node IDs
|
|
126
146
|
- Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
|
|
127
147
|
- Content accepts markdown strings (preferred) or TipTap JSON
|
|
128
148
|
- Decoration colors: **blue** = rewrite, **green** = insert, **red** = delete
|
|
129
149
|
|
|
150
|
+
### Creating New Documents (two-step flow)
|
|
151
|
+
|
|
152
|
+
**Always use the two-step flow** when creating new content:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
1. create_document({ title: "My Doc" }) ← no content, fires instantly, shows spinner
|
|
156
|
+
2. populate_document({ content: "..." }) ← delivers content, clears spinner
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Why two steps?** MCP tool calls are atomic — the server doesn't receive the call until ALL parameters are fully generated. For a document with hundreds or thousands of words, the user would wait 30+ seconds with zero feedback while you generate content tokens. The two-step flow shows a sidebar spinner immediately (step 1 has no content to generate), then the spinner persists while you generate and deliver the content (step 2).
|
|
160
|
+
|
|
161
|
+
**Rules:**
|
|
162
|
+
- `create_document` does NOT accept a `content` parameter — it always creates an empty doc
|
|
163
|
+
- Step 1 (`create_document`) — shows spinner, creates empty doc, does NOT switch the editor
|
|
164
|
+
- Step 2 (`populate_document`) — writes content to the active doc, marks as pending decorations, switches the editor, clears the spinner
|
|
165
|
+
- Never use `write_to_pad` for the initial population — use `populate_document` exclusively
|
|
166
|
+
|
|
167
|
+
### Workspace-Integrated Creation
|
|
168
|
+
|
|
169
|
+
`create_document` accepts optional `workspace` and `container` parameters for direct workspace placement:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
create_document({
|
|
173
|
+
title: "Opening Chapter",
|
|
174
|
+
workspace: "The Immortal", ← creates workspace if it doesn't exist
|
|
175
|
+
container: "Chapters" ← creates container if it doesn't exist
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- **`workspace`** (string) — workspace title to add the doc to. Auto-creates if not found (case-insensitive match).
|
|
180
|
+
- **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
|
|
181
|
+
- Both are optional — omit for standalone docs outside any workspace.
|
|
182
|
+
|
|
183
|
+
This eliminates the need for separate `create_workspace`, `create_container`, and `add_doc` calls when building up a workspace.
|
|
184
|
+
|
|
130
185
|
## Workflow
|
|
131
186
|
|
|
132
187
|
### Single document
|
|
@@ -147,14 +202,39 @@ After editing, tell the user:
|
|
|
147
202
|
4. write_to_pad → apply edits
|
|
148
203
|
```
|
|
149
204
|
|
|
150
|
-
### Creating new content
|
|
205
|
+
### Creating new content (two-step)
|
|
151
206
|
|
|
152
207
|
```
|
|
153
|
-
1. create_document({ title: "My Doc",
|
|
154
|
-
|
|
155
|
-
|
|
208
|
+
1. create_document({ title: "My Doc", workspace: "Project", container: "Chapters" })
|
|
209
|
+
→ spinner appears, doc placed in workspace
|
|
210
|
+
2. populate_document({ content: "# ..." }) → content delivered, spinner clears
|
|
211
|
+
3. read_pad → get node IDs if further edits needed
|
|
212
|
+
4. write_to_pad → refine with edits
|
|
156
213
|
```
|
|
157
214
|
|
|
215
|
+
### Building a workspace (multiple docs)
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
1. create_document({ title: "Ch 1", workspace: "My Book", container: "Chapters" })
|
|
219
|
+
2. populate_document({ content: "..." })
|
|
220
|
+
3. create_document({ title: "Ch 2", workspace: "My Book", container: "Chapters" })
|
|
221
|
+
4. populate_document({ content: "..." })
|
|
222
|
+
5. create_document({ title: "Character Bible", workspace: "My Book", container: "References" })
|
|
223
|
+
6. populate_document({ content: "..." })
|
|
224
|
+
7. tag_doc + update_workspace_context → organize and add context
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The workspace and containers are auto-created on the first `create_document` call. Subsequent calls reuse the existing workspace/containers (matched case-insensitively).
|
|
228
|
+
|
|
229
|
+
### Book workspace guidelines
|
|
230
|
+
|
|
231
|
+
When importing or organizing book-length projects, read the source material first and **follow the grain** — break content into the categories the author is already thinking in, don't impose a template.
|
|
232
|
+
|
|
233
|
+
- **One concept per doc.** Don't create one giant reference doc. If the material covers characters, setting, plot, and themes, those are separate documents.
|
|
234
|
+
- **Preserve originals.** Keep raw drafts separate from revised versions (e.g. Drafts vs. Chapters containers). The author needs both.
|
|
235
|
+
- **Synthesize, don't just copy.** Reorganize messy notes into clean, scannable docs (headers, bullets, sections) while keeping the author's voice and prose verbatim.
|
|
236
|
+
- **Surface open threads.** Unanswered questions, brainstorm lists, and loose ideas get their own doc — don't bury them inside reference material.
|
|
237
|
+
|
|
158
238
|
## Review Etiquette
|
|
159
239
|
|
|
160
240
|
1. **Share the URL.** Always tell the user: http://localhost:5050
|
|
@@ -166,7 +246,7 @@ After editing, tell the user:
|
|
|
166
246
|
|
|
167
247
|
## Troubleshooting
|
|
168
248
|
|
|
169
|
-
**MCP tools not available** —
|
|
249
|
+
**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.
|
|
170
250
|
|
|
171
251
|
**Port 5050 busy** — Another OpenWriter instance owns the port. New sessions auto-enter client mode (proxying via HTTP) — tools still work. No action needed.
|
|
172
252
|
|