scriveno 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/agents/continuity-checker.md +85 -0
  4. package/agents/drafter.md +248 -0
  5. package/agents/plan-checker.md +209 -0
  6. package/agents/researcher.md +114 -0
  7. package/agents/translator.md +204 -0
  8. package/agents/voice-checker.md +154 -0
  9. package/bin/install.js +1620 -0
  10. package/commands/scr/add-note.md +51 -0
  11. package/commands/scr/add-unit.md +101 -0
  12. package/commands/scr/art-direction.md +225 -0
  13. package/commands/scr/autopilot-publish.md +210 -0
  14. package/commands/scr/autopilot-translate.md +237 -0
  15. package/commands/scr/autopilot.md +200 -0
  16. package/commands/scr/back-matter.md +630 -0
  17. package/commands/scr/back-translate.md +197 -0
  18. package/commands/scr/beta-reader.md +97 -0
  19. package/commands/scr/blurb.md +149 -0
  20. package/commands/scr/book-proposal.md +210 -0
  21. package/commands/scr/build-ebook.md +448 -0
  22. package/commands/scr/build-poetry-submission.md +202 -0
  23. package/commands/scr/build-print.md +598 -0
  24. package/commands/scr/build-smashwords.md +171 -0
  25. package/commands/scr/build-world.md +158 -0
  26. package/commands/scr/cast-list.md +104 -0
  27. package/commands/scr/chapter-header.md +158 -0
  28. package/commands/scr/character-arc.md +108 -0
  29. package/commands/scr/character-ref.md +160 -0
  30. package/commands/scr/character-sheet.md +143 -0
  31. package/commands/scr/character-touch.md +157 -0
  32. package/commands/scr/character-voice-sample.md +111 -0
  33. package/commands/scr/check-notes.md +50 -0
  34. package/commands/scr/cleanup.md +159 -0
  35. package/commands/scr/compare.md +112 -0
  36. package/commands/scr/complete-draft.md +49 -0
  37. package/commands/scr/continuity-check.md +129 -0
  38. package/commands/scr/copy-edit.md +118 -0
  39. package/commands/scr/cover-art.md +382 -0
  40. package/commands/scr/cultural-adaptation.md +177 -0
  41. package/commands/scr/demo.md +93 -0
  42. package/commands/scr/dialogue-audit.md +143 -0
  43. package/commands/scr/discuss.md +118 -0
  44. package/commands/scr/discussion-questions.md +129 -0
  45. package/commands/scr/do.md +68 -0
  46. package/commands/scr/draft.md +97 -0
  47. package/commands/scr/editor-review.md +466 -0
  48. package/commands/scr/export.md +942 -0
  49. package/commands/scr/fast.md +65 -0
  50. package/commands/scr/front-matter.md +696 -0
  51. package/commands/scr/health.md +113 -0
  52. package/commands/scr/help.md +121 -0
  53. package/commands/scr/history.md +92 -0
  54. package/commands/scr/illustrate-scene.md +211 -0
  55. package/commands/scr/import.md +95 -0
  56. package/commands/scr/insert-unit.md +108 -0
  57. package/commands/scr/line-edit.md +146 -0
  58. package/commands/scr/manager.md +77 -0
  59. package/commands/scr/manuscript-stats.md +139 -0
  60. package/commands/scr/map-illustration.md +213 -0
  61. package/commands/scr/map-manuscript.md +134 -0
  62. package/commands/scr/merge-units.md +136 -0
  63. package/commands/scr/multi-publish.md +344 -0
  64. package/commands/scr/new-character.md +167 -0
  65. package/commands/scr/new-revision.md +50 -0
  66. package/commands/scr/new-work.md +148 -0
  67. package/commands/scr/next.md +125 -0
  68. package/commands/scr/originality-check.md +170 -0
  69. package/commands/scr/outline.md +131 -0
  70. package/commands/scr/pacing-analysis.md +170 -0
  71. package/commands/scr/panel-layout.md +225 -0
  72. package/commands/scr/pause-work.md +88 -0
  73. package/commands/scr/plan.md +112 -0
  74. package/commands/scr/plant-seed.md +57 -0
  75. package/commands/scr/plot-graph.md +199 -0
  76. package/commands/scr/polish.md +141 -0
  77. package/commands/scr/profile-writer.md +154 -0
  78. package/commands/scr/progress.md +51 -0
  79. package/commands/scr/publish.md +455 -0
  80. package/commands/scr/query-letter.md +183 -0
  81. package/commands/scr/quick-write.md +82 -0
  82. package/commands/scr/relationship-map.md +129 -0
  83. package/commands/scr/remove-unit.md +120 -0
  84. package/commands/scr/reorder-units.md +126 -0
  85. package/commands/scr/resume-work.md +97 -0
  86. package/commands/scr/sacred/annotation-layer.md +105 -0
  87. package/commands/scr/sacred/chronology.md +121 -0
  88. package/commands/scr/sacred/concordance.md +88 -0
  89. package/commands/scr/sacred/cross-reference.md +97 -0
  90. package/commands/scr/sacred/doctrinal-check.md +129 -0
  91. package/commands/scr/sacred/genealogy.md +107 -0
  92. package/commands/scr/sacred/source-tracking.md +101 -0
  93. package/commands/scr/sacred/verse-numbering.md +103 -0
  94. package/commands/scr/sacred-numbering-format.md +103 -0
  95. package/commands/scr/save.md +109 -0
  96. package/commands/scr/scan.md +291 -0
  97. package/commands/scr/sensitivity-review.md +169 -0
  98. package/commands/scr/series-bible.md +127 -0
  99. package/commands/scr/session-report.md +80 -0
  100. package/commands/scr/settings.md +58 -0
  101. package/commands/scr/split-unit.md +123 -0
  102. package/commands/scr/spread-layout.md +187 -0
  103. package/commands/scr/storyboard.md +262 -0
  104. package/commands/scr/subject-touch.md +168 -0
  105. package/commands/scr/submit.md +50 -0
  106. package/commands/scr/subplot-map.md +147 -0
  107. package/commands/scr/sync.md +116 -0
  108. package/commands/scr/synopsis.md +137 -0
  109. package/commands/scr/theme-tracker.md +128 -0
  110. package/commands/scr/thread.md +83 -0
  111. package/commands/scr/timeline.md +141 -0
  112. package/commands/scr/track.md +564 -0
  113. package/commands/scr/translate.md +260 -0
  114. package/commands/scr/translation-glossary.md +298 -0
  115. package/commands/scr/translation-memory.md +310 -0
  116. package/commands/scr/troubleshoot.md +59 -0
  117. package/commands/scr/undo.md +106 -0
  118. package/commands/scr/validate.md +133 -0
  119. package/commands/scr/versions.md +94 -0
  120. package/commands/scr/voice-check.md +133 -0
  121. package/commands/scr/voice-test.md +68 -0
  122. package/data/CONSTRAINTS.json +1606 -0
  123. package/data/demo/.manuscript/BRIEF.md +37 -0
  124. package/data/demo/.manuscript/CHARACTERS.md +90 -0
  125. package/data/demo/.manuscript/OUTLINE.md +46 -0
  126. package/data/demo/.manuscript/PLOT-GRAPH.md +75 -0
  127. package/data/demo/.manuscript/STATE.md +44 -0
  128. package/data/demo/.manuscript/STYLE-GUIDE.md +119 -0
  129. package/data/demo/.manuscript/THEMES.md +51 -0
  130. package/data/demo/.manuscript/WORK.md +51 -0
  131. package/data/demo/.manuscript/config.json +59 -0
  132. package/data/demo/.manuscript/drafts/body/1-the-letter-DRAFT.md +51 -0
  133. package/data/demo/.manuscript/drafts/body/2-the-workshop-DRAFT.md +51 -0
  134. package/data/demo/.manuscript/drafts/body/3-the-pier-DRAFT.md +45 -0
  135. package/data/demo/.manuscript/drafts/body/4-the-clock-DRAFT.md +59 -0
  136. package/data/demo/.manuscript/plans/5-the-reunion-PLAN.md +52 -0
  137. package/data/demo/.manuscript/reviews/2-the-workshop-REVIEW.md +61 -0
  138. package/data/export-templates/scriveno-academic.latex +184 -0
  139. package/data/export-templates/scriveno-acm.latex +67 -0
  140. package/data/export-templates/scriveno-apa7.latex +83 -0
  141. package/data/export-templates/scriveno-book.typst +175 -0
  142. package/data/export-templates/scriveno-chapbook.typst +121 -0
  143. package/data/export-templates/scriveno-elsevier.latex +76 -0
  144. package/data/export-templates/scriveno-epub.css +386 -0
  145. package/data/export-templates/scriveno-fixed-layout-epub.css +76 -0
  146. package/data/export-templates/scriveno-fixed-layout.opf +23 -0
  147. package/data/export-templates/scriveno-ieee.latex +77 -0
  148. package/data/export-templates/scriveno-lncs.latex +79 -0
  149. package/data/export-templates/scriveno-picturebook.typst +113 -0
  150. package/data/export-templates/scriveno-poetry-submission-styles.md +45 -0
  151. package/data/export-templates/scriveno-poetry-submission.docx +0 -0
  152. package/data/export-templates/scriveno-smashwords-styles.md +45 -0
  153. package/data/export-templates/scriveno-smashwords.docx +0 -0
  154. package/data/export-templates/scriveno-stageplay.typst +129 -0
  155. package/data/proof/creative-context/README.md +79 -0
  156. package/data/proof/voice-dna/GUIDED-SAMPLE.md +19 -0
  157. package/data/proof/voice-dna/README.md +45 -0
  158. package/data/proof/voice-dna/STYLE-GUIDE-EXCERPT.md +43 -0
  159. package/data/proof/voice-dna/UNGUIDED-SAMPLE.md +11 -0
  160. package/data/proof/watchmaker-flow/README.md +78 -0
  161. package/docs/architecture.md +425 -0
  162. package/docs/command-reference.md +2384 -0
  163. package/docs/configuration.md +228 -0
  164. package/docs/context-protocol.md +81 -0
  165. package/docs/contributing.md +430 -0
  166. package/docs/creative-context.md +158 -0
  167. package/docs/development.md +152 -0
  168. package/docs/drafter-quality.md +127 -0
  169. package/docs/getting-started.md +198 -0
  170. package/docs/history-protocol.md +96 -0
  171. package/docs/proof-artifacts.md +56 -0
  172. package/docs/publishing.md +296 -0
  173. package/docs/release-notes.md +457 -0
  174. package/docs/runtime-support.md +77 -0
  175. package/docs/sacred-texts.md +296 -0
  176. package/docs/shipped-assets.md +129 -0
  177. package/docs/testing.md +156 -0
  178. package/docs/translation.md +343 -0
  179. package/docs/voice-dna.md +297 -0
  180. package/docs/work-types.md +339 -0
  181. package/lib/architectural-profiles.js +134 -0
  182. package/package.json +54 -0
  183. package/templates/BRIEF.md +51 -0
  184. package/templates/CHARACTERS.md +64 -0
  185. package/templates/CONTEXT.md +56 -0
  186. package/templates/OUTLINE.md +36 -0
  187. package/templates/RECORD.md +68 -0
  188. package/templates/STATE.md +50 -0
  189. package/templates/STYLE-GUIDE.md +121 -0
  190. package/templates/THEMES.md +36 -0
  191. package/templates/WORK.md +67 -0
  192. package/templates/WORLD.md +62 -0
  193. package/templates/WRITING-RULES.md +156 -0
  194. package/templates/academic/ARGUMENT-MAP.md +40 -0
  195. package/templates/academic/CONCEPTS.md +34 -0
  196. package/templates/academic/CONTEXT.md +29 -0
  197. package/templates/academic/PROPOSAL.md +37 -0
  198. package/templates/academic/QUESTIONS.md +24 -0
  199. package/templates/config.json +72 -0
  200. package/templates/pitfalls/comic.md +54 -0
  201. package/templates/pitfalls/commentary.md +62 -0
  202. package/templates/pitfalls/memoir.md +48 -0
  203. package/templates/pitfalls/novel.md +53 -0
  204. package/templates/pitfalls/poetry_collection.md +63 -0
  205. package/templates/pitfalls/research_paper.md +66 -0
  206. package/templates/pitfalls/runbook.md +64 -0
  207. package/templates/pitfalls/screenplay.md +54 -0
  208. package/templates/platforms/README.md +16 -0
  209. package/templates/platforms/apple/manifest.yaml +20 -0
  210. package/templates/platforms/bn/manifest.yaml +20 -0
  211. package/templates/platforms/d2d/manifest.yaml +20 -0
  212. package/templates/platforms/google/manifest.yaml +20 -0
  213. package/templates/platforms/ingram/manifest.yaml +44 -0
  214. package/templates/platforms/kdp/manifest.yaml +42 -0
  215. package/templates/platforms/kobo/manifest.yaml +20 -0
  216. package/templates/platforms/smashwords/manifest.yaml +26 -0
  217. package/templates/sacred/COSMOLOGY.md +88 -0
  218. package/templates/sacred/DOCTRINES.md +45 -0
  219. package/templates/sacred/FIGURES.md +69 -0
  220. package/templates/sacred/FRAMEWORK.md +98 -0
  221. package/templates/sacred/LINEAGES.md +52 -0
  222. package/templates/sacred/README.md +20 -0
  223. package/templates/sacred/THEOLOGICAL-ARC.md +69 -0
  224. package/templates/sacred/catholic/manifest.yaml +93 -0
  225. package/templates/sacred/islamic-hafs/manifest.yaml +134 -0
  226. package/templates/sacred/islamic-warsh/manifest.yaml +134 -0
  227. package/templates/sacred/jewish/manifest.yaml +56 -0
  228. package/templates/sacred/orthodox/manifest.yaml +98 -0
  229. package/templates/sacred/pali/manifest.yaml +20 -0
  230. package/templates/sacred/protestant/manifest.yaml +86 -0
  231. package/templates/sacred/sanskrit/manifest.yaml +20 -0
  232. package/templates/sacred/tewahedo/manifest.yaml +106 -0
  233. package/templates/sacred/tibetan/manifest.yaml +20 -0
  234. package/templates/technical/AUDIENCE.md +26 -0
  235. package/templates/technical/DEPENDENCIES.md +19 -0
  236. package/templates/technical/DOC-BRIEF.md +45 -0
  237. package/templates/technical/PROCEDURES.md +37 -0
  238. package/templates/technical/REFERENCES.md +36 -0
  239. package/templates/technical/SYSTEM.md +25 -0
