TeamcenterKnowledge

Skills

TC Help Server Authoring

Skill tc-help-server-authoring. Author, publish, package, and distribute custom manuals on a local Siemens Support Center Documentation Server (the "Teamcenter Help Server") so they serve, search, and deep-link exactly like an out-of-the-box collection. Covers the real content and Elasticsearch index formats (reverse-engineered from a live install AND from taking apart two real Siemens installer .exe files, no public admin guide exists), the reindex command found in the installer's own log, real bugs hit building this and root-caused with an A/B test (a PowerShell ConvertTo-Json read that silently mangled the schema; an "[ids] failed to parse field [values]" importer error that looked like a nondeterministic vendor bug but was actually caused by launching help-server-win.exe with the wrong process working directory -- 4/4 failures with the wrong directory, 9/9 clean with it pinned via Start-Process -WorkingDirectory, confirmed causal by reproducing the failure on demand; and a Copy-Item -Recurse destination-exists gotcha that doubled a folder segment), and a PowerShell toolkit -- author, publish, package, install-elsewhere, verify -- that automates and has proven the complete pipeline end to end. Use for "add a custom manual to the help server", "install documentation like Teamcenter's own", "package documentation for another server", or any Documentation Portal / help-server-win.exe investigation.

Everything here was verified against a real local install (D:\Siemens\Help Server\, SiemensPLMDocumentationServer service, ports 51000/51001) on 2026-08-17. There is no public Siemens admin guide for adding custom content to this product; this was reverse-engineered by reading the install's own files and log, and proven by actually registering a working section (SKU XCEL00000001, "Documentation Builder") with two live handles.

Prefer the scripts. scripts/XcelDocBuilder.psm1 plus New-XcelDocSection.ps1 / Add-XcelDocModule.ps1 / Publish-XcelDocs.ps1 encode everything below and were used to build and verify the real section this skill documents. Read the rest of this file for the reasoning, or if you need to do something the scripts don't cover.

The layout

<Root>\collections\documentation\external\<SKU>\<locale>\<collection>\<handle>\   content
<Root>\collections\es\external\<SKU>\<locale>\                                    search index
<Root>\help-server-win.exe                                                        server binary + CLI

A handle is one manual (e.g. AWAdmin, MBSE, or a custom one). A SKU is a product release install; every SKU has exactly one productId, the number used in shell URLs (282219420 = "Teamcenter", shared across every Teamcenter SKU). Pick a custom SKU/productId that cannot collide with a real Siemens value — this work uses the convention XCEL0000000N / 90000000N.

Content format (per handle)

  • HTML topic pages, any folder nesting, no required template — plain self-contained HTML works.
  • toc.json — nested nav tree: {href, title, id, children: [...]}. Leaf nodes have an href to an HTML file (relative to the handle root); section headers have children and no href.
  • topics.json — the same tree flattened to one array, with a synthetic HeaderTopic entry per section header, plus a searchtext field per topic (plain-text body extract) that powers the in-collection search box. Read client-side; editing it does not require a reindex.
  • copyright.html — present on every shipped handle; not proven required, kept for consistency.

Search-index format (per SKU, under es\external\<SKU>\<locale>\)

  • package.json — one record: {id, baseVersion, releaseVersion, productTitle, productId, sku, lang, internal, packageId, packageVersion, createDate, largeFile}.
  • product.json — feeds the Elasticsearch products index (see below); a minimal {versions, meta, created, groups} shape works, does not need Siemens' full shared taxonomy.
  • content\NNNN.json — a JSON array of Elasticsearch bulk-index docs. Every handle needs exactly one "collection":"dc" record in here. This is where the first real bug lived — see below.

The three Elasticsearch indices, and why all three matter

Index Fed by What breaks if missing
packages package.json — (nothing observed yet; but see the correction below, it is NOT reliably written)
content content\NNNN.json "dc" records the handle's shell page (/en-US/doc/<productId>/<sku>.<handle>) and the in-collection search box
products product.json the product's documents-listing page (/en-US/documents/<productId>/<sku>) — sometimes 404s when the handle itself is perfectly reachable, see the correction below

A handle can look completely done (static file serves fine, shell page resolves) and still 404 on the listing page because products alone is empty. Verify all three, not just one.

Corrected 2026-08-19, twice. The packages row used to read "this one always succeeded in testing". It does not. On XCEL00000004 all three records were absent after an import that reported success, packages included, checked by its real id (<SKU>-<locale>, read out of package.json, not guessed). So there is no index that can be assumed written, and none of the three is a proxy for the others.

And the products row is not a reliable sentinel in the other direction. On XCEL00000004 and XCEL00000006 the listing page answered 200 with no products document at all, while the shell page 404'd. So a green listing page does not prove products was written, and the shell page was the only endpoint that told the truth. Prove the index by fetching each record by its own id (/content/_doc/<sku>.<handle>, /products/_doc/<productId>) and reading found. See Bug 6.

The reindex command

Found verbatim in the installer's own transcript, <Root>\Debug.info — the installer runs this same command after laying down content, many times in sequence with no ill effect:

"<Root>\help-server-win.exe" import -e http://localhost:51001 -c "<Root>\collections"

Static content (HTML/toc.json/topics.json) is served directly from disk and needs no reindex to go live. Only the search-index layer (es\external\...) needs import — or the fallback below — to become visible in search/shell/listing pages.

Bug 1: a PowerShell object-to-JSON round-trip lied about the schema

The first draft of a content-index record was modeled on a real example read via Get-Content | ConvertFrom-Json | ConvertTo-Json -Depth 3. That command silently truncates anything nested deeper than the depth limit and prints its .ToString() instead — the real files field (an array of objects: {type, uri, key, ext}) rendered as the literal string "@{type=application/json; uri=...}", and that mangled text got copied into the hand-authored record as-is (valid JSON syntax, wrong shape). Re-reading the same file as raw text (Get-Content -Raw, no object conversion) showed the truth: files is an array of real objects, and categories/enrichedCategories values are arrays of strings, never bare strings.

