@enfocussw/switch-scripting-context 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +504 -0
- package/README.md +192 -0
- package/bin/cli.js +8 -0
- package/dist/init.d.ts +78 -0
- package/dist/init.js +894 -0
- package/docs/switch-api/api-versions.md +127 -0
- package/docs/switch-api/connection.md +82 -0
- package/docs/switch-api/document-classes.md +189 -0
- package/docs/switch-api/entry-points.md +185 -0
- package/docs/switch-api/enums.md +181 -0
- package/docs/switch-api/execution-environment.md +143 -0
- package/docs/switch-api/flow-element.md +143 -0
- package/docs/switch-api/http.md +96 -0
- package/docs/switch-api/job-patterns.md +238 -0
- package/docs/switch-api/job.md +187 -0
- package/docs/switch-api/logging.md +117 -0
- package/docs/switch-api/switch.md +210 -0
- package/docs/switch-appstore/app-guidelines.md +281 -0
- package/docs/switch-appstore/app-manual.md +84 -0
- package/docs/switch-appstore/app-store-listing.md +69 -0
- package/docs/switch-appstore/app-store-submission.md +83 -0
- package/docs/switch-project/debugging.md +61 -0
- package/docs/switch-project/logs-and-dataroot.md +80 -0
- package/docs/switch-project/node-versions.md +87 -0
- package/docs/switch-project/project-planning.md +149 -0
- package/docs/switch-project/property-documentation.md +75 -0
- package/docs/switch-project/property-editors.md +249 -0
- package/docs/switch-project/script-declaration.md +407 -0
- package/docs/switch-project/script-structure.md +157 -0
- package/docs/switch-project/tooling.md +165 -0
- package/docs/switch-project/vscode.md +90 -0
- package/docs/switch-scripting.md +70 -0
- package/package.json +65 -0
package/dist/init.js
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseRoutingTable = parseRoutingTable;
|
|
37
|
+
exports.apiFiles = apiFiles;
|
|
38
|
+
exports.parseKeyRules = parseKeyRules;
|
|
39
|
+
exports.prefixDocPaths = prefixDocPaths;
|
|
40
|
+
exports.generateAgentsMd = generateAgentsMd;
|
|
41
|
+
exports.generateGeminiMd = generateGeminiMd;
|
|
42
|
+
exports.generateWindsurfRules = generateWindsurfRules;
|
|
43
|
+
exports.generateZedRules = generateZedRules;
|
|
44
|
+
exports.generateClineRules = generateClineRules;
|
|
45
|
+
exports.generateContinueRule = generateContinueRule;
|
|
46
|
+
exports.generateAiderConf = generateAiderConf;
|
|
47
|
+
exports.aiderReadConflict = aiderReadConflict;
|
|
48
|
+
exports.buildIdMap = buildIdMap;
|
|
49
|
+
exports.findMarkerRange = findMarkerRange;
|
|
50
|
+
exports.packageVersion = packageVersion;
|
|
51
|
+
exports.versionStamp = versionStamp;
|
|
52
|
+
exports.stripGitignoreEntry = stripGitignoreEntry;
|
|
53
|
+
exports.run = run;
|
|
54
|
+
const fs = __importStar(require("fs"));
|
|
55
|
+
const path = __importStar(require("path"));
|
|
56
|
+
const readline = __importStar(require("readline"));
|
|
57
|
+
// ─── Markers ─────────────────────────────────────────────────────────────────
|
|
58
|
+
const MARKER_BEGIN = '<!-- switch-scripting-context begin -->';
|
|
59
|
+
const MARKER_END = '<!-- switch-scripting-context end -->';
|
|
60
|
+
const HTML_MARKERS = { begin: MARKER_BEGIN, end: MARKER_END };
|
|
61
|
+
// YAML has no HTML comments, so a YAML config file marks its section with # comments instead.
|
|
62
|
+
const YAML_MARKERS = {
|
|
63
|
+
begin: '# switch-scripting-context begin',
|
|
64
|
+
end: '# switch-scripting-context end',
|
|
65
|
+
};
|
|
66
|
+
// ─── Shared content (single source of truth for all generated tool files) ────
|
|
67
|
+
/** Package root, whether running from src/ (ts-node) or dist/. */
|
|
68
|
+
function packageRoot() {
|
|
69
|
+
return path.resolve(__dirname, '..');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The routing table in docs/switch-scripting.md is the single source of truth for
|
|
73
|
+
* which API docs exist and when to load each one. Everything the CLI generates is
|
|
74
|
+
* derived from it, so a new doc file needs registering in exactly one place here
|
|
75
|
+
* (plus README.md, which init.test.ts checks).
|
|
76
|
+
*/
|
|
77
|
+
function parseRoutingTable(root = packageRoot()) {
|
|
78
|
+
const hub = fs.readFileSync(path.join(root, 'docs', 'switch-scripting.md'), 'utf8');
|
|
79
|
+
const docs = [];
|
|
80
|
+
for (const line of hub.split('\n')) {
|
|
81
|
+
// | `<switch-api|switch-project|switch-appstore>/<file>.md` | <contents> | <load when> |
|
|
82
|
+
const m = /^\|\s*`(switch-api|switch-project|switch-appstore)\/([a-z0-9-]+\.md)`\s*\|(.*)\|(.*)\|\s*$/.exec(line);
|
|
83
|
+
if (m)
|
|
84
|
+
docs.push({ file: `${m[1]}/${m[2]}`, loadWhen: m[4].trim() });
|
|
85
|
+
}
|
|
86
|
+
if (docs.length === 0) {
|
|
87
|
+
throw new Error('No API docs found in docs/switch-scripting.md — the routing table format changed. ' +
|
|
88
|
+
'Generated tool config would be empty; fix parseRoutingTable() before publishing.');
|
|
89
|
+
}
|
|
90
|
+
return docs;
|
|
91
|
+
}
|
|
92
|
+
/** Filenames only, in routing-table order. */
|
|
93
|
+
function apiFiles(root) {
|
|
94
|
+
return parseRoutingTable(root).map(d => d.file);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The bullets under "## Key rules" in docs/switch-scripting.md, verbatim. Like the routing table,
|
|
98
|
+
* the hub is the single source: tools that get the rules inlined see exactly what the hub says.
|
|
99
|
+
*/
|
|
100
|
+
function parseKeyRules(root = packageRoot()) {
|
|
101
|
+
const hub = fs.readFileSync(path.join(root, 'docs', 'switch-scripting.md'), 'utf8');
|
|
102
|
+
const m = /^## Key rules\n([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(hub);
|
|
103
|
+
const rules = m ? m[1].trim() : '';
|
|
104
|
+
if (!rules.startsWith('- ')) {
|
|
105
|
+
throw new Error('No "## Key rules" list found in docs/switch-scripting.md. Inlined tool config would have ' +
|
|
106
|
+
'no rules; fix parseKeyRules() before publishing.');
|
|
107
|
+
}
|
|
108
|
+
return rules;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Rewrites each backticked doc path (`switch-api/job.md`, `switch-project/`) to start with the
|
|
112
|
+
* docs folder, so it resolves from the project root rather than from the hub's own folder.
|
|
113
|
+
*/
|
|
114
|
+
function prefixDocPaths(text, docsDir) {
|
|
115
|
+
return text.replace(/`((?:switch-api|switch-project|switch-appstore)\/[^`]*)`/g, (_, p) => `\`${docsDir}/${p}\``);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Key rules for a tool that can't import the hub. Doc paths in the hub are relative to the docs
|
|
119
|
+
* folder, so they get the docs folder prefixed to resolve from the project root.
|
|
120
|
+
*/
|
|
121
|
+
function inlineCoreRules(docsDir) {
|
|
122
|
+
const rules = prefixDocPaths(parseKeyRules(), docsDir);
|
|
123
|
+
// The hub states the read-first rule above its table; this list form needs its own wording.
|
|
124
|
+
return `## Core rules
|
|
125
|
+
|
|
126
|
+
- Read the matching file from the API reference list below before answering any question about Switch scripting, not only before writing code, including short and yes/no questions. These rules only summarise; Switch often differs from what general Node.js knowledge suggests.
|
|
127
|
+
${rules}`;
|
|
128
|
+
}
|
|
129
|
+
// ─── Content generators ──────────────────────────────────────────────────────
|
|
130
|
+
// Claude Code and Gemini CLI resolve @file imports themselves, so their generated
|
|
131
|
+
// block is just a pointer to switch-scripting.md; the routing table and key rules
|
|
132
|
+
// living there are the actual instructions. Codex, Windsurf, Zed, Cline, and Continue
|
|
133
|
+
// don't reliably follow that import syntax, so their generators (generateInlineBlock)
|
|
134
|
+
// paste the hub's key rules and routing table directly into the generated file
|
|
135
|
+
// instead. Aider has no instructions file at all; its config loads the hub as a
|
|
136
|
+
// read-only file. Don't collapse these into one shared generator; the split is
|
|
137
|
+
// deliberate, not duplication.
|
|
138
|
+
function generateClaudeMd(docsDir) {
|
|
139
|
+
return `${MARKER_BEGIN}
|
|
140
|
+
## Switch Scripting (Enfocus Switch)
|
|
141
|
+
|
|
142
|
+
@${docsDir}/switch-scripting.md
|
|
143
|
+
${MARKER_END}`;
|
|
144
|
+
}
|
|
145
|
+
function generateCopilotInstructions(docsDir) {
|
|
146
|
+
return `${MARKER_BEGIN}
|
|
147
|
+
## Switch Scripting (Enfocus Switch)
|
|
148
|
+
|
|
149
|
+
This project uses Enfocus Switch scripting (Node.js/TypeScript).
|
|
150
|
+
Consult the Switch scripting reference before answering questions about Switch scripting or writing or modifying code.
|
|
151
|
+
|
|
152
|
+
#file:../${docsDir}/switch-scripting.md
|
|
153
|
+
${MARKER_END}`;
|
|
154
|
+
}
|
|
155
|
+
function generateCopilotScopedInstructions(docsDir) {
|
|
156
|
+
return `---
|
|
157
|
+
applyTo: "**/*.ts"
|
|
158
|
+
---
|
|
159
|
+
${MARKER_BEGIN}
|
|
160
|
+
You are working on an Enfocus Switch scripting project (Node.js/TypeScript).
|
|
161
|
+
|
|
162
|
+
#file:../../${docsDir}/switch-scripting.md
|
|
163
|
+
${MARKER_END}`;
|
|
164
|
+
}
|
|
165
|
+
// alwaysApply keeps the rules in context for questions too. Globs alone attach them only when a
|
|
166
|
+
// matching file is in the chat.
|
|
167
|
+
function generateCursorMdc(docsDir) {
|
|
168
|
+
return `---
|
|
169
|
+
description: Enfocus Switch scripting rules and API reference for Node.js/TypeScript scripts
|
|
170
|
+
alwaysApply: true
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
${MARKER_BEGIN}
|
|
174
|
+
You are working on an Enfocus Switch scripting project (Node.js/TypeScript).
|
|
175
|
+
|
|
176
|
+
${inlineCoreRules(docsDir)}
|
|
177
|
+
|
|
178
|
+
## API reference
|
|
179
|
+
|
|
180
|
+
Load the file matching the task below. \`${docsDir}/switch-scripting.md\` repeats these rules and
|
|
181
|
+
this index, and adds a short summary of the execution environment:
|
|
182
|
+
|
|
183
|
+
${inlineApiFileList(docsDir)}
|
|
184
|
+
${MARKER_END}`;
|
|
185
|
+
}
|
|
186
|
+
// ─── Inline block (for tools without @file import support) ──────────────────
|
|
187
|
+
function inlineApiFileList(docsDir) {
|
|
188
|
+
return parseRoutingTable()
|
|
189
|
+
.map(d => `- \`${docsDir}/${d.file}\` — ${d.loadWhen}`)
|
|
190
|
+
.join('\n');
|
|
191
|
+
}
|
|
192
|
+
function generateInlineBlock(docsDir) {
|
|
193
|
+
return `You are working on an Enfocus Switch scripting project (Node.js/TypeScript).
|
|
194
|
+
|
|
195
|
+
${inlineCoreRules(docsDir)}
|
|
196
|
+
|
|
197
|
+
## API reference
|
|
198
|
+
|
|
199
|
+
Load the file matching the task below. \`${docsDir}/switch-scripting.md\` repeats these rules and
|
|
200
|
+
this index, and adds a short summary of the execution environment:
|
|
201
|
+
|
|
202
|
+
${inlineApiFileList(docsDir)}`;
|
|
203
|
+
}
|
|
204
|
+
function generateAgentsMd(docsDir) {
|
|
205
|
+
return `${MARKER_BEGIN}
|
|
206
|
+
## Switch Scripting (Enfocus Switch)
|
|
207
|
+
|
|
208
|
+
${generateInlineBlock(docsDir)}
|
|
209
|
+
${MARKER_END}`;
|
|
210
|
+
}
|
|
211
|
+
function generateGeminiMd(docsDir) {
|
|
212
|
+
return `${MARKER_BEGIN}
|
|
213
|
+
## Switch Scripting (Enfocus Switch)
|
|
214
|
+
|
|
215
|
+
@${docsDir}/switch-scripting.md
|
|
216
|
+
${MARKER_END}`;
|
|
217
|
+
}
|
|
218
|
+
function generateWindsurfRules(docsDir) {
|
|
219
|
+
return `${MARKER_BEGIN}
|
|
220
|
+
## Switch Scripting (Enfocus Switch)
|
|
221
|
+
|
|
222
|
+
${generateInlineBlock(docsDir)}
|
|
223
|
+
${MARKER_END}`;
|
|
224
|
+
}
|
|
225
|
+
function generateZedRules(docsDir) {
|
|
226
|
+
return `${MARKER_BEGIN}
|
|
227
|
+
## Switch Scripting (Enfocus Switch)
|
|
228
|
+
|
|
229
|
+
${generateInlineBlock(docsDir)}
|
|
230
|
+
${MARKER_END}`;
|
|
231
|
+
}
|
|
232
|
+
function generateClineRules(docsDir) {
|
|
233
|
+
return `${MARKER_BEGIN}
|
|
234
|
+
## Switch Scripting (Enfocus Switch)
|
|
235
|
+
|
|
236
|
+
${generateInlineBlock(docsDir)}
|
|
237
|
+
${MARKER_END}`;
|
|
238
|
+
}
|
|
239
|
+
// Continue reads every Markdown file in .continue/rules/. alwaysApply keeps the rules in context
|
|
240
|
+
// for questions too, not only when a matching file is open.
|
|
241
|
+
function generateContinueRule(docsDir) {
|
|
242
|
+
return `---
|
|
243
|
+
name: Switch scripting
|
|
244
|
+
description: Enfocus Switch scripting rules and API reference for Node.js/TypeScript scripts
|
|
245
|
+
alwaysApply: true
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
${MARKER_BEGIN}
|
|
249
|
+
## Switch Scripting (Enfocus Switch)
|
|
250
|
+
|
|
251
|
+
${generateInlineBlock(docsDir)}
|
|
252
|
+
${MARKER_END}`;
|
|
253
|
+
}
|
|
254
|
+
// Aider resolves a relative read path against the folder it was started from, not the git root,
|
|
255
|
+
// so this entry only works when Aider starts at the project root.
|
|
256
|
+
function generateAiderConf(docsDir) {
|
|
257
|
+
return `${YAML_MARKERS.begin}
|
|
258
|
+
read:
|
|
259
|
+
- ${docsDir}/switch-scripting.md
|
|
260
|
+
${YAML_MARKERS.end}`;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A second top-level read key would not merge with the user's own: Aider keeps only the last
|
|
264
|
+
* one, so appending ours would silently drop every file the user already loads.
|
|
265
|
+
*/
|
|
266
|
+
function aiderReadConflict(existing, docsDir) {
|
|
267
|
+
if (!/^read\s*:/m.test(existing))
|
|
268
|
+
return null;
|
|
269
|
+
return `it already has a read: key. Add ${docsDir}/switch-scripting.md to that list yourself.`;
|
|
270
|
+
}
|
|
271
|
+
// ─── Tool registry ───────────────────────────────────────────────────────────
|
|
272
|
+
const TOOL_REGISTRY = [
|
|
273
|
+
{
|
|
274
|
+
id: 'claude',
|
|
275
|
+
label: 'Claude Code',
|
|
276
|
+
aliases: ['ClawCode'],
|
|
277
|
+
altIds: ['clawcode'],
|
|
278
|
+
files: [
|
|
279
|
+
{
|
|
280
|
+
targetPath: (root) => path.join(root, 'CLAUDE.md'),
|
|
281
|
+
generate: generateClaudeMd,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: 'copilot',
|
|
287
|
+
label: 'GitHub Copilot',
|
|
288
|
+
files: [
|
|
289
|
+
{
|
|
290
|
+
targetPath: (root) => path.join(root, '.github', 'copilot-instructions.md'),
|
|
291
|
+
generate: generateCopilotInstructions,
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
targetPath: (root) => path.join(root, '.github', 'instructions', 'switch-scripting.instructions.md'),
|
|
295
|
+
generate: generateCopilotScopedInstructions,
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'cursor',
|
|
301
|
+
label: 'Cursor',
|
|
302
|
+
files: [
|
|
303
|
+
{
|
|
304
|
+
targetPath: (root) => path.join(root, '.cursor', 'rules', 'switch-scripting.mdc'),
|
|
305
|
+
generate: generateCursorMdc,
|
|
306
|
+
previousFrontmatter: [
|
|
307
|
+
'---\ndescription: Enfocus Switch scripting rules and API reference for Node.js/TypeScript scripts\n' +
|
|
308
|
+
'globs: ["**/*.ts", "**/*.js"]\nalwaysApply: false\n---',
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: 'codex',
|
|
315
|
+
label: 'Codex CLI / OpenCode / Pi',
|
|
316
|
+
altIds: ['opencode', 'pi'],
|
|
317
|
+
files: [
|
|
318
|
+
{
|
|
319
|
+
targetPath: (root) => path.join(root, 'AGENTS.md'),
|
|
320
|
+
generate: generateAgentsMd,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: 'gemini',
|
|
326
|
+
label: 'Gemini CLI',
|
|
327
|
+
files: [
|
|
328
|
+
{
|
|
329
|
+
targetPath: (root) => path.join(root, 'GEMINI.md'),
|
|
330
|
+
generate: generateGeminiMd,
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
id: 'windsurf',
|
|
336
|
+
label: 'Windsurf',
|
|
337
|
+
files: [
|
|
338
|
+
{
|
|
339
|
+
targetPath: (root) => path.join(root, '.windsurfrules'),
|
|
340
|
+
generate: generateWindsurfRules,
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
id: 'zed',
|
|
346
|
+
label: 'Zed',
|
|
347
|
+
files: [
|
|
348
|
+
{
|
|
349
|
+
targetPath: (root) => path.join(root, '.rules'),
|
|
350
|
+
generate: generateZedRules,
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
id: 'cline',
|
|
356
|
+
label: 'Cline',
|
|
357
|
+
files: [
|
|
358
|
+
{
|
|
359
|
+
targetPath: (root) => path.join(root, '.clinerules'),
|
|
360
|
+
generate: generateClineRules,
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
id: 'continue',
|
|
366
|
+
label: 'Continue',
|
|
367
|
+
files: [
|
|
368
|
+
{
|
|
369
|
+
targetPath: (root) => path.join(root, '.continue', 'rules', 'switch-scripting.md'),
|
|
370
|
+
generate: generateContinueRule,
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
id: 'aider',
|
|
376
|
+
label: 'Aider',
|
|
377
|
+
files: [
|
|
378
|
+
{
|
|
379
|
+
targetPath: (root) => path.join(root, '.aider.conf.yml'),
|
|
380
|
+
generate: generateAiderConf,
|
|
381
|
+
markers: YAML_MARKERS,
|
|
382
|
+
appendConflict: aiderReadConflict,
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
];
|
|
387
|
+
// ─── ID map ──────────────────────────────────────────────────────────────────
|
|
388
|
+
function buildIdMap(registry) {
|
|
389
|
+
const map = new Map();
|
|
390
|
+
for (const t of registry) {
|
|
391
|
+
map.set(t.id, t.id);
|
|
392
|
+
for (const alt of t.altIds ?? [])
|
|
393
|
+
map.set(alt, t.id);
|
|
394
|
+
}
|
|
395
|
+
return map;
|
|
396
|
+
}
|
|
397
|
+
// ─── Merge logic ─────────────────────────────────────────────────────────────
|
|
398
|
+
function findMarkerRange(content, markers = HTML_MARKERS) {
|
|
399
|
+
const begin = content.indexOf(markers.begin);
|
|
400
|
+
const end = content.indexOf(markers.end);
|
|
401
|
+
if (begin === -1 || end === -1)
|
|
402
|
+
return null;
|
|
403
|
+
if (begin >= end)
|
|
404
|
+
return null;
|
|
405
|
+
return [begin, end + markers.end.length];
|
|
406
|
+
}
|
|
407
|
+
function resolveFileAction(targetPath, file, docsDir, force) {
|
|
408
|
+
const block = file.generate(docsDir);
|
|
409
|
+
const markers = file.markers ?? HTML_MARKERS;
|
|
410
|
+
if (!fs.existsSync(targetPath)) {
|
|
411
|
+
return { path: targetPath, action: 'create', content: block };
|
|
412
|
+
}
|
|
413
|
+
if (force) {
|
|
414
|
+
return { path: targetPath, action: 'overwrite', content: block };
|
|
415
|
+
}
|
|
416
|
+
const existing = fs.readFileSync(targetPath, 'utf8');
|
|
417
|
+
const range = findMarkerRange(existing, markers);
|
|
418
|
+
if (range) {
|
|
419
|
+
// The Cursor .mdc and Copilot scoped-instructions blocks carry frontmatter above the marker,
|
|
420
|
+
// and only the marker range is replaced. Writing the block whole would stack a second copy of
|
|
421
|
+
// that frontmatter on every re-run, so drop one of the two copies.
|
|
422
|
+
const frontmatter = block.slice(0, block.indexOf(markers.begin)).trim();
|
|
423
|
+
let before = existing.slice(0, range[0]);
|
|
424
|
+
let incoming = block;
|
|
425
|
+
if (frontmatter) {
|
|
426
|
+
if (ownFrontmatter(file, docsDir).includes(before.replace(/\r\n/g, '\n').trim())) {
|
|
427
|
+
before = ''; // our own frontmatter from an earlier run; the block's copy replaces it
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
// User-written or user-edited frontmatter, or init appended below the user's own content.
|
|
431
|
+
// Leave what is there and write only the marker section.
|
|
432
|
+
incoming = block.slice(block.indexOf(markers.begin));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const updated = before + incoming + existing.slice(range[1]);
|
|
436
|
+
return { path: targetPath, action: 'merge-replace', content: updated };
|
|
437
|
+
}
|
|
438
|
+
const reason = file.appendConflict?.(existing, docsDir);
|
|
439
|
+
if (reason) {
|
|
440
|
+
return { path: targetPath, action: 'skip', content: existing, reason };
|
|
441
|
+
}
|
|
442
|
+
const separator = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
443
|
+
return { path: targetPath, action: 'merge-append', content: existing + separator + block + '\n' };
|
|
444
|
+
}
|
|
445
|
+
function applyFileAction(action, dryRun) {
|
|
446
|
+
const rel = path.relative(process.cwd(), action.path);
|
|
447
|
+
if (action.action === 'skip') {
|
|
448
|
+
console.log(` ${rel} → skipped: ${action.reason}`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const label = action.action === 'create' ? 'created'
|
|
452
|
+
: action.action === 'overwrite' ? 'overwritten'
|
|
453
|
+
: action.action === 'merge-replace' ? 'updated (replaced Switch section)'
|
|
454
|
+
: 'updated (appended Switch section)';
|
|
455
|
+
if (dryRun) {
|
|
456
|
+
console.log(` [dry-run] ${rel} → ${label}`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const dir = path.dirname(action.path);
|
|
460
|
+
if (!fs.existsSync(dir))
|
|
461
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
462
|
+
fs.writeFileSync(action.path, action.content, 'utf8');
|
|
463
|
+
console.log(` ${rel} → ${label}`);
|
|
464
|
+
}
|
|
465
|
+
// ─── Docs copy ────────────────────────────────────────────────────────────────
|
|
466
|
+
/** Paths under docs/ that are never shipped to a consuming project. */
|
|
467
|
+
const DOCS_EXCLUDE = ['superpowers', 'temp'];
|
|
468
|
+
function copyDocs(packageRoot, destDir, dryRun) {
|
|
469
|
+
const src = path.join(packageRoot, 'docs');
|
|
470
|
+
const rel = path.relative(process.cwd(), destDir);
|
|
471
|
+
if (dryRun) {
|
|
472
|
+
if (fs.existsSync(destDir)) {
|
|
473
|
+
console.log(` [dry-run] ${rel}/ → wiped (removes files from a previous version, e.g. after a doc was renamed or moved)`);
|
|
474
|
+
}
|
|
475
|
+
console.log(` [dry-run] Copying docs/ → ${rel}/`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
// destDir is entirely owned by this tool (see the .gitignore comment it writes), so wiping
|
|
479
|
+
// it first is safe. Without this, a doc renamed or moved between versions (e.g. a docs/
|
|
480
|
+
// folder restructuring) would leave the old file behind forever: cpSync only adds/overwrites,
|
|
481
|
+
// it never removes, and destDir is gitignored so nothing would surface the staleness.
|
|
482
|
+
if (fs.existsSync(destDir)) {
|
|
483
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
484
|
+
}
|
|
485
|
+
// The published tarball already omits these, but init may run from a git clone.
|
|
486
|
+
fs.cpSync(src, destDir, {
|
|
487
|
+
recursive: true,
|
|
488
|
+
filter: (from) => {
|
|
489
|
+
const rel = path.relative(src, from);
|
|
490
|
+
if (!rel)
|
|
491
|
+
return true;
|
|
492
|
+
const [top] = rel.split(path.sep);
|
|
493
|
+
return !DOCS_EXCLUDE.includes(top) && path.basename(from) !== '.DS_Store';
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
console.log(` docs → ${path.relative(process.cwd(), destDir)}/`);
|
|
497
|
+
}
|
|
498
|
+
// ─── Version stamp ───────────────────────────────────────────────────────────
|
|
499
|
+
/** The hub file in the copied docs folder that carries the version stamp. */
|
|
500
|
+
const HUB_FILE = 'switch-scripting.md';
|
|
501
|
+
/** The running package's own version, so a git-clone install stamps what actually ran. */
|
|
502
|
+
function packageVersion(root = packageRoot()) {
|
|
503
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
|
504
|
+
}
|
|
505
|
+
/** The comment init writes as the first line of the copied hub file. */
|
|
506
|
+
function versionStamp(version) {
|
|
507
|
+
return `<!-- switch-scripting-context ${version} -->`;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Records which version produced the copied docs, so a consumer can tell what is installed
|
|
511
|
+
* without having recorded it at install time. Only the copy is stamped: the repo's own
|
|
512
|
+
* docs/switch-scripting.md is generated output and a stamp there would fight the generator.
|
|
513
|
+
* copyDocs wipes destDir on every run, so there is never a stale stamp to remove.
|
|
514
|
+
*/
|
|
515
|
+
function stampDocsVersion(packageRoot, destDir, dryRun) {
|
|
516
|
+
const stamp = versionStamp(packageVersion(packageRoot));
|
|
517
|
+
const hubPath = path.join(destDir, HUB_FILE);
|
|
518
|
+
const rel = path.relative(process.cwd(), hubPath);
|
|
519
|
+
if (dryRun) {
|
|
520
|
+
console.log(` [dry-run] ${rel} → first line ${stamp}`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
fs.writeFileSync(hubPath, `${stamp}\n${fs.readFileSync(hubPath, 'utf8')}`, 'utf8');
|
|
524
|
+
console.log(` ${rel} → stamped ${stamp}`);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* The hub's doc paths are relative to its own folder, but Claude Code and Gemini import it into a
|
|
528
|
+
* file at the project root, and agents then resolve `switch-api/job.md` from there. In a 72-run
|
|
529
|
+
* Haiku test almost half the runs began with a failed read of such a path. The copy gets the docs
|
|
530
|
+
* folder prefixed to every path; the repo's hub keeps the short form the generator writes.
|
|
531
|
+
*/
|
|
532
|
+
function resolveHubPaths(destDir, docsDir, dryRun) {
|
|
533
|
+
const hubPath = path.join(destDir, HUB_FILE);
|
|
534
|
+
const rel = path.relative(process.cwd(), hubPath);
|
|
535
|
+
if (dryRun) {
|
|
536
|
+
console.log(` [dry-run] ${rel} → doc paths prefixed with ${docsDir}/`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
fs.writeFileSync(hubPath, prefixDocPaths(fs.readFileSync(hubPath, 'utf8'), docsDir), 'utf8');
|
|
540
|
+
console.log(` ${rel} → doc paths prefixed with ${docsDir}/`);
|
|
541
|
+
}
|
|
542
|
+
// ─── Legacy docs folder ──────────────────────────────────────────────────────
|
|
543
|
+
/** Docs folder name written by versions before the rename to docs-for-agents. */
|
|
544
|
+
const LEGACY_DOCS_DIR = 'switch-docs';
|
|
545
|
+
/**
|
|
546
|
+
* Delete the docs folder an older version wrote under its old name.
|
|
547
|
+
*
|
|
548
|
+
* init regenerates every AI config block in place, so after an upgrade the configs point at
|
|
549
|
+
* the new folder and nothing reads the old one. It is gitignored, so nothing surfaces it
|
|
550
|
+
* either, but an agent grepping the project still finds and loads the stale copy. The folder
|
|
551
|
+
* is only removed when it holds the hub file init writes, so a folder of the same name that
|
|
552
|
+
* this tool did not create is left alone.
|
|
553
|
+
*/
|
|
554
|
+
function removeLegacyDocsDir(targetRoot, docsDir, dryRun) {
|
|
555
|
+
if (docsDir === LEGACY_DOCS_DIR)
|
|
556
|
+
return;
|
|
557
|
+
const legacyDir = path.join(targetRoot, LEGACY_DOCS_DIR);
|
|
558
|
+
if (!fs.existsSync(path.join(legacyDir, HUB_FILE)))
|
|
559
|
+
return;
|
|
560
|
+
if (dryRun) {
|
|
561
|
+
console.log(` [dry-run] ${LEGACY_DOCS_DIR}/ → deleted (docs folder from an earlier version)`);
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
fs.rmSync(legacyDir, { recursive: true, force: true });
|
|
565
|
+
console.log(` ${LEGACY_DOCS_DIR}/ → deleted (docs folder from an earlier version)`);
|
|
566
|
+
}
|
|
567
|
+
removeGitignoreEntry(targetRoot, LEGACY_DOCS_DIR, dryRun);
|
|
568
|
+
}
|
|
569
|
+
// ─── .gitignore update ───────────────────────────────────────────────────────
|
|
570
|
+
function updateGitignore(targetRoot, docsDir, dryRun) {
|
|
571
|
+
const gitignorePath = path.join(targetRoot, '.gitignore');
|
|
572
|
+
const entry = `\n# switch-scripting-context (AI context docs — re-generate with npx switch-scripting-context init)\n${docsDir}/\n`;
|
|
573
|
+
if (!fs.existsSync(gitignorePath))
|
|
574
|
+
return;
|
|
575
|
+
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
576
|
+
if (content.includes(`${docsDir}/`))
|
|
577
|
+
return;
|
|
578
|
+
if (dryRun) {
|
|
579
|
+
console.log(` [dry-run] .gitignore → append ${docsDir}/ entry`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// entry opens with its own blank-line separator, so normalise what the file already
|
|
583
|
+
// ends with. Without this, appending after an entry that was just removed leaves a
|
|
584
|
+
// widening gap in the file on every upgrade.
|
|
585
|
+
fs.writeFileSync(gitignorePath, content.replace(/\n*$/, '\n') + entry, 'utf8');
|
|
586
|
+
console.log(` .gitignore → appended ${docsDir}/ entry`);
|
|
587
|
+
}
|
|
588
|
+
/** AI config files that init may have created, for all tools or only the given tool ids. */
|
|
589
|
+
function aiConfigFiles(targetRoot, docsDir, tools) {
|
|
590
|
+
return TOOL_REGISTRY
|
|
591
|
+
.filter(t => !tools || tools.includes(t.id))
|
|
592
|
+
.flatMap(t => t.files.map(file => ({ filePath: file.targetPath(targetRoot, docsDir), file })));
|
|
593
|
+
}
|
|
594
|
+
/** The frontmatter a generator writes above the marker, trimmed; empty if it writes none. */
|
|
595
|
+
function generatedFrontmatter(file, docsDir) {
|
|
596
|
+
const generated = file.generate(docsDir);
|
|
597
|
+
return generated.slice(0, generated.indexOf((file.markers ?? HTML_MARKERS).begin)).trim();
|
|
598
|
+
}
|
|
599
|
+
/** Frontmatter init writes now or wrote in an earlier version. Empty when the file has none. */
|
|
600
|
+
function ownFrontmatter(file, docsDir) {
|
|
601
|
+
const current = generatedFrontmatter(file, docsDir);
|
|
602
|
+
return current ? [current, ...(file.previousFrontmatter ?? [])] : [];
|
|
603
|
+
}
|
|
604
|
+
/** Deletes now-empty folders from dir upwards, stopping at targetRoot. */
|
|
605
|
+
function removeEmptyDirs(dir, targetRoot) {
|
|
606
|
+
while (dir !== targetRoot && dir.startsWith(targetRoot + path.sep) && fs.readdirSync(dir).length === 0) {
|
|
607
|
+
fs.rmdirSync(dir);
|
|
608
|
+
dir = path.dirname(dir);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function removeAiConfig(filePath, file, docsDir, targetRoot, dryRun) {
|
|
612
|
+
if (!fs.existsSync(filePath))
|
|
613
|
+
return;
|
|
614
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
615
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
616
|
+
const content = raw.replace(/\r\n/g, '\n');
|
|
617
|
+
const range = findMarkerRange(content, file.markers);
|
|
618
|
+
if (!range)
|
|
619
|
+
return; // nothing from us in this file
|
|
620
|
+
const rel = path.relative(process.cwd(), filePath);
|
|
621
|
+
let before = content.slice(0, range[0]).trim();
|
|
622
|
+
const after = content.slice(range[1]).trim();
|
|
623
|
+
// Copilot scoped instructions, Cursor .mdc, and Continue rules put frontmatter above the marker. Only an exact
|
|
624
|
+
// copy of what the generator writes counts as ours; edited or user-written frontmatter stays.
|
|
625
|
+
for (const ours of ownFrontmatter(file, docsDir)) {
|
|
626
|
+
if (before === ours) {
|
|
627
|
+
before = '';
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
if (before.endsWith('\n' + ours)) {
|
|
631
|
+
// init appended our block, frontmatter included, below the user's own content
|
|
632
|
+
before = before.slice(0, -ours.length).trim();
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (!before && !after) {
|
|
637
|
+
// File is entirely our content — delete it
|
|
638
|
+
if (dryRun) {
|
|
639
|
+
console.log(` [dry-run] ${rel} → deleted`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
fs.unlinkSync(filePath);
|
|
643
|
+
removeEmptyDirs(path.dirname(filePath), targetRoot);
|
|
644
|
+
console.log(` ${rel} → deleted`);
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
// File has other content — strip our section
|
|
648
|
+
const stripped = (before ? before + '\n' : '') + (after ? '\n' + after + '\n' : '');
|
|
649
|
+
if (dryRun) {
|
|
650
|
+
console.log(` [dry-run] ${rel} → removed Switch section`);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
fs.writeFileSync(filePath, stripped.replace(/\n/g, eol), 'utf8');
|
|
654
|
+
console.log(` ${rel} → removed Switch section`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function escapeRegExp(s) {
|
|
658
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Drop the `docsDir/` line init added, together with the comment line directly above it.
|
|
662
|
+
* The comment and the path are matched as one unit so that a project carrying entries for
|
|
663
|
+
* two docs folders (one left by an older version, one current) keeps the comment belonging
|
|
664
|
+
* to the entry that stays.
|
|
665
|
+
*/
|
|
666
|
+
function stripGitignoreEntry(content, docsDir) {
|
|
667
|
+
const pattern = new RegExp(`(\n# switch-scripting-context \\(AI context docs[^\n]*\\))?\n${escapeRegExp(docsDir)}/\n`, 'g');
|
|
668
|
+
return content.replace(pattern, '\n');
|
|
669
|
+
}
|
|
670
|
+
function removeGitignoreEntry(targetRoot, docsDir, dryRun) {
|
|
671
|
+
const gitignorePath = path.join(targetRoot, '.gitignore');
|
|
672
|
+
if (!fs.existsSync(gitignorePath))
|
|
673
|
+
return;
|
|
674
|
+
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
675
|
+
const updated = stripGitignoreEntry(content, docsDir);
|
|
676
|
+
if (updated === content)
|
|
677
|
+
return;
|
|
678
|
+
if (dryRun) {
|
|
679
|
+
console.log(` [dry-run] .gitignore → removed ${docsDir}/ entry`);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
fs.writeFileSync(gitignorePath, updated, 'utf8');
|
|
683
|
+
console.log(` .gitignore → removed ${docsDir}/ entry`);
|
|
684
|
+
}
|
|
685
|
+
function remove(options) {
|
|
686
|
+
const targetRoot = process.cwd();
|
|
687
|
+
const docsDestDir = path.join(targetRoot, options.docsDir);
|
|
688
|
+
if (options.dryRun)
|
|
689
|
+
console.log('\nDry run — no files will be written.\n');
|
|
690
|
+
const tools = options.tools;
|
|
691
|
+
const partial = tools && !TOOL_REGISTRY.every(t => tools.includes(t.id));
|
|
692
|
+
if (partial) {
|
|
693
|
+
// Removing some tools leaves the rest configured, and they still reference the docs folder.
|
|
694
|
+
// Listing every tool leaves nothing referencing it, so that falls through to a full remove.
|
|
695
|
+
console.log('Cleaning AI config files...');
|
|
696
|
+
for (const { filePath, file } of aiConfigFiles(targetRoot, options.docsDir, options.tools)) {
|
|
697
|
+
removeAiConfig(filePath, file, options.docsDir, targetRoot, options.dryRun);
|
|
698
|
+
}
|
|
699
|
+
console.log('\nDone.\n');
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
// 1. Remove docs dir
|
|
703
|
+
console.log('Removing docs...');
|
|
704
|
+
if (fs.existsSync(docsDestDir)) {
|
|
705
|
+
if (options.dryRun) {
|
|
706
|
+
console.log(` [dry-run] ${options.docsDir}/ → deleted`);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
fs.rmSync(docsDestDir, { recursive: true, force: true });
|
|
710
|
+
console.log(` ${options.docsDir}/ → deleted`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
console.log(` ${options.docsDir}/ → not found, skipping`);
|
|
715
|
+
}
|
|
716
|
+
removeLegacyDocsDir(targetRoot, options.docsDir, options.dryRun);
|
|
717
|
+
// 2. Remove AI config sections / files
|
|
718
|
+
console.log('\nCleaning AI config files...');
|
|
719
|
+
for (const { filePath, file } of aiConfigFiles(targetRoot, options.docsDir)) {
|
|
720
|
+
removeAiConfig(filePath, file, options.docsDir, targetRoot, options.dryRun);
|
|
721
|
+
}
|
|
722
|
+
// 3. Update .gitignore
|
|
723
|
+
console.log('\nUpdating .gitignore...');
|
|
724
|
+
removeGitignoreEntry(targetRoot, options.docsDir, options.dryRun);
|
|
725
|
+
console.log('\nDone.\n');
|
|
726
|
+
}
|
|
727
|
+
// ─── Main init ───────────────────────────────────────────────────────────────
|
|
728
|
+
function init(options) {
|
|
729
|
+
const targetRoot = process.cwd();
|
|
730
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
731
|
+
const docsDestDir = path.join(targetRoot, options.docsDir);
|
|
732
|
+
if (options.dryRun)
|
|
733
|
+
console.log('\nDry run — no files will be written.\n');
|
|
734
|
+
// 1. Copy docs
|
|
735
|
+
console.log('Copying docs...');
|
|
736
|
+
removeLegacyDocsDir(targetRoot, options.docsDir, options.dryRun);
|
|
737
|
+
copyDocs(packageRoot, docsDestDir, options.dryRun);
|
|
738
|
+
stampDocsVersion(packageRoot, docsDestDir, options.dryRun);
|
|
739
|
+
resolveHubPaths(docsDestDir, options.docsDir, options.dryRun);
|
|
740
|
+
// 2. Generate AI config files
|
|
741
|
+
const toolActions = [];
|
|
742
|
+
for (const toolDef of TOOL_REGISTRY) {
|
|
743
|
+
if (!options.tools.includes(toolDef.id))
|
|
744
|
+
continue;
|
|
745
|
+
for (const f of toolDef.files) {
|
|
746
|
+
toolActions.push({ targetPath: f.targetPath(targetRoot, options.docsDir), file: f });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (toolActions.length > 0) {
|
|
750
|
+
console.log('\nGenerating AI config files...');
|
|
751
|
+
for (const { targetPath, file } of toolActions) {
|
|
752
|
+
const action = resolveFileAction(targetPath, file, options.docsDir, options.force);
|
|
753
|
+
applyFileAction(action, options.dryRun);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// 3. Update .gitignore
|
|
757
|
+
console.log('\nUpdating .gitignore...');
|
|
758
|
+
updateGitignore(targetRoot, options.docsDir, options.dryRun);
|
|
759
|
+
console.log('\nDone. Re-run after upgrading switch-scripting-context to refresh docs and config.\n');
|
|
760
|
+
}
|
|
761
|
+
// ─── CLI arg parsing ──────────────────────────────────────────────────────────
|
|
762
|
+
function parseArgs(argv) {
|
|
763
|
+
const args = argv.slice(2);
|
|
764
|
+
const subcommand = args[0] ?? 'help';
|
|
765
|
+
let toolsExplicit = false;
|
|
766
|
+
const initOptions = {
|
|
767
|
+
tools: TOOL_REGISTRY.map(t => t.id),
|
|
768
|
+
docsDir: 'docs-for-agents',
|
|
769
|
+
force: false,
|
|
770
|
+
dryRun: false,
|
|
771
|
+
};
|
|
772
|
+
const removeOptions = {
|
|
773
|
+
docsDir: 'docs-for-agents',
|
|
774
|
+
dryRun: false,
|
|
775
|
+
};
|
|
776
|
+
for (let i = 1; i < args.length; i++) {
|
|
777
|
+
const arg = args[i];
|
|
778
|
+
if (arg === '--force') {
|
|
779
|
+
initOptions.force = true;
|
|
780
|
+
}
|
|
781
|
+
else if (arg === '--dry-run') {
|
|
782
|
+
initOptions.dryRun = true;
|
|
783
|
+
removeOptions.dryRun = true;
|
|
784
|
+
}
|
|
785
|
+
else if (arg === '--tools' && args[i + 1]) {
|
|
786
|
+
const idMap = buildIdMap(TOOL_REGISTRY);
|
|
787
|
+
const raw = args[++i].split(',').map(s => s.trim());
|
|
788
|
+
const invalid = raw.filter(t => !idMap.has(t));
|
|
789
|
+
if (invalid.length > 0) {
|
|
790
|
+
const allValid = [...idMap.keys()].sort().join(', ');
|
|
791
|
+
console.error(`Unknown tool(s): ${invalid.join(', ')}. Valid values: ${allValid}`);
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
initOptions.tools = raw.map(t => idMap.get(t));
|
|
795
|
+
removeOptions.tools = initOptions.tools;
|
|
796
|
+
toolsExplicit = true;
|
|
797
|
+
}
|
|
798
|
+
else if (arg === '--docs-dir' && args[i + 1]) {
|
|
799
|
+
initOptions.docsDir = args[++i];
|
|
800
|
+
removeOptions.docsDir = initOptions.docsDir;
|
|
801
|
+
}
|
|
802
|
+
else if (arg.startsWith('--')) {
|
|
803
|
+
console.error(`Unknown option: ${arg}`);
|
|
804
|
+
process.exit(1);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return { subcommand, initOptions, removeOptions, toolsExplicit };
|
|
808
|
+
}
|
|
809
|
+
// ─── Interactive tool prompt ──────────────────────────────────────────────────
|
|
810
|
+
function toolPromptLabel(t) {
|
|
811
|
+
if (t.aliases && t.aliases.length > 0) {
|
|
812
|
+
return `${t.label} (also covers ${t.aliases.join(', ')})`;
|
|
813
|
+
}
|
|
814
|
+
return t.label;
|
|
815
|
+
}
|
|
816
|
+
async function promptTools() {
|
|
817
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
818
|
+
console.log('\nWhich coding agent(s) do you use?');
|
|
819
|
+
console.log('Enter numbers separated by spaces (or press Enter to select all):\n');
|
|
820
|
+
TOOL_REGISTRY.forEach((t, i) => console.log(` ${i + 1}) ${toolPromptLabel(t)}`));
|
|
821
|
+
console.log();
|
|
822
|
+
return new Promise((resolve) => {
|
|
823
|
+
rl.question('> ', (answer) => {
|
|
824
|
+
rl.close();
|
|
825
|
+
const trimmed = answer.trim();
|
|
826
|
+
if (!trimmed) {
|
|
827
|
+
resolve(TOOL_REGISTRY.map(t => t.id));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const indices = trimmed.split(/[\s,]+/).map(n => parseInt(n, 10) - 1);
|
|
831
|
+
const valid = indices.filter(i => i >= 0 && i < TOOL_REGISTRY.length);
|
|
832
|
+
if (valid.length === 0) {
|
|
833
|
+
resolve(TOOL_REGISTRY.map(t => t.id));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
resolve(valid.map(i => TOOL_REGISTRY[i].id));
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
function printHelp() {
|
|
841
|
+
console.log(`
|
|
842
|
+
Usage: switch-scripting-context <command> [options]
|
|
843
|
+
|
|
844
|
+
Commands:
|
|
845
|
+
init Copy docs and generate AI context config files
|
|
846
|
+
remove Remove files and config added by init (all of it, or only some tools' config)
|
|
847
|
+
|
|
848
|
+
Options (init):
|
|
849
|
+
--tools <list> Comma-separated tools to configure
|
|
850
|
+
IDs: claude, copilot, cursor, codex, gemini, windsurf, zed, cline,
|
|
851
|
+
continue, aider
|
|
852
|
+
Aliases: opencode, pi (→ codex), clawcode (→ claude)
|
|
853
|
+
Default: all tools
|
|
854
|
+
--docs-dir <dir> Destination folder for docs in target project (default: docs-for-agents)
|
|
855
|
+
--force Overwrite existing AI config files rather than merging
|
|
856
|
+
--dry-run Print what would happen without writing any files
|
|
857
|
+
--help Show this help message
|
|
858
|
+
|
|
859
|
+
Options (remove):
|
|
860
|
+
--tools <list> Only remove these tools' config; keeps the docs folder and other tools
|
|
861
|
+
Default: remove everything, same as listing every tool
|
|
862
|
+
--docs-dir <dir> Docs folder to remove (default: docs-for-agents)
|
|
863
|
+
--dry-run Print what would happen without writing any files
|
|
864
|
+
|
|
865
|
+
Examples:
|
|
866
|
+
npx switch-scripting-context init
|
|
867
|
+
npx switch-scripting-context init --tools claude,copilot
|
|
868
|
+
npx switch-scripting-context init --docs-dir ai-docs --dry-run
|
|
869
|
+
npx switch-scripting-context remove
|
|
870
|
+
npx switch-scripting-context remove --dry-run
|
|
871
|
+
npx switch-scripting-context remove --tools cursor
|
|
872
|
+
`);
|
|
873
|
+
}
|
|
874
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
875
|
+
async function run(argv = process.argv) {
|
|
876
|
+
const { subcommand, initOptions, removeOptions, toolsExplicit } = parseArgs(argv);
|
|
877
|
+
if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
|
|
878
|
+
printHelp();
|
|
879
|
+
}
|
|
880
|
+
else if (subcommand === 'init') {
|
|
881
|
+
if (!toolsExplicit) {
|
|
882
|
+
initOptions.tools = await promptTools();
|
|
883
|
+
}
|
|
884
|
+
init(initOptions);
|
|
885
|
+
}
|
|
886
|
+
else if (subcommand === 'remove') {
|
|
887
|
+
remove(removeOptions);
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
console.error(`Unknown command: ${subcommand}`);
|
|
891
|
+
printHelp();
|
|
892
|
+
process.exit(1);
|
|
893
|
+
}
|
|
894
|
+
}
|