0
0
Fork 0

Merge pull request #1 from phobicdotno/feature/phobics-tui

phobiCS-tui v0.1/v0.2: in-fork TUI for CODESYS exported ST + --approve-edits gate
This commit is contained in:
phobicdotno 2026-04-29 08:21:34 +02:00 committed by GitHub
commit 065bce5b3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2247 additions and 6 deletions

View file

@ -289,6 +289,22 @@ codesys-mcp-sp21-plus --ssh-version myplc.lan --ssh-user pi
Requires SSH key auth + passwordless sudo for `/usr/bin/strings` on the PLC. If your key isn't installed yet, the error message includes a one-line PowerShell recipe; full setup instructions live at [ssh-key-windows.md](https://gitlab.usv.no/karstein.kvistad/mr-ai-context/-/blob/main/ssh-key-windows.md).
## phobiCS-tui
This package ships a small ink TUI for browsing CODESYS-exported ST. After installing, run:
phobiCS-tui # auto-discovers mcp-mirror/ from cwd
phobiCS-tui <projectDir> # explicit project directory
phobiCS-tui approve <a.st> <b.st> # diff prompt; exit 0 = accept, 1 = reject, 2 = error
Browser-mode keys: `j`/`k` (or `↓`/`↑`) move the cursor, `l`/`Enter`/`→` expand a device, `h`/`←` collapse, `q` quits.
Approve-mode keys: `y` accept, `n`/`q`/`Esc` reject.
The browser writes the current selection to `%LOCALAPPDATA%/codesys-mcp/tui-state.json` (Windows) or `$XDG_STATE_HOME/codesys-mcp/tui-state.json` (Linux/Mac, defaulting to `~/.local/state/...`). The MCP tool `get_user_selection` reads it so an agent can ground its actions in what the user is looking at.
Approve mode is opt-in for the MCP server's modifying tools — start the server with `--approve-edits` to wire it in. v0.1 gates only `set_pou_code`; the rest of the modifying tools land in a follow-up. Off by default.
## MCP Tools
41 tools across the categories below. Tools marked **NEW** were added in this fork; tools marked **FIXED** existed upstream but were broken before this fork.

672
package-lock.json generated
View file

