0
0
Fork 0
Commit graph

58 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
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
db688c204a feat(symbol-config): 10 new MCP tools for the Symbol Configuration object
Wraps ScriptSymbolConfigObject (ScriptLib/Stubs/scriptengine/ScriptSymbolConfigObject.pyi
and helpme-codesys.com/en/ScriptingEngine/ScriptSymbolConfigObject.html).

Discovery (read-only):
  find_symbol_config         locate the SymbolConfiguration object(s) in a project
  list_all_signatures        every POU/FB/Method (compile=true to force build)
  list_all_datatypes         every DUT/struct/enum/alias
  list_configured_symbols    only the variables actually configured for export,
                             with configured/maximal/effective access per variable
  get_symbol_config_settings every knob: feature flags, attr filter, comment
                             filter, direct I/O access (+ obstacles), layout calc

Setup:
  create_symbol_config       application.create_symbol_config(...) -- IDEMPOTENT,
                             no-ops with success if a symbol config already exists
                             anywhere in the project tree

Mutate:
  set_symbol_config_settings partial-update of any subset of the 6 knobs;
                             refuses to enable direct I/O if obstacles exist
  set_symbol_access          per-variable configured_access setter
  set_signature_access_bulk  expose every variable in one signature at once

Output:
  export_symbol_xsd          write the get_symbol_configuration_xsd() bytes

All 10 tools share a SYMCONF_HELPERS list (ensure_project_open +
find_symbol_config_object). Enum mapping for SymbolAccess /
SymbolConfigContentFeatureFlags / SymbolAttributeFilterTypes /
SymbolCommentFilterType lives partly in TS (string -> int), partly in
Python (probe enum class at runtime, fall back to int literal).

Tool count: 31 -> 41.

Plan: docs/superpowers/plans/2026-04-28-symbol-config-tools.md.
Live verification + README/function-test doc updates land in a follow-up.
2026-04-28 23:44:21 +02:00
Karstein Phobic Nyvold Kvistad
3e9f4b8403 fix(add_library): refuse hollow placeholder when library not installed in repo
The pre-resolve via library_manager.find_library() at line 218 already
detected when the requested name was not present in the installed
library repository -- it logged "Pre-resolve... returned no hit" -- but
the script then proceeded to call add_placeholder(LIBRARY_NAME) without
a managed-lib argument anyway. CODESYS happily creates such a reference
with is_placeholder=False, so the post-add _is_resolved() guard returns
True and the project gets saved with a hollow reference. The next time
the project is opened, the IDE pops:

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

...and compile fails until the user manually deletes the bad reference
from the Library Manager.

Karstein hit this on 2026-04-28 trying to add "OPC UA Pub Sub" (the
real library is "OPC UA PubSub SL", an add-on SL package not present
in the stock V3.5 SP22 install). The script returned SCRIPT_SUCCESS,
list_project_libraries showed it as `[managed]`, and only on the next
set_pou_code call did the broken-placeholder error surface.

Fix: when _resolve_in_repo returns None, hard-refuse upfront with a
clear error pointing at the Library Repository / CODESYS Installer.
Opt-in via ALLOW_UNRESOLVED=1 (mapped to the new MCP arg
`allowUnresolved: true`) for the rare case where a placeholder for a
not-yet-installed library is genuinely wanted.

Tool description and arg docs updated to mark allowUnresolved as
DANGEROUS so future agent calls don't reach for it casually.

Bumped 0.6.0 -> 0.6.1.
2026-04-28 23:05:32 +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
67384a7dd7 feat(cli): add --approve-edits flag (off by default; v0.1 plumbing)
Adds the user-facing --approve-edits surface area:
- ServerConfig.approveEdits?: boolean
- bin.ts: --approve-edits commander option, plumbs into config,
  emits 'Approve edits: ENABLED' banner when on
- server.ts: logs 'Approve edits: ON|off' at startup

v0.1 stops here. The actual gating wiring (set_pou_code -> phobiCS-tui
approve, only proceed on exit 0) lands in the next commit. Landing
the surface area now keeps the user-visible CLI stable while the
internals catch up.
2026-04-28 22:30:28 +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
f34d002601 feat(online): pre-register Device User credentials to suppress modal login dialog
WHY: connect_to_device and download_to_device against a password-protected
runtime pop a modal "Device User Login" dialog in the IDE. IronPython can't
marshal to the WPF UI thread to dismiss it, so headless / agent-driven
sessions block forever -- and even for interactive use, the dialog pops on
EVERY download, which is a constant friction point.

API: ScriptOnline.set_default_credentials(username, password) was added in
CODESYS scripting API 3.5.3.0. Effect lasts until end of the current script
execution. Source: https://content.helpme-codesys.com/en/ScriptingEngine/ScriptOnline.html

Implementation:

- New helper script src/scripts/register_device_credentials.py defines a
  register_device_credentials_if_set() function that no-ops when DEVICE_USER
  or DEVICE_PASSWORD is empty, gracefully skips on older SPs that lack
  set_default_credentials, and never raises (always falls back to the
  current dialog-prompting behaviour).

- connect_to_device.py and download_to_device.py call the helper as the
  FIRST action inside their try blocks, before ensure_project_open and
  any login() attempt, so credentials are registered before any code path
  that could trigger the dialog.

- server.ts adds optional deviceUser / devicePassword args to both tools'
  input schemas. Resolution order:
    args.deviceUser     (per-call override)
    -> process.env.CODESYS_DEVICE_USER
    -> '' (empty, dialog pops as before)
  Same for devicePassword. Env-var path is the recommended config:
    claude mcp add -s user codesys-sp22-patch1 \
      -e CODESYS_DEVICE_USER=Karstein \
      -e CODESYS_DEVICE_PASSWORD=codesys123 \
      -- codesys-mcp-sp21-plus --codesys-path ... --codesys-profile ... \
         --mode persistent --no-auto-launch

Backward compat: when both creds are empty (default for existing users),
the helper short-circuits and behaviour is byte-identical to 0.5.x. No
regression. Verified by smoke-testing prepareScriptWithHelpers locally
with both filled and empty inputs -- function definition + callsite are
both wired in either case; only set_default_credentials() is suppressed
when empty.

README updated for connect_to_device and download_to_device tool rows.
Version bumped 0.5.0 -> 0.6.0.
2026-04-28 21:41:31 +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
c1592f40a0 fix(server): keep executionMode=persistent under --no-auto-launch
Previously, '--mode persistent --no-auto-launch' silently downgraded the
reported executionMode to 'headless' until launch_codesys was called.
get_codesys_status would then mislead the user into thinking they were
in headless mode despite having configured persistent.

Now executionMode tracks the configured intent. The deferred-launch
state is communicated via 'State: stopped' instead. Tool calls before
launch_codesys still route through HeadlessExecutor as a best-effort
fallback.
2026-04-28 17:20:15 +02:00
Karstein Phobic Nyvold Kvistad
722c09ed70 feat(ssh): restart_runtime_ssh -- restart codesyscontrol over SSH (password auth + sudo -S + port-listen liveness probe)
WHY: an unlicensed CODESYS Control runtime drops out of demo mode every 2
hours. systemctl is-active reports "active" even after the binary has
died, so a TCP probe on the runtime port (default 11740) is the only
honest liveness signal. The new tool gives MCP a one-call path to bring
the runtime back without dropping into a terminal.

Implementation choices:

