From 9c98e61974405959fd29b3bbb9e6ffcaf0fd2ecf Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Mon, 27 Apr 2026 20:22:15 +0200 Subject: [PATCH] feat(cli): --print-config emits ready-to-paste .mcp.json for every detected install New flags: - --print-config: scan installs and emit a JSON block per install with derived server names (codesys-sp21-patch5, codesys-sp22-patch1, etc.) - --sp : filter to one SP family; collapses entry name to 'codesys' when exactly one install matches - --name : override the entry name (only valid with --sp narrowing to one) Side effect: --detect now reuses the same detector and additionally prints the derived profile name + suggested server entry name per install, so even users sticking to manual config get the values without guessing. Refactored install discovery into src/detect.ts so both --detect and --print-config share one implementation. New unit test fixture covers version parsing, missing-exe, dedup, sort order, --sp filter behaviour, --name override constraints, and verifies the emitted JSON parses back once // comments are stripped. The output also surfaces the multi-install caveat from launcher.ts: the double-spawn guard refuses to start a second CODESYS.exe even on a different exe path, so only one configured entry can be active at a time. --- README.md | 44 ++++---- src/bin.ts | 66 ++++++------ src/detect.ts | 162 +++++++++++++++++++++++++++++ tests/unit/detect.test.ts | 213 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 431 insertions(+), 54 deletions(-) create mode 100644 src/detect.ts create mode 100644 tests/unit/detect.test.ts diff --git a/README.md b/README.md index 88a9dcb..dedeb86 100644 --- a/README.md +++ b/README.md @@ -21,43 +21,38 @@ Install globally from npm: npm install -g codesys-mcp-sp21-plus ``` -Add to your `.mcp.json` (Claude Code configuration) — pick the block matching your installed CODESYS version, or adjust the path/profile to match. +Generate the `.mcp.json` snippet automatically — `--print-config` scans your installed CODESYS versions and emits a ready-to-paste block per install: -**CODESYS V3.5 SP21 Patch 5:** +```bash +codesys-mcp-sp21-plus --print-config # one entry per detected install +codesys-mcp-sp21-plus --print-config --sp 21 # only the SP21 entry, named "codesys" +``` + +Output looks like this on a machine with two installs: + +```jsonc +// Auto-generated by `codesys-mcp-sp21-plus --print-config` on 2026-04-27. +// Detected 2 CODESYS installations. Add the entries you want; remove the rest. +// +// CAVEAT: the launcher refuses to spawn alongside any other CODESYS.exe, +// so only ONE of these can be active at a time. ... -```json { "mcpServers": { - "codesys": { + "codesys-sp21-patch5": { "command": "codesys-mcp-sp21-plus", "args": [ "--codesys-path", "C:\\Program Files\\CODESYS 3.5.21.50\\CODESYS\\Common\\CODESYS.exe", "--codesys-profile", "CODESYS V3.5 SP21 Patch 5", "--mode", "persistent" ] - } + }, + "codesys-sp22-patch1": { ... } } } ``` -**CODESYS V3.5 SP22 Patch 1:** - -```json -{ - "mcpServers": { - "codesys": { - "command": "codesys-mcp-sp21-plus", - "args": [ - "--codesys-path", "C:\\Program Files\\CODESYS 3.5.22.10\\CODESYS\\Common\\CODESYS.exe", - "--codesys-profile", "CODESYS V3.5 SP22 Patch 1", - "--mode", "persistent" - ] - } - } -} -``` - -Run `codesys-mcp-sp21-plus --detect` to print the exact path/profile values for every CODESYS install on your machine — copy from there rather than guessing. +Drop the relevant entries into `.mcp.json` (the snippet is JSON with `//` comment headers — strip the comments before parsing if your tooling is strict). `--detect` alone (no `--print-config`) prints just the inventory with paths/profile/suggested-name per install. See [Installation](#installation) for source-install / upgrade / multi-install setups. @@ -226,6 +221,9 @@ Notes: | `--keep-alive` | Keep CODESYS running after server stops | `false` | | `--timeout ` | Default command timeout | `60000` | | `--detect` | List installed CODESYS versions and exit | — | +| `--print-config` | Print a ready-to-paste `.mcp.json` snippet for every detected install and exit | — | +| `--sp ` | With `--print-config`: emit only the entry for CODESYS V3.5 SP`` | — | +| `--name ` | With `--print-config --sp `: override the MCP server entry name | — | | `--verbose` | Enable verbose logging | — | | `--debug` | Enable debug logging | — | | `-V, --version` | Show version number | — | diff --git a/src/bin.ts b/src/bin.ts index 5ed64a2..f473589 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -6,6 +6,7 @@ import { program } from 'commander'; import { startMcpServer } from './server'; import { ServerConfig, ExecutionMode } from './types'; +import { detectInstalls, printConfig } from './detect'; let version = '0.1.0'; try { @@ -47,42 +48,45 @@ program .option('--verbose', 'Enable verbose logging') .option('--debug', 'Enable debug logging (more verbose)') .option('--detect', 'Detect installed CODESYS versions and exit') + .option('--print-config', 'Print a ready-to-paste .mcp.json snippet for every detected install and exit') + .option('--sp ', 'With --print-config: emit only the entry for CODESYS V3.5 SP') + .option('--name ', 'With --print-config --sp : override the MCP server entry name') .parse(process.argv); const opts = program.opts(); -// Handle --detect flag if (opts.detect) { - import('fs').then((fs) => { - import('path').then((pathMod) => { - const dirs = [ - 'C:\\Program Files', - 'C:\\Program Files (x86)', - ]; - process.stderr.write('Scanning for CODESYS installations...\n\n'); - let found = 0; - for (const base of dirs) { - try { - const entries = fs.readdirSync(base); - for (const entry of entries) { - if (entry.toLowerCase().includes('codesys')) { - const commonExe = pathMod.join(base, entry, 'CODESYS', 'Common', 'CODESYS.exe'); - const exists = fs.existsSync(commonExe); - process.stderr.write(` ${exists ? '[OK]' : '[--]'} ${pathMod.join(base, entry)}\n`); - if (exists) { - process.stderr.write(` Exe: ${commonExe}\n`); - found++; - } - } - } - } catch { - // dir doesn't exist - } - } - process.stderr.write(`\nFound ${found} CODESYS installation(s).\n`); - process.exit(0); - }); - }); + const installs = detectInstalls(); + process.stderr.write('Scanning for CODESYS installations...\n\n'); + if (installs.length === 0) { + process.stderr.write(' (no installations matching "CODESYS X.Y.Z.W" found)\n'); + } else { + for (const i of installs) { + process.stderr.write(` [OK] ${i.installDir}\n`); + process.stderr.write(` Exe: ${i.exePath}\n`); + process.stderr.write(` Profile: ${i.profileName}\n`); + process.stderr.write(` Suggested entry name: ${i.serverName}\n`); + } + } + process.stderr.write(`\nFound ${installs.length} CODESYS installation(s).\n`); + process.exit(0); +} else if (opts.printConfig) { + const installs = detectInstalls(); + let sp: number | undefined; + if (opts.sp !== undefined) { + sp = parseInt(opts.sp, 10); + if (Number.isNaN(sp)) { + process.stderr.write(`--sp must be a number (e.g. --sp 21). Got "${opts.sp}".\n`); + process.exit(1); + } + } + try { + process.stdout.write(printConfig(installs, { sp, name: opts.name }) + '\n'); + process.exit(0); + } catch (err) { + process.stderr.write(`${(err as Error).message}\n`); + process.exit(1); + } } else { // Build server config const config: ServerConfig = { diff --git a/src/detect.ts b/src/detect.ts new file mode 100644 index 0000000..4f73983 --- /dev/null +++ b/src/detect.ts @@ -0,0 +1,162 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface CodesysInstall { + installDir: string; + exePath: string; + version: string; + major: number; + minor: number; + sp: number; + patch: number; + profileName: string; + serverName: string; +} + +const VERSION_RE = /^CODESYS\s+(\d+)\.(\d+)\.(\d+)\.(\d+)$/i; + +function deriveProfileName(major: number, minor: number, sp: number, patch: number): string { + const head = `CODESYS V${major}.${minor} SP${sp}`; + return patch === 0 ? head : `${head} Patch ${patch}`; +} + +function deriveServerName(sp: number, patch: number): string { + const head = `codesys-sp${sp}`; + return patch === 0 ? head : `${head}-patch${patch}`; +} + +export function detectInstalls( + searchDirs: string[] = ['C:\\Program Files', 'C:\\Program Files (x86)'], + fsApi: { readdirSync: typeof fs.readdirSync; existsSync: typeof fs.existsSync } = fs +): CodesysInstall[] { + const installs: CodesysInstall[] = []; + const seen = new Set(); + + for (const base of searchDirs) { + let entries: string[]; + try { + entries = fsApi.readdirSync(base); + } catch { + continue; + } + for (const entry of entries) { + const m = VERSION_RE.exec(entry); + if (!m) continue; + const major = parseInt(m[1], 10); + const minor = parseInt(m[2], 10); + const sp = parseInt(m[3], 10); + const rawPatch = parseInt(m[4], 10); + const patch = Math.floor(rawPatch / 10); + + const exePath = path.join(base, entry, 'CODESYS', 'Common', 'CODESYS.exe'); + if (!fsApi.existsSync(exePath)) continue; + if (seen.has(exePath.toLowerCase())) continue; + seen.add(exePath.toLowerCase()); + + installs.push({ + installDir: path.join(base, entry), + exePath, + version: `${major}.${minor}.${sp}.${rawPatch}`, + major, + minor, + sp, + patch, + profileName: deriveProfileName(major, minor, sp, patch), + serverName: deriveServerName(sp, patch), + }); + } + } + + installs.sort((a, b) => { + if (a.sp !== b.sp) return a.sp - b.sp; + return a.patch - b.patch; + }); + + return installs; +} + +export interface PrintConfigOptions { + sp?: number; + name?: string; + date?: string; +} + +export function printConfig(installs: CodesysInstall[], opts: PrintConfigOptions = {}): string { + let filtered = installs; + if (opts.sp !== undefined) { + filtered = installs.filter((i) => i.sp === opts.sp); + } + + if (filtered.length === 0) { + if (opts.sp !== undefined) { + throw new Error( + `No CODESYS V3.5 SP${opts.sp} installation detected. Run \`codesys-mcp-sp21-plus --detect\` to see what's available.` + ); + } + throw new Error( + `No CODESYS installations detected. Looked under "C:\\Program Files" and "C:\\Program Files (x86)" for "CODESYS X.Y.Z.W"-named directories with a CODESYS\\Common\\CODESYS.exe inside. Run \`codesys-mcp-sp21-plus --detect\` for raw output.` + ); + } + + if (opts.name !== undefined && filtered.length !== 1) { + throw new Error( + `--name only works when exactly one install is selected. Got ${filtered.length}. Combine with --sp to narrow down.` + ); + } + + const today = opts.date ?? new Date().toISOString().slice(0, 10); + const lines: string[] = []; + + lines.push(`// Auto-generated by \`codesys-mcp-sp21-plus --print-config\` on ${today}.`); + if (filtered.length === 1) { + lines.push(`// Detected 1 CODESYS installation.`); + } else { + lines.push(`// Detected ${filtered.length} CODESYS installations. Add the entries you want; remove the rest.`); + lines.push(`//`); + lines.push(`// CAVEAT: the launcher refuses to spawn alongside any other CODESYS.exe,`); + lines.push(`// so only ONE of these can be active at a time. Adding multiple entries is`); + lines.push(`// fine -- Claude can call them by name -- but call shutdown_codesys before`); + lines.push(`// switching to a different one.`); + } + lines.push(`//`); + lines.push(`// Profile names are derived from the install directory version. If CODESYS's`); + lines.push(`// own Profile dialog shows a different name (e.g. localised), edit the`); + lines.push(`// --codesys-profile value to match exactly.`); + lines.push(''); + + const usedNames = new Set(); + const namedEntries = filtered.map((install, idx) => { + let name: string; + if (opts.name !== undefined) { + name = opts.name; + } else if (filtered.length === 1) { + name = 'codesys'; + } else { + name = install.serverName; + } + let unique = name; + let suffix = 2; + while (usedNames.has(unique)) { + unique = `${name}-${suffix++}`; + } + usedNames.add(unique); + return { name: unique, install, isLast: idx === filtered.length - 1 }; + }); + + lines.push('{'); + lines.push(' "mcpServers": {'); + for (const { name, install, isLast } of namedEntries) { + lines.push(` ${JSON.stringify(name)}: {`); + lines.push(` "command": "codesys-mcp-sp21-plus",`); + lines.push(` "args": [`); + lines.push(` "--codesys-path", ${JSON.stringify(install.exePath)},`); + lines.push(` "--codesys-profile", ${JSON.stringify(install.profileName)},`); + lines.push(` "--mode", "persistent"`); + lines.push(` ]`); + lines.push(` }${isLast ? '' : ','}`); + } + lines.push(' }'); + lines.push('}'); + + return lines.join('\n'); +} diff --git a/tests/unit/detect.test.ts b/tests/unit/detect.test.ts new file mode 100644 index 0000000..fed1e2b --- /dev/null +++ b/tests/unit/detect.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from 'vitest'; +import { detectInstalls, printConfig, CodesysInstall } from '../../src/detect'; + +function makeFakeFs(installDirs: { base: string; entries: string[]; existsExe: (p: string) => boolean }[]) { + return { + readdirSync: ((dir: string) => { + const match = installDirs.find((d) => d.base === dir); + if (!match) { + const err: NodeJS.ErrnoException = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return match.entries; + }) as unknown as typeof import('fs').readdirSync, + existsSync: ((p: string | Buffer | URL) => { + const s = String(p); + for (const d of installDirs) { + if (d.existsExe(s)) return true; + } + return false; + }) as typeof import('fs').existsSync, + }; +} + +describe('detectInstalls', () => { + it('parses 3.5.21.50 -> SP21 Patch 5 with derived names', () => { + const fakeFs = makeFakeFs([ + { + base: 'C:\\Program Files', + entries: ['CODESYS 3.5.21.50', 'Some Other App'], + existsExe: (p) => p.toLowerCase().includes('codesys 3.5.21.50') && p.endsWith('CODESYS.exe'), + }, + ]); + const installs = detectInstalls(['C:\\Program Files'], fakeFs); + expect(installs).toHaveLength(1); + expect(installs[0].sp).toBe(21); + expect(installs[0].patch).toBe(5); + expect(installs[0].profileName).toBe('CODESYS V3.5 SP21 Patch 5'); + expect(installs[0].serverName).toBe('codesys-sp21-patch5'); + }); + + it('omits Patch suffix when raw patch is 0', () => { + const fakeFs = makeFakeFs([ + { + base: 'C:\\Program Files', + entries: ['CODESYS 3.5.21.0'], + existsExe: (p) => p.endsWith('CODESYS.exe'), + }, + ]); + const installs = detectInstalls(['C:\\Program Files'], fakeFs); + expect(installs).toHaveLength(1); + expect(installs[0].profileName).toBe('CODESYS V3.5 SP21'); + expect(installs[0].serverName).toBe('codesys-sp21'); + }); + + it('skips dirs that do not match CODESYS X.Y.Z.W pattern', () => { + const fakeFs = makeFakeFs([ + { + base: 'C:\\Program Files (x86)', + entries: ['CODESYS', 'CODESYS Old', 'CODESYS 3.5'], + existsExe: (p) => p.endsWith('CODESYS.exe'), + }, + ]); + const installs = detectInstalls(['C:\\Program Files (x86)'], fakeFs); + expect(installs).toHaveLength(0); + }); + + it('skips dirs where the exe is missing', () => { + const fakeFs = makeFakeFs([ + { + base: 'C:\\Program Files', + entries: ['CODESYS 3.5.21.50', 'CODESYS 3.5.22.10'], + existsExe: (p) => p.toLowerCase().includes('3.5.22.10'), + }, + ]); + const installs = detectInstalls(['C:\\Program Files'], fakeFs); + expect(installs).toHaveLength(1); + expect(installs[0].sp).toBe(22); + }); + + it('sorts by SP then patch ascending', () => { + const fakeFs = makeFakeFs([ + { + base: 'C:\\Program Files', + entries: ['CODESYS 3.5.22.10', 'CODESYS 3.5.21.50', 'CODESYS 3.5.21.30'], + existsExe: (p) => p.endsWith('CODESYS.exe'), + }, + ]); + const installs = detectInstalls(['C:\\Program Files'], fakeFs); + expect(installs.map((i) => `${i.sp}.${i.patch}`)).toEqual(['21.3', '21.5', '22.1']); + }); + + it('deduplicates the same exe across search dirs', () => { + const fakeFs = makeFakeFs([ + { + base: 'C:\\Program Files', + entries: ['CODESYS 3.5.21.50'], + existsExe: (p) => p.endsWith('CODESYS.exe'), + }, + { + base: 'C:\\Program Files', + entries: ['CODESYS 3.5.21.50'], + existsExe: (p) => p.endsWith('CODESYS.exe'), + }, + ]); + const installs = detectInstalls(['C:\\Program Files', 'C:\\Program Files'], fakeFs); + expect(installs).toHaveLength(1); + }); +}); + +const fixture = (overrides: Partial = {}): CodesysInstall => ({ + installDir: 'C:\\Program Files\\CODESYS 3.5.22.10', + exePath: 'C:\\Program Files\\CODESYS 3.5.22.10\\CODESYS\\Common\\CODESYS.exe', + version: '3.5.22.10', + major: 3, + minor: 5, + sp: 22, + patch: 1, + profileName: 'CODESYS V3.5 SP22 Patch 1', + serverName: 'codesys-sp22-patch1', + ...overrides, +}); + +describe('printConfig', () => { + const sp19 = fixture({ + installDir: 'C:\\Program Files\\CODESYS 3.5.19.40', + exePath: 'C:\\Program Files\\CODESYS 3.5.19.40\\CODESYS\\Common\\CODESYS.exe', + version: '3.5.19.40', + sp: 19, + patch: 4, + profileName: 'CODESYS V3.5 SP19 Patch 4', + serverName: 'codesys-sp19-patch4', + }); + const sp21 = fixture({ + installDir: 'C:\\Program Files\\CODESYS 3.5.21.50', + exePath: 'C:\\Program Files\\CODESYS 3.5.21.50\\CODESYS\\Common\\CODESYS.exe', + version: '3.5.21.50', + sp: 21, + patch: 5, + profileName: 'CODESYS V3.5 SP21 Patch 5', + serverName: 'codesys-sp21-patch5', + }); + const sp22 = fixture(); + + it('emits one entry named "codesys" when there is exactly one install', () => { + const out = printConfig([sp22], { date: '2026-04-27' }); + expect(out).toContain('Detected 1 CODESYS installation'); + expect(out).not.toContain('CAVEAT'); + expect(out).toContain('"codesys": {'); + expect(out).toContain('"--codesys-profile", "CODESYS V3.5 SP22 Patch 1"'); + }); + + it('emits one block per install with derived names when multiple', () => { + const out = printConfig([sp19, sp21, sp22], { date: '2026-04-27' }); + expect(out).toContain('Detected 3 CODESYS installations'); + expect(out).toContain('CAVEAT'); + expect(out).toContain('"codesys-sp19-patch4": {'); + expect(out).toContain('"codesys-sp21-patch5": {'); + expect(out).toContain('"codesys-sp22-patch1": {'); + }); + + it('--sp filters to a single SP family and collapses the name to "codesys" when one match', () => { + const out = printConfig([sp19, sp21, sp22], { sp: 21, date: '2026-04-27' }); + expect(out).toContain('"codesys": {'); + expect(out).toContain('"--codesys-profile", "CODESYS V3.5 SP21 Patch 5"'); + expect(out).not.toContain('SP22'); + expect(out).not.toContain('SP19'); + }); + + it('--sp keeps descriptive names when multiple patches match', () => { + const sp21patch3 = fixture({ + installDir: 'C:\\Program Files\\CODESYS 3.5.21.30', + exePath: 'C:\\Program Files\\CODESYS 3.5.21.30\\CODESYS\\Common\\CODESYS.exe', + version: '3.5.21.30', + sp: 21, + patch: 3, + profileName: 'CODESYS V3.5 SP21 Patch 3', + serverName: 'codesys-sp21-patch3', + }); + const out = printConfig([sp19, sp21, sp21patch3, sp22], { sp: 21, date: '2026-04-27' }); + expect(out).toContain('"codesys-sp21-patch3": {'); + expect(out).toContain('"codesys-sp21-patch5": {'); + }); + + it('throws a clear error when --sp matches no install', () => { + expect(() => printConfig([sp22], { sp: 21 })).toThrow(/SP21/); + }); + + it('throws when no installs detected at all', () => { + expect(() => printConfig([])).toThrow(/No CODESYS installations detected/); + }); + + it('--name overrides the entry name when paired with --sp narrowing to one', () => { + const out = printConfig([sp19, sp22], { sp: 22, name: 'production', date: '2026-04-27' }); + expect(out).toContain('"production": {'); + expect(out).not.toContain('"codesys-sp22-patch1": {'); + }); + + it('--name without single-install narrowing throws', () => { + expect(() => printConfig([sp19, sp22], { name: 'whatever' })).toThrow(/--name only works/); + }); + + it('produces parseable JSON when comments are stripped', () => { + const out = printConfig([sp19, sp22], { date: '2026-04-27' }); + const stripped = out + .split('\n') + .filter((l) => !l.trim().startsWith('//')) + .join('\n'); + const parsed = JSON.parse(stripped); + expect(parsed.mcpServers['codesys-sp19-patch4'].args).toContain('--codesys-path'); + expect(parsed.mcpServers['codesys-sp22-patch1'].args).toContain('--codesys-profile'); + }); +});