@ -1,16 +1,18 @@
{
"name": "codesys-mcp-sp21-plus",
"version": "0.4.9",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codesys-mcp-sp21-plus",
"version": "0.4.9",
"version": "0.5.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"commander": "^11.1.0",
"ink": "^5.2.1",
"react": "^18.3.1",
"ssh2": "^1.17.0",
"uuid": "^9.0.0",
"zod": "^3.24.3"
@ -19,9 +21,13 @@
"codesys-mcp-sp21-plus": "dist/bin.js"
},
"devDependencies": {
"@types/diff": "^5.2.3",
"@types/node": "^20.14.1",
"@types/react": "^18.3.28",
"@types/ssh2": "^1.15.5",
"@types/uuid": "^9.0.0",
"diff": "^5.2.2",
"ink-testing-library": "^4.0.0",
"tsx": "^4.21.0",
"typescript": "^5.5.3",
"vitest": "^1.6.0"
@ -30,6 +36,31 @@
"node": ">=18"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^4.0.0"
},
"engines": {
"node": ">=14.13.1"
}
},
"node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@ -958,6 +989,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/diff": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz",
"integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -975,6 +1013,24 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
@ -1155,6 +1211,33 @@
}
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
"integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
@ -1187,6 +1270,18 @@
"node": "*"
}
},
"node_modules/auto-bind": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
"integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@ -1296,6 +1391,18 @@
"node": ">=4"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/check-error": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
@ -1309,6 +1416,89 @@
"node": "*"
}
},
"node_modules/cli-boxes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
"integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
"integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
"license": "MIT",
"dependencies": {
"restore-cursor": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
"integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==",
"license": "MIT",
"dependencies": {
"slice-ansi": "^5.0.0",
"string-width": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cli-truncate/node_modules/slice-ansi": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
"integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.0.0",
"is-fullwidth-code-point": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/code-excerpt": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
"integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
"license": "MIT",
"dependencies": {
"convert-to-spaces": "^2.0.1"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
@ -1347,6 +1537,15 @@
"node": ">= 0.6"
}
},
"node_modules/convert-to-spaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
"integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@ -1410,6 +1609,13 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1449,6 +1655,16 @@
"node": ">= 0.8"
}
},
"node_modules/diff": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
@ -1479,6 +1695,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@ -1488,6 +1710,18 @@
"node": ">= 0.8"
}
},
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -1518,6 +1752,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.0.tgz",
"integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@ -1563,6 +1807,15 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@ -1773,6 +2026,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-east-asian-width": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz",
"integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-func-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@ -1937,12 +2202,108 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/indent-string": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ink": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.1.3",
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.1",
"auto-bind": "^5.0.1",
"chalk": "^5.3.0",
"cli-boxes": "^3.0.0",
"cli-cursor": "^4.0.0",
"cli-truncate": "^4.0.0",
"code-excerpt": "^4.0.0",
"es-toolkit": "^1.22.0",
"indent-string": "^5.0.0",
"is-in-ci": "^1.0.0",
"patch-console": "^2.0.0",
"react-reconciler": "^0.29.0",
"scheduler": "^0.23.0",
"signal-exit": "^3.0.7",
"slice-ansi": "^7.1.0",
"stack-utils": "^2.0.6",
"string-width": "^7.2.0",
"type-fest": "^4.27.0",
"widest-line": "^5.0.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yoga-layout": "~3.2.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"react": ">=18.0.0",
"react-devtools-core": "^4.19.1"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react-devtools-core": {
"optional": true
}
}
},
"node_modules/ink-testing-library": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz",
"integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/react": ">=18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/ink/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ink/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@ -1961,6 +2322,33 @@
"node": ">= 0.10"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-in-ci": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz",
"integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==",
"license": "MIT",
"bin": {
"is-in-ci": "cli.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@ -2031,6 +2419,24 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loose-envify/node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loupe": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
@ -2299,6 +2705,15 @@
"node": ">= 0.8"
}
},
"node_modules/patch-console": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
"integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -2466,6 +2881,18 @@
"node": ">= 0.10"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -2473,6 +2900,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-reconciler": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -2492,6 +2935,52 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restore-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
"integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
"license": "MIT",
"dependencies": {
"onetime": "^5.1.0",
"signal-exit": "^3.0.2"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/restore-cursor/node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/restore-cursor/node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"license": "MIT",
"dependencies": {
"mimic-fn": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/restore-cursor/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/rollup": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
@ -2559,6 +3048,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@ -2723,6 +3221,49 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/slice-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2750,6 +3291,18 @@
"nan": "^2.23.0"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
"integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -2773,6 +3326,38 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
@ -3304,6 +3889,18 @@
"node": ">=4"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@ -3558,12 +4155,77 @@
"node": ">=8"
}
},
"node_modules/widest-line": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
"integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==",
"license": "MIT",
"dependencies": {
"string-width": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
@ -3577,6 +4239,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View file

@ -4,7 +4,8 @@
"description": "Codesys-MCP-SP21+ -- fork of luke-harriman/Codesys-MCP carrying CODESYS V3.5 SP22 Patch 1 fixes (and forward-compat with later SPs): script-engine API drift, online/runtime tool auto-login, dual-SHA release classifier, set_pou_code omitted-decl wipe fix, add_library managed-overload, etc. MCP server for CODESYS with persistent UI instance and file-based IPC.",
"main": "dist/server.js",
"bin": {
"codesys-mcp-sp21-plus": "dist/bin.js"
"codesys-mcp-sp21-plus": "dist/bin.js",
"phobiCS-tui": "dist/tui/index.js"
},
"files": [
"dist/",
@ -12,10 +13,11 @@
"LICENSE"
],
"scripts": {
"build": "tsc && node -e \"require('fs').cpSync('src/scripts','dist/scripts',{recursive:true})\"",
"build": "tsc && tsc -p tsconfig.tui.json && node -e \"require('fs').cpSync('src/scripts','dist/scripts',{recursive:true})\" && node -e \"const fs=require('fs');fs.writeFileSync('dist/tui/package.json','{\\\"type\\\":\\\"module\\\"}');const p='dist/tui/index.js';fs.writeFileSync(p,'#!/usr/bin/env node\\n'+fs.readFileSync(p,'utf8'));\"",
"build:tui": "tsc -p tsconfig.tui.json",
"test": "vitest --run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json --noEmit",
"prepublishOnly": "npm run build && npm test"
},
"keywords": [
@ -39,14 +41,20 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"commander": "^11.1.0",
"ink": "^5.2.1",
"react": "^18.3.1",
"ssh2": "^1.17.0",
"uuid": "^9.0.0",
"zod": "^3.24.3"
},
"devDependencies": {
"@types/diff": "^5.2.3",
"@types/node": "^20.14.1",
"@types/react": "^18.3.28",
"@types/ssh2": "^1.15.5",
"@types/uuid": "^9.0.0",
"diff": "^5.2.2",
"ink-testing-library": "^4.0.0",
"tsx": "^4.21.0",
"typescript": "^5.5.3",
"vitest": "^1.6.0"

117
src/approve-gate.ts Normal file
View file

@ -0,0 +1,117 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { spawn } from 'child_process';
export const IMPL_SENTINEL = '(* === IMPLEMENTATION === *)';
export interface SetPouCodeArgs {
declarationCode?: string;
implementationCode?: string;
}
export type GateResult =
| { status: 'accepted' }
| { status: 'rejected'; message: string }
| { status: 'no-existing' }
| { status: 'error'; message: string };
function splitOnSentinel(text: string): { decl: string; impl: string } {
const idx = text.indexOf(IMPL_SENTINEL);
if (idx < 0) {
return { decl: text, impl: '' };
}
const decl = text.slice(0, idx).replace(/\s+$/, '');
const after = text.slice(idx + IMPL_SENTINEL.length);
const impl = after.replace(/^\r?\n/, '');
return { decl, impl };
}
export function composeMergedContent(existing: string, args: SetPouCodeArgs): string {
const { decl, impl } = splitOnSentinel(existing);
const newDecl = args.declarationCode ?? decl;
const newImpl = args.implementationCode ?? impl;
return [newDecl, IMPL_SENTINEL, newImpl].join('\n');
}
async function findMirrorFile(
projectFilePath: string,
pouPath: string
): Promise<string | null> {
const projectDir = path.dirname(path.resolve(projectFilePath));
const mirrorRoot = path.join(projectDir, 'mcp-mirror');
try {
await fs.access(mirrorRoot);
} catch {
return null;
}
const leaf = pouPath.split(/[./]/).pop()!;
const matches: string[] = [];
await collectMatches(mirrorRoot, `${leaf}.st`, matches);
return matches.length === 1 ? matches[0] : null;
}
async function collectMatches(dir: string, leafName: string, out: string[]): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await collectMatches(full, leafName, out);
} else if (e.isFile() && e.name === leafName) {
out.push(full);
}
}
}
async function spawnApproveTui(existingPath: string, stagedPath: string): Promise<GateResult> {
const tuiBin = path.join(__dirname, 'tui', 'index.js');
return new Promise<GateResult>((resolve) => {
const child = spawn(process.execPath, [tuiBin, 'approve', existingPath, stagedPath], {
stdio: ['inherit', 'inherit', 'inherit'],
});
child.on('exit', (code) => {
if (code === 0) resolve({ status: 'accepted' });
else if (code === 1)
resolve({ status: 'rejected', message: 'User rejected the change in phobiCS-tui.' });
else
resolve({
status: 'error',
message: `phobiCS-tui exited with code ${code}.`,
});
});
});
}
export interface RunGateOpts {
projectFilePath: string;
pouPath: string;
args: SetPouCodeArgs;
spawnFn?: typeof spawnApproveTui;
}
export async function runApproveGate(opts: RunGateOpts): Promise<GateResult> {
const existingPath = await findMirrorFile(opts.projectFilePath, opts.pouPath);
if (!existingPath) {
return { status: 'no-existing' };
}
let existing: string;
try {
existing = await fs.readFile(existingPath, 'utf8');
} catch (err) {
return { status: 'error', message: `read failed: ${(err as Error).message}` };
}
const proposed = composeMergedContent(existing, opts.args);
const stagedPath = `${existingPath}.staged`;
await fs.writeFile(stagedPath, proposed, 'utf8');
try {
const spawnFn = opts.spawnFn ?? spawnApproveTui;
return await spawnFn(existingPath, stagedPath);
} finally {
await fs.rm(stagedPath, { force: true });
}
}

View file