- ssh2 (npm) instead of spawning ssh/sshpass: sshpass is not on the
  default Windows path, and the target Pi's sshd 10.x rejects pubkey
  signatures from this client environment in practice. ssh2 handles
  password auth + remote stdin + exit-code capture cross-platform.
- sudo -S with the password fed on remote stdin -- avoids a NOPASSWD
  sudoers entry on the PLC.
- After issuing the restart, polls 'ss -tln | grep :<port>' once per
  second until the listen port is up or livenessWaitSeconds expires.
  This is what catches a half-dead runtime that systemctl reports as
  fine.

Defaults match the only Pi we currently target (codesys-pi.local /
karstein / codesys123 / codesyscontrol / port 11740) but every field
is overridable.

Smoke-tested against codesys-pi.local: restart exit 0, port back up
after ~3s.
2026-04-28 16:50:19 +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
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
0fa9b3852b feat(auto-mirror): wrap 13 modifying tools so --auto-mirror actually works
The 75cf74d scaffold added the helpers; this commit makes them
load-bearing by switching every modifying tool's formatToolResponse
call to formatModifyingResponse. Without --auto-mirror, behaviour is
unchanged. With it: mirror_export runs after each successful edit and
'code --add <mirror>' fires once per project to surface the diff in
VSCode's Source Control panel.
2026-04-27 21:53:10 +02:00
Karstein Phobic Nyvold Kvistad
5be20a6e3b remove(codesys-git): drop all 6 CODESYS Git plugin tools
The CODESYS Git plugin wrappers (git_init, git_status, git_commit,
git_remote_add, git_branch_set_upstream_to, git_push) operated on the
binary .project file via CODESYS's IDE-side Git plugin. Drawbacks:

- Required a CODESYS Professional Developer Edition subscription
  (HasGitLicense gate). Anyone without PDE got a fail-fast error on
  every call -- the tools were dead weight for most users.
- Operated on a separate dual-storage repo (the .project stayed put,
  the git repo lived in a sibling directory). Diffs were unreadable
  because they're binary serialisations, not source text.
- Couldn't run on UNC paths -- the plugin rejected them.
- Duplicated functionality release_project_version already provides
  via the system git binary against the source-mirror tree (which IS
  human-readable diff-able .st files).

Removing all 6 tools, all 6 .py templates, and the README section.
Tool count drops 37 -> 31. release_project_version remains the
recommended path for CODESYS-project version control: mirror_export
gives you readable diffs in mcp-mirror/, then standard git commits
+ tags + push, no PDE license required.
2026-04-27 21:43:52 +02:00
Karstein Phobic Nyvold Kvistad
75cf74d317 scaffold(auto-mirror): MirrorCtx + maybeAutoMirror + VSCode integration helpers
In-flight scaffolding for the --auto-mirror feature. Adds:

- ServerConfig.autoMirror flag, wired to --auto-mirror CLI option
- MirrorCtx (autoMirror, scriptManager, executor, workspaceDir,
  openedInVscode set, vscodeCli path)
- findVscodeCli() probes PROGRAMFILES/LOCALAPPDATA/PROGRAMFILES(x86)
  for the code.cmd shim
- maybeOpenMirrorInVscode() spawns 'code --add <mirror>' detached,
  once per mirror dir per session
- maybeAutoMirror() runs mirror_export after a successful edit and
  triggers the VSCode add
- formatModifyingResponse() wrapper around formatToolResponse +
  maybeAutoMirror

No tool wrappers yet -- this is dead code until the modifying tools
are switched from formatToolResponse to formatModifyingResponse.
Doing that as a separate commit so the diff is reviewable.
2026-04-27 21:29:55 +02:00
Karstein Phobic Nyvold Kvistad
2c7eeccb94 fix: drop dead sp21-plus-migration-notes branch refs from generated md + smoke-test doc
Branch was deleted after main caught up. server.ts auto-generates
library.md and pou-dump.md headers in user projects, so the broken
URL was leaking into every consumer of those tools. Now points at
the repo root (main is the only branch).
2026-04-26 22:56:43 +02:00
Karstein Phobic Nyvold Kvistad
888a035c0c feat(list_project_libraries): capture project compiler version
Calls primary_project.get_compilerversion() (ScriptEngine 4.2.0.0+) and
emits the result through the JSON payload. Renders as:

  - library.md: a 'Project compiler version' row in the Versions table
  - list_project_libraries chat output: a 'Compiler version: X.Y.Z.W'
    line in the header section

Motivation: changing the project's compiler version (Project > Project
Settings > Compiler version, or set_compilerversion_to_newest()) only
touched the .project binary -- mirror_export couldn't see it, so the
release classifier had to fall back to SHA comparison and emitted the
generic 'device-tree / library refs / task config / visu / Save() touch'
classification. Compiler-version changes now leave a textual diff in
mcp-mirror/library.md, letting the classifier issue an honest revision
bump instead of the bare build-bump SHA fallback.

Defensive: get_compilerversion() is wrapped in try/except so older
ScriptEngines (< 4.2.0.0) that lack the method don't crash the tool;
they just emit compiler_version=null and the field is omitted from
output.
2026-04-26 19:18:31 +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
26260ac43d fix(release_project_version): preserve real newlines in tag body via -F tempfile
The dual-SHA tracking commit (146d950) wrote the tag annotation with
`git tag -a -m JSON.stringify(body)`. JSON.stringify escapes newlines
as the literal two-char sequence "\n", and the shell passes those
through unchanged -- so git stored the body as one big line with
literal "\n" chars instead of real LF bytes.

The reader (readTagShas) used a multiline regex anchored on `^` and
`$`, which doesn't match across literal "\n" -- so v1.3.2.0's tag was
written with SHAs in the body but they're invisible to the next
release's read-back.

Two fixes:
  - Reader (readTagShas): normalise literal "\n" sequences to real
    newlines before applying the regex. Backward-compatible -- handles
    the v1.3.2.0 tag transparently and works on properly-formed tags
    from v1.3.3.0 onward too.
  - Writer (release_project_version step 8): write the body to a
    temp file and use `git tag -F <tempfile> --cleanup=verbatim` so
    real LF bytes go in. Also adds os import for os.tmpdir().

Verified locally: dist/server.js loads cleanly. End-to-end behaviour
confirmable on the next release_project_version call against any
project.
2026-04-26 18:14:33 +02:00
Karstein Kvistad
146d950e17 feat(release_project_version): dual SHA-256 tracking for binary + mirror
Implements bidirectional change detection for the release pipeline.

Two SHA-256 fingerprints are now stored in every release tag's annotated
body:
  project-sha256: <hash of the .project binary>
  mirror-sha256:  <hash of the mcp-mirror/ tree>

On the next release_project_version call, these are read back via
git cat-file -p <prior-tag> and compared against the current values
to detect three classes of change that the mirror-only diff missed:

  (a) binary changed AND mirror unchanged (working tree, before
      mirror_export). Normal "user edited in IDE" path. Classifier
      handles this as it always did.

  (b) binary unchanged AND mirror changed (working tree, before
      mirror_export). User edited .st files in mcp-mirror/ directly
      with a text editor. mirror_export is about to overwrite those
      edits, so we surface a WARNING in the release log. Future:
      a mirror_import tool would push these back into the binary;
      until then, mirror is one-way (binary -> mirror).

  (c) binary changed AND mirror UNCHANGED after mirror_export. The
      .project binary has a non-textual change that mirror_export
      doesn't capture: device tree, library refs, task config,
      visualizations, OPC UA / symbol config, application composer,
      or just a Save() touch (CODESYS embeds timestamps). Classifier
      sees no diff but project SHA flipped. Promote 'no-changes' to
      a build-level bump so the version still ticks. The Changelog
      entry calls out the SHA-fallback evidence so it's visible in
      review.

