Skip to content

Latest commit

 

History

History
644 lines (548 loc) · 106 KB

File metadata and controls

644 lines (548 loc) · 106 KB

elemctl Specification

English · Русский

This document describes the Console API v2 contract of the 1C:Enterprise.Element platform (1cmycloud.com), the build file format, and the requirements for the elemctl tool. It holds only facts about the platform interface and requirements for the product: the implementation is designed from scratch.

1. Purpose and package composition

The elemctl Python package consists of three layers on top of a shared core:

  1. Library – a programmatic client for Console API v2 and high-level operations: build and deploy. Python standard library only.
  2. CLI – the elemctl console command, entry point elemctl in [project.scripts].
  3. MCP server – the same operations exposed as tools for AI agents, over the stdio transport. The mcp>=1.2,<3 dependency comes as the optional extra elemctl[mcp]. It uses the ergonomic server class of the mcp package: FastMCP in mcp 1.x, MCPServer in mcp 2.x.

Package requirements: name elemctl, version 0.1.0, Python >= 3.10, MIT license, author KeyFire, src/elemctl/ layout, dev extra with pytest. The LICENSE, README.md, .env.example, and .gitignore files are given and are not modified.

2. Connection configuration

Parameters are taken from three sources, in decreasing order of priority:

  1. explicit arguments (CLI flags --base-url, --client-id, --client-secret);
  2. environment variables;
  3. the .env file: path from the --env-file flag, or, without it, the .env file in the current directory, if it exists.

Environment variables:

Variable Meaning Required
ELEMENT_BASE_URL platform base URL starting with http:// or https://, e.g. https://1cmycloud.com yes
ELEMENT_CLIENT_ID Client-Id for obtaining the token yes
ELEMENT_CLIENT_SECRET Client-Secret yes
ELEMENT_APP_ID default application no
ELEMENT_PROJECT_ID default project no
ELEMENT_SPACE_ID default space no
ELEMENT_CA_FILE additional PEM CA bundle for a private cloud no
ELEMENT_TLS_STRICT strict RFC 5280 certificate checks; true by default no
ELEMENT_TLS_VERIFY certificate and hostname verification; true by default no

.env format: KEY=VALUE lines. Empty lines and lines starting with # are skipped. A leading export prefix is allowed, and the value may be wrapped in single or double quotes. The encoding is UTF-8 and a BOM is possible, so read the file as utf-8-sig. A trailing slash in ELEMENT_BASE_URL is trimmed. A nonempty base URL must start with http:// or https://.

3. Authentication

Obtaining a token: POST {base}/console/sys/token

  • header Authorization: Basic base64(client_id:client_secret);
  • body grant_type=client_credentials, Content-Type application/x-www-form-urlencoded.

The response is a JSON object, and the token sits in the first non-empty of the fields id_token, token, value, access_token. Special case: the access_token value can be the string "Not implemented". That is not a token, so skip the field.

All other requests use the header Authorization: Bearer {token}.

The token lives for about an hour. Cache it in a file in the system temporary directory that tempfile.gettempdir() reports, not in a hardcoded /tmp, because the tool also runs on Windows. The cache lives an hour, and its key must distinguish base_url and client_id pairs. On a 401 response, refresh the token forcibly and retry the request once.

4. Console API v2 contract

Common prefix: {base}/console/api/v2. Request and response bodies are JSON, apart from the build upload. Field names are in kebab-case.

The newer console reference documents Console API 2.1 as the main version, with the prefix {base}/console/api/v2.1, and keeps 2.0 beside it, working. The client stays on 2.0 and takes 2.1 only for what 2.0 has not got: the extensions applied to an application (section 4.1). A console that has no handler for a path answers 401 rather than 404, with the text Handler of HTTP request "[<method>] <path>" in application "console" not found., and that is how a server older than a method refuses it. Such a 401 is not about the token, and a renewed one gets the same answer, so the client reads the text before the token is renewed: the answer is named as a method the server does not know, and no new token is asked for. Any other 401 renews the token and repeats the request once.

4.1. Applications

  • GET /applications – list. The name query parameter exists, but the platform ignores it and returns the full list. That was verified against a live instance, so filtering by name has to happen on the client.
  • GET /applications/{id} – card. Significant response fields: id, status, uri (address of the running application), error (error text, if any), technology-version, date-updated, display-name, publication-context, source (an object with source information, containing among other things project-version – the version of the applied build).
  • POST /applications – create. Body:
    • source – the object {"type": "repository"} plus exactly one of the keys: project-version-id (id of the source build) or image-id (project id);
    • display-name, publication-context – publication name and path;
    • development-mode – boolean, whether to create a development environment;
    • optional space-id, technology-version.
  • DELETE /applications/{id} – delete.
  • PUT /applications/{id}/status/start – start.
  • PUT /applications/{id}/status/stop – stop.
  • POST /applications/{id}/actions/debug – data for a debug session (ApplicationDebugInfo: {"debug-token": ..., "debug-address": ...}). The request body is empty; requires debugging enabled on the server (config/debug.yml enabled: true).
  • POST /applications/{id}/project/update – apply a build to the application. Body: {"source": {"type": "repository", "image-id": "<build id>"}} or {"source": {"type": "repository", "project-id": "<id>", "assembly-version": "<version>"}} (assembly-version is optional). A build of an extension goes to the same method and is applied beside the build of the application: source.project-version-id of the card goes on naming the build of the application, and the extension shows up in extension-projects of the 2.1 method below, with the version of the build in assembly-version. Checked live, on an apply that went through and on two that the platform rolled back.
  • POST /applications/{id}/project/export – the build the application runs. The reference documents the method under 2.0 and 2.1, and a live check got the same archive from both. The request has no body, and the answer is the archive itself, application/octet-stream with no Content-Disposition: the files of the build, Assembly.yaml among them, packed anew with entries for the directories. The console takes the archive from the application server, so it is the project the application runs. The extensions applied to it are not in the archive; they have a method of their own (the 2.1 methods below). By the sources of the console, the export of an application in the middle of an update, or of one whose update has failed, answers with the build uploaded for that update, and a live check during an update got exactly that. The manifest is the one the server keeps for the build: a build uploaded into a project comes back with the number the server gave it (section 4.4) and the time of the upload in Created, while a build that created its project comes back with the manifest of its archive. The handler of 2.1 also takes an undocumented extension-id query parameter and then answers with the build of that extension, the way /v2.1/applications/{id}/project/{ExtensionId}/export does; 2.0 ignores the parameter. The controller lets in any authenticated user and reads the application with that user's rights.
  • POST /applications/{id}/dumps – create a dump. Body: include-users, include-binary-data (booleans), description (string).
  • GET /applications/{id}/dumps/{dumpId} – dump status.
  • GET /applications/{id}/users – the users connected to the application, each {user-list-id, user-id, presentation, is-admin, token-access-enabled}. The users of the control panel are among them: the platform connects them itself, and it names such a user by the login. token-access-enabled is the access of the user to the HTTP services of the application by a token. Without it a call of a service with the user's token is refused with a 500 "Token access is denied", whatever the rights of the user.
  • PUT /applications/{id}/users/change-token-access – switch that access. Body: {"user-list-id", "user-id", "enable-access"}, all three required; the answer is the connection as it became. A user who is not connected to the application is refused with a 500 "Can't change user token access" and no word about the cause, so the client finds the connection first and names the user when there is none. The reference documents the method under v2 and v2.1 with the same body, and a live check got the same answer from both; the client takes the v2 prefix, like every other request.
  • GET /me – the user the credentials belong to: id, login, presentation, user-list-id.
  • GET /v2.1/applications/{id}/project – the projects the application runs: application-project, and the extensions applied to it in extension-projects. An extension carries id, project-id, assembly-id, enabled, order, vendor-name, project-name, project-presentation, project-version and assembly-version. id is the id of the extension on the application server: the Ид of the extension's Проект.yaml, the configuration-id its upload answered with (section 4.4). The console fills assembly-id of an extension with the id of its project, which a live check and the sources of the console agree on, so that field names no build. The same path of 2.0 answers with the application project alone.
  • POST /v2.1/applications/{id}/project/{ExtensionId}/export – the build of an applied extension. The request has no body, and the answer is the archive itself, application/octet-stream with no Content-Disposition: the files the build was uploaded with, Assembly.yaml among them, packed anew. The segment is the id of the extension from the previous method, in either case of letters. The reference calls the parameter the id of the extension project, but the project-id gets the same bare 500 "Unable to export extension ... from application ..." as an unknown value, with "Contact administrator for details" in details. A stopped application exports all the same, and a deleted one gets a 404 on the listing. 2.0 has no such method: /v2/... gets the 401 of a missing handler. The controller of the console lets in any authenticated user (PermitAuthenticated) and reads the application with that user's rights.

Application statuses: stable Running, Stopped, Error; transitional Starting, Stopping, Initializing, Updating, Frozen, Creating, Deleting. During transitions the status field may also be empty. The reference describes status as a plain string and lists no values, so this list is what live checks have met. Deleting lasts while the DeleteApplication task runs, and then the application stays in the list under Deleted (section 6.9). UNKNOWN, in capitals, was met on an application whose database files were gone: it could be neither started nor deleted, because a deletion begins with an export of the data. The console gives that status to a server state of the application it has no name for, so nothing says it is final, and the client counts it as neither stable nor transitional: a wait for a status puts up with UNKNOWN for a minute in a row, then stops and names it rather than running out its whole timeout.

4.2. Technology version

  • Reading – from the technology-version field of the application card (a dedicated read endpoint is not present in all platform versions – do not use it).
  • Update: POST /tasks/group-tasks/update-applications-technology, body {"technology-version": "<version>", "applications": ["<app-id>"]}. Returns a group task; its status – GET /tasks/group-tasks/{taskId}.

4.3. Spaces and projects

  • GET /spaces – list of spaces.
  • GET /projects – list of projects; GET /projects/{id} – card; DELETE /projects/{id} – delete. The list is answered in full whatever the query, and deleted projects stay in it under the deleted flag with their former id. Filtering by name and hiding the deleted ones is the client's work, as with applications (section 4.1). A project card carries no status at all: the deleted flag is the only mark of its state. The kind of a project is in project-kind: Application, Library or Extension, and Group for the group of projects the list carries beside its members.

