0
0
Fork 0
Commit graph

45 commits

Author SHA1 Message Date
Karstein Phobic Nyvold Kvistad
e1966c7e83 feat(approve-gate): wire 9 modifying tools through phobiCS-tui
Generalizes runApproveGate beyond set_pou_code by adding:

- runApproveGateOp({slug, oldText, newText}) — writes synthetic
  before/after files into a tmpdir, spawns 'phobiCS-tui approve' on
  them, cleans up the tmpdir on return. Used for ops that don't have
  a clean existing-file -> proposed-file mapping (create/delete/rename
  /add).

- gateOpForTool({enabled, slug, oldText, newText}) — MCP-tool-shaped
  wrapper. Returns null when the op should proceed (gate disabled,
  accepted, or no-existing); returns a {content, isError} block-
  response otherwise. Lets each tool gate with one if-statement.

Wired into the 9 modifying tools (with --approve-edits on, each one
prompts via the TUI before applying):
  - create_pou         all-green diff: name + type + language + parent
  - create_property    all-green diff: name + type + parent FB
  - create_method      all-green diff: name + return type + parent FB
  - create_dut         all-green diff: name + DUT type + parent
  - create_gvl         all-green diff: name + parent + (optional decl)
  - create_folder      all-green diff: name + parent
  - delete_object      all-red diff: object + project
  - rename_object      del+add: old name -> new name
  - add_library        all-green diff: library name + project

set_pou_code keeps using the existing runApproveGate (which composes
real merged file content via the IMPL_SENTINEL split, giving the
nicest possible diff against the real mirror file).
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
3da09f8aba tui(viewer): ST keyword/type/comment/string syntax highlighting
tokenize(line) splits a single line into typed tokens:
  keyword (cyan)   PROGRAM, FUNCTION_BLOCK, IF/THEN/ELSE/END_IF,
                   FOR/TO/DO, VAR/END_VAR, CASE/OF, AND/OR/XOR/NOT,
                   TRUE/FALSE, EXTENDS, IMPLEMENTS, etc.
  type    (magenta) BOOL, INT, DINT, REAL, LREAL, TIME, STRING,
                   ANY_*, ARRAY, POINTER, REFERENCE, etc.
  comment (gray)   (* ... *) inline and // line-to-end
  string  (yellow) 'single' and "double" quoted
  text    (none)   everything else

Identifier matching is case-sensitive uppercase only — matches
typical IEC 61131-3 convention and avoids false positives on lower-
case identifiers like if_x. Multi-line (* ... *) comments are not
joined across lines (out-of-scope for v0.2).
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
e6d833ea1a tui(browser): d opens cross-device diff for highlighted POU
When the cursor is on a POU, d collects all OTHER devices that have
a POU with the same name.

  - 0 peers: no-op (nothing to compare)
  - exactly 1 peer: open the diff overlay immediately
  - 2+ peers: open <CrossPicker> first; user picks with j/k + Enter

The diff overlay uses computeHunks against the full file contents
(not the IMPL_SENTINEL split, since we want to see decl differences
across devices too). Esc/q closes it.
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
2a743c29fa tui(approve): v toggles unified <-> side-by-side diff
Side-by-side renders old | new in two 50% columns separated by '│'.
Consecutive del/add hunks are paired row-by-row; a longer side gets
blank rows on the shorter side. Context lines mirror on both sides.

Footer now lists 'v toggle side-by-side'.
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
f0b7852dbb tui(browser): / filter mode with live POU-name substring match
/ enters filter mode. Typed chars accumulate (case-insensitive
substring match against POU.name). Backspace removes a char. Enter
commits and stays out of input mode (filter persists). Esc clears
the filter and exits.

While the filter is active, devices auto-expand and only POUs whose
name contains the filter text are listed; devices with zero matching
POUs are hidden entirely.

Filter status line ('Filter: <text>') shows above the tree, cyan
while in input mode, dim once committed.
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
2441a898e8 tui(browser): o opens highlighted POU in $EDITOR (or VS Code)
o on a POU row spawns $EDITOR (defaulting to 'code') with the
absolute path. detached + stdio:'ignore' + unref so the editor's
lifetime is independent of the TUI; shell:true so PATH lookup works
on Windows where 'code' is a .cmd shim.

Ignored on device rows (no abs_path).
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
4d7cbdb6f6 tui(browser): r re-scans mcp-mirror/
r calls onRescan() which re-walks the project root and rerenders the
Browser with the new tree. Optional prop so tests don't have to wire
it.
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
2d73d005ef tui(browser): add ? help overlay (Esc to close)
? toggles a bordered help panel listing all keybindings. While the
panel is open, all other keys are ignored except Esc (which closes
it). The footer text gets a '? help' hint.
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
91f5713cca tui(browser): add stale-mirror indicator + resize warning
formatStaleness(mtimeMs) -> null | 'Xs' | 'Xm Ys' | 'Xh Ym'.
<ResizeWarning columns rows/> warns when terminal is below 80x20.

Browser header now shows 'mirror Xm Ys old' between the project
name and the closing rule when the mirror dir mtime is older than
STALE_THRESHOLD_MS (10s). The resize warning sits between the
header and the split view so it's the first thing the user sees
when they shrink the window.
2026-04-29 08:22:09 +02:00
phobicdotno
065bce5b3d
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
2026-04-29 08:21:34 +02:00
Karstein Phobic Nyvold Kvistad
2c19eca4b3 test+docs(symbol-config): 10 vitest e2e prep tests + README + function-test entry
vitest tests/integration/e2e.test.ts:
  - 10 new template-prep assertions, one per Symbol Configuration tool.
    Each asserts no leftover {PLACEHOLDER} in the rendered script and
    that the relevant CODESYS API method names appear (get_all_signatures,
    application.create_symbol_config, configured_access, etc.).
  - Drive-by: fix the existing add_library e2e test that was missing the
    ALLOW_UNRESOLVED placeholder (added when the resolution gate landed
    in d414c77). Test was previously failing on /\{[A-Z_]+\}/ regex.