Helper functions added at module scope:
  - sha256OfFile(filePath): single-file SHA-256.
  - sha256OfDirectory(dirPath): deterministic tree walk, sorts
    entries by name, hashes (relative-path, content) pairs separated
    by NULs.
  - readTagShas(projectDir, tagName): parses project-sha256 /
    mirror-sha256 lines out of an annotated tag body. Returns
    undefined for either field if missing -- gracefully handles
    older tags that don't carry the fingerprints.

The dual-SHA approach was suggested by the user after observing that
a manual edit to MCPTest2.project (made via the IDE) wasn't surfaced
by the mirror-only classifier when the change happened to be in a
non-mirrored region (likely device tree or library refs).

Verification pending: needs a vsc reboot to load the new server.js
into the running MCP process. Once reloaded, the next call against
v1.3.1.0 should:
  - Read priorShas from the v1.3.1.0 tag (likely empty since this
    is the first release with the new tag format).
  - Treat empty priorShas as "no info, can't fall back" and behave
    exactly like the pre-fix orchestrator. So nothing breaks.
  - Write project-sha256 + mirror-sha256 into the v1.3.2.0+ tag bodies.
  - From v1.3.2.0 onward, all three change-detection cases work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:04:55 +02:00
Karstein Kvistad
53c7a0c27d fix(release_project_version): post-bump sanity check (newVersion > latestTag)
Defensive guard against silent version regressions that the in-script
pi-vs-GVL cross-check can miss when CODESYS's in-memory project tree
is stale (both pi.version and the GVL read come from the same in-memory
source -- a stale tree gives consistent-but-wrong values that defeat
the cross-check).

Two observed regressions on MCPTest2 went undetected by the in-script
check and only failed at git-tag time:
  - 2026-04-26 v1.0.4.0 (script saw 1.0.3.0, on-disk was 1.2.0.0)
  - 2026-04-26 v1.1.0.0 (script saw 1.0.0.0, on-disk was 1.2.1.0)

This patch adds an orchestrator-side check after bump_project_version
parses its result: re-read the latest v* tag with `git describe`,
compare lexicographically (4-tuple int compare) against the new
version, and abort with a detailed recovery message if the new value
is not strictly greater. The abort happens BEFORE the post-bump
mirror_export, library.md/pou-dump.md regen, README rewrite, Changelog
append, and any git ops -- so no bad state gets published.

The .project binary on disk has still been mutated with the regressed
value at this point (bump_project_version saves at the end), but the
recovery path is well-understood: shutdown + relaunch + reopen to
clear the stale in-memory tree, then retry. Recovery instructions are
included in the error message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:34:44 +02:00
Karstein Phobic Nyvold Kvistad
c50970b15e classifier + orchestrator: see untracked files, re-mirror after bump
Two real bugs surfaced from the MCPTest2 v1.1.0.0 -> v1.2.0.0 round.

Fix 1: classifier didn't see untracked files (CLASSIFICATION GAP)
   Adding FB_Position + FB_Random5s via create_pou + mirror_export
   produced new untracked .st files in mcp-mirror/. The classifier
   ran `git diff --name-status` which ONLY reports tracked changes;
   the new files were invisible until git-added. Result: classifier
   counted only 1 'modified' (PLC_PRG, the wiring update) instead
   of 1 modified + 2 added, and resolved 'revision' instead of
   'minor'. Added a `git ls-files --others --exclude-standard --
   mcp-mirror/` pass that pulls untracked files and tags them as
   adds (`added (untracked): <path>`). Now create_pou + release
   correctly classifies as minor.

Fix 2: orchestrator didn't re-mirror after bump (DESYNC)
   Pipeline was: mirror_export -> classify -> bump -> regen md ->
   git commit. mirror_export ran BEFORE the bump, so the captured
   mcp-mirror/_MCP_PROJECT_VERSION.st reflected the pre-bump GVL
   value. Then bump updated the in-memory GVL + saved the .project
   binary. Then commit went out with mcp-mirror at the OLD value
   while the binary already had the NEW value. Two consequences:
     - Next release call sees _MCP_PROJECT_VERSION.st as 'modified'
       vs the just-pushed v* tag (because the next mirror_export
       pulls the post-bump value, which now differs from the
       still-pre-bump mirror in the tag), triggering another bump.
     - The runtime-anchor on disk and the runtime-anchor inside the
       running PLC binary were silently desynced from the docs.
   Surfaced as MCPTest2 v1.1.0.0 (8d79193) shipping with binary
   GVL = 1.0.2.0 while docs said 1.1.0.0; resolved by the v1.2.0.0
   re-sync (1513e9c).
   Added a SECOND mirror_export call right after the bump, before
   regenerating library.md / pou-dump.md / Changelog. Soft-fails
   with WARNING -- the bump itself already succeeded, post-bump
   mirror is the documentation step.

Together these two fixes make release_project_version end-to-end
deterministic: 1 release call -> 1 release commit, no manual finish,
no re-bump on the next call. Verified offline: the path of the new
untracked-detection through ls-files --others, plus the second
mirror_export, give the orchestrator the post-bump state it
previously lacked.
2026-04-26 02:12:36 +02:00
Karstein Phobic Nyvold Kvistad
9854c31b66 parseBumpedVersion: handle '(skipped -- node missing)' from-version
The Python bump script emits 'Project Information.Version: <before>
-> <after>' usually, but when there's no Project Information node
(Standard-template projects -- created via create_project), the
output line becomes 'Project Information.Version: (skipped -- node
missing) -> <after>'. The previous regex used \S+ for the before-
group, which choked on the '(skipped' token (whitespace inside the
parenthetical broke the boundary).

Surfaced on MCPTest2 today during the FB_Position + FB_Random5s
release. release_project_version's bump succeeded (1.0.1.0 -> 1.1.0.0
via the GVL-resume path), but the orchestrator returned 'bump
succeeded but new version could not be parsed' because parseBumpedVersion
returned null and the post-bump pipeline (Changelog + library.md +
README + git ops) never ran.

Fix: non-greedy (.+?) capture for the from-group, treat
parenthesised values or 'none' as null. Also added a Runtime-anchor
fallback regex in case future Python changes alter the metadata
line shape -- the runtime-anchor line carries the same to-version
and is more stable.
2026-04-26 01:57:48 +02:00
Karstein Phobic Nyvold Kvistad
a570132c9c classifyMcpMirrorChanges: ignore CRLF + whitespace-only diffs
Real bug surfaced on X33 (commit 6c23e38 on karstein.kvistad/x33,
reverted in 3e6f12f): the classifier called git diff --name-status
without any whitespace flags, so a fresh checkout that re-normalised
.st files from LF to CRLF (Windows working copy via Samba share)
showed every file as M. The orchestrator obediently bumped the
project to v1.0.1.0 with no actual code change, committed,
tagged, pushed -- a phantom release.

