0
0
Fork 0
Commit graph

69 commits

Author SHA1 Message Date
Karstein Phobic Nyvold Kvistad
80ba96aae7 fix: project user tools log in to user management before modifying
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.
2026-06-12 15:21:25 +02:00
Karstein Phobic Nyvold Kvistad
b013c4ee53 fix: code-review findings -- dot-path name check, Python string-literal injection
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.
2026-06-12 14:02:22 +02:00
Karstein Phobic Nyvold Kvistad
e9aa71415e fix: ASCII-fy add_library.py comment, clean dist/scripts on build, global ASCII test
- 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.
2026-06-12 13:43:27 +02:00
Karstein Phobic Nyvold Kvistad
83691c0547 feat: add 7 project-user & misc-object tools (SP21 coverage, phase 5)
list_project_users, add_project_user, remove_project_user,
create_text_list, import_text_list_file, create_image_pool,
add_external_file.

API per SP21 ScriptUserManagement.pyi / ScriptTextListObject.pyi /
ScriptImagePoolObject.pyi / ScriptExternalFileObject.pyi.
Plan: docs/superpowers/plans/2026-06-12-sp21-api-coverage.md (phase 5).
2026-06-12 13:14:28 +02:00
Karstein Phobic Nyvold Kvistad
0f3bebe839 feat: add 9 device-config & task-config tools (SP21 coverage, phase 4)
list_device_parameters, get_device_parameter, set_device_parameter,
export_io_mappings_csv, import_io_mappings_csv, set_device_state
(enable/disable/simulation), get_device_identification, create_task,
configure_task. New shared helper find_device_object.

plug_module/unplug_module deferred: needs a module-slot test device to
verify the connector/index call shape.

API per SP21 ScriptDeviceObject.pyi / ScriptDeviceParameters.pyi /
ScriptTaskConfigObject.pyi.
Plan: docs/superpowers/plans/2026-06-12-sp21-api-coverage.md (phase 4).
2026-06-12 13:11:26 +02:00
Karstein Phobic Nyvold Kvistad
44a58ff2bf feat: add 5 application-build & object tools (SP21 coverage, phase 3)
application_build (generate_code/rebuild/clean), check_online_change,
move_object, get_signature_crc, set_exclude_from_build.

API per SP21 ScriptApplication.pyi / ScriptObject.pyi.
Plan: docs/superpowers/plans/2026-06-12-sp21-api-coverage.md (phase 3).
2026-06-12 13:07:06 +02:00
Karstein Phobic Nyvold Kvistad
fb7d886a33 feat: add 13 project lifecycle/interop tools (SP21 ScriptProject coverage, phase 2)
close_project, save_project_as, save_project_archive,
save_as_compiled_library, export_plcopen_xml, import_plcopen_xml,
export_native, import_native, get_project_info, set_project_info,
get_compiler_version, set_compiler_version_to_newest, clean_all.

API per SP21 ScriptProject.pyi; semantics cross-checked against
helpme-codesys.com/en/ScriptingEngine/ScriptProjects.html.
Plan: docs/superpowers/plans/2026-06-12-sp21-api-coverage.md (phase 2).
2026-06-12 13:04:34 +02:00
Karstein Phobic Nyvold Kvistad
3a012a7149 feat: add 12 online/runtime tools (SP21 ScriptOnline coverage, phase 1)
reset_application (warm/cold/origin), read_variables, write_variables,
force_variables, unforce_variables, list_forced_variables,
create_boot_application (online/offline), source_download, source_upload,
plc_file_list, plc_file_transfer, plc_file_delete.

API per SP21 ScriptOnline.pyi (ScriptOnlineApplication/ScriptOnlineDevice);
semantics cross-checked against
helpme-codesys.com/en/ScriptingEngine/ScriptOnline.html.
Plan: docs/superpowers/plans/2026-06-12-sp21-api-coverage.md (phase 1).
2026-06-12 12:52:17 +02:00
Karstein Phobic Nyvold Kvistad
a990cf23ee fix: stop interpolate() mangling $-sequences in param values
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.
2026-06-12 12:52:17 +02:00
Karstein Phobic Nyvold Kvistad
d801038dda feat(project-tools): reject UNC paths with a clear, actionable error
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>
2026-06-04 11:02:48 +02:00
Karstein Phobic Nyvold Kvistad
e1f47a1c1c chore: rip approve-gate (TUI follow-up); fold add_device tool in gate-free
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>
2026-05-18 17:55:55 +02:00
Karstein Phobic Nyvold Kvistad
4b82b7c569 chore: nuke phobiCS-tui front-end (v0.10.0)
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>
2026-05-18 17:48:28 +02:00
Karstein Phobic Nyvold Kvistad
465718e93b fix(launcher): revalidate stale "Refusing to launch" cache on get_codesys_status
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>
2026-05-15 14:37:02 +02:00
Karstein Phobic Nyvold Kvistad
614a8458f0 feat(live-values): depth-1 sub-property descent
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)
2026-04-29 13:52:35 +02:00
phobicdotno
f0a44f877e
test(e2e): regression coverage for the four 2026-04-29 fixes (#14)
* 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>
2026-04-29 13:44:02 +02:00
phobicdotno
05d0e37e21
feat(remove_library): new MCP tool wrapping ScriptLibManObject.remove_library (#5)
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>
2026-04-29 10:17:03 +02:00
Karstein Phobic Nyvold Kvistad
c81ce33a4b feat(live-values): pump skeleton + VAR-block parser
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.
2026-04-29 09:55:18 +02:00
Karstein Phobic Nyvold Kvistad
a1ac646bb9 feat(live-values): atomic writer for tui-live-values.json
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.
2026-04-29 09:51:17 +02:00
Karstein Phobic Nyvold Kvistad
a1bcf5cbfe tui(v0.3): useLiveValues hook + wire into Browser/Viewer
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={...}/>.
2026-04-29 09:49:15 +02:00
Karstein Phobic Nyvold Kvistad
a9d77df627 tui(v0.3): Viewer overlays inline live values when liveValues prop set
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.
2026-04-29 09:45:49 +02:00
Karstein Phobic Nyvold Kvistad
d233880a86 tui(v0.3): add readLiveValues() with 5s freshness window
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.
2026-04-29 09:42:06 +02:00
Karstein Phobic Nyvold Kvistad
6f9cc46547 tui(v0.3): add LiveValuesPayload type + liveValuesFilePath()
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.
2026-04-29 09:40:54 +02:00
Karstein Phobic Nyvold Kvistad
041d7dc291 tui(viewer): join multi-line (* ... *) comments across line boundaries
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).
2026-04-29 09:02:09 +02:00
Karstein Phobic Nyvold Kvistad
d01f6ed930 fix(test): retry on PermissionError in mock_watcher (Win race)
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'
2026-04-29 08:34:06 +02:00
Karstein Phobic Nyvold Kvistad
e1966c7e83 feat(approve-gate): wire 9 modifying tools through phobiCS-tui
Generalizes runApproveGate beyond set_pou_code by adding:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

If inspection itself fails (file missing, malformed .project, profile
unparseable), pre-flight falls through silently -- the existing CODESYS
open path then produces its original error.
2026-04-27 22:32:53 +02:00