Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v6
with:
node-version: 24

- name: Skill scripts stay executable (canonical + generated mounts)
# Proves the committed install payload is runnable. Canonical scripts
# are checked as committed (not chmod'd first), so a script committed
Expand Down Expand Up @@ -106,7 +110,7 @@ jobs:
run: bash tests/claude-hooks.sh

- name: Candidate archive contract
run: node --test tests/archive-candidate.test.mjs
run: node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs

- name: ShellCheck
# Pinned to v2.0.0 by full commit SHA — public-repo supply-chain
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/release-skill-archive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,18 @@ jobs:
with:
fetch-depth: 1

- uses: actions/setup-node@v6
with:
node-version: 24

- name: Sanity-check skill mirrors are in sync
run: bin/sync-mounts.sh --check

- name: Validate packaging and standalone instructions
run: |
node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs
bash tests/run.sh

- name: Compute identifiers
id: ids
run: |
Expand Down Expand Up @@ -77,7 +86,7 @@ jobs:
--exclude='.DS_Store' \
-C skills/display-dev -cf - . | gzip -n > display-dev.tar.gz

cp skills/display-dev/SKILL.md SKILL.md
node bin/build-standalone.mjs skills/display-dev SKILL.md

ARCHIVE_SHA="$(sha256sum display-dev.tar.gz | awk '{print $1}')"
ARCHIVE_SIZE="$(stat -c %s display-dev.tar.gz)"
Expand Down Expand Up @@ -148,7 +157,9 @@ jobs:
echo "non-deterministic build: $SHA1 != $SHA2"
exit 1
fi
rm -f display-dev.tar.gz.2
node bin/build-standalone.mjs skills/display-dev SKILL.md.2
cmp SKILL.md SKILL.md.2
rm -f display-dev.tar.gz.2 SKILL.md.2

- name: Write checksums.json
run: |
Expand Down
33 changes: 30 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## What you edit, and what is generated

- **Canonical skill content** lives under `display-dev/` (`SKILL.md`, `scripts/`, `bin/`). Edit it here only.
- **Canonical skill content** lives under `display-dev/` (`SKILL.md`, `references/`, `scripts/`, `bin/`). Edit it here only.
- **Hand-authored metadata/config/docs** outside `display-dev/` is edited directly: the plugin manifests (`.cursor-plugin/`, `.claude-plugin/`, `codex/display-dev/.codex-plugin/`), the Codex MCP config (`codex/display-dev/.mcp.json`), the marketplaces (`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`), `README.md`, and this file.
- **Claude Code plugin components** live under `claude/` and are edited directly: the `/display-dev:feedback` command (`claude/commands/`), the hooks (`claude/hooks/`), and their tests (`tests/claude-hooks.sh`). `.claude-plugin/plugin.json` points at them, so other hosts do not load them.
- **Bundled Visualize is never hand-edited.** `claude/skills/visualize/` is a copy of one `display-dev/visualize` release. Change Visualize in its own repo, release it, set the new tag in `claude/visualize.lock`, then run `bin/sync-visualize.sh`. CI runs `bin/sync-visualize.sh --check`.
Expand All @@ -21,6 +21,27 @@ provider-substitution machinery, but current mounts receive the same canonical
bytes and modes. Reintroducing provider placeholders would change this release
invariant and requires an explicit design change plus matching CI updates.

## Conditional references and standalone delivery

Keep common decisions and authority boundaries in `display-dev/SKILL.md`.
The email-domain and staged-upload procedures live in `display-dev/references/`;
read them only for the routed task. All generated mounts include those files.

The archive contains the lean entrypoint and sibling references. The standalone
release asset is generated from the same sources, with a notice after frontmatter
and the references appended in sorted path order. Do not hand-edit release output:

```sh
node bin/build-standalone.mjs display-dev /tmp/display-dev-SKILL.md
node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs
```

Every local reference link in the entrypoint must resolve to a regular file in
`references/`; every reference must be routed. Generation fails on missing,
unrouted, linked, or unsafe paths. With no references, it preserves the original
entrypoint bytes. PR CI and the release workflow run the same generator tests;
release computes the standalone checksum from generated bytes.