@ -47,6 +47,7 @@ program
.option('--fallback-headless', 'Fall back to headless if persistent fails', true)
.option('--keep-alive', 'Keep CODESYS running after server stops', false)
.option('--auto-mirror', 'Re-run mirror_export after every modifying tool so an external editor watching <projectDir>/mcp-mirror/ sees changes live', false)
.option('--approve-edits', 'Gate modifying MCP tools behind a phobiCS-tui y/n diff prompt', false)
.option('--timeout <ms>', 'Default command timeout in ms', '60000')
.option('--verbose', 'Enable verbose logging')
.option('--debug', 'Enable debug logging (more verbose)')
@ -197,6 +198,7 @@ if (opts.sshVersion) {
debug: opts.debug || false,
mode: (opts.mode === 'headless' ? 'headless' : 'persistent') as ExecutionMode,
autoMirror: opts.autoMirror || false,
approveEdits: opts.approveEdits || false,
};
process.stderr.write(`Starting CODESYS MCP Server v${version}\n`);
@ -207,6 +209,9 @@ if (opts.sshVersion) {
if (config.autoMirror) {
process.stderr.write(` Auto-mirror: ENABLED (mirror_export runs after every edit)\n`);
}
if (config.approveEdits) {
process.stderr.write(` Approve edits: ENABLED (modifying tools will prompt via phobiCS-tui)\n`);
}
startMcpServer(config).catch((err) => {
process.stderr.write(`FATAL: ${err.message}\n`);

View file

@ -25,6 +25,8 @@ import { resolveMirrorRoot } from './mirror-paths';
import { inspectProjectFile } from './inspect';
import { parseProfileName } from './detect';
import { decideOpenProjectPreflight } from './preflight';
import { readSelection } from './state-read';
import { runApproveGate } from './approve-gate';
/**
* Classifier for `bump_project_version --level=auto`.
@ -673,6 +675,51 @@ async function fileExists(filePath: string): Promise<boolean> {
}
}
export async function buildGetUserSelectionResponse(stateFilePath: string) {
const r = await readSelection(stateFilePath);
if (r.status === 'ok') {
const lines = [
`User is currently looking at:`,
` Device: ${r.payload.device}`,
` POU: ${r.payload.selection.name} (${r.payload.selection.kind})`,
` Path: ${r.payload.selection.path}`,
` AbsPath: ${r.payload.selection.abs_path}`,
` Project: ${r.payload.project_dir}`,
` Viewer line: ${r.payload.viewer_line}`,
` Updated: ${r.payload.updated_at}`,
];
return { content: [{ type: 'text' as const, text: lines.join('\n') }], isError: false };
}
if (r.status === 'invalid') {
return {
content: [
{
type: 'text' as const,
text: `Invalid TUI state file: ${r.reason}. No active selection.`,
},
],
isError: false,
};
}
return {
content: [{ type: 'text' as const, text: 'No active selection (TUI not running or stale).' }],
isError: false,
};
}
function defaultStateFilePath(): string {
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) {
return path.join(os.homedir(), 'AppData', 'Local', 'codesys-mcp', 'tui-state.json');
}
return path.join(localAppData, 'codesys-mcp', 'tui-state.json');
}
const xdg = process.env.XDG_STATE_HOME;
const base = xdg ?? path.join(os.homedir(), '.local', 'state');
return path.join(base, 'codesys-mcp', 'tui-state.json');
}
export async function startMcpServer(config: ServerConfig): Promise<void> {
// Set log level
if (config.debug) setLogLevel('debug');
@ -680,6 +727,7 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
serverLog.info(`Starting CODESYS Persistent MCP Server v0.1.0`);
serverLog.info(`Mode: ${config.mode}`);
serverLog.info(`Approve edits: ${config.approveEdits ? 'ON' : 'off'}`);
serverLog.info(`CODESYS Path: ${config.codesysPath}`);
serverLog.info(`Profile: ${config.profileName}`);
serverLog.info(`Workspace: ${config.workspaceDir}`);
@ -845,6 +893,12 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
}
);
s.tool(
'get_user_selection',
'Get the POU the user is currently looking at in the phobiCS-tui browser, if any. Returns a freshness-checked snapshot from the TUI state file. Useful for grounding modifying tool calls in what the user has selected.',
async () => buildGetUserSelectionResponse(defaultStateFilePath())
);
// ─── Project Tools ───────────────────────────────────────────────────
s.tool(
@ -1053,6 +1107,29 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
},
['ensure_project_open', 'find_object_by_path']
);
if (config.approveEdits) {
const gate = await runApproveGate({
projectFilePath: escProjPath,
pouPath: sanPouPath,
args: {
declarationCode: args.declarationCode,
implementationCode: args.implementationCode,
},
});
if (gate.status === 'rejected') {
return {
content: [{ type: 'text' as const, text: gate.message }],
isError: false,
};
}
if (gate.status === 'error') {
return {
content: [{ type: 'text' as const, text: `Approve gate error: ${gate.message}` }],
isError: true,
};
}
// 'accepted' or 'no-existing' → fall through and apply the change.
}
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,

46
src/state-read.ts Normal file
View file

@ -0,0 +1,46 @@
import * as fs from 'fs';
export const FRESHNESS_MS = 60_000;
export interface SelectionPayload {
version: 1;
updated_at: string;
project_dir: string;
device: string;
selection: {
kind: string;
name: string;
path: string;
abs_path: string;
};
viewer_line: number;
}
export type ReadResult =
| { status: 'ok'; payload: SelectionPayload }
| { status: 'missing' }
| { status: 'stale' }
| { status: 'invalid'; reason: string };
export async function readSelection(filePath: string): Promise<ReadResult> {
let text: string;
try {
text = await fs.promises.readFile(filePath, 'utf8');
} catch {
return { status: 'missing' };
}
let parsed: SelectionPayload;
try {
parsed = JSON.parse(text);
} catch (err) {
return { status: 'invalid', reason: (err as Error).message };
}
if (parsed.version !== 1) {
return { status: 'invalid', reason: `unsupported version ${parsed.version}` };
}
const ageMs = Date.now() - new Date(parsed.updated_at).getTime();
if (Number.isNaN(ageMs) || ageMs > FRESHNESS_MS) {
return { status: 'stale' };
}
return { status: 'ok', payload: parsed };
}

View file

@ -0,0 +1,51 @@
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { computeHunks } from '../shared/diff.js';
import { Hunk } from '../shared/types.js';
export type Decision = 'accept' | 'reject';
export interface ApproveProps {
fileName: string;
oldText: string;
newText: string;
onDecision: (d: Decision) => void;
}
export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps): React.ReactElement {
const hunks = React.useMemo(() => computeHunks(oldText, newText), [oldText, newText]);
const adds = hunks.filter((h) => h.kind === 'add').length;
const dels = hunks.filter((h) => h.kind === 'del').length;
useInput((input, key) => {
if (input === 'y') return onDecision('accept');
if (input === 'n' || input === 'q' || key.escape) return onDecision('reject');
});
return (
<Box flexDirection="column">
<Text>
Approve change? {fileName} + {adds} lines, {dels} lines
</Text>
<Box flexDirection="column">
{hunks.map((h, i) => (
<HunkLine key={i} hunk={h} />
))}
</Box>
<Text>
y accept n reject q reject & quit ESC reject
</Text>
</Box>
);
}
function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement {
const sigil = hunk.kind === 'add' ? '+' : hunk.kind === 'del' ? '-' : ' ';
const color = hunk.kind === 'add' ? 'green' : hunk.kind === 'del' ? 'red' : undefined;
const lineNoStr = String(hunk.lineNo).padStart(4, ' ');
return (
<Text color={color}>
{sigil} {lineNoStr} {hunk.text}
</Text>
);
}

103
src/tui/browser/Browser.tsx Normal file
View file

@ -0,0 +1,103 @@
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { Project, POU, Selection } from '../shared/types.js';
import { Tree, devicePath, pouPath } from './Tree.js';
import { Viewer } from './Viewer.js';
export interface BrowserProps {
project: Project;
readPou: (pou: POU) => Promise<string>;
writeSelection: (s: Selection) => void;
onQuit: () => void;
}
interface FlatRow {
path: string;
kind: 'device' | 'pou';
device: string;
pou?: POU;
}
function flatten(project: Project, expanded: Set<string>): FlatRow[] {
const rows: FlatRow[] = [];
for (const dev of project.devices) {
rows.push({ path: devicePath(dev.name), kind: 'device', device: dev.name });
if (!expanded.has(devicePath(dev.name))) continue;
for (const p of dev.pous) {
rows.push({ path: pouPath(dev.name, p.relPath), kind: 'pou', device: dev.name, pou: p });
}
}
return rows;
}
export function Browser({ project, readPou, writeSelection, onQuit }: BrowserProps): React.ReactElement {
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
const [cursorIdx, setCursorIdx] = React.useState(0);
const [text, setText] = React.useState<string | null>(null);
const [scrollTop] = React.useState(0);
const rows = React.useMemo(() => flatten(project, expanded), [project, expanded]);
const cursor = rows[Math.min(cursorIdx, rows.length - 1)];
React.useEffect(() => {
if (!cursor || cursor.kind !== 'pou' || !cursor.pou) return;
const handle = setTimeout(() => {
writeSelection({ device: cursor.device, pou: cursor.pou!, viewerLine: scrollTop + 1 });
}, 200);
return () => clearTimeout(handle);
}, [cursor, scrollTop, writeSelection]);
React.useEffect(() => {
if (!cursor || cursor.kind !== 'pou' || !cursor.pou) {
setText(null);
return;
}
let cancelled = false;
readPou(cursor.pou).then((t) => {
if (!cancelled) setText(t);
});
return () => {
cancelled = true;
};
}, [cursor, readPou]);
useInput((input, key) => {
if (input === 'q') return onQuit();
if (input === 'j' || key.downArrow) {
setCursorIdx((i) => Math.min(i + 1, rows.length - 1));
} else if (input === 'k' || key.upArrow) {
setCursorIdx((i) => Math.max(i - 1, 0));
} else if (input === 'l' || key.return || key.rightArrow) {
if (cursor && cursor.kind === 'device') {
setExpanded((e) => {
const next = new Set(e);
next.add(cursor.path);
return next;
});
}
} else if (input === 'h' || key.leftArrow) {
if (cursor && cursor.kind === 'device') {
setExpanded((e) => {
const next = new Set(e);
next.delete(cursor.path);
return next;
});
}
}
});
return (
<Box flexDirection="column">
<Text> {project.rootDir.split(/[/\\]/).pop()} </Text>
<Box flexDirection="row">
<Box flexDirection="column" width="40%">
<Tree project={project} cursorPath={cursor?.path ?? ''} expanded={expanded} />
</Box>
<Box flexDirection="column" width="60%">
<Viewer pou={cursor?.pou ?? null} text={text} scrollTop={scrollTop} visibleRows={20} />
</Box>
</Box>
<Text>j/k nav l expand h collapse q quit</Text>
</Box>
);
}

44
src/tui/browser/Tree.tsx Normal file
View file

@ -0,0 +1,44 @@
import React from 'react';
import { Box, Text } from 'ink';
import { Project, POU } from '../shared/types.js';
export interface TreeProps {
project: Project;
cursorPath: string;
expanded: Set<string>;
}
export function devicePath(deviceName: string): string {
return `device:${deviceName}`;
}
export function pouPath(deviceName: string, relPath: string): string {
return `pou:${deviceName}:${relPath}`;
}
export function Tree({ project, cursorPath, expanded }: TreeProps): React.ReactElement {
const rows: React.ReactElement[] = [];
for (const dev of project.devices) {
const dPath = devicePath(dev.name);
const isExpanded = expanded.has(dPath);
const isCursor = cursorPath === dPath;
rows.push(
<Text key={dPath}>
{isCursor ? '▶ ' : ' '}
{isExpanded ? '▾ ' : '▸ '}
{dev.name} {dev.pous.length} POUs
</Text>
);
if (!isExpanded) continue;
for (const p of dev.pous) {
const pPath = pouPath(dev.name, p.relPath);
const isPCursor = cursorPath === pPath;
rows.push(
<Text key={pPath}>
{isPCursor ? '▶ ' : ' '}
{p.name.padEnd(18)} {p.kind.padEnd(6)} {String(p.loc).padStart(4)} L
</Text>
);
}
}
return <Box flexDirection="column">{rows}</Box>;
}

View file

@ -0,0 +1,34 @@
import React from 'react';
import { Box, Text } from 'ink';
import { POU } from '../shared/types.js';
export interface ViewerProps {
pou: POU | null;
text: string | null;
scrollTop: number;
visibleRows: number;
}
export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): React.ReactElement {
if (!pou || text == null) {
return (
<Box flexDirection="column">
<Text dimColor>(no POU selected)</Text>
</Box>
);
}
const lines = text.split(/\r?\n/);
const slice = lines.slice(scrollTop, scrollTop + visibleRows);
return (
<Box flexDirection="column">
<Text bold>
{pou.name}.st ({pou.kind}, {pou.loc} L)
</Text>
{slice.map((l, i) => (
<Text key={scrollTop + i}>
{String(scrollTop + i + 1).padStart(4, ' ')} {l}
</Text>
))}
</Box>
);
}

104
src/tui/index.tsx Normal file
View file

@ -0,0 +1,104 @@
import React from 'react';
import { render } from 'ink';
import * as fs from 'fs/promises';
import { Approve, Decision } from './approve/Approve.js';
import { Browser } from './browser/Browser.js';
import { walk } from './shared/scan.js';
import { findProjectRoot } from './shared/discover.js';
import { writeSelection } from './shared/state-write.js';
import { stateFilePath } from './shared/state-paths.js';
import { Selection } from './shared/types.js';
const argv = process.argv.slice(2);
async function main(): Promise<number> {
if (argv[0] === '--version' || argv[0] === '-v') {
process.stdout.write('phobiCS-tui v0.1.0\n');
return 0;
}
if (argv[0] === 'approve') return runApprove(argv[1], argv[2]);
return runBrowser(argv[0]);
}
async function runBrowser(maybeRoot: string | undefined): Promise<number> {
const root = maybeRoot
? maybeRoot
: (await findProjectRoot(process.cwd())) ?? null;
if (!root) {
process.stderr.write(
`No mcp-mirror/ found near ${process.cwd()}. Run mirror_export in CODESYS first.\n`
);
return 1;
}
let project;
try {
project = await walk(root);
} catch (err) {
process.stderr.write(`phobiCS-tui: ${(err as Error).message}\n`);
return 1;
}
const stateFile = stateFilePath();
return new Promise<number>((resolve) => {
const onWriteSelection = (s: Selection) => {
writeSelection(stateFile, project!.rootDir, s).catch((err) => {
process.stderr.write(`phobiCS-tui: state write failed: ${err}\n`);
});
};
const onQuit = () => {
app.unmount();
resolve(0);
};
const readPou = (pou: { absPath: string }) => fs.readFile(pou.absPath, 'utf8');
const app = render(
<Browser
project={project!}
readPou={readPou}
writeSelection={onWriteSelection}
onQuit={onQuit}
/>
);
});
}
async function runApprove(oldPath: string | undefined, newPath: string | undefined): Promise<number> {
if (!oldPath || !newPath) {
process.stderr.write('usage: phobiCS-tui approve <existing> <proposed>\n');
return 2;
}
let oldText: string;
let newText: string;
try {
oldText = await fs.readFile(oldPath, 'utf8');
newText = await fs.readFile(newPath, 'utf8');
} catch (err) {
process.stderr.write(`phobiCS-tui: ${(err as Error).message}\n`);
return 2;
}
return new Promise<number>((resolve) => {
const onDecision = (d: Decision) => {
app.unmount();
resolve(d === 'accept' ? 0 : 1);
};
const fileName = oldPath.split(/[/\\]/).pop() ?? oldPath;
const app = render(
<Approve fileName={fileName} oldText={oldText} newText={newText} onDecision={onDecision} />
);
process.on('SIGTERM', () => {
app.unmount();
resolve(1);
});
process.on('SIGINT', () => {
app.unmount();
resolve(1);
});
});
}
main()
.then((code) => process.exit(code))
.catch((err) => {
process.stderr.write(`phobiCS-tui: ${err}\n`);
process.exit(2);
});

3
src/tui/package.json Normal file
View file

@ -0,0 +1,3 @@
{
"type": "module"
}

39
src/tui/shared/diff.ts Normal file
View file

@ -0,0 +1,39 @@
import { diffLines } from 'diff';
import { Hunk } from './types.js';
export function computeHunks(oldText: string, newText: string): Hunk[] {
// jsdiff treats trailing-newline presence as a token boundary, so a missing
// EOF newline shows up as a spurious del+add pair on the last line. Normalize.
const a = oldText.endsWith('\n') ? oldText : oldText + '\n';
const b = newText.endsWith('\n') ? newText : newText + '\n';
const parts = diffLines(a, b);
const out: Hunk[] = [];
let oldLine = 1;
let newLine = 1;
for (const part of parts) {
const lines = part.value.split('\n');
if (lines.length > 0 && lines[lines.length - 1] === '') {
lines.pop();
}
if (part.added) {
for (const text of lines) {
out.push({ kind: 'add', lineNo: newLine, text });
newLine++;
}
} else if (part.removed) {
for (const text of lines) {
out.push({ kind: 'del', lineNo: oldLine, text });
oldLine++;
}
} else {
for (const text of lines) {
out.push({ kind: 'ctx', lineNo: newLine, text });
oldLine++;
newLine++;
}
}
}
return out;
}

View file

@ -0,0 +1,17 @@
import * as fs from 'fs/promises';
import * as path from 'path';
export async function findProjectRoot(startDir: string): Promise<string | null> {
let cur = path.resolve(startDir);
while (true) {
try {
const stat = await fs.stat(path.join(cur, 'mcp-mirror'));
if (stat.isDirectory()) return cur;
} catch {
// not here, walk up
}
const parent = path.dirname(cur);
if (parent === cur) return null;
cur = parent;
}
}

107
src/tui/shared/scan.ts Normal file
View file

@ -0,0 +1,107 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { POU, POUKind, Device, Project } from './types.js';
const MIRROR_DIR = 'mcp-mirror';
export async function walk(rootDir: string): Promise<Project> {
const mirrorDir = path.join(rootDir, MIRROR_DIR);
let mirrorStat;
try {
mirrorStat = await fs.stat(mirrorDir);
} catch {
throw new Error(
`No mcp-mirror/ found at ${rootDir}. Run mirror_export in CODESYS first.`
);
}
const deviceNames = (await fs.readdir(mirrorDir, { withFileTypes: true }))
.filter((d) => d.isDirectory())
.map((d) => d.name);
const devices: Device[] = [];
for (const name of deviceNames) {
const pous = await collectPous(path.join(mirrorDir, name));
devices.push({ name, pous });
}
return {
rootDir,
mirrorMtimeMs: mirrorStat.mtimeMs,
devices,
};
}
async function collectPous(deviceRoot: string): Promise<POU[]> {
const stFiles = await listStFilesRecursive(deviceRoot);
const stPathSet = new Set(stFiles.map((f) => f.toLowerCase()));
const out: POU[] = [];
for (const abs of stFiles) {
const rel = path
.relative(deviceRoot, abs)
.split(path.sep)
.join('/');
const stat = await fs.stat(abs);
const text = await fs.readFile(abs, 'utf8');
const loc = text.split(/\r?\n/).filter((l) => l.trim().length > 0).length;
const name = path.basename(abs, '.st');
const kind = classify(abs, name, stPathSet);
out.push({
name,
kind,
relPath: rel,
absPath: abs,
loc,
mtimeMs: stat.mtimeMs,
});
}
return out;
}
async function listStFilesRecursive(dir: string): Promise<string[]> {
const out: string[] = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
out.push(...(await listStFilesRecursive(full)));
} else if (e.isFile() && e.name.toLowerCase().endsWith('.st')) {
out.push(full);
}
}
return out;
}
function classify(absPath: string, name: string, stPathSet: Set<string>): POUKind {
if (name === 'PLC_PRG') return 'PRG';
if (name === '_MCP_PROJECT_VERSION') return 'META';
if (name === 'Get') return parentHasSiblingSt(absPath, stPathSet) ? 'PROPERTY_GETTER' : 'OTHER';
if (name === 'Set') return parentHasSiblingSt(absPath, stPathSet) ? 'PROPERTY_SETTER' : 'OTHER';
// Property declarator: same-named child dir contains Get.st/Set.st
// (e.g. FB_Sweep/PropX.st alongside FB_Sweep/PropX/Get.st).
if (hasGetOrSetChild(absPath, name, stPathSet)) return 'OTHER';
if (/^FB_/.test(name)) return 'FB';
if (/^GVL_/.test(name)) return 'GVL';
if (/^ST_/.test(name)) return 'STRUCT';
if (/^e[A-Z]/.test(name)) return 'ENUM';
if (parentHasSiblingSt(absPath, stPathSet)) return 'METHOD';
return 'OTHER';
}
function hasGetOrSetChild(absPath: string, name: string, stPathSet: Set<string>): boolean {
const parentDir = path.dirname(absPath);
const childDir = path.join(parentDir, name);
const get = path.join(childDir, 'Get.st').toLowerCase();
const set = path.join(childDir, 'Set.st').toLowerCase();
return stPathSet.has(get) || stPathSet.has(set);
}
function parentHasSiblingSt(absPath: string, stPathSet: Set<string>): boolean {
const parentDir = path.dirname(absPath);
const grandparentDir = path.dirname(parentDir);
const parentName = path.basename(parentDir);
const sibling = path.join(grandparentDir, `${parentName}.st`).toLowerCase();
return stPathSet.has(sibling);
}

View file

@ -0,0 +1,19 @@
import * as os from 'os';
import * as path from 'path';
const APP_DIR = 'codesys-mcp';
const FILE_NAME = 'tui-state.json';
export function stateFilePath(): string {
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) {
throw new Error('LOCALAPPDATA is not set; cannot resolve TUI state file path');
}
return path.win32.join(localAppData, APP_DIR, FILE_NAME);
}
const xdg = process.env.XDG_STATE_HOME;
const home = process.env.HOME ?? os.homedir();
const base = xdg ?? path.posix.join(home, '.local', 'state');
return path.posix.join(base, APP_DIR, FILE_NAME);
}

View file

@ -0,0 +1,29 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { Selection } from './types.js';
export async function writeSelection(
filePath: string,
projectDir: string,
selection: Selection
): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const payload = {
version: 1,
updated_at: new Date().toISOString(),
project_dir: projectDir,
device: selection.device,
selection: {
kind: selection.pou.kind,
name: selection.pou.name,
path: selection.pou.relPath,
abs_path: selection.pou.absPath,
},
viewer_line: selection.viewerLine,
};
const tmp = `${filePath}.${process.pid}.tmp`;
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
await fs.rename(tmp, filePath);
}

52
src/tui/shared/types.ts Normal file
View file

@ -0,0 +1,52 @@
export type POUKind =
| 'PRG'
| 'FB'
| 'GVL'
| 'STRUCT'
| 'ENUM'
| 'METHOD'
| 'PROPERTY_GETTER'
| 'PROPERTY_SETTER'
| 'META'
| 'OTHER';
export interface POU {
/** Display name without .st extension */
name: string;
kind: POUKind;
/** Path relative to the device root, with forward slashes. */
relPath: string;
absPath: string;
/** Non-blank line count */
loc: number;
mtimeMs: number;
}
export interface Device {
/** Top-level subdir under mcp-mirror/, e.g. "CodesysRpi" */
name: string;
pous: POU[];
}
export interface Project {
/** The project's parent directory (the dir that contains mcp-mirror/) */
rootDir: string;
/** Mirror dir mtime — used for the "stale" indicator */
mirrorMtimeMs: number;
devices: Device[];
}
export interface Selection {
device: string;
pou: POU;
viewerLine: number;
}
export type HunkKind = 'add' | 'del' | 'ctx';
export interface Hunk {
kind: HunkKind;
/** 1-based line number in the side this hunk belongs to (new-side for add/ctx, old-side for del). */
lineNo: number;
text: string;
}

View file

@ -67,6 +67,12 @@ export interface ServerConfig extends LauncherConfig {
* as a hard error -- the underlying edit already succeeded.
*/
autoMirror: boolean;
/**
* If true, modifying tools (set_pou_code et al.) will shell out to
* phobiCS-tui in approve mode and only proceed on exit 0. Off by default
* so existing scripted flows are not regressed.
*/
approveEdits?: boolean;
}
/** Script template parameters */

View file

@ -0,0 +1,74 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { Approve } from '../../src/tui/approve/Approve.tsx';
const OLD = 'PROGRAM PLC_PRG\nVAR\n counter : INT := 0;\nEND_VAR';
const NEW = 'PROGRAM PLC_PRG\nVAR\n counter : DINT := 0;\n overflow : BOOL;\nEND_VAR';
describe('<Approve>', () => {
it('renders both deletions and additions in a unified diff', () => {
const { lastFrame } = render(
<Approve fileName="PLC_PRG.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
);
const out = lastFrame()!;
expect(out).toContain('counter : INT := 0;');
expect(out).toContain('counter : DINT := 0;');
expect(out).toContain('overflow : BOOL;');
expect(out).toMatch(/Approve change\? PLC_PRG\.st/);
});
it('reports add/del totals in the header', () => {
const { lastFrame } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
);
const out = lastFrame()!;
expect(out).toMatch(/\+ 2 lines.* 1 lines/);
});
const flush = () => new Promise<void>((r) => setImmediate(r));
it('calls onDecision("accept") when y is pressed', async () => {
const decision = vi.fn();
const { stdin } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
);
await flush();
stdin.write('y');
await flush();
expect(decision).toHaveBeenCalledWith('accept');
});
it('calls onDecision("reject") when n is pressed', async () => {
const decision = vi.fn();
const { stdin } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
);
await flush();
stdin.write('n');
await flush();
expect(decision).toHaveBeenCalledWith('reject');
});
it('calls onDecision("reject") when q is pressed', async () => {
const decision = vi.fn();
const { stdin } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
);
await flush();
stdin.write('q');
await flush();
expect(decision).toHaveBeenCalledWith('reject');
});
it('calls onDecision("reject") on escape', async () => {
const decision = vi.fn();
const { stdin } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
);
await flush();
stdin.write(String.fromCharCode(27));
await flush();
expect(decision).toHaveBeenCalledWith('reject');
});
});