4.4. Project builds (assemblies)

  • Uploading a build file – a binary POST (Content-Type application/octet-stream, body – the file bytes):
    • POST /projects/{id}/assemblies – add a build to an existing project;
    • POST /projects – create a new project from a build, or add the build to the project that carries the Ид of its Проект.yaml;
    • POST /spaces/{space-id}/projects – the same, with a new project created in that space. The space of an upload into a project goes as the optional space-id query parameter, spelled the way the reference spells it. A live check found the server reading it: an id that is not a UUID is refused with a 400 and an unknown space with a 404, while the PascalCase SpaceId the client used to send went through unread. POST /projects documents no space parameter, and the server reads neither spelling there: a new project uploaded with space-id=not-a-uuid was created all the same. So the space of a new project goes into the path of POST /spaces/{space-id}/projects, which answers an unknown space with a 404 and adds a build whose Ид already has a project to that project, the way POST /projects does. The commit the build was made from goes as commit-id, a query parameter the reference documents for POST /projects/{id}/assemblies, and the server puts it on the build card: that is the commit the schema guard of the next deploy compares against (section 7). The reference lists branch-name, commit-message and modified for the same method too, and the client sends none of them. A live check found the server taking them and showing none on the card: its branch-name names a branch of group development, not of git, and modified=1 is refused with a 500. The PascalCase CommitId, BranchName and CommitMessage are not parameters of the method at all, and the server ignores them. POST /projects documents no commit parameter, so a build that creates its project carries no commit. What the platform does not keep – the branch, whether the tree had uncommitted changes, the directory of the sources – the client keeps in a local registry of uploads (section 7). The two uploads treat the version of the archive differently. An upload into a project, POST /projects/{id}/assemblies, does not read it at all: the server takes the base from the Версия of the project descriptor and gives the build the highest number it has ever given in that base plus one. Checked live: an archive of 1.0.0-i1 became 1.0.0-1, the next one, 1.0.0-10001, became 1.0.0-2, a 1.0.0-2 already in the project became 1.0.0-3, and 1.0.1-7 became 1.0.0-502 in a project whose descriptor says 1.0.0. A deleted build keeps its number: the build list no longer shows it, and the server counts on from it all the same. With 1.0.0-4 deleted from the top of the list, the next upload became 1.0.0-5, and with 1.0.0-50 and 1.0.0-51 deleted, it became 1.0.0-52 while the list ended at 1.0.0-8; a build deleted from the middle of the list changes nothing. The count is nowhere to be read: the project card does not carry it, the build list does not show a deleted build, and the card of a deleted build answers with a 404. That upload answers with the card of the build, and its assembly-version is the number the server handed out. An upload without a project id, POST /projects, keeps the version of the archive as it is, a suffix that is not a number included, and answers a version the project already has with a 409 (below). The number of a deleted build is free for it again, and taking it does not wind the count back: an archive of 1.0.0-2 went in under that number after the earlier 1.0.0-2 had been deleted, and the next upload into the project became 1.0.0-8, not 1.0.0-3. So the suffix of a version, the number of a CI run for instance, survives the second way and never the first: what ties a build uploaded into a project to its run is the name of the archive and the commit on the card. The response carries the id of the created build in one of the fields image-id, assembly-id or id, checked in that order. Next to it sits an artifact object describing the project the build landed in: artifact-id is the project id and opens as a project card, configuration-id is the Ид of Проект.yaml, and name is the project presentation. The console shows a project under the presentation of the build uploaded last, the Представление of its Проект.yaml, not under the manifest Name: builds of one Ид uploaded with a new Name and a new presentation each left the project card named after the presentation every time. So a build uploaded into a project under a different name renames that project and its group, and deleting the build does not undo it. The client refuses such an upload and names the price; uploading a foreign build on purpose takes --force-rename. An upload without a project id renames a project the same way when its Ид leads to a project that has another name (below). Nothing can be compared before such an upload: the server chooses the project, and no method finds a project by its Ид. So the client reads the project list before the upload and holds the artifact of the answer against it: a project the list did not have was created by the upload, and a project it had under another name was renamed by it (section 7, builds upload). The check is best effort: when the names cannot be compared, because the manifest is unreadable or the project card is unreachable, the upload proceeds as before. Not being able to compare is no proof of danger.
  • A project is identified by the Ид of Проект.yaml, the configuration-id of the answer (section 6.8). POST /projects therefore does not always create a project: when a live project carries that Ид, looked for in the space of the upload first and then in any other, the build is added to it and its artifact-id comes back in the response, whatever the vendor and the name of the manifest. The vendor and the name are a constraint rather than the key: one project of a space per pair. A live check took the two apart. Builds of acme/crm, acme/crm-next and globex/crm with one Ид all went to one project, which took the name of each in turn, through POST /spaces/{space-id}/projects as well. A build of acme/crm with a fresh Ид got a 409. There are two ways to hit a 409 ALREADY_EXISTS. The first is uploading a version that is already there, answered with "Версия сборки ... уже присутствует в группе проекта". The second is a fresh Ид with a vendor and a name another project of the space already holds, answered with "Сборка с именем поставщика ... уже зарегистрирована в другом проекте": the console starts a new project for the Ид and refuses the build it cannot register there, and the new project goes with the refusal. The earlier checks had the Ид and the pair agree, so they could not tell the key from the constraint; the source of the console looks an upload without a project id up by the Ид (FindProjectById).
  • GET /projects/{id}/assemblies – list of builds. Each element contains assembly-version, a string like 1.0-42, and an id in id or image-id. The response is either an array or an object with the list in the items or assemblies field. The method has no pages and reports no total. limit, size, pageSize, count, top, maxResults, page, pageNumber, offset, skip, from and start are all ignored: the answers to every one of them match byte for byte, and neither the headers nor the body carry a counter or a cursor. Verified by live calls on two installations. The platform deletes builds nobody uses, and age has nothing to do with it: the vendor's help calls this automatic deletion of unused builds. The console does it at one moment only, when an application finishes applying a build: a background job then goes over the builds of that application's project with the same base version as the build it now runs. A release build stays, and so do a protected build, the project's default build, a build a live application of the project runs or has had uploaded into it, and the build with the highest number of that base. The rest go, ten at most per apply. A build uploaded without a project id (POST /projects) is protected on arrival, like the build the project's repository was created from, so no apply removes it, while a build uploaded into a project by its id (POST /projects/{id}/assemblies) is not protected. A library build no remaining build depends on goes with the builds of its group. The builds of an extension project are outside the housekeeping altogether. An extension is applied by the same update of the application, and the job that follows starts from the build the card of the application names: it goes over the application's own project and takes the unused builds of that base there, while the extension project keeps every build, the ones no application runs included, until they are deleted by hand or go with the project. A live check agreed with the source: the application ran 1.0.0-1 of its project, 1.0.0-2 and 1.0.0-3 had been uploaded into that project by id, and the extension project held 1.0.0-1 to 1.0.0-4. Applying the extension build 1.0.0-2 took 1.0.0-2 of the application project and nothing of the extension project, and applying 1.0.0-4 after it left the extension builds 1.0.0-2 and 1.0.0-3 where they were. So a project nobody applies anything to keeps every build, and a build uploaded for later survives the apply of another one only while its number is the highest. The source of the console in the server distribution says so, and a live check on a project of nine builds agreed with every line of it. Creating an application deleted nothing in two minutes. Applying 1.0.0-3 took 1.0.0-2, 1.0.0-4 and 1.0.0-11 away before the apply returned, and left 1.0.0-1 and 1.0.0-10, both uploaded without a project id, 1.0.0-3, 1.0.0-12, the highest, and both builds of 2.0.0. Applying 1.0.0-12 took 1.0.0-3. Applying 2.0.0-1 took nothing, 1.0.0-12 included, since that apply looked at 2.0.0 alone. Seen earlier: seventeen builds of one day's series were made and one survived, the one the application runs, while a build from two months earlier is still listed: it is the project's first, and the upload that creates a project is an upload without a project id. So a listing is not the project's history and not a page of it. It is what survived, and the client must say so.
  • GET /projects/{id}/assemblies/{version} – build card, DELETE .../{version} – delete. The last segment is what the method calls it: the version, which is assembly-version or project-version, a string like 1.0-42. It is not the id of the card: a UUID there is answered with a 404 "Assembly with version not found". Checked live on two installations of different ages, both behave this way. The pages used to claim the opposite, that only a UUID works and a version gets a 400 "Version is not a valid UUID"; anyone following that claim was left with an unreachable card whichever form they gave. An id is still an address a caller holds: the build list prints it, and an upload answers with one. So the client accepts both forms and looks the value up in the build list to get the version the method takes. A value in no card is named as missing instead of becoming a 404 out of the depths of the platform. When the address is refused with a 400 or a 404, the client tries the id as the segment too: no reachable installation wants it, but the 400 the pages described had to come from somewhere, and the second spelling costs one request. The version of a build uploaded into a project is the number the server gave it, not the one of its archive (the upload above). Deleting a build is rejected with a 500 while an application created from it still exists (section 6.9); once that application is really gone, the same request succeeds. A 500 is an answer, not a misunderstood address: it is raised as it is and never retried with another spelling.

Build versions are compared by the numeric suffix after the last hyphen: 1.0-10 is newer than 1.0-9. Lexicographic comparison gives the wrong order.

4.5. Development environment branches

  • GET /branches – list; optional queries project-id, name.
  • GET /branches/{id} – card. Fields: name, kind, project, application, source-branch, deletion-mark, version-stamp.
  • POST /branches – create. Body: name, kind: "development", project: {"id": "<id>"}, optionally application: {"id": "<id>"}.
  • PUT /branches/{id} – modify. The platform uses optimistic locking, so read the card first, then send a body assembled from the current values. Those are name, kind, deletion-mark and version-stamp, which has to come back exactly as it was. Collapse source-branch and application to {"id": ...}, or to {"name": ...} when there is no id. To rebind to an application, replace application with {"id": "<new app-id>"}.
  • Branch changes are accepted by that same PUT /branches/{id} with an additional body key write-parameters: {"merge": true}.
  • DELETE /branches/{id} – delete the branch.

