@hanna84/mcp-writing 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/package.json +2 -1
- package/scripts/manual-scrivener-realtest.mjs +262 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,21 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
#### [v1.11.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v1.10.0...v1.11.0)
|
|
9
|
+
|
|
10
|
+
- feat: add reusable manual real-data Scrivener test runner [`#58`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/58)
|
|
12
|
+
|
|
7
13
|
#### [v1.10.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
14
|
/compare/v1.9.4...v1.10.0)
|
|
9
15
|
|
|
16
|
+
> 21 April 2026
|
|
17
|
+
|
|
10
18
|
- feat: Scrivener direct extraction beta (M1, M2, M2.5) [`#57`](https://github.com/hannasdev/mcp-writing.git
|
|
11
19
|
/pull/57)
|
|
20
|
+
- Release 1.10.0 [`504bd7f`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/504bd7facfaf11052e98127e2ae0a104259d8a57)
|
|
12
22
|
|
|
13
23
|
#### [v1.9.4](https://github.com/hannasdev/mcp-writing.git
|
|
14
24
|
/compare/v1.9.3...v1.9.4)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"start": "node --experimental-sqlite index.js",
|
|
25
25
|
"new:entity": "node scripts/new-world-entity.js",
|
|
26
|
+
"manual:realtest": "node scripts/manual-scrivener-realtest.mjs",
|
|
26
27
|
"setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
|
|
27
28
|
"release": "release-it",
|
|
28
29
|
"lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { importScrivenerSync, validateProjectId } from "../importer.js";
|
|
5
|
+
import { mergeScrivenerProjectMetadata } from "../scrivener-direct.js";
|
|
6
|
+
|
|
7
|
+
function usage() {
|
|
8
|
+
return [
|
|
9
|
+
"Usage:",
|
|
10
|
+
" node scripts/manual-scrivener-realtest.mjs \\",
|
|
11
|
+
" --source-dir <external-sync-dir> \\",
|
|
12
|
+
" --scriv-path <copied-project.scriv> \\",
|
|
13
|
+
" --project-id <project|universe/project> [options]",
|
|
14
|
+
"",
|
|
15
|
+
"Keep large real-data test assets outside the repository so they cannot be",
|
|
16
|
+
"accidentally committed. Example external storage location:",
|
|
17
|
+
" $HOME/.mcp-writing-manual-data/",
|
|
18
|
+
"",
|
|
19
|
+
"Options:",
|
|
20
|
+
" --help Show this help message.",
|
|
21
|
+
" --sync-dir <path> Temp sync root to write into.",
|
|
22
|
+
" Default: ./tmp/manual-realtest-sync",
|
|
23
|
+
" --sample-count <n> Number of sample sidecars to include. Default: 5",
|
|
24
|
+
" --no-clean Reuse existing sync dir instead of recreating it.",
|
|
25
|
+
"",
|
|
26
|
+
"Example:",
|
|
27
|
+
" npm run manual:realtest -- \\",
|
|
28
|
+
" --source-dir <path-to-external-sync-source> \\",
|
|
29
|
+
" --scriv-path <path-to-external-test-data>/<project-name>.scriv \\",
|
|
30
|
+
" --project-id <universe>/<project-name> \\",
|
|
31
|
+
" --sync-dir <path-to-external-test-data>/manual-realtest-sync",
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const options = {
|
|
37
|
+
syncDir: "./tmp/manual-realtest-sync",
|
|
38
|
+
sampleCount: 5,
|
|
39
|
+
clean: true,
|
|
40
|
+
help: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (let index = 0; index < argv.length; index++) {
|
|
44
|
+
const arg = argv[index];
|
|
45
|
+
if (arg === "--help") {
|
|
46
|
+
options.help = true;
|
|
47
|
+
} else if (arg === "--source-dir") {
|
|
48
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
49
|
+
options.sourceDir = argv[++index];
|
|
50
|
+
} else if (arg === "--scriv-path") {
|
|
51
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
52
|
+
options.scrivPath = argv[++index];
|
|
53
|
+
} else if (arg === "--project-id") {
|
|
54
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
55
|
+
options.projectId = argv[++index];
|
|
56
|
+
} else if (arg === "--sync-dir") {
|
|
57
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
58
|
+
options.syncDir = argv[++index];
|
|
59
|
+
} else if (arg === "--sample-count") {
|
|
60
|
+
if (index + 1 >= argv.length) throw new Error(`${arg} requires a value.`);
|
|
61
|
+
const sampleCountValue = argv[++index];
|
|
62
|
+
if (!/^\d+$/.test(sampleCountValue)) {
|
|
63
|
+
throw new Error("--sample-count must be a positive integer.");
|
|
64
|
+
}
|
|
65
|
+
options.sampleCount = Number(sampleCountValue);
|
|
66
|
+
} else if (arg === "--no-clean") {
|
|
67
|
+
options.clean = false;
|
|
68
|
+
} else {
|
|
69
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return options;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateOptions(options) {
|
|
77
|
+
if (options.help) return;
|
|
78
|
+
|
|
79
|
+
if (!options.sourceDir || !options.scrivPath || !options.projectId) {
|
|
80
|
+
throw new Error("Missing required arguments: --source-dir, --scriv-path, --project-id are required.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!Number.isInteger(options.sampleCount) || options.sampleCount < 1) {
|
|
84
|
+
throw new Error("--sample-count must be a positive integer.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function walkSidecars(dir, out = []) {
|
|
89
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
90
|
+
const fullPath = path.join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory()) walkSidecars(fullPath, out);
|
|
92
|
+
else if (entry.name.endsWith(".meta.yaml")) out.push(fullPath);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function summarizeImport(result) {
|
|
98
|
+
return {
|
|
99
|
+
projectId: result.projectId,
|
|
100
|
+
scenesDir: result.scenesDir,
|
|
101
|
+
sourceFiles: result.sourceFiles,
|
|
102
|
+
created: result.created,
|
|
103
|
+
existing: result.existing,
|
|
104
|
+
skipped: result.skipped,
|
|
105
|
+
beatMarkersSeen: result.beatMarkersSeen,
|
|
106
|
+
ignoredFiles: result.ignoredFiles,
|
|
107
|
+
filesToProcess: result.filesToProcess,
|
|
108
|
+
existingSidecars: result.existingSidecars,
|
|
109
|
+
filePreviews: result.filePreviews,
|
|
110
|
+
dryRun: result.dryRun,
|
|
111
|
+
preflight: result.preflight,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function summarizeMerge(result) {
|
|
116
|
+
return {
|
|
117
|
+
projectId: result.projectId,
|
|
118
|
+
scenesDir: result.scenesDir,
|
|
119
|
+
sidecarFiles: result.sidecarFiles,
|
|
120
|
+
updated: result.updated,
|
|
121
|
+
unchanged: result.unchanged,
|
|
122
|
+
skippedNoBracketId: result.skippedNoBracketId,
|
|
123
|
+
noData: result.noData,
|
|
124
|
+
fieldAddCounts: result.fieldAddCounts,
|
|
125
|
+
previewChanges: result.previewChanges,
|
|
126
|
+
stats: result.stats,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runStep(name, fn) {
|
|
131
|
+
try {
|
|
132
|
+
return { name, ok: true, result: fn() };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return {
|
|
135
|
+
name,
|
|
136
|
+
ok: false,
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isSafeToDeleteSync(syncDir) {
|
|
143
|
+
const resolvedPath = path.resolve(syncDir);
|
|
144
|
+
|
|
145
|
+
// Never delete root or home directory
|
|
146
|
+
const parsed = path.parse(resolvedPath);
|
|
147
|
+
if (resolvedPath === parsed.root || resolvedPath === path.resolve(process.env.HOME || os.homedir())) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Allow deletion only if path contains a manual-realtest marker or is in ./tmp or /tmp
|
|
152
|
+
const hasMarker = resolvedPath.includes("manual-realtest");
|
|
153
|
+
|
|
154
|
+
// Check if in tmp directories with proper path boundary checking
|
|
155
|
+
const localTmpDir = path.resolve("./tmp");
|
|
156
|
+
const isInLocalTmp =
|
|
157
|
+
resolvedPath === localTmpDir || resolvedPath.startsWith(localTmpDir + path.sep);
|
|
158
|
+
const isInSystemTmp =
|
|
159
|
+
resolvedPath === "/tmp" || resolvedPath.startsWith("/tmp" + path.sep);
|
|
160
|
+
const inTmpDir = isInLocalTmp || isInSystemTmp;
|
|
161
|
+
|
|
162
|
+
return hasMarker || inTmpDir;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function main() {
|
|
166
|
+
const options = parseArgs(process.argv.slice(2));
|
|
167
|
+
|
|
168
|
+
if (options.help) {
|
|
169
|
+
console.log(usage());
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
validateOptions(options);
|
|
174
|
+
|
|
175
|
+
const projectIdCheck = validateProjectId(options.projectId);
|
|
176
|
+
if (!projectIdCheck.ok) {
|
|
177
|
+
throw new Error(`Invalid --project-id '${options.projectId}': ${projectIdCheck.reason}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const syncDir = path.resolve(options.syncDir);
|
|
181
|
+
const sourceDir = path.resolve(options.sourceDir);
|
|
182
|
+
const scrivPath = path.resolve(options.scrivPath);
|
|
183
|
+
|
|
184
|
+
if (!fs.existsSync(sourceDir) || !fs.statSync(sourceDir).isDirectory()) {
|
|
185
|
+
throw new Error(`--source-dir not found or not a directory: ${sourceDir}`);
|
|
186
|
+
}
|
|
187
|
+
if (!fs.existsSync(scrivPath) || !fs.statSync(scrivPath).isDirectory()) {
|
|
188
|
+
throw new Error(`--scriv-path not found or not a directory: ${scrivPath}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (options.clean) {
|
|
192
|
+
if (!isSafeToDeleteSync(syncDir)) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Safety check failed: --sync-dir must contain 'manual-realtest' or be in ./tmp or /tmp. Got: ${syncDir}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
fs.rmSync(syncDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
fs.mkdirSync(syncDir, { recursive: true });
|
|
200
|
+
|
|
201
|
+
const report = {
|
|
202
|
+
syncDir,
|
|
203
|
+
sourceDir,
|
|
204
|
+
scrivPath,
|
|
205
|
+
projectId: options.projectId,
|
|
206
|
+
cleanStart: options.clean,
|
|
207
|
+
tests: [],
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
report.tests.push(runStep("import_preflight", () => summarizeImport(importScrivenerSync({
|
|
211
|
+
scrivenerDir: sourceDir,
|
|
212
|
+
mcpSyncDir: syncDir,
|
|
213
|
+
projectId: options.projectId,
|
|
214
|
+
dryRun: true,
|
|
215
|
+
preflight: true,
|
|
216
|
+
}))));
|
|
217
|
+
|
|
218
|
+
report.tests.push(runStep("import_write", () => summarizeImport(importScrivenerSync({
|
|
219
|
+
scrivenerDir: sourceDir,
|
|
220
|
+
mcpSyncDir: syncDir,
|
|
221
|
+
projectId: options.projectId,
|
|
222
|
+
dryRun: false,
|
|
223
|
+
preflight: false,
|
|
224
|
+
}))));
|
|
225
|
+
|
|
226
|
+
report.tests.push(runStep("merge_dry_run", () => summarizeMerge(mergeScrivenerProjectMetadata({
|
|
227
|
+
scrivPath,
|
|
228
|
+
mcpSyncDir: syncDir,
|
|
229
|
+
projectId: options.projectId,
|
|
230
|
+
dryRun: true,
|
|
231
|
+
}))));
|
|
232
|
+
|
|
233
|
+
report.tests.push(runStep("merge_write", () => summarizeMerge(mergeScrivenerProjectMetadata({
|
|
234
|
+
scrivPath,
|
|
235
|
+
mcpSyncDir: syncDir,
|
|
236
|
+
projectId: options.projectId,
|
|
237
|
+
dryRun: false,
|
|
238
|
+
}))));
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
// Use scenesDir from import_write result (the actual written path) instead of re-deriving
|
|
242
|
+
const importWriteResult = report.tests.find((test) => test.name === "import_write" && test.ok)?.result;
|
|
243
|
+
const scenesDir = importWriteResult?.scenesDir;
|
|
244
|
+
|
|
245
|
+
const sidecars = scenesDir && fs.existsSync(scenesDir) ? walkSidecars(scenesDir) : [];
|
|
246
|
+
report.sidecarCount = sidecars.length;
|
|
247
|
+
report.sampleSidecars = sidecars
|
|
248
|
+
.slice(0, options.sampleCount)
|
|
249
|
+
.map((filePath) => path.relative(syncDir, filePath));
|
|
250
|
+
|
|
251
|
+
const failures = report.tests.filter((test) => !test.ok);
|
|
252
|
+
console.log(JSON.stringify(report, null, 2));
|
|
253
|
+
if (failures.length) process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
main();
|
|
258
|
+
} catch (err) {
|
|
259
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
260
|
+
console.error(usage());
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|