vscode-autoconfig 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/cli.js +873 -0
  4. package/package.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryo Kasai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # vscode-autoconfig
2
+
3
+ [![CI](https://github.com/RKasai127/vscode-autoconfig/actions/workflows/ci.yml/badge.svg)](https://github.com/RKasai127/vscode-autoconfig/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/vscode-autoconfig.svg)](https://www.npmjs.com/package/vscode-autoconfig)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Analyze your project's manifest files (`package.json`, `requirements.txt`, `pyproject.toml`, ...) and generate `.vscode/settings.json` + `.vscode/extensions.json` recommendations — using a plain rule table.
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ npx vscode-autoconfig # dry-run: shows what would change, writes nothing
13
+ npx vscode-autoconfig --write # applies the changes shown by the dry-run above
14
+ ```
15
+
16
+ The dry-run is always the default. Nothing is ever written to disk unless you pass `--write` (or `-w`).
17
+
18
+ ### Example dry-run output
19
+
20
+ ```
21
+ vscode-autoconfig — dry run (no files written)
22
+
23
+ Detected
24
+ node typescript (devDependency)
25
+ node react (dependency)
26
+
27
+ .vscode/settings.json (will be created)
28
+ + typescript.tsdk: "node_modules/typescript/lib"
29
+
30
+ .vscode/extensions.json (will be created)
31
+ + dbaeumer.vscode-eslint
32
+ + dsznajder.es7-react-js-snippets
33
+
34
+ 2 settings, 2 extensions would be added.
35
+ Run again with --write to apply.
36
+ ```
37
+
38
+ ## Flags
39
+
40
+ | Flag | Description |
41
+ | --------------------- | ------------------------------------------------------------------------------------------------------------- |
42
+ | `-w, --write` | Apply changes. Without it, the command only previews changes (dry-run). |
43
+ | `--ecosystems <list>` | Comma-separated list of ecosystems to detect, e.g. `--ecosystems node`. Defaults to all supported ecosystems. |
44
+ | `--rules <path>` | Path to an external rule file (JSON or YAML). Merged with the built-in rules by default. |
45
+ | `--no-builtin-rules` | Ignore the built-in rules; use only the file passed via `--rules`. |
46
+ | `--json` | Print machine-readable JSON instead of the terminal report. |
47
+
48
+ There is intentionally no `--cwd` flag: the tool always operates on the current directory. `cd` into the project you want to configure before running it.
49
+
50
+ ## How it works
51
+
52
+ 1. **Detectors** scan the current directory (non-recursively — files inside `node_modules/`, `.venv/`, etc. are never inspected) and produce a flat list of _findings_: which manifest files exist, which dependencies/devDependencies are declared, which config files are present.
53
+ 2. **Rules** (plain JSON, see `src/rules/*.json`) declare a condition over findings and, when satisfied, contribute VS Code settings and/or extension recommendations.
54
+ 3. The matched rules are aggregated into one settings patch and one extension list, then merged into your existing `.vscode/settings.json` / `.vscode/extensions.json` — additively, never overwriting a value you already set.
55
+
56
+ ### Supported ecosystems (v1)
57
+
58
+ - **Node.js** — `package.json` dependencies/devDependencies, plus common config files (`tsconfig.json`, ESLint/Prettier configs).
59
+ - **Python** — `requirements.txt` and `pyproject.toml` (both PEP 621 `project.dependencies` and Poetry's `tool.poetry.dependencies`).
60
+
61
+ ## Merging behavior
62
+
63
+ - Settings/extensions you already have are **never overwritten**. Only missing keys/extensions are added.
64
+ - For `.vscode/extensions.json`, if you manually remove an ID from `recommendations`, it will simply be re-added the next time you run `--write` (deletions are not tracked). If you don't want an extension recommended again, add it to `unwantedRecommendations` yourself.
65
+ - Rules are merged in a fixed order (built-in rules, then any `--rules` file). If two rules propose conflicting values for the same settings key, the first one wins and the rest are reported as skipped — nothing is silently overwritten.
66
+
67
+ ## External rule files (`--rules`)
68
+
69
+ External rule files (JSON or YAML) let you extend or fully replace the built-in rule table without forking this tool. **Only use rule files from sources you trust** — treat them like any other executable configuration you'd run on your machine, and never run one handed to you by someone you don't trust.
70
+
71
+ ## License
72
+
73
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,873 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/args.ts
4
+ import { Command } from "commander";
5
+ function parseArgs(argv) {
6
+ const program = new Command();
7
+ program.name("vscode-autoconfig").description(
8
+ "Analyze your project's manifests and generate .vscode/settings.json + extensions.json recommendations"
9
+ ).option("-w, --write", "apply changes (default: dry-run only, writes nothing)", false).option("--ecosystems <list>", "comma-separated list of ecosystems to detect (node,python)").option(
10
+ "--rules <path>",
11
+ "path to an external rule file (JSON or YAML), merged with the built-in rules"
12
+ ).option("--no-builtin-rules", "ignore the built-in rules; use only the file passed via --rules").option("--json", "output machine-readable JSON instead of a terminal report", false).version("0.1.0");
13
+ program.parse(argv);
14
+ const opts = program.opts();
15
+ return {
16
+ write: opts.write,
17
+ ecosystems: opts.ecosystems ? opts.ecosystems.split(",").map((s) => s.trim()).filter(Boolean) : void 0,
18
+ rules: opts.rules,
19
+ builtinRules: opts.builtinRules,
20
+ json: opts.json
21
+ };
22
+ }
23
+
24
+ // src/cli/run.ts
25
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync4, writeFileSync } from "fs";
26
+
27
+ // src/core/aggregate.ts
28
+ function deepEqual(a, b) {
29
+ return JSON.stringify(a) === JSON.stringify(b);
30
+ }
31
+ function isPlainObject(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ function isLanguageOverrideKey(key) {
35
+ return /^\[.+]$/.test(key);
36
+ }
37
+ function mergeKey(target, key, patchValue, depthRemaining, pathLabel, ruleId, owners, conflicts) {
38
+ if (!(key in target)) {
39
+ target[key] = structuredClone(patchValue);
40
+ owners.set(pathLabel, ruleId);
41
+ return;
42
+ }
43
+ const currentValue = target[key];
44
+ if (deepEqual(currentValue, patchValue)) return;
45
+ if (depthRemaining > 0 && isPlainObject(currentValue) && isPlainObject(patchValue)) {
46
+ for (const [subKey, subValue] of Object.entries(patchValue)) {
47
+ mergeKey(
48
+ currentValue,
49
+ subKey,
50
+ subValue,
51
+ depthRemaining - 1,
52
+ `${pathLabel}.${subKey}`,
53
+ ruleId,
54
+ owners,
55
+ conflicts
56
+ );
57
+ }
58
+ return;
59
+ }
60
+ const existingConflict = conflicts.get(pathLabel);
61
+ if (existingConflict) {
62
+ existingConflict.ignoredRuleIds.push(ruleId);
63
+ } else {
64
+ conflicts.set(pathLabel, {
65
+ key: pathLabel,
66
+ winningRuleId: owners.get(pathLabel) ?? ruleId,
67
+ ignoredRuleIds: [ruleId]
68
+ });
69
+ }
70
+ }
71
+ function aggregate(matchedRules) {
72
+ const settings = {};
73
+ const owners = /* @__PURE__ */ new Map();
74
+ const conflictsByKey = /* @__PURE__ */ new Map();
75
+ const extensionIds = /* @__PURE__ */ new Map();
76
+ for (const rule of matchedRules) {
77
+ for (const [key, value] of Object.entries(rule.settings ?? {})) {
78
+ const maxDepth = isLanguageOverrideKey(key) ? 2 : 1;
79
+ mergeKey(settings, key, value, maxDepth, key, rule.id, owners, conflictsByKey);
80
+ }
81
+ for (const extensionId of rule.extensions ?? []) {
82
+ const lower = extensionId.toLowerCase();
83
+ if (!extensionIds.has(lower)) {
84
+ extensionIds.set(lower, extensionId);
85
+ }
86
+ }
87
+ }
88
+ return {
89
+ matchedRuleIds: matchedRules.map((rule) => rule.id),
90
+ settings,
91
+ extensions: [...extensionIds.values()].sort((a, b) => a.localeCompare(b)),
92
+ conflicts: [...conflictsByKey.values()]
93
+ };
94
+ }
95
+
96
+ // src/core/engine.ts
97
+ function isFindingMatcher(condition) {
98
+ return !("all" in condition) && !("any" in condition) && !("not" in condition);
99
+ }
100
+ function matchesFinding(matcher, finding) {
101
+ if (matcher.ecosystem !== void 0 && matcher.ecosystem !== finding.ecosystem) return false;
102
+ if (matcher.kind !== void 0 && matcher.kind !== finding.kind) return false;
103
+ if (matcher.name !== void 0 && matcher.name !== finding.name) return false;
104
+ return true;
105
+ }
106
+ function matchesCondition(condition, findings) {
107
+ if ("all" in condition) {
108
+ return condition.all.every((sub) => matchesCondition(sub, findings));
109
+ }
110
+ if ("any" in condition) {
111
+ return condition.any.some((sub) => matchesCondition(sub, findings));
112
+ }
113
+ if ("not" in condition) {
114
+ return !matchesCondition(condition.not, findings);
115
+ }
116
+ if (isFindingMatcher(condition)) {
117
+ return findings.some((finding) => matchesFinding(condition, finding));
118
+ }
119
+ return false;
120
+ }
121
+ function evaluateRules(findings, rules) {
122
+ return rules.filter((rule) => matchesCondition(rule.when, findings));
123
+ }
124
+
125
+ // src/detectors/node.ts
126
+ import { existsSync, readFileSync } from "fs";
127
+ import { join } from "path";
128
+
129
+ // src/detectors/types.ts
130
+ var DetectorError = class extends Error {
131
+ };
132
+
133
+ // src/detectors/node.ts
134
+ var NODE_CONFIG_FILENAMES = [
135
+ "tsconfig.json",
136
+ ".eslintrc",
137
+ ".eslintrc.json",
138
+ ".eslintrc.js",
139
+ ".eslintrc.cjs",
140
+ ".eslintrc.yml",
141
+ ".eslintrc.yaml",
142
+ "eslint.config.js",
143
+ "eslint.config.mjs",
144
+ "eslint.config.cjs",
145
+ "eslint.config.ts",
146
+ ".prettierrc",
147
+ ".prettierrc.json",
148
+ ".prettierrc.js",
149
+ ".prettierrc.cjs",
150
+ ".prettierrc.yml",
151
+ ".prettierrc.yaml",
152
+ "prettier.config.js",
153
+ "prettier.config.cjs"
154
+ ];
155
+ var nodeDetector = {
156
+ ecosystem: "node",
157
+ detect(ctx) {
158
+ const findings = [];
159
+ const pkgPath = join(ctx.cwd, "package.json");
160
+ if (existsSync(pkgPath)) {
161
+ findings.push({ ecosystem: "node", kind: "manifestPresence", name: "package.json" });
162
+ let pkg;
163
+ try {
164
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
165
+ } catch (error) {
166
+ throw new DetectorError(`Failed to parse package.json: ${error.message}`);
167
+ }
168
+ for (const name of Object.keys(pkg.dependencies ?? {})) {
169
+ findings.push({ ecosystem: "node", kind: "dependency", name });
170
+ }
171
+ for (const name of Object.keys(pkg.devDependencies ?? {})) {
172
+ findings.push({ ecosystem: "node", kind: "devDependency", name });
173
+ }
174
+ for (const name of Object.keys(pkg.scripts ?? {})) {
175
+ findings.push({ ecosystem: "node", kind: "scriptPresence", name });
176
+ }
177
+ }
178
+ for (const filename of NODE_CONFIG_FILENAMES) {
179
+ if (existsSync(join(ctx.cwd, filename))) {
180
+ findings.push({ ecosystem: "node", kind: "configFilePresence", name: filename });
181
+ }
182
+ }
183
+ return findings;
184
+ }
185
+ };
186
+
187
+ // src/detectors/python.ts
188
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
189
+ import { join as join2 } from "path";
190
+ import { parse as parseToml } from "smol-toml";
191
+ function parsePackageName(spec) {
192
+ const match = spec.trim().match(/^([A-Za-z0-9_.-]+)/);
193
+ return match?.[1] ?? null;
194
+ }
195
+ function parseRequirementsTxt(content) {
196
+ const names = [];
197
+ for (const rawLine of content.split("\n")) {
198
+ const line = rawLine.split("#")[0]?.trim() ?? "";
199
+ if (!line || line.startsWith("-")) continue;
200
+ const name = parsePackageName(line);
201
+ if (name) names.push(name);
202
+ }
203
+ return names;
204
+ }
205
+ function parsePyprojectToml(content) {
206
+ const doc = parseToml(content);
207
+ const dependencies = [];
208
+ const devDependencies = [];
209
+ const pep621Deps = doc.project?.dependencies;
210
+ if (Array.isArray(pep621Deps)) {
211
+ for (const spec of pep621Deps) {
212
+ if (typeof spec !== "string") continue;
213
+ const name = parsePackageName(spec);
214
+ if (name) dependencies.push(name);
215
+ }
216
+ }
217
+ const poetryDeps = doc.tool?.poetry?.dependencies ?? {};
218
+ for (const name of Object.keys(poetryDeps)) {
219
+ if (name === "python") continue;
220
+ dependencies.push(name);
221
+ }
222
+ const poetryDevDeps = doc.tool?.poetry?.group?.dev?.dependencies ?? doc.tool?.poetry?.["dev-dependencies"] ?? {};
223
+ for (const name of Object.keys(poetryDevDeps)) {
224
+ devDependencies.push(name);
225
+ }
226
+ return { dependencies, devDependencies };
227
+ }
228
+ var pythonDetector = {
229
+ ecosystem: "python",
230
+ detect(ctx) {
231
+ const findings = [];
232
+ const requirementsPath = join2(ctx.cwd, "requirements.txt");
233
+ if (existsSync2(requirementsPath)) {
234
+ findings.push({ ecosystem: "python", kind: "manifestPresence", name: "requirements.txt" });
235
+ const names = parseRequirementsTxt(readFileSync2(requirementsPath, "utf-8"));
236
+ for (const name of names) {
237
+ findings.push({ ecosystem: "python", kind: "dependency", name });
238
+ }
239
+ }
240
+ const pyprojectPath = join2(ctx.cwd, "pyproject.toml");
241
+ if (existsSync2(pyprojectPath)) {
242
+ findings.push({ ecosystem: "python", kind: "manifestPresence", name: "pyproject.toml" });
243
+ let parsed;
244
+ try {
245
+ parsed = parsePyprojectToml(readFileSync2(pyprojectPath, "utf-8"));
246
+ } catch (error) {
247
+ throw new DetectorError(`Failed to parse pyproject.toml: ${error.message}`);
248
+ }
249
+ for (const name of parsed.dependencies) {
250
+ findings.push({ ecosystem: "python", kind: "dependency", name });
251
+ }
252
+ for (const name of parsed.devDependencies) {
253
+ findings.push({ ecosystem: "python", kind: "devDependency", name });
254
+ }
255
+ }
256
+ return findings;
257
+ }
258
+ };
259
+
260
+ // src/detectors/registry.ts
261
+ var detectorRegistry = [nodeDetector, pythonDetector];
262
+
263
+ // src/detectors/collect.ts
264
+ function collectFindings(ctx, options = {}, detectors = detectorRegistry) {
265
+ const findings = [];
266
+ const errors = [];
267
+ const activeDetectors = options.ecosystems ? detectors.filter((detector) => options.ecosystems.includes(detector.ecosystem)) : detectors;
268
+ for (const detector of activeDetectors) {
269
+ try {
270
+ findings.push(...detector.detect(ctx));
271
+ } catch (error) {
272
+ errors.push(`[${detector.ecosystem}] ${error.message}`);
273
+ }
274
+ }
275
+ return { findings, errors };
276
+ }
277
+
278
+ // src/vscode/jsonc.ts
279
+ import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
280
+ function lineFromOffset(text, offset) {
281
+ let line = 1;
282
+ for (let i = 0; i < offset && i < text.length; i++) {
283
+ if (text[i] === "\n") line++;
284
+ }
285
+ return line;
286
+ }
287
+ function parseJsonc(text) {
288
+ const rawErrors = [];
289
+ const value = parse(text, rawErrors, { allowTrailingComma: true });
290
+ const errors = rawErrors.map((error) => ({
291
+ message: printParseErrorCode(error.error),
292
+ line: lineFromOffset(text, error.offset)
293
+ }));
294
+ return { value: errors.length > 0 ? void 0 : value, errors };
295
+ }
296
+ var FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2, eol: "\n" };
297
+ function setJsoncValue(text, path, value) {
298
+ const edits = modify(text, path, value, { formattingOptions: FORMATTING_OPTIONS });
299
+ return applyEdits(text, edits);
300
+ }
301
+ function insertJsoncArrayItem(text, path, index, value) {
302
+ const edits = modify(text, [...path, index], value, {
303
+ isArrayInsertion: true,
304
+ formattingOptions: FORMATTING_OPTIONS
305
+ });
306
+ return applyEdits(text, edits);
307
+ }
308
+
309
+ // src/vscode/extensions-merge.ts
310
+ function readRecommendations(text) {
311
+ const parsed = parseJsonc(text);
312
+ return parsed.value?.recommendations ?? [];
313
+ }
314
+ function mergeExtensions(existingText, ids) {
315
+ const baseText = existingText && existingText.trim().length > 0 ? existingText : "{}";
316
+ const parsed = parseJsonc(baseText);
317
+ const unwanted = new Set(
318
+ (parsed.value?.unwantedRecommendations ?? []).map((id) => id.toLowerCase())
319
+ );
320
+ const seen = new Set((parsed.value?.recommendations ?? []).map((id) => id.toLowerCase()));
321
+ let text = baseText;
322
+ const added = [];
323
+ const alreadyPresent = [];
324
+ const skippedUnwanted = [];
325
+ for (const id of ids) {
326
+ const lower = id.toLowerCase();
327
+ if (seen.has(lower)) {
328
+ alreadyPresent.push(id);
329
+ continue;
330
+ }
331
+ if (unwanted.has(lower)) {
332
+ skippedUnwanted.push(id);
333
+ continue;
334
+ }
335
+ const currentLength = readRecommendations(text).length;
336
+ text = insertJsoncArrayItem(text, ["recommendations"], currentLength, id);
337
+ seen.add(lower);
338
+ added.push(id);
339
+ }
340
+ return { nextText: text, added, alreadyPresent, skippedUnwanted };
341
+ }
342
+
343
+ // src/vscode/settings-merge.ts
344
+ function isPlainObject2(value) {
345
+ return typeof value === "object" && value !== null && !Array.isArray(value);
346
+ }
347
+ function isLanguageOverrideKey2(key) {
348
+ return /^\[.+]$/.test(key);
349
+ }
350
+ function planNested(path, currentValue, patchValue, depthRemaining, additions, skipped) {
351
+ if (depthRemaining <= 0 || !isPlainObject2(currentValue) || !isPlainObject2(patchValue)) {
352
+ skipped[path.join(".")] = "existing";
353
+ return;
354
+ }
355
+ for (const [subKey, subValue] of Object.entries(patchValue)) {
356
+ const subPath = [...path, subKey];
357
+ if (!(subKey in currentValue)) {
358
+ additions.push({ path: subPath, value: subValue });
359
+ continue;
360
+ }
361
+ planNested(subPath, currentValue[subKey], subValue, depthRemaining - 1, additions, skipped);
362
+ }
363
+ }
364
+ function planSettingsPatch(current, patch) {
365
+ const additions = [];
366
+ const skipped = {};
367
+ for (const [key, value] of Object.entries(patch)) {
368
+ if (!(key in current)) {
369
+ additions.push({ path: [key], value });
370
+ continue;
371
+ }
372
+ const maxDepth = isLanguageOverrideKey2(key) ? 2 : 1;
373
+ planNested([key], current[key], value, maxDepth, additions, skipped);
374
+ }
375
+ return { additions, skipped };
376
+ }
377
+ function mergeSettings(existingText, patch) {
378
+ const baseText = existingText && existingText.trim().length > 0 ? existingText : "{}";
379
+ const parsed = parseJsonc(baseText);
380
+ const current = parsed.value ?? {};
381
+ const { additions, skipped } = planSettingsPatch(current, patch);
382
+ let text = baseText;
383
+ const added = {};
384
+ for (const addition of additions) {
385
+ text = setJsoncValue(text, addition.path, addition.value);
386
+ added[addition.path.join(".")] = addition.value;
387
+ }
388
+ return { nextText: text, added, skipped };
389
+ }
390
+
391
+ // src/diff/preview.ts
392
+ function checkParseError(text) {
393
+ if (text === void 0) return void 0;
394
+ const parsed = parseJsonc(text);
395
+ if (parsed.errors.length === 0) return void 0;
396
+ const first = parsed.errors[0];
397
+ return { line: first.line, message: first.message };
398
+ }
399
+ function buildPreview(params) {
400
+ const {
401
+ mode,
402
+ findings,
403
+ ecosystems,
404
+ aggregated,
405
+ settingsPath,
406
+ extensionsPath,
407
+ settingsText,
408
+ extensionsText
409
+ } = params;
410
+ const files = [];
411
+ const skippedFiles = [];
412
+ let settingsAdded = {};
413
+ let settingsSkipped = {};
414
+ const settingsError = checkParseError(settingsText);
415
+ if (settingsError) {
416
+ skippedFiles.push({
417
+ file: settingsPath,
418
+ reason: `Syntax error near line ${settingsError.line} of settings.json (${settingsError.message}). This file was skipped.`,
419
+ line: settingsError.line
420
+ });
421
+ } else {
422
+ const result = mergeSettings(settingsText, aggregated.settings);
423
+ settingsAdded = result.added;
424
+ settingsSkipped = result.skipped;
425
+ files.push({
426
+ path: settingsPath,
427
+ label: "settings.json",
428
+ existed: settingsText !== void 0,
429
+ before: settingsText ?? "",
430
+ after: result.nextText
431
+ });
432
+ }
433
+ let extensionsAdded = [];
434
+ let extensionsAlreadyPresent = [];
435
+ let extensionsUnwanted = [];
436
+ const extensionsError = checkParseError(extensionsText);
437
+ if (extensionsError) {
438
+ skippedFiles.push({
439
+ file: extensionsPath,
440
+ reason: `Syntax error near line ${extensionsError.line} of extensions.json (${extensionsError.message}). This file was skipped.`,
441
+ line: extensionsError.line
442
+ });
443
+ } else {
444
+ const result = mergeExtensions(extensionsText, aggregated.extensions);
445
+ extensionsAdded = result.added;
446
+ extensionsAlreadyPresent = result.alreadyPresent;
447
+ extensionsUnwanted = result.skippedUnwanted;
448
+ files.push({
449
+ path: extensionsPath,
450
+ label: "extensions.json",
451
+ existed: extensionsText !== void 0,
452
+ before: extensionsText ?? "",
453
+ after: result.nextText
454
+ });
455
+ }
456
+ const json = {
457
+ mode,
458
+ detected: { ecosystems, findings },
459
+ matchedRules: aggregated.matchedRuleIds,
460
+ settings: { added: settingsAdded, skipped: settingsSkipped },
461
+ extensions: {
462
+ added: extensionsAdded,
463
+ alreadyPresent: extensionsAlreadyPresent,
464
+ unwanted: extensionsUnwanted
465
+ },
466
+ conflicts: aggregated.conflicts,
467
+ skippedFiles
468
+ };
469
+ return { json, files };
470
+ }
471
+
472
+ // src/diff/render.ts
473
+ import { diffLines } from "diff";
474
+ import pc from "picocolors";
475
+ function renderJson(json) {
476
+ return JSON.stringify(json, null, 2);
477
+ }
478
+ function renderTerminal(json, files) {
479
+ const lines = [];
480
+ const title = json.mode === "dry-run" ? "vscode-autoconfig \u2014 dry run (no files written)" : "vscode-autoconfig \u2014 applying changes";
481
+ lines.push(pc.bold(title));
482
+ lines.push("");
483
+ if (json.detected.findings.length > 0) {
484
+ lines.push(pc.bold("Detected"));
485
+ for (const finding of json.detected.findings) {
486
+ lines.push(` ${finding.ecosystem.padEnd(7)} ${finding.name} (${finding.kind})`);
487
+ }
488
+ lines.push("");
489
+ }
490
+ for (const file of files) {
491
+ if (file.before === file.after) continue;
492
+ lines.push(pc.bold(`${file.path}${file.existed ? "" : " (will be created)"}`));
493
+ for (const part of diffLines(file.before, file.after)) {
494
+ if (part.removed) continue;
495
+ const prefix = part.added ? "+" : " ";
496
+ const colorize = part.added ? pc.green : (s) => s;
497
+ for (const line of part.value.split("\n")) {
498
+ if (line.length === 0) continue;
499
+ lines.push(colorize(` ${prefix} ${line}`));
500
+ }
501
+ }
502
+ lines.push("");
503
+ }
504
+ if (json.conflicts.length > 0) {
505
+ lines.push(pc.yellow("Conflicts (first matching rule wins, no overwrite):"));
506
+ for (const conflict of json.conflicts) {
507
+ lines.push(
508
+ ` ${conflict.key}: "${conflict.winningRuleId}" wins over ${conflict.ignoredRuleIds.join(", ")}`
509
+ );
510
+ }
511
+ lines.push("");
512
+ }
513
+ if (json.skippedFiles.length > 0) {
514
+ lines.push(pc.red("Skipped files:"));
515
+ for (const skipped of json.skippedFiles) {
516
+ lines.push(` ${skipped.file}: ${skipped.reason}`);
517
+ }
518
+ lines.push("");
519
+ }
520
+ const settingsAddedCount = Object.keys(json.settings.added).length;
521
+ const extensionsAddedCount = json.extensions.added.length;
522
+ lines.push(`${settingsAddedCount} settings, ${extensionsAddedCount} extensions would be added.`);
523
+ if (json.mode === "dry-run") {
524
+ lines.push("Run again with --write to apply.");
525
+ }
526
+ return lines.join("\n");
527
+ }
528
+
529
+ // src/rules/loader.ts
530
+ import { readFileSync as readFileSync3 } from "fs";
531
+ import { extname } from "path";
532
+ import { parse as parseYaml } from "yaml";
533
+
534
+ // src/rules/schema.ts
535
+ import { z } from "zod";
536
+
537
+ // src/core/types.ts
538
+ var RULE_SCHEMA_VERSION = 1;
539
+
540
+ // src/rules/schema.ts
541
+ var ecosystemSchema = z.enum(["node", "python"]);
542
+ var findingKindSchema = z.enum([
543
+ "dependency",
544
+ "devDependency",
545
+ "manifestPresence",
546
+ "configFilePresence",
547
+ "scriptPresence"
548
+ ]);
549
+ var findingMatcherSchema = z.object({
550
+ ecosystem: ecosystemSchema.optional(),
551
+ kind: findingKindSchema.optional(),
552
+ name: z.string().optional()
553
+ }).strict();
554
+ var jsonValueSchema = z.lazy(
555
+ () => z.union([
556
+ z.string(),
557
+ z.number(),
558
+ z.boolean(),
559
+ z.null(),
560
+ z.array(jsonValueSchema),
561
+ z.record(jsonValueSchema)
562
+ ])
563
+ );
564
+ var ruleConditionSchema = z.lazy(
565
+ () => z.union([
566
+ z.object({ all: z.array(ruleConditionSchema) }).strict(),
567
+ z.object({ any: z.array(ruleConditionSchema) }).strict(),
568
+ z.object({ not: ruleConditionSchema }).strict(),
569
+ findingMatcherSchema
570
+ ])
571
+ );
572
+ var ruleSchema = z.object({
573
+ id: z.string().min(1),
574
+ description: z.string().optional(),
575
+ when: ruleConditionSchema,
576
+ settings: z.record(jsonValueSchema).optional(),
577
+ extensions: z.array(z.string()).optional()
578
+ });
579
+ var ruleFileSchema = z.object({
580
+ schemaVersion: z.number().int(),
581
+ rules: z.array(ruleSchema)
582
+ }).superRefine((file, ctx) => {
583
+ if (file.schemaVersion !== RULE_SCHEMA_VERSION) {
584
+ ctx.addIssue({
585
+ code: z.ZodIssueCode.custom,
586
+ path: ["schemaVersion"],
587
+ message: `This rule file has schemaVersion ${file.schemaVersion}, but this tool only supports version ${RULE_SCHEMA_VERSION}.`
588
+ });
589
+ }
590
+ const seenIds = /* @__PURE__ */ new Set();
591
+ for (const [index, rule] of file.rules.entries()) {
592
+ if (seenIds.has(rule.id)) {
593
+ ctx.addIssue({
594
+ code: z.ZodIssueCode.custom,
595
+ path: ["rules", index, "id"],
596
+ message: `Rule id "${rule.id}" is duplicated. Rule ids must be unique.`
597
+ });
598
+ }
599
+ seenIds.add(rule.id);
600
+ }
601
+ });
602
+
603
+ // src/rules/node.json
604
+ var node_default = {
605
+ schemaVersion: 1,
606
+ rules: [
607
+ {
608
+ id: "node-typescript-presence",
609
+ description: "TypeScript project detected via tsconfig.json or a typescript devDependency",
610
+ when: {
611
+ any: [
612
+ { ecosystem: "node", kind: "configFilePresence", name: "tsconfig.json" },
613
+ { ecosystem: "node", kind: "devDependency", name: "typescript" }
614
+ ]
615
+ },
616
+ settings: {
617
+ "js/ts.tsdk.path": "./node_modules/typescript/lib"
618
+ },
619
+ extensions: ["dbaeumer.vscode-eslint"]
620
+ },
621
+ {
622
+ id: "node-eslint-config-presence",
623
+ description: "ESLint config file or eslint devDependency detected",
624
+ when: {
625
+ any: [
626
+ { ecosystem: "node", kind: "configFilePresence", name: ".eslintrc" },
627
+ { ecosystem: "node", kind: "configFilePresence", name: ".eslintrc.json" },
628
+ { ecosystem: "node", kind: "configFilePresence", name: ".eslintrc.js" },
629
+ { ecosystem: "node", kind: "configFilePresence", name: ".eslintrc.cjs" },
630
+ { ecosystem: "node", kind: "configFilePresence", name: ".eslintrc.yml" },
631
+ { ecosystem: "node", kind: "configFilePresence", name: ".eslintrc.yaml" },
632
+ { ecosystem: "node", kind: "configFilePresence", name: "eslint.config.js" },
633
+ { ecosystem: "node", kind: "configFilePresence", name: "eslint.config.mjs" },
634
+ { ecosystem: "node", kind: "configFilePresence", name: "eslint.config.cjs" },
635
+ { ecosystem: "node", kind: "configFilePresence", name: "eslint.config.ts" },
636
+ { ecosystem: "node", kind: "devDependency", name: "eslint" }
637
+ ]
638
+ },
639
+ settings: {
640
+ "eslint.enable": true,
641
+ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }
642
+ },
643
+ extensions: ["dbaeumer.vscode-eslint"]
644
+ },
645
+ {
646
+ id: "node-prettier-config-presence",
647
+ description: "Prettier config file or prettier devDependency detected",
648
+ when: {
649
+ any: [
650
+ { ecosystem: "node", kind: "configFilePresence", name: ".prettierrc" },
651
+ { ecosystem: "node", kind: "configFilePresence", name: ".prettierrc.json" },
652
+ { ecosystem: "node", kind: "configFilePresence", name: ".prettierrc.js" },
653
+ { ecosystem: "node", kind: "configFilePresence", name: ".prettierrc.cjs" },
654
+ { ecosystem: "node", kind: "configFilePresence", name: ".prettierrc.yml" },
655
+ { ecosystem: "node", kind: "configFilePresence", name: ".prettierrc.yaml" },
656
+ { ecosystem: "node", kind: "configFilePresence", name: "prettier.config.js" },
657
+ { ecosystem: "node", kind: "configFilePresence", name: "prettier.config.cjs" },
658
+ { ecosystem: "node", kind: "devDependency", name: "prettier" }
659
+ ]
660
+ },
661
+ settings: {
662
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
663
+ },
664
+ extensions: ["esbenp.prettier-vscode"]
665
+ },
666
+ {
667
+ id: "node-react-dependency",
668
+ description: "React dependency detected",
669
+ when: { ecosystem: "node", kind: "dependency", name: "react" },
670
+ extensions: ["dsznajder.es7-react-js-snippets"]
671
+ },
672
+ {
673
+ id: "node-vue-dependency",
674
+ description: "Vue dependency detected",
675
+ when: { ecosystem: "node", kind: "dependency", name: "vue" },
676
+ extensions: ["Vue.volar"]
677
+ },
678
+ {
679
+ id: "node-next-dependency",
680
+ description: "Next.js dependency detected",
681
+ when: { ecosystem: "node", kind: "dependency", name: "next" },
682
+ extensions: ["dsznajder.es7-react-js-snippets"]
683
+ },
684
+ {
685
+ id: "node-tailwind-dependency",
686
+ description: "Tailwind CSS dependency detected",
687
+ when: { ecosystem: "node", kind: "dependency", name: "tailwindcss" },
688
+ extensions: ["bradlc.vscode-tailwindcss"]
689
+ }
690
+ ]
691
+ };
692
+
693
+ // src/rules/python.json
694
+ var python_default = {
695
+ schemaVersion: 1,
696
+ rules: [
697
+ {
698
+ id: "python-manifest-presence",
699
+ description: "requirements.txt or pyproject.toml detected",
700
+ when: {
701
+ any: [
702
+ { ecosystem: "python", kind: "manifestPresence", name: "requirements.txt" },
703
+ { ecosystem: "python", kind: "manifestPresence", name: "pyproject.toml" }
704
+ ]
705
+ },
706
+ extensions: ["ms-python.python", "ms-python.vscode-pylance"]
707
+ },
708
+ {
709
+ id: "python-black-dependency",
710
+ description: "black dependency detected",
711
+ when: {
712
+ any: [
713
+ { ecosystem: "python", kind: "dependency", name: "black" },
714
+ { ecosystem: "python", kind: "devDependency", name: "black" }
715
+ ]
716
+ },
717
+ settings: {
718
+ "[python]": {
719
+ "editor.defaultFormatter": "ms-python.black-formatter",
720
+ "editor.formatOnSave": true
721
+ }
722
+ },
723
+ extensions: ["ms-python.black-formatter"]
724
+ },
725
+ {
726
+ id: "python-ruff-dependency",
727
+ description: "ruff dependency detected",
728
+ when: {
729
+ any: [
730
+ { ecosystem: "python", kind: "dependency", name: "ruff" },
731
+ { ecosystem: "python", kind: "devDependency", name: "ruff" }
732
+ ]
733
+ },
734
+ settings: {
735
+ "[python]": {
736
+ "editor.codeActionsOnSave": { "source.fixAll.ruff": true }
737
+ }
738
+ },
739
+ extensions: ["charliermarsh.ruff"]
740
+ },
741
+ {
742
+ id: "python-mypy-dependency",
743
+ description: "mypy dependency detected",
744
+ when: {
745
+ any: [
746
+ { ecosystem: "python", kind: "dependency", name: "mypy" },
747
+ { ecosystem: "python", kind: "devDependency", name: "mypy" }
748
+ ]
749
+ },
750
+ extensions: ["ms-python.mypy-type-checker"]
751
+ }
752
+ ]
753
+ };
754
+
755
+ // src/rules/loader.ts
756
+ var RuleFileValidationError = class extends Error {
757
+ };
758
+ function validate(source, raw) {
759
+ const result = ruleFileSchema.safeParse(raw);
760
+ if (!result.success) {
761
+ const messages = result.error.issues.map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`).join("\n");
762
+ throw new RuleFileValidationError(`Rule file "${source}" failed validation:
763
+ ${messages}`);
764
+ }
765
+ return result.data.rules;
766
+ }
767
+ function loadBuiltinRules() {
768
+ return [...validate("node.json", node_default), ...validate("python.json", python_default)];
769
+ }
770
+ function loadExternalRules(path) {
771
+ const raw = readFileSync3(path, "utf-8");
772
+ const ext = extname(path).toLowerCase();
773
+ const parsed = ext === ".yaml" || ext === ".yml" ? parseYaml(raw) : JSON.parse(raw);
774
+ return validate(path, parsed);
775
+ }
776
+ function checkForDuplicateIds(rules) {
777
+ const ruleIds = /* @__PURE__ */ new Set();
778
+ for (const rule of rules) {
779
+ if (ruleIds.has(rule.id)) {
780
+ throw new RuleFileValidationError(
781
+ `Rule id "${rule.id}" is duplicated across the built-in and external rule sets. Rule ids must be unique.`
782
+ );
783
+ }
784
+ ruleIds.add(rule.id);
785
+ }
786
+ }
787
+ function loadRules({ externalPath, useBuiltinRules = true } = {}) {
788
+ const externalRules = externalPath ? loadExternalRules(externalPath) : [];
789
+ const rules = useBuiltinRules ? [...loadBuiltinRules(), ...externalRules] : externalRules;
790
+ checkForDuplicateIds(rules);
791
+ return rules;
792
+ }
793
+
794
+ // src/vscode/paths.ts
795
+ import { join as join3 } from "path";
796
+ function vscodeDir(cwd) {
797
+ return join3(cwd, ".vscode");
798
+ }
799
+ function settingsJsonPath(cwd) {
800
+ return join3(cwd, ".vscode", "settings.json");
801
+ }
802
+ function extensionsJsonPath(cwd) {
803
+ return join3(cwd, ".vscode", "extensions.json");
804
+ }
805
+
806
+ // src/cli/run.ts
807
+ function run(options) {
808
+ try {
809
+ const rules = loadRules({
810
+ externalPath: options.rulesPath,
811
+ useBuiltinRules: options.builtinRules
812
+ });
813
+ const { findings, errors } = collectFindings(
814
+ { cwd: options.cwd },
815
+ { ecosystems: options.ecosystems }
816
+ );
817
+ const matchedRules = evaluateRules(findings, rules);
818
+ const aggregated = aggregate(matchedRules);
819
+ const settingsPath = settingsJsonPath(options.cwd);
820
+ const extensionsPath = extensionsJsonPath(options.cwd);
821
+ const settingsText = existsSync3(settingsPath) ? readFileSync4(settingsPath, "utf-8") : void 0;
822
+ const extensionsText = existsSync3(extensionsPath) ? readFileSync4(extensionsPath, "utf-8") : void 0;
823
+ const ecosystemsDetected = [...new Set(findings.map((s) => s.ecosystem))];
824
+ const preview = buildPreview({
825
+ mode: options.write ? "write" : "dry-run",
826
+ findings,
827
+ ecosystems: ecosystemsDetected,
828
+ aggregated,
829
+ settingsPath,
830
+ extensionsPath,
831
+ settingsText,
832
+ extensionsText
833
+ });
834
+ const hasDiff = Object.keys(preview.json.settings.added).length > 0 || preview.json.extensions.added.length > 0;
835
+ const hasErrors = errors.length > 0 || preview.json.skippedFiles.length > 0;
836
+ const reportLines = errors.map((error) => `Error: ${error}`);
837
+ const report = options.json ? renderJson(preview.json) : renderTerminal(preview.json, preview.files);
838
+ const outputLines = [...reportLines, report];
839
+ if (options.write && hasDiff) {
840
+ mkdirSync(vscodeDir(options.cwd), { recursive: true });
841
+ for (const file of preview.files) {
842
+ if (file.before !== file.after) {
843
+ writeFileSync(file.path, file.after, "utf-8");
844
+ }
845
+ }
846
+ }
847
+ return { exitCode: hasErrors ? 2 : hasDiff ? 1 : 0, output: outputLines.join("\n") };
848
+ } catch (error) {
849
+ if (error instanceof RuleFileValidationError) {
850
+ return { exitCode: 2, output: `Error: ${error.message}` };
851
+ }
852
+ throw error;
853
+ }
854
+ }
855
+
856
+ // src/cli.ts
857
+ async function main() {
858
+ const cliArgs = parseArgs(process.argv);
859
+ const runResutl = run({
860
+ cwd: process.cwd(),
861
+ write: cliArgs.write,
862
+ ecosystems: cliArgs.ecosystems,
863
+ rulesPath: cliArgs.rules,
864
+ builtinRules: cliArgs.builtinRules,
865
+ json: cliArgs.json
866
+ });
867
+ console.log(runResutl.output);
868
+ process.exitCode = runResutl.exitCode;
869
+ }
870
+ main().catch((error) => {
871
+ console.error(error instanceof Error ? error.message : error);
872
+ process.exitCode = 2;
873
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "vscode-autoconfig",
3
+ "version": "0.1.0",
4
+ "description": "Analyze your project's manifests and auto-generate .vscode/settings.json and .vscode/extensions.json via rule-based detection (no AI/LLM).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "vscode-autoconfig": "./dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "dev": "tsup --watch",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest",
23
+ "lint": "eslint .",
24
+ "typecheck": "tsc --noEmit",
25
+ "format": "prettier --write .",
26
+ "format:check": "prettier --check .",
27
+ "prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build"
28
+ },
29
+ "dependencies": {
30
+ "commander": "^12.1.0",
31
+ "diff": "^5.2.0",
32
+ "jsonc-parser": "^3.3.1",
33
+ "picocolors": "^1.0.1",
34
+ "smol-toml": "^1.3.0",
35
+ "yaml": "^2.5.0",
36
+ "zod": "^3.23.8"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/js": "^9.7.0",
40
+ "@types/diff": "^5.2.1",
41
+ "@types/node": "^20.14.0",
42
+ "eslint": "^9.7.0",
43
+ "eslint-config-prettier": "^9.1.0",
44
+ "prettier": "^3.3.3",
45
+ "tsup": "^8.1.0",
46
+ "typescript": "^5.5.3",
47
+ "typescript-eslint": "^8.0.0",
48
+ "vitest": "^2.0.0"
49
+ }
50
+ }