The tool works only with the documented Console API v2. It neither uses nor describes the internal, undocumented APIs of the platform console.

4.6. Application tasks

GET /tasks/application-tasks – list of tasks for all applications. There is no server-side filter, so filter on the client. Task fields: id, application-id, status (including Error, Failed), operation-type, error-message, start-date (ISO 8601, may end with Z).

4.7. User lists

A user list holds either the users of an application, which has a list of its own named after it, or the users of the control panel, which has one list per installation. What the panel calls the sign-in settings lives here.

  • GET /user-lists – the list of user lists: id, presentation, space-id. There is no server-side name filter, so filter on the client.
  • GET /user-lists/{id} – the full card: self-registration, password-policy, password-policy-enabled, account-services-settings, confirmations, the gateways, include-personal-data-in-messages.
  • GET|PUT /user-lists/{id}/settings/self-registration – {enabled, phone-required, email-required}. This is the panel's "allow users to register themselves". The PUT wants the whole object.
  • GET|POST /user-lists/{id}/settings/account-services-settings, PUT|DELETE .../{account-service-id} – the account services of the list. An entry is {account-service-id, account-service-type, local-id, enabled, create-user-on-auth, additional-settings}. The type Local authenticates by a password, so the panel's "allow signing in with a login and a password" is that entry being enabled. The other types are external services: OIDC, Cas, ActiveDirectory, Esia. The PUT wants the whole entry back.
  • GET /applications/{id}/userlists – the ids of the lists connected to the application; POST connects, DELETE disconnects. Note the spelling: no dash here. There are no per-connection settings: the link is a set of ids and nothing more.
  • GET /user-lists/{id}/users – the users of the list, id and login among their fields. An entry carries the access tokens of the user as well, secrets included, so the client takes nothing but the ids out of it.
  • The application card names the application's own list in default-user-list. That is the list of its users, and the panel list connected to it is a separate one.

Two things the panel can do and the API cannot, so both stay manual:

  • the composition of an application's authentication forms is not in the API at all;
  • the connection setting "users of the list are connected to the application automatically on sign-in" is not represented either. It is in neither userlists, which is a set of ids, nor the application's account-services-settings; on a stand where the setting is on, nothing appears there.

The rules for parsing the response of an account service are accepted in the body of an account service under the key userPropertiesCalculationRules. Those rules are presentation-rule, email-rule, phone-rule and response-kind, all of them JsonPath or XPath. There are two traps. The schema of the reference calls the same thing calculation-rules, and the platform answers 400 to that spelling. And a GET never returns the rules: the setting is write-only, so an API client cannot confirm it applied.

5. Build file format (.xasm / .xlib)

A build file is a ZIP archive (deflate):

  • at the root, Assembly.yaml – the manifest, flat key-value pairs:

    ManifestVersion: 1.0 | 1.1
    ProjectKind: Application | Library | Extension
    Vendor: <vendor>
    Name: <project name>
    Version: <version, e.g. 1.0-42>
    Created: <UTC, format YYYY.MM.DD HH:MM:SS>
    BranchName: <git branch name>
    CommitId: <commit hash>
    

    For a library (ProjectKind: Library), a Release: line (empty value) is added at the end; the file extension is .xlib, for an application and an extension – .xasm.

    An extension (ProjectKind: Extension) is written with ManifestVersion: 1.1, every other kind with 1.0. The server picks the reader of the manifest by its version, and the reader of 1.0 knows Application and Library alone: an extension under 1.0 is refused on apply with "Unknown project kind: "Extension"". The console writes the same pair for an extension it creates. An extension built this way was applied live through project/update and exported back file for file (section 4.1).

  • then the project files at paths {vendor}/{name}/... relative to the repository root. The project directory must follow the scheme {repo}/{vendor}/{name}/Проект.yaml. For an application and an extension, locally available libraries declared in Библиотеки/Libraries are included recursively; the console gives an extension its libraries the way it gives them to an application. The projects an extension extends, listed under РасширяемыеПроекты/ExtendedProjects, are not libraries and stay out of the archive. A declared library without a project under the same repository root stays an external platform dependency. Unreferenced sibling projects are not included. Path separators in the archive are forward slashes, on Windows too.

Build file name: {Имя} {Version}.xasm (with a space).

Project metadata comes from Проект.yaml. It is YAML, and parsing the flat top-level "key: value" pairs is enough; skip the nested indented lines. Bilingual sources are a capability the platform declares, and a descriptor written with English keys deploys fine, so every key is read in both spellings: Имя/Name, Поставщик/Vendor, Версия/Version (base, e.g. 1.0) and ВидПроекта/ProjectKind. The value Библиотека or Library means a library, Расширение or Extension an extension; anything else means an application. When both spellings of a key are present, the Russian one wins. The service file names are bilingual too: the platform converter accepts Project.yaml and Проект.yaml, Subsystem.yaml and Подсистема.yaml. So the descriptor is looked up by both names everywhere: project discovery, local library dependencies, archive inspection, the schema guard's availability probe.

When the build version is not set explicitly, it is built as {base version}-{N+1}, where N is the counter from the version of the project's latest build of the same base version. deploy takes N as the higher of the latest build in the list and the highest number the local registry of uploads remembers for the project in that base: the server counts on from the highest number it has ever given, deleted builds included (section 4.4). A project bumped to a new base version starts from -1 again, whatever counters the old base reached. With no last build of that base, the suffix comes from the CI run number in the environment: the first numeric value of CI_PIPELINE_IID, GITHUB_RUN_NUMBER, BUILD_NUMBER, in that order. Otherwise a clean CI checkout would produce -1 every time. With no CI number either, the version is {base version}-1.

Git metadata, meaning the commit hash and the branch name, comes from the git repository that contains the project directory. When git is unavailable, the fields stay empty.

File selection for the archive:

  • inside resource directories, whose literal name is Ресурсы, files of any extension are included, at any level and in their subdirectories too: per the platform documentation a resource is an arbitrary file, such as .pdf, .htm, .mxl, .docx or .xsd;
  • outside resource directories only these extensions are included: .yaml .xbsl .xbql .md .txt .json (sources), .png .svg .jpg .jpeg .gif .webp .ico (images), .css .htm .html .js .woff .woff2 .ttf .eot (web resources);
  • the description files of a SOAP service client are included wherever they lie: <Client>.Wsdl.<n> and <Client>.Xsd. The platform puts them next to the project element rather than into a resource directory and forbids renaming them, so they are matched by name, not by extension;
  • the directories .git, .claude, .github, __pycache__, node_modules, .venv and all hidden ones (starting with a dot) are excluded;
  • the files .gitignore, .env, .DS_Store and any *.xasm or *.xlib are excluded, inside resource directories too.

5.1. Parsing a built archive

The reverse of a build: from a .xasm or .xlib file you get the manifest, the project properties from its Проект.yaml inside the archive, and the contents. It is needed to attach a library to a project without unpacking its sources.

The layout inside a project; only the directories tell the truth about the contents:

  • a first-level directory is a subsystem. Подсистема.yaml or Subsystem.yaml is optional, and a library subsystem may have none at all, so it cannot be relied upon when looking for subsystems;
  • a nested directory of a subsystem is a package. A package has no description file, and every directory contributes a name segment;
  • the qualified name of a type: {vendor}::{name}::{subsystem}[::{package}]::{TypeName}. The same name without the last segment is what Использование and импорт take.

Only types with ОбластьВидимости: Глобально, in English VisibilityScope: Global, are visible outside, in the project that attached the library. The default is ВПодсистеме, or InSubsystem, and the global scope is written explicitly. An English descriptor carries English enumeration values as well, so those values are read in both spellings.

Compatibility is checked against the РежимСовместимости property of Проект.yaml. The ВерсияТехнологии property does not exist in Проект.yaml: it belongs to the body of the Console API request that creates an application, not to the project file.