View file

@ -0,0 +1,82 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { Browser } from '../../src/tui/browser/Browser.tsx';
import { Project } from '../../src/tui/shared/types.ts';
const project: Project = {
rootDir: '/p',
mirrorMtimeMs: 0,
devices: [
{
name: 'D1',
pous: [
{ name: 'PLC_PRG', kind: 'PRG', relPath: 'PLC_PRG.st', absPath: '/abs/PLC_PRG.st', loc: 5, mtimeMs: 0 },
{ name: 'FB_X', kind: 'FB', relPath: 'FB_X.st', absPath: '/abs/FB_X.st', loc: 9, mtimeMs: 0 },
],
},
],
};
const flush = () => new Promise<void>((r) => setImmediate(r));
describe('<Browser>', () => {
it('shows device row, expands it on l, then moves cursor onto the first POU on j', async () => {
const onWriteSelection = vi.fn();
const readPou = async () => 'PROGRAM PLC_PRG\nEND_PROGRAM';
const { stdin, lastFrame } = render(
<Browser
project={project}
readPou={readPou}
writeSelection={onWriteSelection}
onQuit={() => {}}
/>
);
await flush();
expect(lastFrame()).toContain('D1');
stdin.write('l');
await flush();
expect(lastFrame()).toContain('PLC_PRG');
stdin.write('j');
await flush();
expect(lastFrame()).toMatch(/▶ PLC_PRG/);
});
it('calls writeSelection when a POU is highlighted', async () => {
const onWriteSelection = vi.fn();
const readPou = async () => 'PROGRAM X\nEND_PROGRAM';
const { stdin } = render(
<Browser
project={project}
readPou={readPou}
writeSelection={onWriteSelection}
onQuit={() => {}}
/>
);
await flush();
stdin.write('l');
await flush();
stdin.write('j');
await new Promise((r) => setTimeout(r, 250));
expect(onWriteSelection).toHaveBeenCalled();
const arg = onWriteSelection.mock.calls.at(-1)![0];
expect(arg.device).toBe('D1');
expect(arg.pou.name).toBe('PLC_PRG');
});
it('calls onQuit on q', async () => {
const onQuit = vi.fn();
const { stdin } = render(
<Browser
project={project}
readPou={async () => ''}
writeSelection={() => {}}
onQuit={onQuit}
/>
);
await flush();
stdin.write('q');
await flush();
expect(onQuit).toHaveBeenCalled();
});
});