Never trust a lossy object-serializer round-trip as ground truth for an unfamiliar JSON schema. Read the raw bytes, or use New-XcelDcRecord in the provided module, which was checked against the raw file and builds a real object graph (so PowerShell's own ConvertTo-Json -Depth 12 on it serializes correctly with no truncation).

Bug 2: [ids] failed to parse field [values] — traced to the wrong process working directory (see Bug 4)

help-server-win.exe import would sometimes fail partway through:

error:   Error Importing: <SKU>-<locale>, <releaseVersion>
error:   Error: [parsing_exception] [1:37] [ids] failed to parse field [values]

Diagnosis: query Elasticsearch directly (http://localhost:51001/_cat/indices?v) — the packages doc gets created, but content/products docs for the affected SKU do not. The exact same record, PUT directly to Elasticsearch, is accepted immediately:

PUT http://localhost:51001/content/_doc/<record.id>       body = the one record object
PUT http://localhost:51001/products/_doc/<productId>      body = product.json's content
PUT http://localhost:51001/packages/_doc/<package.id>     body = package.json's content (usually unnecessary — this one succeeds via the normal importer)

Index name is the real Elasticsearch index (content, products, packages) — not the "index" field value inside a content record ("content-external", which is the app's own internal routing label, not an ES index name). Type is always _doc, id is the record's own id field.

Root cause: almost certainly the same broken process working directory documented as Bug 4. Two earlier versions of this doc got the diagnosis wrong in opposite directions — first "recurs on every full reindex" (written after 3 failures in a row, before anything else had changed), then "not deterministic, root cause unknown" (written after a 4th attempt succeeded right after the Bug 4 fix landed, which read as coincidence rather than cause). A deliberate A/B test resolved it: 4 of 4 attempts using the old bare-& invocation from an arbitrary directory failed with this exact error (including one reproduced on demand, after the fix, by deliberately reverting to the old invocation style); 9 of 9 attempts using Start-Process -WorkingDirectory $HelpServerRoot succeeded. 13 data points, zero exceptions to the split. The mechanism is still not confirmed (no source access) — the leading hypothesis is that the importer reads some local state relative to its own working directory to decide how to build its create-vs-update ids query, and gets it wrong when that directory isn't its own install root — but the correlation is strong enough that this is no longer treated as an unpredictable vendor bug: always invoke help-server-win.exe with its working directory pinned to $HelpServerRoot, and this error should not occur. Publish-XcelDocs does this via Start-Process and additionally still detects and auto-recovers from the error signature if it ever appears anyway.

Bug 3: Copy-Item -Recurse doubles a folder when the destination already exists

Hit while building Export-XcelDocSection.ps1. Pre-creating the destination SKU folder (New-Item -ItemType Directory ...\<SKU>) and then running Copy-Item -Path <source>\<SKU> -Destination ...\<SKU> -Recurse does not merge the source folder's contents into the destination — PowerShell copies the source folder itself into it, producing ...\<SKU>\<SKU>\.... Caught by running Import-XcelDocPackage.ps1 -WhatIf (which extracts into a scratch copy and reports the resulting file list) before ever pointing the script at a real install. Fix: create only the parent directory and let Copy-Item create the SKU leaf itself.

This is the general reason -WhatIf exists on the packaging script and not the others: authoring scripts write content this workspace wrote, trivially easy to inspect and re-run; a packaging/copy step moves a whole tree in one shot and is a different failure class, worth a dry run before trusting against a real target.

Bug 4: Publish-XcelDocs was writing logs into whatever folder it happened to be called from

help-server-win.exe writes its own logs\ folder relative to its **caller's process working directory**, not its own install directory. Running Publish-XcelDocs.ps1 from inside this skill's own scripts\ folder left a stray logs\ folder there — found only because it showed up in git status before a commit, not because anything looked broken.

Two fixes were tried and both failed, which is worth recording exactly because the failure was silent — the script kept working, it just kept leaving the same litter behind:

  1. Push-Location $HelpServerRoot before the call. Does not work: Push-Location/Set-Location only move PowerShell's own $PWD, which a native child process launched via & does not inherit.
  2. [System.IO.Directory]::SetCurrentDirectory($HelpServerRoot) before the call. Also does not work, even though this sets the real, process-wide Environment.CurrentDirectory that native children are supposed to inherit — PowerShell appears to re-sync that value back to $PWD at each pipeline invocation, undoing the manual set before & $exe ever actually launches the process.

**What actually works, verified from three different starting directories with zero stray logs\ folders left behind afterward:** Start-Process -FilePath $exe -WorkingDirectory $HelpServerRoot -RedirectStandardOutput ... -RedirectStandardError ... -Wait -NoNewWindow. -WorkingDirectory sets the Win32 CreateProcess working directory directly and is not subject to either of the above.

Confirmed causal, not coincidental, for Bug 2. A controlled A/B test deliberately reverted to the broken bare-& invocation from an arbitrary directory after this fix was in place: it reproduced [ids] failed to parse field [values] on demand, immediately. Combined with 9 consecutive clean runs using Start-Process -WorkingDirectory, the full record is 4 failures / 4 attempts with the wrong working directory and 9 successes / 9 attempts with the right one. See Bug 2 above for the corrected writeup — treat this fix as the actual resolution, not a workaround for a separate unexplained bug.

Bug 5: the verifier never verified anything, and reported a pass over a real 404

Found 2026-08-19, while registering the second real section (XCEL00000002, Tier Health). Every "All endpoints live" this skill has ever printed should be treated as unproven. The reference build in the section below is genuinely live, but that was confirmed by hand afterwards, not by this function.

Two independent defects stacked, and each one alone was enough:

  1. Invoke-WebRequest could never reach this server at all. Windows PowerShell 5.1 parses Set-Cookie eagerly, and the Documentation Server issues one with Domain='.localhost', which .NET rejects: The 'Domain'='.localhost' part of the cookie is invalid. The call threw before the response was ever inspected. Every check, every time.

    Scope: this is Windows PowerShell 5.1 only, not Invoke-WebRequest as such. Measured 2026-08-19 on PowerShell 7.6.5 Core, where the same cmdlet against the same server reads normally: 200 for a live handle and a 404 for a fake one on the identical call shape, which an always-throwing call cannot produce. The Set-Cookie with Domain=.localhost is genuinely there (dumped from the response header) and PowerShell 7's HTTP stack simply does not reject it. So pwsh readings are real readings. The module still uses HttpWebRequest with no CookieContainer, which is right, because it must work under either edition. The distinction matters when judging someone's evidence: "verified in raw PowerShell" is only worthless if it was 5.1.

  2. The catch block then threw too. if ($_.Exception.Response) under Set-StrictMode -Version Latest raises PropertyNotFoundStrict when the exception has no Response property. That killed the whole foreach, so the caller received an EMPTY collection.

And the caller's verdict was if ($checks.Pass -contains $false). Nothing is $false in an empty collection, so it fell to the else branch and printed "All endpoints live." all([]) is $true. The same shape appears in Add-XcelDcRecordToIndex, which also crashed under StrictMode while Add-XcelDocModule.ps1 still printed New handle '<x>' is live.

Observed consequence, not hypothetical: three of four handles for XCEL00000002 had no content record in Elasticsearch, the shell pages 404'd, and all three scripts reported success. It was caught only because the endpoints were checked independently in Python afterwards.

Fixed:

  • Test-XcelDocEndpoints uses [System.Net.HttpWebRequest] with no CookieContainer, so the cookie header is never parsed. It walks the exception chain for the status code, because calling a .NET method from PowerShell wraps the real WebException in a MethodInvocationException.
  • Its outcome is three-valued: Pass, plus Ran distinguishing "answered 404" from "nobody could ask", plus the error text.
  • New Assert-XcelDocLive is the single verdict, and it refuses to pass a result set of the wrong size. New-XcelDocSection.ps1 and Add-XcelDocModule.ps1 both route through it and exit 1 on failure.

Verified against known-bad input, which is the only reason to believe any of it:

Case Statuses Verdict
live handle ran:200 ran:200 ran:200 pass
handle that does not exist ran:404 ran:404 ran:200 refused
server port unreachable COULD-NOT-RUN x3 refused
empty result set none refused, "the verifier itself did not complete"

The lesson is the one this workspace already had and this skill did not apply to itself: a checker is not a checker until it has been run against known-bad input. A broken check reports exactly like a healthy one, so the reassurance survives the defect it exists to catch. Run it against a deliberately broken copy AND a known-good one, and keep both controls.

Extended 2026-08-19, folding in Bug 6

The first version of this fix still had the weakness Bug 6 documents: it checked three HTTP endpoints and never asked Elasticsearch anything, so it counted the product documents listing as evidence. On the cameo-switcher session's SKUs that page answered 200 with nothing indexed at all, which would have turned a total indexing failure into a two-of-three "partial success".

Test-XcelDocEndpoints now runs five checks, and only four of them can decide anything:

Check Decisive
static content page yes
handle shell page yes
content/_doc/<sku>.<handle>, reading found yes
products/_doc/<productId>, reading found yes
product documents listing no, reported for information only

The two Elasticsearch checks fetch by id and read found, never a search. Both obvious alternatives lie: _count?q=<SKU> matches an analysed field and returned 9471 on a SKU with zero documents and 10207 on one with four, and a match_phrase on sku returns 0 for everything because these records carry no sku field at all. A 200 from Elasticsearch is also not the answer on its own; found is.

Verified by reproducing Bug 6 deliberately: the content record for one live handle was deleted from the index, leaving static content 200 and the listing 200, and the checker returned False on the two decisive reds. The record was then restored and re-confirmed. All four real handles pass, a fake SKU is refused.

Bug 6: import printed Done. and indexed nothing, and the listing page still answered 200

Found 2026-08-19 on XCEL00000004 (Cameo Connector Configuration) and again on XCEL00000006 (Teamcenter Subscriptions), and independently reproduced the same day by another session on XCEL00000005 (Teamcenter Single Sign-On). EXERCISED, three SKUs, two sessions.

The third case rules out the wrapper scripts. That session reached it through Add-XcelDocModule -SkipPublish followed by a separate Publish-XcelDocs call, bypassing the wrapper scripts' own auto-recovery entirely, and got the identical clean-success-with-nothing-indexed result. So this is the importer, not Publish-XcelDocs swallowing something.

help-server-win.exe import ran clean. No Error Importing: line, no [ids] failed to parse field [values], no non-zero exit, the working directory correctly pinned per Bug 4, and its last word was Done. For the brand new SKU it had created no content document and no products document at all:

GET http://localhost:51001/content/_doc/XCEL00000004.cameo_switcher   404
GET http://localhost:51001/products/_doc/900000004                    404

So SawIdsParsingBug=False FailedSkus=[] is not evidence that anything was indexed. The importer's own success output does not distinguish "imported" from "silently skipped".

What makes this worse than Bug 2 is which check it defeats. Of the three endpoints:

Endpoint Answered Should have
static content page 200 200, correctly, it is served from disk and needs no index
product documents listing 200 404, per this skill's own table, since products was empty
handle shell page 404 the only honest red

The listing page answering 200 with no products document contradicts the index table near the top of this file. Do not rely on it as the products sentinel: on this install it renders regardless. The handle shell page was the single check that told the truth, and a two-out-of-three pass reads as a partial success rather than as a total indexing failure, which is exactly the wrong impression.

⚠ Counting index documents does not settle it either. GET /content/_count?q=<SKU> returned 9471 for a SKU with zero documents: that field is analysed, so the query matches loosely and the number is meaningless. Fetch the record by its own id (/content/_doc/<sku>.<handle> and /products/_doc/<productId>) and read found, or ask a question the instrument cannot answer sideways.

Fix, and it is cheap: re-run with -ForceFallbackForSkus "<SKU>", which PUTs the records into Elasticsearch directly. Both SKUs went to three real 200s immediately afterwards, confirmed by fetching each endpoint and each _doc by id.

⇒ Given Bug 2 and this one, treat the importer as unreliable for a brand-new SKU by default. Cause unknown and not the working directory this time. Either pass -ForceFallbackForSkus on the first publish of any new section, or verify by _doc id before believing Done.

While you are here: the SKU number is claimed by hand, and sessions collide on it

New-XcelDocSection.ps1 refuses a SKU whose content folder exists, which is the only thing preventing two sections from landing on one number. It fired for real: XCEL00000005 was free when checked and taken by another session before the create, seconds later. Both sessions had checked the folder first, and both still nearly collided, so this is a routine race rather than carelessness.

⇒ There is no registry, so list collections\documentation\external in the same breath as the create, not earlier in the session. A number that was free when you planned the work can be gone by the time you mint it, and the gap is seconds wide when several sessions are publishing at once. Expect the throw rather than treating it as a surprise, and just take the next free number. Claimed so far: XCEL00000001 Documentation Builder, 00000002 Tier Health, 00000003 SSO Setup, 00000004 Cameo Connector Configuration, 00000005 Teamcenter Single Sign-On, 00000006 Teamcenter Subscriptions.

SUPERSEDED 2026-08-19: "six SKUs sharing one productId" did not produce what it was for

The section below this note is kept for the record — a wrong approach that got proven wrong, with the evidence, is more useful to the next person than a gap where it used to be. Do not follow it. See the corrected recipe immediately after.

Chris's actual ask was one entry on the server's home page, with the six apps' manuals appearing as topics under it. Four SKUs (XCEL00000001, 00000003, 00000004, 00000006) migrated onto a shared productId/productTitle to try to achieve that. It could not have worked, and did not: Chris's own screenshot of the home page showed four separate rows, each labeled "Xcelerator Workspace Modules" / P XCELERATOR WORKSPACE TOOLS — visually similar, structurally still four independent entries. Checking how real Teamcenter's own multi-SKU product renders on the exact same home page confirms this is not a configuration mistake but how the page fundamentally works: its 282219420 "Teamcenter" productId spans four real SKUs, and two of them (Teamcenter 2606 and Teamcenter 2606 Developer References) render as two separate rows sharing the identical bold label and P TEAMCENTER badge — never one collapsed entry. This page is one row per package (packages/_doc/<sku>-en-US), always, regardless of productId or productTitle overlap. No combination of productId/productTitle/baseVersion values changes that. Getting one row with several manuals under it needs the manuals to be handles under one shared SKU, which is a different thing from four SKUs quietly agreeing on a shared productId — it is what XCEL00000001 looked like internally the whole time (doc_builder + automation-toolkit, two handles, one package, one row).

The "six SKUs, one productId" work is now reverted: XCEL00000003 (SSO Setup), XCEL00000004 (Cameo Connector Configuration) and XCEL00000006 (Teamcenter Subscriptions)'s standalone packages/content documents were deleted and their content re-registered as three new handles under XCEL00000001 instead. Their source files were kept as a rollback path, not deleted — but moved out from under collections\ entirely (see Bug 10 below) rather than left in place, after a first pass that left them in place turned out to be a live fuse: help-server-win.exe import rescans everything under collections\ on every publish, and would have silently resurrected both retired SKUs' index entries the next time anyone ran it. See "One shared SKU, one handle per module" below for the current, correct recipe and where things stand.

One shared SKU, one handle per module — the corrected recipe, 2026-08-19

Target: one SKU (XCEL00000001), one shared productId (900000001) / productTitle ("Xcelerator Workspace Tools"), one package-level releaseVersion representing the umbrella ("Xcelerator Workspace Modules 1.0", not any single app's version), and one handle per app under the existing xcelerator_docs collection. Each handle keeps the app's own real version in its own content record's releaseVersions/productVersions — that per-handle versioning was never the problem; the per-SKU versioning was.

This is exactly what Add-XcelDocModule.ps1 already does. Nothing new needed there — point it at -Sku "XCEL00000001" -ProductId "900000001" with your own -Handle/-HandleTitle, same as adding any other module manual. New-XcelDocSection.ps1 is no longer the right tool for a new Xcelerator app's docs — it mints a new SKU, which is exactly the pattern being reverted. Use it only for a genuinely separate product that should not share this productId at all.

Live now, migrated by hand this pass (copy the handle's content folder into documentation\external\XCEL00000001\en-US\xcelerator_docs\<handle>\, rebuild its content record via New-XcelDcRecord — do not reuse the old SKU's record verbatim, two of the three had drifted categories/enrichedCategories.productGroups still keyed by their old, pre-migration productId even after their earlier productId migration, an inconsistency this rebuild fixes as a side effect):

Handle Was Now
doc_builder, automation-toolkit XCEL00000001 (native) unchanged
ssosetup XCEL00000003 XCEL00000001
cameo_switcher XCEL00000004 XCEL00000001
tc_subscriptions XCEL00000006 XCEL00000001

Migrated 2026-08-19: Tier Health (XCEL00000002 → handle tier_health) and Teamcenter Single Sign-On (XCEL00000005 → handle tc_sso), by their own owning sessions, following this same recipe: add as a handle under XCEL00000001 via Add-XcelDocModule.ps1, then retire the old SKU the two-step way (Bug 10) — delete its packages/content ES documents, and move its files out from under collections\ to _retired-skus\{documentation,es}\external\<SKU>\ rather than leaving them in place. All seven handles (doc_builder, automation-toolkit, ssosetup, cameo_switcher, tc_subscriptions, tc_sso, tier_health) are live under XCEL00000001 as of that date — see Bug 11 below for what nearly slipped through during this round of migrations.

Corrected 2026-08-20: the second half of that claim was false for both SKUs. Their ES documents really were deleted (confirmed found:false on both packages/_doc at the time), but their files were never actually moved out of collections\ — both sat there, live, for a full day, undetected because nothing re-scans collections\ until either our own import call or a real Siemens installer runs one. See Bug 12: this wasn't cosmetic. XCEL00000002's package.json also carries a packageVersion the importer itself rejects ("Unsupported Package Version: 0.6.0, Supported: ^1.x"), a second, independent defect. Both are now actually moved to _retired-skus\{documentation,es}\external\<SKU>\, verified by a real reset+import cycle that no longer mentions either SKU.

Verified end to end: all five handles pass every check in Test-XcelDocEndpoints; the home page shows exactly one "Xcelerator Workspace Modules" row; that row's own listing page shows Showing 1-5 of 5 with all five manuals as separate topic rows; the three old bare SKU URLs (/en-US/doc/900000001/XCEL0000000{3,4,6}.<handle>) now 404; the three new consolidated URLs (/en-US/doc/900000001/XCEL00000001.<handle>) all resolve 200.

Merge-XcelProductJson merges components and additively unions versions[], but does not prune stale entries on its own — after three components were retired, the shared document still carried their old release strings and component rows until they were removed by hand (a direct PUT of a hand-built, cleaned document, not through the merge helper, since removal is a different operation from the additive merge the helper is designed for). If this consolidation pattern recurs, that cleanup step needs doing explicitly; nothing currently automates it.

Bug 7: the ES fallback has never repaired a SKU with more than one handle

Found 2026-08-19. This one silently limits the fix everyone has been relying on, including the change that made the fallback run by default.

Publish-XcelDocsFallback read its content chunk like this:

$records = @(Get-Content $_.FullName -Raw | ConvertFrom-Json)
foreach ($rec in $records) { ... "/content/_doc/$($rec.id)" ... }

In PowerShell 5.1 ConvertFrom-Json emits a JSON array as one object down the pipeline, so @() wraps the whole array as a single element. Measured on a 4-record chunk:

Form Count [0].id
`@(Get-Content ... ConvertFrom-Json)` 1
`$o = Get-Content ... ConvertFrom-Json; @($o)` 4

So $rec was the array, $rec.id was four ids, and the PUT went to /content/_doc/A.one A.two A.three A.four. Elasticsearch answers 400 Bad Request with an empty body, which is why nothing in the output named a cause.

Why it looked fine: every SKU it had been exercised on had exactly one handle, and a single-element array survives the bug intact. It fails from the second handle onward, and it fails for ALL of them, not just the extras.

Fixed by assigning before wrapping. Verified against known-bad input: all four content records for XCEL00000002 were deleted, the fallback was run with no manual PUTs, and all four came back found=true with their shell pages at 200.

⇒ The general shape, again: a bug that only appears past N=1 reads as a working feature for as long as every test case is N=1. Two SKUs proved the fallback "worked" and both had one handle.

Second site, found 2026-08-19 while adding the seventh handle

Add-XcelDcRecordToIndex had the identical shape and I did not sweep for it when fixing the first:

$existing = @(Get-Content $chunkPath -Raw | ConvertFrom-Json)
if ($existing.id -contains $Record.id) { ... }

With one existing record the chunk parses to a bare object and this works. From the second record onward $existing holds a single nested array, .id is read off an object[], and StrictMode throws The property 'id' cannot be found on this object. The record is never appended, and Add-XcelDocModule.ps1 carried on and reported the handle live.

That is the precise explanation for a symptom seen earlier the same day and misattributed at the time: registering four handles on a fresh SKU, the first two landed and the last two did not. First append: no chunk. Second: one record, parses bare, fine. Third and fourth: two or more records, throw.

When you fix one of these, grep for the shape, not the line. One grep -rn "@(Get-Content.*ConvertFrom-Json)" across the toolkit would have found both sites at once, and this one cost an extra debugging round because it was not run.

Bug 8: Expand-XcelSectionPackage's package check silently rejects a valid zip, depending on which .NET runtime built it

Found and diagnosed 2026-08-19 by the SSO Setup session (XCEL00000003), while trying to export/import their own real section — reported here, not touched by them, because the scripts belong to this skill; fixed and re-verified in this pass, credit for the diagnosis is theirs.

Expand-XcelSectionPackage tested a zip's root entries with $_.FullName -match '^(documentation|es)/' — a forward slash only. A perfectly valid package built by Export-XcelDocSection.ps1 was rejected:

"...does not look like an Xcel doc section package (no documentation/ or es/ root)."

Root cause: [System.IO.Compression.ZipFile]::CreateFromDirectory's entry-name separator is not stable across .NET runtimes, from the identical call, on the identical machine. Confirmed directly by building a real package both ways and reading the raw .FullName values (not through a library that normalizes them — Python's zipfile does exactly that, and reading a package through it here would have shown clean documentation/... paths and sent the investigation looking for a different bug entirely, the same trap Bug 1's ConvertTo-Json posed from the other direction: an object layer that helpfully cleans up the thing being inspected):

Runtime Entry separator Old regex match
Windows PowerShell 5.1.26100.8894 (.NET Framework) \ (backslash) 0 of 17
pwsh 7.6.5 Core (.NET 10.0.11) / (forward slash) 20 of 20

Same PS5.1-vs-pwsh7 divergence shape as Bug 5, different mechanism. This one matters more than a cosmetic inconsistency because of what this specific function is for: Export-XcelDocSection.ps1 exists precisely so a package built on one machine installs on another, and there is no reason to expect both ends run the same PowerShell edition. A check that only accepts one runtime's output defeats the feature in exactly the cases it exists to serve.

Fixed: '^(documentation|es)[\\/]' — tolerant of either separator. Verified against known-bad input in both directions, not just reasoned about: a package built by genuine Windows PowerShell 5.1 (ZipFile.CreateFromDirectory run directly in powershell.exe, not simulated) now extracts correctly through Import-XcelDocPackage.ps1 -WhatIf (17 files, correct paths, no doubled SKU segment), and a genuinely malformed zip (real content, wrong root structure) is still correctly rejected with the same error. Only the guard was broken; the packaging mechanism itself was never at fault.

This also corrects the round-trip claim in the Reference Build section below. That claim was true as stated but narrower in scope than written: the export/re-import test was run entirely from a pwsh 7 session, where CreateFromDirectory happens to produce forward slashes, so the broken regex accidentally matched and the bug never manifested. The round trip was genuinely proven for pwsh 7 and was never proven — and would have failed — for Windows PowerShell 5.1, which is what powershell.exe actually launches by default on this OS. It is proven for both now, per the table above. A proof recorded in a skill is the thing the next session relies on instead of re-testing; say exactly what was covered, because "proven end to end" silently meant "end to end, on one of the two runtimes people actually run this on."

Official packaging, for comparison (and why this skill doesn't imitate it)

Two real docs-<product>-<version>-en-US.exe installers were taken apart (Simcenter Flotherm 2604 and Polarion 2606, both Siemens-signed). Both are a native PE launcher (~39.1 MB, consistent with InstallAnywhere/Flexera — strings LAX, IA_P, .jar) with a ZIP appended to the end of the file. The embedded PE OriginalFilename (e.g. 202510077-en-US-20260422100347.exe) matches, character for character, the <SKU>-<locale>-<timestamp>.xml.gz release-manifest filename already in the local install's own _msidata\ReleaseInfo\IXW\collections\ — proving these are the actual installer artifacts for collections already present locally, not generic samples.

The payload unzips to exactly /documentation/... + /es/... — the same two trees this skill already authors. A real content record from it validates this skill's schema field-for-field (files as an array of objects, baseVersions keyed by productId with array values), with one variant: a PDF-only collection uses "subTypes":{"dc":["dc.system"]} / "status":"APPROVED" where an HTML+toc.json collection uses dc.product / PUBLISHED.

Deliberately not replicated: the signed InstallAnywhere wrapper. Building one needs Siemens' own build tooling and signing certificate, and buys nothing on a locally-managed install — the .zip + import path already produces the identical collections\ result and passes the same verification. Export-XcelDocSection.ps1 packages the same two folders as a plain .zip; Import-XcelDocPackage.ps1 is the install step. Full writeup, including the "false EOCD" detour (a valid-but-irrelevant 1-entry archive found before the real 5,807/9,218-entry one — a signature match that parses correctly is not proof it answers the question), lives in the "Official Packaging" topic of the live reference build below.

Bug 9: the product.json refresh gap wasn't just in the manual migration, it was in every publish path

Found 2026-08-19 while migrating XCEL00000001's own releaseVersion. The read-merge-write recipe (Bug 7 / Merge-XcelProductJson, new this pass) fixed the manual migration case, but Add-XcelDocModule.ps1 and Publish-XcelDocs.ps1 both still wrote their local product.json straight to disk (or not at all) and then called the fallback, which PUTs that local file to the shared document unconditionally. Any handle-add or republish on an already-shared productId could still silently evict every other module, exactly the failure mode this whole exercise exists to prevent, just triggered from a different entry point than the one that got fixed first.

Fixed in all three publish paths (New-XcelDocSection.ps1, Add-XcelDocModule.ps1, Publish-XcelDocs.ps1): each now calls Merge-XcelProductJson — read the live products/_doc/<productId> document, update-in-place only the caller's own component (matched by id: $Sku, not the shared productId — a second thing New-XcelProductJson got wrong until this pass, defaulting a solo product's first component's id to $ProductId instead of its SKU, which is exactly the inconsistency Bug 7's manual fix corrected by hand for XCEL00000001 specifically — now it can't recur for any future section), leave every other component and version string untouched — immediately before publishing, not earlier in the script. versions[] is treated as purely additive (an old release string is never removed on a bump) to match the real Siemens product.json pattern of keeping historical release strings rather than pruning them.

Verified against the real shared document (4 components live: Cameo Connector Configuration, SSO Setup, Teamcenter Subscriptions, Documentation Builder): republishing XCEL00000001 with a bumped releaseVersion left the other three components byte-for-byte untouched, updated only its own updated timestamp, and all three neighbors' shell pages still resolved 200 afterward.

RETRACTED: "documents listing showing one row instead of two" was never real — the checking instrument was

The bug this section originally described did not exist. Diagnosed 2026-08-19 by the Cameo Switcher session, and confirmed here. The original report, and the investigation below it, both checked the listing by fetching the served HTML directly (curl/grep) and looking for the handle titles in it. The listing rows are rendered client-side from a POST /api/universal-search/search call — they are never present in the raw HTML response, for any handle, on any SKU, real Teamcenter included. So a grep-the-HTML check reads as "N-1 of N handles missing" whenever it's run against a multi-handle SKU, independent of whether anything is actually wrong.

The negative control that caught it: grepping real Teamcenter's own served HTML for "Active Workspace Customization" and "1st Stop" — both plainly present as rows when that same page is loaded in a browser — found neither. A control failing on known-good input means the instrument doesn't measure the thing, exactly the standing house rule about testing a checker against known-bad (and here, known-good) input before trusting what it reports. The correct check, used throughout this skill's own scripts (Test-XcelDocEndpoints, and this file's own real-Teamcenter comparisons), is either the live search API response or a browser-rendered read — get_page_text against the loaded page, not the page source. There was nothing wrong with the index, the listing, or any content record. Both handles rendered correctly the entire time.

Kept below for reference, since it's true independent of the phantom bug it was gathered to investigate: the listing does not gate on carrying a downloadable asset. Querying the real Teamcenter SKU PL20251212545240207's 215 collection:dc records directly, 3 of 215 carry formats:["html"] alone (no pdf, no other asset), and the very first hit in that SKU's own live search response, handle home, carries formats:["custom","json"] — neither html nor pdf. This was gathered as evidence toward a bug that turned out not to exist, but the fact itself is real and may be useful again: asset presence is not a factor in whether a handle renders.

Two claims from an earlier pass through this file did not survive being tested against a second real SKU, and are corrected here rather than left standing:

  • "The listing keys on productId, not the SKU in the URL" — wrong. Querying /en-US/documents/282219420/PL20241125556497283 (Teamcenter's 2506 SKU, same shared productId 282219420 as the 2606 one) returns 202 documents, releaseVersions:["Teamcenter 2506"], releasesku:"PL20241125556497283" throughout — a completely different result set from the 215 Teamcenter 2606 documents the other SKU under the same productId returns, not "the same ~188 KB whichever SKU addresses it" as previously stated. The search request is scoped by releasesku regardless of which productId the URL resolves through. Directly consistent with what this install's own shared-productId section shows: XCEL00000001's listing returns total:2 (its own two handles) even though three other modules (Cameo, SSO Setup, Subscriptions) share its productId and are not in that response. A shared productId does not aggregate a combined listing across SKUs on this install; each SKU still gets its own listing, scoped by its own releasesku. What productId sharing actually buys is untested beyond this — possibly just a valid shell-route namespace and the products/_doc taxonomy entry, not a merged catalog page.
  • "The url field's missing productId segment is a broken link in every record" — not a defect. Both the malformed-looking bare form (/en-US/doc/<sku>.<handle>, no productId) this module writes and the identical bare form a real Teamcenter record stores in its own url field (verified: PL20251212545240207.AWAdmin's live url is /en-US/doc/PL20251212545240207.AWAdmin?audience=..., no productId either) both resolve with a 302 to the canonical productId-qualified form and land on 200. Tested both, side by side, following redirects. This matches Siemens' own stored convention exactly; there is nothing here for New-XcelDcRecord to fix. A check that flags a 302 as broken without following it would produce this false alarm; a browser, and every normal HTTP client, does not.

⇒ Corroborates the standing house rule at one more level: a correction to a correction is still just a claim until it's tested against a second real data point, not one. Everything above was checked directly against this real install (two different real Teamcenter SKUs, the live search API, the raw content-index query, curl with and without following redirects) rather than reasoned from the shape of the first result. Both corrections stand even after the phantom-bug retraction above — they were reached by fetching the live search API response and following real redirects, not by grepping served HTML, so they don't share the flawed instrument that produced the phantom.

Bug 10: a retired SKU's files left under collections\ can resurrect it on the next full import

Found 2026-08-19 by the Cameo Switcher session, immediately after the SSO Setup/Cameo/ Subscriptions consolidation. Retiring a SKU (see "One shared SKU, one handle per module" above) deleted its packages/content Elasticsearch documents, which is what made its row disappear from the home page. It did not touch the SKU's files under collections\documentation\external\<SKU>\ and collections\es\external\<SKU>\ — and help-server-win.exe import (step one of every Publish-XcelDocs run, by anyone, for any SKU) scans and re-registers everything under collections\, including a package.json/content\0001.json nobody has touched in weeks. A retired SKU's files sitting there are a live fuse: the next full import — triggered by any session's routine republish, not necessarily anything to do with the retired SKU — silently resurrects its packages/content documents and the duplicate home-page row reappears, "long after everyone has forgotten why" (the session's own words, and accurate).

Fixed: moved the three retired SKUs' complete trees out from under collections\ entirely, to D:\Siemens\Help Server\_retired-skus\{documentation,es}\external\<SKU>\ — a sibling location the importer never scans, so nothing there can be rediscovered. This keeps every file as a byte-for-byte rollback path (nothing deleted) while fully defusing the resurrection risk, which a same-directory rename or a "do not touch" comment would not have. Verified by running the real import command afterward: none of the three retired SKUs appear in its "Importing packages" output, their packages/_doc documents still read found:false, and XCEL00000001 still carries exactly 5 content records.

Retiring a SKU under this model is two steps, not one: delete the ES documents (what makes it stop rendering) and move its files out of collections\ (what stops it from coming back). Doing only the first looks complete — the visible symptom is gone — right up until someone's unrelated publish silently undoes it.

Bug 11: "my handle published cleanly" no longer proves "the section is live"

Found 2026-08-19 by the SSO Setup session, publishing ssosetup right after the Tier Health and TC SSO migrations landed. On a shared SKU, reindexing is per SKU, not per handle — any one module's Publish-XcelDocs.ps1 run re-imports the whole SKU's chunk file, all seven handles at once. That means one module's publish can drop a different module out of the index, and from inside that publish the failure is invisible: every check it runs is scoped to its own id, and those all pass.

Observed, not hypothetical. tc_sso had been added to the chunk file (content\0001.json, now 7 records) and its record read PUBLISHED with a valid dtManifest, its folder was complete, its topics.json served — but a later publish reindexed the SKU without also re-registering it in Elasticsearch, and both content/_doc/XCEL00000001.tc_sso and its shell page 404'd. Nobody's own publish noticed, because nobody's publish checks a sibling's id. The SSO Setup session's own republish (which reindexes the whole SKU) happened to fix it as a side effect; it could just as easily have broken a different sibling instead.

Fix, contributed by that session: scripts/check_neighbours.py. Reads a SKU's chunk file, looks up every record id in it (not just "mine") against the live content index, and exits 0 only if all of them resolve — 1 if any is missing, 2 if Elasticsearch could not be reached at all (never reported as a clean estate, per the house rule that a check which cannot run must never look like a pass). Carries its own --self-test against a known-bad id and a known-good control, which was re-run against this install (not just trusted) before adoption:

python scripts\check_neighbours.py --self-test --es http://localhost:51001
python scripts\check_neighbours.py "D:\Siemens\Help Server\collections\es\external\XCEL00000001\en-US\content\0001.json" --es http://localhost:51001

Both passed here on 2026-08-19, and a direct check of all seven handles' shell pages plus the shared products/_doc/900000001 document confirmed 7/7 live with no stray components.

Run check_neighbours.py against the shared SKU's chunk file after any publish that touches XCEL00000001, not just after your own handle's. It is the only check in this toolkit that looks at anyone else's id. Test-XcelDocEndpoints (Bug 5) is still correct for verifying your own handle end to end; it was never meant to catch a sibling regression and does not.

Bug 12: a real Siemens documentation installer dies on our own SKU, and rolls back its own install

Found 2026-08-20. Chris ran a genuine Siemens documentation installer (adding "Active Integration Gateway 2606" to this server) and it reported the index could not be rebuilt. The new documentation did not land — collections\ held no trace of it afterward, and re-running the installer produced the identical failure.

Every Siemens documentation installer's post-copy step runs the exact reset then import sequence documented at the top of this file, against the entire collections\ tree — there is no way to scope it to just the packages the installer is adding. Debug.info (the installer's own transcript) showed why this one failed:

Importing packages: "XCEL00000001, en-US, 1.0.0, Xcelerator Workspace Modules 1.0"
error:   Error Importing: XCEL00000001-en-US, Xcelerator Workspace Modules 1.0
error:   Error: [parsing_exception] [1:37] [ids] failed to parse field [values], with { line=1 & col=37 }

Bug 2's exact error text, but a different trigger: this is the vendor's own installer invoking help-server-win.exe import, not one of our own scripts, and six real Siemens packages had already imported cleanly in the same run before it reached ours and died. That rules out Bug 2's process-working-directory explanation — cwd was constant for the whole run, and it didn't stop the first six packages. The installer appears to treat an import failure as fatal to the whole transaction and rolls back its own just-copied files. New Siemens documentation cannot currently be installed on this server while our SKU is present and broken. This is worse than Bug 6/Bug 2: those left our own section degraded; this blocks Siemens' own content.

Checked for a real fix, not just a workaround. Diffed a live XCEL00000001 content record against a real Siemens one (PL20251212545240207.AWAdmin) field-by-field — identical keys, identical types, nothing malformed on our side. So there is no schema defect in our records to correct; the failure lives inside the closed help-server-win.exe binary's own create-vs-update logic, most likely specific to a SKU that has ever been fallback-published (direct PUT to Elasticsearch) rather than created purely by import. Not confirmed without decompiling the vendor binary, and not worth chasing further given the fix below is cheap and general. There is no "do it the right way so it stops being a bug" available to us here — only the operational workaround.

One data point complicates even that theory, recorded rather than smoothed over: reproducing the failure with XCEL00000001 deliberately suspended (see below) still hit the identical [ids] error, but on XCEL00000005 — one of the two dangling SKUs described below, not XCEL00000001 at all. And once XCEL00000002 and XCEL00000005 were properly retired (moved out of collections\, not just de-indexed), a plain help-server-win.exe import of XCEL00000001 alone succeeded with no error at all** — the fallback wasn't even needed that time. **So this may not be a property of any one SKU in isolation; it may be a knock-on effect of XCEL00000002's separate Unsupported Package Version error (below) leaving the importer's internal state broken for whatever package it processes next. Marked as a hypothesis with one supporting data point, not a conclusion — the fix below doesn't depend on which explanation is right.

A second, independent defect surfaced in the same test: XCEL00000002's package.json carries "packageVersion": "0.6.0", which the importer rejects outright (Unsupported Package Version: "0.6.0", Supported: "^1.x") — logged as an error but non-fatal, unlike the [ids] crash. Every SKU this skill has minted uses 1.0.0 (see New-XcelProductJson); 0.6.0 on Tier Health's package must have come from a hand-edit that used the app's own version instead of the SKU's. Worth checking before it's someone's real problem: packageVersion in package.json must satisfy ^1.x, independent of whatever version string the app itself is on.

And two loose ends from the "One shared SKU" migration turned out not to be cosmetic. Per the correction added above, XCEL00000002 and XCEL00000005 were de-indexed but never actually moved out of collections\ — sitting there live for a full day. That's exactly Bug 10's resurrection risk, and this session's test proved it's not theoretical: both independently broke a real import (the version error on 2, the [ids] crash on 5) before either one had anything to do with XCEL00000001. Both are now moved to _retired-skus\{documentation,es}\external\<SKU>\, verified by a real reset+import that no longer mentions either.

Fix: Suspend-XcelDocSection.ps1 / Resume-XcelDocSection.ps1, using the same move-out-of-scan mechanism Bug 10 uses for permanent retirement, applied reversibly:

# Before running ANY Siemens documentation installer:
.\scripts\Suspend-XcelDocSection.ps1 -Sku "XCEL00000001"

# ... run the Siemens installer ...

# Immediately after:
.\scripts\Resume-XcelDocSection.ps1 -Sku "XCEL00000001" -ProductId "900000001" -Handle "doc_builder"

Suspend moves both source trees (documentation\external\<SKU> and es\external\<SKU>) to _suspended-during-install\, refusing if the SKU isn't currently present or is already suspended. Resume moves them back and unconditionally republishes through Publish-XcelDocs.ps1 -ForceFallbackForSkus, never trusting a plain import to have picked the SKU back up correctly — because the installer's reset step wipes every index regardless of which files were present, so there is nothing to "check" first, only something to restore.

Verified end to end, not just reasoned about: suspended XCEL00000001, ran a real reset then import — all seven then-present Siemens packages imported clean, confirming the suspend actually prevents the installer-fatal crash. Separately hit the version and [ids] errors on the two still-dangling orphans in that same run, retired both properly, ran reset+import again — fully clean, and it picked up "Active Integration Gateway 2606," the package whose install this bug had been blocking, confirming the fix's actual purpose, not just its absence-of-error. Resumed XCEL00000001, verified all seven handles found:true in Elasticsearch and 200 on their shell pages.

Any custom section on this server is a standing risk to every future Siemens documentation install, not just to its own visibility. Suspend before installing anything from Siemens; resume after. And per Bug 10, a "retired" SKU is not actually retired until its files are physically out of collections\ — checking Elasticsearch alone (as the migration writeup above did) is exactly the kind of narrower-than-it-looks verification the workspace's standing rules warn about.

Using the scripts

Import-Module .\scripts\XcelDocBuilder.psm1

# New section (once per SKU/productId):
.\scripts\New-XcelDocSection.ps1 -Sku "XCEL00000002" -ProductId "900000002" `
    -ProductTitle "Tier Health" -Collection "xcelerator_docs" `
    -Handle "overview" -HandleTitle "Tier Health"

# Add a module to a section that already exists:
.\scripts\Add-XcelDocModule.ps1 -Sku "XCEL00000002" -ProductId "900000002" `
    -Handle "install-guide" -HandleTitle "Install Guide"

# Republish after hand-editing content, or force the fallback:
.\scripts\Publish-XcelDocs.ps1 -Sku "XCEL00000002" -ProductId "900000002" -Handle "overview"
.\scripts\Publish-XcelDocs.ps1 -ForceFallbackForSkus "XCEL00000002"

# Before/after any real Siemens documentation installer (Bug 12):
.\scripts\Suspend-XcelDocSection.ps1 -Sku "XCEL00000001"
.\scripts\Resume-XcelDocSection.ps1 -Sku "XCEL00000001" -ProductId "900000001" -Handle "doc_builder"

# Package a section and install it on a different server:
.\scripts\Export-XcelDocSection.ps1 -Sku "XCEL00000002" -OutputZipPath "C:\temp\tier-health-docs.zip"
.\scripts\Import-XcelDocPackage.ps1 -ZipPath "C:\temp\tier-health-docs.zip" `
    -TargetHelpServerRoot "\\other-machine\D$\Siemens\Help Server" -WhatIf   # dry run first
.\scripts\Import-XcelDocPackage.ps1 -ZipPath "C:\temp\tier-health-docs.zip" `
    -TargetHelpServerRoot "\\other-machine\D$\Siemens\Help Server" `
    -Sku "XCEL00000002" -ProductId "900000002" -Handle "overview"

All three scripts finish by calling Test-XcelDocEndpoints, which checks the static content page, the handle shell page, and the product documents-listing page — report a handle as live only when all three return 200.

What this does NOT do

  • Does not touch TC_Help_Documentation_Link (the Teamcenter site preference driving the real product's Help menu) — that is one global value shared with all of Teamcenter's own documentation. Pointing a specific module's UI at its own shell URL (an About/Help button) is the safer integration point; changing the global preference needs a deliberate human decision.
  • Does not index individual topic pages for full-text site search — only the one dc record per handle. The in-collection search box (reads topics.json client-side) works regardless; the server's global "search everything" box does not yet reach individual custom topic pages.

Reference build

The recipe above was proven on a real section, still live on the local install: http://localhost:51000/en-US/documents/900000001/XCEL00000001 (SKU XCEL00000001, "Documentation Builder" — now one of several modules sharing productId 900000001 / "Xcelerator Workspace Tools", see the shared-productId note above; the URL itself did not change since this SKU minted that productId originally), two handles: doc_builder documents this whole process (including the "Official Packaging" topic above) and automation-toolkit documents the scripts themselves, was created by Add-XcelDocModule.ps1, and was exported and re-imported onto this same server as a round-trip proof of Export-XcelDocSection.ps1 / Import-XcelDocPackage.ps1). That first round trip ran entirely under pwsh 7 and, per Bug 8, could not have exercised the Windows PowerShell 5.1 failure mode — it proved pwsh 7 only, not "end to end" as originally written. Re-verified 2026-08-19 with a package built by genuine Windows PowerShell 5.1 (powershell.exe directly, not simulated): rejected by the broken check, accepted after the Bug 8 fix, 17/17 files landing correctly, no doubled SKU segment. Both runtimes are covered now.


Generated from skills/tc-help-server-authoring/SKILL.md in the tc-automation-skills library, which is the canonical copy and also serves as the agent skill set for Teamcenter work.