6. Platform behaviour you have to account for

  1. Silent rollback of build apply. When applying a build to the application fails, a compilation error for instance, the platform silently rolls the application back to the previous build and starts it. The Running status does not mean success. A reliable check of the result looks like this:
    • take the application tasks (section 4.6) with status Error or Failed whose start-date is not earlier than the moment the deploy started. Old errors from history do not count;
    • compare the actually applied version, the source.project-version of the application card, with the version of the uploaded build;
    • a build of an extension is compared with the extensions of the application instead, extension-projects of GET /v2.1/applications/{id}/project (section 4.1): the card names the build of the application alone, before an extension is applied and after it. The extension of the build's project has to run the version of the build, and it has to be enabled. A server without Console API 2.1 has no such list, so the apply of an extension cannot be verified there, and the check says so rather than report a rollback;
    • for information, make a check GET against the application uri. Codes 401 and 403 are normal for closed applications and do not contradict success.
  2. Empty skeleton on creation. On some platform configurations an application created with a "project" source, meaning image-id set to the project id, comes out empty, with no project data. A reliable source is a specific build in project-version-id, for example the project's latest build.
  3. Deletion with drafts. If the application's development environment has unpublished edits, DELETE /applications/{id} returns 400 with FAILED_PRECONDITION in the body. There is no forced deletion in the API, only the control panel, and the tool must provide a clear hint.
  4. Readiness of a new application. After creation, the application sits in transitional statuses and without a uri for some time, so provide for waiting until it is ready: a uri has appeared and the status is stable. An Error status while waiting is an immediate error. A read of the card that breaks off on the network is a missed poll, not the end of the wait, since the application is being created all the same. UNKNOWN held for a minute in a row ends this wait as well, with an error naming the status (section 4.1): while the task that creates the application runs, the console reports Initializing whatever the server says, so UNKNOWN comes only after that task. The task list of section 4.6 carries the tasks of every application at once and is the read that breaks off most, so a broken read of it is made again, three times in all.
  5. Restart after apply. project/update may restart the application itself. After the call, wait until it leaves the transitional statuses. If the result is not Running, stop it unless it is already Stopped, wait for Stopped, start it and wait for Running. Reasonable waits: about 3 minutes for a stop, about 5 minutes for a start and stabilization, polling every 10 seconds or so. A read of the card that breaks off is a missed poll in these waits too, as in section 6.4. UNKNOWN held for a minute in a row ends each of them with an error naming the status (section 4.1).
  6. Error is a final status. A stable Error, after a failed apply for instance, is an immediate failure: surface the error messages of the application tasks (section 4.6) right away. Do not stop or restart such an application, and do not keep waiting for another status: from Error it never moves to Stopped, and the wait just burns the whole time budget.
  7. Windows. Temporary files and caches go through tempfile only. Switch console output to UTF-8 with reconfigure for stdout and stderr, otherwise Cyrillic breaks.
  8. The project is identified by its Ид. A platform project is identified by the Ид of Проект.yaml, and the Vendor + Name pair of the manifest only has to be free of the other projects of the space. An upload without a project id lands in the live project that carries that Ид, under whatever vendor and name, and renames it after the build; a fresh Ид with a pair another project already holds is refused with a 409 (section 4.4). A truly separate project takes a fresh Ид and a fresh pair together, which is why an isolated compilation check is built around a throwaway application rather than a throwaway project.
  9. Deletion runs in the background and in order. DELETE /applications/{id} returns immediately, and the application lives on for a while with a DeleteApplication task. While it exists, deleting the build it was created from is rejected with a 500. The order for cleanup is: delete the application, wait until its card answers 404 or its status becomes Deleted, and only then delete the build. A read of the card that breaks off during this wait is a missed poll as well, as in section 6.4.
  10. Compilation is the server's, and it happens on apply. A local build only packs an archive. The syntax, the types and the visibility of the sources are checked by the server compiler when a build is applied or an application is created out of it. There is no separate "compile" endpoint, so the only way to check the sources without risking the working application is a throwaway application created from the same build (section 7, probe).
  11. Signing in to a freshly created application. A new application gets its own empty user list (default-user-list, section 4.7), password sign-in in it is off, and it has no account service. The accounts used to sign in to other applications therefore do not work here, and connecting another application's user list (POST /applications/{id}/userlists) together with enabling the local sign-in does not change that; this was verified. What works is a control-panel account: the platform connects its users to the application itself, and the sign-in works right away. The tool has to say so out loud when it creates an application (section 7, apps create and apps ensure): the way in does not follow from the card, and it cannot be found by trying, because a user has a failed-attempt counter.
  12. A server that is still starting. After a start or an update the server takes minutes to bring its console up; thirteen of them were seen after one update. All that time every console request, the token request included, is answered with a 404 whose text names the console application: Application "console" not found. That is the status a missing application or build gets, so only the text tells them apart. The client recognizes the answer in one place, for every request it makes, and raises an error that says what it means: the server is starting, repeat once /console answers 302. deploy waits it out by itself at any step of its cycle, for up to --server-start-timeout seconds (900 by default, 0 turns the wait off), and announces the start and the end of the wait. A request the console refused was never processed, so asking again is safe whatever its method. The other commands do not wait: they name the cause and stop.
  13. A removal is applied without a question. A build whose sources no longer have a tabular part is applied as it is: the part goes, and its rows go with it. There is no refusal and no warning, the task ends well and the application keeps running; that was seen on a catalog with a row in the part. An attribute of a tabular part and an attribute of the object go the same way, with their values, and so does a catalog whose description is gone from the sources, with its whole table: that was seen on an empty catalog. A narrowed length or a changed type recreates the data of the object instead. What the deploy does about each is in section 7.

7. CLI requirements

Common flags are accepted in any position, after the subcommand too: --base-url, --client-id, --client-secret, --env-file, --timeout (seconds, default 60), --lang, --json, --quiet. --version is the exception and stands before the subcommand.

A key that takes one value cannot be repeated with a different value: the parser refuses such a call before the command runs, with exit code 2 like any other refusal of argparse, and names the key and both values. --output a --output b used to write to b, and nothing said that a had been dropped. The same value given again passes, because a script that assembles a command line out of parts may well repeat a key. --key=value and --key value count as the same key. The values are compared as the command gets them, so --limit 5 and --limit 05 agree. The rule covers the common flags wherever they stand, the keys of every command and the keys a plugin declares (section 10). The keys meant to be repeated (apps list --status, apps token-access --user, probe --cleanup and a multiple argument of a plugin) collect every value, and a flag given twice means the same as a flag given once.

The connection flags are about talking to the platform. build and inspect never do that, so they refuse those flags instead of dropping them quietly. A call carrying --env-file reads as a build bound to a stand, and twice that left a reader asking whether a local build goes to the server after all; silence of that kind is what misleads. Nothing that worked stops working, because the flags changed nothing, and the refusal names the commands that do reach the platform: deploy and builds upload. It covers the flags only. ELEMENT_* variables and a .env next to the project are always around, and whether a build runs must not depend on them.

Output works like this: the result is JSON on stdout (ensure_ascii=False, indent 2), progress of long operations comes as lines on stderr, and an error is JSON with an error field on stderr plus return code 1.

--json makes that a guarantee rather than just a convention: for the duration of the call stdout is redirected to stderr, and the answer alone is written to the real stdout. A caller then parses stdout whole. Without the flag, anything a handler or a plugin prints stays in the stream ahead of the answer. A failure keeps to stderr and leaves stdout empty in this mode too.

--quiet silences the progress stream for the duration of the call: the progress lines, the summaries and the warnings that go to stderr, a plugin command's context.log among them. The answer on stdout and a failure on stderr with its exit code stay, and the form of the answer does not change: --brief and --json are the keys for that. A plugin command written with --quiet after it used to be refused as an unrecognized argument before it did any work.

apps list and builds list print their count and truncation lines after the answer, never before it. Other stderr output can still come first even for these two commands – a warning such as the one ELEMENT_TLS_VERIFY=false prints, or another command's own progress lines – so only stdout read on its own is safe to parse as a whole.