58
tests/tui/Tree.test.tsx Normal file
View file

@ -0,0 +1,58 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render } from 'ink-testing-library';
import { Tree } from '../../src/tui/browser/Tree.tsx';
import { Project } from '../../src/tui/shared/types.ts';
const project: Project = {
rootDir: '/p',
mirrorMtimeMs: 0,
devices: [
{
name: 'CodesysRpi',
pous: [
{ name: 'PLC_PRG', kind: 'PRG', relPath: 'a/PLC_PRG.st', absPath: '/abs/a/PLC_PRG.st', loc: 5, mtimeMs: 0 },
{ name: 'FB_Test', kind: 'FB', relPath: 'a/FB_Test.st', absPath: '/abs/a/FB_Test.st', loc: 87, mtimeMs: 0 },
],
},
],
};
describe('<Tree>', () => {
it('renders devices and POU rows when expanded', () => {
const { lastFrame } = render(
<Tree
project={project}
cursorPath="device:CodesysRpi"
expanded={new Set(['device:CodesysRpi'])}
/>
);
const out = lastFrame()!;
expect(out).toContain('CodesysRpi');
expect(out).toContain('PLC_PRG');
expect(out).toContain('FB_Test');
expect(out).toContain('PRG');
expect(out).toContain('FB');
});
it('does not show POU rows when device is collapsed', () => {
const { lastFrame } = render(
<Tree project={project} cursorPath="device:CodesysRpi" expanded={new Set()} />
);
const out = lastFrame()!;
expect(out).toContain('CodesysRpi');
expect(out).not.toContain('PLC_PRG');
});
it('marks the cursor row', () => {
const { lastFrame } = render(
<Tree
project={project}
cursorPath="pou:CodesysRpi:a/FB_Test.st"
expanded={new Set(['device:CodesysRpi'])}
/>
);
const out = lastFrame()!;
expect(out).toMatch(/▶ FB_Test/);
});
});

