Live verification on SP21: users.create raised "permission 'Modify' not
granted to user '(nobody)'". The scripts now log in as 'Owner' with empty
password (CODESYS default) when nobody is logged on; new optional
adminUser/adminPassword args override for protected projects.
1. find_object_by_path: final name verification used the original
full_path instead of the dot->slash normalized segments, so
dot-separated paths ('Application.MyPOU') traversed correctly but
failed the final check and returned None.
2. User-arbitrary values (plcPath/plcDirectory, passwords, comments,
project-info fields, device parameter name/value, task event,
device credentials) were interpolated into r"..."/r"""...""" Python
literals unescaped -- a quote or triple-quote in the value broke the
generated script (or injected code). Templates now take pre-escaped
literals via pyStringLiteral().
Reviewed-range: dead49a..e9aa714. Third reviewer finding (task.priority
must be int) was rejected: SP21 ScriptTaskConfigObject.pyi types the
priority/interval/interval_unit setters as str.
- add_library.py line 49 had UTF-8 box-drawing dashes in a comment with no
coding declaration -- latent IronPython 2.7 source-encoding risk.
- build now removes dist/scripts before copying, so deleted/renamed
templates (compile_project.py.bak, probe_app_error_state.py,
set_library_namespace.py) no longer ship in the npm tarball.
- script-manager test now asserts EVERY template is ASCII-only instead of
per-phase lists.
String.replace with a string replacement interprets $$/$& as regex
replacement patterns, corrupting IEC string literals like '$R$N' passed
through tool params (set_pou_code code bodies, write_variable values).
Use a function replacement so values pass through verbatim.
CODESYS opens/saves projects from UNC paths (\server\share\...) only
unreliably -- it tends to fail late and opaquely. Add src/path-guard.ts
(isUncPath + uncPathError) and gate open_project, create_project,
save_project, and launch_codesys_with_project on it: each now returns
isError early telling the user to map a drive (net use Z: \server\share)
or copy the project to a local drive. Mapped drive letters and local
drives (incl. \?\C:\) are not treated as UNC, so existing workflows are
unaffected.
Also bumps version to 0.10.1 (first published release of the 0.10.x line;
includes the prior unreleased phobiCS-tui removal and add_device tool).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Knock-on cleanup from the phobiCS-tui nuke in 4b82b7c. With the TUI gone
the gate had no UI to render -- it just printed a stderr warning and
auto-approved on every call. Ripping it out kills ~470 lines of dead code
and a redundant CLI flag.
Removed:
- src/approve-gate.ts (whole file; runApproveGate + gateOpForTool + helpers)
- tests/unit/approve-gate.test.ts
- bin.ts: --approve-edits CLI option, approveEdits config wiring, startup log
- types.ts: ServerConfig.approveEdits field + its doc comment
- server.ts: import of runApproveGate/gateOpForTool, the `Approve edits:` log,
16 `gateOpForTool({...}); if (blocked) return blocked;` blocks across
every modifying MCP tool handler, and the lone `runApproveGate({...})`
block in set_pou_code.
Folded in (Karstein's prior WIP, gate-free now per his explicit choice
"Single rip commit, I edit your WIP too"):
- New `add_device` MCP tool in server.ts that wraps ScriptDeviceObject.add
for attaching child devices (Modbus TCP Server under Ethernet, Ethernet
under PLC, etc.). The supporting `src/scripts/add_device.py` remains
UNTRACKED in Karstein's working tree -- still his to commit separately.
Without that script the tool will fail at runtime; with it, fine.
Verify:
- `npx tsc --noEmit` clean
- `npm run build` clean
- `npx vitest --run tests/unit/` -> 14 files, 119 tests, all pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The phobiCS-tui CLI/UI is retired in favour of driving the codesys-mcp-sp21-plus
MCP tools directly from Claude Code (see https://docs.anthropic.com/claude-code).
The MCP server itself is unchanged; only the TUI front-end goes.
Removed:
- src/tui/ (browser + approve + shared + entry; 14 files)
- dist/tui/ (compiled output)
- tests/tui/ (14 .test.* files + the mini-mirror fixture tree)
- tsconfig.tui.json
- 3 superpowers plans/specs docs (2026-04-28 phobics-tui v0.1-v0.2, 2026-04-29 v0.3-live-values, 2026-04-28 tui-design)
- package.json: phobiCS-tui bin entry, build:tui script, TUI compile step in build, TUI typecheck step
- package.json: dependencies ink + react + diff (TUI-only); devDependencies @types/diff + @types/react + ink-testing-library
- README.md: ## phobiCS-tui section + ### Inline live values subsection
Git history side:
- Worktree .worktrees/phobics-tui removed (was on feature/phobics-tui-followup @ 7e427e9)
- Local + origin branches deleted:
- feature/phobics-tui (was 08ee361, 0 unmerged vs origin/main)
- feature/phobics-tui-followup (was 7e427e9, 0 unmerged vs origin/main)
- feature/phobics-tui-v0.3-live-values (was 9f4dc48, 0 unmerged vs origin/main)
- All three branches were merged into main, so deleting refs loses no history --
the commits remain reachable through main.
Knock-on (deliberately deferred):
- src/approve-gate.ts and the --approve-edits flag in src/bin.ts / src/server.ts
still exist. With the TUI gone, the gate auto-approves at every prompt and
prints a `[approve-gate] No TTY available -- phobiCS-tui cannot render` warning
to stderr. The infrastructure also still has callers in the uncommitted
add_device work, so a clean rip-out is left for a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Once launch() refused due to a foreign CODESYS, the error string was
cached in lastError and getStatus() returned that frozen snapshot
forever. Closing the foreign CODESYS did not update the status -- only
an MCP restart cleared it. From the user's POV: "every time CODESYS
HAS been open, you get a problem".
Fix: getStatus() now calls revalidateLaunchRefusal() first. If the
launcher is parked in 'error' state with a "Refusing to launch:"
prefix and findConflictingInstances() now returns empty, we transition
back to 'stopped' and clear lastError so the next status call / launch
attempt sees a fresh state. Other 'error' states (process died,
watcher timeout, exe not found) are not auto-cleared -- only the
launch-refusal cache, since that's the one that goes stale on its own
when the user closes the foreign window.
Bump 0.9.13 -> 0.9.14.
Tests: 2 new launcher unit tests pin the auto-clear behaviour and
verify unrelated 'error' states are NOT auto-cleared. 22/22 launcher
tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the pump beyond top-level vars: when a var's declared type
resolves to another mirror .st file, we descend one level and read
each of that type's vars as <var>.<member>.
Parser: parseVarDecls(text) now returns name + declared type per
decl. ARRAY/POINTER/REFERENCE wrappers stripped to the inner type.
parseVarNames is kept as a thin wrapper for the existing callers.
Pump: new resolveTypeMirror(typeName, deviceRoot) dep. Pump itself
doesn't know how to find a type's source -- the caller plugs in a
strategy. server.ts wires a recursive walk under the device root
looking for '<typeName>.st'; the mirror layout guarantees stable
filenames for POU/FB/DUT (every code-bearing object).
Pump's deviceRootFor(absPath) parses the abs path back to the
device root by locating the '/mcp-mirror/<device>/' segment. If
that fails we fall back to the file's parent dir (still works for
same-folder type lookups, just won't find types in sibling folders).
Constructor now accepts partial deps and fills in safe defaults
(no-op resolveTypeMirror -> never descend, matching v0.3
top-level-only behaviour). Existing tests don't need to change.
407/407 tests pass; new coverage:
- parseVarDecls: name+type extraction, ARRAY/POINTER/REFERENCE
stripping, AT %loc prefix, missing-type pathological case
- LiveValuesPump.tick: descends when resolver returns content;
doesn't descend when resolver returns null (primitives)
* feat(live-values): --live-values-interval <ms> CLI flag
Replaces the hardcoded 500ms poll with a configurable interval.
Clamped to [100, 60000] -- below 100 the read_variable IPC round-
trip-per-var dominates and the pump can't keep up; above 60000 a
session shorter than the interval would never see any update.
ServerConfig.liveValuesIntervalMs?: number plumbs through bin.ts
(parseInt + clamp) into the LiveValuesPump constructor and the
'Live-values pump started (<ms>ms)' log line.
Default unchanged at 500ms, so existing --live-values invocations
behave identically.
* test(e2e): regression coverage for the four 2026-04-29 fixes
Five new template-prep assertions:
1. set_symbol_access mutation lookup goes to get_all_signatures FIRST
(PR #7). The configured-view probe is tracking-only and must come
AFTER both compile=False/True all-signatures lookups -- if the order
ever flips, the mutation hits the read-only view and CODESYS rejects
the assignment with 'can only be changed in the list of all
signatures/data types'.
2. set_symbol_access int->SymbolAccess coerce (PR #9). The fallback
block must use type(maximal_access) and call enum_cls(int_val)
BEFORE 'var.configured_access = requested_access'. Without it,
non-zero ints are rejected with 'must be zero'.
3. set_signature_access_bulk same coerce, lazily on the first variable
in the for-loop (PR #9). Verifies loop -> coerce -> assign order.
4. create_project without deviceName preserves the no-swap path
(backwards compat for PR #8). Empty DEVICE_NAME substitutes; the
if-DEVICE_NAME branch is in the template but the runtime gates it.
5. create_project deviceName swap (PRs #8/#10/#11/#12 combined).
Verifies:
- PromptHandling.NONE setter is rendered (script_prompt_handling
is read-only -- earlier code silently failed).
- device_repository accessed via script_engine, not as a builtin.
- update() is the first-attempt mutation, remove+add only fires
'if not update_ok'.
- project.close() + precompilecache delete render after swap.
Vitest: 27/27 in tests/integration/e2e.test.ts (was 22).
---------
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Adds remove_library as the symmetric counterpart to add_library.
Script (src/scripts/remove_library.py):
- IronPython 2.7 / ASCII-only; no f-strings, no KeyboardInterrupt in
except-Exception clauses.
- Placeholder substitution: LIBRARY_NAME (bare) and LIBRARY_FQN_OR_NAME
(bare or "Name, Version (Company)").
- Locates the project Library Manager via the same three-step discovery
used in add_library.py: has_library_manager/get_library_manager on the
project, first-level child walk, then find("Library Manager") fallback.
- Pre-check: walks lm.references using _ref_name_matches logic (handles
placeholder "#Name" and managed "Name, Version (Company)" forms).
If the library is not referenced the script exits SCRIPT_SUCCESS with
the "Library Not Present:" marker -- idempotent, same convention as
add_library's dedup no-op branch.
- If found: calls lm.remove_library(existing_name) per the SP22 stub
(ScriptLibManObject.pyi: remove_library(name: str)), confirms removal
from lm.references, then project.save().
- Emits SCRIPT_SUCCESS or SCRIPT_ERROR with traceback on exception.
Server (src/server.ts):
- Tool registered immediately after add_library (~line 2067).
- Reads "Library Not Present:" marker to pick idempotent vs removed
wording -- same marker-driven pattern as add_library's dedup wording.
- Thin surface: projectFilePath + libraryName (required) +
libraryFqnOrName (optional, for multi-version disambiguation).
Tests (tests/integration/e2e.test.ts):
- Template-prep test asserts: no leftover {PLACEHOLDER}s, substituted
values present, remove_library call present, references walk present,
"Library Not Present" marker present, SCRIPT_SUCCESS/SCRIPT_ERROR
markers present.
- All 22 tests pass.
References:
- helpme-codesys.com scripting engine > ScriptLibManObject
- SP22 stub: Stubs/scriptengine/ScriptLibManObject.pyi (remove_library
at line 455, references property at line 464)
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Task 6 of v0.3 plan (parser + pump class; full --live-values CLI
wiring is Task 7).
parseVarNames(text) extracts variable names from any VAR /
VAR_INPUT / VAR_OUTPUT / VAR_GLOBAL / etc. block. Handles:
- one name per declaration line
- 'AT %QX0.1' location prefix
- := initializer
- inline (* ... *) and // line comments
- multi-line (* ... *) blocks (state threaded across lines)
Doesn't handle the multi-name shorthand 'a, b : INT;' -- vanishingly
rare in practice, cost of getting it wrong is just a missing overlay.
LiveValuesPump owns a setInterval. Each tick:
- reads tui-state.json via the injected readSelection
- reads the POU's .st file from the mirror
- parseVarNames -> per-var read_variable round-trip (errors per-var
are silent, others may succeed)
- writeLiveValues snapshot
Reentrancy guard: if a tick is still in flight when the next
interval fires, skip the new tick rather than queueing. Errors at any
layer are swallowed -- pump must never crash the server.
start()/stop() lifecycle so the server can hand it to the existing
shutdown path.
Task 5 of v0.3 plan.
writeLiveValues(filePath, projectDir, payload) wraps the caller's
{device, pou_name, values} in the v1 envelope (version, updated_at,
project_dir) and writes atomically via <file>.<pid>.tmp + rename.
Creates parent dirs as needed.
Mirrors src/tui/shared/state-write.ts. Kept separate because the
TUI subpackage is ESM and the server is CJS; the duplication is
~15 lines of code that almost never changes.
Task 4 of v0.3 plan.
useLiveValues(filePath, pouName, intervalMs=500) sets up a recursive
setTimeout poll. Returns the values map iff readLiveValues returns
ok AND the snapshot's pou_name matches the requested pouName;
returns null otherwise (cursor not on a POU, file missing/stale,
wrong POU snapshot).
Cancelled flag + setTimeout (not setInterval) so the cleanup is
race-free: if the file read takes longer than intervalMs, the next
tick won't pile up.
Browser computes the live-values file path once via
liveValuesFilePath() (catches Windows LOCALAPPDATA-missing -> null
so we don't crash; pump just won't be able to write either) and
passes the resulting map down to <Viewer liveValues={...}/>.
Task 3 of v0.3 plan.
<Viewer liveValues={...}/> walks each visible line for an identifier
that's a key in the map. First match wins; appends ' ◀ live: <val>'
in green at end of line. No prop -> no overlay (default).
Matches against the original line text, not against highlighter
tokens: the highlighter splits mixed-case names like bRunning into
'b' + 'R' + 'unning' (uppercase-only ident regex) which would never
match a 'bRunning' key. Operating on the line preserves identifiers
intact.
Task 2 of v0.3 plan.
Mirrors src/state-read.ts (the server-side selection reader): same
discriminated union (ok | missing | stale | invalid), but lives in
the ESM TUI subpackage (TUI is the consumer, server is the
producer).
Tighter freshness window than the selection file (5s vs 60s). Stale
overlay would be misleading -- if the pump dies or the runtime goes
offline we want no overlay rather than frozen values.
Task 1 of the v0.3 inline-live-values plan.
LiveValuesPayload is the on-disk shape the server pump writes and
the TUI reads. Same versioning + envelope approach as the existing
selection state file: version=1, ISO updated_at, project_dir +
device + pou_name match the user's current selection so the TUI can
reject stale snapshots.
liveValuesFilePath() lives next to stateFilePath() and resolves to
%LOCALAPPDATA%/codesys-mcp/tui-live-values.json on Windows or
$XDG_STATE_HOME/codesys-mcp/tui-live-values.json (default
~/.local/state) on POSIX. Path joining stays platform-flavoured
even when the unit test forces a different process.platform, so the
suite is host-independent.
Adds tokenizeWithState(line, openComment) -> {tokens, commentLeftOpen}
and tokenizeLines(allLines) which threads the open-comment flag across
lines.
Viewer now tokenizes from line 0 (not just the visible slice) so the
state going into the visible window is correct, then renders the
slice. A line wholly inside a (* block is emitted as a single
'comment' token; PROGRAM and other keywords on those lines no longer
get falsely highlighted.
tokenize(line) kept as a thin wrapper for the single-line callers
(tests, future use).
On Windows, the renamed .command.json / script .py file is briefly
locked by Defender or NTFS rename finalization, which surfaces as a
transient PermissionError when the watcher's open() runs immediately
after the producer's atomicWrite + rename.
Wrap the two reads in process_command() with read_with_retry(), which
retries up to 10 times with 20 ms sleeps before giving up. POSIX
behaviour unchanged (first attempt always succeeds).
Repro on Windows:
npm test
> FAIL tests/unit/ipc.test.ts > sendCommand handles script error
> PermissionError: [Errno 13] Permission denied: '...command.json'
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).
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).
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.
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'.
/ 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.
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).
? 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.
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.
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).
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.
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.
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.
<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
<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.
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.
<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.
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.
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.
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.
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.
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.
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.
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.
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.