Commands, with the significant flags in parentheses:

  • token – obtain and print the token.
  • apps list [--name --status --include-deleted --brief], apps get [APP_ID], apps find NAME [--include-deleted], apps create NAME [--project-id --version-id --latest-build --space-id --tech-version --no-dev-mode --wait --verify --no-verify], apps ensure NAME [--project-id --version-id --latest-build --space-id --tech-version --no-dev-mode --wait --verify --no-verify --apply], apps apply [APP_ID] VERSION_ID, apps delete APP_ID, apps start [APP_ID], apps stop [APP_ID], apps users [APP_ID], apps token-access APP_ID [--user --enable --disable], apps export [APP_ID] [--output], apps export-extension [APP_ID] EXTENSION [--output].
    • apps list --name filters by a case-insensitive name substring, and it does so on the client because the platform ignores the query parameter (section 4.1). --status selects by the whole status word, several of them separated by commas or given by repeating the key. --brief prints brief cards instead of full ones: id, name, status, uri, applied version.
    • apps list hides the deleted applications. They stay in the platform list under the Deleted status, and a stand a few months old answers with hundreds of cards of which a handful are alive. --include-deleted brings them back, and so does --status deleted: a filter that would answer with nothing is worse than no filter. A cut nobody is told about is a trap of its own, so the command ends with a count line on stderr, "7 live of 324", plus the number shown when a filter narrowed the answer further. Whatever is cut, stdout stays the same JSON array.
    • apps get adds applied-build to the card: the brief card of the build the application runs, with its branch and commit from the build card or from the local registry of uploads, in the shape builds list --brief prints. It is null when the card names no applied build.
    • APP_ID of apps get, delete, start, stop, debug and users is the application id (UUID) or its exact name. A value that is not a UUID is resolved through the list by an exact case-insensitive match, and deleted applications do not count. No match is an error, and several matches are an error listing the ids: destructive commands must not guess.
    • apps find searches for an exact, case-insensitive name match among the fields name, display-name and publication-context. Output is {"id": ..., "found": true|false} with return code 0 in both cases: the absence of an application is an answer, not an error. A non-zero return code means the request failed and comes with JSON carrying an error field on stderr. In scripts, check the found field, not the return code.
    • Deleted applications remain in the platform list with the Deleted status and their former id. apps find skips them: the found id must be usable, otherwise the caller gets an id on which apps get and deploy return 404. The --include-deleted flag restores the former behaviour, searching among all applications including deleted ones.
    • apps ensure idempotently brings an application with the given name into existence: it searches by the apps find rules, where deleted ones do not count, and creates only if absent. Output is {"id": ..., "created": true|false, "sign-in": ...}, and created: false means the application already existed and was left alone. The creation flags are the same as for apps create and take effect only when creation happens. An existing application is never recreated: delete and create produce a new URL and break external bindings to the former one. Because the creation flags include --version-id, an existing application gets two more fields in the answer: applied, whether it runs the requested assembly, and applied-version-id, the one it does run. Staying silent about that cost a stand that went to work on the previous build. An assembly the card does not name is looked up among the builds of the extension projects and judged by the extensions of the application, the way apps apply and verify-deploy judge it (section 6.1): the card names the build of the application alone, and ensure used to answer applied: false for an extension that ran the very build asked for. For such a build applied-version-id names the build the extension runs, extension-project-id and extension name its project and its row of extension-projects, and a server without Console API 2.1 gets applied: null, since it cannot tell. --apply brings the application to the assembly in the same call, and apps apply does it separately. --verify upgrades the verdict about an application that already runs the requested assembly from that comparison to the full check. An application ensure had created used to answer applied: true on trust, since it was created from that assembly, and now answers with the checked verdict whenever a check ran.
    • apps apply [APP_ID] VERSION_ID applies an already uploaded assembly to an application and verifies the result (section 6.6). An apply is not a fact until it is verified: on a failure the platform silently rolls the application back to the previous build and starts it. The output is the verification report, and the exit code is 1 when the assembly did not land. Until this command, applying was reachable through the MCP tool alone, while long operations are the ones that want a CLI run in the background. The assembly of an extension is applied the same way and verified by the extensions of the application (section 6.1): the card goes on naming the build of the application, and comparing with it called every extension apply a rollback, the successful ones included.
    • apps create and apps ensure end by saying how to sign in to the application (section 6.11). That is the sign-in field of the output, shaped {"url", "account": "control-panel", "hint", "note"}, plus the same two sentences on stderr. url is the application address out of the card, and it is null while the application has none yet, which is the case without --wait; the hint then says where to take it from. account is a code, not a text: the way in is a control-panel account, and the note says why the accounts used to sign in to other applications do not work here.
    • --latest-build uses the project's latest build as the source and protects against an empty skeleton (section 6.2). --wait waits until ready (section 6.4), verifies the result (section 6.6) and outputs the final card.
    • A source given by --version-id is looked up in the build list of the project before anything is created. The project is --project-id, or ELEMENT_PROJECT_ID when the flag is absent. The platform deletes the builds nobody uses and cannot create an application from a deleted one: it answers with a bare 400 "Can't create application", which reads like a limit on the number of applications. So the command refuses by itself. A build the project does not list is looked for in the other projects of the stand first: a build uploaded without a project id sits in the project the server chose for it, while ELEMENT_PROJECT_ID names the project the stand works with. No method finds a build by its id alone, so the search reads the build lists: the project the local registry of uploads remembers for the build first, then the others, newest first. A project from ELEMENT_PROJECT_ID is a default, and it gives way to the project of the build, with a line on stderr. A project named by --project-id is not replaced behind the caller's back: the refusal names the project the build is in and the flag to pass. Such a build used to be refused as a deleted one, and the refusal offered a build of the project from the environment instead. When no project lists the build, the refusal names the cause and offers the build that a running application of the project runs, or --latest-build when no application runs one. When the other projects cannot be read, the refusal says that the project came from ELEMENT_PROJECT_ID, because the build may belong to another project. Without a project there is no list to look in, and the create goes as before.
    • Waiting means verifying. A failed apply is rolled back by the platform to the previous build, and the application comes up Running all the same, so a card handed back after a wait is not evidence that the build asked for is the one running. --wait therefore ends with the same check apps apply and verify-deploy do: the applied build id against the requested one, the application tasks that failed since the creation started, the uri. It puts the report into the verify field of the output and answers with exit code 1 when the check does not pass. --verify asks for the check on its own, and waits too, because there is nothing to check on an application still being created. --no-verify brings back the plain wait. Without either flag nothing is waited for and nothing is checked, as before.
    • The application exists once the create has answered, so a wait or a check that breaks off does not lose it. The output still carries the id: the card of apps create, the id of apps ensure with applied: null, since nothing was checked. The wait-error field holds the error that ended the wait, stderr names the verify-deploy call that checks the build later, and the exit code is 1. The answer used to be a bare network error without the id, and the id of an application that came up minutes later had to be looked up by name.
    • apps users [APP_ID] prints the users connected to the application the way GET /applications/{id}/users gives them (section 4.1): user-list-id, user-id, presentation, is-admin, token-access-enabled. There is no login among them: the platform names a user of the control panel by the login in presentation and any other user by its presentation. The command only reads, so the application defaults to ELEMENT_APP_ID, and it ends with a count line on stderr: how many users are connected, how many of them administer the application and how many reach its HTTP services by a token. Until the command the list was seen only in a refusal of apps token-access.
    • apps token-access APP_ID shows or switches the access of a user to the HTTP services of the application by a token (section 4.1). The application is named explicitly, by the argument or by --app-id: ELEMENT_APP_ID names the working application, and a switch that opens its services must not land there by default. --user is a login, a presentation or a user id, and without it the command speaks about the account elemctl itself signs in with (GET /me). Without --enable or --disable it only reads; both at once is an error. A switch reads the connection back, so token-access-enabled in the output is what the platform keeps after the change, and a flag that did not move is an error with exit code 1. The output is {"app-id", "user", "user-id", "user-list-id", "token-access-enabled", "changed"}, where changed says whether this very call altered anything: the state that is already there sends no request. A user who is not connected to the application is named as such, with the users that are, before anything is sent. --user may be repeated, and then every user is read or switched in turn. The output becomes {"ok", "app-id", "users"} with an entry per user in the order given: the report above with "ok": true, or {"ok": false, "user", "error"} for a user that could not be found or switched. Such a user does not stop the others, and the return code is 1 when any entry failed. The same user given twice counts once, and a single user keeps the output above.
    • apps export [APP_ID] saves the build the application runs to a file (section 4.1). --output is the file to write or a directory for it. Without it the file lands in the current directory under the name {Name} {Version}.xasm taken from the manifest of the archive, the name a build of elemctl gets; for a build uploaded into a project that is the number the server gave it. The extensions applied to the application are not in the archive, apps export-extension saves them. The command only reads the server, so the application defaults to ELEMENT_APP_ID the way apps get does. The output is {app-id, file, size, manifest}, where file is the full path and manifest is the Assembly.yaml of the archive. An answer that is not a build archive is refused, and no file is written. A server without the method is refused with the reason and the answer its console gave (status 401, section 4).
    • apps export-extension [APP_ID] EXTENSION saves the build of an extension applied to the application to a file (section 4.1, Console API 2.1). EXTENSION is the id of the extension, the id of its project, the name or the presentation of the project, compared exactly and without regard to case. The value is looked up among the extensions of the application first, because the export answers a miss with a 500 that names no cause: no match is an error naming the extensions the application has, and several matches are an error listing them. --output is the file to write or a directory for it. Without it the file lands in the current directory under the name {Name} {Version}.xasm taken from the manifest of the archive, the name a build of elemctl gets. The command only reads the server, so the application defaults to ELEMENT_APP_ID the way apps get does. The output is {app-id, extension-id, project-id, vendor-name, project-name, assembly-version, enabled, file, size, manifest}, where file is the full path and manifest is the Assembly.yaml of the archive. An answer that is not a build archive is refused, and no file is written. A server without the method is refused with the reason and the answer its console gave (status 401, section 4), rather than with what reads as a failed sign-in.
  • spaces list.
  • user-lists list [--name], user-lists get [LIST] [--app], user-lists self-registration [LIST] [--app --enable --disable], user-lists password-login [LIST] [--app --enable --disable] – user lists and their sign-in settings (section 4.7). The target is the LIST argument, an id or the exact presentation, resolved like an application name: no match is an error, several matches are an error listing the ids. The other way to give a target is --app, the application's own list out of its default-user-list; giving both is an error. Without --enable or --disable the two setting commands only read the current state, so the same command answers "how is it now". Both flags at once is an error. password-login works on the account service of type Local. The output is {"list-id", "enabled", "changed"}, where enabled: null means the list has no such service at all and nothing signs in by password, and changed says whether this very call altered anything: switching to the state that is already there sends no request.
  • projects list [--name --include-deleted], projects get [PROJECT_ID], projects delete PROJECT_ID.
    • projects list --name filters by a case-insensitive name substring, and it does so on the client because the platform answers the full list (section 4.3). Projects marked deleted are hidden unless --include-deleted is given: a stand a few months old keeps hundreds of them in the list against a handful of live ones, and a check for a project name must not cost the full listing.
  • builds list [--project-id --limit --brief], builds get VERSION [--project-id], builds upload FILE [--project-id --new-project --force-rename --space-id], builds delete VERSION [--project-id]. builds upload sends the commit the manifest of the archive names as commit-id (section 4.4), names on stderr a build the server numbered otherwise than its archive (section 4.4) and reports the target in the output through project-id and project-id-source: flag or env for the project the call named, server for the project the server chose, and a note on stderr says when the target comes from ELEMENT_PROJECT_ID. --new-project ignores the environment binding and uploads without a project id: the server puts the build into the live project that carries its Ид, or creates one when there is none (section 6.8); it is mutually exclusive with --project-id. Such an upload names that project in the project field, {id, name, configuration-id, created, renamed-from, found-by}, and in a line on stderr. The project comes from the artifact of the answer (section 4.4), and an answer without one sends the client through the build lists (found-by is response or build-list). created and renamed-from are judged against the project list read before the upload: an id the list did not have is a project the upload created, and an id it had under another name is a project the upload renamed. The rename is a warning on stderr, since deleting the build does not bring the name back. The answer used to print project-id: null, and a project the server had created for the build was then looked up by hand. A list that cannot be read leaves created null and does not stop the upload. --force-rename allows uploading an assembly whose name differs, which renames the target project. builds list shows the ten newest builds, and --limit 0 lifts the cut. It ends with a count line on stderr saying which of the two cuts the reader is looking at: the tool's --limit, or the platform's housekeeping (section 4.4: it deletes the builds nobody uses, whatever their age). "30 of 30" without that line was read as the project's whole history. The housekeeping is judged by the facts of the answer, not by the length of the listing: the platform hands out the numbers of a base version one after another, so a hole in the numbering is a build already taken away, and the line says there are holes without counting them. A hole can also be a jump: an upload without a project id keeps the number of its archive, and the next upload into the project counts on from it. Seen live, 1.0.0-3 was followed by 1.0.0-500 and 1.0.0-501, and the numbers between had never existed. The created stamps of the neighbours cannot tell a jump from a loss, since the server handed out four numbers in a third of a second, so the local registry of uploads does: a hole under a build this machine uploaded without a project id is named as a jump and is no evidence of the housekeeping. A build uploaded that way from elsewhere is not in the registry, and its hole still reads as a deletion. A hole can be a jump and a deletion at once. Seen live, the listing went from 1.0.0-8 straight to 1.0.0-52 after 1.0.0-50, uploaded without a project id, and 1.0.0-51 were deleted, and the line called the hole a deletion alone; a jump to 1.0.0-10 over 1.0.0-4, which the housekeeping had taken, was called a jump and no deletion. So a hole under a jump of this machine is a jump only while the registry knows no upload of the project inside it. A hole with such an upload inside, under a jump or with the jumped build itself inside, is named as both: the build that brought its number from the archive and the uploads of this machine the listing no longer has, the first three of them and how many more. It counts as a deletion. An empty listing says the project has no builds, and a listing without a single numbered build says there is no numbering to judge by, instead of calling the numbering unbroken. An extension project is outside the housekeeping altogether (section 4.4), and its count line says so: a hole there is a build deleted by hand, and the builds nobody needs stay until builds delete takes them. The kind comes from the project card, and a card that cannot be read leaves the line any other project gets. --brief prints the id, the versions, the date, the branch and the commit of a build. The branch and the commit come from the card when the platform filled them, otherwise from the local registry of uploads, and branch-name-source and commit-id-source say which answered: platform, registry, or null when neither knows. The registry alone knows dirty, whether the tree had uncommitted changes, and project-dir, the directory the build was made from.
  • The local registry of uploads. Every upload elemctl makes – deploy, builds upload, probe – appends a JSON line to uploads.jsonl in the data directory of the user: ELEMCTL_DATA_DIR when it is set, otherwise %LOCALAPPDATA%\elemctl on Windows and $XDG_STATE_HOME/elemctl (~/.local/state/elemctl) elsewhere. A line holds the build id, the project id, the version, the branch, the commit, the dirty flag, the directory of the sources, the archive, the stand, the command, the application the upload was made for (the target of a deploy, the throwaway application of a probe), the route of the upload (project into a project by its id, no-project-id without one; the lines written before the route got that name carry vendor-name and are read the same way) and the time. The platform keeps the commit alone, and with several sessions deploying from one machine a build on an application could not be traced to the working tree it came from. The registry is local: a build uploaded from another machine, from CI or by an elemctl that had no registry yet is not in it. A registry that cannot be written is a warning on stderr, never a failure – the build is on the server by then – and one that cannot be read reads as empty. The schema guard of deploy takes the commit of an applied build from here when the card of that build carries none. deploy counts the number of the next build from here as well: the version a build of the project got stays in the registry after the build is deleted (section 5). The registry keeps the newest 1000 uploads, ELEMCTL_REGISTRY_LIMIT sets another number and 0 keeps every one: once the file has grown past the limit by a tenth, the next upload cuts it back to the newest lines. The file is rewritten beside itself and swapped in by a rename, and a line another process appended meanwhile is carried over; a swap the system refuses leaves the file as it was until the next upload.
  • build [--project-dir --output --build-version --last-build --commit --branch --kind {application,library,extension} --require-clean] – build the archive locally. Output: file, name, vendor, version, version-source (flag, last-build, the CI variable name or default), kind, branch, commit and dirty, which says whether the project directory has uncommitted changes and is null when git is unavailable. The version is a field of its own so CI does not parse the file name. Without --project-dir, the project directory is found automatically: the first directory with Проект.yaml when descending from the current one. --kind defaults based on ВидПроекта. --require-clean aborts before building when the project directory has uncommitted changes; git being unavailable also aborts, because there is nothing to confirm a clean tree with.
  • inspect FILE – parse a prebuilt archive (section 5.1). Local as well: the platform is not called, and the connection flags are refused the same way.
  • deploy [--app-id --project-id --project-dir --output --build-version --branch --commit --dry-run --require-clean --allow-data-loss --server-start-timeout] – the full cycle: build -> upload -> apply -> restart -> verification of the actual apply (section 6.1). Output – a JSON report with fields: app-id, uri, status, version, assembly-id, assembly-version (the version the server gave the uploaded build), renumbered (true when that is not the version of the archive, null when the upload did not say), applied-version, applied (true, false or null, where null means the actual version could not be determined), uri-status, problems (list of strings, the platform's texts as they came), problems-lines (the same broken into plain lines: JSON escapes a multi-line refusal into and exactly where it has to be read), ok (boolean), dirty and dirty-files (uncommitted changes of the project directory at build time), extension-project-id and extension (below). The build captures the current disk state, so the divergence from HEAD must be visible; a warning also goes to stderr, and null means git was unavailable. hint points at the log of the server when a task was refused without a compilation error in its text. The platform may answer with "Contact administrator for details" alone, and the cause then sits in the server log: the last Caused by line, with SrcPath: beside it naming the file the apply stopped at. An apply that leaves the application in Error ends the deploy with an error, and that error carries the same hint. The server numbers a build uploaded into a project itself (section 4.4), so an explicit --build-version it will not keep – a suffix that is not a number, no suffix at all, a base other than the Версия of the project – is named on stderr before the build, and a build the server numbered otherwise than its archive is named right after the upload. Without --build-version the deploy counts the number on from the higher of the build list and the local registry of uploads (section 5): the server does not give the number of a deleted build again, and the registry remembers the numbers the uploads of this machine got. A build uploaded from elsewhere and deleted since is in neither of them, so the count can still fall short, and the line after the upload then says that the number is the server's, without a warning. A build of an extension project, ProjectKind: Extension in the manifest, is verified by the extensions of the application (section 6.1): extension-project-id names the project, extension is the entry of extension-projects the verdict rests on, null when the application has no such extension, and applied-version and applied-version-id name the build the extension runs rather than the build on the card. Return code 0 only when ok. --dry-run builds and stops there, and --require-clean aborts before building on a dirty tree. --server-start-timeout is how many seconds a server that is still starting is waited out (section 6.12). Before anything is built, the schema guard reads the sources against the commit the applied build was made from: the commit-id of its card (section 4.4), or, when the card carries none, the commit the local registry of uploads remembers for that build. A build that created its project has no commit on its card, and the registry knows one when the build was uploaded from this machine; a line on stderr says the commit came from there, and whether that build was made from a tree with uncommitted changes, which the comparison cannot see. schema-commit names the commit compared against and schema-commit-source where it came from, platform or registry. The project directory is found the way the build finds it. A narrowed length or a changed type of an attribute, a dimension or a resource, the attributes of a tabular part included, and a removed dimension recreate or break the data, so the deploy refuses them unless --allow-data-loss is given. So does an element that keeps data of its own – a catalog, a document, an information or accumulation register, a constants set, an exchange plan or a settings storage – whose description is gone from the sources: its whole table goes with every row. The disk cannot tell of a file that is not there, so the guard lists the files of the applied commit with git and follows an element by its Ид. A description moved to another subsystem or renamed keeps its data and is compared with its old text; an element without an Ид cannot be told from a moved one and is left alone. A file still in its place that carries another Ид describes another element: the description was made anew, and the element it used to describe is followed the same way. Checked live, a catalog described anew under its old path and name was applied without a question and came out empty, so such an element is refused as a removal, and the refusal names the former Ид to give back when the element is meant to be the same. The fields of the two descriptions are not compared with each other: they belong to different elements. A removed attribute, resource, tabular part or attribute of a tabular part is a deliberate edit the server applies without asking (section 6.13): the deploy names each one on stderr and in the schema-warnings field and goes on. Fields are matched by Ид, so a rename keeps its data, and across a translation of the description only an Ид can tell that an element is gone. schema-check says what the guard did: clean, warned (removals only), allowed, or skipped:<reason> when there was nothing to compare against: no-project-dir, read-failed, no-applied-build, no-applied-extension, applied-build-not-listed, no-commit-id or commit-unavailable, each explained in words of its own. The sources of an extension are compared with the build the extension runs, found through the extensions of the application, and an extension the application does not have yet is no-applied-extension: its first apply has nothing to compare against. A skipped check is said once more after the verdict: a passed verification alone read as if the schema had been checked too.
  • verify-deploy [APP_ID] [--app-id --version-id --expected-version --since-minutes] – the verification of section 6.1 on its own, deploying nothing. It looks at the application tasks in an error status raised over the last --since-minutes minutes, whose error-message carries the file and the position of a compilation error. Then it compares the applied build with the expected one: --version-id is the id of the uploaded build and the reliable comparison, --expected-version is the version string and the backup. It finishes with a control GET on the address. The report is the same as deploy gives, and the return code is 0 only when ok. It is what a CI script needs after apps create: a build that failed to apply is rolled back silently, and a status of Running proves nothing. A --version-id the card does not name is looked up among the builds of the extension projects of the stand (project-kind, section 4.3): a build found there is an extension build and is verified by the extensions of the application, the way deploy verifies one, and any other build keeps the verdict of the card. The lookup costs the listing of the projects and one build listing per extension project, and it runs only when the card names another build. --expected-version alone leads to no project, so an extension is verified by its id.
  • probe [--project-dir --output --build-version --name --space-id --keep --require-clean], probe --cleanup APP_ID – an isolated compilation check of the sources: build -> upload -> a throwaway application (that is the compilation, section 6.10) -> errors with file and position -> cleanup. Before the build the manifest is checked for what the server needs to take a probe: Представление (Presentation), without which the console refuses the upload with a bare 500; ЯзыкРазработки (DevelopmentLanguage), a required property without which no application is created; and ЯзыкиЛокализации (LocalizationLanguages) whenever ЯзыкПоУмолчанию (DefaultLanguage) is set, since the server refuses that pair without naming the cause. A missing key stops the probe before anything is built or uploaded, and the error names every such key with the line to add, in the spelling the manifest uses. ELEMENT_APP_ID and ELEMENT_PROJECT_ID are deliberately left unused: the probe must not be able to reach the working application, and the target project is chosen by the platform out of the Ид of the project descriptor (section 6.8). The default build version is {base}-probe-{token}. It has to be a new one every time, because a repeat is a 409, and it must not look like the project's latest build: the counter is numeric, and a non-numeric suffix keeps a probe build out of that comparison. Cleanup runs whether the compilation passed or failed, in the order of section 6.9: the application, then the build, then the project, and the project only if the probe itself created it. Output: ok, project-dir, vendor, name, file, version, project-id, project-created, project-renamed-from (the former name of a project the probe build renamed: the build goes into the project that carries the Ид of the sources, and a presentation edited in them renames that project; a line on stderr says so, the cleanup does not bring the name back, and the field is null when nothing was renamed), assembly-id, app-id, app-name, status, errors (a list of {file, entry, line, column, environment, message}, where file is the path relative to the project directory), messages (the platform texts verbatim, so nothing is lost when the failure is not a compilation one), cleanup (kept, app-deleted, assembly-deleted, project-deleted, problems, command, steps) and hint, the pointer to the server log that the deploy report carries too. hint is filled when the server refused and named no file; a wait that ran out of time leaves it empty. A stand that does not know the compatibility mode of the project refuses the whole project and then complains about types and properties of that mode in files the change never touched. That refusal is recognized, the parsing stops there, compatibility-refused names the mode and messages-dropped counts what followed from it, so a verdict about the stand cannot read as a verdict about the code. Return code 0 only when ok. A failed cleanup is a problem in the report and on stderr; it does not change the compilation verdict. --keep leaves the application and the build in place for a hands-on look. What a probe does leave behind is a tombstone: the platform keeps deleted applications in the list with the Deleted status and their former id, and there is no API to remove them. Whatever a probe leaves, on purpose with --keep or through a cleanup step that failed, comes with the commands that remove it, in cleanup and on stderr: command is the one command, probe --cleanup APP_ID, and steps are the same by hand in the order of section 6.9, with the build addressed in the probe's own project. A bare builds delete looks in the project of ELEMENT_PROJECT_ID, where the probe build is not, and a build deleted right after its application is refused with a 500 until the application is gone. Both lines name the --env-file the probe was run with. probe --cleanup APP_ID removes a probe left on the stand, starting from its application, an id or a name. The application card names the project and the build it was created from, so nothing has to be remembered between the runs. Only a probe's application is touched: its name starts with elemctl-probe-, or the build it runs carries -probe- in its version, which covers a probe named with --name, or the local registry of uploads remembers a probe of this machine creating it, which covers a probe given both --name and --build-version and one that got deploys of its own since. Any other application is refused with the reason, and so is the one ELEMENT_APP_ID names. The order is that of section 6.9: the application, a wait until it is gone, the build of this very probe (its token in the version, or the build the registry remembers the probe uploading) together with every build the registry remembers uploading for this application, and the project last. A build uploaded for the application that another live application runs now stays, and its entry in builds names that application in kept. The project goes only when no build is left in it and no live application runs it, and never when it is the project of ELEMENT_PROJECT_ID: a probe usually lands in the project that owns its sources, the working one, while a project the probe created ends up empty. The builds other runs uploaded into the probe's application from another machine, which the registry does not know, are left to the platform, which deletes the builds nobody uses. A second run finishes what the first one left: an application that is already a tombstone is not deleted twice, since the list keeps its card, and a project deleted already is not deleted again. The run builds nothing, so the flags of a probe run are refused beside --cleanup. Output: ok, app-id, app-name, project-id, app-deleted, builds ({id, version, deleted} each), project-deleted, project-kept (why the project stayed) and problems; return code 0 only when ok. --cleanup may be repeated, and then the probes are removed in turn, one after another: two probes of the same sources share a project, and it can go only after the last of their builds. The output becomes {"ok", "cleanups"}: the report above for each application in the order given, or {"ok": false, "app-id", "error"} for one that was refused (not a probe's, the working one, not found), with app-id as it was given. A refusal does not stop the others. ok and the return code 0 need every cleanup to finish. The same application given twice counts once, and a single one keeps the output above.
  • branches list [--project-id --name], branches get ID, branches create NAME [--project-id --app-id], branches update ID [--app-id], branches delete ID, branches merge ID.
  • dumps create [APP_ID] [--description], dumps get APP_ID DUMP_ID.
  • tasks list [--app-id], tasks get-group TASK_ID.
  • tech get [APP_ID], tech set APP_ID VERSION.
  • debug-adapter – the path to the platform debug adapter directory supplied by a plugin (the elemctl.debug_adapter entry-point group, section 10). Output {"path": ..., "found": true, "adapter-class": ...} when present or {"path": null, "found": false}; exit code 0 in both cases. The path is a ready value for the VS Code extension's xbsl.debug.adapterPath (a directory with a repo/ subdirectory).
  • plugins – diagnostics: what the plugins bring. debug-adapter holds the declared adapter directories and whether each of them holds jars. commands holds the commands of the plugins with the entry point they arrived through and the name of their MCP tool, which is null when the command stays out of MCP. failures holds what was left out: a plugin that did not load, or a command that would have taken over a name of the core, each with the entry point and the reason. The answer looks like {"debug-adapter": [{"path": ..., "has-jars": true|false}], "commands": [{"name": ..., "source": ..., "mcp": ...}], "failures": [{"source": ..., "error": ...}]}.
  • The subcommands the plugins bring (section 10, the elemctl.commands group) stand alongside the commands of the core and are listed by --help. They may not take over a name of the core; the command reference describes the core alone. A plugin that fails to load is left out and does not take the CLI down (section 10): every command names it on stderr, and a call to a command that is missing while plugins failed is refused with JSON on stderr, the failures in its plugin-failures field.
  • self-update [--version X] [--stop-holders[=all]] – update the installed elemctl by unpacking the wheel from PyPI into site-packages, without touching busy exe files. Plain pipx or pip breaks the install when elemctl.exe is held by a running MCP server. Here only the package files are updated, and the exe stub calls the new code. The command also fixes pipx_metadata.json. Output is {updated, from, to}. Without --version the latest release is asked of every source PyPI has. Both listings of releases, the simple index and the JSON summary, are cached node by node and may name the previous release for minutes after a new one is out, so the command reads both, takes the newer answer and then asks for the pages of the three next numbers (0.44.1, 0.45.0 and 1.0.0 after 0.44.0): the page of a new release is there as soon as it is published. When the sources disagree, a line on stderr says what each of them named. An installation newer than every source is left as it is rather than replaced with the release before. A version given with --version that the index does not list yet is looked up on its own page, and only a 404 there means the version does not exist. When the files of the package are held, the command names the holders by pid and command line and refuses; the previous installation stays as it was. --stop-holders ends the servers first, since elemctl mcp would keep running the old code anyway. The running commands of other sessions are named and left alone: each is somebody's work in progress, such as a wait for a pipeline. When one of them keeps the files busy, the refusal says to wait for it and repeat. --stop-holders=all stops the commands too, and a stopped command leaves no result.
  • mcp – start the MCP server; without the extra installed – a clear error with the hint pip install "elemctl[mcp]".

Positional APP_ID and PROJECT_ID marked as optional above are taken from the configuration when absent, from ELEMENT_APP_ID and ELEMENT_PROJECT_ID. If those are empty too, the command errors out.

8. MCP server requirements

Server name elemctl, stdio transport, credentials from the same environment variables and .env. In the server instructions, warn about the silent rollback of build apply (section 6.1). The tools, whose docstrings are short and in Russian:

list_apps(name="", status="", include_deleted=False) – the filters of apps list (section 7): name by a case-insensitive substring on the client (section 4.1), status by the whole status word, and the deleted applications hidden unless asked for. The answer is an object with total, live, shown, a summary line and applications, so that what was hidden is stated rather than guessed at; get_app(app_id) – the card with applied-build, as apps get prints it (section 7), find_app(name), create_app(name, project_id="", version_id="", space_id="", development_mode=True, verify=False) – when only project_id is given, the project's latest build is automatically used as the source (section 6.2); verify=True waits for the application and checks that the build asked for is the one it runs, putting the report into the verify field of the answer (the same check ensure_app(verify=True) makes, for a created application as well as for one that was found); a wait that breaks off keeps the id of the created application in the answer, beside a wait-error field with the reason, and ensure_app then answers applied: null; over an application that already exists, ensure_app judges an extension build by the extensions of the application, as apps ensure does (section 7); a version_id is looked up before the create, as in the CLI (section 7), with the project taken from project_id or from the stand's ELEMENT_PROJECT_ID: a build of another project is found there, the stand's project gives way to it, an explicit project_id is refused with the project the build is in, and a build no project lists is refused as deleted; create_app and ensure_app add a sign-in field to their answer – the way into the application (section 6.11) in the same shape the CLI prints: an agent sees only the JSON, so the hint has to live there; start_app(app_id), stop_app(app_id), debug_info(app_id) – data for a debug session (requires debugging enabled on the server), debug_adapter() – the path to the platform debug adapter from a plugin (section 10; a local operation that does not call the platform), delete_app(app_id) (the docstring – a warning about irreversibility and URL change), list_spaces(), app_id of get_app/delete_app/start_app/stop_app/debug_info is the id (UUID) or the exact application name (resolved like the CLI does), list_projects(name="", include_deleted=False) – the filters of projects list (section 7), list_builds(project_id, limit=10, brief=True) – an object {total, shown, summary, builds}: the listing has to say whether it is the whole store, judged by the gaps in the build numbering (section 4.4) and naming a jump the local registry explains (section 7), the summary of an extension project says that no housekeeping takes its builds, and a brief card names the source of its branch and commit, the card or the local registry of uploads, the way builds list --brief does (section 7), get_build(project_id, version) – the whole card of one build, addressed by the build version (section 4.4; an id is accepted and resolved through the listing), build_assembly(project_dir="", output_dir="", version=""), inspect_assembly(file) – parsing of a built archive (section 5.1; a local operation), deploy(app_id, project_id, project_dir="", version="", branch="", allow_data_loss=False, server_start_timeout=900) – returns the deploy report plus a log field with progress lines, and waits out a server that is still starting (section 6.12); allow_data_loss=True lets through what the schema guard refuses, the way --allow-data-loss does in the CLI (section 7), and without it the refusal comes back as an error before anything is built; probe(project_dir="", space_id="", keep=False) – an isolated compilation check that does not touch the working application (section 7), the report plus a log field; probe_cleanup(app_id) – probe --cleanup (section 7), the removal of a probe left on the stand, the report plus a log field; app_id may be a list, answered the way a repeated --cleanup is, {ok, cleanups} plus log; apply_build(app_id, version_id), verify_deploy(app_id, expected_version="", since_minutes=30) – verification of the apply per section 6.1, an extension build included (section 7, verify-deploy); list_app_tasks(app_id=""), list_branches(project_id="", name=""), merge_branch(branch_id), list_user_lists(name="") and configure_user_list(list_id="", app_id="", self_registration=None, password_login=None) – the sign-in settings of a user list (section 4.7) in one call: the list is given by id, by presentation or by the application whose own list it is; both flags are optional, and without them the tool only reports the state (self-registration-enabled, password-login-enabled, changed); list_app_users(app_id) – the users connected to the application as apps users shows them (section 7), in an object {app-id, total, summary, users}; token_access(app_id, user="", enabled=None) – the access of a user to the HTTP services of the application by a token, as apps token-access shows and switches it (section 7): app_id is required, an empty user means the account elemctl signs in with, and without enabled the tool only reads; user may be a list, answered the way a repeated --user is, {ok, app-id, users} with an entry per user; export_app(app_id, output="") – the build the application runs saved to a file, as apps export does it (section 7); an empty output is the current directory of the server process; export_extension(app_id, extension, output="") – the build of an extension applied to the application saved to a file, as apps export-extension does it (section 7); an empty output is the current directory of the server process.

The tools the plugins bring (section 10, the elemctl.commands group) are registered alongside these: the schema is built out of the declared arguments, the description is the help of the command, and the core adds an env_file parameter of its own. A name already taken by a tool of the core is not taken over: that command is left out, and so is a plugin that fails to load. The server names them on stderr, which a client keeps as its log, and starts with the rest.

9. Quality requirements

  • pytest tests without network access: .env parsing and configuration priorities, file selection and build archive contents (including the manifest), auto-increment and numeric comparison of versions, deploy outcome logic (ok/applied), the hint on FAILED_PRECONDITION, application search by name, plugin discovery through entry points and debug-adapter path resolution (stubbed entry points, directories in temp folders), adapter extraction from a tiny .car, self-update by unpacking a wheel (urllib mocked, wheel and site-packages in temp folders).
  • Docstrings, comments and identifiers – English, tests and tools included: the project is public and international. Russian stays where it faces the user: the i18n message catalog, argparse help, user-facing strings, MCP tool descriptions and the server instructions, plus platform identifiers quoted as they are (Проект.yaml, Ресурсы, Имя, Поставщик) and the Russian data of test fixtures. Straight quotes " in text, dashes – en dash – (not em dash), ellipsis – three dots ....
  • The library never prints to stdout or stderr itself: it delivers progress through a callback the caller passes in.
  • API errors are raised as a dedicated exception carrying JSON-serializable details of the server response.

10. Plugins (entry points)

elemctl discovers external packages through importlib.metadata.entry_points. The core declares nothing about plugins in its own pyproject.toml: it is a consumer that reads the entry points on demand. Non-publishable vendor artifacts, the proprietary 1C jars, then live in a separate package while the public core stays clean.

The elemctl.debug_adapter group. The entry-point value is a path, as a Path or a str, or a zero-argument callable returning one (() -> Path | str). The path points to the platform debug adapter directory, meaning a directory with a repo/ subdirectory holding the adapter jars, com.e1c.g5rt.debugger.adapter*.jar among them. This is a ready value for the VS Code extension's xbsl.debug.adapterPath.

Declaration in a plugin package:

[project.entry-points."elemctl.debug_adapter"]
name = "my_package:adapter_root"

The elemctl.commands group. The entry-point value is a Command, a list of them, or a zero-argument callable returning either. One declaration serves both surfaces: the core builds a CLI subcommand and an MCP tool out of it and knows nothing about what the command does. This is where a command belongs when it knows about someone's own environment: internal circuits, neighbouring systems, private stands. A public core is no place for it.

[project.entry-points."elemctl.commands"]
name = "my_package.commands:commands"

The declaration types are exported from elemctl.plugins:

  • Argument(name, help="", type=str, default=None, required=False, choices=(), cli_alias="", multiple=False) – name is "--stand" for an option or "stand" for a positional argument. The value name, dest, is the name without the leading dashes and with the inner ones replaced by underscores, exactly as argparse does it. The types are str, int, float and bool. A bool means a flag, store_true in the CLI and a boolean defaulting to false in MCP, so it cannot be positional. required works for both kinds: a positional argument is optional unless required=True makes it required. cli_alias gives a positional argument a CLI-only key synonym: Argument("page", cli_alias="--page") accepts both wiki-get 123 and wiki-get --page 123. The MCP tool schema keeps the one page parameter it always had – the alias is a parser convenience, not a second declared argument. The CLI builds the two forms as a mutually exclusive pair sharing one dest: both at once, or neither of a required argument, is a parser refusal. An option cannot declare cli_alias – it already has a name to call it by. multiple=True lets an argument take several values: the handler gets the list of the values in the order of the command line, the declared default as a list when none is given, [] without one, and the MCP tool gets an array of the declared type. An option may then be given more than once, and the CLI builds it with action="append". A positional argument takes its values one after another, with nargs="+" when it is required and nargs="*" otherwise, and its cli_alias key is built with action="append" like an option: wiki-get 123 456 and wiki-get --page 123 --page 456 hand over the same list, and the two forms stay a mutually exclusive pair. The CLI keeps the default out of the parser, so the command line replaces the default rather than adding to it. An argument without the field takes one value: the parser refuses its option given again with a different value, as it refuses any key of one value (section 7), and lets the same value through. A flag cannot be multiple, and the default of a multiple argument is None, a list or a tuple. A plugin that also runs on an older core checks hasattr(Argument, "multiple") before declaring a multiple option, since the older dataclass refuses the keyword and the plugin is left out. A multiple positional argument needs POSITIONAL_MULTIPLE of elemctl.plugins as well, read as getattr(plugins, "POSITIONAL_MULTIPLE", False): a core that knows multiple for options alone refuses it on a positional argument with a PluginError, and the hasattr check holds there too.
  • Command(name, help, handler, arguments=[], mcp=True, mcp_name="") – name is the CLI subcommand. The MCP tool is named mcp_name, or the same name with dashes turned into underscores. mcp=False leaves the command in the CLI only. source is filled in by discovery with the name of the entry point.
  • CommandContext – what the handler gets. config is the assembled connection configuration. client is a platform client built on first use and cached, so a command that never reaches the platform does not demand credentials. log(message) takes progress lines: the CLI prints them to stderr as they come, and the MCP tool collects them into the log field of its answer. surface names the caller: "cli" for a subcommand, "mcp" for a tool, the values of SURFACE_CLI and SURFACE_MCP. It is None in a context built outside the core, by a test or a library caller. A plugin used to tell the two apart by the shape of log alone, which is the core's to change. A plugin that also runs on an older core reads it with getattr(context, "surface", None), since there the attribute is missing.

The handler is called as handler(context, **values), the values keyed by dest. Its result must be JSON-serializable: the CLI prints it, the MCP tool returns it. The CLI exit code is taken from the result:

  • a dict result whose exit-code field holds an integer from 0 to 255 ends with that code. A bool does not count as an integer here, and the range is what a process returns portably: POSIX keeps only the low eight bits of an exit status, so 256 would arrive as 0;
  • otherwise a dict result with "ok": false ends with 1, the same convention the deploy and probe reports follow, and every other result ends with 0;
  • a valid exit-code wins over ok, so {"ok": false, "exit-code": 0} ends with 0. A value of another type or out of the range is ignored, and ok decides.

The name of the field is exported as elemctl.plugins.EXIT_CODE_FIELD; a plugin that also runs on an older core can tell by its absence that the process code there follows ok alone. The MCP tool returns the exit-code field untouched, with the rest of the result. The handler does not end the process itself: the same function serves the MCP server, where a SystemExit leaves the call unanswered and stops the server. To the MCP tool the core adds an env_file parameter, as every core tool has, and, for a dict result, a log field with the progress lines.

Discovery behavior:

  • entry points are sorted by name; debug_adapter_path() returns the first directory that actually holds the adapter jars (a directory without repo/ or without the adapter jar is skipped), otherwise None;
  • a failing entry point is an error, PluginError, a subclass of ElemctlError, rather than a silent skip: a tool that silently drops a plugin would leave the user without debugging and without an explanation. Both loading an entry point and calling the function it names are guarded. A plugin written for a newer core fails in that call, with a TypeError about a field the installed core does not know, and the error names the installed version;
  • a command declaration is validated at discovery time, not when the command is run: an empty name, a handler that is not callable, an unsupported argument type, a boolean positional argument, duplicate value names, a cli_alias on an option, one that does not start with a dash, one that collides with another argument's own flag, a multiple flag and a multiple argument whose default is not a list are all PluginError;
  • an argument may not take a common flag of section 7, one the CLI accepts in any position: not as an option, not as the value name of a positional argument and not as a cli_alias. The CLI moves such a flag in front of the subcommand and parses it itself, and a subcommand writes its values where the root parser keeps its own, so the plugin's value either never reached the command or replaced the value of the core. An env_file of the plugin's own would also be a second one on its MCP tool. The set is exactly the flags the CLI moves, so a flag the core adds is closed to plugins at once, and such a declaration is a PluginError like the ones above;
  • nor may an argument take a value name the CLI itself keeps in the parse of a command – command, handler, plugin_command, plugin_commands, plugin_failures – or an option the subparser answers itself, -h and --help. A positional handler replaced the function the CLI calls with the string the user typed, and the call ended in a TypeError; an option --help stopped the parser of the whole CLI from being built, with a bare argparse.ArgumentError. Both lists are read off the parser the CLI builds, so a name the core starts to keep is closed to plugins at once, and such a declaration is a PluginError as well;
  • a failure stays with its plugin. discover_commands() returns the commands that loaded and a PluginFailure for each entry point that did not, {source, error} in its to_dict(); one bad command leaves its whole entry point out. plugin_commands() is the strict form and raises the first failure. The CLI and the MCP server are built from discover_commands(): the plugin that failed is left out, and the core and the other plugins keep working. One broken plugin used to stop the parser from being built, so no command worked, the core ones included, and the answer was a Python traceback;
  • a plugin may not take over a name the core already occupies – neither a CLI subcommand nor an MCP tool. The clashing command is left out and reported like a plugin that did not load, while the core keeps its own;
  • the ELEMCTL_NO_PLUGINS=1 environment variable disables discovery, leaving the core capabilities alone. The command reference generator sets it, so the reference describes the core alone.

Surfaces using the mechanism: the CLI debug-adapter/plugins (section 7) and the subcommands of the plugins, the MCP tool debug_adapter (section 8) and the tools of the plugins, and the VS Code extension, which requests the path from elemctl debug-adapter when the adapterPath setting is empty.

The adapter itself is extracted from the platform distribution by tools/extract_adapter.py, which is clean code and is not shipped in the package distribution (prune). It copies the data/ide/theia/plugins/@1c-appengine-plugin/bin/debugger/ directory from the .car into <output>/<version>/ and updates index.json. The proprietary jars stay out of the public package; a separate plugin package ships them.