## Validation layers

### Automated trust-boundary suite
Expand Down Expand Up @@ -57,15 +78,21 @@ Before release, also run the suite under Linux with the repository mounted
read-only and container networking disabled:

```sh
standalone="$(mktemp)"
node bin/build-standalone.mjs display-dev "$standalone"
docker run --rm --network none \
--mount "type=bind,src=$PWD,dst=/repo,readonly" \
--mount "type=bind,src=$standalone,dst=/standalone-SKILL.md,readonly" \
--workdir /repo \
ubuntu:24.04 \
bash tests/run.sh
bash tests/run.sh /standalone-SKILL.md
rm "$standalone"
```

Docker may need network access to pull `ubuntu:24.04` before this command can
run; `--network none` applies to the test container. This command runs the Bash
run; `--network none` applies to the test container. The standalone file is generated
on the host because this image has no Node.js.
The suite writes only to its temporary directory. This command runs the Bash
suite, not an agent. It proves Linux portability and shell-level boundaries
only.

Expand Down
8 changes: 6 additions & 2 deletions bin/build-candidate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ async function collectDirectory(sourceRoot, directory) {
for (const basename of names) {
if (basename === '.DS_Store') continue;
const name = `${directory}/${basename}`;
if (!/^(scripts|bin)\/[^/]+$/.test(name)) fail(`invalid candidate path: ${name}`);
if (!/^(scripts|bin|references)\/[^/]+$/.test(name)) fail(`invalid candidate path: ${name}`);
const absoluteFile = path.join(absolute, basename);
const stat = await lstat(absoluteFile);
if (!stat.isFile() || stat.isSymbolicLink()) fail(`candidate entry must be a regular file: ${name}`);
Expand All @@ -113,7 +113,11 @@ export async function buildCandidate(sourcePath, outputPath) {
{ name: 'SKILL.md', mode: 0o644, bytes: await readFile(skillPath) },
...(await collectDirectory(sourceRoot, 'scripts')),
...(await collectDirectory(sourceRoot, 'bin')),
].sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
];
if ((await readdir(sourceRoot)).includes('references')) {
entries.push(...await collectDirectory(sourceRoot, 'references'));
}
entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));

const unpackedSize = entries.reduce((sum, entry) => sum + entry.bytes.length, 0);
if (unpackedSize > MAX_UNPACKED_BYTES) fail(`candidate exceeds ${MAX_UNPACKED_BYTES} unpacked bytes`);
Expand Down
46 changes: 46 additions & 0 deletions bin/build-standalone.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node

import { lstat, readFile, readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

export async function buildStandalone(sourceRoot) {
const original = await readFile(path.join(sourceRoot, 'SKILL.md'));
const skill = original.toString('utf8');
const routes = [...new Set([...skill.matchAll(/\]\((references\/[^)]+)\)/g)].map((match) => match[1]))].sort();
const referenceRoot = path.join(sourceRoot, 'references');
let names = [];
try {
const info = await lstat(referenceRoot);
if (!info.isDirectory()) throw new Error('references must be a real directory');
names = (await readdir(referenceRoot)).filter((name) => name !== '.DS_Store').sort();
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
for (const route of routes) {
if (!/^references\/[a-z0-9-]+\.md$/.test(route)) throw new Error(`invalid reference path: ${route}`);
}
if (JSON.stringify(names.map((name) => `references/${name}`)) !== JSON.stringify(routes)) {
throw new Error('reference files must match the paths routed from SKILL.md');
}
if (!routes.length) return original;

const frontmatter = skill.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
if (!frontmatter) throw new Error('SKILL.md is missing YAML frontmatter');
const notice = '\nThis standalone file includes reference documents in appended sections labeled\n'+
'with their exact paths. When an instruction asks you to read a reference, use\n'+
'that section instead of opening a sibling file.\n';
let output = frontmatter[0] + notice + skill.slice(frontmatter[0].length);
for (const route of routes) {
const file = path.join(sourceRoot, route);
if (!(await lstat(file)).isFile()) throw new Error(`reference must be a regular file: ${route}`);
output += `\n\n## ${route}\n\n${await readFile(file, 'utf8')}`;
}
return Buffer.from(output);
}

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const [source, output, ...extra] = process.argv.slice(2);
if (!source || !output || extra.length) throw new Error('usage: build-standalone.mjs <skill-tree> <output-file>');
await writeFile(output, await buildStandalone(source));
}
101 changes: 4 additions & 97 deletions codex/display-dev/skills/display-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,52 +181,14 @@ true:
approaches the safe inline tool-call ceiling; and
- the code-execution environment can send HTTPS requests to `api.display.dev`.