Fix: add --ignore-cr-at-eol AND -w to the git diff invocation so
the classifier only reports diffs with real content changes.

  --ignore-cr-at-eol  ignore the carriage-return at the end of line
                      when comparing lines (handles CRLF<->LF flips)
  -w                  ignore whitespace differences entirely
                      (defensive; protects against stray blank
                      lines and indent normalisation that aren't
                      real changes)

The companion fix is to also add a .gitattributes to each project
that pins the .st files to a stable line-ending in storage so the
phantom diffs don't appear in the first place. That's a per-project
artefact, shipped alongside the project repos (X33 + MCPTest2)
rather than this fork.
2026-04-26 01:12:43 +02:00
Karstein Phobic Nyvold Kvistad
3212996cf1 feat(release_project_version) + Changelog timestamp HH:MM
Two changes bundled:

1. Adds release_project_version, the one-shot orchestrator that runs
   the whole sync from a CODESYS code change to a tagged + pushed
   git commit. Sequence:

     mirror_export                refresh mcp-mirror/
     classifier                   diff vs latest v* tag
     -- if no changes              short-circuit, no commit
     bump_project_version         resolved-level bump
     Changelog.md                 append entry with classification
     list_project_libraries       regen library.md as markdown
     get_all_pou_code             regen pou-dump.md as markdown
     README.md                    regex-replace v<old> -> v<new>
     git add                      mcp-mirror, .md files, .gitignore,
                                    .project binary
     git commit                   "release v<new> (label)"
     git tag                      v<new> -a + message
     git push --follow-tags       (configurable via push arg)

   This is the standard the project README points at: "ask Claude to
   run release_project_version after every confirmed change in
   CODESYS." All four sources of truth (Project Information.Version,
   _MCP_PROJECT_VERSION.sVersion, Changelog.md, v* git tag) move
   together in one call.

   Markdown rendering helpers (renderLibraryMd, renderPouDumpMd,
   gfmSlug) are extracted to module level so the orchestrator can
   call them directly. The list_project_libraries tool's response
   formatting still uses inline rendering since it returns plain
   text; markdown is for the on-disk artefact only.

2. Changelog entry timestamp now includes HH:MM in local time
   (YYYY-MM-DD HH:MM, no seconds, no TZ suffix). User feedback:
   date alone was too coarse to distinguish multiple bumps in the
   same day. Format chosen for compact heading + sort-friendly +
   no timezone-conversion friction. appendChangelogEntry handles
   both auto-mode (called from bump_project_version --auto) and
   manual-mode (called from release_project_version directly).
2026-04-26 00:59:10 +02:00
Karstein Phobic Nyvold Kvistad
634e273aab bump_project_version: auto-maintain Changelog.md alongside the bump
After every successful bump (auto or manual), append an entry to
<projectDir>/Changelog.md describing the change. Newest entries at
the top under a one-time intro header. Each entry carries:

  ## v<X.Y.Z.W> -- YYYY-MM-DD (<label>) [(from `<prev>`)]

  - <evidence bullet 1>
  - <evidence bullet 2>
  ...

Where:
  <label> is one of:
    seed              -- first-run, no prior version
    auto: <level>     -- classifier resolved to <level>
    manual: <level>   -- explicit level passed
  <evidence> is the same classification list shown in the tool
    response (D/R/A/M file paths from the mcp-mirror/ diff against
    the latest v* tag); for manual bumps it's empty + a "(no
    classification evidence -- manual bump)" placeholder line.

Soft-fail: any I/O error during Changelog write logs a warning but
does not fail the bump itself -- the Project Information.Version
update has already succeeded by the time this runs, and the
Changelog is documentation, not state-of-truth.

Format choices:
  - File path: <projectDir>/Changelog.md (alongside README.md /
    library.md / pou-dump.md, mirroring the existing convention).
  - Heading style: H2 ## per version, H1 # for the file title only.
    Matches GitLab GFM auto-anchor expectations.
  - Date: ISO YYYY-MM-DD in UTC, so collaborators in different
    timezones see the same date for the same bump.
  - Insertion: before the first existing ## v<...> heading, after
    the intro. So a chronological reader sees newest first.
  - Versions explicitly cross-referenced to the runtime anchor
    (_MCP_PROJECT_VERSION.sVersion) in the intro so the reader knows
    a Changelog entry == a value the running PLC will report back
    via read_running_version_online.

Now rounds out the version-tracking convention end-to-end:
  Project Information.Version           (offline metadata)
  _MCP_PROJECT_VERSION.sVersion         (runtime anchor in IEC code)
  Changelog.md                          (human-readable history)
  v* git tag                            (machine-readable history /
                                          classifier baseline for
                                          subsequent auto-bumps)

All four move together on every bump.
2026-04-26 00:34:40 +02:00
Karstein Phobic Nyvold Kvistad
e29bb7e534 feat(read_running_version_online): read _MCP_PROJECT_VERSION from running PLC
New MCP tool that reads the running project's version from a
connected PLC over the CODESYS online protocol (port 11740 / gateway).
Returns the value of `_MCP_PROJECT_VERSION.sVersion` -- the runtime
anchor that bump_project_version maintains automatically (commit
00d2dd8). Closes the loop on the version-tracking convention:

  bump_project_version           writes _MCP_PROJECT_VERSION.sVersion
                                 into the project (compiled into the
                                 boot application on next download)
  read_running_version_online    reads the same symbol back from the
                                 running PLC over the online protocol

Pairs with the existing connect_to_device + read_variable pattern
but with sharpened error messages tailored to the version-read use
case:
  - missing GVL                  -> 'has bump_project_version run on
                                     this project? Or has the boot
                                     application not been downloaded
                                     since the bump?'
  - read returns None            -> 'variable exists in project but
                                     not in boot app -- download_to_device
                                     after the last bump.'
  - missing online API           -> typed clearly, suggests SP-version
                                     drift.

Implementation: ensure_project_open + ensure_online_connection +
online_app.read_value('_MCP_PROJECT_VERSION.sVersion'); strips quote
characters from the returned STRING; sanity-checks the shape against
\b\d+\.\d+\.\d+\.\d+\b and warns if it doesn't match the
4-part convention. TS handler extracts the matched RUNNING_VERSION
line from the script output and surfaces it as the headline of the
tool response.

SSH transport variant (read_running_version_ssh) lands as a separate
later commit once a real PFC is reachable to test against.
2026-04-26 00:33:22 +02:00
Karstein Phobic Nyvold Kvistad
00d2dd8d96 bump_project_version: also maintain _MCP_PROJECT_VERSION GVL
Establishes the runtime-readable version anchor convention. Every
bump (manual or auto) now ALSO ensures the Application has a GVL
named '_MCP_PROJECT_VERSION' with:

  {attribute 'qualified_only'}
  VAR_GLOBAL CONSTANT
      sVersion : STRING := '<X.Y.Z.W>';
  END_VAR

Created on first bump; updated in place thereafter. Soft-fails if
the Application object can't be found or create_gvl() raises -- the
primary outcome (Project Information.Version updated and saved) has
already happened by the time GVL maintenance runs, so a GVL hiccup
is logged as a WARNING but doesn't fail the whole tool.

Why this matters:
  - Project Information.Version is metadata. The running PLC binary
    embeds it but exposing it at runtime requires the auto-generated
    Project_Info library helpers (GetVersion etc.), which not every
    project has wired up.
  - A plain VAR_GLOBAL CONSTANT in a known-name GVL is the simplest,
    most portable runtime anchor. Any IEC code can read it as
    `_MCP_PROJECT_VERSION.sVersion`. The future read_running_version_online
    tool will pull it via online connect + read_variable. The future
    SSH transport variant can pull it via libcmd-symbol-export or by
    grepping a debug log line that the project author can wire to
    write at startup.