Suite is now 107/107 green (excluding the orphan .worktrees/phobics-tui
suite that's been failing pre-this-change).

README.md:
  - Tool count 31 -> 41.
  - New "Symbol Configuration Tools" section under Library Management
    with one row per tool.
  - Project-structure tool count footnote updated.

docs/function-test-2026-04-28.md:
  - Append "Symbol Configuration tools (added 2026-04-28 evening)" block
    with a per-tool vitest/live status grid.
  - Live SP22 column is deferred: the MCP tool list is negotiated at
    Claude Code session start and doesn't refresh mid-session, so the
    new tools aren't callable in the session that built them. Document
    the round-trip cycle so a fresh session can run it.
  - Note the SymbolAccess enum-value probe risk per the plan
    (docs/superpowers/plans/2026-04-28-symbol-config-tools.md).

Python 3 ast.parse run against all 10 new + 1 helper scripts -- 11/11
parsed cleanly (catches the obvious IronPython 2.7 syntax issues that
Py3 would also flag).
2026-04-28 23:52:34 +02:00
Karstein Phobic Nyvold Kvistad
7848c845a2 feat(approve-gate): wire set_pou_code through phobiCS-tui when --approve-edits
When --approve-edits is on, set_pou_code:
  1. finds the existing mirror .st for the POU (best-effort glob; no
     match -> skip the gate, proceed)
  2. composes the proposed merged content (decl/impl swapped at the
     IMPL_SENTINEL line, keeping the unchanged half)
  3. writes the proposal to <existing>.staged
  4. spawns 'phobiCS-tui approve <existing> <staged>' with stdio
     inherited so the user sees and answers it
  5. cleans up .staged regardless of outcome

Exit 0 -> accepted -> apply the change. Exit 1 -> user-facing
'rejected' response (isError=false; the user actively said no).
Exit 2 -> error response (isError=true).

When the flag is off (default), set_pou_code runs unchanged -- this
keeps existing scripted flows from regressing.
2026-04-28 22:32:39 +02:00
Karstein Phobic Nyvold Kvistad
d6999c21bf feat(mcp): register get_user_selection tool
Adds an MCP tool the LLM can call to ground modifying calls in what
the user is currently looking at in the phobiCS-tui browser.
Backed by readSelection() from src/state-read.ts.

defaultStateFilePath() is intentionally duplicated from
src/tui/shared/state-paths.ts: the TUI subpackage is ESM and the MCP
server is CJS, so a cross-import would force one or the other to
change module systems for ~15 lines of code that almost never
changes.
2026-04-28 22:28:09 +02:00
Karstein Phobic Nyvold Kvistad
0e100610e5 feat(state): add CJS reader for phobiCS-tui state file
readSelection(path) -> discriminated union of:
  - ok   (the parsed v1 payload)
  - missing  (file does not exist)
  - stale    (updated_at older than FRESHNESS_MS = 60s)
  - invalid  (bad JSON or unknown version)

Lives outside src/tui/ because the MCP server (CJS) consumes it; the
TUI itself only writes the file.
2026-04-28 22:26:15 +02:00
Karstein Phobic Nyvold Kvistad
b1202f60d8 tui: add Browser composer with debounced selection writes
<Browser project readPou writeSelection onQuit/> wires Tree (40%) and
Viewer (60%) into a stateful split view.

Keys: j/k nav, l/Enter/right expand a device, h/left collapse,
q quit.

Two effects fire whenever the cursor sits on a POU:
  - debounced 200 ms writeSelection() so rapid j/k presses do not
    storm the state file
  - lazy readPou() with a cancelled-flag guard so the previous POU's
    text never lands after the user has already moved on
2026-04-28 22:23:49 +02:00
Karstein Phobic Nyvold Kvistad
9902cd6dcf tui: add Tree component (collapsible device/POU listing)
<Tree project cursorPath expanded/> renders the project as a
two-level collapsible list: device headers with POU count, and (when
expanded) POU rows with name + kind + LOC count. Cursor row marked
with a leading triangle.

devicePath()/pouPath() are exported keypath helpers used by the tree
state machine to address rows uniformly.
2026-04-28 22:21:05 +02:00
Karstein Phobic Nyvold Kvistad
357c1d11c5 tui: wire approve dispatch in bin entry
phobiCS-tui dispatcher:
- --version / -v: print version, exit 0
- approve <existing> <proposed>: read both files, render <Approve>,
  exit 0 on accept / 1 on reject / 2 on bad args or read error
- no args: print 'browser mode coming' placeholder, exit 0
- SIGTERM/SIGINT during approve: unmount + exit 1

Integration tests cover the no-TTY paths (--version, missing file,
missing args). Accept/reject keybinds are covered by the
ink-testing-library tests in Approve.test.tsx; piping stdin into a
no-TTY ink process is flaky on Windows so we don't try to
integration-test that path.

The shebang line is intentionally absent from the source; the build
script prepends one to dist/tui/index.js.
2026-04-28 22:19:51 +02:00
Karstein Phobic Nyvold Kvistad
884de61cd7 tui: add Approve component with y/n keybind
<Approve fileName oldText newText onDecision/> renders a unified diff
(+/-/space sigils, 4-col line numbers, add/del totals header) and
binds y -> accept, n/q/ESC -> reject via ink useInput.

Tests use ink-testing-library; each keystroke case awaits a microtask
flush before stdin.write so that ink's useEffect-installed 'readable'
listener has actually been attached at the moment the byte arrives.
2026-04-28 22:18:00 +02:00
Karstein Phobic Nyvold Kvistad
99a9519138 tui: add mcp-mirror auto-discovery
findProjectRoot(startDir) walks up the filesystem looking for an
mcp-mirror/ subdirectory and returns the dir that contains it. Returns
null if no mirror is found anywhere upward to the FS root.
2026-04-28 21:55:32 +02:00
Karstein Phobic Nyvold Kvistad
4d22232ec0 tui: add line-based diff hunk computation
computeHunks() returns Hunk[] (add | del | ctx) with line numbers on
the new side for add/ctx and the old side for del.

Both inputs are normalized to end with a newline before calling
jsdiff's diffLines, otherwise a missing EOF newline is treated as a
token boundary and the last line shows up as a spurious del+add
pair.
2026-04-28 21:54:20 +02:00
Karstein Phobic Nyvold Kvistad
ba40f10e6e tui: add atomic Selection JSON writer
writeSelection() emits the v1 state envelope (version, updated_at,
project_dir, device, selection{kind,name,path,abs_path}, viewer_line)
to <file>.<pid>.tmp then atomic-renames into place. mkdir -p the
parent, no .tmp left behind on success.
2026-04-28 21:52:07 +02:00
Karstein Phobic Nyvold Kvistad
461bd10a73 tui: add state-file path resolver (Windows + XDG)
Resolves the location of the cross-process TUI state file:
- Windows: %LOCALAPPDATA%/codesys-mcp/tui-state.json
- POSIX: $XDG_STATE_HOME/codesys-mcp/tui-state.json, falling back to
  $HOME/.local/state/codesys-mcp/tui-state.json

Uses path.win32 / path.posix explicitly so the POSIX branches still
produce POSIX-flavored paths when this runs on a Windows host (test
machine), keeping the unit tests host-independent.
2026-04-28 21:50:44 +02:00
Karstein Phobic Nyvold Kvistad
64ab4aeaae tui: add mirror scanner with classification + LOC
Walks mcp-mirror/<device>/ trees, classifies each .st as
PRG/FB/GVL/STRUCT/ENUM/METHOD/PROPERTY_GETTER/PROPERTY_SETTER/META/OTHER,
counts non-blank LOC, and returns a Project tree.

Property declarators (e.g. FB_Sweep/PropX.st alongside
FB_Sweep/PropX/Get.st) are detected via a Get.st/Set.st child probe so
they don't get classified as METHOD.
2026-04-28 21:49:02 +02:00
Karstein Phobic Nyvold Kvistad
0f8981d6bd fix(rename_object): rewrite \bOldName\b refs in every POU/DUT/GVL by default
Empirical failure: rename_object Application/ST_Sample -> ST_SampleRenamed updated the struct's own TYPE header but Application/PLC_PRG kept 's : ST_Sample;' -- the old name -- breaking the project.
Root cause: scriptengine.ScriptObject.rename()/set_name() is a node-local rename only; the IDE's project-wide Rename refactor lives above the scripting layer (no documented find_references() / refactor variant).
Fix: after the local rename succeeds, walk every text-bearing object (textual_declaration / textual_implementation), word-boundary regex-replace \bOldName\b -> NewName via a callback (so backslashes in NewName don't get interpreted as backrefs), set_text the changed nodes, save once. New optional updateReferences param defaults to true; pass false for the legacy minimal-rename behaviour.
Docs: https://content.helpme-codesys.com/en/ScriptingEngine/ScriptObject.html

Risk: false positives in comments / string literals are theoretically
possible but rare for IEC identifiers. Documented in the tool description.

The target node itself is skipped during the references walk (matched by
get_id()) so the rename's already-updated TYPE/FUNCTION_BLOCK/PROGRAM
header isn't double-rewritten.

Two new vitest e2e checks added: assert UPDATE_REFERENCES=1 renders the
re.escape + word-boundary regex, and UPDATE_REFERENCES=0 still produces
a fully-substituted script with no leftover placeholders.

### Manual smoke test
1. Open a project with: a DUT 'ST_Sample', a POU 'PLC_PRG' with
   'VAR s : ST_Sample; END_VAR', and a third POU referencing 'ST_Sample.foo'.
2. mcp__codesys__rename_object objectPath=Application/ST_Sample
   newName=ST_SampleRenamed.
3. Expect SCRIPT_SUCCESS with 'References Updated In: 2 node(s)'.
4. mcp__codesys__compile_project should succeed (no unresolved-symbol
   errors for ST_Sample).
5. With updateReferences=false, the same rename should leave PLC_PRG
   stale and compile_project should fail -- validates the opt-out.
6. Word-boundary check: rename 'Foo' -> 'Bar' must NOT touch 'FooBar'
   or 'BarFoo' anywhere.
2026-04-28 20:58:45 +02:00
Karstein Phobic Nyvold Kvistad
fc49e7ff8b fix(add_library): dedup pre-check + default to add_placeholder, opt-in direct/force
Empirical failure: add_library('Standard') on a project that already had Standard, * (System) silently created a SECOND direct Standard reference, pulling in unresolved transitive deps (e.g. yellow-warning IoStandard 3.1.3.1).
Root cause: script always called add_library() without checking lm.references first; never called add_placeholder() so the result was a direct (non-* (System)) reference.
Fix: (1) walk lm.references for an existing entry by bare name and no-op with a confirmation message unless force=true; (2) default to add_placeholder() so transitive deps resolve at compile (matches the modern '<Name>, * (System)' convention); (3) keep add_library() reachable via direct=true; (4) on miss, dump dir(lm) so unknown SPs surface the actual API.
Docs: https://content.helpme-codesys.com/en/ScriptingEngine/ScriptLibManObject.html

Mildly breaking for callers: previous behaviour was always direct
add_library(); pass direct=true to restore. The dedup default also flips
'add second copy' to 'no-op' -- pass force=true to restore.

Test updated: tests/integration/e2e.test.ts now passes USE_DIRECT='0' and
FORCE_DUP='0' alongside LIBRARY_NAME, asserts add_placeholder + dedup
strings appear in the rendered script.

### Manual smoke test
1. mcp__codesys__add_library libraryName=Standard against a project that
   already has Standard listed: expect SCRIPT_SUCCESS with body 'Library
   Already Present: Standard' and NO second entry in the Library Manager.
2. mcp__codesys__add_library libraryName=Util on a project without Util:
   expect a new entry rendered as 'Util, * (System)' (placeholder, not
   direct).
3. mcp__codesys__add_library libraryName=Standard direct=true: expect a
   direct (non-* (System)) reference even on dedup hit if also force=true.
4. mcp__codesys__list_project_libraries should reflect each result.
2026-04-28 20:54:34 +02:00
Karstein Phobic Nyvold Kvistad
b3d7a6fa64 feat(open_project): pre-flight profile-mismatch check using offline .project inspection
Before delegating to CODESYS for the actual open, inspect the
.project's projectinspectiondata.auxiliary (via src/inspect.ts -- pure
offline, ZIP+XML, no CODESYS) and compare its saved SP+patch against
the server's configured --codesys-profile.

Three outcomes:
- exact match -> proceed silently
- same SP, different patch -> proceed with a warning prefix in the
  response (CODESYS will pop its patch-difference dialog)
- SP mismatch -> refuse without opening; suggest the user either pick
  a different MCP server entry or run --print-config --for-project to
  generate one for the project's required SP

If inspection itself fails (file missing, malformed .project, profile
unparseable), pre-flight falls through silently -- the existing CODESYS
open path then produces its original error.
2026-04-27 22:32:53 +02:00
Karstein Phobic Nyvold Kvistad
7c74249e1b feat(cli): --print-config --for-project <path> picks the install that opens the project
Run --print-config with --for-project pointing at a .project file
and the snippet narrows to just the install(s) that can open it
(exact SP+patch match, or fallback to same-SP-different-patch with
a warning about the conversion dialog). No more eyeballing -- the
project's projectinspectiondata.auxiliary tells us which CODESYS
to route to, and --for-project just looks it up.

Mutually exclusive with --sp. Errors are explicit (no install
matches at all, or both flags supplied).
2026-04-27 22:27:31 +02:00
Karstein Phobic Nyvold Kvistad
be42f60488 fix(mirror): per-project mirror dir name when multiple .project files share a parent
The default mirror root was hard-coded to <projectDir>/mcp-mirror/,
which collides when two .project files live in the same folder
(e.g. \files\...\Multi plc test\ProjectA.project +
ProjectB.project both default to the same mirror -- each
mirror_export call clobbers the other's output).

New resolution rule (preserves backward-compat -- existing setups
unaffected):
- If <projectDir>/mcp-mirror/ already exists, use it
- Else if exactly one .project sibling, use <projectDir>/mcp-mirror/
- Else (multiple .project) use <projectDir>/<basename>_mcp_mirror/

Implemented as src/mirror-paths.ts (TS helper, used by server.ts
maybeOpenMirrorInVscode and unit-testable) plus the same logic
inlined in the Python scripts that need it (mirror_export.py et al.,
no shared-import infra exists CODESYS-side).
2026-04-27 22:22:09 +02:00
Karstein Phobic Nyvold Kvistad
37fc80764e feat(ssh): read_running_version_ssh -- read PLC project version over SSH, no CODESYS needed
New MCP tool + --ssh-version CLI flag. Bypasses the CODESYS IDE
entirely: SSH to a CODESYS Control Linux PLC, sudo strings the boot
application binary, extract the X.Y.Z.W literal of
_MCP_PROJECT_VERSION.sVersion. Filters out 3.5.x.y CODESYS runtime
versions automatically.

Solves the case where the .project file is locked by another CODESYS
instance, or no CODESYS install is reachable, but the PLC is. Read-
only on the PLC (just strings the boot binary).

Requires SSH key auth + passwordless sudo for /usr/bin/strings on
the PLC. Both error paths surface exact-instructions error messages
(PowerShell key install command, sudoers line) instead of opaque
failures.

Smoke-tested against codesys-pi (RPi running CODESYS Control 3.5.22)
with MCPTest2 v1.5.0.0 downloaded -- correctly extracts 1.5.0.0 and
filters out the 3.5.22.0 runtime version literal.
2026-04-27 22:13:29 +02:00
Karstein Phobic Nyvold Kvistad
0afdc08c47 feat(cli): --inspect <project> reads CODESYS profile + mandatory libs offline (no CODESYS needed) 2026-04-27 21:47:31 +02:00
Karstein Phobic Nyvold Kvistad
976ea05236 fix(launcher): only refuse double-spawn of the SAME --codesys-path
Previous guard refused any CODESYS.exe in tasklist regardless of which
exe path the launcher was configured for. This broke the multi-install
setup the README documents (codesys-21 + codesys-22 entries are
supposed to coexist), and refused to launch any time the user had a
manual CODESYS window open from a different install.

Different CODESYS installs (e.g. SP21 + SP22) are designed to run in
parallel -- they're separate processes, separate IPC, separate file
locks. The only genuine conflict is two instances of the SAME exe
trying to attach to the SAME .project file (CODESYS pops 'project is
currently in use'). The same-exe case can't share IPC with us anyway
since we didn't spawn it.

Implementation:
- New findRunningCodesys() returns [{pid, exePath}] via PowerShell
  Get-Process (tasklist doesn't expose ExecutablePath; WMIC is
  deprecated on modern Windows).
- pathsEqual() exported helper: case-insensitive, slash-normalised,
  trims trailing separators.
- Spawn-guard now filters by pathsEqual(p.exePath, config.codesysPath).
  Refusal message names the conflicting exe and PIDs explicitly.
- shutdown_codesys orphan-killer also filters by exe path so we never
  kill a CODESYS instance the user owns or that belongs to a different
  MCP entry.

Tests:
- 6 new pathsEqual cases (identical / case-insensitive / slash-mix /
  trailing-sep / different installs / different drives).
- detect test for the new --print-config caveat copy (no longer
  warns 'only one at a time'; warns about same-.project conflict).
- 58/58 pass.

Also updates --print-config CAVEAT in src/detect.ts to reflect that
multiple entries can be active simultaneously, with the only hard rule
being don't open the same .project from two CODESYS instances.
2026-04-27 20:57:59 +02:00
Karstein Phobic Nyvold Kvistad
9c98e61974 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 <n>: filter to one SP family; collapses entry name to 'codesys'
  when exactly one install matches
- --name <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.
2026-04-27 20:22:15 +02:00
Karstein Phobic Nyvold Kvistad
405be44a58 rename: Codesys-MCP-SP22+ -> Codesys-MCP-SP21+ + multi-install README + missing tools
Project rename. The 'SP21+' identifier is more accurate than the
previous 'SP22+' label -- this fork specifically carries the SP21+
migration fixes (the upstream's system.execute_on_primary_thread()
removal and downstream API drift), and is forward-compat with later
SPs. The repo on GitHub has been renamed to
phobicdotno/Codesys-MCP-SP21-plus accordingly.

Substitutions (UTF-8 preserved this time -- prior PowerShell pass
mangled em-dashes via a Win-1252 round-trip):
- package.json: name codesys-mcp-sp22-plus -> codesys-mcp-sp21-plus,
  bin entry, repository.url, homepage, description, author trailer
- README.md: title, banner, install + clone snippets, CLI invocations
- ARCHITECTURE.md: comparison-table column header + temp-dir prefix
- tests/TEST_OVERVIEW.md: title
- src/bin.ts: header comment + program().name()
- src/launcher.ts: SESSION_DIR_PREFIX
- src/types.ts: header comment

Compatibility phrase 'Works on SP19, SP21, and SP22+' and the technical
identifier 'SP22 Patch 1 fixes' (which name the actual CODESYS version)
were preserved -- those refer to CODESYS, not the project name.

README additions:
- Quick Start example switched from SP21 Patch 3 to SP22 Patch 1 to
  match the more common modern install
- New 'Multiple CODESYS installations' subsection with worked
  side-by-side example for SP21 (3.5.21.50) and SP22 (3.5.22.10) --
  one named MCP server entry per install, called by name from Claude.
  Documents the constraint that --codesys-path/--codesys-profile are
  bound at server startup, that --detect lists installs, and that
  config edits require a Claude Code restart
- Tool count corrected from 28 -> 37 in the Features bullets
- New Tools sections covering 10 previously-undocumented tools:
  Version Anchor + Release Pipeline (bump_project_version,
  release_project_version, read_running_version_online), Source
  Mirror (mirror_export), and CODESYS Git PDE-gated (git_init,
  git_status, git_commit, git_remote_add, git_branch_set_upstream_to,
  git_push). Each row carries the actual operational gotchas
  discovered during this session (UNC localRepoPath rejection,
  push-without-upstream failure, optimizer stripping unreferenced
  globals from the online symbol table, etc.)
- list_project_libraries + add_library row text updated to reflect
  the post-fix behaviour (compiler-version capture; managed-overload
  preference + resolution gate)

Local origin URL updated: phobicdotno/Codesys-MCP -> phobicdotno/Codesys-MCP-SP21-plus.

37/37 tests green.
2026-04-26 22:02:46 +02:00
Karstein Phobic Nyvold Kvistad
f157e7b61e rename: project -> Codesys-MCP-SP22+
Project identity rename. Concrete changes:

- package.json
  - name: codesys-mcp-persistent -> codesys-mcp-sp22-plus (npm-safe;
    '+' is not allowed in npm package names)
  - description: prepended 'Codesys-MCP-SP22+' branding + brief
    summary of the SP22+ fixes carried in this fork
  - bin entry: codesys-mcp-persistent -> codesys-mcp-sp22-plus
  - repository.url + homepage: now point at
    github.com/phobicdotno/Codesys-MCP-SP22-plus (using -plus
    spelling on the URL side; '+' would require URL-encoding)
  - author: noted upstream + SP22+ fork attribution
- README.md: title + about-this-fork blurb + clone instructions
- tests/TEST_OVERVIEW.md: title
- ARCHITECTURE.md: comparison-table column header + temp-dir path
- src/bin.ts: file header + program().name()
- src/launcher.ts: SESSION_DIR_PREFIX (with note on backwards compat)
- src/types.ts: file header

Display name everywhere: 'Codesys-MCP-SP22+'.
npm package name: 'codesys-mcp-sp22-plus'.

Out of scope for this commit -- need user action:
- Local directory rename (C:/Users/karstein.kvistad/Codesys-MCP ->
  ...-SP22+) cannot be done while the persistent MCP is using dist/
  from the current path; user can rename after shutting down VS Code.
- GitHub repo rename (phobicdotno/Codesys-MCP -> ...-SP22-plus or
  ...-SP22+) needs the github.com web UI; the package.json URLs
  already point at the post-rename name so a 'git remote set-url
  origin <new>' lines up after.
- MCP client config in .mcp.json or similar: the bin name changed
  from 'codesys-mcp-persistent' to 'codesys-mcp-sp22-plus' -- update
  any 'command' field that referenced the old name. (Local sessions
  invoking node dist/bin.js directly are unaffected.)

37/37 tests still green after rename.
2026-04-26 20:42:15 +02:00
Karstein Phobic Nyvold Kvistad
04b46fb49a fix(read_running_version_online): document the real root cause + fix
Three changes after deeper investigation against PLATEA Win V3:

1. The 'symbol' := 'read' attribute experiment didn't help. Reverted
   bump_project_version's GVL template to plain VAR_GLOBAL +
   qualified_only (matches what shipped originally, minus CONSTANT).
   Comment updated to explain both why CONSTANT is wrong (compile-time
   inlining) and why the symbol attribute alone wasn't enough (it
   requires a Symbol Configuration object to do anything).

2. Real root cause for read_running_version_online's 'Invalid expression':
   CODESYS strips unreferenced GVLs from the online symbol table at
   compile time, regardless of attribute pragmas. The version anchor by
   definition has no IEC code reading it, so the optimizer drops it.
   GVL_Test.bRun reads fine despite no references because GVL_Test has
   OTHER referenced variables; entire-GVL retention seems to be the
   stripping unit, not per-variable.
   Verified end-to-end: adding 'sVersionTag := _MCP_PROJECT_VERSION
   .sVersion;' in PLC_PRG made the read return '1.4.1.0' on PLATEA.

3. Updated the read_running_version_online error message to surface
   BOTH the (now-rare) CONSTANT case AND the (common) unreferenced-GVL
   case, with the exact 2-line code snippet a user needs to paste into
   their main program. The bump tool intentionally does NOT auto-inject
   this -- modifying user code on every release was deemed too invasive.
   Documented the requirement in TEST_OVERVIEW.md alongside the v5
   sweep notes.

37/37 tests still green. v1.4.2.0 of MCPTest2 carries the working
PLC_PRG reference as the canonical demonstration.
2026-04-26 20:13:25 +02:00
Karstein Phobic Nyvold Kvistad
ef259c8ee3 fix(online tools): auto-login + non-CONSTANT version GVL
Two related v5-sweep fixes for the online/runtime tool family:

1. Auto-login helper for headless mode

   In headless mode each MCP call spawns a fresh CODESYS --noUI process,
   so the login state established by connect_to_device dies before the
   next call. Pre-fix, only connect_to_device and download_to_device did
   their own login(); the other four (start_stop_application,
   read_variable, write_variable, read_running_version_online) silently
   failed in headless with 'Application not logged in.' (start/stop) or
   'Invalid expression' (read/write). They worked in persistent mode
   only because the login carried across calls.

   Added ensure_logged_in(online_app, login_wait_seconds=30) to
   ensure_online_connection.py. Idempotent: short-circuits via
   online_app.is_logged_in (persistent mode is a no-op, no extra login
   roundtrip). When not logged in, runs the same enum-probe + call-shape
   probe + STABLE_STATES settle-wait pattern as connect_to_device.py.
   Added to start_stop_application.py, read_variable.py,
   write_variable.py, read_running_version_online.py.

2. _MCP_PROJECT_VERSION GVL emitted as plain VAR_GLOBAL, not CONSTANT

   CODESYS inlines VAR_GLOBAL CONSTANT scalars at compile time and
   strips them from the online symbol table. The whole point of
   _MCP_PROJECT_VERSION.sVersion is to be readable live from the
   running PLC, so CONSTANT was the wrong storage class.
   read_running_version_online failed against EVERY project bumped via
   the old template -- 'Invalid expression' on the runtime read.

   Dropped CONSTANT from VERSION_GVL_DECLARATION_TEMPLATE in
   bump_project_version.py. Existing projects auto-migrate on the next
   bump because maintain_version_gvl()'s existing-GVL branch overwrites
   textual_declaration with the (now non-CONSTANT) template. The string
   is still effectively read-only at runtime -- only the bump tool
   updates it.

   read_running_version_online.py also got a more precise error message
   that explicitly fingerprints the 'Invalid expression' failure mode
   and points at the CONSTANT root cause. Useful for any user landing
   on a project that pre-dates this fix.

Verified end-to-end against local CODESYS Control Win V3 (PLATEA, port
11740) on MCPTest2 v1.3.4.0:
- connect_to_device, get_application_state, download_to_device,
  start_stop_application (both directions), read_variable
  (PLC_PRG.watchdog1 = 225 ticking), write_variable (200 -> 204 in 4s
  proves write took), disconnect_from_device: all 7 PASS.
- read_running_version_online failure reproduced (CONSTANT inlined),
  fix landed -- next bump on MCPTest2 will validate.

37/37 unit/integration tests green. TEST_OVERVIEW.md updated with the
v5 device sweep, with the headless-mode deep-dive, and with the
broken-by-design notes on read_running_version_online.
2026-04-26 19:54:13 +02:00
Karstein Phobic Nyvold Kvistad
d414c779a5 fix(add_library): refuse to save unresolvable placeholders
Prior behaviour: lm.add_library(LIBRARY_NAME) was called with a string,
which always hits the placeholder overload of ScriptLibManObject.add_library
(see helpme-codesys.com "ScriptLibManObject" / local SP22 stub
ScriptLib/Stubs/scriptengine/ScriptLibManObject.pyi). If the named
placeholder is not registered in the IDE, the resulting reference fails
to resolve at load time and the next project open throws

  Library Manager: Error: Could not open library 'X'.
  (Reason: The placeholder library 'X' could not be resolved.)

after which script_engine.projects.primary returns None and the project
is effectively bricked until the binary is reverted. add_library reported
SUCCESS in this scenario.

Fix:
  1. Pre-resolve LIBRARY_NAME via the IDE-level
     library_manager.find_library(name) global. If found, pass the
     resulting ManagedLib to lm.add_library(...) -- the V3.5.5.0 ManagedLib
     overload which produces a managed reference instead of a placeholder.
  2. After the add, walk lm.references to locate the new entry and verify
     it resolved (managed -> always; placeholder -> non-empty
     effective_resolution per ScriptPlaceholderReference in the stub).
  3. If the reference did not resolve, call lm.remove_library(name) to back
     out the bad reference and refuse to save the project, returning an
     actionable error instead.

Also tightened the libman lookup to use the documented
has_library_manager / get_library_manager() container API
(ScriptLibManObjectContainer in the stub) instead of name-searching for
"Library Manager" as a tree node, matching what list_project_libraries.py
already does. The legacy name-search fallback is preserved for older SPs.

Regression test added in tests/integration/e2e.test.ts asserting the
rendered script template carries the resolution gate, the managed-overload
preference, the back-out call, and that primary_project.save() in the
add_library body lives downstream of the _is_resolved gate.

Followup: lm.remove_library(name) is documented for SP22 and is the
clean back-out path. On SPs that lack remove_library (none observed in
the 3.5.21+ docs but possible on truly old branches) the script reports
the constraint and exits non-zero rather than silently saving a bad
reference.
2026-04-26 19:44:00 +02:00
Karstein Phobic Nyvold Kvistad
f7d564bc47 test(bench): v5 sweep -- fix set_pou_code params, all 10 cases pass
Three changes:
1. Fix the bench harness's set_pou_code case. The previous version
   passed POU_PATH / DECLARATION_CODE / IMPLEMENTATION_CODE, but the
   script template expects POU_FULL_PATH / DECLARATION_CONTENT /
   IMPLEMENTATION_CONTENT. Plus the recently-added SET_DECLARATION /
   SET_IMPLEMENTATION boolean flags from 35abc8c. With these
   corrections, set_pou_code passes for the first time in the bench.
2. Add bench-results-v5.json from a fresh persistent-mode run on
   MCPTest2 v1.3.4.0. All 10 cases PASS.
3. Update TEST_OVERVIEW.md with the v5 numbers alongside the v1
   historical numbers. v5 is ~2x faster across the board than v1
   (likely the cumulative effect of the ScriptManager cache removal +
   SP22 Patch 1 IPC improvements). first-cold open_project is slower
   than v1 (10.6 s vs 7.7 s) -- one-shot, noise probably; warm
   already-open call dropped from 740 ms to 314 ms.

The v5 set_pou_code pass is the third end-to-end signal that the
omitted-decl wipe fix is good (after the e2e regression test and the
live PLC_PRG restoration earlier today).
2026-04-26 19:30:48 +02:00
Karstein Phobic Nyvold Kvistad
35abc8cb52 fix(set_pou_code): omitted declaration/impl no longer wipes the POU
Bug: calling set_pou_code with implementationCode only (declarationCode
omitted) would wipe the POU's PROGRAM/VAR...END_VAR block in the binary.
After such a call mirror_export classified the POU as 'UNKNOWN' (no
PROGRAM/FUNCTION_BLOCK keyword in the empty declaration), and the var
block disappeared from the .st mirror file.

Root cause: the TS wrapper substituted '' (empty string) into the Python
template when declarationCode was undefined, giving DECLARATION_CONTENT
= "". The Python script then took the truthy-ish branch (empty string
is not None) and called decl_obj.replace('') -- wiping textual_decl.

Fix: pass explicit SET_DECLARATION / SET_IMPLEMENTATION boolean flags
from the TS wrapper, gate the replace() calls on those flags. Empty
string remains a valid intentional value (caller wants to wipe).

- Reproduced on MCPTest2: PLC_PRG declaration block was wiped between
  v1.3.0.0 and v1.3.1.0 by exactly this code path.
- Regression test added in tests/integration/e2e.test.ts covering the
  omitted-declarationCode path.
- Existing set_pou_code test extended to assert SET_DECLARATION /
  SET_IMPLEMENTATION are emitted in the rendered script.
2026-04-26 18:41:48 +02:00
Karstein Kvistad
9c6d28c146 docs(TEST_OVERVIEW): mark create_folder fixed end-to-end + iteration story
- Bumped status from "fixed (factory fallback)" to "fixed in c87f3a9
  (4-iteration debug saga)" with the v1->v4 iteration table:
  * v1 2607063: name= kwarg -- FAIL (SP22 wants foldername)
  * v2 e07f281: positional + foldername= -- FAIL (silent None return)
  * v3 32e6120: project-level + cache removal -- FAIL (still None=fail)
  * v4 c87f3a9: walk children to detect side-effect success -- PASS
- Real root cause documented: SP22's create_folder returns void; the
  fix verifies by walking parent.get_children(False).
- Side benefit from v3 (ScriptManager cache dropped) noted as the
  hot-reload mechanism that made v4 reachable in one debug session.
- "Lesson for future fork work" callout: probably applies to other
  create_* methods too, audit pending.
- Inventory table row for create_folder updated with the c87f3a9
  link + a more accurate description of the strategy chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:25:22 +02:00
Karstein Kvistad
32e612000d fix(create_folder): try project.create_folder(name, SV_POU) first; drop ScriptManager cache
create_folder v2 (positional foldername) returned None silently against
the SP22 Application object -- no exception raised, no folder created.
Investigation showed:
  - ScriptObject.create_folder(foldername) is documented to "create a
    folder in the structured view of the parent node", but on Application
    specifically it's a silent no-op (the structured view isn't pinned to
    POU view there).
  - ScriptProject.create_folder(foldername, structured_view=None) on the
    project itself with explicit SV_POU GUID
    ({21AF5390-2942-461a-BF89-951AAF6999F1}) is the documented and
    reliable pathway -- the resulting folder appears under Application
    in the IDE tree because that's where SV_POU lives.

v3 fix: try strategies in order until one returns non-None:
  (1) primary_project.create_folder(name, SV_POU_GUID) -- new, primary
  (2) parent.create_folder(name) positional -- pre-SP21 path
  (3) parent.create_folder(foldername=name) -- alt keyword
  (4) primary_project.create_folder(name) -- default view
  (5) parent.create_object(typeUuid='85d1215e-...') -- alt factory
  (6) parent.add(script_engine.types.IecFolder, name=name) -- legacy
Each strategy guards on hasattr + return-value-not-None, so a silent
no-op falls through instead of being mistaken for success.

ScriptManager: dropped the in-memory template cache. Each loadTemplate
call now reads the .py from disk fresh. Cost: ~1ms per call vs ~1.5s
of CODESYS execution time -- invisible. Win: edits to dist/scripts/
take effect without an MCP restart, which makes iterating on script-
side fixes (like this very create_folder loop) much faster. Existing
"cache hit" unit test rewritten as "two loads return equal content".

tests/test-fixes.mjs: standalone harness that drives a single persistent
CODESYS through HeadlessExecutor + CodesysLauncher to verify the four
broken-tool fixes end-to-end. Useful for regression-testing without
needing a vsc reboot loop. Currently only smoke-tests
create_folder + compile + cross-project; expand as more fixes need
verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:16:44 +02:00
Karstein Kvistad
0fc23ef8cd docs(TEST_OVERVIEW): real bench numbers + mark broken tools as fixed
- Headline result table at the top: persistent is 15-24x faster than
  headless across all CODESYS-roundtripping tools. Numbers are from a
  fresh bench-results.json run on MCPTest2.
- Updated mode primer with measured first-call vs warm-call costs:
  persistent first launch ~14.6s, subsequent calls ~1.5s; headless
  first cold call ~58s, warm calls ~22s.
- Inventory table: replaced "typical" estimates with measured numbers
  for the 9 tools the bench covers.
- Deep-dive section: each broken tool now marked "fixed in 2607063"
  with concise summary of the fix that landed (instead of "proposed
  fix"). Removed the long pre-fix code blocks since they're in the
  commit history now.
- Status legend: create_folder, compile_project, get_compile_messages,
  connect_to_device, open_project all flipped from broken to fixed
  (with caveats: some need PLC or runtime verification).
- Footnote about set_pou_code bench-harness failure (multi-line code
  through triple-quoted-string interpolation -- not a tool bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:58:05 +02:00
Karstein Kvistad
2607063306 fix: 4 broken-tool fixes (compile json long, connect_to_device LoginMode,
create_folder SP21+ fallback, ensure_project_open cross-project switch)
+ bench results

compile_project.py + get_compile_messages.py:
- IronPython 2.7's json.dumps cannot serialize System.Int64-backed `long`
  values, which is what CODESYS's compile-message objects expose as
  line_number / position. Added _coerce_int + _coerce_str helpers and a
  shared _build_message_entry function. Three duplicated message-building
  blocks collapsed into single helper calls.
- Defensive `try: json.dumps(...) except TypeError: json.dumps(default=str)`
  so a stray field that slips past the helpers doesn't kill the emit.

connect_to_device.py:
- SP21+ may expose the login enum as LoginMode rather than
  OnlineChangeOption. Extended the candidate sweep to probe both
  script_engine.LoginMode and script_engine.OnlineChangeOption AND
  online_app.LoginMode/OnlineChangeOption (some builds attach it to the
  app object). Added "OnlineChange" + "Login" + "Download" to the
  preferred-priority list. Added a 3-arg call shape variant for SPs
  that take (mode, secondary-mode, force-bool).

create_folder.py:
- parent_object.create_folder() is no longer exposed on every parent type
  in SP21+. Added two fallback factories tried in order:
    1. parent.create_object(typeUuid='85d1215e-6520-4983-9a55-2d39d1f24cb4', name=...)
    2. parent.add(script_engine.types.IecFolder, name=...)
  with detailed warnings when each path fails. Final TypeError now lists
  every factory tried so a future SP rotation surfaces clearly.

ensure_project_open.py:
- Uncommented the close-prior-project branch (was a TODO since the
  initial fork). Cross-project switches in a persistent CODESYS now do
  save() -> close() -> 500ms pump -> open(target). Without this,
  projects.open against a different already-primary project fails
  intermittently on file lock contention or pops a "project in use"
  modal that freezes the IDE thread.
- save() is best-effort: if it raises (transient lock, save-as required)
  we still proceed with close + open rather than getting stuck in a
  half-switched state forever.

tests/bench-results.json:
- Captured timings from a clean run on MCPTest2 (PLCWinNT, 5 lib refs,
  ~12 POUs). 9 tools x 2 modes x iterations.
  Headers (mean ms): persistent vs headless --
    open_project              7700  vs   40041   (~5x; first call cold)
    mirror_export             1547  vs   23723   (~15x)
    list_project_libraries    1565  vs   23322   (~15x)
    get_all_pou_code          1607  vs   23376   (~15x)
    save_project              2095  vs   23321   (~11x)
    create_pou (FB)           1540  vs   23903   (~16x)
    delete_object             1544  vs   27420   (~18x)
    bump_project_version      1540  vs   30678   (~20x)
    bump_project_version #2   1556  vs   37769   (~24x)
- set_pou_code FAILED in both modes -- bench harness param-shape issue
  (multi-line code passed verbatim to triple-quoted-string interpolation
  doesn't survive the round-trip). Tool itself works fine through the
  MCP tool call path; bench needs to escape newlines / use the same
  prepareScriptWithHelpers shape the server uses. Filed for follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:53:25 +02:00
Karstein Kvistad
2e2ab00ae1 docs+test: TEST_OVERVIEW + bench harness for headless vs persistent
TEST_OVERVIEW.md: complete tool inventory (37 tools), each tagged
working / broken with status notes and per-mode timing characteristics.
Mode primer up top explains the per-call vs first-call cost asymmetry.

Deep-dive on every broken tool with proposed fixes:
  - create_folder: parent_object.create_folder() not exposed in SP21+;
    fall back to create_object(typeUuid=...) or types.IecFolder.
  - compile_project / get_compile_messages: IronPython 2.7 json.dumps
    can't serialize System.Int64 (line_number / position fields).
    Fix is a _coerce_int helper applied uniformly.
  - connect_to_device: SP21+ may expose the login enum as LoginMode
    instead of OnlineChangeOption. Extend the candidate sweep over
    multiple enum sources, with priority on TryOnlineChange-equivalents.
  - open_project (cross-project switch): ensure_project_open has the
    "close prior project" branch commented out; uncomment with a
    save+close+delay sequence and silent-mode guard.

list_project_libraries is flagged as  working in current SP22
(historical entries in the project memory should be cleared).

bench.mjs: standalone benchmark harness driving HeadlessExecutor and
CodesysLauncher directly (no MCP server in the loop). Copies the source
.project to a temp dir so write tools don't mutate the original. Covers
9 tools (read-only + write-revertible) with configurable iterations,
emits markdown to stdout + JSON to --out.

Run with:
  node tests/bench.mjs --modes headless,persistent --iterations 2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:41:52 +02:00
Luke
e374519fe1 Initial release: MCP server for CODESYS with persistent UI instance
v0.3.0 returning-watcher architecture — background thread polls for commands
and marshals execution onto the CODESYS UI thread, keeping the IDE fully
responsive between operations. File-based IPC with atomic writes, async mutex
command serialization, headless fallback, and 35 passing tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:59:16 +10:00