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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/agents/continuity-checker.md +85 -0
- package/agents/drafter.md +248 -0
- package/agents/plan-checker.md +209 -0
- package/agents/researcher.md +114 -0
- package/agents/translator.md +204 -0
- package/agents/voice-checker.md +154 -0
- package/bin/install.js +1620 -0
- package/commands/scr/add-note.md +51 -0
- package/commands/scr/add-unit.md +101 -0
- package/commands/scr/art-direction.md +225 -0
- package/commands/scr/autopilot-publish.md +210 -0
- package/commands/scr/autopilot-translate.md +237 -0
- package/commands/scr/autopilot.md +200 -0
- package/commands/scr/back-matter.md +630 -0
- package/commands/scr/back-translate.md +197 -0
- package/commands/scr/beta-reader.md +97 -0
- package/commands/scr/blurb.md +149 -0
- package/commands/scr/book-proposal.md +210 -0
- package/commands/scr/build-ebook.md +448 -0
- package/commands/scr/build-poetry-submission.md +202 -0
- package/commands/scr/build-print.md +598 -0
- package/commands/scr/build-smashwords.md +171 -0
- package/commands/scr/build-world.md +158 -0
- package/commands/scr/cast-list.md +104 -0
- package/commands/scr/chapter-header.md +158 -0
- package/commands/scr/character-arc.md +108 -0
- package/commands/scr/character-ref.md +160 -0
- package/commands/scr/character-sheet.md +143 -0
- package/commands/scr/character-touch.md +157 -0
- package/commands/scr/character-voice-sample.md +111 -0
- package/commands/scr/check-notes.md +50 -0
- package/commands/scr/cleanup.md +159 -0
- package/commands/scr/compare.md +112 -0
- package/commands/scr/complete-draft.md +49 -0
- package/commands/scr/continuity-check.md +129 -0
- package/commands/scr/copy-edit.md +118 -0
- package/commands/scr/cover-art.md +382 -0
- package/commands/scr/cultural-adaptation.md +177 -0
- package/commands/scr/demo.md +93 -0
- package/commands/scr/dialogue-audit.md +143 -0
- package/commands/scr/discuss.md +118 -0
- package/commands/scr/discussion-questions.md +129 -0
- package/commands/scr/do.md +68 -0
- package/commands/scr/draft.md +97 -0
- package/commands/scr/editor-review.md +466 -0
- package/commands/scr/export.md +942 -0
- package/commands/scr/fast.md +65 -0
- package/commands/scr/front-matter.md +696 -0
- package/commands/scr/health.md +113 -0
- package/commands/scr/help.md +121 -0
- package/commands/scr/history.md +92 -0
- package/commands/scr/illustrate-scene.md +211 -0
- package/commands/scr/import.md +95 -0
- package/commands/scr/insert-unit.md +108 -0
- package/commands/scr/line-edit.md +146 -0
- package/commands/scr/manager.md +77 -0
- package/commands/scr/manuscript-stats.md +139 -0
- package/commands/scr/map-illustration.md +213 -0
- package/commands/scr/map-manuscript.md +134 -0
- package/commands/scr/merge-units.md +136 -0
- package/commands/scr/multi-publish.md +344 -0
- package/commands/scr/new-character.md +167 -0
- package/commands/scr/new-revision.md +50 -0
- package/commands/scr/new-work.md +148 -0
- package/commands/scr/next.md +125 -0
- package/commands/scr/originality-check.md +170 -0
- package/commands/scr/outline.md +131 -0
- package/commands/scr/pacing-analysis.md +170 -0
- package/commands/scr/panel-layout.md +225 -0
- package/commands/scr/pause-work.md +88 -0
- package/commands/scr/plan.md +112 -0
- package/commands/scr/plant-seed.md +57 -0
- package/commands/scr/plot-graph.md +199 -0
- package/commands/scr/polish.md +141 -0
- package/commands/scr/profile-writer.md +154 -0
- package/commands/scr/progress.md +51 -0
- package/commands/scr/publish.md +455 -0
- package/commands/scr/query-letter.md +183 -0
- package/commands/scr/quick-write.md +82 -0
- package/commands/scr/relationship-map.md +129 -0
- package/commands/scr/remove-unit.md +120 -0
- package/commands/scr/reorder-units.md +126 -0
- package/commands/scr/resume-work.md +97 -0
- package/commands/scr/sacred/annotation-layer.md +105 -0
- package/commands/scr/sacred/chronology.md +121 -0
- package/commands/scr/sacred/concordance.md +88 -0
- package/commands/scr/sacred/cross-reference.md +97 -0
- package/commands/scr/sacred/doctrinal-check.md +129 -0
- package/commands/scr/sacred/genealogy.md +107 -0
- package/commands/scr/sacred/source-tracking.md +101 -0
- package/commands/scr/sacred/verse-numbering.md +103 -0
- package/commands/scr/sacred-numbering-format.md +103 -0
- package/commands/scr/save.md +109 -0
- package/commands/scr/scan.md +291 -0
- package/commands/scr/sensitivity-review.md +169 -0
- package/commands/scr/series-bible.md +127 -0
- package/commands/scr/session-report.md +80 -0
- package/commands/scr/settings.md +58 -0
- package/commands/scr/split-unit.md +123 -0
- package/commands/scr/spread-layout.md +187 -0
- package/commands/scr/storyboard.md +262 -0
- package/commands/scr/subject-touch.md +168 -0
- package/commands/scr/submit.md +50 -0
- package/commands/scr/subplot-map.md +147 -0
- package/commands/scr/sync.md +116 -0
- package/commands/scr/synopsis.md +137 -0
- package/commands/scr/theme-tracker.md +128 -0
- package/commands/scr/thread.md +83 -0
- package/commands/scr/timeline.md +141 -0
- package/commands/scr/track.md +564 -0
- package/commands/scr/translate.md +260 -0
- package/commands/scr/translation-glossary.md +298 -0
- package/commands/scr/translation-memory.md +310 -0
- package/commands/scr/troubleshoot.md +59 -0
- package/commands/scr/undo.md +106 -0
- package/commands/scr/validate.md +133 -0
- package/commands/scr/versions.md +94 -0
- package/commands/scr/voice-check.md +133 -0
- package/commands/scr/voice-test.md +68 -0
- package/data/CONSTRAINTS.json +1606 -0
- package/data/demo/.manuscript/BRIEF.md +37 -0
- package/data/demo/.manuscript/CHARACTERS.md +90 -0
- package/data/demo/.manuscript/OUTLINE.md +46 -0
- package/data/demo/.manuscript/PLOT-GRAPH.md +75 -0
- package/data/demo/.manuscript/STATE.md +44 -0
- package/data/demo/.manuscript/STYLE-GUIDE.md +119 -0
- package/data/demo/.manuscript/THEMES.md +51 -0
- package/data/demo/.manuscript/WORK.md +51 -0
- package/data/demo/.manuscript/config.json +59 -0
- package/data/demo/.manuscript/drafts/body/1-the-letter-DRAFT.md +51 -0
- package/data/demo/.manuscript/drafts/body/2-the-workshop-DRAFT.md +51 -0
- package/data/demo/.manuscript/drafts/body/3-the-pier-DRAFT.md +45 -0
- package/data/demo/.manuscript/drafts/body/4-the-clock-DRAFT.md +59 -0
- package/data/demo/.manuscript/plans/5-the-reunion-PLAN.md +52 -0
- package/data/demo/.manuscript/reviews/2-the-workshop-REVIEW.md +61 -0
- package/data/export-templates/scriveno-academic.latex +184 -0
- package/data/export-templates/scriveno-acm.latex +67 -0
- package/data/export-templates/scriveno-apa7.latex +83 -0
- package/data/export-templates/scriveno-book.typst +175 -0
- package/data/export-templates/scriveno-chapbook.typst +121 -0
- package/data/export-templates/scriveno-elsevier.latex +76 -0
- package/data/export-templates/scriveno-epub.css +386 -0
- package/data/export-templates/scriveno-fixed-layout-epub.css +76 -0
- package/data/export-templates/scriveno-fixed-layout.opf +23 -0
- package/data/export-templates/scriveno-ieee.latex +77 -0
- package/data/export-templates/scriveno-lncs.latex +79 -0
- package/data/export-templates/scriveno-picturebook.typst +113 -0
- package/data/export-templates/scriveno-poetry-submission-styles.md +45 -0
- package/data/export-templates/scriveno-poetry-submission.docx +0 -0
- package/data/export-templates/scriveno-smashwords-styles.md +45 -0
- package/data/export-templates/scriveno-smashwords.docx +0 -0
- package/data/export-templates/scriveno-stageplay.typst +129 -0
- package/data/proof/creative-context/README.md +79 -0
- package/data/proof/voice-dna/GUIDED-SAMPLE.md +19 -0
- package/data/proof/voice-dna/README.md +45 -0
- package/data/proof/voice-dna/STYLE-GUIDE-EXCERPT.md +43 -0
- package/data/proof/voice-dna/UNGUIDED-SAMPLE.md +11 -0
- package/data/proof/watchmaker-flow/README.md +78 -0
- package/docs/architecture.md +425 -0
- package/docs/command-reference.md +2384 -0
- package/docs/configuration.md +228 -0
- package/docs/context-protocol.md +81 -0
- package/docs/contributing.md +430 -0
- package/docs/creative-context.md +158 -0
- package/docs/development.md +152 -0
- package/docs/drafter-quality.md +127 -0
- package/docs/getting-started.md +198 -0
- package/docs/history-protocol.md +96 -0
- package/docs/proof-artifacts.md +56 -0
- package/docs/publishing.md +296 -0
- package/docs/release-notes.md +457 -0
- package/docs/runtime-support.md +77 -0
- package/docs/sacred-texts.md +296 -0
- package/docs/shipped-assets.md +129 -0
- package/docs/testing.md +156 -0
- package/docs/translation.md +343 -0
- package/docs/voice-dna.md +297 -0
- package/docs/work-types.md +339 -0
- package/lib/architectural-profiles.js +134 -0
- package/package.json +54 -0
- package/templates/BRIEF.md +51 -0
- package/templates/CHARACTERS.md +64 -0
- package/templates/CONTEXT.md +56 -0
- package/templates/OUTLINE.md +36 -0
- package/templates/RECORD.md +68 -0
- package/templates/STATE.md +50 -0
- package/templates/STYLE-GUIDE.md +121 -0
- package/templates/THEMES.md +36 -0
- package/templates/WORK.md +67 -0
- package/templates/WORLD.md +62 -0
- package/templates/WRITING-RULES.md +156 -0
- package/templates/academic/ARGUMENT-MAP.md +40 -0
- package/templates/academic/CONCEPTS.md +34 -0
- package/templates/academic/CONTEXT.md +29 -0
- package/templates/academic/PROPOSAL.md +37 -0
- package/templates/academic/QUESTIONS.md +24 -0
- package/templates/config.json +72 -0
- package/templates/pitfalls/comic.md +54 -0
- package/templates/pitfalls/commentary.md +62 -0
- package/templates/pitfalls/memoir.md +48 -0
- package/templates/pitfalls/novel.md +53 -0
- package/templates/pitfalls/poetry_collection.md +63 -0
- package/templates/pitfalls/research_paper.md +66 -0
- package/templates/pitfalls/runbook.md +64 -0
- package/templates/pitfalls/screenplay.md +54 -0
- package/templates/platforms/README.md +16 -0
- package/templates/platforms/apple/manifest.yaml +20 -0
- package/templates/platforms/bn/manifest.yaml +20 -0
- package/templates/platforms/d2d/manifest.yaml +20 -0
- package/templates/platforms/google/manifest.yaml +20 -0
- package/templates/platforms/ingram/manifest.yaml +44 -0
- package/templates/platforms/kdp/manifest.yaml +42 -0
- package/templates/platforms/kobo/manifest.yaml +20 -0
- package/templates/platforms/smashwords/manifest.yaml +26 -0
- package/templates/sacred/COSMOLOGY.md +88 -0
- package/templates/sacred/DOCTRINES.md +45 -0
- package/templates/sacred/FIGURES.md +69 -0
- package/templates/sacred/FRAMEWORK.md +98 -0
- package/templates/sacred/LINEAGES.md +52 -0
- package/templates/sacred/README.md +20 -0
- package/templates/sacred/THEOLOGICAL-ARC.md +69 -0
- package/templates/sacred/catholic/manifest.yaml +93 -0
- package/templates/sacred/islamic-hafs/manifest.yaml +134 -0
- package/templates/sacred/islamic-warsh/manifest.yaml +134 -0
- package/templates/sacred/jewish/manifest.yaml +56 -0
- package/templates/sacred/orthodox/manifest.yaml +98 -0
- package/templates/sacred/pali/manifest.yaml +20 -0
- package/templates/sacred/protestant/manifest.yaml +86 -0
- package/templates/sacred/sanskrit/manifest.yaml +20 -0
- package/templates/sacred/tewahedo/manifest.yaml +106 -0
- package/templates/sacred/tibetan/manifest.yaml +20 -0
- package/templates/technical/AUDIENCE.md +26 -0
- package/templates/technical/DEPENDENCIES.md +19 -0
- package/templates/technical/DOC-BRIEF.md +45 -0
- package/templates/technical/PROCEDURES.md +37 -0
- package/templates/technical/REFERENCES.md +36 -0
- 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
|
+
};
|