Then perform these steps:

1. Measure the raw file byte length without reading its contents into the
conversation. Call `create_upload` with the basename and exact `size_bytes`.
2. In code execution, send the raw file bytes with `PUT` to the returned
first-party `upload_url`. Use every exact entry in `required_headers`,
including `Authorization`, `Content-Type`, and `Content-Length`. Do not
encode the file as JSON, base64, or multipart form data. It is acceptable
for the initiating execution trace to show the URL and bearer.
3. Call `publish` once with the returned `upload_id` plus the user-approved
name, visibility, sharing, or `short_id` / `base_version` update fields. For
an ordinary authenticated publish, omit visibility; pass `company` only
when the user asked to share with the organization. Do not also pass `content` or `format`.
4. Report the canonical artifact URL and relevant publish result. Do not repeat
the upload bearer or source. Discard the upload ID.

The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new
`create_upload` without inferring or revealing whether expiry, ticket validity,
organization binding, or a missing object caused it. If it returns
`upload_size_mismatch`, report that exact mismatch, measure the raw file again,
and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on
Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes
between staging and publishing.
When these prerequisites hold, read [references/staged-upload.md](references/staged-upload.md)
before calling `create_upload`.

If the tools are absent or `api.display.dev` is unreachable, use `dsp publish`
where the file exists, ask the user to publish through the dashboard, or use
inline `content` only when the source is small enough. Never install a runtime
or move credentials to work around the missing capability.

For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow
`api.display.dev` for code execution, then the user must start a new task. MCP
connector traffic uses a separate path and does not grant shell egress.
Personal Claude Pro and Max currently provide no custom code-execution domain
allowlist, so large staged publishing is unavailable there; use one of the
fallbacks above.

Example:

```text
User: Publish the 20MB report generated in code execution for my organization.
Agent: Confirms the authorized remote tools, measures the file, calls
create_upload, transfers raw bytes through api.display.dev with every returned
header, calls publish(upload_id=...), and reports the artifact URL without the
temporary bearer or source.
```

## Create or sign in to a display.dev account

When the host bundles the display.dev remote MCP server, sign-in uses that
Expand Down Expand Up @@ -263,76 +225,21 @@ provisioning result.

## Add or transfer a company email domain

Email-domain management is Owner-only. Prefer the authorized MCP tools when
they are registered:

- `list_email_domains` lists verified, pending, dormant, and transfer states.
- `add_email_domain` adds a domain and returns the DNS TXT record. A domain
already connected elsewhere creates an inert transfer request.
- `verify_email_domain` checks the fresh TXT record for an ordinary claim or
transfer request. It also rechecks a support-required transfer after support
confirms the blocker is resolved.
- `remove_email_domain` removes an ordinary row or cancels a non-terminal
transfer request.

Installed-CLI equivalents are:

```bash
dsp email-domains list
dsp email-domains add <domain>
dsp email-domains verify <domain>
dsp email-domains remove <domain>
```
Email-domain management is Owner-only. For listing, adding, verifying, transferring,
or removing a domain, first read [references/email-domains.md](references/email-domains.md).

The paid email domains add-on is human-operated. Neither MCP nor the direct CLI
can enable or disable it. If the user asks to change it, direct a current Owner
to **Settings → Email Domains**. Do not call the REST endpoint as a fallback or
claim the add-on changed. After the Owner completes the change, continue with
the registered domain-management tools when needed.

For a foreign-domain collision, surface the request-specific TXT record and
state that DNS verification does not move the domain by itself. After
verification, report `transfer_ready` or `transfer_support_required` without
inferring the source organization or its contents. Source-owner approval is
not required.

