Use whenever manipulating or validating a Spryker data-import CSV or import manifest. csv.php: read/inspect/filter/delete/duplicate/set/replace/scale/apply-translations — reliable where shell tools (cut/awk/sed) corrupt multi-line quoted fields. validate.php: preflight, refs (incl. composite tuples), required, unique, absent, paths, product-refs, manifest-diff, known-set — catches boot-aborting data statically in seconds. Concept-free: you supply columns/values/patterns; the tools do the mechanics and return compact JSON. Also home of the authoritative invocation & command-discipline rules every consumer skill follows.
Use whenever manipulating or validating a Spryker data-import CSV or import manifest. csv.php: read/inspect/filter/delete/duplicate/set/replace/scale/apply-translations — reliable where shell tools (cut/awk/sed) corrupt multi-line quoted fields. validate.php: preflight, refs (incl. composite tuples), required, unique, absent, paths, product-refs, manifest-diff, known-set — catches boot-aborting data statically in seconds. Concept-free: you supply columns/values/patterns; the tools do the mechanics and return compact JSON. Also home of the authoritative invocation & command-discipline rules every consumer skill follows.
spryker-import-tools
Two self-contained PHP scripts under scripts/, both concept-free (they know nothing about stores/locales/currencies — you decide what to do; they do it correctly and print JSON):
csv.php — RFC-4180 CSV manipulation.
validate.php — pre-boot consistency checks (its own CSV reader; no dependency on csv.php).
Why use them instead of shell tools or editing by hand: Spryker CSVs contain multi-line quoted fields (e.g. cms_page.csv), so cut/awk/sed corrupt them; and a single bad import row aborts the whole 30–60 min install, which these checks catch statically in seconds. Bulk work in-context burns tokens — the scripts return a terse verdict instead.
Run
php <path>/scripts/csv.php <command> <file> [options] and php <path>/scripts/validate.php <check> [options] (host php, or docker run --rm -v "$PWD":/app -w /app php:8.3-cli php … if there's no host php). Both print JSON ({status, …}, exit 0/1/2); read-type commands take --plain for clean lines. Prefer reading the output / the exit code directly over piping through python/ruby/jq — it's unnecessary here and prompts; the direct read (or --quiet + exit code for validate) is the quieter path.
Invocation & command discipline (AUTHORITATIVE — the one copy; consumer skills summarize and point here)
These rules keep an unattended run quiet (no needless permission prompts) and safe. Every skill that drives these tools follows them.
Locate the scripts:.claude/skills/spryker-import-tools/scripts/csv.php and …/scripts/validate.php (setup install, relative to the project cwd), or ${CLAUDE_PLUGIN_ROOT}/skills/spryker-import-tools/scripts/… (plugin install); docker php:8.3-cli fallback per the Run section if there is no host php.
Invoke by the LITERAL path from the project cwd — never cd, never a shell variable.$CSV/$VALIDATE in the consumer skills are shorthand for that literal path — resolve it once in reasoning and substitute it inline in every call. A cd prefix, an assignment prefix (CSV=… php …), or a multi-line assign-then-use all miss the allowlist and prompt on every single call.
One simple command per Bash call — no shell operators.cd, ;, |, &&, ||, $(…), backticks, for/while, redirects, env-prefixes and multi-line commands can never be allowlisted — they prompt regardless. Instead:
One op over many files = ONE command: list all files before the flags and add --in-place (set/replace/scale/drop-columns/filter/delete all take many files); inspect many with one columns/distinct/count <f1> <f2> … --plain. Never a shell loop.
Count with the tool — rowCount/matchedRows from the JSON, or csv count --plain; never | grep -c/| wc -l.
Explore with the built-in Read / Grep / Glob tools — never bash ls/find/grep/cat/head/sed, and never bundle inspections with ; echo …. The built-in tools are not Bash, so they never prompt.
Config/YAML edits use the built-in Edit tool (anchored, format-preserving) — never regenerate a file with python/ruby/sed.
rm/rm -rf prompt by design (not allowlisted) — surface every deletion as an explicit step; don't force it.
Destructive-operation gate — explain, then ask, every time.csv filter/delete --in-place and header-only truncation rewrite/empty files without prompting on their own, and docker/sdk reset/clean-data can wipe DB + search volumes (git does NOT restore those). Before any in-place data removal/truncation or any DB/volume drop: preview it (filter/delete with no --out/--in-place reports matchedRows without writing), tell the developer in ONE plain line what will be destroyed, and get an explicit go-ahead — even if the allowlist would let it through silently. (reset/clean-data weight + the hand-to-developer escape hatch: boot-and-verify §3b.)
csv.php — CSV operations
Inspection (add --plain for line output — no JSON to parse):
read <file> [--limit N] — dump header + rows as JSON. Takes ONE file — passing several is a clean error (no silent truncation to the first; use columns/count for many-file inspection).
count <file> [<file2> …] [--plain] — data-row count per file, many files in ONE call.--plain → rowCount⇥file per line. Use this for "how many rows across these N files" — never a shell for … do … done loop with | grep rowCount; one count call replaces the whole loop.
columns <file> [<file2> …] [--plain] — header + row count. Accepts many files in one call (per-file output; a missing file is reported inline, batch survives). --plain → column names per line, with a == <file> == delimiter when multiple.
distinct <file>… --column C [--plain] — distinct values of a column with row counts, sorted by count desc (--plain → count⇥value per line); takes many files in one call (aggregated). Use this to inspect data — never awk/sort/uniq -c (they corrupt multi-line quoted fields).
Mutation — write with --out <file> for ONE file, or --in-place for one-or-many files. Batch: list several files before the flags and add --in-place to apply the same op to all in a single command (per-file report in files[]) — this is how you avoid a shell for loop. Multiple files without --in-place is an error.
filter <file>... (--where col=val … [--match exact|prefix|contains] | --in col=v1,v2,… | --in-file col=path) [--out f | --in-place] — keep matching rows. --where repeatable (AND); --in/--in-file = set-membership. Without a target it previews: matchedRows/resultRows are the FULL counts, the echoed rows are capped at 20 (--limit N to raise) — the counts are the destructive-gate evidence, not the row dump.
delete <file>... (same conditions) [--out f | --in-place] — drop matching rows.
duplicate-columns <file>... --from en_US --to fr_CA[,de_DE,…] [--skip-base url] (--out f | --in-place) — for every X.en_US column, add X.<to> copying the value; --skip-base leaves families alone. --to takes a comma-list → all project locales in one call.
duplicate-rows <file>... --column locale --from en_US --to fr_CA[,uk_UA,…] (--out f | --in-place) — clone rows per --to value (glossary → all locales; store-relation rows → all stores).
set <file>... --column store --value US [--where col=val ...] (--out f | --in-place) — set a column on matching rows (empty --where = all rows).
replace <file>... --column url.fr_CA --search /en/ --with /fr/ [--regex] [--where col=val] (--out f | --in-place) — string/regex replace within an existing column.
scale <file>... --column value_gross [--column value_net] (--by 1.47 | --rates PLN=4.3,UAH=45 [--currency-column currency]) [--no-round] [--json-keys net_price,gross_price] [--where col=val] (--out f | --in-place) — multiply numeric values (currency conversion); --column is repeatable — net + gross scale together in one call (--column value_net --column value_gross); --rates applies a per-currency factor in one call; --json-keys scales embedded price tiers.
derive <file>... --target value_gross --source value_net --factor 1.19 [--no-round] [--only-empty] [--where col=val] [--out f | --in-place] — cross-column arithmetic scale can't do: writes target = source × factor, creating the target column if absent. Skips rows whose source is empty/non-numeric; a non-numeric/zero --factor is a hard error (never silently zeroes a column). gap-fills (won't overwrite existing target values). E.g. fill gross from net + tax, or a base-currency price into a second column before converting.
Notes: all access is by header name, never index. A missing column returns a clean JSON error with exit 2. Idempotent where sensible. Writes are atomic (temp file + rename — a crash mid-batch never leaves a truncated CSV). macOS/OrbStack bind-mounts lag — re-check a written file a beat later. Counting: never | grep -c — use rowCount (columns/read) or matchedRows (filter preview). Batch, don't loop.
validate.php — pre-boot checks
Output: JSON {status, check, findingCount, findings, errors}, exit 2 on any finding, 0 clean. Add --quiet to branch on the exit code alone.
preflight <import-config.yml> [--base dir] — run this FIRST; one driver that auto-discovers and checks the boot-critical invariants across every file the manifest imports, so you never enumerate files by hand. Reads the manifest's source: list and, by column shape, bundles four checks: url.<locale> global uniqueness — CROSS-FILE, like the real spy_url constraint (a product URL colliding with a category/merchant URL is caught; one dup aborts the 30–60 min install; navigation-node sources are exempt — a nav node's url is a link target, legitimately repeated across menus), is_searchable.<locale> non-blank (blank = silently unsearchable), price completeness — gross-mode aware (a missing/0value_gross, or a missing net in a net-ONLY file, is a hard problem; a missing net beside a present gross is how the shipped gross-mode stores legitimately look → reported under non-gating priceNetWarnings/warningCount), and base-before-relation import order (currency before currency-store, store before every *-store, and every store-definition entry — checked on its LAST occurrence — before the first product/category entity; a 3rd store's locale-store appended after the catalog = a silently empty store). Also flags any manifest source: that doesn't exist. --baseline <previous-preflight.json> suppresses findings already present in that report — capture a baseline on the untouched clone first, then only NEW findings gate (the shipped demoshop carries pre-existing quirks that are not yours to fix). It also runs generate-mode required-shape checks (shapeWarnings): product-abstract source has a color_code column (else 0 rows import silently), visibility ∈ {PDP,PLP,Cart,∅}, product-abstract imported with an *approval-status entity (else 0 search docs, invisibly), merchant-product-offer accompanied by merchant-product, and tax_set_name ⊆ the tax source. Returns one grouped verdict (problemCount, then unreadableSources/urlDuplicates//////), exit if any. Shipment-price files are exempt from the empty-or- price rule (free shipment is a real value). — the "a finished project carries only its own locales" check, across (the sweep misses that tree). Use the individual checks below to drill into a specific file; is the sweep that replaces remembering to run each one everywhere.
Concept-free by design: new demoshop files/entities need no new code — point the checks at them. A green boot is still the correctness authority; these are the cheap early net. If a tool genuinely can't do something, a fallback is fine — but prefer the tool (awk/sed/python corrupt multi-line quoted fields and prompt).
Data & references — the maintained literals
Two files hold every literal the consumer skills used to inline, so a demo-data bump is one diff here instead of a hunt across eleven prose files. known-set keeps both honest.
data/entity-map.yml — one row per shipped data_entity: its source filename (which often differs from the entity name — that mismatch is why the file exists), its class (structural = theme-independent plumbing kept regardless of catalog; content = theme-dependent demo data; unclassified = undecided, blocks boot), and, for structural rows, the why. The authoritative structural keep-set the clean/minimal-baseline keep-lists derive from.
references/demo-facts.md — the concrete identifiers (a real SKU pair, the cms-block-email-- prefix + block_key column, the shipped product-label + navigation keys, the shop_ui colour keys, the manifest families), each paired with the command that re-derives it. No record counts — those are stated as their measuring command, never a stored number.
Flag cookbook (one worked example per command — copy, don't guess)
Non-obvious flag behaviours that otherwise cost guesses:
set adds a missing column — csv set url.uk_UA.csv --column url.uk_UA --value /uk/ creates url.uk_UA if absent (not only sets an existing one).
prefix/substring match uses --match — csv filter cms_block.csv --where block_key=cms-block-email-- --match prefix (there is no --where-contains; note the column is block_key, not name).
membership uses --in / --in-file — csv filter product_abstract.csv --in abstract_sku=A1,A2 or --in-file abstract_sku=keep.txt (one value per line).
refs reference set is --in OR --ref-file+--ref-column — validate refs offer_store.csv --column store --ref-file store.csv --ref-column name (not --in-file).
known-set runs from any cwd on its defaults — validate known-set --base <repo-root> (checks the map + the count gate); validate known-set --emit-map --base <repo-root> > /tmp/skeleton.yml to regenerate the map skeleton for a diff review after a demo-data bump.
--baseline takes a SAVED preflight report — capture once on the untouched clone: run validate preflight <manifest> plainly and Write its JSON output to .ai-dev/preflight-baseline.json with the Write tool (not a > redirect — redirects can't be allowlisted and prompt). Then gates only NEW findings.
drop-columns <file>... (--column name | --suffix .de_DE)... [--out f | --in-place] — remove columns; --suffix drops a whole family across files. Repeatable; a named column absent from a file is reported (per-file skippedColumns), not fatal; idempotent.
rename-columns <file>... --rename old:new [--rename old2:new2 …] [--out f | --in-place] — rename header columns and the matching row keys, many files in one call (repeatable --rename = multiple pairs). A pair whose old is absent, or whose new already exists as a different column, is reported in skippedColumns (not fatal); idempotent; malformed --rename (no :) is a hard error. Use it instead of hand-authoring a near-duplicate file that differs only by column names — e.g. deriving price_product_offer from product_price: replace the values, rename-columns the ref column, done in two commands with no loop.
apply-translations <file>... --target-column name.uk_UA [--source-column name.en_US] --map map.csv (--out f | --in-place) — apply a source→target value map to one column (map file has source,target). Only the target column changes — safe by construction. Exact match, multi-line-safe; unmapped values left as-is.
searchableBlanks
priceMissing
foreignLocaleColumns
foreignLocaleRows
shapeWarnings
orderViolations
2
0
Add --locales <pl_PL,uk_UA,en_US> to also flag any .<locale> column or locale row value that isn't in the project's set
data/import/**
absent
preflight
manifest-diff <reference.yml> <new.yml> [--base dir] — diff the data_entity lists of two import manifests. Reports missing (entities the reference/demo manifest imports that the new one does not — each with its source row count so you can rank: rows ≈ product population → structural/required, rows ≪ → opt-in demo garnish) and added. Exit 2 if anything is missing. This is the mechanical guard against generate-mode silently dropping behavioural plumbing (e.g. product-shipment-type) — diff, then classify every omission as intentional-content-drop or must-keep-structural.
known-set [<manifest>] [--base dir] [--map <entity-map.yml>] [--skills <dir>] [--emit-map] — the maintainability gate: keep data/entity-map.yml honest against the shipped manifest, and keep record counts out of the skills. Defaults resolve relative to the script, so it runs from any cwd (default manifest data/import/local/full_EU.yml, default map data/entity-map.yml, default skills dir the plugin's skills/). Reports and gates (exit 2) on: a manifest entity missing from the map; a stale map row the manifest no longer imports; a map source that isn't the file the manifest imports for that entity (or is gone from disk); any class: unclassified row (an undecided entity blocks the boot by design); a structural row with an empty why; and any bare record count (N rows|files|entities|products|blocks|labels|SKUs|docs|CSVs|nodes|keys|columns|attributes|categories) in a .md under the skills dir (a 0/~0 count or a count-ok-marked line is exempt). --emit-map regenerates the map skeleton from the manifest, preserving the class/why of entities already classified and marking new ones unclassified — a demo-data bump becomes a diff review, not a retype. Run it beside the pre-release grep gate. It does NOT scan prose for stray identifiers/paths — paths and product-refs own that against the real files, where a <placeholder>-templated doc won't false-positive.
refs <file>... --column C [--column C2] (--in a,b,c | --ref-file F --ref-column K) [--split ,] [--composite] — every value in the column(s) must be in the reference set. A missing column is a finding, never a silent pass. --split handles multi-value cells. Multi-file: pass several files — each column is checked only against the files that HAVE it (a heterogeneous batch where a.csv has key and b.csv has attribute_key won't false-fail), and a column absent from EVERY file is still flagged. (--composite uses the first file.)
--composite treats the columns as one tuple and checks it against the ref file's --ref-columns (positional) — for cross-entity integrity. E.g. every (merchant, store) sold must exist in merchant_store. Plain refs would wrongly pass if each part exists independently.
required <file> --column C [--column C2] — cells must be non-empty (e.g. is_searchable.<locale> — a blank imports as silently unsearchable).
unique <file> --column C — no repeated values (empty cells ignored). E.g. url.<locale> — a duplicate fails the import.
absent <file-or-dir> [<path2>...] --string S [--string S2] — none of the strings may appear (stale-literal sweep). Directories are recursed, so absent config/ src/Pyz --string DE sweeps whole trees in one call. A path resolving to nothing readable is a CANNOT READ FILE finding, never a silent pass. Triage tool, not an absolute gate — scope a hard check to files that must be zero-hit (CodeBucketConfig.php, default_store.php), classify the rest.
paths <import-config.yml> [--base dir] — every source: in the import config resolves to an existing file.
product-refs <dir> (--keep-from <file>:<column> | --keep-in a,b,c)... [--pattern n1,n2] [--list-suffix _skus] [--exclude <substr>]... [--exclude-column <col>]... — broad orphan scanner for a catalog reduction. Walks every CSV under <dir>, auto-discovers each product-ref column (name in --pattern, default sku,abstract_sku,concrete_sku,product_sku,product; plus any header carrying sku as an underscore token — sku_concrete, abstract_product_sku, alternative_product_concrete_sku, …; plus any column ending in --list-suffix, default _skus, treated as a comma-list), reports every token NOT in the kept union set (--keep-from file:columns ∪ --keep-in). The JSON carries a columnscoverage summary (a 100%-orphan column is usually a non-product code, e.g. product_option.sku; --exclude-column it) and the orphan findings. --exclude <substr> skips files not in the active manifest. Exit 2 on any orphan. The coverage tool a catalog reduction needs (the reduce strategy in [[project-data]]).
manifest-refs <import-config.yml> [--base dir] — orphan sweep across the manifest's DISTINCTIVE-key relations, in one command. By column-name convention it builds each key family's producer set from the entity that defines it (abstract_sku←product-abstract, concrete_sku←product-concrete, sku←both, merchant_reference←merchant, product_offer_reference←product-offer/merchant-product-offer, category_key←category, sales_unit_key←product-measurement-sales-unit), then flags every consumer cell with no producer. Reports checked families and unchecked ones (producer entity absent from the manifest → not validated, never a false orphan). Exit 2 on any orphan. It is NOT the whole FK graph: it covers only families whose column name is distinctive. Relations keyed on a generic column — product-label-store→product-label by name, stock-address→stock by warehouse_name, etc. — can't be represented without cross-entity false positives, so they are invisible here. Heuristic for the gap: any file the project copied from a seeded demo dir whose parent set the project then REPLACED (labels, sales units, warehouses, attributes) is guilty until a targeted refs --ref-file against the project's own parent proves it clean. Run manifest-refs as the cheap first net, then refs --ref-file per replaced-parent relation — don't treat a green manifest-refs as full referential integrity.
orphan-files <import-config.yml> <root-dir> [<root2>…] [--base dir] — the complement of paths: lists every CSV under the roots that no manifest source references (dead files that make the tree lie about what's actually imported). paths proves every source resolves to a file; orphan-files proves every file is a source. Reports onDisk/referenced counts + the orphan findings; exit 2 on any orphan. The gate the project-data "Consolidate & clean up" pass uses to prove the on-disk tree equals the active manifest.
threshold-glossary <import-config.yml> --locales a,b [--base dir] — asserts every Sales-Order-Threshold row's message key resolves in each project locale's glossary. The regular threshold (data_entity: sales-order-threshold) builds its key at runtime from type+store+currency (sales-order-threshold.<type>.<store_lc>.<cur_lc>.message) and looks it up unconditionally — the empty message_glossary_key column is not an exemption. For every threshold row × locale, flags the (key, locale) pairs missing from the manifest's glossary sources. Reports thresholdRows/thresholdFiles/glossaryFiles so a 0 finding count from no threshold file found is visible, not falsely reassuring. Skips merchant-relationship-sales-order-threshold (auto-generated, never glossary-seeded). Exit 2 on any miss. Catches the green-boot-then-500-at-add-to-cart defect the boot itself won't surface (the [[project-data]] threshold gate).