Write me the ENV Portal workspaces for my project. ENV Portal is a browser extension that rewrites the address of the page I am on into the same page on my other environments. A workspace is the JSON that describes one project. The specification is below the line at the bottom of this message. Follow it exactly, including the four surprising behaviours. My addresses. One block per page, every address a complete page address rather than a site root. Add a block for every other family of hosts the project has: a media host, an API, a ticket system, a repository. the same page, in each environment I have it - [paste one] - [paste one] - [paste one, or delete this line] another family of hosts (delete this block if there is none) - [paste one] - [paste one] Which locale to assume when an address has none: [e.g. de-DE, or say "none"] The ticket key shape, if I want a ticket link: [e.g. PROJ-42395, or "none"] If I paste a workspace and a symptom instead of addresses, do not write me a new one. Find the cause: the specification ends with a section for that, and I can run any address through the editor's Try an address card and tell you what it says. Settle these yourself rather than asking me: - Addresses that differ only in locale are the same page. Declare every spelling you can see in `languages`. - One workspace for the whole project, however many hosts it has. A second workspace is for a second project, not for a second kind of host. - A family of hosts whose addresses have a different path shape, a media host or an API, is its own environment in that same workspace, with an `onlyOn` pattern saying which addresses it applies to. Keep `{{path}}` in those templates and let `onlyOn` hold the group back. Do not gate a group with a variable that fails to resolve; that was the workaround before `onlyOn` existed and it costs the links their `{{path}}`. - Every address I paste is an example of a shape, not a claim about one page. If my API example is a different page from my website example, the shape is still what you need. Do not ask me to line them up. - A block I deleted, or answered with "none", does not exist. Do not ask about it. Ask me only for what you cannot derive and cannot do without: at most three questions, in one message. Otherwise reply with nothing but the workspace, as one fenced json code block per workspace: a JSON object each, and nothing outside the fence but the workspace's name on the line before it. The fence matters, because my chat renders your reply as Markdown and unfenced JSON reaches me with `[a-z]{2}` reduced to `{2}` and every doubled backslash halved. I paste the contents of one block into one workspace in the settings page. ============================================================================== THE SPECIFICATION ============================================================================== ## What to return When you have enough information, reply with the workspace in one fenced `json` code block and nothing else: no prose before or after it, no headings, no trailing summary. When there is more than one workspace, one block each, with nothing but the workspace's name on the line before it. **The fence is not optional.** Unfenced JSON is rendered as Markdown on its way to the author's screen, and Markdown eats the parts of a workspace that matter most: `[a-z]{2}` in a character class comes out as `{2}`, a doubled backslash comes out single, and `*` disappears. What arrives then looks like what you sent and is not. Inside a fence nothing is interpreted. One block holds **one workspace: a JSON object**, not an array and not a wrapper. The settings page edits one workspace at a time, and the author copies the block's contents — not the fence — into that editor, which runs `JSON.parse` over exactly what is pasted. JSON has no comments. The annotated blocks below use them to describe the schema; never emit one. Double quotes on every key and string value, no trailing commas. Diagnosing is the exception. When the author brings a workspace and a symptom rather than addresses, answer in prose, and put nothing in a fence but the part that changes. See when a workspace does not behave. When you do not have enough information, do the opposite: ask, and emit no JSON at all. A guessed workspace is worse than a question, because it looks finished and fails later on one page in one environment. See what to ask for. ## Output shape One JSON object. One object is one project. { "workspaceName": "string, required, shown in the sidebar", "schemaVersion": 2, // integer, optional, emit 2 "languages": ["de-DE", "en-global"],// string[], optional, emit whenever // the project uses locales "variables": [ /* VariableConfig, optional */ ], "environments": [ // required, at least one { "name": "string, required, the group heading in the popup", "onlyOn": "regex, optional", // build this group only on addresses it matches "links": [ // required { "name": "string, required, the link text", "url": "string, required, a template", "onlyOn": "regex, optional",// same field, one link "defaultLanguage": "string, optional", "defaultPath": "string, optional" } ] } ] } `ticketPrefix` is accepted on input and converted away. Never emit it. ### VariableConfig { "name": "string, required, [A-Za-z0-9_-]+ only", "label": "string, optional, shown on the input and the chip", "extractFrom": [ /* VariableSource, optional */ ], "promptIfMissing": true, // boolean, optional. Offer a text box when unresolved "remember": true, // boolean, optional. Keep the last value found, per ticket "valuePattern": "\\d+" // string, optional, default \d+. Shape the value takes inside a template. // Widen it for anything that is not digits } ### VariableSource Exactly one of `urlPattern` or `domPattern`. Both present is rejected, neither is rejected. | Field | Applies to | Meaning | | --- | --- | --- | | `urlPattern` | address | Regex run against the active tab address. Capture group 1 is the value. | | `domPattern` | page | Regex run against every link href on the page. Capture group 1 is the value. | | `scopeSelector` | page | CSS selector. Limits the search to matching elements. Must be a valid selector. | | `hoverSelector` | page | CSS selector inside each scope. Pointer events are sent to these before reading, then released. | | `requireInScope` | page | Regex. A scope is trusted only if its markup also matches. Supports `{{ticketNumber}}`. | | `waitMs` | page | Integer 0 to 15000, default 5000. How long to keep re-reading after hovering. | All regexes are case insensitive. In JSON, a backslash doubles: `\d` is written `\\d`. ## Placeholders `{{language}}` and `{{path}}` are reserved and filled from the address. Every other `{{name}}` refers to a declared variable. A placeholder naming nothing declared is left in the output verbatim, so a typo produces a broken URL rather than an error. ## Resolution order For one tab address and one workspace list: 1. **Match an environment.** Walk workspaces, then environments, then links in declaration order. Take each link's hostname, including port. Match when the page's hostname contains it as a substring. **The first match wins and ends the search.** The matched workspace is the only one used. 2. **Detect the locale.** `demo-xx-xx` first, then `xx`, `xx-xx`, `xx-global`. Empty when nothing matches. 3. **Extract the path.** Within the matched environment, find the link whose expanded base address is the longest prefix of the page address. Remove that prefix, then the locale segment. Query strings and fragments stay part of the path. 4. **Resolve variables**, strongest source first: 1. values captured while matching a host template in step 1 2. `urlPattern` sources run against the address 3. values handed in by the caller: what the user typed, what was read off the page, what was remembered. Each is first passed through that variable's own `urlPattern` sources, so a pasted URL or a prefixed number reduces to the value. Only declared names are accepted. 5. **Build links.** For every link in every environment of the matched workspace, not only the matched environment. `ticketNumber` is an ordinary variable with no special handling. ## One workspace, and how its groups stay apart One project is one workspace, however many hosts it has: every environment, every localhost, the API hosts, the media host, the ticket system, the action links. Default to that. The popup groups links by environment name, so a workspace with six environments reads as six labelled groups rather than a list. Emit a second workspace for a second project, not for a second kind of host. What makes that safe is a rule to design against: every link in the matched workspace is built, whether or not it fits the page. Put a media host in beside a website on nothing but `{{path}}` and every page offers two dead media addresses: on https://www.example.com/de-de/products/pump-42 https://media.example.com/products/pump-42 ← nothing is there https://media-stage.example.com/products/pump-42 ← nor here **Say where a group applies with `onlyOn`, a regex tested against the whole address.** It sits on an environment, on a single link, or on both. A group whose pattern does not match the current address contributes no links at all: "environments": [ { "name": "Website", "onlyOn": "^https?://(?:www|www-stage)\\.example\\.com/", "links": [ { "name": "Live", "url": "https://www.example.com/{{language}}/{{path}}" }, { "name": "Stage", "url": "https://www-stage.example.com/{{language}}/{{path}}" } ] }, { "name": "Media", "onlyOn": "^https?://media(?:-stage)?\\.example\\.com/", "links": [ { "name": "Live", "url": "https://media.example.com/{{path}}" }, { "name": "Stage", "url": "https://media-stage.example.com/{{path}}" } ] } ] On a page the media group is absent, on an image the website group is, and both live in one workspace with `{{path}}` intact in every template. Absent `onlyOn` means everywhere, which is what every link without it says. Emit it for any family of hosts whose addresses have a different path shape from the rest of the project. Two properties of it matter before you design around it. **It gates building, not matching.** An environment that is gated off can still be the one the address matched, and `{{path}}` still comes out of it, so gating a group never changes which environment a page resolves to. And the pattern needs no capture group, unlike every other regex in this format: it answers yes or no, so there is nothing to read out of it. Do not gate with a variable that fails to resolve. Giving a group a variable extracted only from its own hosts has the same visible effect, since a link referring to a variable with no value is dropped, but it costs those links their `{{path}}` and their `defaultPath` to buy nothing. `onlyOn` says it directly. A variable is still the answer when a link needs a piece of the address rather than the whole path. That is reshaping rather than gating, and it carries a value instead of a condition. Given `https://www.example.com/de-de/products/pump-42` and an API at `https://api.example.com/products/de/pump-42`: "variables": [ { "name": "slug", "extractFrom": [{ "urlPattern": "/([^/?#]+)/?$" }], "valuePattern": "[^/?#]+" }, { "name": "region", "extractFrom": [{ "urlPattern": "/([a-z]{2})-[a-z]{2}(/|$)" }], "valuePattern": "[a-z]{2}" } ] with the link `https://api.example.com/products/{{region}}/{{slug}}`, which no longer needs `{{path}}` at all. Cut such a pattern to what the value is, not to where the link belongs: `/(\\d+-[^/?#]+)$` reads a job slug and reads nothing on the listing above it, so the link is held back there as a side effect of the value being absent. Where the link applies is `onlyOn`'s job, and saying it there leaves the reason legible in the diagnostics. `valuePattern` has to be widened past its `\\d+` default for any value that is not digits. It is the shape the value takes inside a template, so a slug left on the default makes the extension look for a number in that position later and find nothing. A ticket system or a repository is a link in the workspace it serves. It is also what makes a ticket page somewhere ENV Portal can offer links from at all. ## Invariants an assistant must not violate - **A link referring to a variable with no value is omitted.** Never suggest a fallback that emits a partial URL. - **The address always outranks a handed-in value.** Do not design around caching an address-derived value. - **A remembered value is offered only on a page naming the same ticket.** On a page with no ticket it is not offered. - **More than one distinct value read from a page is discarded.** Design `scopeSelector` and `requireInScope` so one page yields one value. - **Reading a page happens on the click that opens the popup, on that tab only.** No host permissions, nothing leaves the machine. - **`onlyOn` gates building, never matching.** Do not use it to steer which environment an address resolves to. ## Validation, as enforced on save | Rule | Effect when broken | | --- | --- | | Unknown key on a link, environment or variable | rejected | | Unknown key at the top level of a workspace | accepted and preserved | | `name` and `url` on a link | both required | | Variable `name` outside `[A-Za-z0-9_-]+` | rejected | | Source with both `urlPattern` and `domPattern` | rejected | | Source with neither | rejected | | Pattern that does not compile | rejected | | Pattern with no capture group, including only `(?:…)` | rejected | | `scopeSelector` that is not a valid CSS selector | rejected | | `waitMs` outside 0 to 15000, or not an integer | rejected | | A variable with no `extractFrom` at all | accepted, resolved by typing alone | | `onlyOn` that does not compile | rejected | | `onlyOn` with no capture group | accepted. It answers yes or no about an address, so there is nothing to capture | ## Behaviour that will surprise you Four of these. Ignoring them produces workspaces that look right and misbehave. **Locale detection guesses, unless the workspace declares `languages`.** The guess has two holes, and both are silent. It is greedy, so any two-letter segment counts: `/ui/settings` yields `language: "ui"` and `path: "settings"`. It is also case-sensitive and lowercase-only, so the canonical BCP 47 spelling `de-DE` is missed completely — the locale drops out of every link built from that tab, and nothing reports an error. Declaring the locales the project uses turns the guess into a lookup, matched case-insensitively and longest-first, which closes both holes: "languages": ["de-DE", "en-GB", "en-global"] Emit `languages` whenever the addresses show a locale segment. Take the spellings from the real addresses. If a project uses two-letter segments that are not locales, leave `{{language}}` out of those templates instead. **The locale and path are appended to a bare origin, and to nothing else.** `https://dev.example.com` becomes `https://dev.example.com/de-de/products/pump-42`, which is the rule's whole purpose. A placeholder-free template that carries a path, a query or a fragment is an action rather than a page — a cache-clear endpoint, a webhook, a health check — and is emitted exactly as written, including `https://dev.example.com/_node/clear-cache?force=1`. So write an action link as it must be called, and never expect a suffix to finish one. A template holding any placeholder, a variable included, is also left alone. **One hostname must not nest inside another.** If dev is `dev.example.com` and a branch deployment is `mr-3282.dev.example.com`, the second contains the first, so on the branch deployment the first match is the dev environment. Path extraction then runs against the wrong environment and returns empty, and every link loses the page. Give branch deployments a distinct host such as `mr-3282.branch.example.com`. Matching is first-wins, not closest-wins. **A page is only reachable if some link names its host.** To offer links from a ticket page, the workspace needs a link pointing at the ticket system. ## What to ask for before writing one The template is derived from real addresses, never guessed from a description. That only works if the addresses carry the structure, so the first job is deciding whether they do. ### An example is a shape, not a page Addresses are pasted to show structure. Two of them describing different pages still describe one template, so `https://api.example.com/jobs/de/9412-marketing-lead` is full evidence for `https://api.example.com/jobs/{{lang}}/{{slug}}` even when the website address the author pasted beside it is job 9544. Never ask which page an example belongs to, and never ask for a matching pair. Read the shape and move on. What that leaves is one real decision, and it is made in the pattern rather than asked about: where a link makes sense on some pages only, give its variable a `urlPattern` narrow enough to find nothing elsewhere. A job endpoint keyed on `/(\\d+-[^/?#]+)$` resolves on a job page and is held back on the listing above it, which is the behaviour wanted. The same variable keyed on `/([^/?#]+)$` would resolve everywhere and build a call to a job that does not exist. ### An origin is not a page Two different things arrive looking alike: | Given | What it tells you | | --- | --- | | `https://www.example.com/de-DE/products/pump-42` | A page. Locale segment, path shape, depth — everything the template needs. | | `https://www.example.com` | An origin. The host, and nothing else. It cannot tell you whether the project is `/{{language}}/{{path}}`, `/{{path}}`, `/{{language}}/products/{{path}}`, or something else again. | Deriving a locale or a path template from an origin means inventing the project's URL structure. Do not. Note also that a bare origin as a link `url` is a placeholder-free template, which triggers the append behaviour described above. ### Stop and ask when any of these is true - Fewer than two complete page addresses, for **the same page**, in different environments. - The addresses may not be the same page. Different paths across environments give you no shared structure to extract. Localised paths are not this case: `/de-de/karriere/offene-stellen/…` and `/en-us/career/job-offers/…` are one page in two locales, and they confirm the same `/{{language}}/{{path}}` template. Declare both spellings in `languages` and carry on. - Only origins were supplied, or a mix of origins and pages. - A path begins with a two-letter segment and you cannot tell whether it is a locale or a real path segment. - A branch or preview deployment is mentioned but no real address for one was given. - A ticket link is wanted but no ticket address or key shape was given. Do not ask about anything already settled: whether it is one workspace or several, whether two addresses in different locales are the same page, or something the author said does not exist. At most three questions, in one message, and only for what cannot be derived. A fifth question costs more than a workspace the author checks with `?debug=1`. Ask the minimum needed and stop there. Three addresses of one page are usually the whole answer: https://www.example.com/de-DE/products/pump-42 https://www-stage.example.com/de-DE/products/pump-42 http://localhost:4321/de-DE/products/pump-42 ### Also worth asking, once the addresses are in 1. One real ticket address, and the ticket key shape, for example `PROJ-42395`. 2. Whether branch or preview deployments exist, and one real address for one. 3. Which locale to assume when an address has none, for `defaultLanguage`. Never invent one. 4. For every family of hosts beyond the website, whether its addresses carry a locale and share the website's path shape. That is what decides its `onlyOn`, and one address per family answers it without asking. Derive the shared prefix and the varying parts from the addresses rather than asking the author to describe them. ## Worked example Given `https://www.example.com/de-de/products/pump-42`, `https://dev.example.com/de-de/products/pump-42`, `https://mr-3282.branch.example.com/de-de/products/pump-42`, `https://gitlab.example.com/team/repo/-/merge_requests/3282` and `https://example.atlassian.net/browse/PROJ-42395`: { "workspaceName": "Example", "schemaVersion": 2, "variables": [ { "name": "ticketNumber", "label": "Ticket number", "extractFrom": [{ "urlPattern": "PROJ-(\\d+)" }], "promptIfMissing": true }, { "name": "mrNumber", "label": "Merge request number", "extractFrom": [ { "urlPattern": "/-/merge_requests/(\\d+)" }, { "urlPattern": "^https?://mr-(\\d+)\\." }, { "domPattern": "/-/merge_requests/(\\d+)", "scopeSelector": "[data-testid*='development-context-panel']", "hoverSelector": "button", "requireInScope": "PROJ-{{ticketNumber}}" } ], "remember": true, "promptIfMissing": true } ], "environments": [ { "name": "Live", "links": [ { "name": "Live", "url": "https://www.example.com/{{language}}/{{path}}", "defaultLanguage": "de-de" } ] }, { "name": "Dev", "links": [ { "name": "Dev", "url": "https://dev.example.com/{{language}}/{{path}}", "defaultLanguage": "de-de" } ] }, { "name": "Branch", "links": [ { "name": "Branch deployment", "url": "https://mr-{{mrNumber}}.branch.example.com/{{language}}/{{path}}", "defaultLanguage": "de-de" }, { "name": "Merge request", "url": "https://gitlab.example.com/team/repo/-/merge_requests/{{mrNumber}}" }, { "name": "Ticket", "url": "https://example.atlassian.net/browse/PROJ-{{ticketNumber}}" } ] } ] } Verified behaviour of that workspace: | On this page | Environment | Path | Branch link | Ticket link | | --- | --- | --- | --- | --- | | `www.example.com/de-de/products/pump-42` | Live | `products/pump-42` | held back | held back | | `example.atlassian.net/browse/PROJ-42395` | Branch | empty | built, once the page yields the number | built | | `mr-3282.branch.example.com/de-de/products/pump-42` | Branch | `products/pump-42` | built | held back | The held-back links are correct rather than missing. On the live site neither number is present, and on the branch deployment no ticket number appears in the address. ## Before you reply Run this over the JSON you are about to send. - It is one fenced `json` block per workspace, holding an object rather than an array, and nothing but a name line sits outside a fence. The editor does accept an array of workspaces on paste, but only into a new workspace, so one object per block stays the shape that works everywhere. - `JSON.parse` would accept the block's contents: no comments, no trailing commas, no prose. - Every workspace has `workspaceName`, `schemaVersion: 2` and a non-empty `environments`. - Every environment has a `name` and at least one link; every link has a `name` and a `url`. - No `ticketPrefix`. - Every `{{variable}}` other than `{{language}}` and `{{path}}` names a variable declared in `variables`. - Every regex compiles and has at least one capturing group; no source carries both `urlPattern` and `domPattern`; every `waitMs` is an integer from 0 to 15000. - `languages` is present if the addresses showed a locale, spelled as the addresses spell it. - Every placeholder-free template is one of two things on purpose: a bare origin, meant to receive the locale and path, or an action link written exactly as it must be called. - No two hosts nest inside one another. - Every family of hosts with its own path shape carries `onlyOn`, and no group is gated by a variable that exists to fail. Every `onlyOn` compiles. - Nothing was invented: every environment, variable, host and ticket system traces back to something supplied. ## When a workspace does not behave A workspace that saves and then behaves wrongly has one cause, in one of the five steps of the resolution order above, and the address plus the Try an address card names which. Ask for both before reading anything else: the exact address the author was on, and what that card says on it. Then change one thing and have them run it again. Do not rewrite the workspace. A rewrite hides which line was wrong, and the next symptom starts from nothing. | What the author sees | What it is | The fix | | --- | --- | --- | | The popup offers nothing at all | No link's hostname is a substring of the page's hostname, so no environment matched | Add a link naming that host. A page is reachable only if some link names it | | Links come from the wrong environment | One host nests inside another and the first declaration won | Give the inner host a distinct name, or declare it first. Matching is first-wins, never closest-wins | | Every link lands on the site root | The path came out empty: no link in the matched environment prefixes the address, often because the template spells the locale differently or carries a path the address does not | Compare the template against the address the card shows, one segment at a time | | The locale is gone from every built link | No `languages`, and the guess is lowercase-only, so `de-DE` was missed | Declare `languages` with the spellings the addresses use | | The locale is a segment that is not a locale, such as `ui` | The guess is greedy about any two-letter segment | Declare `languages`, or leave `{{language}}` out of those templates | | One link is missing, the rest are fine | It refers to a variable with no value on that address, or its own `onlyOn` did not match | The card names which. Widen the variable's source, or the pattern | | A whole group is missing | The environment's `onlyOn` did not match | Test the pattern against the address as written, scheme included | | A link appears on a page where it is dead | It has no `onlyOn`, so it was built with a path that belongs to another family of hosts | Give its environment an `onlyOn` | | An action link came out with a locale and path stuck on | It is a bare origin, which is the one shape that gets the suffix | Write the endpoint's own path into the template. Anything past the host stops the append | | A value read off the page never arrives | Nothing reads a page except the click that opens the popup, and two distinct values on one page are discarded | Narrow `scopeSelector` and `requireInScope` until one page yields one value. The Try an address card cannot resolve these at all | | A variable resolves, and the link using it still does not build | The name in the template does not match the declared name. An undeclared placeholder passes through as written | Compare the two spellings, including case | | A host template with a variable in it never matches | `valuePattern` defaults to `\d+` and the value is not digits | Declare the shape the value takes | A workspace the settings page refuses to save is a different failure, and the message names the field. The rejections are listed under validation above: an unknown key inside a link, an environment or a variable, a source with both `urlPattern` and `domPattern` or with neither, a pattern that does not compile or captures nothing, an `onlyOn` that does not compile, a variable name outside `[A-Za-z0-9_-]+`, and a `waitMs` outside 0 to 15000. Unknown keys at the top level of a workspace are kept, so a typo there fails quietly instead. ## Checking your output The author can check a workspace without leaving the settings page. The editor's sidebar has a **Try an address** card: they paste one page address into it and it shows the matched environment, the locale, the path, every resolved value, every link it would open and every link it would not, with the reason. It reads the editor buffer, so it answers before anything is saved. Tell them to try one address per family of hosts, and ask for what it says rather than guessing. Two limits on that card, both worth passing on. It tries only the workspace being edited, while the popup checks every saved workspace and takes the first whose host matches. And it reads no page, so a link waiting on a `domPattern` value shows as held back there and still works in the popup. The popup itself answers the same question on a real page: opening the settings page with `?debug=1` makes it list every resolved value with its source, and every held-back link with what it waits for.