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.
@@ -2,51 +2,78 @@
2
2
  * Shared constants and utility functions for OpenWriter server.
3
3
  * Both state.ts and documents.ts import from here to avoid duplication.
4
4
  */
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, rmSync } from 'fs';
6
6
  import { join, isAbsolute, basename, dirname, resolve, sep } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { randomUUID } from 'crypto';
9
- export const DATA_DIR = join(homedir(), '.openwriter');
10
- export const VERSIONS_DIR = join(DATA_DIR, '.versions');
11
- export const WORKSPACES_DIR = join(DATA_DIR, '_workspaces');
12
- export const CONFIG_FILE = join(DATA_DIR, 'config.json');
9
+ export const ROOT_DIR = join(homedir(), '.openwriter');
13
10
  export const TEMP_PREFIX = '_untitled-';
11
+ // ---- Profile state ----
12
+ let activeProfile = 'Default';
13
+ export function getActiveProfile() {
14
+ return activeProfile;
15
+ }
16
+ export function setActiveProfile(name) {
17
+ activeProfile = name;
18
+ }
19
+ // ---- Profile-aware path getters ----
20
+ export function getDataDir() {
21
+ return join(ROOT_DIR, 'profiles', activeProfile);
22
+ }
23
+ export function getVersionsDir() {
24
+ return join(getDataDir(), '.versions');
25
+ }
26
+ export function getWorkspacesDir() {
27
+ return join(getDataDir(), '_workspaces');
28
+ }
29
+ export function getConfigFile() {
30
+ return join(ROOT_DIR, 'config.json');
31
+ }
32
+ // ---- Directory bootstrapping ----
14
33
  export function ensureDataDir() {
15
- if (!existsSync(DATA_DIR)) {
16
- // One-time migration: rename ~/.superwriter/ → ~/.openwriter/
34
+ // Ensure ROOT_DIR exists (with legacy migration)
35
+ if (!existsSync(ROOT_DIR)) {
17
36
  const legacyDir = join(homedir(), '.superwriter');
18
37
  if (existsSync(legacyDir)) {
19
- renameSync(legacyDir, DATA_DIR);
38
+ renameSync(legacyDir, ROOT_DIR);
20
39
  }
21
40
  else {
22
- mkdirSync(DATA_DIR, { recursive: true });
41
+ mkdirSync(ROOT_DIR, { recursive: true });
23
42
  }
24
43
  }
44
+ // Migrate flat files into profiles/Default/ if needed
45
+ migrateToProfiles();
46
+ // Ensure active profile directory exists
47
+ const dataDir = getDataDir();
48
+ if (!existsSync(dataDir))
49
+ mkdirSync(dataDir, { recursive: true });
25
50
  }
26
51
  export function ensureWorkspacesDir() {
27
52
  ensureDataDir();
28
- if (!existsSync(WORKSPACES_DIR))
29
- mkdirSync(WORKSPACES_DIR, { recursive: true });
53
+ const wsDir = getWorkspacesDir();
54
+ if (!existsSync(wsDir))
55
+ mkdirSync(wsDir, { recursive: true });
30
56
  }
31
57
  export function sanitizeFilename(name) {
32
58
  return name.replace(/[<>:"/\\|?*]/g, '-').trim() || 'Untitled';
33
59
  }
34
60
  export function filePathForTitle(title) {
35
- return join(DATA_DIR, `${sanitizeFilename(title)}.md`);
61
+ return join(getDataDir(), `${sanitizeFilename(title)}.md`);
36
62
  }
37
63
  export function tempFilePath() {
38
- return join(DATA_DIR, `${TEMP_PREFIX}${randomUUID()}.md`);
64
+ return join(getDataDir(), `${TEMP_PREFIX}${randomUUID()}.md`);
39
65
  }
40
66
  // ---- Path resolution for external documents ----
41
67
  /** Resolve a filename to a full path. Basenames resolve to DATA_DIR; absolute paths pass through. */
42
68
  export function resolveDocPath(filename) {
69
+ const dataDir = getDataDir();
43
70
  // External docs use absolute paths as identifiers — pass through
44
71
  if (isAbsolute(filename))
45
72
  return filename;
46
73
  // Internal docs must resolve within DATA_DIR — block path traversal
47
- const resolved = resolve(DATA_DIR, filename);
48
- const dataDir = resolve(DATA_DIR);
49
- if (resolved !== dataDir && !resolved.startsWith(dataDir + sep)) {
74
+ const resolved = resolve(dataDir, filename);
75
+ const dataDirResolved = resolve(dataDir);
76
+ if (resolved !== dataDirResolved && !resolved.startsWith(dataDirResolved + sep)) {
50
77
  throw new Error(`Path traversal blocked: ${filename}`);
51
78
  }
52
79
  return resolved;
@@ -55,7 +82,7 @@ export function resolveDocPath(filename) {
55
82
  export function isExternalDoc(filename) {
56
83
  if (isAbsolute(filename) || /[/\\]/.test(filename)) {
57
84
  const resolved = isAbsolute(filename) ? filename : filename;
58
- return !resolved.startsWith(DATA_DIR);
85
+ return !resolved.startsWith(getDataDir());
59
86
  }
60
87
  return false;
61
88
  }
@@ -83,10 +110,11 @@ export function generateNodeId() {
83
110
  export const LEAF_BLOCK_TYPES = new Set(['paragraph', 'heading', 'codeBlock', 'horizontalRule', 'table', 'image']);
84
111
  export function readConfig() {
85
112
  ensureDataDir();
86
- if (!existsSync(CONFIG_FILE))
113
+ const configFile = getConfigFile();
114
+ if (!existsSync(configFile))
87
115
  return {};
88
116
  try {
89
- return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
117
+ return JSON.parse(readFileSync(configFile, 'utf-8'));
90
118
  }
91
119
  catch {
92
120
  return {};
@@ -96,5 +124,132 @@ export function saveConfig(updates) {
96
124
  ensureDataDir();
97
125
  const current = readConfig();
98
126
  const merged = { ...current, ...updates };
99
- atomicWriteFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
127
+ atomicWriteFileSync(getConfigFile(), JSON.stringify(merged, null, 2));
128
+ }
129
+ // ---- Profile CRUD ----
130
+ export function listProfiles() {
131
+ const config = readConfig();
132
+ return config.profiles || ['Default'];
133
+ }
134
+ export function createProfile(name) {
135
+ if (!name.trim())
136
+ throw new Error('Profile name cannot be empty');
137
+ const config = readConfig();
138
+ const profiles = config.profiles || ['Default'];
139
+ if (profiles.includes(name))
140
+ throw new Error(`Profile "${name}" already exists`);
141
+ // Create profile directory
142
+ const profileDir = join(ROOT_DIR, 'profiles', name);
143
+ mkdirSync(profileDir, { recursive: true });
144
+ // Update config
145
+ profiles.push(name);
146
+ saveConfig({ profiles });
147
+ }
148
+ export function deleteProfile(name) {
149
+ if (name === 'Default')
150
+ throw new Error('Cannot delete the Default profile');
151
+ if (name === activeProfile)
152
+ throw new Error('Cannot delete the active profile');
153
+ const config = readConfig();
154
+ const profiles = config.profiles || ['Default'];
155
+ const idx = profiles.indexOf(name);
156
+ if (idx === -1)
157
+ throw new Error(`Profile "${name}" not found`);
158
+ // Move directory to .trash-{name}/ (soft delete)
159
+ const profileDir = join(ROOT_DIR, 'profiles', name);
160
+ const trashDir = join(ROOT_DIR, 'profiles', `.trash-${name}`);
161
+ if (existsSync(profileDir)) {
162
+ // If a trash dir already exists for this name, remove the old trash first
163
+ if (existsSync(trashDir)) {
164
+ rmSync(trashDir, { recursive: true, force: true });
165
+ }
166
+ renameSync(profileDir, trashDir);
167
+ }
168
+ profiles.splice(idx, 1);
169
+ saveConfig({ profiles });
170
+ }
171
+ export function listTrashedProfiles() {
172
+ const profilesDir = join(ROOT_DIR, 'profiles');
173
+ if (!existsSync(profilesDir))
174
+ return [];
175
+ try {
176
+ return readdirSync(profilesDir)
177
+ .filter(name => name.startsWith('.trash-') && statSync(join(profilesDir, name)).isDirectory())
178
+ .map(name => name.slice('.trash-'.length));
179
+ }
180
+ catch {
181
+ return [];
182
+ }
183
+ }
184
+ export function restoreProfile(name) {
185
+ const trashDir = join(ROOT_DIR, 'profiles', `.trash-${name}`);
186
+ if (!existsSync(trashDir))
187
+ throw new Error(`No trashed profile "${name}" found`);
188
+ const profileDir = join(ROOT_DIR, 'profiles', name);
189
+ if (existsSync(profileDir))
190
+ throw new Error(`A profile named "${name}" already exists`);
191
+ renameSync(trashDir, profileDir);
192
+ // Add back to config
193
+ const config = readConfig();
194
+ const profiles = config.profiles || ['Default'];
195
+ if (!profiles.includes(name)) {
196
+ profiles.push(name);
197
+ saveConfig({ profiles });
198
+ }
199
+ }
200
+ // ---- Migration: flat files → profiles/Default/ ----
201
+ function migrateToProfiles() {
202
+ const profilesDir = join(ROOT_DIR, 'profiles');
203
+ if (existsSync(profilesDir))
204
+ return; // Already migrated
205
+ // Create profiles/Default/
206
+ const defaultDir = join(profilesDir, 'Default');
207
+ mkdirSync(defaultDir, { recursive: true });
208
+ // Items to move from ROOT_DIR → profiles/Default/
209
+ const dirsToMove = ['_workspaces', '_images', '.versions', '_marks'];
210
+ const filesToMove = ['_doc-order.json', 'external-docs.json'];
211
+ for (const dir of dirsToMove) {
212
+ const src = join(ROOT_DIR, dir);
213
+ if (existsSync(src)) {
214
+ renameSync(src, join(defaultDir, dir));
215
+ }
216
+ }
217
+ for (const file of filesToMove) {
218
+ const src = join(ROOT_DIR, file);
219
+ if (existsSync(src)) {
220
+ renameSync(src, join(defaultDir, file));
221
+ }
222
+ }
223
+ // Move .git and .gitignore
224
+ for (const name of ['.git', '.gitignore']) {
225
+ const src = join(ROOT_DIR, name);
226
+ if (existsSync(src)) {
227
+ renameSync(src, join(defaultDir, name));
228
+ }
229
+ }
230
+ // Move all .md files
231
+ try {
232
+ const entries = readdirSync(ROOT_DIR);
233
+ for (const entry of entries) {
234
+ if (entry.endsWith('.md')) {
235
+ const src = join(ROOT_DIR, entry);
236
+ if (statSync(src).isFile()) {
237
+ renameSync(src, join(defaultDir, entry));
238
+ }
239
+ }
240
+ }
241
+ }
242
+ catch { /* best effort */ }
243
+ // Update config
244
+ const configFile = getConfigFile();
245
+ let config = {};
246
+ if (existsSync(configFile)) {
247
+ try {
248
+ config = JSON.parse(readFileSync(configFile, 'utf-8'));
249
+ }
250
+ catch { /* */ }
251
+ }
252
+ config.activeProfile = 'Default';
253
+ config.profiles = ['Default'];
254
+ atomicWriteFileSync(configFile, JSON.stringify(config, null, 2));
100
255
  }
@@ -7,18 +7,19 @@ import multer from 'multer';
7
7
  import { existsSync, mkdirSync } from 'fs';
8
8
  import { join, extname } from 'path';
9
9
  import { randomUUID } from 'crypto';
10
- import { DATA_DIR, ensureDataDir } from './helpers.js';
10
+ import { getDataDir, ensureDataDir } from './helpers.js';
11
11
  import express from 'express';
12
- const IMAGES_DIR = join(DATA_DIR, '_images');
12
+ function getImagesDir() { return join(getDataDir(), '_images'); }
13
13
  function ensureImagesDir() {
14
14
  ensureDataDir();
15
- if (!existsSync(IMAGES_DIR))
16
- mkdirSync(IMAGES_DIR, { recursive: true });
15
+ const dir = getImagesDir();
16
+ if (!existsSync(dir))
17
+ mkdirSync(dir, { recursive: true });
17
18
  }
18
19
  const storage = multer.diskStorage({
19
20
  destination: (_req, _file, cb) => {
20
21
  ensureImagesDir();
21
- cb(null, IMAGES_DIR);
22
+ cb(null, getImagesDir());
22
23
  },
23
24
  filename: (_req, file, cb) => {
24
25
  const ext = extname(file.originalname) || '.png';
@@ -39,9 +40,11 @@ const upload = multer({
39
40
  });
40
41
  export function createImageRouter() {
41
42
  const router = Router();
42
- // Static serving for images
43
+ // Dynamic static serving resolves active profile's images dir per request
43
44
  ensureImagesDir();
44
- router.use('/_images', express.static(IMAGES_DIR));
45
+ router.use('/_images', (req, res, next) => {
46
+ express.static(getImagesDir())(req, res, next);
47
+ });
45
48
  // Upload endpoint
46
49
  router.post('/api/upload-image', upload.single('image'), (req, res) => {
47
50
  if (!req.file) {
@@ -11,19 +11,23 @@ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadc
11
11
  import { TOOL_REGISTRY } from './mcp.js';
12
12
  import { z } from 'zod';
13
13
  import { zodToJsonSchema } from 'zod-to-json-schema';
14
- import { save, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc } from './state.js';
15
- import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument } from './documents.js';
14
+ import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, clearAllCaches } from './state.js';
15
+ import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename } from './documents.js';
16
16
  import { createWorkspaceRouter } from './workspace-routes.js';
17
17
  import { createLinkRouter } from './link-routes.js';
18
18
  import { createTweetRouter } from './tweet-routes.js';
19
19
  import { markdownToTiptap } from './markdown.js';
20
20
  import { importGoogleDoc } from './gdoc-import.js';
21
21
  import { createVersionRouter } from './version-routes.js';
22
+ import { clearVersionsCache } from './versions.js';
22
23
  import { createSyncRouter } from './sync-routes.js';
23
24
  import { removeDocFromAllWorkspaces } from './workspaces.js';
24
- import { resolveDocPath } from './helpers.js';
25
+ import { resolveDocPath, getActiveProfile, setActiveProfile, listProfiles, createProfile, deleteProfile, listTrashedProfiles, restoreProfile, saveConfig, readConfig } from './helpers.js';
25
26
  import { createImageRouter } from './image-upload.js';
26
27
  import { createExportRouter } from './export-routes.js';
28
+ import { createConnectionRouter } from './connection-routes.js';
29
+ import { createSchedulerRouter } from './scheduler-routes.js';
30
+ import { platformFetch, isAuthenticated } from './connections.js';
27
31
  import { PluginManager } from './plugin-manager.js';
28
32
  import { checkForUpdate } from './update-check.js';
29
33
  import { addMark, getMarks, resolveMarks } from './marks.js';
@@ -81,6 +85,40 @@ export async function startHttpServer(options = {}) {
81
85
  app.use(createSyncRouter(broadcastSyncStatus));
82
86
  // Mount export routes
83
87
  app.use(createExportRouter());
88
+ // Mount connection CRUD + profile binding routes
89
+ app.use(createConnectionRouter());
90
+ // Mount scheduler proxy routes
91
+ app.use(createSchedulerRouter());
92
+ // Newsletter analytics proxy routes
93
+ app.get('/api/publications', async (req, res) => {
94
+ try {
95
+ if (!isAuthenticated()) {
96
+ res.status(401).json({ error: 'Not authenticated' });
97
+ return;
98
+ }
99
+ const qs = req.query.documentId ? `?documentId=${encodeURIComponent(req.query.documentId)}` : '';
100
+ const resp = await platformFetch(`/publications${qs}`);
101
+ const data = await resp.json();
102
+ res.status(resp.status).json(data);
103
+ }
104
+ catch (err) {
105
+ res.status(500).json({ error: err.message });
106
+ }
107
+ });
108
+ app.get('/api/publications/:id/stats', async (req, res) => {
109
+ try {
110
+ if (!isAuthenticated()) {
111
+ res.status(401).json({ error: 'Not authenticated' });
112
+ return;
113
+ }
114
+ const resp = await platformFetch(`/publications/${req.params.id}/stats`);
115
+ const data = await resp.json();
116
+ res.status(resp.status).json(data);
117
+ }
118
+ catch (err) {
119
+ res.status(500).json({ error: err.message });
120
+ }
121
+ });
84
122
  // Mount version history routes
85
123
  app.use(createVersionRouter({
86
124
  getDocId,
@@ -136,6 +174,26 @@ export async function startHttpServer(options = {}) {
136
174
  app.get('/api/documents', (_req, res) => {
137
175
  res.json(listDocuments());
138
176
  });
177
+ app.get('/api/documents/:filename/text', (req, res) => {
178
+ try {
179
+ const filepath = resolveDocPath(req.params.filename);
180
+ const raw = readFileSync(filepath, 'utf-8');
181
+ // Parse YAML frontmatter
182
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
183
+ const text = fmMatch ? fmMatch[2].trim() : raw.trim();
184
+ let meta = {};
185
+ if (fmMatch) {
186
+ try {
187
+ meta = JSON.parse(fmMatch[1]);
188
+ }
189
+ catch { }
190
+ }
191
+ res.json({ text, meta });
192
+ }
193
+ catch (err) {
194
+ res.status(404).json({ error: 'Document not found' });
195
+ }
196
+ });
139
197
  app.put('/api/documents/reorder', (req, res) => {
140
198
  try {
141
199
  const { order } = req.body;
@@ -209,10 +267,32 @@ export async function startHttpServer(options = {}) {
209
267
  res.status(400).json({ error: err.message });
210
268
  }
211
269
  });
270
+ // Sync browser editor content to server state — guarantees server has latest before MCP calls.
271
+ // Used by compose modals that need to read server state via MCP tools.
272
+ app.post('/api/documents/sync-content', (req, res) => {
273
+ try {
274
+ const { document: doc, filename } = req.body;
275
+ if (!doc || !filename) {
276
+ res.status(400).json({ error: 'document and filename required' });
277
+ return;
278
+ }
279
+ if (filename === getActiveFilename()) {
280
+ updateDocument(doc);
281
+ save();
282
+ }
283
+ res.json({ ok: true });
284
+ }
285
+ catch (err) {
286
+ res.status(500).json({ error: err.message });
287
+ }
288
+ });
212
289
  app.post('/api/documents/switch', (req, res) => {
213
290
  try {
291
+ const alreadyActive = req.body.filename === getActiveFilename();
214
292
  const result = switchDocument(req.body.filename);
215
- broadcastDocumentSwitched(result.document, result.title, result.filename);
293
+ if (!alreadyActive) {
294
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
295
+ }
216
296
  res.json(result);
217
297
  }
218
298
  catch (err) {
@@ -400,6 +480,83 @@ export async function startHttpServer(options = {}) {
400
480
  res.status(500).json({ error: err.message });
401
481
  }
402
482
  });
483
+ // ---- Profile management ----
484
+ app.get('/api/profiles', (_req, res) => {
485
+ res.json({ profiles: listProfiles(), active: getActiveProfile() });
486
+ });
487
+ app.post('/api/profiles', (req, res) => {
488
+ try {
489
+ const { name } = req.body;
490
+ if (!name?.trim()) {
491
+ res.status(400).json({ error: 'name is required' });
492
+ return;
493
+ }
494
+ createProfile(name.trim());
495
+ res.json({ success: true });
496
+ }
497
+ catch (err) {
498
+ res.status(400).json({ error: err.message });
499
+ }
500
+ });
501
+ app.post('/api/profiles/switch', async (req, res) => {
502
+ try {
503
+ const { name } = req.body;
504
+ if (!name?.trim()) {
505
+ res.status(400).json({ error: 'name is required' });
506
+ return;
507
+ }
508
+ const profiles = listProfiles();
509
+ if (!profiles.includes(name)) {
510
+ res.status(404).json({ error: `Profile "${name}" not found` });
511
+ return;
512
+ }
513
+ // Flush current doc
514
+ cancelDebouncedSave();
515
+ save();
516
+ // Switch profile
517
+ setActiveProfile(name);
518
+ saveConfig({ activeProfile: name });
519
+ // Clear caches and reload
520
+ clearAllCaches();
521
+ clearVersionsCache();
522
+ load();
523
+ // Broadcast fresh state
524
+ broadcastDocumentSwitched(getDocument(), getTitle(), getFilePath().split(/[/\\]/).pop() || '', getMetadata());
525
+ broadcastDocumentsChanged();
526
+ broadcastWorkspacesChanged();
527
+ broadcastPendingDocsChanged();
528
+ res.json({ success: true, active: name });
529
+ }
530
+ catch (err) {
531
+ res.status(500).json({ error: err.message });
532
+ }
533
+ });
534
+ app.delete('/api/profiles/:name', (req, res) => {
535
+ try {
536
+ deleteProfile(req.params.name);
537
+ res.json({ success: true });
538
+ }
539
+ catch (err) {
540
+ res.status(400).json({ error: err.message });
541
+ }
542
+ });
543
+ app.get('/api/profiles/trash', (_req, res) => {
544
+ res.json({ profiles: listTrashedProfiles() });
545
+ });
546
+ app.post('/api/profiles/restore', (req, res) => {
547
+ try {
548
+ const { name } = req.body;
549
+ if (!name?.trim()) {
550
+ res.status(400).json({ error: 'name is required' });
551
+ return;
552
+ }
553
+ restoreProfile(name.trim());
554
+ res.json({ success: true });
555
+ }
556
+ catch (err) {
557
+ res.status(400).json({ error: err.message });
558
+ }
559
+ });
403
560
  // Google Doc import
404
561
  app.post('/api/import/gdoc', (req, res) => {
405
562
  try {
@@ -422,7 +579,7 @@ export async function startHttpServer(options = {}) {
422
579
  console.error(`[Plugin] ${result.error}`);
423
580
  }
424
581
  // Auto-enable from saved config.json
425
- const savedConfig = (await import('./helpers.js')).readConfig();
582
+ const savedConfig = readConfig();
426
583
  for (const [name, state] of Object.entries(savedConfig.plugins || {})) {
427
584
  if (state.enabled && !((options.plugins || []).includes(name))) {
428
585
  const result = await pluginManager.enable(name);
@@ -1,19 +1,136 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { execSync } from 'child_process';
4
5
  import { fileURLToPath } from 'url';
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = path.dirname(__filename);
8
+ function log(msg) {
9
+ console.error(msg);
10
+ }
11
+ function isGloballyInstalled() {
12
+ try {
13
+ const result = execSync('openwriter --version', {
14
+ stdio: ['pipe', 'pipe', 'pipe'],
15
+ timeout: 10000,
16
+ });
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ function installGlobally() {
24
+ log('\n② Installing openwriter globally...');
25
+ // Try without sudo first (works on Windows, nvm, Homebrew, volta, etc.)
26
+ try {
27
+ execSync('npm install -g openwriter', { stdio: 'inherit', timeout: 120000 });
28
+ log(' ✓ Installed globally');
29
+ return true;
30
+ }
31
+ catch {
32
+ // Likely permission error on macOS/Linux system Node
33
+ }
34
+ // Try with sudo on non-Windows
35
+ if (process.platform !== 'win32') {
36
+ log(' Retrying with sudo...');
37
+ try {
38
+ execSync('sudo npm install -g openwriter', { stdio: 'inherit', timeout: 120000 });
39
+ log(' ✓ Installed globally (sudo)');
40
+ return true;
41
+ }
42
+ catch {
43
+ // sudo failed too
44
+ }
45
+ }
46
+ log(' ✗ Could not install globally. Run manually:');
47
+ log(' npm install -g openwriter');
48
+ return false;
49
+ }
50
+ function isMcpConfigured() {
51
+ const claudeJson = path.join(os.homedir(), '.claude.json');
52
+ if (!fs.existsSync(claudeJson))
53
+ return false;
54
+ try {
55
+ const content = JSON.parse(fs.readFileSync(claudeJson, 'utf-8'));
56
+ return !!content?.mcpServers?.openwriter;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ function configureMcp() {
63
+ log('\n③ Configuring MCP server for Claude Code...');
64
+ // Try using claude CLI
65
+ try {
66
+ execSync('claude mcp add -s user openwriter -- openwriter --no-open', {
67
+ stdio: 'inherit',
68
+ timeout: 15000,
69
+ });
70
+ log(' ✓ MCP server configured');
71
+ return true;
72
+ }
73
+ catch {
74
+ // claude CLI not available or failed
75
+ }
76
+ // Fallback: edit ~/.claude.json directly
77
+ log(' claude CLI not found — writing config directly...');
78
+ const claudeJson = path.join(os.homedir(), '.claude.json');
79
+ try {
80
+ let config = {};
81
+ if (fs.existsSync(claudeJson)) {
82
+ config = JSON.parse(fs.readFileSync(claudeJson, 'utf-8'));
83
+ }
84
+ if (!config.mcpServers)
85
+ config.mcpServers = {};
86
+ // Add openwriter as first entry (Claude Code loads sequentially)
87
+ const existing = config.mcpServers;
88
+ config.mcpServers = {
89
+ openwriter: { command: 'openwriter', args: ['--no-open'] },
90
+ ...existing,
91
+ };
92
+ fs.writeFileSync(claudeJson, JSON.stringify(config, null, 2), 'utf-8');
93
+ log(` ✓ MCP server added to ${claudeJson}`);
94
+ return true;
95
+ }
96
+ catch (err) {
97
+ log(` ✗ Could not configure MCP server. Add manually:`);
98
+ log(' claude mcp add -s user openwriter -- openwriter --no-open');
99
+ return false;
100
+ }
101
+ }
7
102
  export function installSkill() {
103
+ // Step 1: Copy SKILL.md
104
+ log('① Installing OpenWriter skill...');
8
105
  const source = path.join(__dirname, '../../skill/SKILL.md');
9
106
  const targetDir = path.join(os.homedir(), '.claude', 'skills', 'openwriter');
10
107
  const target = path.join(targetDir, 'SKILL.md');
11
108
  if (!fs.existsSync(source)) {
12
- console.error(`Error: SKILL.md not found at ${source}`);
109
+ log(` Error: SKILL.md not found at ${source}`);
13
110
  process.exit(1);
14
111
  }
15
112
  fs.mkdirSync(targetDir, { recursive: true });
16
113
  fs.copyFileSync(source, target);
17
- console.error(`Installed OpenWriter skill to ${target}`);
114
+ log(` Skill installed to ${target}`);
115
+ // Step 2: Global install (skip if already installed)
116
+ const alreadyInstalled = isGloballyInstalled();
117
+ if (alreadyInstalled) {
118
+ log('\n② openwriter already installed globally — skipping');
119
+ }
120
+ else {
121
+ if (!installGlobally()) {
122
+ process.exit(1);
123
+ }
124
+ }
125
+ // Step 3: Configure MCP server (skip if already configured)
126
+ if (isMcpConfigured()) {
127
+ log('\n③ MCP server already configured — skipping');
128
+ }
129
+ else {
130
+ configureMcp();
131
+ }
132
+ // Done
133
+ log('\n✓ OpenWriter is ready!');
134
+ log(' Restart Claude Code, then type /openwriter to start writing.\n');
18
135
  process.exit(0);
19
136
  }