Handle each returned transfer state explicitly:

- `transfer_pending_dns`: publish the request-specific TXT record, then verify.
The Owner may cancel it with `remove_email_domain` or
`dsp email-domains remove <domain>`.
- `transfer_ready`: send a current target Owner to **Settings → Email Domains**
for final review and confirmation. The Owner may still cancel instead.
- `transfer_support_required`: do not guess at hidden source details. Tell the
user to contact support. After support confirms the issue is resolved, run
`verify_email_domain` or `dsp email-domains verify <domain>` again on the
same unexpired request. The existing DNS proof is retained. If the retry
remains support-required, report that result without exposing or guessing
source details. The Owner may cancel instead.
- `transfer_expired`: the request is terminal and cannot be cancelled. Run
`add_email_domain` or `dsp email-domains add <domain>` again to create a new
request and fresh TXT record.

Completed transfers appear as ordinary `verified` rows. Cancelled requests are
terminal and no longer actionable; add the domain again only when the user
intends to start a fresh request.

Final confirmation is dashboard-only. When a transfer is ready, direct a
current target Owner to **Settings → Email Domains** to review the people who
will move, accept session revocation and source retirement, and confirm.
Neither an MCP tool nor the CLI can complete the transfer. Never claim
completion after DNS verification.

Example:

```text
User: Transfer example.com to this organization.
Agent: add_email_domain returns a fresh TXT record because example.com is
already connected elsewhere. The user publishes it, then the agent runs
verify_email_domain. If the result is transfer_ready, the agent sends a target
Owner to Settings → Email Domains for the final review and confirmation.
```

## Share an artifact

Use only the audience the user requested:
Expand Down
60 changes: 60 additions & 0 deletions codex/display-dev/skills/display-dev/references/email-domains.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Company email domains

Prefer the authorized MCP tools when
they are registered:

- `list_email_domains` lists verified, pending, dormant, and transfer states.
- `add_email_domain` adds a domain and returns the DNS TXT record. A domain
already connected elsewhere creates an inert transfer request.
- `verify_email_domain` checks the fresh TXT record for an ordinary claim or
transfer request. It also rechecks a support-required transfer after support
confirms the blocker is resolved.
- `remove_email_domain` removes an ordinary row or cancels a non-terminal
transfer request.

Installed-CLI equivalents are:

```bash
dsp email-domains list
dsp email-domains add <domain>
dsp email-domains verify <domain>
dsp email-domains remove <domain>
```

For a foreign-domain collision, surface the request-specific TXT record and
state that DNS verification does not move the domain by itself. After
verification, report `transfer_ready` or `transfer_support_required` without
inferring the source organization or its contents. Source-owner approval is
not required.

Handle each returned transfer state explicitly:

- `transfer_pending_dns`: publish the request-specific TXT record, then verify.
The Owner may cancel it with `remove_email_domain` or
`dsp email-domains remove <domain>`.
- `transfer_ready`: send a current target Owner to **Settings → Email Domains**
for final review and confirmation. The Owner may still cancel instead.
- `transfer_support_required`: do not guess at hidden source details. Tell the
user to contact support. After support confirms the issue is resolved, run
`verify_email_domain` or `dsp email-domains verify <domain>` again on the
same unexpired request. The existing DNS proof is retained. If the retry
remains support-required, report that result without exposing or guessing
source details. The Owner may cancel instead.
- `transfer_expired`: the request is terminal and cannot be cancelled. Run
`add_email_domain` or `dsp email-domains add <domain>` again to create a new
request and fresh TXT record.

Completed transfers appear as ordinary `verified` rows. Cancelled requests are
terminal and no longer actionable; add the domain again only when the user
intends to start a fresh request.

Example:

```text
User: Transfer example.com to this organization.
Agent: add_email_domain returns a fresh TXT record because example.com is
already connected elsewhere. The user publishes it, then the agent runs
verify_email_domain. If the result is transfer_ready, the agent sends a target
Owner to Settings → Email Domains for the final review and confirmation.
```

Loading
Loading