package/bin/install.js ADDED
@@ -0,0 +1,1620 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+ const crypto = require('crypto');
8
+ const architecturalProfiles = require('../lib/architectural-profiles.js');
9
+
10
+ const PKG_ROOT = path.join(__dirname, '..');
11
+ const PKG = require('../package.json');
12
+ const VERSION = PKG.version;
13
+ const DOCS_URL = PKG.homepage || PKG.repository?.url || 'https://github.com/aihxp/scriveno';
14
+ const MIN_NODE_MAJOR = 20;
15
+
16
+ const COLORS = {
17
+ reset: '\x1b[0m',
18
+ bold: '\x1b[1m',
19
+ dim: '\x1b[2m',
20
+ cyan: '\x1b[36m',
21
+ green: '\x1b[32m',
22
+ yellow: '\x1b[33m',
23
+ red: '\x1b[31m',
24
+ gray: '\x1b[90m',
25
+ };
26
+
27
+ function c(color, text) { return `${COLORS[color]}${text}${COLORS.reset}`; }
28
+ function shellQuote(value) {
29
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
30
+ }
31
+
32
+ // Escape a string for safe embedding inside a YAML double-quoted scalar.
33
+ // Handles both `\` and `"` -- bare backslashes are invalid in YAML double-quoted
34
+ // scalars, so they must be escaped before the `"`-escaping pass.
35
+ function yamlDoubleQuoted(s) {
36
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
37
+ }
38
+
39
+ function buildFilesystemMcpCommand(allowedDirs) {
40
+ return `npx -y @modelcontextprotocol/server-filesystem ${allowedDirs.map(shellQuote).join(' ')}`;
41
+ }
42
+
43
+ function generatePerplexitySetupGuide({ isGlobal, guideDir, dataDir, currentProjectDir }) {
44
+ const connectorCommand = isGlobal
45
+ ? buildFilesystemMcpCommand(['/absolute/path/to/project', dataDir])
46
+ : buildFilesystemMcpCommand([currentProjectDir, dataDir]);
47
+ const currentProjectCommand = buildFilesystemMcpCommand([currentProjectDir, dataDir]);
48
+
49
+ return `# Scriveno for Perplexity Desktop
50
+
51
+ This setup target prepares Scriveno for **Perplexity Desktop on macOS** using Perplexity's documented **local MCP connector** flow.
52
+
53
+ ## What this target supports
54
+
55
+ - Guided setup assets for Perplexity Desktop
56
+ - Local filesystem access to a Scriveno project and Scriveno's shared data
57
+ - Honest runtime framing: this is **not** slash-command parity with Claude Code, Codex, Cursor, or Gemini CLI
58
+
59
+ ## Prerequisites
60
+
61
+ 1. Install **Perplexity Desktop** from the Mac App Store
62
+ 2. In Perplexity Desktop, open **Settings -> Connectors**
63
+ 3. Install the **PerplexityXPC** helper when prompted
64
+ 4. Ensure Node.js >=20.0.0 is available so \`npx\` can run the filesystem MCP server
65
+
66
+ ## Add the connector
67
+
68
+ In Perplexity Desktop:
69
+
70
+ 1. Open **Settings -> Connectors**
71
+ 2. Click **Add Connector**
72
+ 3. In the **Simple** tab, choose any server name such as \`Scriveno Project Files\`
73
+ 4. Paste this command:
74
+
75
+ \`\`\`bash
76
+ ${connectorCommand}
77
+ \`\`\`
78
+
79
+ 5. Save and wait for the connector to show **Running**
80
+ 6. Toggle the connector on from **Sources** when you want Perplexity to access your Scriveno files
81
+
82
+ ## Current project command
83
+
84
+ This installer was run from:
85
+
86
+ \`\`\`
87
+ ${currentProjectDir}
88
+ \`\`\`
89
+
90
+ If you want a command that is ready for this specific project right now, use:
91
+
92
+ \`\`\`bash
93
+ ${currentProjectCommand}
94
+ \`\`\`
95
+
96
+ ## Notes
97
+
98
+ - ${isGlobal ? 'Global install stores shared setup assets under your home directory, but the MCP connector itself still needs a project path.' : 'Project install points the connector at this project and its local .scriveno directory.'}
99
+ - Keep the allowed directories narrow. Prefer the project root and the matching Scriveno data directory only.
100
+ - Voice-critical drafting still depends on explicit \`STYLE-GUIDE.md\` loading per unit. Perplexity memory or spaces are not a substitute for Scriveno's Voice DNA pipeline.
101
+
102
+ ## Installed assets
103
+
104
+ - Guide directory: \`${guideDir}\`
105
+ - Scriveno data directory: \`${dataDir}\`
106
+ `;
107
+ }
108
+
109
+ const BANNER = `
110
+ ${c('bold', 'Scriveno')} ${c('gray', 'v' + VERSION)}
111
+ ${c('dim', 'Spec-driven creative writing, publishing, and translation for AI coding agents.')}
112
+ `;
113
+
114
+ const RUNTIME_SUPPORT_NOTE = c(
115
+ 'dim',
116
+ 'Installer requires Node.js >=20.0.0. Use a current LTS for new installs.'
117
+ );
118
+
119
+ const RUNTIMES = {
120
+ 'claude-code': {
121
+ label: 'Claude Code',
122
+ type: 'commands',
123
+ commands_dir_global: path.join(os.homedir(), '.claude', 'commands'),
124
+ commands_dir_project: '.claude/commands',
125
+ agents_dir_global: path.join(os.homedir(), '.claude', 'agents'),
126
+ agents_dir_project: '.claude/agents',
127
+ command_layout: 'flat-prefixed',
128
+ detect: () => fs.existsSync(path.join(os.homedir(), '.claude')),
129
+ },
130
+ 'cursor': {
131
+ label: 'Cursor',
132
+ type: 'commands',
133
+ commands_dir_global: path.join(os.homedir(), '.cursor', 'commands', 'scr'),
134
+ commands_dir_project: '.cursor/commands/scr',
135
+ agents_dir_global: path.join(os.homedir(), '.cursor', 'agents'),
136
+ agents_dir_project: '.cursor/agents',
137
+ detect: () => fs.existsSync(path.join(os.homedir(), '.cursor')),
138
+ },
139
+ 'gemini-cli': {
140
+ label: 'Gemini CLI',
141
+ type: 'commands',
142
+ commands_dir_global: path.join(os.homedir(), '.gemini', 'commands', 'scr'),
143
+ commands_dir_project: '.gemini/commands/scr',
144
+ agents_dir_global: path.join(os.homedir(), '.gemini', 'agents'),
145
+ agents_dir_project: '.gemini/agents',
146
+ detect: () => fs.existsSync(path.join(os.homedir(), '.gemini')),
147
+ },
148
+ 'codex': {
149
+ label: 'Codex',
150
+ type: 'skills',
151
+ skills_dir_global: path.join(os.homedir(), '.codex', 'skills'),
152
+ skills_dir_project: '.codex/skills',
153
+ commands_dir_global: path.join(os.homedir(), '.codex', 'commands', 'scr'),
154
+ commands_dir_project: '.codex/commands/scr',
155
+ agents_dir_global: path.join(os.homedir(), '.codex', 'agents'),
156
+ agents_dir_project: '.codex/agents',
157
+ skill_style: 'per-command',
158
+ detect: () => fs.existsSync(path.join(os.homedir(), '.codex')),
159
+ },
160
+ 'opencode': {
161
+ label: 'OpenCode',
162
+ type: 'commands',
163
+ commands_dir_global: path.join(os.homedir(), '.config', 'opencode', 'commands', 'scr'),
164
+ commands_dir_project: '.config/opencode/commands/scr',
165
+ agents_dir_global: path.join(os.homedir(), '.config', 'opencode', 'agents'),
166
+ agents_dir_project: '.config/opencode/agents',
167
+ detect: () => fs.existsSync(path.join(os.homedir(), '.config', 'opencode')),
168
+ },
169
+ 'copilot': {
170
+ label: 'GitHub Copilot',
171
+ type: 'commands',
172
+ commands_dir_global: path.join(os.homedir(), '.github', 'commands', 'scr'),
173
+ commands_dir_project: '.github/commands/scr',
174
+ agents_dir_global: path.join(os.homedir(), '.github', 'agents'),
175
+ agents_dir_project: '.github/agents',
176
+ detect: () => fs.existsSync(path.join(os.homedir(), '.github')),
177
+ },
178
+ 'windsurf': {
179
+ label: 'Windsurf',
180
+ type: 'commands',
181
+ commands_dir_global: path.join(os.homedir(), '.windsurf', 'commands', 'scr'),
182
+ commands_dir_project: '.windsurf/commands/scr',
183
+ agents_dir_global: path.join(os.homedir(), '.windsurf', 'agents'),
184
+ agents_dir_project: '.windsurf/agents',
185
+ detect: () => fs.existsSync(path.join(os.homedir(), '.windsurf')),
186
+ },
187
+ 'antigravity': {
188
+ label: 'Antigravity',
189
+ type: 'commands',
190
+ commands_dir_global: path.join(os.homedir(), '.gemini', 'antigravity', 'commands', 'scr'),
191
+ commands_dir_project: '.gemini/antigravity/commands/scr',
192
+ agents_dir_global: path.join(os.homedir(), '.gemini', 'antigravity', 'agents'),
193
+ agents_dir_project: '.gemini/antigravity/agents',
194
+ detect: () => fs.existsSync(path.join(os.homedir(), '.gemini', 'antigravity')),
195
+ },
196
+ 'manus': {
197
+ label: 'Manus Desktop',
198
+ type: 'skills',
199
+ skills_dir_global: path.join(os.homedir(), '.manus', 'skills', 'scriveno'),
200
+ skills_dir_project: '.manus/skills/scriveno',
201
+ detect: () => fs.existsSync(path.join(os.homedir(), '.manus')) || fs.existsSync('/Applications/Manus.app') || fs.existsSync(path.join(os.homedir(), 'Applications', 'Manus.app')),
202
+ },
203
+ 'perplexity-desktop': {
204
+ label: 'Perplexity Desktop',
205
+ type: 'guided-mcp',
206
+ guide_dir_global: path.join(os.homedir(), '.scriveno', 'perplexity'),
207
+ guide_dir_project: '.scriveno/perplexity',
208
+ detect: () => fs.existsSync('/Applications/Perplexity.app') || fs.existsSync(path.join(os.homedir(), 'Applications', 'Perplexity.app')),
209
+ },
210
+ 'generic': {
211
+ label: 'Generic (SKILL.md)',
212
+ type: 'skills',
213
+ skills_dir_global: path.join(os.homedir(), '.scriveno', 'skills'),
214
+ skills_dir_project: '.scriveno/skills',
215
+ detect: () => false,
216
+ },
217
+ };
218
+
219
+ function generateSkillManifest(constraintsPath) {
220
+ const commandsRoot = path.join(PKG_ROOT, 'commands', 'scr');
221
+ const entries = collectCanonicalCommandInventory(commandsRoot, constraintsPath).map((entry) => ({
222
+ name: entry.commandRef,
223
+ category: entry.category,
224
+ description: entry.description,
225
+ }));
226
+
227
+ // Sort by category, then alphabetically by name within category
228
+ entries.sort((a, b) => {
229
+ if (a.category < b.category) return -1;
230
+ if (a.category > b.category) return 1;
231
+ return a.name.localeCompare(b.name);
232
+ });
233
+
234
+ // Build markdown table
235
+ const tableRows = entries.map(e => `| ${e.name} | ${e.category} | ${e.description} |`);
236
+
237
+ return `# Scriveno -- AI Creative Writing Skills
238
+
239
+ Version: ${VERSION}
240
+
241
+ Scriveno is a spec-driven creative writing, publishing, and translation pipeline.
242
+
243
+ ## Available Commands
244
+
245
+ | Command | Category | Description |
246
+ |---------|----------|-------------|
247
+ ${tableRows.join('\n')}
248
+
249
+ ## Usage
250
+
251
+ Each command above has a detailed instruction file in the \`commands/scr/\` subdirectory.
252
+ To use a command, read the corresponding \`.md\` file and follow its instructions.
253
+
254
+ ## Quick Start
255
+
256
+ 1. Run \`/scr:help\` to see commands grouped by stage
257
+ 2. Run \`/scr:new-work\` to start a new project
258
+ 3. Run \`/scr:demo\` to explore a sample project
259
+ `;
260
+ }
261
+
262
+ function stripWrappingQuotes(value) {
263
+ if (!value) return '';
264
+ const trimmed = value.trim();
265
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
266
+ return trimmed.slice(1, -1);
267
+ }
268
+ return trimmed;
269
+ }
270
+
271
+ function extractFrontmatterBlock(content) {
272
+ if (typeof content !== 'string' || content.length === 0) return null;
273
+ // Strip a leading UTF-8 BOM if present so the first-line check is robust.
274
+ const stripped = content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content;
275
+ const lines = stripped.split(/\r?\n/);
276
+ if (lines.length === 0 || lines[0] !== '---') return null;
277
+ for (let i = 1; i < lines.length; i++) {
278
+ if (lines[i] === '---' || lines[i] === '...') {
279
+ return lines.slice(1, i);
280
+ }
281
+ }
282
+ // No closing fence -- treat as malformed / no frontmatter.
283
+ return null;
284
+ }
285
+
286
+ function stripInlineComment(rawValue) {
287
+ const trimmedLeading = rawValue.replace(/^\s+/, '');
288
+ if (trimmedLeading.startsWith('"') || trimmedLeading.startsWith('\'')) {
289
+ // Preserve `#` inside quoted values; do not attempt to parse quote escaping beyond
290
+ // the simple wrapping-quote behavior already handled by stripWrappingQuotes.
291
+ return rawValue;
292
+ }
293
+ // YAML inline comments require whitespace before `#`.
294
+ // Use [ \t] rather than \s so newline whitespace does not trigger truncation
295
+ // if a multi-line string is ever fed in (defensive for future refactors).
296
+ const idx = rawValue.search(/[ \t]#/);
297
+ if (idx === -1) return rawValue;
298
+ return rawValue.slice(0, idx);
299
+ }
300
+
301
+ function readFrontmatterValues(content) {
302
+ const lines = extractFrontmatterBlock(content);
303
+ const result = {};
304
+ if (!lines) return result;
305
+
306
+ for (const line of lines) {
307
+ if (line.length === 0) continue;
308
+ const leading = line.replace(/^\s+/, '');
309
+ if (leading.length === 0) continue;
310
+ if (leading.startsWith('#')) continue; // YAML comment line
311
+
312
+ const idx = line.indexOf(':');
313
+ if (idx === -1) continue;
314
+
315
+ const key = line.slice(0, idx).trim();
316
+ if (!key) continue;
317
+ if (Object.prototype.hasOwnProperty.call(result, key)) {
318
+ // L-02: warn on duplicate keys; we retain first-occurrence-wins to avoid
319
+ // changing downstream behavior, but surface the edit bug.
320
+ try {
321
+ console.warn(
322
+ `[scriveno] frontmatter duplicate key "${key}" -- first occurrence retained; later value ignored`
323
+ );
324
+ } catch { /* best effort */ }
325
+ continue;
326
+ }
327
+
328
+ let value = line.slice(idx + 1);
329
+ value = stripInlineComment(value);
330
+ // M-03: detect YAML block-scalar indicators (| or >). The parser does not
331
+ // support multi-line continuation; warn and fall back to an empty value so
332
+ // Codex skill metadata does not ship a literal `|` / `>`.
333
+ const leadingValue = value.replace(/^\s+/, '');
334
+ if (leadingValue.startsWith('|') || leadingValue.startsWith('>')) {
335
+ try {
336
+ console.warn(
337
+ `[scriveno] frontmatter key "${key}" uses a YAML block scalar (${leadingValue[0]}); falling back to empty value`
338
+ );
339
+ } catch { /* best effort */ }
340
+ result[key] = '';
341
+ continue;
342
+ }
343
+ value = stripWrappingQuotes(value);
344
+ result[key] = value;
345
+ }
346
+
347
+ return result;
348
+ }
349
+
350
+ function readFrontmatterValue(content, key) {
351
+ const values = readFrontmatterValues(content);
352
+ return Object.prototype.hasOwnProperty.call(values, key) ? values[key] : '';
353
+ }
354
+
355
+ function commandRefToCodexSkillName(commandRef) {
356
+ if (commandRef === '/scr:sacred-verse-numbering') {
357
+ return 'scr-tradition-verse-numbering';
358
+ }
359
+ return commandRef
360
+ .replace(/^\/scr:/, 'scr-')
361
+ .replace(/:/g, '-');
362
+ }
363
+
364
+ function commandRefToConstraintKey(commandRef) {
365
+ return commandRef.replace(/^\/scr:/, '');
366
+ }
367
+
368
+ function commandRefToClaudeInvocation(commandRef) {
369
+ return `/${commandRefToCodexSkillName(commandRef)}`;
370
+ }
371
+
372
+ function commandRefToCodexInvocation(commandRef) {
373
+ return `$${commandRefToCodexSkillName(commandRef)}`;
374
+ }
375
+
376
+ function commandEntryToFlatCommandFileName(entry) {
377
+ return `${commandRefToCodexSkillName(entry.commandRef)}.md`;
378
+ }
379
+
380
+ function collectCommandEntries(commandsRoot) {
381
+ const entries = [];
382
+
383
+ function walk(dir, segments = []) {
384
+ if (!fs.existsSync(dir)) return;
385
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
386
+ if (entry.isDirectory()) {
387
+ walk(path.join(dir, entry.name), segments.concat(entry.name));
388
+ continue;
389
+ }
390
+ if (!entry.name.endsWith('.md')) continue;
391
+
392
+ const relSegments = segments.concat(entry.name.replace(/\.md$/, ''));
393
+ const relPath = path.join(...segments, entry.name);
394
+ const filePath = path.join(dir, entry.name);
395
+ const content = fs.readFileSync(filePath, 'utf8');
396
+ const commandTail = relSegments.join(':');
397
+ const commandRef = `/scr:${commandTail}`;
398
+ const description = readFrontmatterValue(content, 'description') || commandTail.replace(/[:\-]/g, ' ');
399
+ const argumentHint = readFrontmatterValue(content, 'argument-hint');
400
+
401
+ entries.push({
402
+ commandRef,
403
+ skillName: commandRefToCodexSkillName(commandRef),
404
+ description,
405
+ argumentHint,
406
+ relativePath: relPath,
407
+ });
408
+ }
409
+ }
410
+
411
+ walk(commandsRoot);
412
+ entries.sort((a, b) => a.commandRef.localeCompare(b.commandRef));
413
+ assertNoSkillNameCollisions(entries);
414
+ return entries;
415
+ }
416
+
417
+ // Both Claude (flat scr-foo.md filename) and Codex (per-command skill dir
418
+ // scr-foo/SKILL.md) install commands keyed by the same skill-name function:
419
+ // /scr:foo and /scr:foo:bar both flatten under commandRefToCodexSkillName by
420
+ // stripping `/scr:` and replacing remaining `:` with `-`. So
421
+ // /scr:sacred-verse-numbering and /scr:sacred:verse-numbering both produce
422
+ // scr-sacred-verse-numbering, and at install time the second one written
423
+ // silently overwrites the first.
424
+ //
425
+ // This check is the early gate. Run it once at collection time so every
426
+ // install path (Claude flat, Codex skill, generic SKILL.md) sees the same
427
+ // guarantee: no two source files can claim the same flat skill name.
428
+ function assertNoSkillNameCollisions(entries) {
429
+ const seen = new Map();
430
+ const collisions = [];
431
+ for (const entry of entries) {
432
+ const existing = seen.get(entry.skillName);
433
+ if (existing) {
434
+ collisions.push({ skillName: entry.skillName, sources: [existing.relativePath, entry.relativePath] });
435
+ } else {
436
+ seen.set(entry.skillName, entry);
437
+ }
438
+ }
439
+ if (collisions.length === 0) return;
440
+
441
+ const lines = collisions.map(c =>
442
+ ` ${c.skillName}\n <- ${c.sources[0]}\n <- ${c.sources[1]}`
443
+ );
444
+ throw new Error(
445
+ `Scriveno installer aborted: two or more source command files flatten to the same skill name.\n` +
446
+ `Both Claude (flat scr-foo.md filenames) and Codex (per-command skill directories) would silently\n` +
447
+ `overwrite one of each pair. Rename one source file in each pair so the flat names differ.\n\n` +
448
+ lines.join('\n\n')
449
+ );
450
+ }
451
+
452
+ function collectCanonicalCommandInventory(commandsRoot, constraintsPath = path.join(PKG_ROOT, 'data', 'CONSTRAINTS.json')) {
453
+ const constraints = JSON.parse(fs.readFileSync(constraintsPath, 'utf8'));
454
+ const commandMetadata = constraints.commands || {};
455
+
456
+ return collectCommandEntries(commandsRoot).map((entry) => {
457
+ const key = commandRefToConstraintKey(entry.commandRef);
458
+ const metadata = commandMetadata[key] || {};
459
+ return {
460
+ ...entry,
461
+ category: metadata.category || 'uncategorized',
462
+ description: metadata.description || entry.description || key.replace(/-/g, ' '),
463
+ };
464
+ });
465
+ }
466
+
467
+ function generateCodexSkill(entry, commandPath) {
468
+ const invocation = commandRefToCodexInvocation(entry.commandRef);
469
+ const shortDescription = entry.description.length > 120
470
+ ? `${entry.description.slice(0, 117)}...`
471
+ : entry.description;
472
+ const argumentsLine = entry.argumentHint
473
+ ? `- Treat any text after \`${invocation}\` as the arguments for the underlying Scriveno command ${entry.argumentHint}.`
474
+ : `- Treat any text after \`${invocation}\` as the arguments for the underlying Scriveno command.`;
475
+
476
+ return `---
477
+ name: "${entry.skillName}"
478
+ description: "${yamlDoubleQuoted(entry.description)}"
479
+ metadata:
480
+ short-description: "${yamlDoubleQuoted(shortDescription)}"
481
+ ---
482
+
483
+ <codex_skill_adapter>
484
+ ## Invocation
485
+ - This skill is invoked by mentioning \`${invocation}\`.
486
+ ${argumentsLine}
487
+ - When the installed Scriveno command file mentions \`/scr:...\`, rewrite that command surface for Codex users as \`$scr-...\`.
488
+ - Example: \`/scr:help\` becomes \`$scr-help\`
489
+ - Example: \`/scr:new-work\` becomes \`$scr-new-work\`
490
+ - Example: \`/scr:sacred:concordance\` becomes \`$scr-sacred-concordance\`
491
+ </codex_skill_adapter>
492
+
493
+ <objective>
494
+ Execute Scriveno's \`${entry.commandRef}\` command inside Codex by reading the installed Scriveno command file below as the source of truth.
495
+ </objective>
496
+
497
+ <context>
498
+ Installed command file: ${commandPath}
499
+ </context>
500
+
501
+ <process>
502
+ 1. Read \`${commandPath}\`.
503
+ 2. Execute that command file exactly as written.
504
+ 3. Treat text after \`${invocation}\` as the command arguments.
505
+ 4. When suggesting other Scriveno commands to Codex users, translate \`/scr:...\` references to the \`$scr-...\` surface.
506
+ </process>
507
+ `;
508
+ }
509
+
510
+ function listRelativeFiles(dir, prefix = '') {
511
+ if (!fs.existsSync(dir)) return [];
512
+ const files = [];
513
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
514
+ const rel = path.join(prefix, entry.name);
515
+ const abs = path.join(dir, entry.name);
516
+ if (entry.isDirectory()) {
517
+ files.push(...listRelativeFiles(abs, rel));
518
+ } else {
519
+ files.push(rel);
520
+ }
521
+ }
522
+ return files;
523
+ }
524
+
525
+ function cleanMirroredFiles(srcDir, destDir) {
526
+ if (!fs.existsSync(srcDir) || !fs.existsSync(destDir)) return 0;
527
+ let removed = 0;
528
+ for (const relPath of listRelativeFiles(srcDir)) {
529
+ const destPath = path.join(destDir, relPath);
530
+ if (fs.existsSync(destPath)) {
531
+ fs.rmSync(destPath, { force: true });
532
+ removed++;
533
+ }
534
+ }
535
+ return removed;
536
+ }
537
+
538
+ function removePathIfExists(targetPath) {
539
+ if (!fs.existsSync(targetPath)) return false;
540
+ fs.rmSync(targetPath, { recursive: true, force: true });
541
+ return true;
542
+ }
543
+
544
+ function atomicWriteFileSync(targetPath, content) {
545
+ const dir = path.dirname(targetPath);
546
+ fs.mkdirSync(dir, { recursive: true });
547
+ const tmpPath = `${targetPath}.tmp.${crypto.randomUUID()}`;
548
+ const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content));
549
+ let fd;
550
+ try {
551
+ fd = fs.openSync(tmpPath, 'w');
552
+ fs.writeSync(fd, buffer, 0, buffer.length, 0);
553
+ fs.fsyncSync(fd);
554
+ fs.closeSync(fd);
555
+ fd = undefined;
556
+ fs.renameSync(tmpPath, targetPath);
557
+ // H-01: fsync the parent directory so the rename is durable on crash.
558
+ // Best effort -- Windows rejects dir fsync with EISDIR/EPERM; some network
559
+ // filesystems also reject it. Swallow any error to preserve existing
560
+ // cross-platform behavior.
561
+ try {
562
+ const dfd = fs.openSync(dir, 'r');
563
+ try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
564
+ } catch { /* best effort -- Windows rejects dir fsync */ }
565
+ } catch (err) {
566
+ if (fd !== undefined) {
567
+ try { fs.closeSync(fd); } catch { /* best effort */ }
568
+ }
569
+ try { fs.unlinkSync(tmpPath); } catch { /* best effort */ }
570
+ throw err;
571
+ }
572
+ }
573
+
574
+ // L-01: match the exact canonical UUID shape emitted by crypto.randomUUID(),
575
+ // so a user file incidentally named `foo.tmp.<36 dashes>` is NOT deleted.
576
+ const ORPHAN_TMP_PATTERN = /\.tmp\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
577
+
578
+ // M-02: orphan tmp files can sit deep in skill directories (e.g.
579
+ // `~/.codex/skills/scr-help/SKILL.md.tmp.<uuid>`). Sweep recursively with a
580
+ // depth cap so an adversarial / pathological tree cannot hang the installer.
581
+ const ORPHAN_SWEEP_MAX_DEPTH = 4;
582
+
583
+ function cleanOrphanedTempFiles(dir, _depth = 0) {
584
+ if (!fs.existsSync(dir)) return 0;
585
+ let removed = 0;
586
+ let entries;
587
+ try {
588
+ entries = fs.readdirSync(dir, { withFileTypes: true });
589
+ } catch {
590
+ return 0;
591
+ }
592
+ for (const entry of entries) {
593
+ const full = path.join(dir, entry.name);
594
+ if (entry.isDirectory()) {
595
+ if (_depth < ORPHAN_SWEEP_MAX_DEPTH) {
596
+ removed += cleanOrphanedTempFiles(full, _depth + 1);
597
+ }
598
+ continue;
599
+ }
600
+ if (!entry.isFile()) continue;
601
+ if (!ORPHAN_TMP_PATTERN.test(entry.name)) continue;
602
+ try {
603
+ fs.unlinkSync(full);
604
+ removed++;
605
+ } catch { /* best effort */ }
606
+ }
607
+ return removed;
608
+ }
609
+
610
+ function insertMarkerComment(content, comment) {
611
+ if (content.startsWith('---\n')) {
612
+ const frontmatterEnd = content.indexOf('\n---\n', 4);
613
+ if (frontmatterEnd !== -1) {
614
+ const insertAt = frontmatterEnd + '\n---\n'.length;
615
+ return `${content.slice(0, insertAt)}${comment}\n${content.slice(insertAt)}`;
616
+ }
617
+ }
618
+ return `${comment}\n${content}`;
619
+ }
620
+
621
+ // Code-block-aware rewriter.
622
+ //
623
+ // Splits content into an ordered sequence of prose/code segments using
624
+ // CommonMark-ish fenced code block rules, then applies `transform` only to
625
+ // `/scr:*` references in prose. Code segments (including the fence lines)
626
+ // pass through byte-for-byte unchanged.
627
+ //
628
+ // Fence rules:
629
+ // - An opener is a line whose first non-whitespace content matches `^(?:`{3,}|~{3,})`.
630
+ // - A closer is a subsequent line whose first non-whitespace content is the
631
+ // SAME fence character repeated at least as many times as the opener.
632
+ // (`\`\`\`` does not close a `~~~` block and vice versa.)
633
+ // - If a code block has no closer before EOF, the remainder of the file is
634
+ // treated as code (fail-safe: prefer under-rewriting over mangling code).
635
+ //
636
+ // Indented (4-space / tab) code blocks are NOT detected -- only fenced blocks.
637
+ // This is intentional per Phase 27 CONTEXT: documentation snippets use fences.
638
+ function rewriteInstalledCommandRefs(content, transform) {
639
+ if (typeof content !== 'string' || content.length === 0) return content;
640
+
641
+ // Preserve original line endings by splitting on \r?\n but also tracking
642
+ // the separators. Simpler approach: split into lines and remember whether
643
+ // the original ended with a trailing newline so we can reconstruct.
644
+ const lines = content.split(/\n/);
645
+ // Note: because we split on /\n/, any \r is preserved at the end of each
646
+ // non-final line. We re-join with \n and the \r stays attached, preserving
647
+ // CRLF round-trip.
648
+
649
+ const out = [];
650
+ let i = 0;
651
+ const FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
652
+
653
+ while (i < lines.length) {
654
+ const line = lines[i];
655
+ const m = line.match(FENCE_RE);
656
+ if (!m) {
657
+ // prose line
658
+ out.push(line.replace(/\/scr:[a-z0-9:-]+/gi, (ref) => transform(ref)));
659
+ i++;
660
+ continue;
661
+ }
662
+ // Opener: emit as-is, then consume until matching closer or EOF.
663
+ const fenceChar = m[2][0]; // '`' or '~'
664
+ const fenceLen = m[2].length;
665
+ out.push(line);
666
+ i++;
667
+ while (i < lines.length) {
668
+ const inner = lines[i];
669
+ const mc = inner.match(FENCE_RE);
670
+ if (mc && mc[2][0] === fenceChar && mc[2].length >= fenceLen) {
671
+ // closer -- emit and exit code block
672
+ out.push(inner);
673
+ i++;
674
+ break;
675
+ }
676
+ // still inside code block -- emit verbatim
677
+ out.push(inner);
678
+ i++;
679
+ }
680
+ // If we fell out of the loop with no closer (i === lines.length without
681
+ // seeing a matching closer), the code block implicitly extends to EOF --
682
+ // the trailing lines were already pushed verbatim above.
683
+ }
684
+
685
+ return out.join('\n');
686
+ }
687
+
688
+ function markInstalledCommand(content, runtimeKey, commandRef, sourcePath) {
689
+ const marker = `<!-- scriveno-cli-installed-command runtime:${runtimeKey} command:${commandRef} source:${sourcePath} -->`;
690
+ return insertMarkerComment(content, marker);
691
+ }
692
+
693
+ function generateClaudeCommandContent(entry, sourceContent) {
694
+ const rewritten = rewriteInstalledCommandRefs(sourceContent, commandRefToClaudeInvocation);
695
+ return markInstalledCommand(rewritten, 'claude-code', commandRefToClaudeInvocation(entry.commandRef), entry.relativePath);
696
+ }
697
+
698
+ function generateCodexCommandContent(entry, sourceContent) {
699
+ const rewritten = rewriteInstalledCommandRefs(sourceContent, commandRefToCodexInvocation);
700
+ return markInstalledCommand(
701
+ rewritten,
702
+ 'codex',
703
+ commandRefToCodexInvocation(entry.commandRef),
704
+ entry.relativePath
705
+ );
706
+ }
707
+
708
+ function isScrivenoInstalledCommandFile(filePath) {
709
+ if (!fs.existsSync(filePath)) return false;
710
+ const content = fs.readFileSync(filePath, 'utf8');
711
+ return content.includes('scriveno-cli-installed-command');
712
+ }
713
+
714
+ function cleanFlatCommandFiles(commandsDir, currentFileNames, legacyDirs = []) {
715
+ if (!fs.existsSync(commandsDir)) return 0;
716
+
717
+ const manifestPath = path.join(commandsDir, '.scriveno-installed.json');
718
+ const manifest = readJsonIfExists(manifestPath);
719
+ const currentFileSet = new Set(currentFileNames);
720
+ const knownFileNames = new Set(Array.isArray(manifest?.files) ? manifest.files : []);
721
+ let removed = 0;
722
+
723
+ for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
724
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
725
+ const filePath = path.join(commandsDir, entry.name);
726
+ if (isScrivenoInstalledCommandFile(filePath)) {
727
+ knownFileNames.add(entry.name);
728
+ }
729
+ }
730
+
731
+ removePathIfExists(manifestPath);
732
+
733
+ for (const legacyDir of legacyDirs) {
734
+ if (removePathIfExists(path.join(commandsDir, legacyDir))) {
735
+ removed++;
736
+ }
737
+ }
738
+
739
+ for (const fileName of knownFileNames) {
740
+ if (!currentFileSet.has(fileName) && removePathIfExists(path.join(commandsDir, fileName))) {
741
+ removed++;
742
+ }
743
+ }
744
+
745
+ for (const fileName of currentFileNames) {
746
+ if (removePathIfExists(path.join(commandsDir, fileName))) {
747
+ removed++;
748
+ }
749
+ }
750
+
751
+ return removed;
752
+ }
753
+
754
+ function writeInstalledCommandManifest(commandsDir, runtimeKey, fileNames) {
755
+ const manifestPath = path.join(commandsDir, '.scriveno-installed.json');
756
+ const manifest = {
757
+ installer: 'scriveno-cli',
758
+ version: VERSION,
759
+ runtime: runtimeKey,
760
+ files: fileNames,
761
+ generated_at: new Date().toISOString(),
762
+ };
763
+ atomicWriteFileSync(manifestPath, JSON.stringify(manifest, null, 2));
764
+ }
765
+
766
+ function printHelp() {
767
+ console.log(BANNER);
768
+ console.log(`Usage:
769
+ scriveno
770
+ scriveno --runtimes codex,claude-code --global --writer --silent
771
+
772
+ Options:
773
+ --runtimes <list> Comma-separated runtime keys to install (for example: codex,claude-code)
774
+ --runtime <key> Repeatable single-runtime selector
775
+ --detected Install to every detected runtime
776
+ --global Install for all projects (default)
777
+ --project Install only in the current directory
778
+ --writer Use writer mode (default)
779
+ --developer Use developer mode
780
+ --silent Skip prompts and reduce output
781
+ --help Show this help text
782
+ --version Show the Scriveno package version
783
+
784
+ Runtime keys:
785
+ ${Object.keys(RUNTIMES).join(', ')}
786
+ `);
787
+ }
788
+
789
+ function parseArgs(argv) {
790
+ const options = {
791
+ runtimeKeys: [],
792
+ installDetected: false,
793
+ isGlobal: null,
794
+ developerMode: null,
795
+ silent: false,
796
+ showHelp: false,
797
+ showVersion: false,
798
+ };
799
+
800
+ function addRuntimeList(value) {
801
+ for (const key of String(value).split(',').map((item) => item.trim()).filter(Boolean)) {
802
+ if (!Object.prototype.hasOwnProperty.call(RUNTIMES, key)) {
803
+ throw new Error(`Unknown runtime "${key}". Expected one of: ${Object.keys(RUNTIMES).join(', ')}`);
804
+ }
805
+ if (!options.runtimeKeys.includes(key)) {
806
+ options.runtimeKeys.push(key);
807
+ }
808
+ }
809
+ }
810
+
811
+ for (let i = 0; i < argv.length; i++) {
812
+ const arg = argv[i];
813
+ if (arg === '--help' || arg === '-h') {
814
+ options.showHelp = true;
815
+ } else if (arg === '--version' || arg === '-v') {
816
+ options.showVersion = true;
817
+ } else if (arg === '--silent' || arg === '--yes') {
818
+ options.silent = true;
819
+ } else if (arg === '--detected') {
820
+ options.installDetected = true;
821
+ } else if (arg === '--global') {
822
+ options.isGlobal = true;
823
+ } else if (arg === '--project') {
824
+ options.isGlobal = false;
825
+ } else if (arg === '--writer') {
826
+ options.developerMode = false;
827
+ } else if (arg === '--developer') {
828
+ options.developerMode = true;
829
+ } else if (arg === '--runtime') {
830
+ const value = argv[i + 1];
831
+ if (!value) throw new Error('--runtime requires a value');
832
+ addRuntimeList(value);
833
+ i++;
834
+ } else if (arg.startsWith('--runtime=')) {
835
+ addRuntimeList(arg.slice('--runtime='.length));
836
+ } else if (arg === '--runtimes') {
837
+ const value = argv[i + 1];
838
+ if (!value) throw new Error('--runtimes requires a value');
839
+ addRuntimeList(value);
840
+ i++;
841
+ } else if (arg.startsWith('--runtimes=')) {
842
+ addRuntimeList(arg.slice('--runtimes='.length));
843
+ } else {
844
+ throw new Error(`Unknown argument "${arg}"`);
845
+ }
846
+ }
847
+
848
+ return options;
849
+ }
850
+
851
+ function resolveInstallRequest(parsed, detectedRuntimeKeys, { isTTY }) {
852
+ const hasRuntimeDirective = parsed.runtimeKeys.length > 0 || parsed.installDetected;
853
+ const hasModifierOverrides = parsed.isGlobal !== null || parsed.developerMode !== null;
854
+
855
+ if (!isTTY && !hasRuntimeDirective) {
856
+ return {
857
+ action: 'usage_error',
858
+ message: 'Non-interactive use requires --runtimes <list> or --detected.',
859
+ };
860
+ }
861
+
862
+ if (parsed.silent && !hasRuntimeDirective) {
863
+ return {
864
+ action: 'usage_error',
865
+ message: 'Silent installs require --runtimes <list>, --runtime <key>, or --detected.',
866
+ };
867
+ }
868
+
869
+ if (hasRuntimeDirective) {
870
+ return {
871
+ action: 'install',
872
+ runtimeKeys: parsed.runtimeKeys.length > 0
873
+ ? parsed.runtimeKeys
874
+ : detectedRuntimeKeys,
875
+ isGlobal: parsed.isGlobal ?? true,
876
+ developerMode: parsed.developerMode ?? false,
877
+ silent: parsed.silent,
878
+ installMode: 'non-interactive',
879
+ };
880
+ }
881
+
882
+ return {
883
+ action: 'interactive',
884
+ isGlobal: parsed.isGlobal,
885
+ developerMode: parsed.developerMode,
886
+ hasModifierOverrides,
887
+ };
888
+ }
889
+
890
+ function ask(rl, question) {
891
+ return new Promise((resolve) => rl.question(question, resolve));
892
+ }
893
+
894
+ function requireSupportedNode() {
895
+ const major = Number.parseInt(process.versions.node.split('.')[0], 10);
896
+ if (!Number.isInteger(major) || major < MIN_NODE_MAJOR) {
897
+ console.error(c('red', `Scriveno's installer requires Node.js >=20.0.0. You are running ${process.versions.node}.`));
898
+ console.error(c('dim', 'See the repository README for the full runtime support matrix and current installer guidance.'));
899
+ process.exit(1);
900
+ }
901
+ }
902
+
903
+ function copyDir(src, dest) {
904
+ if (!fs.existsSync(src)) return 0;
905
+ fs.mkdirSync(dest, { recursive: true });
906
+ let count = 0;
907
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
908
+ const srcPath = path.join(src, entry.name);
909
+ const destPath = path.join(dest, entry.name);
910
+ if (entry.isDirectory()) {
911
+ count += copyDir(srcPath, destPath);
912
+ } else {
913
+ fs.copyFileSync(srcPath, destPath);
914
+ count++;
915
+ }
916
+ }
917
+ return count;
918
+ }
919
+
920
+ function sha256File(filePath) {
921
+ try {
922
+ const buf = fs.readFileSync(filePath);
923
+ return crypto.createHash('sha256').update(buf).digest('hex');
924
+ } catch (err) {
925
+ if (err.code === 'ENOENT') return null;
926
+ throw err;
927
+ }
928
+ }
929
+
930
+ function copyDirWithPreservation(src, dest, options = {}) {
931
+ const timestamp = options.timestamp || new Date().toISOString().replace(/[:.]/g, '-');
932
+ const result = { fresh: 0, replaced: 0, backedUp: 0 };
933
+ if (!fs.existsSync(src)) return result;
934
+ fs.mkdirSync(dest, { recursive: true });
935
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
936
+ const srcPath = path.join(src, entry.name);
937
+ const destPath = path.join(dest, entry.name);
938
+ if (entry.isDirectory()) {
939
+ const sub = copyDirWithPreservation(srcPath, destPath, { ...options, timestamp });
940
+ result.fresh += sub.fresh;
941
+ result.replaced += sub.replaced;
942
+ result.backedUp += sub.backedUp;
943
+ continue;
944
+ }
945
+ // H-02 + M-01: use lstat on the destination so we can (a) detect
946
+ // non-regular-file dests (symlinks, sockets, FIFOs) and refuse to hash
947
+ // through them, and (b) route the final write through atomicWriteFileSync
948
+ // so a crash mid-copy cannot leave destPath truncated.
949
+ let destStat = null;
950
+ try {
951
+ destStat = fs.lstatSync(destPath);
952
+ } catch (err) {
953
+ if (err.code !== 'ENOENT') throw err;
954
+ }
955
+
956
+ // Read the source buffer once -- used for both hashing and the atomic write.
957
+ const srcBuf = fs.readFileSync(srcPath);
958
+
959
+ if (destStat === null) {
960
+ // No existing dest -- fresh write.
961
+ atomicWriteFileSync(destPath, srcBuf);
962
+ result.fresh++;
963
+ continue;
964
+ }
965
+
966
+ if (!destStat.isFile()) {
967
+ // M-01: dest is a symlink / socket / directory-named-like-a-file /
968
+ // anything non-regular. Treat it as "modified" and back it up before
969
+ // installing the shipped template. Removing the non-regular entry via
970
+ // rename preserves the user's data under a .backup.<timestamp> sibling.
971
+ const backupPath = `${destPath}.backup.${timestamp}`;
972
+ try {
973
+ fs.renameSync(destPath, backupPath);
974
+ } catch {
975
+ // Fall back to unlink -- renameSync can fail across some boundaries.
976
+ try { fs.unlinkSync(destPath); } catch { /* best effort */ }
977
+ }
978
+ atomicWriteFileSync(destPath, srcBuf);
979
+ result.backedUp++;
980
+ continue;
981
+ }
982
+
983
+ const destHash = sha256File(destPath);
984
+ const srcHash = crypto.createHash('sha256').update(srcBuf).digest('hex');
985
+ if (srcHash === destHash) {
986
+ // Identical content -- rewrite atomically so an interrupted run still
987
+ // leaves a complete file (no partial-write window).
988
+ atomicWriteFileSync(destPath, srcBuf);
989
+ result.replaced++;
990
+ } else {
991
+ const backupPath = `${destPath}.backup.${timestamp}`;
992
+ fs.renameSync(destPath, backupPath);
993
+ atomicWriteFileSync(destPath, srcBuf);
994
+ result.backedUp++;
995
+ }
996
+ }
997
+ return result;
998
+ }
999
+
1000
+ function readJsonIfExists(filePath) {
1001
+ if (!fs.existsSync(filePath)) return null;
1002
+ try {
1003
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1004
+ } catch {
1005
+ return null;
1006
+ }
1007
+ }
1008
+
1009
+ // I-01: tag ownership on schema entries and derive INSTALLER_OWNED_FIELDS from
1010
+ // the schema so a future contributor cannot forget to classify a new field.
1011
+ // `developer_mode` is the only user-owned field today; everything else is
1012
+ // installer-owned (matches prior INSTALLER_OWNED_FIELDS list exactly).
1013
+ const SETTINGS_SCHEMA = [
1014
+ { name: 'version', type: 'string', required: true, owned_by: 'installer' },
1015
+ { name: 'runtime', type: 'string', required: true, allow_empty: true, owned_by: 'installer' },
1016
+ { name: 'runtimes', type: 'array-of-string', required: true, owned_by: 'installer' },
1017
+ { name: 'scope', type: 'string', required: true, enum: ['global', 'project'], owned_by: 'installer' },
1018
+ { name: 'developer_mode', type: 'boolean', required: true, owned_by: 'user' },
1019
+ { name: 'data_dir', type: 'string', required: true, owned_by: 'installer' },
1020
+ { name: 'install_mode', type: 'string', required: true, enum: ['interactive', 'non-interactive'], owned_by: 'installer' },
1021
+ { name: 'installed_at', type: 'string', required: true, owned_by: 'installer' },
1022
+ ];
1023
+
1024
+ const INSTALLER_OWNED_FIELDS = SETTINGS_SCHEMA
1025
+ .filter((f) => f.owned_by === 'installer')
1026
+ .map((f) => f.name);
1027
+
1028
+ function mergeSettings(existing, incoming, _schema = SETTINGS_SCHEMA) {
1029
+ const merged = { ...incoming };
1030
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return merged;
1031
+ for (const [key, value] of Object.entries(existing)) {
1032
+ if (INSTALLER_OWNED_FIELDS.includes(key)) continue;
1033
+ merged[key] = value;
1034
+ }
1035
+ return merged;
1036
+ }
1037
+
1038
+ function migrateSettings(raw) {
1039
+ if (raw == null) return null;
1040
+ const out = { ...raw };
1041
+ if (!Object.prototype.hasOwnProperty.call(out, 'runtimes') || out.runtimes === undefined) {
1042
+ if (typeof out.runtime === 'string' && out.runtime.length > 0) {
1043
+ out.runtimes = [out.runtime];
1044
+ } else {
1045
+ out.runtimes = [];
1046
+ }
1047
+ }
1048
+ if (!Object.prototype.hasOwnProperty.call(out, 'scope') || out.scope === undefined) {
1049
+ out.scope = 'global';
1050
+ }
1051
+ if (!Object.prototype.hasOwnProperty.call(out, 'install_mode') || out.install_mode === undefined) {
1052
+ out.install_mode = 'interactive';
1053
+ }
1054
+ return out;
1055
+ }
1056
+
1057
+ function describeActualType(value) {
1058
+ if (value === null) return 'null';
1059
+ if (Array.isArray(value)) return 'array';
1060
+ return typeof value;
1061
+ }
1062
+
1063
+ function validateSettings(settings) {
1064
+ const errors = [];
1065
+ if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
1066
+ return {
1067
+ valid: false,
1068
+ errors: [`settings: expected object, received ${describeActualType(settings)}`],
1069
+ };
1070
+ }
1071
+
1072
+ const schemaFieldNames = new Set(SETTINGS_SCHEMA.map((f) => f.name));
1073
+ let hardErrorCount = 0;
1074
+
1075
+ for (const field of SETTINGS_SCHEMA) {
1076
+ const hasKey = Object.prototype.hasOwnProperty.call(settings, field.name);
1077
+ if (!hasKey) {
1078
+ if (field.required) {
1079
+ errors.push(`${field.name}: required field is missing`);
1080
+ hardErrorCount++;
1081
+ }
1082
+ continue;
1083
+ }
1084
+ const value = settings[field.name];
1085
+ const actual = describeActualType(value);
1086
+
1087
+ if (field.type === 'string') {
1088
+ if (typeof value !== 'string') {
1089
+ errors.push(`${field.name}: expected string, received ${actual}`);
1090
+ hardErrorCount++;
1091
+ continue;
1092
+ }
1093
+ if (!field.allow_empty && value === '') {
1094
+ errors.push(`${field.name}: expected non-empty string, received empty string`);
1095
+ hardErrorCount++;
1096
+ continue;
1097
+ }
1098
+ } else if (field.type === 'boolean') {
1099
+ if (typeof value !== 'boolean') {
1100
+ errors.push(`${field.name}: expected boolean, received ${actual}`);
1101
+ hardErrorCount++;
1102
+ continue;
1103
+ }
1104
+ } else if (field.type === 'array-of-string') {
1105
+ if (!Array.isArray(value)) {
1106
+ errors.push(`${field.name}: expected array, received ${actual}`);
1107
+ hardErrorCount++;
1108
+ continue;
1109
+ }
1110
+ const badIdx = value.findIndex((el) => typeof el !== 'string');
1111
+ if (badIdx !== -1) {
1112
+ errors.push(`${field.name}: expected array of string, received ${describeActualType(value[badIdx])} at index ${badIdx}`);
1113
+ hardErrorCount++;
1114
+ continue;
1115
+ }
1116
+ }
1117
+
1118
+ if (Array.isArray(field.enum) && !field.enum.includes(value)) {
1119
+ errors.push(`${field.name}: expected one of [${field.enum.join(', ')}], received ${value}`);
1120
+ hardErrorCount++;
1121
+ }
1122
+ }
1123
+
1124
+ for (const key of Object.keys(settings)) {
1125
+ if (!schemaFieldNames.has(key)) {
1126
+ errors.push(`${key}: unknown field (warning)`);
1127
+ }
1128
+ }
1129
+
1130
+ return { valid: hardErrorCount === 0, errors };
1131
+ }
1132
+
1133
+ function readSettings(dataDir) {
1134
+ const settingsPath = path.join(dataDir, 'settings.json');
1135
+ const raw = readJsonIfExists(settingsPath);
1136
+ if (raw === null) {
1137
+ throw new Error(`settings.json not found at ${settingsPath}`);
1138
+ }
1139
+ const migrated = migrateSettings(raw);
1140
+ const result = validateSettings(migrated);
1141
+ if (!result.valid) {
1142
+ const hardErrors = result.errors.filter((e) => !/\(warning\)\s*$/.test(e));
1143
+ throw new Error(`Invalid settings: ${hardErrors.join('; ')}`);
1144
+ }
1145
+ return migrated;
1146
+ }
1147
+
1148
+ function isScrivenoCodexSkillDir(skillDir) {
1149
+ const skillFile = path.join(skillDir, 'SKILL.md');
1150
+ if (!fs.existsSync(skillFile)) return false;
1151
+ const content = fs.readFileSync(skillFile, 'utf8');
1152
+ return content.includes('<codex_skill_adapter>')
1153
+ && content.includes("Execute Scriveno's `")
1154
+ && content.includes('Installed command file:');
1155
+ }
1156
+
1157
+ function cleanCodexSkillDirs(skillsDir, currentSkillNames) {
1158
+ if (!fs.existsSync(skillsDir)) return 0;
1159
+
1160
+ const manifestPath = path.join(skillsDir, '.scriveno-installed.json');
1161
+ const manifest = readJsonIfExists(manifestPath);
1162
+ const currentSkillSet = new Set(currentSkillNames);
1163
+ const knownScrivenoSkillNames = new Set(Array.isArray(manifest?.skills) ? manifest.skills : []);
1164
+
1165
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
1166
+ if (!entry.isDirectory()) continue;
1167
+ const skillDir = path.join(skillsDir, entry.name);
1168
+ if (isScrivenoCodexSkillDir(skillDir)) {
1169
+ knownScrivenoSkillNames.add(entry.name);
1170
+ }
1171
+ }
1172
+
1173
+ let removed = 0;
1174
+ removePathIfExists(path.join(skillsDir, 'scriveno'));
1175
+ removePathIfExists(manifestPath);
1176
+
1177
+ for (const skillName of knownScrivenoSkillNames) {
1178
+ if (!currentSkillSet.has(skillName)) {
1179
+ if (removePathIfExists(path.join(skillsDir, skillName))) {
1180
+ removed++;
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ for (const skillName of currentSkillNames) {
1186
+ if (removePathIfExists(path.join(skillsDir, skillName))) {
1187
+ removed++;
1188
+ }
1189
+ }
1190
+
1191
+ return removed;
1192
+ }
1193
+
1194
+ function writeCodexSkillManifest(skillsDir, skillNames) {
1195
+ const manifestPath = path.join(skillsDir, '.scriveno-installed.json');
1196
+ const manifest = {
1197
+ installer: 'scriveno-cli',
1198
+ version: VERSION,
1199
+ skills: skillNames,
1200
+ generated_at: new Date().toISOString(),
1201
+ };
1202
+ atomicWriteFileSync(manifestPath, JSON.stringify(manifest, null, 2));
1203
+ }
1204
+
1205
+ async function main() {
1206
+ const parsed = parseArgs(process.argv.slice(2));
1207
+ if (parsed.showHelp) {
1208
+ printHelp();
1209
+ return;
1210
+ }
1211
+ if (parsed.showVersion) {
1212
+ console.log(VERSION);
1213
+ return;
1214
+ }
1215
+
1216
+ const detectedRuntimeKeys = Object.entries(RUNTIMES).filter(([, runtime]) => runtime.detect()).map(([key]) => key);
1217
+ const installRequest = resolveInstallRequest(parsed, detectedRuntimeKeys, { isTTY: Boolean(process.stdin.isTTY) });
1218
+
1219
+ if (installRequest.action === 'usage_error') {
1220
+ printHelp();
1221
+ console.log(c('yellow', `\n${installRequest.message}`));
1222
+ process.exitCode = 1;
1223
+ return;
1224
+ }
1225
+
1226
+ if (installRequest.action === 'install') {
1227
+ runInstall(installRequest);
1228
+ return;
1229
+ }
1230
+
1231
+ console.log(BANNER);
1232
+ console.log(RUNTIME_SUPPORT_NOTE + '\n');
1233
+
1234
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1235
+ const runtimeKeys = Object.keys(RUNTIMES);
1236
+
1237
+ console.log(c('bold', 'Select your AI coding agent:'));
1238
+ runtimeKeys.forEach((key, i) => {
1239
+ const label = RUNTIMES[key].label;
1240
+ const badge = detectedRuntimeKeys.includes(key) ? c('green', ' (detected)') : '';
1241
+ console.log(` ${c('cyan', (i + 1) + '.')} ${label}${badge}`);
1242
+ });
1243
+
1244
+ const runtimeChoice = await ask(rl, `\n${c('dim', 'Choice [1]: ')}`);
1245
+ const parsedRuntimeChoice = Number.parseInt((runtimeChoice || '1').trim(), 10);
1246
+ const validRuntimeChoice = Number.isInteger(parsedRuntimeChoice)
1247
+ && parsedRuntimeChoice >= 1
1248
+ && parsedRuntimeChoice <= runtimeKeys.length;
1249
+ if ((runtimeChoice || '').trim() && !validRuntimeChoice) {
1250
+ console.log(c('yellow', `Invalid choice "${runtimeChoice.trim()}". Defaulting to 1 (${RUNTIMES[runtimeKeys[0]].label}).`));
1251
+ }
1252
+ const runtimeKey = runtimeKeys[validRuntimeChoice ? parsedRuntimeChoice - 1 : 0];
1253
+ const runtime = RUNTIMES[runtimeKey];
1254
+
1255
+ let isGlobal;
1256
+ if (installRequest.isGlobal !== null) {
1257
+ isGlobal = installRequest.isGlobal;
1258
+ console.log('\n' + c('bold', 'Install scope:'));
1259
+ console.log(` ${c('green', 'OK')} Preset via CLI flag: ${isGlobal ? 'Global' : 'Project'}`);
1260
+ } else {
1261
+ console.log('\n' + c('bold', 'Install scope:'));
1262
+ console.log(` ${c('cyan', '1.')} Global -- available in all your projects`);
1263
+ console.log(` ${c('cyan', '2.')} Project -- just this directory`);
1264
+ if (runtime.type === 'guided-mcp') {
1265
+ console.log(c('dim', ' Note: Perplexity Desktop connectors still point at specific project paths even when you choose Global scope.'));
1266
+ }
1267
+ const scopeChoice = await ask(rl, `\n${c('dim', 'Choice [1]: ')}`);
1268
+ isGlobal = (scopeChoice || '1').trim() === '1';
1269
+ }
1270
+
1271
+ let developerMode;
1272
+ if (installRequest.developerMode !== null) {
1273
+ developerMode = installRequest.developerMode;
1274
+ console.log('\n' + c('bold', 'Mode:'));
1275
+ console.log(` ${c('green', 'OK')} Preset via CLI flag: ${developerMode ? 'Developer mode' : 'Writer mode'}`);
1276
+ } else {
1277
+ console.log('\n' + c('bold', 'Mode:'));
1278
+ console.log(` ${c('cyan', '1.')} ${c('bold', 'Writer mode')} -- git terminology hidden, friendly errors (default for non-developers)`);
1279
+ console.log(` ${c('cyan', '2.')} ${c('bold', 'Developer mode')} -- full git access, technical output`);
1280
+ const modeChoice = await ask(rl, `\n${c('dim', 'Choice [1]: ')}`);
1281
+ developerMode = (modeChoice || '1').trim() === '2';
1282
+ }
1283
+ rl.close();
1284
+
1285
+ runInstall({
1286
+ runtimeKeys: [runtimeKey],
1287
+ isGlobal,
1288
+ developerMode,
1289
+ silent: false,
1290
+ detectedRuntimeKeys,
1291
+ installMode: 'interactive',
1292
+ });
1293
+ }
1294
+
1295
+ function installCommandRuntime(runtime, isGlobal, log) {
1296
+ const commandsDir = isGlobal ? runtime.commands_dir_global : path.resolve(runtime.commands_dir_project);
1297
+ const agentsDir = isGlobal ? runtime.agents_dir_global : path.resolve(runtime.agents_dir_project);
1298
+ removePathIfExists(commandsDir);
1299
+ const removedAgentFiles = cleanMirroredFiles(path.join(PKG_ROOT, 'agents'), agentsDir);
1300
+ const commandCount = copyDir(path.join(PKG_ROOT, 'commands', 'scr'), commandsDir);
1301
+ const agentCount = copyDir(path.join(PKG_ROOT, 'agents'), agentsDir);
1302
+ log(` ${c('green', 'OK')} ${runtime.label}: ${commandCount} command files -> ${c('dim', commandsDir)}`);
1303
+ log(` ${c('green', 'OK')} ${runtime.label}: ${agentCount} agent prompts -> ${c('dim', agentsDir)}${removedAgentFiles ? c('dim', ` (cleaned ${removedAgentFiles} stale files)`) : ''}`);
1304
+ }
1305
+
1306
+ function installClaudeCommandRuntime(runtime, isGlobal, log) {
1307
+ const commandsDir = isGlobal ? runtime.commands_dir_global : path.resolve(runtime.commands_dir_project);
1308
+ const agentsDir = isGlobal ? runtime.agents_dir_global : path.resolve(runtime.agents_dir_project);
1309
+ const commandsRoot = path.join(PKG_ROOT, 'commands', 'scr');
1310
+ const commandEntries = collectCommandEntries(commandsRoot);
1311
+ const fileNames = commandEntries.map((entry) => commandEntryToFlatCommandFileName(entry));
1312
+
1313
+ fs.mkdirSync(commandsDir, { recursive: true });
1314
+ const removedCommandFiles = cleanFlatCommandFiles(commandsDir, fileNames, ['scr']);
1315
+ const removedAgentFiles = cleanMirroredFiles(path.join(PKG_ROOT, 'agents'), agentsDir);
1316
+
1317
+ for (const entry of commandEntries) {
1318
+ const sourcePath = path.join(commandsRoot, entry.relativePath);
1319
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
1320
+ const fileName = commandEntryToFlatCommandFileName(entry);
1321
+ const targetPath = path.join(commandsDir, fileName);
1322
+ atomicWriteFileSync(targetPath, generateClaudeCommandContent(entry, sourceContent));
1323
+ }
1324
+
1325
+ writeInstalledCommandManifest(commandsDir, 'claude-code', fileNames);
1326
+ const agentCount = copyDir(path.join(PKG_ROOT, 'agents'), agentsDir);
1327
+
1328
+ log(` ${c('green', 'OK')} ${runtime.label}: ${commandEntries.length} /scr-* command files -> ${c('dim', commandsDir)}${removedCommandFiles ? c('dim', ` (cleaned ${removedCommandFiles} stale items)`) : ''}`);
1329
+ log(` ${c('green', 'OK')} ${runtime.label}: ${agentCount} agent prompts -> ${c('dim', agentsDir)}${removedAgentFiles ? c('dim', ` (cleaned ${removedAgentFiles} stale files)`) : ''}`);
1330
+ }
1331
+
1332
+ function installManifestSkillRuntime(runtime, isGlobal, log) {
1333
+ const skillsDir = isGlobal ? runtime.skills_dir_global : path.resolve(runtime.skills_dir_project);
1334
+ removePathIfExists(skillsDir);
1335
+ const manifest = generateSkillManifest(path.join(PKG_ROOT, 'data', 'CONSTRAINTS.json'));
1336
+ fs.mkdirSync(skillsDir, { recursive: true });
1337
+ atomicWriteFileSync(path.join(skillsDir, 'SKILL.md'), manifest);
1338
+ const commandCount = copyDir(path.join(PKG_ROOT, 'commands', 'scr'), path.join(skillsDir, 'commands', 'scr'));
1339
+ const agentCount = copyDir(path.join(PKG_ROOT, 'agents'), path.join(skillsDir, 'agents'));
1340
+ log(` ${c('green', 'OK')} ${runtime.label}: SKILL.md manifest -> ${c('dim', path.join(skillsDir, 'SKILL.md'))}`);
1341
+ log(` ${c('green', 'OK')} ${runtime.label}: ${commandCount} command files -> ${c('dim', path.join(skillsDir, 'commands', 'scr'))}`);
1342
+ log(` ${c('green', 'OK')} ${runtime.label}: ${agentCount} agent prompts -> ${c('dim', path.join(skillsDir, 'agents'))}`);
1343
+ }
1344
+
1345
+ function installCodexRuntime(runtime, isGlobal, log) {
1346
+ const skillsDir = isGlobal ? runtime.skills_dir_global : path.resolve(runtime.skills_dir_project);
1347
+ const commandsDir = isGlobal ? runtime.commands_dir_global : path.resolve(runtime.commands_dir_project);
1348
+ const agentsDir = isGlobal ? runtime.agents_dir_global : path.resolve(runtime.agents_dir_project);
1349
+ const sourceCommandsRoot = path.join(PKG_ROOT, 'commands', 'scr');
1350
+ const commandEntries = collectCommandEntries(sourceCommandsRoot);
1351
+ const skillNames = commandEntries.map((entry) => entry.skillName);
1352
+
1353
+ // Wipe the installed commands dir so stale files from previous installs
1354
+ // (removed commands, legacy flat layouts, etc.) do not linger.
1355
+ removePathIfExists(commandsDir);
1356
+ fs.mkdirSync(skillsDir, { recursive: true });
1357
+ const removedSkillDirs = cleanCodexSkillDirs(skillsDir, skillNames);
1358
+ const removedAgentFiles = cleanMirroredFiles(path.join(PKG_ROOT, 'agents'), agentsDir);
1359
+
1360
+ // NOTE: `collectCommandEntries` returns .md files only, and the authoritative
1361
+ // `commands/scr/**` tree is .md-only today. No non-.md assets need mirroring.
1362
+ // Rewrite each command file individually for the Codex invocation surface
1363
+ // ($scr-*) using atomicWriteFileSync (Phase 23). Re-reading the pristine
1364
+ // source on every run means the install marker is inserted once against
1365
+ // clean content -- not on top of a previously-marked installed file -- so
1366
+ // re-runs are idempotent (single marker, current prose rewrite).
1367
+ let commandCount = 0;
1368
+ for (const entry of commandEntries) {
1369
+ const sourcePath = path.join(sourceCommandsRoot, entry.relativePath);
1370
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
1371
+ const targetPath = path.join(commandsDir, entry.relativePath);
1372
+ atomicWriteFileSync(targetPath, generateCodexCommandContent(entry, sourceContent));
1373
+ commandCount++;
1374
+ }
1375
+
1376
+ const agentCount = copyDir(path.join(PKG_ROOT, 'agents'), agentsDir);
1377
+
1378
+ for (const entry of commandEntries) {
1379
+ const skillDir = path.join(skillsDir, entry.skillName);
1380
+ fs.mkdirSync(skillDir, { recursive: true });
1381
+ const commandPath = path.join(commandsDir, entry.relativePath);
1382
+ atomicWriteFileSync(path.join(skillDir, 'SKILL.md'), generateCodexSkill(entry, commandPath));
1383
+ }
1384
+ writeCodexSkillManifest(skillsDir, skillNames);
1385
+
1386
+ log(` ${c('green', 'OK')} ${runtime.label}: ${commandEntries.length} \$scr-* skills -> ${c('dim', skillsDir)}${removedSkillDirs ? c('dim', ` (cleaned ${removedSkillDirs} stale dirs)`) : ''}`);
1387
+ log(` ${c('green', 'OK')} ${runtime.label}: ${commandCount} command files -> ${c('dim', commandsDir)}`);
1388
+ log(` ${c('green', 'OK')} ${runtime.label}: ${agentCount} agent prompts -> ${c('dim', agentsDir)}${removedAgentFiles ? c('dim', ` (cleaned ${removedAgentFiles} stale files)`) : ''}`);
1389
+ }
1390
+
1391
+ function installGuidedRuntime(runtime, isGlobal, dataDir, log) {
1392
+ const guideDir = isGlobal ? runtime.guide_dir_global : path.resolve(runtime.guide_dir_project);
1393
+ const currentProjectDir = path.resolve('.');
1394
+ const setupGuide = generatePerplexitySetupGuide({
1395
+ isGlobal,
1396
+ guideDir,
1397
+ dataDir,
1398
+ currentProjectDir,
1399
+ });
1400
+ const connectorCommand = isGlobal
1401
+ ? buildFilesystemMcpCommand(['/absolute/path/to/project', dataDir])
1402
+ : buildFilesystemMcpCommand([currentProjectDir, dataDir]);
1403
+ const currentProjectCommand = buildFilesystemMcpCommand([currentProjectDir, dataDir]);
1404
+
1405
+ removePathIfExists(guideDir);
1406
+ fs.mkdirSync(guideDir, { recursive: true });
1407
+ atomicWriteFileSync(path.join(guideDir, 'SETUP.md'), setupGuide);
1408
+ atomicWriteFileSync(path.join(guideDir, 'connector-command.txt'), connectorCommand + '\n');
1409
+ atomicWriteFileSync(path.join(guideDir, 'connector-command.current-project.txt'), currentProjectCommand + '\n');
1410
+
1411
+ log(` ${c('green', 'OK')} ${runtime.label}: setup guide -> ${c('dim', path.join(guideDir, 'SETUP.md'))}`);
1412
+ log(` ${c('green', 'OK')} ${runtime.label}: connector recipe -> ${c('dim', path.join(guideDir, 'connector-command.txt'))}`);
1413
+ }
1414
+
1415
+ function writeSharedAssets(dataDir, runtimeKeys, isGlobal, developerMode, installMode, log) {
1416
+ fs.mkdirSync(path.join(dataDir, 'templates'), { recursive: true });
1417
+ fs.mkdirSync(path.join(dataDir, 'data'), { recursive: true });
1418
+ const templateResult = copyDirWithPreservation(path.join(PKG_ROOT, 'templates'), path.join(dataDir, 'templates'));
1419
+ const dataResult = copyDirWithPreservation(path.join(PKG_ROOT, 'data'), path.join(dataDir, 'data'));
1420
+ const sum = (r) => r.fresh + r.replaced + r.backedUp;
1421
+ log(` ${c('green', 'OK')} ${sum(templateResult)} templates + ${sum(dataResult)} data files -> ${c('dim', dataDir)}`);
1422
+ const totalBackedUp = templateResult.backedUp + dataResult.backedUp;
1423
+ if (totalBackedUp > 0) {
1424
+ log(` ${c('yellow', 'i')} Preserved ${totalBackedUp} user-modified file(s) as .backup.<timestamp>`);
1425
+ }
1426
+
1427
+ const settingsPath = path.join(dataDir, 'settings.json');
1428
+ // M-04: run migrate + validate on the existing file before merging so
1429
+ // hand-edited / schema-invalid / stale-format settings do not silently
1430
+ // propagate user-owned junk across installs. On invalid, back up the file
1431
+ // to `settings.json.invalid.<timestamp>` and fall back to a clean merge
1432
+ // (i.e. drop the unusable existing).
1433
+ const rawExistingSettings = readJsonIfExists(settingsPath);
1434
+ let existingSettings = null;
1435
+ if (rawExistingSettings !== null) {
1436
+ const migrated = migrateSettings(rawExistingSettings);
1437
+ const validation = validateSettings(migrated);
1438
+ if (validation.valid) {
1439
+ existingSettings = migrated;
1440
+ } else {
1441
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1442
+ const invalidPath = `${settingsPath}.invalid.${ts}`;
1443
+ try {
1444
+ fs.renameSync(settingsPath, invalidPath);
1445
+ log(` ${c('yellow', 'i')} Existing settings.json was invalid; preserved as ${c('dim', invalidPath)}`);
1446
+ } catch {
1447
+ // If rename fails, we still proceed with a fresh merge below.
1448
+ }
1449
+ existingSettings = null;
1450
+ }
1451
+ }
1452
+ const incomingSettings = {
1453
+ version: VERSION,
1454
+ runtime: runtimeKeys[0],
1455
+ runtimes: runtimeKeys,
1456
+ scope: isGlobal ? 'global' : 'project',
1457
+ developer_mode: developerMode,
1458
+ data_dir: dataDir,
1459
+ install_mode: installMode,
1460
+ installed_at: new Date().toISOString(),
1461
+ };
1462
+ const mergedSettings = mergeSettings(existingSettings, incomingSettings);
1463
+ atomicWriteFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2));
1464
+ log(` ${c('green', 'OK')} settings.json -> ${c('dim', settingsPath)}`);
1465
+ }
1466
+
1467
+ function printNextSteps(runtimeKeys) {
1468
+ console.log('\n' + c('bold', 'Next steps:'));
1469
+ let step = 1;
1470
+ if (runtimeKeys.includes('codex')) {
1471
+ console.log(` ${c('cyan', `${step}.`)} In Codex, run ${c('bold', '$scr-help')} to see available commands`);
1472
+ step++;
1473
+ console.log(` ${c('cyan', `${step}.`)} Start with ${c('bold', '$scr-new-work')} or ${c('bold', '$scr-demo')}`);
1474
+ step++;
1475
+ }
1476
+ if (runtimeKeys.includes('claude-code')) {
1477
+ console.log(` ${c('cyan', `${step}.`)} In Claude Code, run ${c('bold', '/scr-help')}`);
1478
+ step++;
1479
+ }
1480
+ if (runtimeKeys.some((key) => key !== 'codex' && key !== 'claude-code' && RUNTIMES[key].type !== 'guided-mcp')) {
1481
+ console.log(` ${c('cyan', `${step}.`)} In another command-directory runtime, run ${c('bold', '/scr:help')}`);
1482
+ step++;
1483
+ }
1484
+ if (runtimeKeys.includes('perplexity-desktop')) {
1485
+ console.log(` ${c('cyan', `${step}.`)} Open the generated Perplexity Desktop setup guide and add the connector recipe`);
1486
+ }
1487
+ console.log('\n' + c('dim', `Docs: ${DOCS_URL}\n`));
1488
+ }
1489
+
1490
+ function collectTargetDirsForSweep(runtimeKeys, isGlobal, dataDir) {
1491
+ const dirs = new Set([dataDir]);
1492
+ for (const runtimeKey of runtimeKeys) {
1493
+ const runtime = RUNTIMES[runtimeKey];
1494
+ if (!runtime) continue;
1495
+ const resolve = (g, p) => isGlobal ? g : (p ? path.resolve(p) : null);
1496
+ if (runtime.commands_dir_global || runtime.commands_dir_project) {
1497
+ const d = resolve(runtime.commands_dir_global, runtime.commands_dir_project);
1498
+ if (d) dirs.add(d);
1499
+ }
1500
+ if (runtime.skills_dir_global || runtime.skills_dir_project) {
1501
+ const d = resolve(runtime.skills_dir_global, runtime.skills_dir_project);
1502
+ if (d) dirs.add(d);
1503
+ }
1504
+ if (runtime.agents_dir_global || runtime.agents_dir_project) {
1505
+ const d = resolve(runtime.agents_dir_global, runtime.agents_dir_project);
1506
+ if (d) dirs.add(d);
1507
+ }
1508
+ if (runtime.guide_dir_global || runtime.guide_dir_project) {
1509
+ const d = resolve(runtime.guide_dir_global, runtime.guide_dir_project);
1510
+ if (d) dirs.add(d);
1511
+ }
1512
+ }
1513
+ return Array.from(dirs);
1514
+ }
1515
+
1516
+ function runInstall({ runtimeKeys, isGlobal, developerMode, silent, installMode }) {
1517
+ const dataDir = isGlobal ? path.join(os.homedir(), '.scriveno') : path.resolve('.scriveno');
1518
+ const log = silent ? () => {} : (message) => console.log(message);
1519
+
1520
+ if (!runtimeKeys.length) {
1521
+ throw new Error('No runtimes selected for installation');
1522
+ }
1523
+
1524
+ let totalOrphansRemoved = 0;
1525
+ for (const dir of collectTargetDirsForSweep(runtimeKeys, isGlobal, dataDir)) {
1526
+ totalOrphansRemoved += cleanOrphanedTempFiles(dir);
1527
+ }
1528
+ if (totalOrphansRemoved > 0) {
1529
+ log(c('dim', ` Cleaned ${totalOrphansRemoved} orphaned temp file(s) from prior interrupted install`));
1530
+ }
1531
+
1532
+ if (!silent) {
1533
+ console.log('\n' + c('bold', 'Installing...'));
1534
+ }
1535
+
1536
+ for (const runtimeKey of runtimeKeys) {
1537
+ const runtime = RUNTIMES[runtimeKey];
1538
+ if (!runtime) {
1539
+ throw new Error(`Unknown runtime "${runtimeKey}"`);
1540
+ }
1541
+ if (runtimeKey === 'codex') {
1542
+ installCodexRuntime(runtime, isGlobal, log);
1543
+ } else if (runtime.command_layout === 'flat-prefixed') {
1544
+ installClaudeCommandRuntime(runtime, isGlobal, log);
1545
+ } else if (runtime.type === 'skills') {
1546
+ installManifestSkillRuntime(runtime, isGlobal, log);
1547
+ } else if (runtime.type === 'guided-mcp') {
1548
+ installGuidedRuntime(runtime, isGlobal, dataDir, log);
1549
+ } else {
1550
+ installCommandRuntime(runtime, isGlobal, log);
1551
+ }
1552
+ }
1553
+
1554
+ writeSharedAssets(dataDir, runtimeKeys, isGlobal, developerMode, installMode, log);
1555
+
1556
+ if (silent) {
1557
+ console.log(`Installed Scriveno ${VERSION} to ${runtimeKeys.join(', ')} (${isGlobal ? 'global' : 'project'}, ${developerMode ? 'developer' : 'writer'} mode).`);
1558
+ return;
1559
+ }
1560
+
1561
+ console.log('\n' + c('bold', c('green', 'Installation complete!')));
1562
+ printNextSteps(runtimeKeys);
1563
+ }
1564
+
1565
+ // Only run interactive installer when executed directly
1566
+ if (require.main === module) {
1567
+ requireSupportedNode();
1568
+ main().catch((err) => {
1569
+ console.error(c('red', '\nInstallation failed:'), err.message);
1570
+ process.exit(1);
1571
+ });
1572
+ }
1573
+
1574
+ module.exports = {
1575
+ copyDir,
1576
+ RUNTIMES,
1577
+ parseArgs,
1578
+ resolveInstallRequest,
1579
+ collectCommandEntries,
1580
+ assertNoSkillNameCollisions,
1581
+ cleanCodexSkillDirs,
1582
+ commandRefToCodexSkillName,
1583
+ commandRefToClaudeInvocation,
1584
+ commandRefToCodexInvocation,
1585
+ commandEntryToFlatCommandFileName,
1586
+ generateClaudeCommandContent,
1587
+ generateCodexCommandContent,
1588
+ rewriteInstalledCommandRefs,
1589
+ installCodexRuntime,
1590
+ cleanFlatCommandFiles,
1591
+ generateCodexSkill,
1592
+ generateSkillManifest,
1593
+ buildFilesystemMcpCommand,
1594
+ generatePerplexitySetupGuide,
1595
+ atomicWriteFileSync,
1596
+ cleanOrphanedTempFiles,
1597
+ collectTargetDirsForSweep,
1598
+ readFrontmatterValue,
1599
+ readFrontmatterValues,
1600
+ SETTINGS_SCHEMA,
1601
+ validateSettings,
1602
+ migrateSettings,
1603
+ readSettings,
1604
+ readJsonIfExists,
1605
+ sha256File,
1606
+ copyDirWithPreservation,
1607
+ mergeSettings,
1608
+ INSTALLER_OWNED_FIELDS,
1609
+ writeSharedAssets,
1610
+ // Phase 29 v1.7 -- architectural profiles (tradition / platform)
1611
+ listTraditions: architecturalProfiles.listTraditions,
1612
+ listPlatforms: architecturalProfiles.listPlatforms,
1613
+ validateTradition: architecturalProfiles.validateTradition,
1614
+ validatePlatform: architecturalProfiles.validatePlatform,
1615
+ inferTradition: architecturalProfiles.inferTradition,
1616
+ inferPlatform: architecturalProfiles.inferPlatform,
1617
+ // Per-work-type pitfall packs
1618
+ listPitfallPacks: architecturalProfiles.listPitfallPacks,
1619
+ getPitfallPackPath: architecturalProfiles.getPitfallPackPath,
1620
+ };