47
tests/tui/diff.test.ts Normal file
View file

@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { computeHunks } from '../../src/tui/shared/diff.ts';
describe('computeHunks', () => {
it('returns ctx hunks when both sides are identical', () => {
const a = 'one\ntwo\nthree';
const hunks = computeHunks(a, a);
expect(hunks.every((h) => h.kind === 'ctx')).toBe(true);
expect(hunks).toHaveLength(3);
});
it('marks added lines', () => {
const a = 'one\ntwo';
const b = 'one\ntwo\nthree';
const hunks = computeHunks(a, b);
const added = hunks.filter((h) => h.kind === 'add');
expect(added).toHaveLength(1);
expect(added[0].text).toBe('three');
});
it('marks deleted lines', () => {
const a = 'one\ntwo\nthree';
const b = 'one\ntwo';
const hunks = computeHunks(a, b);
const deleted = hunks.filter((h) => h.kind === 'del');
expect(deleted).toHaveLength(1);
expect(deleted[0].text).toBe('three');
});
it('handles a substitution as del + add', () => {
const a = 'a\nb\nc';
const b = 'a\nB\nc';
const hunks = computeHunks(a, b);
expect(hunks.some((h) => h.kind === 'del' && h.text === 'b')).toBe(true);
expect(hunks.some((h) => h.kind === 'add' && h.text === 'B')).toBe(true);
});
it('reports add/ctx line numbers against the new side, del against the old side', () => {
const a = 'a\nb\nc';
const b = 'a\nx\nc';
const hunks = computeHunks(a, b);
const del = hunks.find((h) => h.kind === 'del')!;
const add = hunks.find((h) => h.kind === 'add')!;
expect(del.lineNo).toBe(2);
expect(add.lineNo).toBe(2);
});
});

