diff --git a/src/scripts/remove_library.py b/src/scripts/remove_library.py new file mode 100644 index 0000000..cf7d72a --- /dev/null +++ b/src/scripts/remove_library.py @@ -0,0 +1,169 @@ +import sys, scriptengine as script_engine, os, traceback + +# RTFM (helpme-codesys.com "ScriptLibManObject" + local SP22 stub +# Stubs/scriptengine/ScriptLibManObject.pyi): +# +# - ScriptLibManObject.remove_library(name: str) removes a reference by +# name. The name argument accepts either the bare library name (e.g. +# "Standard") or the fully-qualified "Name, Version (Company)" form. +# - lm.references gives back ScriptLibraryReference items. Placeholder +# refs have .name == "#"; managed refs have .name == +# ", ()". +# - The project-level libman is obtained the same way add_library.py does: +# container.has_library_manager / container.get_library_manager(), then +# first-level-child walk, then find("Library Manager") fallback. + +LIBRARY_NAME = "{LIBRARY_NAME}" +LIBRARY_FQN_OR_NAME = "{LIBRARY_FQN_OR_NAME}" + + +def _ref_name_matches(ref_name, target): + """Match a reference name against a bare or fully-qualified target. + A managed ref shows up as 'Name, Version (Company)'; a placeholder + shows up as '#Name'. Either target form is accepted.""" + if ref_name is None: + return False + if ref_name == target: + return True + if ref_name == ('#' + target): + return True + # Managed: leading 'Name, ...' + if ref_name.startswith(target + ','): + return True + return False + + +def _find_reference(lm, target): + """Walk lm.references and return the first entry whose name matches + target (bare or fully-qualified), or None.""" + try: + refs = lm.references + except Exception as e: + print("DEBUG: lm.references unavailable: %s" % e) + return None + if refs is None: + return None + for r in refs: + try: + rn = getattr(r, 'name', None) + except Exception: + rn = None + if _ref_name_matches(rn, target): + return r + return None + + +try: + print("DEBUG: remove_library script: Library='%s', FQN='%s', Project='%s'" + % (LIBRARY_NAME, LIBRARY_FQN_OR_NAME, PROJECT_FILE_PATH)) + primary_project = ensure_project_open(PROJECT_FILE_PATH) + if not LIBRARY_NAME: + raise ValueError("Library name empty.") + + project_name = os.path.basename(PROJECT_FILE_PATH) + + # Locate the project's Library Manager. Mirror the discovery logic from + # add_library.py: container API first, then child walk, then name search. + lib_manager = None + try: + if hasattr(primary_project, 'has_library_manager') and primary_project.has_library_manager: + lib_manager = primary_project.get_library_manager() + print("DEBUG: Found Library Manager via project.get_library_manager()") + except Exception as e: + print("DEBUG: project.get_library_manager() failed: %s" % e) + + if not lib_manager: + try: + for child in primary_project.get_children(False): + try: + if getattr(child, 'has_library_manager', False): + lib_manager = child.get_library_manager() + if lib_manager is not None: + print("DEBUG: Found Library Manager under '%s'" % child.get_name()) + break + except Exception: + pass + except Exception as e: + print("DEBUG: walking children for libman failed: %s" % e) + + if not lib_manager: + try: + found_list = primary_project.find("Library Manager", True) + if found_list: + lib_manager = found_list[0] + print("DEBUG: Found Library Manager via find('Library Manager') fallback") + except Exception as e: + print("DEBUG: find('Library Manager') failed: %s" % e) + + if not lib_manager: + raise RuntimeError("Library Manager not found in project '%s'." % project_name) + + print("DEBUG: Library Manager found: %s" % getattr(lib_manager, 'get_name', lambda: '?')()) + + # Pre-check: is the library actually referenced? Walk lm.references for + # either the bare name or the fully-qualified name. + # Check bare LIBRARY_NAME first; fall back to LIBRARY_FQN_OR_NAME if + # it differs (caller may pass the "Name, Version (Company)" form). + match_target = LIBRARY_NAME + existing_ref = _find_reference(lib_manager, match_target) + if existing_ref is None and LIBRARY_FQN_OR_NAME and LIBRARY_FQN_OR_NAME != LIBRARY_NAME: + match_target = LIBRARY_FQN_OR_NAME + existing_ref = _find_reference(lib_manager, match_target) + + if existing_ref is None: + msg = ("Library '%s' is not referenced in this project. Nothing to remove." + % LIBRARY_NAME) + print(msg) + print("Library Not Present: %s" % LIBRARY_NAME) + print("Project: %s" % project_name) + print("SCRIPT_SUCCESS: %s" % msg) + sys.exit(0) + + existing_name = getattr(existing_ref, 'name', '?') + is_ph = bool(getattr(existing_ref, 'is_placeholder', False)) + kind = "placeholder" if is_ph else "managed" + print("DEBUG: Found reference to remove -- name=%r, kind=%s" % (existing_name, kind)) + + # Verify remove_library is exposed (may not exist on very old SPs). + if not hasattr(lib_manager, 'remove_library'): + raise RuntimeError( + "lm.remove_library() is not available on this CODESYS SP. " + "Cannot remove library '%s' via script." % LIBRARY_NAME) + + # Remove using the name the script API already knows about (the name + # from lm.references, which is always the form remove_library accepts). + lib_manager.remove_library(existing_name) + print("DEBUG: remove_library('%s') returned without exception." % existing_name) + + # Verify removal succeeded by re-checking lm.references. + still_present = _find_reference(lib_manager, LIBRARY_NAME) + if still_present is not None: + raise RuntimeError( + "remove_library('%s') returned without error but the reference " + "is still present in lm.references. Project NOT saved." % existing_name) + + # Save only after confirmed removal. + try: + primary_project.save() + print("DEBUG: Project saved successfully after removing library.") + except Exception as save_err: + detailed_error = traceback.format_exc() + error_message = ("Error saving project after removing library '%s': %s\n%s" + % (LIBRARY_NAME, save_err, detailed_error)) + print(error_message) + print("SCRIPT_ERROR: %s" % error_message) + sys.exit(1) + + print("Library Removed: %s" % LIBRARY_NAME) + print("Project: %s" % project_name) + print("SCRIPT_SUCCESS: Library '%s' (%s, ref-name=%r) removed from project '%s'." + % (LIBRARY_NAME, kind, existing_name, project_name)) + sys.exit(0) + +except Exception as e: + detailed_error = traceback.format_exc() + error_message = ("Error removing library '%s' from project '%s': %s\n%s" + % (LIBRARY_NAME, PROJECT_FILE_PATH, e, detailed_error)) + print(error_message) + print("SCRIPT_ERROR: %s" % error_message) + sys.exit(1) diff --git a/src/server.ts b/src/server.ts index 79a37e2..42fb9cb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2077,6 +2077,44 @@ export async function startMcpServer(config: ServerConfig): Promise { } ); + s.tool( + 'remove_library', + "Removes a library reference from the CODESYS project's Library Manager. Idempotent: if the named library is not currently referenced, the tool succeeds with a no-op confirmation rather than an error. Accepts either the bare library name (e.g. 'Standard') or the fully-qualified 'Name, Version (Company)' form. Verifies removal in lm.references before saving. Per helpme-codesys.com ScriptLibManObject docs and the local SP22 stub Stubs/scriptengine/ScriptLibManObject.pyi.", + { + projectFilePath: z.string().describe("Path to the project file."), + libraryName: z.string().describe("Bare name of the library to remove (e.g. 'Standard', 'Util'). Must match a reference currently present in the project's Library Manager."), + libraryFqnOrName: z.string().optional().describe("Optional fully-qualified name 'Name, Version (Company)' to target a specific version when multiple references with the same bare name exist. Falls back to libraryName when omitted."), + }, + async (args: { projectFilePath: string; libraryName: string; libraryFqnOrName?: string }) => { + const escaped = resolvePath(args.projectFilePath, workspaceDir); + const fqn = (args.libraryFqnOrName || args.libraryName).trim(); + const script = scriptManager.prepareScriptWithHelpers( + 'remove_library', + { + PROJECT_FILE_PATH: escaped, + LIBRARY_NAME: args.libraryName.trim(), + LIBRARY_FQN_OR_NAME: fqn, + }, + ['ensure_project_open'] + ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `remove-library-${args.libraryName.replace(/[^A-Za-z0-9._-]+/g, '_')}`, + oldText: `(* remove Library *)\nname: ${args.libraryName}\nproject: ${args.projectFilePath}\n`, + newText: '', + }); + if (blocked) return blocked; + const result = await executor.executeScript(script); + // Script emits "Library Not Present:" on the idempotent no-op path + // and "Library Removed:" on actual removal. + const noopHit = result.output.includes('Library Not Present:'); + const successMessage = noopHit + ? `Library '${args.libraryName}' not referenced in ${args.projectFilePath}. No-op.` + : `Library '${args.libraryName}' removed from ${args.projectFilePath}. Project saved.`; + return await formatModifyingResponse(result, successMessage, escaped, mirrorCtx); + } + ); + // ─── Symbol Configuration Tools ─────────────────────────────────────── // // Wraps ScriptSymbolConfigObject (since CODESYS 3.5.10.0). The Symbol diff --git a/tests/integration/e2e.test.ts b/tests/integration/e2e.test.ts index ed7d4c6..4492b31 100644 --- a/tests/integration/e2e.test.ts +++ b/tests/integration/e2e.test.ts @@ -148,6 +148,33 @@ describe('E2E Script Preparation', () => { expect(script).not.toMatch(/\{[A-Z_]+\}/); }); + it('remove_library script renders without leftover placeholders and contains required markers', () => { + const script = mgr.prepareScriptWithHelpers( + 'remove_library', + { + PROJECT_FILE_PATH: 'C:\\test.project', + LIBRARY_NAME: 'Standard', + LIBRARY_FQN_OR_NAME: 'Standard, 3.5.17.0 (System)', + }, + ['ensure_project_open'] + ); + // Placeholders must all be substituted + expect(script).not.toMatch(/\{[A-Z_]+\}/); + // Substituted values must appear + expect(script).toContain('LIBRARY_NAME = "Standard"'); + expect(script).toContain('LIBRARY_FQN_OR_NAME = "Standard, 3.5.17.0 (System)"'); + // Core SP22 API call + expect(script).toContain('lm.remove_library' || 'remove_library'); + expect(script).toContain('remove_library'); + // references walk must be present (pre-check) + expect(script).toContain('references'); + // Idempotent no-op marker + expect(script).toContain('Library Not Present'); + // Success and error markers + expect(script).toContain('SCRIPT_SUCCESS'); + expect(script).toContain('SCRIPT_ERROR'); + }); + it('check_status script has no placeholders after load', () => { const script = mgr.loadTemplate('check_status'); // check_status has no {PLACEHOLDER} params