qualified_only is set so the symbol can't accidentally shadow a
same-named local in user code.

The GVL convention will be exercised end-to-end on MCPTest running
on the local soft PLC (port 11740) once the read_running_version_online
tool ships -- that's the next ship in this sequence.
2026-04-26 00:31:26 +02:00
Karstein Phobic Nyvold Kvistad
bd06fe76d7 bump_project_version --auto: short-circuit when no mirror changes
Previously, level=auto with no diff against the latest v* tag still
called the Python bump with level='build', incrementing 1.0.0.0 to
1.0.0.1 even though nothing in mcp-mirror/ had changed since the
baseline. That's wrong -- 'no changes' should mean 'no bump'.

Refactored the classifier to return a tagged ClassifyResult:

  kind: 'no-changes'   -> short-circuit, no Python call, return a
                          'no version change' message with evidence.
  kind: 'first-run'    -> no v* tag yet (or not a git repo); call
                          Python with level='build' which triggers
                          the seed-at-1.0.0.0 path when Version is
                          unset.
  kind: 'bump'         -> resolved level + evidence; call Python.

Test against X33 right now: latest tag is v1.0.0.0, mcp-mirror/ has
no changes against that tag, so auto would correctly return 'no
version change' without bumping.
2026-04-26 00:28:28 +02:00
Karstein Phobic Nyvold Kvistad
5451ddf1e6 bump_project_version: add level=auto with mcp-mirror/ git-diff classifier
Replaces the manual 'pick the right level' workflow with automatic
classification driven by git-diff over the project's mcp-mirror/
folder. Today's standard is the only one we care about (no project
has version-tracking hooked up before this fork shipped it), so the
classifier looks at exactly the artefacts the MCP itself writes.

Classifier rules (file-granularity in v1):

  any D (delete)  or R (rename)  -> major   (public symbol gone)
  any A (add)                    -> minor   (new public symbol)
  any M (modify)                 -> revision (internal change)
  no changes / no v* tag         -> build   (also triggers the
                                              Python-side seed-at-
                                              1.0.0.0 first-run path
                                              when Version is unset)

When level=auto:
  1. Resolve the project's parent directory.
  2. Verify it's a git repo (fall back to 'build' if not).
  3. Find the latest v* tag via `git describe --tags --abbrev=0
     --match "v*"`.
  4. `git diff --name-status -M50% <tag> -- mcp-mirror/` and tally
     D/R/A/M counts.
  5. Resolve to one of major/minor/revision/build per the rules above.
  6. Pass the resolved level to the existing Python bump script.

The classification evidence (each D/R/A/M file path) is included in
the tool response so the user can audit the decision -- 'why did this
bump revision and not minor?' has a one-line answer.

Future iterations: split each modified .st file at its
`(* === IMPLEMENTATION === *)` separator and distinguish decl-only
changes (minor / major) from impl-only changes (revision); also wrap
this into a release_project_version orchestrator that re-runs
mirror_export, regenerates library.md, updates the README header,
and tags + pushes the resulting commit. Out of scope for this commit.
2026-04-26 00:26:53 +02:00
Karstein Phobic Nyvold Kvistad
e37a2191a9 list_project_libraries: capture + render project version + IDE + devices
Enriches the tool's output with project-level metadata that previously
lived only in hand-edited library.md headers:

  Project info:
    Version: 1.0.0.0          (Project Information.version)
    Title:   ...              (Project Information.title)
    Company: ...              (Project Information.company)
    Author:  ...              (Project Information.author)
  IDE:    CODESYS V3.5 SP22 Patch 1, ScriptEngine.plugin 4.2.0.0
  Devices (N):
    MainPLC                   [4096 / 1006 120D / 6.2.0.1]
    MainPLC/Kbus              [32778 / Wago 750-Series Local Bus Interface / 2.1.0.1]
    ...

Implementation:
  - collect_project_info() reads .version / .title / .company / .author
    on the Project Information node (first child of project root). Each
    field is read defensively (try/except) since some installs leave
    them unset; missing fields are dropped from the output.
  - collect_devices() walks the tree depth-first for nodes where
    is_device is True, captures get_device_identification() into a
    type/id/version triple. The triple is the offline target id the
    IDE uses to pick a compiler + runtime when building -- not the
    live firmware reported by a connected PLC over a runtime
    connection (the latter would require an online connect).
  - sys.version inside IronPython under CODESYS reports the IDE
    version directly (same string we see in ready.signal).

server.ts renders these as a Header block above the existing
library-by-container tables. Hand-edited X33/library.md "Versions"
section is now redundant -- next regeneration will produce the
header automatically.

Verified via the local SP22 install + the live X33 watcher: pi.version
read-back works after bump_project_version sets it, devices walk
returns 13 entries on X33 (MainPLC + 11 Kbus modules + the network
adapter).
2026-04-26 00:12:06 +02:00
Karstein Phobic Nyvold Kvistad
75d77e2fb2 bump_project_version: seed at 1.0.0.0 on first run
Per user feedback. Previous behaviour treated 'no version yet' as
0.0.0.0 and bumped from there, so the very first call with level=build
produced 0.0.0.1 -- awkward as a canonical starting point. Most
projects start tracking at 1.0.0.0 the moment they turn on versioning.

New behaviour: if Project Information.version is None / empty / '0.0.0.0',
the tool seeds the value at 1.0.0.0 directly and ignores the level
argument for that one call. Subsequent calls bump per the level as
before.

Test cases:
  None     + level=build    -> 1.0.0.0  (seed)
  None     + level=major    -> 1.0.0.0  (seed)
  ''       + level=minor    -> 1.0.0.0  (seed)
  0.0.0.0  + level=revision -> 1.0.0.0  (seed)
  1.0.0.0  + level=build    -> 1.0.0.1
  1.0.0.0  + level=minor    -> 1.1.0.0
  1.2.3.4  + level=major    -> 2.0.0.0
2026-04-26 00:08:26 +02:00
Karstein Phobic Nyvold Kvistad
2ed3f17dc7 feat(bump_project_version): bump Project Information.Version one part
New MCP tool that increments one part of the 4-part
Project Information.Version field of the primary project, saves the
project, and reports the before/after.

Behaviour:
  - level=major   -> bump major,    reset minor/revision/build to 0
  - level=minor   -> bump minor,    reset revision/build to 0
  - level=revision-> bump revision, reset build to 0
  - level=build   -> bump build only