View file

@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { findProjectRoot } from '../../src/tui/shared/discover.ts';
async function tmpDir(): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), 'phobics-tui-disc-'));
}
describe('findProjectRoot', () => {
it('returns the start dir when it directly contains mcp-mirror/', async () => {
const root = await tmpDir();
await fs.mkdir(path.join(root, 'mcp-mirror'));
expect(await findProjectRoot(root)).toBe(root);
});
it('walks upward to find mcp-mirror/', async () => {
const root = await tmpDir();
await fs.mkdir(path.join(root, 'mcp-mirror'));
const sub = path.join(root, 'a', 'b', 'c');
await fs.mkdir(sub, { recursive: true });
expect(await findProjectRoot(sub)).toBe(root);
});
it('returns null when no mcp-mirror/ is found anywhere upward', async () => {
const root = await tmpDir();
expect(await findProjectRoot(root)).toBeNull();
});
});

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,2 @@
(* fixture *)
LINE_TWO := 0;

View file

@ -0,0 +1,42 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { spawn } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
const BIN = path.resolve('dist/tui/index.js');
beforeAll(async () => {
await fs.access(BIN);
});
function run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [BIN, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => (stdout += d.toString()));
child.stderr.on('data', (d) => (stderr += d.toString()));
child.on('exit', (code) => resolve({ code: code ?? -1, stdout, stderr }));
});
}
describe('phobiCS-tui (integration, no-TTY paths only)', () => {
it('--version prints version and exits 0', async () => {
const r = await run(['--version']);
expect(r.code).toBe(0);
expect(r.stdout).toMatch(/phobiCS-tui v\d/);
});
it('approve with missing file exits 2', async () => {
const r = await run(['approve', '/nonexistent/old.st', '/nonexistent/new.st']);
expect(r.code).toBe(2);
});
it('approve with no args exits 2 with usage on stderr', async () => {
const r = await run(['approve']);
expect(r.code).toBe(2);
expect(r.stderr).toMatch(/usage:/);
});
});

51
tests/tui/scan.test.ts Normal file
View file

@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import * as path from 'path';
import * as url from 'url';
import { walk } from '../../src/tui/shared/scan.ts';
const fixtureRoot = path.join(
path.dirname(url.fileURLToPath(import.meta.url)),
'fixtures',
'mini-mirror'
);
describe('scan.walk', () => {
it('classifies every .st under the fixture mirror', async () => {
const project = await walk(fixtureRoot);
expect(project.devices).toHaveLength(1);
const dev = project.devices[0];
expect(dev.name).toBe('CodesysRpi');
const byName = Object.fromEntries(dev.pous.map((p) => [p.name, p]));
expect(byName['PLC_PRG'].kind).toBe('PRG');
expect(byName['FB_Test'].kind).toBe('FB');
expect(byName['DoSomething'].kind).toBe('METHOD');
expect(byName['GVL_Test'].kind).toBe('GVL');
expect(byName['ST_Sample'].kind).toBe('STRUCT');
expect(byName['eMode'].kind).toBe('ENUM');
expect(byName['_MCP_PROJECT_VERSION'].kind).toBe('META');
expect(byName['FB_Sweep'].kind).toBe('FB');
expect(byName['PropX'].kind).toBe('OTHER');
expect(byName['Get'].kind).toBe('PROPERTY_GETTER');
expect(byName['Set'].kind).toBe('PROPERTY_SETTER');
});
it('counts non-blank LOC', async () => {
const project = await walk(fixtureRoot);
const plcPrg = project.devices[0].pous.find((p) => p.name === 'PLC_PRG');
expect(plcPrg).toBeDefined();
expect(plcPrg!.loc).toBe(2);
});
it('sets project rootDir to the dir containing mcp-mirror/', async () => {
const project = await walk(fixtureRoot);
expect(project.rootDir).toBe(fixtureRoot);
});
it('throws a clear error if mcp-mirror/ is missing', async () => {
await expect(walk(path.join(fixtureRoot, 'nonexistent'))).rejects.toThrow(
/No mcp-mirror/
);
});
});

View file

@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as path from 'path';
import { stateFilePath } from '../../src/tui/shared/state-paths.ts';
const ORIG_PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform')!;
const ORIG_ENV = { ...process.env };
function setPlatform(p: NodeJS.Platform) {
Object.defineProperty(process, 'platform', { value: p, configurable: true });
}
beforeEach(() => {
process.env = { ...ORIG_ENV };
});
afterEach(() => {
Object.defineProperty(process, 'platform', ORIG_PLATFORM);
process.env = { ...ORIG_ENV };
});
describe('stateFilePath', () => {
it('uses %LOCALAPPDATA%/codesys-mcp/tui-state.json on Windows', () => {
setPlatform('win32');
process.env.LOCALAPPDATA = 'C:\\\\Users\\\\u\\\\AppData\\\\Local';
const p = stateFilePath();
expect(p).toBe(
path.join('C:\\\\Users\\\\u\\\\AppData\\\\Local', 'codesys-mcp', 'tui-state.json')
);
});
it('uses $XDG_STATE_HOME/codesys-mcp/tui-state.json when set', () => {
setPlatform('linux');
process.env.XDG_STATE_HOME = '/tmp/xdg-state';
const p = stateFilePath();
expect(p).toBe('/tmp/xdg-state/codesys-mcp/tui-state.json');
});
it('falls back to ~/.local/state on Linux without XDG_STATE_HOME', () => {
setPlatform('linux');
delete process.env.XDG_STATE_HOME;
process.env.HOME = '/home/u';
const p = stateFilePath();
expect(p).toBe('/home/u/.local/state/codesys-mcp/tui-state.json');
});
it('throws on Windows when LOCALAPPDATA is unset', () => {
setPlatform('win32');
delete process.env.LOCALAPPDATA;
expect(() => stateFilePath()).toThrow(/LOCALAPPDATA/);
});
});

