Imported from skeeks-cms/cms (
AGENTS.md). Install upstream withnpx skills add skeeks-cms/cms. Copyright stays with the author.
SkeekS CMS
This is the shared skeeks/cms package: a reusable Yii2 content-management core consumed by many SkeekS projects. Changes here affect every consuming project, so keep package-level behavior conservative, reusable and backward-aware.
Package role
- The package provides CMS models, controllers, migrations, widgets, components and configuration under
src/. - Site structure and pages are represented by CMS tree models; publications, news, products and similar records are content elements.
- Content types and their dynamic properties allow projects to add fields through administration without creating a dedicated model for every content kind.
- User access is governed by the existing CMS/RBAC mechanisms.
- The package is a Yii2 extension configured through
src/config/common.php,src/config/web.phpandsrc/config/console.php.
Official links:
- Website:
https://cms.skeeks.com - Source:
https://github.com/skeeks-cms/cms - Issues:
https://github.com/skeeks-cms/cms/issues
Architecture and engineering rules
- Keep browser and administration behavior in backend/admin controllers.
- Keep protocol and API integrations separate from backend UI controllers.
- Put shared business behavior and write operations in services or models.
- Keep transport controllers narrow: parse requests, authenticate, authorize and format responses.
- Do not duplicate model validation or bypass Yii validation, permissions, ownership checks or transactions.
- Prefer existing package and project conventions over generic Yii assumptions.
- Avoid unrelated refactors in this shared package.
- Inspect current implementations and usages before changing public model or component contracts.
Important CMS domains
Use ast-index to discover the current definitions and contracts instead of relying on memory. Common starting points are:
CmsTreefor site sections and pages;CmsContentfor configured content containers;CmsContentTypefor section/content type metadata;CmsContentElementfor publications and other content records;- storage-file models and services for uploaded files;
- task models and services for CRM task behavior.
The exact namespace, active implementation and extension points must be verified before editing.
MCP and OAuth ownership
MCP and OAuth are deliberately outside this package:
skeeks/cms-mcpowns the MCP endpoint, tool contracts, providers and MCP-specific application services.skeeks/cms-oauth2-serverowns OAuth controllers, models, migrations, resources and scopes.- Do not add MCP or OAuth transport code back to
skeeks/cms. - Read the target package's
AGENTS.mdcompletely before changing MCP or OAuth behavior.
For MCP, tools must remain thin and delegate validation, model access, transactions and business rules to services. There are no delete tools: create drafts first and publish explicitly.
For a site with skeeks/cms-mcp installed, the canonical endpoint is https://<site-domain>/cms/mcp. In Codex, both url and oauth_resource must contain that exact site-specific URL. Never configure the obsolete /cms/mcp-task/create route. Use the actual target site's public domain rather than assuming skeeks.com.
The same authorized tools are available to non-MCP AI clients at
https://<site-domain>/cms/rest-api. The catalog has three identifiers:
api_version, server_version and the OAuth/RBAC-specific tools_revision.
Persist the /tools response outside the chat and revalidate its private ETag;
reuse cached schemas after 304 Not Modified. Do not download and reinterpret
the complete catalog for every user request. Use /tools/index,
/tools/{tool_name} or the prefix, names and q catalog filters when only
a small part of an uncached inventory is needed. Never share catalog caches
between OAuth credential stores.
The Windows REST helper is owned by skeeks/cms-mcp at
scripts/skeeks-rest.ps1; this core package only documents how agents locate
and invoke the installed helper.
Common reads should use the package's scripts/skeeks-api.ps1 direct-first
client. Known operations execute immediately; catalog or schema discovery is a
fallback after an unknown-tool or validation error, not a mandatory preflight.
When the user asks to create a SkeekS CMS task through MCP, use create_cms_task. Default executor_id is 1 unless the user specifies another executor.
AI-assisted content direction
CMS-facing services may support AI clients that:
- inspect site, theme, content-type and field metadata;
- create or edit sections and pages;
- create or edit publications;
- upload or select files and insert their URLs into content;
- validate drafts and publish explicitly;
- return a public URL for verification.
Preserve existing content structure unless the user requests a rewrite. Resolve explicit entities for updates, validate dynamic fields for the selected content type, and ask the user when a required type or required field cannot be inferred safely.
Code search
For PHP symbols, classes, usages, inheritance and dependencies in shared vendor code, run ast-index first from:
C:\SkeekS\dev\php\vendor
Useful commands:
ast-index search "Query"
ast-index class "ClassName"
ast-index symbol "SymbolName"
ast-index usages "SymbolName"
ast-index implementations "InterfaceName"
ast-index callers "methodName"
ast-index outline "path\to\file.php"
Use rg only for raw strings, config, markup, regular expressions, application-project searches, or when ast-index returns no useful result.
Only update the shared vendor index when the user explicitly asks обнови вендоры, обнови индекс or обнови индекс вендоров. Do not try ast-index update first. Run the known-good command immediately with elevated permissions:
cd C:\SkeekS\dev\php\vendor
$env:PATH='C:\Users\User\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin;' + $env:PATH
& 'C:\Users\User\.cache\codex-runtimes\codex-primary-runtime\dependencies\bin\fallback\pnpm.cmd' dlx @ast-index/cli update
Lead visibility and recipients
Unassigned leads without company evidence are administrator triage. Ordinary employees see a lead through an available company: an explicit company link, a linked client/submitter/partner in CmsCompany2user, or an exact normalized phone/email shared with that company or its contact. Names and short phone fragments do not grant access. Explicit executors and their superiors retain the assigned-work scope. Never automatically link CRM records while reading. New-lead recipients are active site workers with cms/admin-lead permission whose explicit-identity CmsLeadQuery::forManager() contains the lead. Source ingestion selects recipients after saving contacts; repeated contact sync checks existing new-lead notifications before emitting another one. Test: tests/cms-lead-company-access.php with Composer autoload, isolated SQLite.
Verification
Before finishing a change:
- Check the package Git status and preserve unrelated work.
- Run
php -lfor changed PHP files. - Run the narrowest relevant automated tests or smoke checks available.
- Verify package boundaries and configuration wiring.
- Stage only intended files; never stage
.idea/.
Keep durable agent knowledge in this root AGENTS.md. Do not add auxiliary Markdown documentation directories unless the user explicitly changes this convention.
Native scheduled maintenance
The existing cms/cache/flush-all and ajaxfileupload/cleanup route keys now resolve to cms.flush-cache and cms.cleanup-temporary-files native job types. Keep their schedule identities, intervals, activation and execution dates. Manual execution uses the existing CMS admin permission.
CacheFlusher is shared with the CLI. It discovers configured CacheInterface components using the Yii console cache rules, skips APC under CLI and raises an error when flush returns false. Native runs report skipped caches as warnings. It flushes cache components only; runtime directories and published assets are not part of this operation.
The upload handler delegates to yii2-ajax-file-upload's TemporaryFileCleanup service (release 2.0.2 or newer), which the upload CLI also uses. No console subprocess is involved. CMS owns job registration; the upload library owns file expiry behavior and has no cms-job dependency.
Keep cms-job optional at the CMS core dependency boundary to avoid a cycle. Queued maintenance requires cms-job and its migrations/workers at installation level. Both jobs fail closed without that runtime. Deploy new services/classes before configuration and restart maintenance consumers. This change adds no schema migration.
tests/native-cache-smoke.php uses only in-memory caches; the upload package's tests/temporary-cleanup-smoke.php uses its own disposable filesystem directory.
Category synchronization protection
Tree/CmsTree owns is_sx_info_update (boolean, default 1), exposed in the existing
admin-tree form for records linked by sx_id. Migration m260917_210000 protects
pre-existing GPD categories (sx_id > 0) by setting it to 0 once, across all sites.
Do not repeat this backfill at runtime or reset a user's later choice. Import
writers in cms-shop must honor the flag before preparing media or modifying data.
The migration intentionally refuses automatic rollback to avoid removing protection.
tests/tree-gpd-sync-migration.php verifies old/new and prefixed-table behavior.
List value ownership
PropertyTypeList adds an ownership validator through the property's getEnums relation. It validates single and multiple selections before RelatedPropertiesModel saves/replaces rows; empty optional selections remain allowed. This is validation, not a database constraint: save(false) and SQL can bypass it. tests/property-list-ownership.php covers cross-property, missing, empty and duplicate selections.
Main domain query cache
CmsSite::getCmsSiteMainDomain keeps its ActiveQuery relation contract. For a concrete site ID it caches SQL for 28800 seconds using the CmsSiteDomain table tag (HasTableCache) and the site's getCacheTag() (AdminCacheController button). The global table tag covers a domain moving between sites. Eager loading from an unpopulated site model remains uncached. Opt in only when the DB queryCache resolves to the application's cache used by HasTableCache; callers can use noCache() explicitly. Direct SQL/updateAll writers must invalidate the tags, as they bypass model events. Loaded AR relations still follow Yii's normal object lifetime and need refresh/reload to observe later changes. Test: tests/site-main-domain-cache.php with Composer autoload; isolated SQLite checks cache hits, empty results, site isolation, manual invalidation, actual model save/delete/main-domain changes, moves and a separate queryCache.
Scoped site and address reads
CmsSite::getById caches its SQL for eight hours with the site table tag and record/site cache tag. Its existing public static sites array remains a per-process snapshot; resetting a persistent cache does not refresh already loaded objects in that array. CmsSite::getCmsSiteAddress caches only the first address query with the address table tag and owning site tag, retaining its priority order and limit. Other address queries are unchanged. Both opt-ins require queryCache to resolve to the cache used by HasTableCache. UrlRuleTree URL creation reuses an already loaded matching cms.currentTree only in web applications. Parsing URLs must not add models to the static map: that would retain trees and relations across long-running console work. Console lookup follows its previous path. The web test also checks 1000 replacements of currentTree without growing the static map. Tests: site-settings-cache.php and tree-route-reuse.php (Composer autoload).
Image preview response
ImagePreviewController returns the saved preview through Yii sendFile with inline disposition. Do not reopen it with Imagine show(): that encodes it a second time and can lose the effective quality on the first response. Thumbnail keeps the existing quality contract: explicit q is in the URL; otherwise quality comes from seo.img_preview_quality during generation. Existing static previews are not regenerated by a settings change. The isolated tests/webp-quality.php accepts Composer vendor directory, controller source path and a JPEG fixture path. It checks real WebP quality, site-default equivalence, first-response byte identity and JPEG/PNG output, using temporary files without bootstrapping the site or connecting to its DB.
Lead tasks
CmsTask.cms_lead_id (migration m261012_230000, FK SET NULL) links a task to a lead independently of the exclusive company/client/project relation. The lead card shows open tasks above the comment form and a "Задачи" related tab; its "Поставить задачу" button opens task create with CmsTask[cms_lead_id]. The task form keeps the lead only if CmsLeadQuery::forManager() returns it, and fills the lead's company (else client) when no relation was chosen. Subtasks copy the lead. cms-mcp task create/update/list accept cms_lead_id with the same lead-access check. Next step: an open task is one in CmsTask::activeStatuses() (new, accepted, in work, paused; on-check is done from the executor's side). A lead in work without one is "next step missing"; CmsLeadQuery::nextStep() backs the list filter and CmsLead::nextTask the list column and card surface. "Не дозвонились" (no-answer action, CmsLeadNoAnswerForm + CmsLeadNoAnswerService) increments cms_lead.no_answer_count (m261013_100000), moves the actor's nearest open task on the lead or creates "Перезвонить: …", writes a lead comment; closing as unreachable rejects the lead and cancels the actor's open lead tasks through TaskWorkflow.
Lead stages and kanban
CmsLeadStage (m261013_120000, per site, unique name, sort, optional #rrggbb color) labels steps inside the system status "in work"; cms_lead.cms_lead_stage_id is nullable (FK SET NULL, deletion refused while leads use the stage). The four system statuses stay authoritative for access, notifications, partner rewards and validation. CmsLeadStatusWidget shows the stage only with showStage=true: the UPA partner cabinet uses the widget and must not see internal stages. The admin "Канбан" action (CmsLeadBoardService) has columns new, "in work" without stage, one per stage, success, rejected (last 30 days). A drop into a stage claims a new lead or reopens a terminal one through normal validation; a drop into a terminal column only returns the process form URL with ?status= preselected, because a result or reason is required; a drop into "new" is refused. The board lives in skeeks/cms until a second consumer justifies a shared cms-backend renderer. Stages are managed by administrators in "Этапы лидов" (AdminCmsLeadStageController). The lead card shows the same moves as a stage bar (_stage-bar.php, CmsLeadBoardService::steps() and denyReason()); both post to kanban-move. The process form receives a target status as ?status= and only preselects the control (data-sx-lead-status-preselect): never assign it to the model, the window header is rendered from the same instance. A lead that enters "in work" without a stage (claim, creation with a manager, reopening) gets the first stage of its site in CmsLead::beforeSave; a lead already in work and sites without stages are untouched. The stage is kept when a lead is closed, so reopening returns it to its previous stage. With stages configured, the kanban hides the empty "in work without stage" column: it only holds leads taken into work before stages existed. AdminCmsLeadController::$defaultAction is "kanban" (tabs: Канбан, Таблица, Новый лид); related lead tabs in company/client cards still render the table.
Lead reject reasons
CmsLeadRejectReason (m261013_140000, per site, unique name, sort, one is_no_answer default) is managed by administrators in "Причины отказа лидов". Once a site has reasons, a rejected lead must have cms_lead_reject_reason_id and reject_reason becomes an optional comment; without reasons the text stays required as before. beforeSave copies the reason name into an empty reject_reason, because the UPA partner cabinet, notifications and API read the text. The no-answer form preselects the is_no_answer reason when closing. CmsLeadStatusWidget(showStage) also shows the reason of a rejected lead.
Lead deal amount
cms_lead.deal_amount (m261013_160000, decimal 18.2, RUB, one-off) is required when a lead becomes successful (earlier successes are not forced) and cleared when it leaves "success". It is employee data: shown only through CmsLeadStatusWidget(showStage) and the kanban card. The process form offers 10/20/30% shortcuts that fill partner_reward_value from it; the reward stays editable. Net profit (contractor costs) belongs to deals/projects, not leads.
Lead report
The "Отчёт" tab (CmsLeadReportService, views/admin-cms-lead/report.php) counts leads created in the period (a cohort) in their current state, within forManager()->cmsSite(). Conversion = successful / target leads; rejections with a reason marked is_not_target (m261013_180000: spam, duplicates) are not target. Deal sum and average check use successful leads with deal_amount; the partner share uses only leads that have both an amount and a reward. Partner rewards come from EVENT_PARTNER_REWARDS (LeadReportPartnerRewardsEvent), which skeeks/cms-shop answers from shop_partner_lead: the core does not depend on it. A successful lead's amount is corrected through the hidden deal-amount action (header button "Сумма сделки", executor or administrator, DynamicModel form). It saves only deal_amount: the status does not change, so EVENT_PARTNER_SUCCESS and the partner reward are not triggered again.
Contractor delivery address
cms_contractor.delivery_address (m261015_020000, varchar 255, null) is the consignee address used in documents. It is edited in the client contractor form after the postcode and shown in the card; "our" contractors do not have it. skeeks/cms-shop ShopDocument::createFromBill fills consignee_address of UPD, waybill and invoice-facture from it, falling back to the buyer address. Model rules, form and card access it through hasAttribute(), so code works before the migration is applied. The API writes it through safeAttributes.