Convention follows the rest of CODESYS / 3S / WAGO library practice
(visible in any X33 library reference like 'WagoAppCanLayer2,
1.6.1.4 (WAGO)'):
  Major     -- incompatible API break (rename FB, change public
               signature, remove method).
  Minor     -- backward-compatible feature add.
  Revision  -- bug fix only, no API change.
  Build     -- internal / CI counter, often 0 for hand-released.

Implementation notes:
  - Project Information lives as the first child node of the project
    root. Its .version property is read/written directly; IronPython
    coerces strings like '1.2.3.4' to a System.Version on assignment,
    str(System.Version) gives the dotted form back. None / empty /
    unset is treated as '0.0.0.0'.
  - Verified live against X33 (MRCodesysX33_0021): set version to
    '1.0.0.0' from None, project.save() persisted it; reload via
    primary_project.get_children() found the same value. Probe done
    via the inject-once.mjs bridge against the live watcher in PID
    23056 before this commit landed.
  - project.save() is called after the bump so the new value sticks
    in the .project file. Soft-fails on save error (visible WARNING
    in DEBUG output but the bump itself is still reported as
    successful) so a save permission glitch doesn't mask the actual
    version change.

Used by the X33 GitLab project's "version in README header" workflow:
the version surfaces at the top of README.md (and at the top of the
library list once list_project_libraries gets enriched in a follow-up
commit).
2026-04-26 00:07:36 +02:00
Karstein Phobic Nyvold Kvistad
0a4c1a0de8 mirror_export: default mirror root to <projectDir>/mcp-mirror
Drops the redundant MCP/ folder layer. Previous default put the
mirror at <projectDir>/MCP/mirror -- two levels deep with a folder
called "MCP" containing exactly one item called "mirror". After
real-world usage (X33 layout reorg this session) the cleaner default
is <projectDir>/mcp-mirror -- one level, descriptive name.

Existing X33 layout was already migrated:
  X33/MCP/mirror/  ->  X33/mcp-mirror/      (renamed)
  X33/MCP/library.md  ->  X33/library.md    (moved up)
  X33/MCP/pou-dump.md  ->  X33/pou-dump.md  (moved up)

Tool description and the mirrorRoot arg description updated to match.
2026-04-25 23:29:11 +02:00
Karstein Phobic Nyvold Kvistad
76b7cf485a feat(mirror_export): switch mirror file extension to .st
Match the de-facto community convention. .st (IEC 61131-3 Structured
Text) is what the existing PLC tooling around forge.codesys.com,
ArthurkaX/cds-text-sync, and the VS Code "IEC Structured Text"
extension already syntax-highlight by default. The previous .iecst
was specific but unhelpful: nothing else in the toolchain knew what
to do with it.

User asked for the switch right after Phase 1 landed (commit 7a6e725).
Re-ran the export against MRCodesysX33_0021 (X33) on disk: 91 files
re-emitted as .st, identical content + tree shape, total 254 KB.
2026-04-25 22:59:25 +02:00
Karstein Phobic Nyvold Kvistad
7a6e7254a6 feat(mirror_export): write the project tree out as a browseable .iecst mirror
Phase 1 of the "CODESYS project as a filesystem you can edit + diff +
ai-tooling against" idea. Today the only way to read code in a project
is via a one-shot get_all_pou_code dump (one giant JSON) or by clicking
through the IDE; neither is friendly to AI-assisted edits, code review,
git diffs or external tooling. mirror_export walks the live project
tree and emits one .iecst file per code-bearing object, preserving the
project's folder structure as nested directories on disk.

What the tool does:

  - Walks every node from script_engine.projects.primary.get_children()
    recursively (depth-first).
  - Structural nodes (Device, Application, Folder, ...) become
    directories under MIRROR_ROOT.
  - Code-bearing nodes (Program, FB, Function, Method, Property, DUT,
    GVL, Interface, ...) become <name>.iecst files in their parent
    directory; if a code-bearing node has child code objects (e.g. an
    FB with methods), those children land in a sibling subdirectory
    with the parent's name.
  - File header: `(* === CODESYS export -- KIND === *)` + project path
    + generated timestamp, so a future write-back tool can map each
    file back to set_pou_code's pouPath.
  - Body: declaration block, then `(* === IMPLEMENTATION === *)`
    separator (when both are present), then implementation block.

Defaults the mirror root to `<projectDir>/MCP/mirror` so it lands next
to the existing library.md / pou-dump.md if the user has been
following the same folder convention.

Implementation details that would have bitten without testing:

  - UTF-8 output via codecs.open. CODESYS POU text occasionally
    contains non-ASCII (smart quotes from copy-paste, degree signs,
    etc.). IronPython 2.7's builtin open() defaults to ASCII and
    would raise; saw it on X33's ST_HetronicIn2 (smart quote) until
    fixed.
  - Filesystem-illegal characters in CODESYS object names (`/`, `\`,
    `<>:"|?*`) replaced with `_`. CODESYS lets you name a folder
    "Remote / Hetronic"; on Windows that splits as two folders with
    naive os.path.join.
  - Kind classifier strips leading `//` and `(* *)` comments and
    `{attribute := '...'}` pragmas before matching the IEC keyword,
    otherwise GVLs decorated with attribute pragmas got bucketed as
    OTHER (X33 had 2 of these; first try misclassified them).

Verified against MRCodesysX33_0021 (X33): 91 files, 254 KB, 0 errors,
preserves the full tree (MainPLC/Plc Logic/Application/MRLib/...).

Phase 2 (write-back via a `sync_pou_from_file` tool that reads a
mirror file, splits decl/impl on the IMPLEMENTATION separator, and
calls set_pou_code) is out of scope for this commit.
2026-04-25 22:33:50 +02:00
Karstein Phobic Nyvold Kvistad
9b766c8b6b fix(list_project_libraries): use ScriptLibManObjectContainer API
RTFM. Per the helpme-codesys.com Library Manager scripting page and
the local SP22 stub Stubs/scriptengine/ScriptLibManObject.pyi:

- ScriptLibManObjectContainer is added to BOTH the project AND every
  Application object. It exposes:
    has_library_manager   -- @property (NOT a method) returning bool
    get_library_manager() -- method returning the LibMan ScriptObject
- ScriptLibManObject (the libman itself) exposes:
    .references            -- @property, ScriptLibraryReferences (list-
                              like) of ScriptLibraryReference objects
                              with structured fields (name, namespace,
                              is_placeholder, is_managed, system_library,
                              effective_resolution, ...)
    get_libraries(recursive=False)  -- list[str], names only
- ScriptLibManObjectMarker.is_libman is the universal marker, useful
  as a fallback when walking the tree.

The previous implementation searched the project tree by NAME for an
object literally called "Library Manager" via primary_project.find()
and a children name probe. That never matches because the libman's
actual name is generated, not "Library Manager." On the X33 project
(MRCodesysX33_0021) it returned empty -- false negative on a project
that obviously has dozens of system + application libraries. This
also took add_library down with it (same wrong axis), and the smoke
test from earlier in the fork's history flagged the inconsistency
without identifying the cause.

Fixed:
- Walk the tree depth-first, accept any node where has_library_manager
  is True (depth-limited at 8 to be safe).
- For each, call get_library_manager() and iterate .references for
  structured data; fall back to get_libraries() name-only enumeration
  if .references is unavailable on the SP.
- Capture every documented ScriptLibraryReference field defensively
  (each access wrapped in try/except since some fields raise on
  placeholders / unmanaged / SP-version skew).
- Return a structured JSON shape grouped by container so the TS side
  can show which Application owns which libraries.

server.ts:
- Updated the result-parsing block to handle the new structured shape.
- Distinguishes "no library managers found" (suspicious, libman
  discovery probably broken) from "found managers, all empty" (just
  empty applications).
- Renders flags ([system, placeholder, managed, optional, redirected])
  + namespace + effective_resolution per reference.
- Tool description rewritten to advertise the actual mechanism and
  cite the doc + local stub source.

add_library is NOT fixed in this commit -- it has the same wrong-axis
bug but landing the lookup-only fix first to verify the API contract.
add_library will land separately once we know list_project_libraries
sees the right libman in production (X33 smoke test).
2026-04-25 21:59:23 +02:00
Karstein Phobic Nyvold Kvistad
e3e5f58039 feat(git_branch_set_upstream_to): ship the missing canonical step
RTFM. The helpme-codesys.com Git scripting page
(https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html)
spells out the canonical "init -> commit -> remote_add -> push"
sequence and explicitly inserts a mandatory step between remote_add
and push:

  "Set Upstream Before Push: After adding a remote, use
   project.git.branch_set_upstream_to(origin_remote) before pushing."

The previous commit (8a6059b) shipped git_remote_add + git_push but
left this step labelled "out of scope," which guaranteed the very
first end-to-end smoke test would fail with:

  sLocalBranchName: The branch 'master' ('refs/heads/master')
  does not track an upstream branch.

Confirmed against the GitSmokeTest project (C:\Temp\GitSmokeTest)
on 2026-04-25 immediately after the commit landed.

This commit adds git_branch_set_upstream_to as a thin wrapper over
project.git.branch_set_upstream_to(remoteName, branchName?) using
the simplest of the four overloads in
Stubs/scriptengine/GitScriptProject.pyi. branchName defaults to the
current branch (per the stub default arg). Defensive checks +
HasGitLicense rewrite mirror the rest of the git_* tool family.

Tool description carries the doc citation up front and the exact
error message you get without it, so the next user sees the missing
step before having to discover it experimentally.
2026-04-25 20:45:43 +02:00
Karstein Phobic Nyvold Kvistad
8a6059b9a0 feat(git_*): add git_remote_add and git_push wrappers
Two new MCP tools wrapping the remaining pieces needed for an
end-to-end "init -> commit -> push to GitLab" flow:

  git_remote_add   Wraps project.git.remote_add(name, url). Required:
                   remoteName + remoteUrl. Conventionally name='origin'
                   for the primary upstream.

  git_push         Wraps project.git.push(...). Three overloads handled:
                   - push() when no branch + no creds (relies on tracked
                     upstream + git config / Windows Credential Manager)
                   - push(branch) when only branch is provided
                   - push(branch, user, SecureString(token)) when both
                     credentials are provided; derives current branch via
                     branch_show_current() if branchName is omitted.

Credentials handling: when a token is supplied it is converted to
System.Security.SecureString before being handed to push(), per the
CODESYS Git scripting docs guidance ("Use SecureString passwords
whenever possible"). Tool description carries an honest security note
that the token is briefly templated into the IronPython source that the
watcher executes -- prefer cached credentials when feasible. server.ts
neutralises backslashes/double-quotes and rejects newlines in the
templated values up front.

Both tools share the same defensive checks as the existing git_* trio:
- hasattr(script_engine, 'git') / project.git is None handling so the
  user gets a "run git_init first" hint when the project is unbound.
- attribute probe + dir() dump if the API surface is missing the
  expected method.
- HasGitLicense detection in the catch-all that rewrites SCRIPT_ERROR
  into the clear "PDE subscription required" message.

API contract verified against:
- Stubs/scriptengine/GitScriptProject.pyi (local SP22 install) -- the
  remote_add and push overload set.
- helpme-codesys.com Git scripting page
  (https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html)
  -- SecureString recommendation for password params.

Out of scope: pull, fetch, branch_set_upstream_to, push_delete,
clone (top-level script_engine.git, not project-bound). Can land
separately.
2026-04-25 20:24:15 +02:00
Karstein Phobic Nyvold Kvistad
31e842929e fix(git_init): default to sibling _git dir, auto-create, clear empty check
CODESYS Git uses a dual-storage model: the .project file stays where
it is, the git working tree lives in a SEPARATE empty directory. The
prior default ("init in the project's own folder") tripped over the
project file itself and CODESYS would raise the unhelpful
'gitProjectStoragePath: ... is not a valid Git repository location:
DirectoryNotEmpty'. Hit this end-to-end during the first PDE-Demo
smoke test on 2026-04-25 -- the user had to dance around it manually.

git_init now:
- Defaults LocalRepoPath to '<project_basename>_git' as a sibling of
  the project's own dir when not supplied (or when supplied equal to
  the project dir, which has the same trap).
- Auto-creates the target dir if missing.
- Pre-validates emptiness and raises a clear, action-oriented error
  if non-empty, instead of letting CODESYS surface DirectoryNotEmpty.

server.ts tool description updated to call out the dual-storage rule
and the auto-default behaviour up front.

Verified API contract via Stubs/scriptengine/GitScriptProject.pyi
(local SP22 install) and the helpme-codesys.com Git scripting page
(https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html).
2026-04-25 20:22:10 +02:00
Karstein Phobic Nyvold Kvistad
3623c45d31 feat(git_*): hard-stop with clear PDE-subscription message on HasGitLicense
The 3 git_* tools previously surfaced the raw CODESYS exception
"Permission denied: Rule 'HasGitLicense' failed with state 'False'."
which is opaque to anyone who does not already know that CODESYS Git
scripting is gated behind the Professional Developer Edition
subscription. Verified 2026-04-25 on this workstation that the
plug-in is installed (PlugIns/GitIntegration.plugin.dll v1.7.0.0,
ScriptDriverGit.plugin.dll, full ScriptLib/Stubs/scriptengine/Git*.pyi
set, and the Git menu visible in the IDE) but the runtime rule still
returns False because no PDE subscription is active. Per the CODESYS
Git store page the subscription is the actual gate (Additional
Requirements / Licensing).

Each script now detects 'HasGitLicense' in the exception or traceback
and rewrites SCRIPT_ERROR to a clean, actionable message pointing at
https://store.codesys.com/en/codesys-git.html with a "what to activate"
hint, falling back to the original generic error formatting on any
non-license failure.

git_status: also adds an early license probe via project.git.has_working_tree()
(license-gated per the SP22 stubs) so the rewrite triggers reliably
instead of being swallowed by the per-method probe loop further down.

Tool descriptions in server.ts updated to advertise the PDE
subscription requirement up front, so MCP clients see it without
having to fail first.
2026-04-25 19:25:58 +02:00
Karstein Phobic Nyvold Kvistad
e236a0cfad feat: add git_status / git_init / git_commit MCP tools (CODESYS Git plug-in)
Cross-references the official Git scripting docs at
https://content.helpme-codesys.com/en/CODESYS%20Git/_git_using_scripting.html
which exposes a project-bound git API as 'primary_project.git' (when the
CODESYS Git plug-in is loaded). Confirmed earlier today via dir(scriptengine)
that 'git' is one of the top-level scriptengine modules on this install.

Three tools in this commit, ordered shallow-to-deep:

  git_status        Read-only. Returns current branch via
                    project.git.branch_show_current() AND a defensive probe
                    of any status/changes/diff/changed_files methods on
                    project.git (the docs page lists the call patterns by
                    example but does not enumerate the full API surface, so
                    the probe + diagnostic dump is how we'll discover the
                    rest in the next iteration).

  git_init          Wraps project.git.init(local_repo_path). Defaults the
                    repo path to the project file's parent directory so a
                    plain 'init this project's folder' call needs no extra
                    args. One-shot setup; pair with git_status afterwards.

  git_commit        Wraps project.git.commit_complete(message, user, mail).
                    Required: message + authorName + authorEmail (latter
                    validated as email by zod). Stages all working-tree
                    changes and commits in one shot per the docs. Multi-line
                    messages handled via triple-quoted Python injection with
                    standard backslash + triple-quote escaping.

Defensive checks across all three:
  - hasattr(script_engine, 'git') so we can distinguish "Git plug-in not
    installed" from "project not in a repo".
  - project.git is None handled with a clear 'run git_init first' message.
  - On missing methods the script dumps sorted(dir(project.git)) so the
    next debug session sees the exact surface.

Out of scope for this commit (deliberate -- one feature per commit per
the project rule, more git ops can land separately):
  - branch ops (branch_copy, checkout)
  - remote ops (remote_add, push, pull, fetch, branch_set_upstream_to)
  - merge
  - clone (script_engine.git.clone -- top-level, not project-bound)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:01:09 +02:00
Karstein Phobic Nyvold Kvistad
b3bf4a83d5 fix(download_to_device): port login() probe + loginWaitSeconds from connect
Same SP21+/SP22 login() drift as connect_to_device:
  - 'TryOnlineChange' is gone from OnlineChangeOption
  - login() now requires (OnlineChangeOption, bool) -- two positional
The fork hard-coded the old call shape; observed today on SP22 Patch 1
with the runtime up and the project compiled, every download_to_device
attempt failed with "login() takes exactly 2 arguments (0 given)".

Fix mirrors e862846 / eee8ce2:
  - Probe OnlineChangeOption members at runtime (priority order tuned
    for download: WithDownload / ForceDownload come first, since
    'download' implies a write).
  - Try (enum, False) / (enum, True) / (enum,) / bool / no-arg shapes.
  - Add LOGIN_WAIT_SECONDS post-login state-stabilisation poll for the
    credential dialog (default 60s, configurable 0-600).
  - Tool-side IPC timeout = waitSec + 120s headroom for the actual
    download work (download is heavier than connect, hence 120 vs 30).

The download itself is still done via the existing fall-through:
online_app.download() if exposed, else create_boot_application().
On SP22 the dir() shows source_download + create_boot_application (no
'download'), so the create_boot_application path is what runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:40:26 +02:00
Karstein Phobic Nyvold Kvistad
eee8ce2d1e feat(connect_to_device): add loginWaitSeconds for credential dialog
When connecting to a password-protected runtime for the first time,
CODESYS pops a modal credential dialog. login() in some SP versions
returns immediately (without waiting for the dialog), leaving the
application in an undefined state until the user fills it in.

Behaviour:
  - New optional 'loginWaitSeconds' parameter on the tool (default 60,
    range 0-600). After login() returns, the script polls
    online_app.application_state once per second up to that many
    seconds, exiting early when the state lands on a recognisable
    value: run / stop / connected / halt / breakpoint.
  - During the poll, system.delay(1000) pumps the UI message loop so
    the dialog renders and stays interactive while we wait. Once the
    user enters the password and clicks OK, login completes, state
    transitions, and the loop exits.
  - Tool-side IPC timeout extends accordingly (waitSec + 30s headroom)
    so the IPC layer doesn't kill the script mid-dialog.

Concrete case observed today on SP22 Patch 1: prior connect appeared
to "succeed" only because the user happened to be at the keyboard and
manually entered the password while the dialog was up. Without the
poll, an unattended run would race past the dialog into a half-state.

Future companion: download_to_device hits the same login path and
should get the same parameter; will follow as a separate commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:31:56 +02:00
Karstein Phobic Nyvold Kvistad
9f44a603d8 fix: make IEC reserved identifier check BLOCKING (refuse, don't warn)
Per user feedback after 1f5811c -- a soft warning appended to a success
message is too easy for an AI agent to miss when iterating fast. Switch
both set_pou_code and create_gvl to refuse the operation up front when
declarationCode contains a reserved IEC identifier (s/t/d/m/h/ms/us/ns/
S/R), with isError=true and the project NOT modified.

Behaviour change:
  * Old: script ran, project was modified, warning was appended to the
    success message.
  * New: script never runs if any reserved name is detected; tool
    response is the refusal text + the same per-name diagnostic + a
    'Project NOT modified' assurance.

Refusal happens before any IPC -- no half-state, no rollback needed.
Fix the offending names in declarationCode and retry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:08:47 +02:00
Karstein Phobic Nyvold Kvistad
1f5811cab9 feat: warn AI caller when IEC reserved identifiers are used in declarations
Adds Node-side pre-check to set_pou_code and create_gvl that scans the
declarationCode for variable names colliding with IEC 61131-3 reserved
identifiers, and surfaces a warning back through the tool response so
the caller (an AI agent) sees it without having to dig into the compile
output.

Reserved set:
  - s, t, d, m, h, ms, us, ns -- time-literal suffixes (T#5s, T#100ms)
  - S, R                       -- SR/RS flip-flop input convention

The motivating concrete case (today): an agent wrote
'VAR fb : FB_Test; s : ST_Sample; END_VAR' and CODESYS red-underlined
the 's'. Without surfaced feedback the agent had no signal until the
project was inspected by a human.

Behaviour:
  - The check is non-blocking. The tool still proceeds with the script
    call -- the user might have an intentional reason. Set + GVL create
    succeed; the warning is appended to the success message.
  - One warning per offending name (deduped). Suggests a rename pattern
    (Hungarian-prefix or '<name>Inst' / '<name>Sample').
  - Pattern is line-anchored on '<name> : <type>' so it ignores struct
    member access ('fb.s'), CASE labels in implementation, etc.
  - Implementation in implementationCode is NOT scanned -- variables are
    used there, not declared.

Future tightening to consider: handle comma-separated declarations like
's, t : BOOL;' (currently catches only the trailing name in such lists).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:05:49 +02:00
Karstein Phobic Nyvold Kvistad
d7a263b5d1 revert: remove library install/update MCP tools (out of scope, fragile)
Removes the five MCP tools added across 1f23cb0..a0a4945 plus the
helpers and Node-side imports that supported them:

  - install_library_file        (1f23cb0, a0a4945)
  - install_library_from_url    (7fcca42)
  - install_addon_from_file     (79c83fd, 33f494e)
  - update_all_libraries        (9a4e96f)
  - set_library_version         (3832f9c)

  Plus src/scripts/{install_library_file,update_all_libraries,
  set_library_version}.py and the downloadToTempFile / runProcess /
  locateAPInstallerCli helpers in src/server.ts.

Reasons (decided 2026-04-25 after the WAGO bring-up attempt):

  * Library install via scriptengine kept hitting an evolving API
    surface across SP versions. Even after probing the actual SP22
    Patch 1 surface (which exposes 'librarymanager', not 'libraries'),
    the install methods on that object were not yet validated and
    the iteration would have continued.

  * .package install via APInstaller.CLI requires admin rights to
    write under Program Files. The MCP server runs at user level
    and the user is on a workstation without full admin, so this
    path is dead end without an elevation strategy that doesn't
    exist for headless agent use.

  * The right tool for system-library auto-download is the in-IDE
    'Download missing libraries' dialog (which works -- only the
    WAGO subtree is broken because WAGO pulled it from the Store
    archive). For .package bundles, the right tool is the standalone
    CODESYS Installer GUI which handles UAC properly. Wrapping
    those in MCP added complexity without solving the underlying
    permission/scope problems.

The fork's substantive value is the SP21+/SP22 watcher rewrite and
KeyboardInterrupt hardening -- keeping focus there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:09:11 +02:00