View file

@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { writeSelection } from '../../src/tui/shared/state-write.ts';
import { Selection } from '../../src/tui/shared/types.ts';
async function tmpDir(): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), 'phobics-tui-'));
}
const sampleSelection: Selection = {
device: 'CodesysRpi',
pou: {
name: 'FB_Test',
kind: 'FB',
relPath: 'Plc Logic/Application/FB_Test.st',
absPath: '/abs/Plc Logic/Application/FB_Test.st',
loc: 87,
mtimeMs: 0,
},
viewerLine: 12,
};
describe('writeSelection', () => {
it('writes a JSON file atomically with v1 envelope', async () => {
const dir = await tmpDir();
const target = path.join(dir, 'tui-state.json');
await writeSelection(target, '/abs/project', sampleSelection);
const text = await fs.readFile(target, 'utf8');
const parsed = JSON.parse(text);
expect(parsed.version).toBe(1);
expect(parsed.project_dir).toBe('/abs/project');
expect(parsed.device).toBe('CodesysRpi');
expect(parsed.selection.name).toBe('FB_Test');
expect(parsed.selection.kind).toBe('FB');
expect(parsed.viewer_line).toBe(12);
expect(typeof parsed.updated_at).toBe('string');
expect(() => new Date(parsed.updated_at)).not.toThrow();
});
it('creates parent directories as needed', async () => {
const dir = await tmpDir();
const target = path.join(dir, 'a', 'b', 'tui-state.json');
await writeSelection(target, '/abs/project', sampleSelection);
const stat = await fs.stat(target);
expect(stat.isFile()).toBe(true);
});
it('does not leave the .tmp file behind on success', async () => {
const dir = await tmpDir();
const target = path.join(dir, 'tui-state.json');
await writeSelection(target, '/abs/project', sampleSelection);
const entries = await fs.readdir(dir);
expect(entries.filter((e) => e.endsWith('.tmp'))).toEqual([]);
});
});

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { composeMergedContent, IMPL_SENTINEL } from '../../src/approve-gate';
const existing = [
'PROGRAM PLC_PRG',
'VAR',
' counter : INT := 0;',
'END_VAR',
IMPL_SENTINEL,
'counter := counter + 1;',
].join('\n');
describe('composeMergedContent', () => {
it('overrides only declaration when only declarationCode is provided', () => {
const out = composeMergedContent(existing, {
declarationCode: 'PROGRAM PLC_PRG\nVAR\n counter : DINT := 0;\nEND_VAR',
implementationCode: undefined,
});
expect(out).toContain('counter : DINT := 0;');
expect(out).toContain('counter := counter + 1;');
});
it('overrides only implementation when only implementationCode is provided', () => {
const out = composeMergedContent(existing, {
declarationCode: undefined,
implementationCode: 'counter := counter + 2;',
});
expect(out).toContain('counter : INT := 0;');
expect(out).toContain('counter := counter + 2;');
});
it('overrides both when both are provided', () => {
const out = composeMergedContent(existing, {
declarationCode: 'PROGRAM X\nVAR\nEND_VAR',
implementationCode: 'x := 1;',
});
expect(out).toBe(['PROGRAM X', 'VAR', 'END_VAR', IMPL_SENTINEL, 'x := 1;'].join('\n'));
});
it('handles existing files with no sentinel by appending one', () => {
const noSentinel = 'PROGRAM PLC_PRG\nVAR\nEND_VAR';
const out = composeMergedContent(noSentinel, {
declarationCode: undefined,
implementationCode: 'x := 1;',
});
expect(out).toContain(IMPL_SENTINEL);
expect(out).toContain('x := 1;');
});
});

View file

@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { buildGetUserSelectionResponse } from '../../src/server';
async function tmpFile(content: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-tool-'));
const f = path.join(dir, 'tui-state.json');
await fs.writeFile(f, content, 'utf8');
return f;
}
const fresh = () =>
JSON.stringify({
version: 1,
updated_at: new Date().toISOString(),
project_dir: '/p',
device: 'D1',
selection: {
kind: 'FB',
name: 'FB_Test',
path: 'a/FB_Test.st',
abs_path: '/abs/a/FB_Test.st',
},
viewer_line: 12,
});
describe('buildGetUserSelectionResponse', () => {
it('returns a structured payload for a fresh state file', async () => {
const f = await tmpFile(fresh());
const res = await buildGetUserSelectionResponse(f);
expect(res.isError).toBeFalsy();
expect(res.content[0].text).toContain('FB_Test');
expect(res.content[0].text).toContain('D1');
});
it('returns "no active selection" text when state file is missing', async () => {
const res = await buildGetUserSelectionResponse('/nonexistent.json');
expect(res.isError).toBeFalsy();
expect(res.content[0].text).toMatch(/no active selection/i);
});
it('returns "no active selection" text when state is stale', async () => {
const old = JSON.stringify({
...JSON.parse(fresh()),
updated_at: new Date(Date.now() - 120_000).toISOString(),
});
const f = await tmpFile(old);
const res = await buildGetUserSelectionResponse(f);
expect(res.isError).toBeFalsy();
expect(res.content[0].text).toMatch(/no active selection/i);
});
});

View file

@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { readSelection, FRESHNESS_MS } from '../../src/state-read';
async function tmpFile(content: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-read-'));
const f = path.join(dir, 'tui-state.json');
await fs.writeFile(f, content, 'utf8');
return f;
}
const FRESH = JSON.stringify({
version: 1,
updated_at: new Date().toISOString(),
project_dir: '/p',
device: 'D1',
selection: {
kind: 'FB',
name: 'FB_Test',
path: 'a/FB_Test.st',
abs_path: '/abs/a/FB_Test.st',
},
viewer_line: 12,
});
describe('readSelection', () => {
it('returns the parsed payload when fresh', async () => {
const f = await tmpFile(FRESH);
const r = await readSelection(f);
expect(r.status).toBe('ok');
if (r.status === 'ok') {
expect(r.payload.device).toBe('D1');
expect(r.payload.selection.name).toBe('FB_Test');
}
});
it('returns stale when updated_at is older than the freshness window', async () => {
const old = JSON.stringify({
...JSON.parse(FRESH),
updated_at: new Date(Date.now() - FRESHNESS_MS - 5000).toISOString(),
});
const f = await tmpFile(old);
const r = await readSelection(f);
expect(r.status).toBe('stale');
});
it('returns missing when the file does not exist', async () => {
const r = await readSelection('/nonexistent/tui-state.json');
expect(r.status).toBe('missing');
});
it('returns invalid when the JSON is malformed', async () => {
const f = await tmpFile('not json');
const r = await readSelection(f);
expect(r.status).toBe('invalid');
});
});

View file

@ -14,5 +14,5 @@
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
"exclude": ["node_modules", "dist", "tests", "src/tui"]
}

19
tsconfig.tui.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"outDir": "dist/tui",
"rootDir": "src/tui",
"jsx": "react",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/tui/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}