源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)

This commit is contained in:
agent-radar
2026-09-02 01:05:41 +08:00
commit 04bac1f6e2
2200 changed files with 489415 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
---
mode: primary
hidden: true
model: opencode/claude-sonnet-4-5
color: "#7C6FF0"
tools:
"*": false
read: true
glob: true
grep: true
edit: true
write: true
---
You write OpenWork release changelogs. The prompt gives you verified facts: the version, previous version, release commit, published-at timestamp, docs date label, tracker file path, verbatim LOC line, compare URL, and full commit subject list. Use only these facts. Never invent features, numbers, or dates, and never recompute the LOC line.
Before writing, read `packages/docs/changelog.mdx` and the most recent `changelog/release-tracker-*.md` file to match their formats exactly.
Modify only these two files:
1. The tracker file named in the prompt. If it does not exist, create it with this standard header:
```markdown
# Release Changelog Tracker
Internal preparation file for release summaries. This is not yet published to the changelog page or docs.
```
Otherwise, append the new section. Keep `## vX.Y.Z` sections within a tracker file in ascending version order. Never edit or reflow existing entries.
2. `packages/docs/changelog.mdx`. Never edit or reflow existing entries.
Do not modify any other file.
## Tracker section format
Use this exact heading sequence and content:
1. `## vX.Y.Z`
2. `#### Commit` — the short hash in backticks
3. `#### Released at` — the UTC ISO timestamp in backticks
4. `#### Title` — one outcome-focused sentence fragment with no trailing period
5. `#### One-line summary`
6. `#### Main changes` — 3–5 bullets, or a short paragraph for a tiny release
7. `#### Lines of code changed since previous release` — the provided LOC line verbatim
8. `#### Release importance` — `Major release: …` or `Minor release: …` with a one-line justification
9. `#### Major improvements` — `True` or `False`
10. `#### Number of major improvements`
11. `#### Major improvement details` — bullets or `None.`
12. `#### Major bugs resolved` — `True` or `False`
13. `#### Number of major bugs resolved`
14. `#### Major bug fix details` — bullets or `None.`
15. `#### Deprecated features` — `True` or `False`
16. `#### Number of deprecated features`
17. `#### Deprecated details` — bullets or `None.`
All counts must match the number of detail bullets.
## Docs entry format
```mdx
<Update label="<docs date label>" tags={[...]}>
## [<version>](<compare url>): <Title>
- bullet
- bullet
</Update>
```
Choose tags from `"🚀 New Features"`, `"🐛 Bug Fixes"`, and `"🏗️ Refactoring"`, ordered by prominence in the release. Write 2–5 bullets with two-space indentation inside the block, and leave one blank line between blocks.
Docs entries are ordered newest-version-first. If the new version is higher than every documented version, insert it directly after the frontmatter ending on line 3 (`---`). For a backfill, insert it directly below the entry of the lowest documented version that is higher than the new version, so the new entry sits immediately after its closest newer version even when undated or unversioned entries appear elsewhere in the file.
Write for non-technical users and lead with user-visible outcomes, such as “X now does Y” or “Fixed X so Y.” Use plain language and no PR numbers in docs bullets. Fold internal tooling (evals, testkit, CI), release plumbing, and dependency bumps into at most one bullet, or omit them entirely when the release has enough user-facing changes. If a release is mostly internal, say so plainly. Titles should read like the existing titles, such as “Linux installs repair themselves” and “Managed provider credentials reach the engine.”
Do not use bash. When done, briefly state which two files you changed.
+26
View File
@@ -0,0 +1,26 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
color: "#E67E22"
tools:
"*": false
"github-pr-search": true
---
You are a duplicate PR detection agent. When a PR is opened, your job is to search for potentially duplicate or related open PRs.
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself.
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
If you find potential duplicates:
- List them with their titles and URLs
- Briefly explain why they might be related
If no duplicates are found, say so clearly. BUT ONLY SAY "No duplicate PRs found" (don't say anything else if no dups)
Keep your response concise and actionable.
+20
View File
@@ -0,0 +1,20 @@
---
description: Deep-reasoning executor for multi-file features, refactors, and gnarly debugging. Invoked by the orchestrator via the Task tool when a task is too complex for the default executor or after it failed two repair rounds. Same contract as executor, maximum reasoning effort.
mode: all
model: openai/gpt-5.6-sol-fast
variant: xhigh
---
You are the escalation tier — harder tasks, same contract as `executor`.
You implement exactly the brief you receive — no scope expansion, no re-exploration beyond what the task requires. If the brief seems wrong, ambiguous, or underspecified, say so and stop rather than improvising.
Verify with the narrowest fast check that covers your change (e.g. `pnpm --filter <pkg> typecheck`, a targeted `test:*` script) — never repo-wide builds unless the brief asks.
Report back — your final message is all the orchestrator sees; keep it under 20 lines:
1. Files changed: `path:line` + one-line summary each.
2. Commands run, with exit codes.
3. Anything skipped, assumed, or needing follow-up.
Resumed repair rounds report only the delta.
+18
View File
@@ -0,0 +1,18 @@
---
description: Default executor for routine, well-specified coding tasks. Invoked by the orchestrator via the Task tool with a concrete brief. Writes and edits code, runs the narrowest verifying check, and reports back exactly what changed.
mode: all
model: openai/gpt-5.6-sol-fast
variant: medium
---
You implement exactly the brief you receive — no scope expansion, no re-exploration beyond what the task requires. If the brief seems wrong, ambiguous, or underspecified, say so and stop rather than improvising.
Verify with the narrowest fast check that covers your change (e.g. `pnpm --filter <pkg> typecheck`, a targeted `test:*` script) — never repo-wide builds unless the brief asks.
Report back — your final message is all the orchestrator sees; keep it under 20 lines:
1. Files changed: `path:line` + one-line summary each.
2. Commands run, with exit codes.
3. Anything skipped, assumed, or needing follow-up.
Resumed repair rounds report only the delta.
+42
View File
@@ -0,0 +1,42 @@
---
description: Orchestrator. Plans, delegates, and verifies; never writes code. Strict about test coverage — every test-scenario request starts with a spec plan in chat.
mode: primary
model: anthropic/claude-fable-5
variant: max
---
# Orchestrator
You think, plan, and verify. You do not write code. All file changes go through executor subagents via the Task tool:
- `executor` — routine, well-specified tasks.
- `executor-deep` — multi-file features, refactors, gnarly debugging, or escalation after `executor` fails two repair rounds.
- Independent tasks run in parallel (multiple Task calls in one message), never overlapping on the same files.
## Delegation brief
Every task prompt contains: **Goal** · **Files** (exact `path:line`) · **Constraints** · **Acceptance criteria** · **Verify** (exact commands). Pointers, not pasted file contents — paste only what the executor cannot cheaply derive itself (error output, cross-package signatures). Explore first (yourself or the `explore` agent) so executors never re-discover context you already have.
## Repair loop
Failed verification → resume the same executor session (`task_id`) with only the failing output and precise repair instructions. Start fresh if anything else touched those files since. Two repair rounds max, then re-decompose (usually to `executor-deep`). Fix it yourself only when trivial.
## Test scenarios (strict)
Any request to create or extend coverage ("create a test scenario", "test X", "cover Y") starts with a **spec plan** in your reply — before any file is written. For coverage-only requests, stop after the plan and wait for approval.
1. **Claims** — each machine-checkable with its negative half: what must happen, and what must not happen to another account, request, file, or state.
2. **Overlap** — search `evals/specs/` first; extend an existing spec before creating a new one. Name what you checked.
3. **Lane** — app-less PR lane `*.test.ts`, or app/Den-driving E2E lane `*.e2e.test.ts`. Justify the choice.
4. **Resources** — `needs()` opt-ins and env; `server()` orgs and mocks (`mcpMock()` witnesses instead of real providers); which surfaces and how many: `app()` desktops (`profileDir` continuity, `localServerDelayMs` races), `chrome()` for Den Web, `inviteMember()` for multi-member, `faultProxy()` for failure injection, `daytonaSandbox()` per-sandbox desktops (`OPENWORK_EVAL_DAYTONA_SANDBOX_A/B`).
5. **Environment** — Daytona (`OPENWORK_EVAL_DAYTONA=1`) when credentials are available, else local fallback; name the lane and its prerequisites.
6. **Budget** — smallest spec count that covers the claims; one scenario per spec; one spec per run so each failure has one owner. Push app-less mechanisms to unit coverage and say so.
7. **Run + verdict** — exact commands; `Passed` / `Incomplete` / `Failed`; any skip is `Incomplete`, never passed.
Scoping decisions — what the spec deliberately does not click, mock, or assert — go in the plan with reasons, not discovered later as code comments.
Then: `write-a-spec` → delegate authoring to an executor → `run-tests` → `diagnose-a-red-run` when red → `publish-evidence`. Load the skills; never restate their mechanics.
## Verification
Read the full diff yourself and rerun the executor's narrowest check. Runtime-observable changes need a testkit spec verdict per the plan above. Docs, types-only, and inert `.opencode/` config skip runtime proof — say so explicitly.
+78
View File
@@ -0,0 +1,78 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
color: "#44BA81"
tools:
"*": false
"github-triage": true
---
You are a triage agent responsible for triaging github issues.
Use your github-triage tool to triage issues.
## Labels
### windows
Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows.
- Use if they mention WSL too
#### perf
Performance-related issues:
- Slow performance
- High RAM usage
- High CPU usage
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
#### desktop
Desktop app issues:
- `opencode web` command
- The desktop app itself
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
#### nix
**Only** add if the issue explicitly mentions nix.
#### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### docs
Add if the issue requests better documentation or docs updates.
#### opentui
TUI issues potentially caused by our underlying TUI library:
- Keybindings not working
- Scroll speed issues (too fast/slow/laggy)
- Screen flickering
- Crashes with opentui in the log
**Do not** add for general TUI bugs.
When assigning to people here are the following rules:
adamdotdev:
ONLY assign adam if the issue will have the "desktop" label.
fwang:
ONLY assign fwang if the issue will have the "zen" label.
jayair:
ONLY assign jayair if the issue will have the "docs" label.
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
+9
View File
@@ -0,0 +1,9 @@
---
name: browser-setup
description: Guide user through OpenWork Browser automation setup
---
Help the user set up browser automation.
Tell the user that OpenWork Browser is built in and enabled by default.
Keep the user prompt minimal, then use browser tools against the built-in browser target when they ask to browse.
+4
View File
@@ -0,0 +1,4 @@
---
description: Say hello stranger
---
hello stranger
+36
View File
@@ -0,0 +1,36 @@
---
description: Run the OpenWork release flow
---
You are running the OpenWork release flow in this repo.
Arguments: `$ARGUMENTS`
- If empty, default to a patch release.
- If set to `minor` or `major`, use that bump type.
Load and follow `.opencode/skills/release/SKILL.md`; `docs/RELEASING.md` is the
full runbook. Use the tag-first path unless the user explicitly requests
PR-first.
1. Fetch `origin/dev`. Work only from a clean checkout based on its current
tip. If the user's checkout is dirty or `dev` is already checked out, create
an isolated release worktree and leave the original checkout untouched.
2. Run `pnpm release:prepare:dry -- <bump>`, then
`pnpm release:prepare -- <bump>`. In an isolated branch worktree, pass
`--ci` only after independently confirming the branch is based on current
`origin/dev` and the worktree is clean.
3. Run `pnpm release:ship`. A rejected direct push to protected `dev` is
expected: `release:ship` must push a `release/vX.Y.Z-dev-sync` branch and
open the backfill PR. Never bypass or force-push `dev`.
4. Watch the matching `Release App` run through completion. The release is not
done until `Publish GitHub Release` succeeds and the release is public.
5. Get the version backfill PR approved and merged. If the workflow opens an
AUR packaging PR, report its URL, wait for it to merge, then rerun Release
App with the same tag as described by the release skill.
6. Verify the public release assets resolve, `npm view openwork-server version`
matches the tag, and the latest relevant Daytona snapshot run is green.
Diagnose unexpected failures instead of treating Node runtime deprecation
warnings or the expected protected-branch rejection as release failures.
Report the tag, release URL, workflow verdict, merged backfill PR, npm version,
public asset check, and any non-blocking channel status.
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": "allow",
"plugin": ["opencode-chrome-devtools@1.0.4"],
"mcp": {
"openwork": {
"type": "remote",
"enabled": true,
"url": "https://api.openworklabs.com/mcp/agent",
"oauth": {}
}
}
}
+399
View File
@@ -0,0 +1,399 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.17.11"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.17.11",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.17.11.tgz",
"integrity": "sha512-mE6SR1tVxL98zbZrlneqF/+dpIKLeZHKzWZy35Hq54uEnuCM1gKq3LK8F2PG2zMBDKQtz2fahEIksJ7svoPLLw==",
"license": "MIT",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "1.17.11",
"effect": "4.0.0-beta.83",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.3.4",
"@opentui/keymap": ">=0.3.4",
"@opentui/solid": ">=0.3.4"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.17.11",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.11.tgz",
"integrity": "sha512-UdSwnNM4/cD5rHnI8O7VaHuiwYnsEOglpRPVeeYE2S5Nm1K34ijCyZGDvBecb0ShdBQgn49XvyQWJH6cREsIPw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.83",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz",
"integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.8.0",
"find-my-way-ts": "^0.1.6",
"ini": "^7.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^2.0.1",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^14.0.0",
"yaml": "^2.9.0"
}
},
"node_modules/fast-check": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz",
"integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/ini": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz",
"integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==",
"license": "ISC",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/msgpackr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz",
"integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multipasta": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz",
"integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz",
"integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
@@ -0,0 +1,101 @@
---
name: browser-automation
description: Local OpenWork Electron browser automation with CDP. Use when driving a local Electron dev app, browser_list, browser_snapshot, browser_eval, composer automation, or local UI smoke tests.
---
# Browser Automation
## What I Do
- Attach OpenCode browser tools to the OpenWork Electron app during local development.
- Drive the app UI through Electron's Chrome DevTools Protocol endpoint.
- Send a task/session from the composer and confirm the response in the UI.
## Local Dev Setup
`pnpm dev` enables Electron CDP by default:
```sh
OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=${OPENWORK_ELECTRON_REMOTE_DEBUG_PORT:-9823}
```
The default browser URL for OpenCode browser tools is:
```text
http://127.0.0.1:9823
```
The app UI normally loads at:
```text
http://localhost:5173/
```
To use a different CDP port, launch with an override:
```sh
OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=9830 pnpm dev
```
To disable Electron CDP for a run:
```sh
OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=0 pnpm dev
```
## Background Launch
Use a detached launch when the user wants the app running in the background:
```sh
nohup pnpm dev > /tmp/openwork-dev.log 2>&1 &
```
Then wait for the CDP port:
```sh
lsof -nP -iTCP:9823 -sTCP:LISTEN
```
## Browser Tool Flow
1. List targets with `browser_list` using `browser_url: "http://127.0.0.1:9823"`.
2. Select the `OpenWork` target ID.
3. Read state with `browser_eval` or `browser_snapshot`.
4. Fill the Lexical composer by targeting `[contenteditable="true"][data-lexical-editor="true"]`.
5. Click the `Run task` button.
6. Confirm the session response by checking `document.body.innerText` or the current URL.
## Send A Session
Use this `browser_eval` pattern after selecting the OpenWork target:
```js
(() => {
const editor = document.querySelector('[contenteditable="true"][data-lexical-editor="true"]');
if (!editor) return { ok: false, reason: 'editor not found' };
editor.focus();
const data = new DataTransfer();
data.setData('text/plain', 'Say hello from the Electron browser test.');
editor.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: data,
}));
const run = Array.from(document.querySelectorAll('button'))
.find((button) => button.innerText.trim() === 'Run task');
if (!run) return { ok: false, reason: 'Run task not found', inserted };
if (run.disabled) return { ok: false, reason: 'Run task disabled', inserted, text: editor.innerText };
run.click();
return { ok: true, inserted: true, text: editor.innerText };
})()
```
## Notes
- Electron CDP is used for development test tooling. User browser tasks should use the built-in OpenWork Browser target.
- A successful local attach should show an `OpenWork` target at `http://127.0.0.1:9823`.
- The known-good smoke prompt is `Say hello from the Electron browser test.` and the expected response is `Hello from the Electron browser test.`
+23
View File
@@ -0,0 +1,23 @@
---
name: build-a-witness
description: Mock a provider, add a mock, fake the API for tests. Use when building a deterministic provider witness for @openwork/testkit specs.
---
# Skill: Build a Witness
Follow the pattern established by `@openwork/labs` (`evals/packages/labs/src/mock-mcp.ts`) and the Google fixtures (`evals/packages/labs/src/mock-google.ts`).
## Witness contracts
- Attribute requests per identity using the credential. Key observations by
`CREDENTIAL` token-ID fingerprints, never by global state.
- Expose since-ISO-scoped request logs so each assertion observes only its run.
- Negative queries return immediately. An empty mailbox must not consume its
full timeout; slow negative assertions are the first assertions people
delete.
- Consent and chooser pages must be programmatically drivable. Resolve only
after the callback was actually served, never after a timer.
- A mock contributes environment to `server()` at boot. Its address is Den boot
configuration; a mock started after Den is a mock Den ignores.
- On Daytona, publish the mock at a URL reachable by the remote Den with
`exposeMock`.
+166
View File
@@ -0,0 +1,166 @@
---
name: create-plugin
description: "Create an OpenCode plugin for OpenWork. Scaffolds the plugin file with the correct API shape, tool definitions, and hook registration. Use when the user asks to 'create a plugin', 'write a plugin', or 'make a plugin that does X'."
---
# Skill: Create an OpenCode Plugin
Scaffold a working OpenCode plugin for use in OpenWork.
## When to use
- User asks "create a plugin that does X"
- User asks "write a plugin" or "make a plugin"
- User wants to extend OpenWork/OpenCode with custom tools
## Plugin API
An OpenCode plugin is an **async factory function** that returns a **hooks object**.
### File location
Plugins can live in:
- **Project**: `.opencode/plugins/my-plugin.ts` (auto-discovered)
- **Global**: `~/.config/opencode/plugins/my-plugin.ts`
- **npm**: published as a package, referenced in `opencode.json` `plugin` array
- **URL**: `file:` or `https:` path in the `plugin` array
### Minimal shape
```typescript
import { z } from "zod";
export default async () => ({
tool: {
my_tool_name: {
description: "What this tool does.",
args: z.object({
input: z.string().describe("The input parameter."),
}).shape, // NOTE: .shape, not the ZodObject itself
async execute(args: { input: string }) {
// Your logic here. Can use fetch(), fs, child_process, etc.
return `Result: ${args.input}`;
},
},
},
});
```
### Key rules
1. **Export default an async function** that returns the hooks object.
2. **Tool args use `zodSchema.shape`** (a `ZodRawShape`), not the `ZodObject`.
3. **`execute` returns a `string`** or `{ output: string; metadata?: Record<string, unknown> }`.
4. **Tools are declared, not registered imperatively** — return them in the hooks object.
5. **`fetch()` works** — plugins run in-process inside the OpenCode runtime.
6. **`process.env` is accessible** — use env vars for secrets/config.
### Available hooks
```typescript
{
// Modify the system prompt
"experimental.chat.system.transform": async (input, output: { system: string[] }) => {
output.system.push("Extra instruction for the agent.");
},
// Define tools the agent can call
tool: {
tool_name: { description, args, execute },
},
// Run code before/after a tool executes
"tool.execute.before": async ({ tool, args }) => { /* ... */ },
"tool.execute.after": async ({ tool, args, result }) => { /* ... */ },
// React to lifecycle events
event: async ({ event }) => { /* ... */ },
}
```
### Registering the plugin
Add to `opencode.json`:
```json
{
"plugin": [
".opencode/plugins/my-plugin.ts"
]
}
```
Or install from npm:
```json
{
"plugin": [
"my-published-plugin"
]
}
```
## Anthropic / Claude plugin compatibility
OpenCode plugins are **NOT** the same as Anthropic's plugin format. Key differences:
| Aspect | OpenCode Plugin | Anthropic Plugin |
|--------|----------------|-----------------|
| Entry point | Async factory function | Manifest JSON |
| Tool args | Zod schema `.shape` | JSON Schema |
| Runtime | In-process (Bun/Node) | Sandboxed container |
| Auth | `process.env` | OAuth/API key in manifest |
| Distribution | npm / file path / URL | Anthropic marketplace |
To adapt an Anthropic plugin for OpenCode:
1. Rewrite the tool definitions using Zod instead of JSON Schema.
2. Move any auth from the manifest to env vars or `opencode.json` provider config.
3. Export the async factory function instead of the manifest.
4. HTTP-based Anthropic plugins can often be wrapped as MCP servers instead.
## Example: plugin with multiple tools
```typescript
import { z } from "zod";
export default async () => ({
"experimental.chat.system.transform": async (_input: unknown, output: { system: string[] }) => {
output.system.push("You have access to a note-taking system. Use save_note and list_notes.");
},
tool: {
save_note: {
description: "Save a note with a title and body.",
args: z.object({
title: z.string().describe("Note title"),
body: z.string().describe("Note content"),
}).shape,
async execute(args: { title: string; body: string }) {
const fs = await import("node:fs/promises");
const path = `.opencode/notes/${args.title.replace(/[^a-zA-Z0-9-_]/g, "_")}.md`;
await fs.mkdir(".opencode/notes", { recursive: true });
await fs.writeFile(path, `# ${args.title}\n\n${args.body}\n`);
return `Saved note: ${path}`;
},
},
list_notes: {
description: "List all saved notes.",
args: {},
async execute() {
const fs = await import("node:fs/promises");
try {
const files = await fs.readdir(".opencode/notes");
return files.filter(f => f.endsWith(".md")).join("\n") || "No notes yet.";
} catch {
return "No notes yet.";
}
},
},
},
});
```
## Scaffold workflow
When the user describes what they want the plugin to do:
1. Create `.opencode/plugins/<name>.ts` with the plugin code.
2. Add it to `opencode.json` `plugin` array if not already present.
3. Tell the user to restart their session for the plugin to load.
@@ -0,0 +1,71 @@
---
name: daytona-chrome-cdp
description: Launch and control standalone Chrome in a Daytona sandbox via CDP. Use for web sign-in, OAuth, Den Web setup, browser-only flows, or when the app should not be driven through Electron CDP.
---
# Daytona Standalone Chrome CDP
Use this skill when a Daytona sandbox needs a normal Chrome/Chromium browser in
the XFCE display, separate from the Electron app. This is useful for Den Web
sign-in, OAuth provider setup, browser-only admin flows, and checking what a user
would see in a regular browser.
## Launch Chrome
Run inside the Electron or server sandbox:
```bash
daytona exec "$SANDBOX" -- "bash -lc 'mkdir -p /tmp/daytona-chrome-profile; DISPLAY=:99 nohup chromium --no-sandbox --disable-dev-shm-usage --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 --user-data-dir=/tmp/daytona-chrome-profile about:blank >/tmp/daytona-chrome.log 2>&1 &'"
```
If `chromium` is missing, try `google-chrome`, `google-chrome-stable`, or install
Chromium in the sandbox only when needed.
## Get The CDP URL
```bash
CHROME_CDP_URL=$(daytona preview-url "$SANDBOX" -p 9222 2>/dev/null | grep -v '^time=')
```
Then connect browser tools:
```text
browser_list({ browser_url: CHROME_CDP_URL })
```
## Verify It Is Not Electron
After selecting the Chrome target:
```js
navigator.userAgent
```
Expected: contains `Chrome/` or `Chromium/` and does not contain `Electron/`.
## Drive A Web Flow
Use normal browser tools:
```text
browser_navigate({ browser_url: CHROME_CDP_URL, target_id: TARGET_ID, url: DEN_WEB_URL })
browser_snapshot({ browser_url: CHROME_CDP_URL, target_id: TARGET_ID })
browser_click({ browser_url: CHROME_CDP_URL, target_id: TARGET_ID, uid: UID })
browser_fill({ browser_url: CHROME_CDP_URL, target_id: TARGET_ID, uid: UID, value: VALUE })
```
Validate with the `daytona-flow-validator` loop. Do not assume navigation or
sign-in worked until the post-action snapshot or URL proves it.
## Common Uses
- Sign into Den Web while Electron remains on the desktop handoff screen.
- Complete OAuth approval pages from a mock or real provider.
- Configure web-only admin state before validating Electron sync.
- Compare Den Web behavior against Electron cloud account behavior.
## Stop Chrome
```bash
daytona exec "$SANDBOX" -- "bash -lc 'pkill -f \"chromium.*remote-debugging-port=9222\" || pkill -f \"chrome.*remote-debugging-port=9222\" || true'"
```
@@ -0,0 +1,176 @@
---
name: daytona-cloud-instance
description: Daytona cloud instance, Den server, OpenWork Cloud, Marketplace onboarding, desktop plus cloud e2e, frame proof. Use when launching, validating, or recording Daytona cloud/Den flows.
---
# Daytona Cloud Instance
Use this skill to launch or validate an OpenWork Cloud/Den Daytona server sandbox and collect useful URLs for desktop sign-in and Marketplace onboarding demos.
## Goal
Start the Den cloud stack in Daytona, confirm it is reachable, and return the URLs needed by Electron/Desktop validation.
## Fast Path
From the repo root, prefer the existing Daytona server helper when available:
```bash
bash .devcontainer/test-server-on-daytona.sh $(git rev-parse --abbrev-ref HEAD)
```
If the branch is local-only, push it first or apply the local diff manually in the sandbox before validating.
If a sandbox already has manual patches, do not keep stacking new manual edits
on top of them. Either stash them in the sandbox and check out the pushed PR
branch, or create a fresh sandbox. A common safe sequence is:
```bash
daytona exec "$SERVER_SANDBOX" -- 'bash -lc '\''cd /workspace && git fetch origin <branch> && git stash push -u -m sandbox-manual-patch && git checkout -B <branch> FETCH_HEAD'\'''
daytona exec "$ELECTRON_SANDBOX" -- 'bash -lc '\''cd /workspace && git fetch origin <branch> && git stash push -u -m sandbox-manual-patch && git checkout -B <branch> FETCH_HEAD'\'''
```
## Expected Services
The Den server sandbox should expose:
- Den Web: port `3005`
- Den API: port `8788`
- Worker proxy: port `8789`
- Artifacts server: port `8090` when `--artifacts-volume` or recording is enabled
Get URLs with:
```bash
daytona preview-url "$SERVER_SANDBOX" -p 3005
daytona preview-url "$SERVER_SANDBOX" -p 8788
daytona preview-url "$SERVER_SANDBOX" -p 8789
daytona preview-url "$SERVER_SANDBOX" -p 8090
```
## Dev Auth Defaults
For local and Daytona cloud testing, run Den API with:
```bash
OPENWORK_DEV_MODE=1
```
In dev mode, email verification is disabled by default so seeded/demo users can sign in without a real inbox. Override explicitly when needed:
```bash
DEN_REQUIRE_EMAIL_VERIFICATION=true
DEN_REQUIRE_EMAIL_VERIFICATION=false
```
Production defaults to requiring email verification unless explicitly disabled.
Validate the escape hatch against Den API directly before recording UI:
```bash
curl -fsS -X POST "$DEN_API_URL/api/auth/sign-in/email" \
-H 'Content-Type: application/json' \
--data '{"email":"alex@acme.test","password":"OpenWorkDemo123!"}'
```
If this returns `403` asking for verification, Den API is not running the branch
or did not restart after the auth change. Restart Den API before recording.
## Health Checks
Validate the server before driving UI:
```bash
curl -fsS "$DEN_API_URL/health"
curl -fsS "$DEN_WEB_URL/api/den/health"
```
Validate seeded auth if present:
```bash
curl -fsS -X POST "$DEN_WEB_URL/api/auth/sign-in/email" \
-H 'Content-Type: application/json' \
--data '{"email":"alex@acme.test","password":"OpenWorkDemo123!"}'
```
If the Den Web proxy returns `503` but the direct Den API request succeeds,
continue debugging the proxy separately. Do not claim the browser sign-in path
passed from the direct API response alone.
## Den Web Mode
Prefer production Den Web for recordings. Next dev behind a Daytona proxy can
leave client-only pages stuck on server-rendered loading states or produce
origin/HMR problems.
Build and run production Den Web in the server sandbox:
```bash
daytona exec "$SERVER_SANDBOX" -- 'bash -lc '\''cd /workspace && pnpm --filter @openwork-ee/den-web build'\'''
daytona exec "$SERVER_SANDBOX" -- 'bash -lc '\''pkill -f "next dev --hostname 0.0.0.0 --port 3005" || true; pkill -f "next-server" || true'\'''
daytona exec "$SERVER_SANDBOX" -- 'bash -lc '\''cd /workspace && nohup pnpm --filter @openwork-ee/den-web exec next start --hostname 0.0.0.0 --port 3005 > /tmp/den-web-prod.log 2>&1 &'\'''
```
Then verify:
```bash
daytona exec "$SERVER_SANDBOX" -- 'curl -fsS http://127.0.0.1:3005/api/den/health'
```
If `daytona exec` appears to hang after starting `next start`, run a second
status command. The server may be running while the original shell remains open
because of background-process output handling.
## Desktop Den Session Bridge
The gold path is a real Den Web sign-in plus desktop deep-link handoff. If the
Daytona proxy breaks browser form submit or custom-protocol handling, you may
bridge only after proving direct Den API auth works, and you must report the
caveat in the PR evidence.
For a desktop Marketplace proof, inject the validated Den session into the
Electron renderer with the exact storage keys the app reads:
```js
localStorage.setItem('openwork.den.baseUrl', DEN_WEB_URL)
localStorage.setItem('openwork.den.apiBaseUrl', DEN_API_URL)
localStorage.setItem('openwork.den.authToken', TOKEN)
localStorage.setItem('openwork.den.activeOrgId', ORG_ID)
localStorage.setItem('openwork.den.activeOrgSlug', ORG_SLUG)
localStorage.setItem('openwork.den.activeOrgName', ORG_NAME)
window.dispatchEvent(new CustomEvent('openwork-den-settings-changed'))
window.dispatchEvent(new CustomEvent('openwork-den-session-updated', { detail: { token: TOKEN } }))
```
Use this only as a Daytona workaround. The final report must distinguish:
- Den API auth passed.
- Den Web/deep-link handoff was bridged because of Daytona proxy behavior.
- Desktop Marketplace sync/import behavior passed.
## Recording Flow
For founder/designer proof, record the actual journey:
1. Den sign-in page shows clear Cloud value proposition.
2. User signs in on Den.
3. Den dashboard explains Marketplaces contain plugins and assigned marketplaces sync to desktop.
4. Pretend download/open desktop handoff.
5. Desktop signed-out Marketplace nudge says OpenWork works without an account.
6. Desktop signs in to OpenWork Cloud.
7. Marketplace refresh shows `OpenWork Marketplace` and org marketplaces.
8. Built-ins show `Built-in`, with no install/remove actions.
9. A live org plugin installs and appears in My Extensions as `Connected`.
10. Workspace files materialize under `.opencode`.
Before every screenshot, check native overlays:
```bash
daytona exec "$ELECTRON_SANDBOX" -- 'bash -lc '\''DISPLAY=:99 wmctrl -l; ! DISPLAY=:99 wmctrl -l | grep -q "Authorize folder"'\'''
```
Do not publish a screenshot or video if a native folder picker is visible.
Also inspect at least one representative screenshot locally with the `Read`
tool or another image viewer before sharing links. Window-list checks are not
enough: a browser or app window can still obscure the claimed content.
@@ -0,0 +1,85 @@
---
name: daytona-cloud-server
description: Daytona cloud server, Den sandbox, desktop plus cloud e2e, marketplace server, cloud auth, org policies, connect Electron to Den. Use for server-side setup in validated flows.
---
# Daytona Cloud Server
Use this skill when the user needs the hosted/server side of OpenWork running in
Daytona. This is separate from the Electron desktop sandbox.
## What This Covers
- Den Web on port `3005`.
- Den API on port `8788`.
- Worker proxy on port `8789`.
- MySQL inside the server sandbox.
- Public Daytona preview URLs for Electron to consume.
- Marketplace, org policy, cloud auth, and server-managed extension flows.
## Start Server Sandbox
From the repo root:
```bash
bash .devcontainer/test-server-on-daytona.sh [branch-or-commit]
```
The helper creates a separate server sandbox, starts MySQL, Den API, Den Web,
and waits for health checks, then prints URLs.
If dependencies or the base image changed, refresh the server snapshot:
```bash
bash .devcontainer/create-daytona-openwork-server-snapshot.sh
```
## Connect Electron To Server
For end-to-end desktop validation, use the `daytona-electron-den` skill. This
section only covers wiring the Electron sandbox to the printed server URLs.
Start a second Daytona sandbox for Electron and point it at the server URLs:
```bash
bash .devcontainer/test-on-daytona.sh [branch-or-commit] \
--den-base-url <DEN_WEB_URL> \
--den-api-base-url <DEN_API_URL>
```
For flows that must require cloud sign-in, add `--require-signin`.
## Validate Server Health
Use the public URLs printed by the helper:
```bash
curl -sf <DEN_WEB_URL>/api/den/health
curl -sf <DEN_API_URL>/health
```
Inspect logs if health checks fail:
```bash
daytona exec "$SERVER_SANDBOX" -- 'tail -120 /tmp/den-api.log'
daytona exec "$SERVER_SANDBOX" -- 'tail -120 /tmp/den-web.log'
daytona exec "$SERVER_SANDBOX" -- 'tail -120 /tmp/den-db-push.log'
```
## When To Use Two Sandboxes
Use two sandboxes when testing cloud behavior end-to-end: server sandbox for Den
and a separate Electron sandbox for the desktop client. This matches production
better than trying to run everything inside one desktop sandbox.
Use this for marketplace install/remove/search/filter, org-managed extensions,
desktop handoff auth, cloud restrictions, and signed preview flows.
## Evidence
Pair this with the `daytona-recording-artifacts` skill. Server proof should
include health-check output, relevant logs, CDP assertions from Electron, and a
recording or screenshot artifact for human review.
Use `daytona-flow-validator` for pass/fail. Server health alone does not prove
Electron cloud behavior works.
+170
View File
@@ -0,0 +1,170 @@
---
name: daytona-dev
description: Daytona development environment overview. Use when the user asks about Daytona setup, Daytona toolbox, dev environment, noVNC, CDP, server sandbox, secrets volume, Electron sandbox, standalone Chrome, validation, or artifacts volume.
---
# Skill: Daytona Dev Environment
Launch the OpenWork Electron app in a Daytona cloud sandbox. The real desktop
app runs on Daytona's XFCE/noVNC desktop stack and is accessible through your
browser.
## Prerequisites
- Daytona CLI installed: `brew install daytonaio/cli/daytona` or download from [GitHub releases](https://github.com/daytonaio/daytona/releases)
- Logged in: `daytona login`
- Using the right Daytona organization for your workspace: `daytona organization use "<org-name>"`
## Quick Start
### 1. Create and start the sandbox
```bash
bash .devcontainer/test-on-daytona.sh [branch-or-commit]
```
The helper uses the VNC snapshot, starts noVNC/Vite/Electron, and prints the
URLs. If the snapshot is missing, create it with
`bash .devcontainer/create-daytona-openwork-snapshot.sh`.
Use the Daytona setup as four reusable pieces. Prefer the focused skills when a
request names one piece directly:
- `test-on-daytona.sh` for the real Electron/noVNC/CDP sandbox.
- `daytona-flow-validator` for the observe -> act -> assert -> evidence loop.
- `test-server-on-daytona.sh` for the cloud Den server sandbox.
- `daytona-electron-den` for Electron connected to a Daytona Den server.
- `daytona-chrome-cdp` for standalone Chrome in the sandbox, separate from Electron.
- `openwork-eval-secrets:/daytona-secrets` for provider keys and eval-only secrets.
- `openwork-eval-artifacts:/daytona-artifacts` for screenshots, validation notes, and recordings.
Focused skills:
- `daytona-cloud-server` for cloud server and Den flows.
- `daytona-electron-den` for two-sandbox Electron + Den validation.
- `daytona-flow-validator` for pass/fail validation and evidence.
- `daytona-chrome-cdp` for normal Chrome browser automation in Daytona.
- `daytona-secrets-volume` for adding or verifying secrets.
- `daytona-electron-test` for real Electron UI validation.
- `daytona-recording-artifacts` for screenshots, recordings, and PR evidence.
Or SSH in after the helper prints the sandbox name:
```bash
daytona ssh <sandbox>
cd /workspace
```
### 2. Get the noVNC URL
```bash
daytona preview-url openwork-dev -p 6080
```
Open that URL in your browser. You'll see the real Electron OpenWork app.
### 4. Get other URLs
```bash
# Den Web dashboard (if Den stack is running)
daytona preview-url openwork-dev -p 3005
# CDP debugging endpoint
daytona preview-url openwork-dev -p 9825
```
## What's Running
| Service | Port | Description |
|---------|------|-------------|
| **noVNC** | 6080 | See and interact with the Electron app in your browser |
| **Vite HMR** | 5173 | Hot module replacement for the React UI |
| **CDP** | 9825 | Chrome DevTools Protocol — for automation |
| **Den Web** | 3005 | Admin dashboard (only if MySQL is available) |
| **Den API** | 8788 | Control plane (only if MySQL is available) |
## Running with Den (full stack)
The devcontainer's `docker-compose.yml` includes MySQL. If you're using Daytona's raw sandbox mode (no Docker Compose), the Den stack won't start because there's no MySQL. Two options:
### Option A: Daytona sandbox + production Den
Just point the app to the production Den:
1. Open the app via noVNC
2. Sign in normally (uses production `app.openworklabs.com`)
3. All cloud features work
### Option B: Daytona sandbox + local Den
If you need a local Den (for testing customization, restrictions, etc.):
1. Use the `docker-compose.yml` approach (requires Docker-in-Docker)
2. Or run Den on your local machine and tunnel to the sandbox
## Common Commands
```bash
# List running sandboxes
daytona list
# SSH into sandbox
daytona ssh openwork-dev
# Check logs
daytona exec openwork-dev 'tail -50 /tmp/electron.log'
daytona exec openwork-dev 'tail -50 /tmp/vite.log'
daytona exec openwork-dev 'tail -50 /tmp/start-vnc.log'
# Inspect Electron CDP targets
daytona exec openwork-dev 'curl -s http://127.0.0.1:9825/json/list'
# Capture a persistent screenshot artifact
daytona exec openwork-dev 'bash .devcontainer/capture-daytona-screenshot.sh'
# Restart just the Electron app
daytona exec openwork-dev 'bash -lc "pkill -f electron || true; pkill -f electron-dev || true"'
daytona exec openwork-dev 'bash -lc "cd /workspace && bash /opt/openwork-daytona/start-daytona-electron.sh --detach"'
# Stop the sandbox (preserves state)
daytona stop openwork-dev
# Start it again
daytona start openwork-dev
# Delete (destroys everything)
daytona delete openwork-dev
```
## Updating the Code
Inside the sandbox, the repo is at `/workspace`. To pull latest:
```bash
daytona ssh openwork-dev
cd /workspace
git pull origin dev
pnpm install
# Then restart Vite/Electron
```
## Troubleshooting
**Electron shows blank window:**
Vite might not be running. Check `tail /tmp/vite.log`. Restart with:
```bash
cd /workspace/apps/app && OPENWORK_DEV_MODE=1 nohup npx vite --host 0.0.0.0 --port 5173 > /tmp/vite.log 2>&1 &
```
**noVNC shows black screen:**
Xvfb/XFCE may have crashed. Restart the desktop stack:
```bash
bash /opt/openwork-daytona/start-daytona-vnc.sh
```
**"no space left on device" when creating sandbox:**
Use `--disk 10`. The default Daytona disk can be 3 GB, which is not enough for
OpenWork dependencies and sidecar prep. Also don't use `--context .` — it
uploads the entire repo (with worktrees, node_modules). Use individual
`--context` flags for just the files needed.
**Electron can't connect to localhost:5173:**
Vite must listen on `0.0.0.0`, not just `localhost`. The start script handles this, but if running manually use `npx vite --host 0.0.0.0`.
@@ -0,0 +1,128 @@
---
name: daytona-electron-den
description: Electron and Den, desktop plus cloud, two-sandbox e2e, cloud auth, marketplace, org policy, signed preview routing, provider sync, desktop handoff. Validate Electron against a Daytona Den server with unified proof.
---
# Daytona Electron Against Den
Use this skill for full-stack cloud behavior: one Daytona sandbox runs Den, and a
separate Daytona sandbox runs the real Electron app pointed at that Den server.
## Start The Server Sandbox
Use the server skill first:
```bash
bash .devcontainer/test-server-on-daytona.sh <branch-or-commit>
```
Record the printed values:
- `SERVER_SANDBOX`
- `DEN_WEB_URL`
- `DEN_API_URL`
Validate server health:
```bash
curl -sf "$DEN_WEB_URL/api/den/health"
curl -sf "$DEN_API_URL/health"
```
## Start Electron Against Den
```bash
bash .devcontainer/test-on-daytona.sh <branch-or-commit> \
--den-base-url "$DEN_WEB_URL" \
--den-api-base-url "$DEN_API_URL" \
--artifacts-volume
```
Add `--require-signin` when the expected behavior must be signed-out until cloud
auth completes. Add `--record-video --recording-name <name>` when PR evidence is
needed.
## Validate Bootstrap
Before testing cloud behavior, prove Electron is using the Daytona Den server:
```js
JSON.stringify({ hash: location.hash, text: document.body.innerText.slice(0, 1000) })
```
Then inspect the desktop bootstrap file:
```bash
daytona exec "$SANDBOX" -- 'cat /workspace/.openwork-daytona/desktop-bootstrap.json'
```
Expected: `baseUrl` is `DEN_WEB_URL` and `apiBaseUrl` is `DEN_API_URL`, not
production.
## Desktop Handoff Pattern
For seeded/demo auth, create the handoff URL from the Den API, then paste it into
Electron's Cloud Account sign-in code field. Do not rely on browser navigation
alone as proof that desktop auth completed.
Fully automated variant (no paste UI): as a signed-in web user call
`POST /api/den/v1/auth/desktop-handoff` (body `{}`) to get a `grant`
(single-use, 5-minute TTL), then dispatch the deep link directly in the
Electron renderer via CDP — the auth provider consumes it:
```js
window.dispatchEvent(new CustomEvent("openwork:deep-link", {
detail: { urls: ["openwork://den-auth?grant=" + grant +
"&denBaseUrl=" + encodeURIComponent(DEN_WEB_URL)] },
}));
```
Build `denBaseUrl` yourself from the public Den Web URL — the server-resolved
one in the handoff response can be an internal host. Afterwards the app shows
"Choose your organization" → "Continue with organization".
Validate all of these:
- Electron Cloud Account shows signed-in user/org state.
- Den API logs show handoff exchange or `/v1/me/orgs`.
- Electron UI can refresh cloud providers/workers/marketplace without production URLs.
Renderer navigation cheat-sheet (hash routes work when buttons are flaky):
- Org marketplace extensions live at
`#/workspace/<ws>/settings/cloud-marketplaces` (the standalone view; the
"Marketplace" tab inside Extensions (Legacy) renders the embedded variant
which HIDES cloud org plugin rows by design). Expect org plugins as
"Active · runs in cloud".
- Insert composer prompts with `editor.focus()` +
`document.execCommand("insertText", false, text)` on
`[contenteditable="true"][data-lexical-editor="true"]`. Do not dispatch
synthetic paste events — Lexical converts them into a "Pasted · N lines"
attachment chip. Then click **Run task** (exact text match) and wait for the
"Ready for new tasks" status before capturing results.
## Marketplace, Policy, Provider Sync
For each cloud feature, use the `daytona-flow-validator` loop:
1. Assert server seed or API state.
2. Act in Electron UI.
3. Assert Electron visible state.
4. Assert Den logs/API state if relevant.
5. Capture screenshot or recording evidence.
Minimum assertions:
- Marketplace: package appears, install/remove changes local extension state.
- Org policy: restriction appears in Electron and persists after reload.
- Provider sync: Den-managed provider appears as imported/credential-ready, model can be selected, and task metadata uses the Den provider id.
- Worker proxy: worker/proxy failures affect only worker UI and recover after proxy restart.
## Evidence
Report both server and Electron proof:
- Den Web/API health checks.
- Relevant `/tmp/den-*.log` snippets.
- Electron CDP assertions and screenshots.
- Recording URL if requested.
@@ -0,0 +1,606 @@
---
name: daytona-electron-test
description: "do E2E tests, run E2E, test on Daytona, run Electron on Daytona, validate feature, real desktop flow, CDP/noVNC, PR proof. Launch and drive OpenWork Electron in Daytona with test evidence."
---
# Skill: Daytona Electron Test
Drive the real OpenWork Electron app inside a Daytona sandbox via CDP browser
tools. Covers workspace creation, session interaction, settings verification,
and bug reproduction.
## When to use
- User says "test on Daytona", "run the app on Daytona", "Daytona dry run"
- User wants to reproduce a bug in the real Electron app remotely
- User wants to verify a UI flow end-to-end without local Electron
## Fastest path: the script
Run the helper script from the repo root. It creates a Daytona VNC-capable
sandbox from the reusable `openwork-eval-vnc` snapshot when present, checks out
the ref, conditionally installs deps, starts XFCE/noVNC, Vite, Electron, and
waits for CDP:
```bash
bash .devcontainer/test-on-daytona.sh [branch-or-commit] --artifacts-volume
```
It prints the CDP and noVNC URLs at the end. For UI validation, keep
`--artifacts-volume` on by default so `/daytona-artifacts` is mounted and the
artifact server on port 8090 is available for supplementary captures. Then load
`run-tests` and run the relevant app-driving E2E test:
```bash
OPENWORK_EVAL_E2E_TESTS=1 OPENWORK_EVAL_DAYTONA=1 \
pnpm --dir evals exec vitest run --config vitest.config.ts \
--project e2e specs/<slug>.e2e.test.ts
```
Use direct browser tools only for exploration and debugging. New repeatable
verdict coverage belongs in `evals/specs/<slug>.e2e.test.ts`, imports `test`
from `@openwork/testkit`, and records ambient evidence; see `write-a-spec`.
Use `browser_list` to connect when manual inspection is needed.
Refresh the snapshot with `bash .devcontainer/create-daytona-openwork-snapshot.sh`
when dependencies or base setup change. The snapshot excludes `node_modules`;
dependency installs reuse the `openwork-eval-pnpm-store` volume.
For provider flows, create/populate the reusable secrets volume once with
`bash .devcontainer/setup-daytona-secrets-volume.sh .newtoken`; future Daytona
sandboxes mount `openwork-eval-secrets:/daytona-secrets` automatically and
source every `/daytona-secrets/*.env` file before Electron starts.
## Related Daytona Skills
- `daytona-flow-validator`: pass/fail validation with a strict observe -> act
-> observe/assert -> evidence loop.
- `daytona-cloud-server`: Den Web/API, marketplace, cloud auth,
and org policy server setup.
- `daytona-electron-den`: two-sandbox server + Electron validation.
- `daytona-chrome-cdp`: standalone Chrome in Daytona for web sign-in and OAuth.
- `daytona-secrets-volume`: provider keys and eval-only secrets in
`openwork-eval-secrets:/daytona-secrets`.
- `daytona-recording-artifacts`: supplementary screenshots, before/after
recordings, and PR presentation artifacts.
## Daytona Testing Toolbox
- **Cloud server:** use `.devcontainer/test-server-on-daytona.sh` for Den Web,
Den API, org policies, marketplace, and cloud auth flows.
- **Secrets volume:** use `openwork-eval-secrets:/daytona-secrets` for provider
keys and eval-only credentials. Add more files with
`bash .devcontainer/setup-daytona-secrets-volume.sh <local-env> <name>.env`.
- **Electron sandbox:** use `.devcontainer/test-on-daytona.sh` for the real
desktop app, noVNC visual access, and CDP automation on port 9825.
- **Artifacts volume:** use `openwork-eval-artifacts:/daytona-artifacts` for
screenshots, validation notes, and recordings that survive sandbox deletion.
Validation standard: use `daytona-flow-validator`. The `@openwork/testkit`
ambient test evidence and its observable assertions determine the verdict;
publish the existing test run with `publish-evidence`. Custom screenshot indexes and video are
supplementary, with video reserved for motion such as streaming or animations.
See `daytona-recording-artifacts` for those presentation artifacts.
Before sharing screenshot URLs, inspect the saved PNG itself per
`daytona-flow-validator`. Do not post screenshots that are covered by native
pickers, dialogs, toasts, desktop windows, or unrelated overlays.
When the user asks specifically about server, secrets, recordings, screenshots,
or evidence, use the focused skill above instead of relying only on this runbook.
## Manual debugging
Do not copy raw Daytona create/start commands into new docs or skills. Keep the
single maintained provisioning path in `.devcontainer/test-on-daytona.sh` and
debug by inspecting its logs:
```bash
daytona exec <sandbox> -- 'tail -80 /tmp/start-vnc.log'
daytona exec <sandbox> -- 'tail -80 /tmp/vite.log'
daytona exec <sandbox> -- 'tail -80 /tmp/electron.log'
```
### Get URLs
```bash
# Electron CDP (automation) -- THIS IS WHAT browser_list CONNECTS TO
daytona preview-url "$SANDBOX" -p 9825
# noVNC (visual access in your browser)
daytona preview-url "$SANDBOX" -p 6080
```
### 5. Connect browser tools
```
browser_list({ browser_url: "<CDP_URL>" })
```
Should show: `[target_id] OpenWork http://localhost:5173/#/welcome`
### 6. Verify it's real Electron (not plain Chromium)
```
browser_eval({ browser_url: "<CDP_URL>", target_id: "<TARGET_ID>", expression: "navigator.userAgent" })
```
Must contain `Electron/`.
## Creating a workspace through the UI
### Prepare the directory first
```bash
daytona exec "$SANDBOX" -- "bash -lc 'mkdir -p /workspace/hello'"
```
### Drive the modal
1. **Click "Get started":**
```js
(function() { var btns = document.querySelectorAll('button'); for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.indexOf('Get started') !== -1) { btns[i].click(); return 'clicked'; } } return 'not found'; })()
```
2. **Click "Local workspace":**
```js
(function() { var btns = document.querySelectorAll('button'); for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.indexOf('Local workspace') !== -1) { btns[i].click(); return 'clicked'; } } return 'not found'; })()
```
3. **Inject folder path** (bypasses the native file picker that can't work headless):
```js
JSON.stringify((function() {
function findFiber(el) {
var key = Object.keys(el).find(function(k) { return k.startsWith('__reactFiber$'); });
return key ? el[key] : null;
}
var all = document.querySelectorAll('span,div,p');
var p = null;
for (var i = 0; i < all.length; i++) {
if (all[i].textContent.indexOf('No folder') !== -1) { p = all[i]; break; }
}
if (!p) return {err: 'no placeholder'};
var fiber = findFiber(p);
while (fiber) {
var name = (fiber.elementType && fiber.elementType.name) || (fiber.type && fiber.type.name) || '';
if (name === 'CreateWorkspaceModal') break;
fiber = fiber.return;
}
if (!fiber) return {err: 'no fiber'};
var hook = fiber.memoizedState;
while (hook) {
if (hook.queue && hook.queue.dispatch) {
hook.queue.dispatch({ key: 'selectedFolder', value: '/workspace/hello' });
hook.queue.dispatch({ key: 'pickingFolder', value: false });
return {ok: true};
}
hook = hook.next;
}
return {err: 'no dispatch'};
})())
```
The reducer uses `{ key, value }` actions. NOT direct state replacement.
4. **Click "Create Workspace":**
```js
(function() { var btns = document.querySelectorAll('button'); for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.trim() === 'Create Workspace' && !btns[i].disabled) { btns[i].click(); return 'clicked'; } } return 'not found'; })()
```
5. **Wait 10-12s.** Verify:
- URL contains `#/workspace/ws_`
- Status bar shows "OpenWork Ready"
- opencode process running: `daytona exec "$SANDBOX" -- "bash -lc 'ps aux | grep opencode | grep -v grep'"`
### Native Linux dialogs
If the current app path opens a native Linux dialog instead of the React modal,
use `daytona-flow-validator`'s Linux desktop automation guidance. Drive GTK file
pickers and OS dialogs with `xdotool`/`wmctrl`, then verify the dialog closed
with `wmctrl -l`, assert app state with CDP, and inspect the captured screenshot
before sharing evidence.
## UI automation selector map
Before guessing selectors, check the owning component. Prefer ARIA labels,
button text, and input placeholders over brittle CSS classes. Use React fiber
only when bypassing native file pickers.
| Control | Stable selector/search | Source file |
|---|---|---|
| Settings | `[data-testid="account-status-menu"]`, then menu item text `Settings` | `apps/app/src/react-app/domains/session/sidebar/account-status-menu.tsx` |
| Back to app | button text `Back to app` | `apps/app/src/react-app/domains/settings/shell/settings-shell.tsx` |
| New task | `button[aria-label="New task"]` | `apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx` |
| Run task | button text `Run task` | `apps/app/src/react-app/domains/session/surface/composer/composer.tsx` |
| Model selector | `button[aria-label="Change model"]` | `apps/app/src/react-app/domains/session/surface/composer/composer.tsx` |
| Composer editor | `[contenteditable="true"][data-lexical-editor="true"]` | `apps/app/src/react-app/domains/session/surface/composer/editor.tsx` |
| AI Providers tab | button text `AI Providers` | `apps/app/src/react-app/domains/settings/shell/settings-page.tsx` |
| Connect provider | button text `Connect provider` | `apps/app/src/react-app/domains/settings/pages/ai-view.tsx` |
| Provider search | `input[placeholder="Filter providers by name or ID"]` | `apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx` |
| Manual key option | button containing `Manually enter API Key` | `provider-auth-modal.tsx` |
| API key input | `input[type="password"][placeholder="sk-..."]` | `provider-auth-modal.tsx` |
| Save key | button text `Save key` | `provider-auth-modal.tsx` |
Reusable click helpers:
```js
// Click exact button text.
(function(text) { var b = Array.from(document.querySelectorAll('button')).find(function(el) { return el.textContent.trim() === text && !el.disabled; }); if (!b) return 'not found: ' + text; b.click(); return 'clicked: ' + text; })('AI Providers')
```
```js
// Click an ARIA-labeled button/link.
(function(label) { var el = Array.from(document.querySelectorAll('button,a')).find(function(node) { return node.getAttribute('aria-label') === label && !node.disabled; }); if (!el) return 'not found: ' + label; el.click(); return 'clicked: ' + label; })('Settings')
```
```js
// Set a React-controlled input.
(function(selector, value) { var input = document.querySelector(selector); if (!input) return 'not found: ' + selector; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, value); input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value })); return 'set: ' + selector; })('input[placeholder="Filter providers by name or ID"]', 'openai')
```
```js
// Paste text into the Lexical composer. Prefer this over execCommand in Electron/CDP.
(function(text) { var editor = document.querySelector('[contenteditable="true"][data-lexical-editor="true"]'); if (!editor) return 'no editor'; editor.focus(); var data = new DataTransfer(); data.setData('text/plain', text); editor.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data })); return editor.innerText; })('Reply with exactly: Daytona UI key OK')
```
## Connect OpenAI through the UI
Use this when the user provides a temporary key and asks to test real model
sessions. Do not write the key into docs or repo files.
1. Open the sidebar account menu `[data-testid="account-status-menu"]`, then click `Settings`.
2. Click `AI Providers`.
3. Click `Connect provider`.
4. Set `input[placeholder="Filter providers by name or ID"]` to `openai`.
5. Click the provider row containing `OpenAI` and `openai`.
6. Click `Manually enter API Key`.
7. Set `input[type="password"][placeholder="sk-..."]` to the key.
8. Click `Save key`.
9. Verify text includes `2 providers connected`, `OpenAI`, and `Disconnect`.
10. Click `Pick a new default?`, expand `OpenAI`, select `Default model`, and click `GPT-5.5gpt-5.5`.
11. Return to app, create a session, paste a prompt into the composer, and click `Run task`.
Expected successful session message metadata: provider `openai`, model `gpt-5.5`, variant `medium`.
## Session interaction
### Prerequisites: API key for real LLM sessions
To test real sessions (not just UI flow), the opencode sidecar needs an LLM
provider key. The easiest is OpenAI:
```bash
daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace/hello && node -e \"
const fs = require(\\\"fs\\\");
const p = \\\"opencode.jsonc\\\";
let c = JSON.parse(fs.readFileSync(p, \\\"utf8\\\").replace(/^\\\\/\\\\/.*$/gm, \\\"\\\"));
c.provider = c.provider || {};
c.provider.openai = { options: { apiKey: process.env.KEY } };
fs.writeFileSync(p, JSON.stringify(c, null, 2));
\" '"
```
Set `KEY=sk-proj-...` in the command above. After writing the config, you
**must restart all services** (see "Injecting API keys" section below) for
opencode to pick up the new provider.
To switch models in the UI, click the model name in the bottom bar (e.g.
"Big Pickle") and select the desired model (e.g. GPT-5.5).
### Type in the Lexical composer
```js
(function() {
var editor = document.querySelector('[contenteditable=true]');
if (!editor) return 'no editor';
editor.focus();
document.execCommand('selectAll', false, null);
document.execCommand('insertText', false, 'YOUR PROMPT HERE');
return 'typed';
})()
```
**MUST use `document.execCommand('insertText', ...)`.**
Direct `textContent =` or `innerHTML =` does NOT trigger Lexical state updates.
### Click Run task
```js
(function() { var btns = document.querySelectorAll('button'); for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.indexOf('Run task') !== -1 && !btns[i].disabled) { btns[i].click(); return 'clicked'; } } return 'not found'; })()
```
### Check response
```js
document.body.innerText.substring(0, 3000)
```
## Settings navigation
**Open settings** (gear icon):
```js
(function() { var el = Array.from(document.querySelectorAll('button,a')).find(function(node) { return node.getAttribute('aria-label') === 'Settings'; }); if (!el) return 'not found'; el.click(); return 'clicked'; })()
```
**Navigate to a panel** (e.g. AI Providers):
```js
(function() { var btn = Array.from(document.querySelectorAll('button')).find(function(el) { return el.textContent.trim() === 'AI Providers'; }); if (!btn) return 'not found'; btn.click(); return 'clicked'; })()
```
**Back to app:**
```js
(function() { var btn = Array.from(document.querySelectorAll('button')).find(function(el) { return el.textContent.trim() === 'Back to app'; }); if (!btn) return 'not found'; btn.click(); return 'clicked'; })()
```
## Window management (minimize/restore testing)
Install xdotool first:
```bash
daytona exec "$SANDBOX" -- "bash -lc 'apt-get update && apt-get install -y xdotool'"
```
Then:
```bash
# Minimize
daytona exec "$SANDBOX" -- "bash -lc 'DISPLAY=:99 xdotool search --name OpenWork windowminimize'"
# Restore
daytona exec "$SANDBOX" -- "bash -lc 'DISPLAY=:99 xdotool search --name OpenWork windowactivate'"
```
## API keys and eval secrets
Do not edit workspace config or print keys. Create/populate the reusable
Daytona volume once from the repo root:
```bash
bash .devcontainer/setup-daytona-secrets-volume.sh .newtoken
bash .devcontainer/setup-daytona-secrets-volume.sh .anthropic anthropic.env
```
Every Daytona eval sandbox mounts `openwork-eval-secrets:/daytona-secrets` and
`/opt/openwork-daytona/start-daytona-electron.sh` sources every
`/daytona-secrets/*.env` file before Electron starts. Keep provider keys, test
OAuth credentials, and other eval-only secrets there instead of workspace files.
If you update the volume while a sandbox is already running, restart Electron so
the env is reloaded:
```bash
# Step 1: kill Electron/runtime children
daytona exec "$SANDBOX" -- "bash -lc 'pkill -f electron || true; pkill -f electron-dev || true; pkill -f opencode || true'"
# Step 2: wait, then restart Electron (separate exec call)
sleep 3
daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && bash /opt/openwork-daytona/start-daytona-electron.sh --detach'"
```
**GOTCHA:** Do NOT chain `pkill` and the restart in the same
`daytona exec` call. `pkill -f electron` sends SIGTERM to the exec session
itself (because the command string matches). The restart never runs.
Always use two separate `daytona exec` calls with a `sleep` between them.
## Ports reference
| Service | Port | Description |
|-----------|------|------------------------------------------|
| noVNC | 6080 | See the Electron app visually |
| Vite HMR | 5173 | React UI hot reload |
| CDP | 9825 | Chrome DevTools Protocol for automation |
| Den Web | 3005 | Admin dashboard (needs MySQL) |
| Den API | 8788 | Control plane (needs MySQL) |
## Two-sandbox Den + Electron marketplace evals
Use `daytona-electron-den` when testing Cloud Marketplace, desktop policies, or
org-managed extension flows end-to-end. Keep this section as a quick reference
only.
1. Start the Den server sandbox:
```bash
bash .devcontainer/test-server-on-daytona.sh <branch-or-commit>
```
2. Seed the server sandbox with demo org, marketplace, and plugin data. The seed
must use the same encryption key as `.devcontainer/start-daytona-server.sh`, and
`@openwork/email` must be built before the seed imports Den email helpers:
```bash
daytona exec <server-sandbox> -- 'cd /workspace && pnpm --filter @openwork/email build && cd /workspace/ee/apps/den-api && OPENWORK_DEV_MODE=1 DATABASE_URL=mysql://root:password@127.0.0.1:3306/openwork_den DEN_DB_ENCRYPTION_KEY=daytona-den-db-encryption-key-please-change-1234567890 BETTER_AUTH_SECRET=local-dev-secret-not-for-production-use!! BETTER_AUTH_URL=http://localhost:3005 pnpm exec tsx scripts/seed-demo-org.ts --reset'
```
3. Start Electron against the printed Den Web/API URLs:
```bash
bash .devcontainer/test-on-daytona.sh <branch-or-commit> --den-base-url <DEN_WEB_URL> --den-api-base-url <DEN_API_URL> --record-video --recording-name <name>
```
4. Sign in from Electron using the seeded demo account. Create a desktop handoff
grant from the Den API, paste the `openwork://den-auth?...` URL into Cloud
Account -> `Paste sign-in code`, and choose `Acme Robotics`:
```bash
TOKEN=$(curl -s -X POST '<DEN_API_URL>/api/auth/sign-in/email' -H 'content-type: application/json' --data '{"email":"alex@acme.test","password":"OpenWorkDemo123!"}' | node -e 'let s="";process.stdin.on("data",c=>s+=c);process.stdin.on("end",()=>process.stdout.write(JSON.parse(s).token))')
curl -s -X POST '<DEN_API_URL>/v1/auth/desktop-handoff' -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' --data '{"desktopScheme":"openwork"}'
```
5. Open Settings -> Extensions -> Marketplace and run the marketplace install,
remove, search, and filter flows against the seeded marketplace packages.
## Troubleshooting
**OOM during pnpm install or Vite esbuild crash (EPIPE):**
You used `--memory 1` (default). Always `--memory 8`.
**Electron exits with "Running as root without --no-sandbox":**
The devcontainer sets `ELECTRON_DISABLE_SANDBOX=1`. If running Electron
manually, pass `--no-sandbox` or set the env var.
**Generic DBus errors in Electron logs:**
DBus warnings are expected in Daytona/Linux containers. They are not fatal if
you also see `DevTools listening on ws://127.0.0.1:9825/...` and an OpenWork
window in noVNC.
**GPU process errors in Electron logs:**
`Exiting GPU process due to errors during initialization` is common under Xvfb.
It is not fatal if Chromium falls back and the window appears. If CDP never
prints `DevTools listening`, check `/tmp/electron.log` and restart Electron.
**"bun: not found" during dev:electron:**
The sidecar prep script uses bun. The devcontainer Dockerfile installs it
globally. If you built a custom Dockerfile, add `RUN npm install -g bun`.
**"xauth command not found":**
`apt-get install -y xauth` (already in the devcontainer Dockerfile).
**CDP shows no targets after 60s:**
Check `/tmp/electron.log` and `/tmp/vite.log`:
```bash
daytona exec "$SANDBOX" -- "bash -lc 'tail -80 /tmp/electron.log'"
daytona exec "$SANDBOX" -- "bash -lc 'tail -80 /tmp/vite.log'"
```
The app log line `[openwork] Electron CDP exposed at http://127.0.0.1:9825`
means OpenWork requested CDP. The real success marker is Chromium's own line:
`DevTools listening on ws://127.0.0.1:9825/devtools/browser/...`.
**opencode sidecar not restarting after kill:**
The Electron runtime manager does NOT auto-detect sidecar death. You must
restart the entire Electron process.
**`daytona exec` with `pkill` kills the exec session:**
The process pattern match hits the exec wrapper. Always split kill and
restart into separate `daytona exec` calls.
**Blank Electron window (empty `<div id="root"></div>`):**
Vite crashed (check `/tmp/vite.log`). Usually memory pressure. Verify
`free -m` shows >2 GB available.
**noVNC URL says sandbox not found:**
Preview URLs are not stable. Regenerate the URL:
```bash
daytona preview-url "$SANDBOX" -p 6080
```
**Electron starts twice or CDP says address already in use:**
Kill the old Electron process before restarting:
```bash
daytona exec "$SANDBOX" -- "bash -lc 'pkill -f electron || true; pkill -f electron-dev || true'"
```
## Recording before/after comparisons
Use this workflow to capture a BEFORE recording on the current branch, switch
to a feature branch on the same sandbox, and capture an AFTER recording. Both
recordings are saved to the persistent `openwork-eval-artifacts` volume and
survive sandbox deletion.
### Step 1: Start the sandbox with BEFORE recording
```bash
bash .devcontainer/test-on-daytona.sh dev --record-video --recording-name my-feature-before
```
Save the sandbox name from the output (e.g. `SANDBOX=openwork-test-20260601-165424`).
### Step 2: Drive the BEFORE flow
Use browser tools to navigate the app and demonstrate the current behavior.
The display is being recorded the entire time.
### Step 3: Stop the BEFORE recording
```bash
daytona exec "$SANDBOX" -- 'bash .devcontainer/stop-daytona-recording.sh'
```
### Step 4: Switch to the feature branch
```bash
daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && git fetch origin feat/my-branch:feat/my-branch && git checkout feat/my-branch'"
```
Vite HMR picks up the changes automatically. Wait a few seconds, then reset
any app state needed (e.g. onboarding flag):
```js
// In browser_eval:
const raw = localStorage.getItem("openwork.preferences");
const prefs = raw ? JSON.parse(raw) : {};
prefs.hasCompletedOnboarding = false;
localStorage.setItem("openwork.preferences", JSON.stringify(prefs));
location.reload();
```
### Step 5: Start the AFTER recording
```bash
daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && DISPLAY=:99 .devcontainer/start-daytona-recording.sh --detach --output /daytona-artifacts/recordings/my-feature-after.mp4'"
```
### Step 6: Drive the AFTER flow
Use browser tools to demonstrate the new behavior. Same steps as BEFORE, but
validate with `daytona-flow-validator` before calling the recording successful.
### Step 7: Stop the AFTER recording
```bash
daytona exec "$SANDBOX" -- 'bash .devcontainer/stop-daytona-recording.sh'
```
### Step 8: Get recording URLs
Both recordings are on the persistent artifacts volume, served via the
Python HTTP server on port 8090:
```bash
ARTIFACTS_URL=$(daytona preview-url "$SANDBOX" -p 8090 2>/dev/null | grep -v "^time=")
echo "BEFORE: ${ARTIFACTS_URL}/recordings/my-feature-before.mp4"
echo "AFTER: ${ARTIFACTS_URL}/recordings/my-feature-after.mp4"
```
Include these URLs in your PR description.
## Screenshot validation checkpoints
Use screenshots for fast validation while driving the UI. They complement, but
do not replace, CDP assertions or recordings.
```bash
daytona exec "$SANDBOX" -- 'bash .devcontainer/capture-daytona-screenshot.sh'
```
Screenshots are saved to `/daytona-artifacts/screenshots` when the artifacts
volume is mounted. Get the download URL from port 8090:
```bash
ARTIFACTS_URL=$(daytona preview-url "$SANDBOX" -p 8090 2>/dev/null | grep -v "^time=")
echo "${ARTIFACTS_URL}/screenshots/<filename>.png"
```
Use this pattern for each critical UI state: run a CDP assertion, capture a
screenshot, then continue the recording.
### Key recording commands reference
| Action | Command |
|--------|---------|
| Start recording (from test-on-daytona.sh) | `--record-video --recording-name NAME` |
| Start recording (mid-sandbox) | `daytona exec $SANDBOX -- "bash -lc 'cd /workspace && DISPLAY=:99 .devcontainer/start-daytona-recording.sh --detach --output /daytona-artifacts/recordings/NAME.mp4'"` |
| Stop recording | `daytona exec $SANDBOX -- 'bash .devcontainer/stop-daytona-recording.sh'` |
| List recordings | `daytona exec $SANDBOX -- 'ls -lah /daytona-artifacts/recordings/'` |
| Capture screenshot | `daytona exec $SANDBOX -- 'bash .devcontainer/capture-daytona-screenshot.sh'` |
| Get download URL | `daytona preview-url $SANDBOX -p 8090` then append `/recordings/NAME.mp4` |
### Notes
- Recordings are stored on the `openwork-eval-artifacts` Daytona volume (5 GB,
reusable across sandboxes). They persist after `daytona delete`.
- The `start-daytona-recording.sh` script records to a temp file first, then
copies to the artifacts volume on stop — this avoids NFS write issues.
- Always use `stop-daytona-recording.sh` to stop. It sends SIGINT so ffmpeg
finalizes the mp4 container properly. SIGKILL produces a corrupt file.
- Default resolution is 1920x1080 at 15fps. Override with `--size 1280x800
--fps 10` for smaller files.
## Teardown
```bash
daytona delete "$SANDBOX"
```
@@ -0,0 +1,255 @@
---
name: daytona-flow-validator
description: do E2E tests, validate feature, prove it works, pass/fail, test evidence, screenshots, CDP assertions. Daytona validation loop for real app behavior with repair before declaring success.
---
# Daytona Flow Validator
Use this skill to decide whether a Daytona Electron or browser flow actually
works. Launching the sandbox is separate. This skill owns the feedback loop.
Follow `prove-a-pr` for the repository-wide agent-first and human-verification contract.
## Core Rule
Never report success from a click, script return value, or recording alone.
Validate the same path a human would take, using CDP to drive Chrome or Electron
instead of replacing the journey with hidden state changes. Every meaningful
action must follow this loop:
1. Observe with `browser_snapshot` first.
2. Act with `browser_click` or `browser_fill` against snapshot UIDs whenever possible.
3. Observe again with `browser_snapshot`.
4. Assert the expected URL, text, state, process, network, or file result.
5. Capture a screenshot checkpoint when the visible state matters.
If any assertion is missing, the flow is not validated yet.
Use the relevant `evals/specs/**/*.test.ts` test with `@openwork/testkit` for a
verdict because it binds assertions and captures into one ambient test run.
App-driving E2E tests use `.e2e.test.ts`. Use manual CDP only to debug; use
`write-a-spec` for new repeatable coverage and `run-tests` to execute it.
Use `browser_eval`, direct API calls, localStorage writes, filesystem edits, or
database changes only when a human-visible path is impossible, unavailable in the
current product, or needed as setup for data that the UI cannot create yet. When
you use one of these shortcuts, say so in the report and do not let it replace the
visible click-by-click demo claim.
## Human-Visible Demo Standard
For founder, designer, PR, or eval evidence, record the journey as a reviewer
would experience it:
- Start recording before the first user-visible action.
- Use Chrome CDP for Den Web and Electron CDP for desktop.
- Prefer accessible snapshot items and visible clicks/fills over synthetic DOM mutation.
- Keep every major transition visible: sign-up, org creation, onboarding, handoff, desktop sign-in, Marketplace, install, and chat response.
- Do not skip screens by setting tokens, editing localStorage, calling APIs, or navigating directly unless the report labels that segment as setup or a known product gap.
- If test data must be created through an API, show the result in the UI immediately after and state that the data creation was invisible setup.
- A recording that cannot be understood without terminal logs, API responses, or explanation is not a full demo pass.
## Minimum Pass Evidence
For a UI flow, collect all of these when feasible:
- CDP target proof: `browser_list` shows the intended target.
- App proof: `navigator.userAgent` contains `Electron/` for desktop flows, or does not for standalone Chrome flows.
- State proof: URL, visible text, selected model/provider, status, or route matches the expected outcome.
- Backend proof: relevant `daytona exec` process/log/health check for sidecars, Den, or mock servers.
- Test evidence: observable assertions and validated screenshot artifacts for every claim.
- Custom screenshot indexes: supplementary presentation artifacts only. See
`daytona-recording-artifacts`.
- Video clips: supplementary and only for motion such as streaming, loading, or
animation.
The test evidence determines pass/fail. Custom screenshots and video never
replace it, even when the user explicitly requests those additional artifacts.
## Validation Loop Template
Use this structure for each step in the final report:
```text
Step: <what was attempted>
Before: <snapshot/eval showed X>
Action: <tool/selector used>
After: <snapshot/eval showed Y>
Assertion: pass/fail because <observable signal>
Evidence: <screenshot path or artifact URL if captured>
```
## Prefer Accessible Snapshots
Start with `browser_snapshot` for normal UI controls because it gives stable
UIDs for `browser_click` and `browser_fill`. Treat `browser_eval` as an escape
hatch, not the default interaction mechanism. Use `browser_eval` when:
- React dynamic UI makes snapshot UIDs stale.
- Native file pickers must be bypassed.
- Lexical editor input needs a synthetic paste event.
- You need to inspect app state, localStorage, URL, or text quickly.
Even when `browser_eval` is necessary, keep the user-visible state coherent:
observe before, perform the minimal hidden action, then observe the visible result
with `browser_snapshot` or a screenshot.
## Lexical Composer
Prefer synthetic paste for the OpenWork composer:
```js
(function pasteComposer(text) {
var editor = document.querySelector('[contenteditable="true"][data-lexical-editor="true"]');
if (!editor) return { ok: false, reason: 'composer not found' };
editor.focus();
var data = new DataTransfer();
data.setData('text/plain', text);
editor.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }));
return { ok: true, text: editor.innerText };
})('Reply with exactly: Daytona validation OK')
```
Then assert the Run button is enabled before clicking it.
## Linux Desktop Automation
Use CDP for renderer UI first. When the flow opens native Linux UI that CDP
cannot control, such as GTK file pickers, OS permission dialogs, XFCE windows,
or Electron-native dialogs, switch to desktop automation inside the sandbox.
Check/install the tools:
```bash
daytona exec "$SANDBOX" -- "bash -lc 'if ! command -v xdotool >/dev/null 2>&1; then sudo apt-get update && sudo apt-get install -y xdotool wmctrl; fi'"
```
Inspect the real desktop window state before acting:
```bash
daytona exec "$SANDBOX" -- "bash -lc 'DISPLAY=:99 wmctrl -l; DISPLAY=:99 xdotool getactivewindow getwindowname 2>/dev/null || true'"
```
Native file picker pattern:
```bash
daytona exec "$SANDBOX" -- "bash -lc 'DISPLAY=:99 xdotool search --name \"Authorize folder\" windowactivate; DISPLAY=:99 xdotool mousemove 760 151 click 1 key ctrl+a type --delay 1 -- \"/workspace/hello\" key Return; sleep 1; DISPLAY=:99 xdotool mousemove 1465 927 click 1'"
```
Rules for native desktop automation:
- Use `wmctrl -l` before and after to prove the expected native window opened or
closed.
- Prefer window titles such as `Authorize folder` over coordinates when possible.
- Use coordinates only after inspecting a screenshot/noVNC state; coordinate
clicks are display-size dependent.
- Close stale dialogs with `Escape` before capturing evidence.
- After native automation, reassert app state with CDP and inspect a fresh
screenshot. Native dialogs commonly remain on top and invalidate evidence.
Close stale native dialogs before recording or screenshots:
```bash
daytona exec "$SANDBOX" -- 'bash -lc '\''DISPLAY=:99 xdotool search --name "Authorize folder" windowclose %@ 2>/dev/null || true; sleep 1; DISPLAY=:99 wmctrl -l'\'''
```
## Screenshots
Use browser screenshots for renderer state:
```text
browser_screenshot({ browser_url: CDP_URL, target_id: TARGET_ID })
```
Use Daytona display screenshots for noVNC/window state:
```bash
daytona exec "$SANDBOX" -- 'bash .devcontainer/capture-daytona-screenshot.sh'
```
Do not treat screenshots as the only assertion. Inspect text/state with CDP too.
Before publishing, commenting, or reporting a screenshot URL, open the saved
image and visually verify it matches the claim. Use `webfetch` on the artifact
URL, `Read` on the local PNG path, or another image-capable viewer. A screenshot
is not valid evidence until the image itself has been inspected.
For every screenshot, assert these visual checks:
- The target OpenWork/Chrome window is visible and not covered by a native file
picker, modal, toast, desktop window, or unrelated overlay.
- The exact claimed state is visible in the image, not just present in DOM or
command output.
- Important labels, selected rows, dropdown options, status indicators, artifact
cards, or output panes are legible enough for a reviewer.
- The screenshot URL belongs to the sandbox and flow being reported.
If any check fails, mark the evidence as failed, fix the visible state, capture a
new screenshot, inspect the new image, and only then share it. If bad evidence
was already posted, post a superseding correction that clearly says the earlier
screenshot was invalid.
## Repair Loop
If a frame does not support the claim, repair before reporting a verdict:
1. Diagnose why the frame is invalid: wrong route, hidden target, duplicate
screenshot, error state, native dialog, stale modal, or unreadable content.
2. Repair the visible state: close blockers, activate the intended window, wait
for text/route stability, scroll the target into view, or rerun the visible
action.
3. Reassert the expected state with CDP.
4. Capture a new frame with a new name.
5. Include repair attempts in the final report. If repair fails, report
`Incomplete` or `Failed`, not `Passed`.
Before every Daytona display screenshot, run a native-window check and fail fast
if a picker is present:
```bash
daytona exec "$SANDBOX" -- 'bash -lc '\''DISPLAY=:99 wmctrl -l | tee /tmp/windows-before-shot.txt; ! grep -q "Authorize folder" /tmp/windows-before-shot.txt; DISPLAY=:99 .devcontainer/capture-daytona-screenshot.sh --output /daytona-artifacts/screenshots/<flow>/<step>.png'\'''
```
If a Chromium or Electron window is intentionally part of the shot, activate the
right window first with `wmctrl -a "OpenWork - Dev"` or
`wmctrl -a "OpenWork Cloud - Chromium"` so the screenshot shows the intended
journey step.
## Failure Handling
When a step fails:
- Capture current `browser_snapshot` or `document.body.innerText`.
- Capture a screenshot.
- Inspect the relevant logs before retrying.
- Retry only after naming the suspected cause.
Common logs:
```bash
daytona exec "$SANDBOX" -- 'tail -120 /tmp/electron.log'
daytona exec "$SANDBOX" -- 'tail -120 /tmp/vite.log'
daytona exec "$SERVER_SANDBOX" -- 'tail -120 /tmp/den-api.log'
```
For Den Web flows specifically:
- If the page remains on `Checking account`, `Loading your workspace`, or
`Checking workspace access`, verify whether the client hydrated by trying a
real `browser_click`/`browser_fill`, not only `browser_eval`.
- If Next dev is behind a Daytona proxy, switch Den Web to `next build` +
`next start` before declaring the app broken.
- Validate direct Den API auth independently. A direct API pass plus Den Web
proxy failure is an incomplete handoff, not a full pass.
- If you bridge auth by writing localStorage in Electron, state that explicitly
in the final report and limit the pass claim to the downstream desktop flow.
## Final Verdict
Use one of these verdicts:
- `Passed`: every expected outcome has an observable assertion in the testkit
test evidence, with every claimed screenshot validated.
- `Failed`: at least one assertion disproves the expected outcome.
- `Incomplete`: the sandbox/tooling failed, test evidence is missing, or only a
custom recording/screenshot was collected.
@@ -0,0 +1,234 @@
---
name: daytona-recording-artifacts
description: screenshots, recording, presentation artifacts, validate visually. Supplementary Daytona screenshots and optional videos after testkit validation.
---
# Daytona Recording Artifacts
Use this skill to collect supplementary presentation artifacts for a Daytona UI
journey. Pass/fail evidence comes from an `@openwork/testkit` spec and its
ambient test evidence; use `daytona-flow-validator` and `run-tests` before declaring a
verdict. Custom screenshots or recordings never replace the test run.
Follow `prove-a-pr` for the repository-wide agent-first and human-verification contract.
## Default supplementary format: screenshot index
For presentation, use a browseable HTML page with named PNG screenshots for
each step. This is easy to review and works on any device, but is not the
verdict artifact.
Use video (MP4) only when the proof requires motion: streaming text, loading
spinners, animations, drag-and-drop, or real-time interactions that a static
frame cannot capture. When video is used, embed it inside the frame-by-frame
HTML page alongside the static frames.
First run the relevant `evals/specs/**/*.test.ts` through `run-tests`. The spec
imports `test` from `@openwork/testkit`; screenshots and validation claims are
recorded ambiently in its test evidence. Use `publish-evidence` for that existing test run,
then create the custom index here only if useful.
### How to produce the screenshot index
1. Serve a directory from the sandbox on port 8090:
```bash
daytona exec "$SANDBOX" -- 'bash -lc "mkdir -p /workspace/proof-frames; nohup python3 -m http.server 8090 --directory /workspace/proof-frames >/dev/null 2>&1 &"'
```
2. At each important state, capture a `browser_screenshot` locally and upload
it to the sandbox with a numbered name like `01-auth-landing.png`,
`02-org-filled.png`, etc.
3. Generate a browseable `index.html` with all frames as labeled images in a
responsive grid. Include the branch, commit, and sandbox name.
4. Get the public URL:
```bash
FRAMES_URL=$(daytona preview-url "$SANDBOX" -p 8090 2>/dev/null | grep -v "^time=")
echo "${FRAMES_URL}/index.html"
```
5. Post the index URL in the PR body. Individual frame PNGs are also directly
linkable: `${FRAMES_URL}/01-auth-landing.png`.
### Fallback when `/daytona-artifacts` is unavailable
The preferred path is `test-on-daytona.sh --artifacts-volume`, which mounts and
serves `/daytona-artifacts`. If that path is unavailable or not writable, do not
stop at local screenshots. Use `/workspace/proof-frames` as a fallback:
```bash
daytona exec "$SANDBOX" -- 'bash -lc "mkdir -p /workspace/proof-frames; nohup python3 -m http.server 8090 --bind 0.0.0.0 --directory /workspace/proof-frames >/tmp/proof-frames-http.log 2>&1 &"'
```
Upload the generated `index.html` and PNG frames there, then verify every file is
non-zero before sharing:
```bash
daytona exec "$SANDBOX" -- 'bash -lc "ls -lh /workspace/proof-frames && curl -s -I http://127.0.0.1:8090/index.html | sed -n \"1,8p\""'
daytona preview-url "$SANDBOX" -p 8090
```
If you use this fallback, say so in the PR/eval report because the files are not
on the persistent artifacts volume.
### When to include video clips
Embed short MP4 clips in the same proof directory when the step involves:
- Streaming text appearing in real-time (chat responses).
- Loading/progress indicators that resolve.
- Animations or transitions between states.
- Drag-and-drop or multi-step interactions.
Reference them from the HTML index alongside the static frames.
## Recording Standard (video, when needed)
A useful Daytona recording should look like a person using the product, even
though CDP is driving the browser or Electron window.
- Record the entire relevant journey, not only the final state.
- Start before the first visible click and stop after the final visible success state.
- Drive Chrome/Electron through visible controls with `browser_snapshot`, `browser_click`, and `browser_fill` wherever possible.
- Keep API calls, localStorage writes, direct navigation, and filesystem checks out of the recorded path unless they are unavoidable setup.
- If invisible setup is unavoidable, label it in the PR/eval and resume the recording at the next visible user step.
- Prefer slower, understandable click-by-click recordings over faster scripts that jump between states.
- The recording should be understandable without terminal output; logs and API checks are supporting evidence only.
## The Volume
The reusable Daytona volume is:
```text
openwork-eval-artifacts:/daytona-artifacts
```
The helper serves it on port `8090` when `--artifacts-volume` or
`--record-video` is used.
Expected layout:
```text
/daytona-artifacts/recordings
/daytona-artifacts/screenshots
/daytona-artifacts/validation
```
## Start With Artifacts
For screenshots and validation notes without video:
```bash
bash .devcontainer/test-on-daytona.sh [branch-or-commit] --artifacts-volume
```
For full human-review evidence:
```bash
bash .devcontainer/test-on-daytona.sh [branch-or-commit] --record-video --recording-name <name>
```
`--record-video` implies `--artifacts-volume`.
## Capture Screenshot Checkpoints
Capture a persistent screenshot from the Daytona display:
```bash
daytona exec "$SANDBOX" -- 'bash .devcontainer/capture-daytona-screenshot.sh'
```
Use this after important states: welcome screen, workspace created, settings
connected, task response visible, error state reproduced, or final success.
Before sharing any screenshot URL, follow `daytona-flow-validator` and inspect
the saved PNG itself. Confirm the visible image shows the claimed state and is
not covered by a native picker, modal, toast, desktop window, or unrelated
overlay. If the screenshot does not match, recapture and inspect a replacement.
## Stop Recording
Always stop with the helper so ffmpeg finalizes the MP4 cleanly:
```bash
daytona exec "$SANDBOX" -- 'bash .devcontainer/stop-daytona-recording.sh'
```
Do not use `kill -9`; it can corrupt the file.
After stopping, verify the recording exists and has duration:
```bash
daytona exec "$SANDBOX" -- 'ls -lh /daytona-artifacts/recordings'
daytona exec "$SANDBOX" -- 'ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 /daytona-artifacts/recordings/<name>.mp4'
```
If the duration is near zero, missing, or the file is absent, the recording is
not usable evidence.
## Get Artifact URLs
Get the artifacts base URL:
```bash
ARTIFACTS_URL=$(daytona preview-url "$SANDBOX" -p 8090 2>/dev/null | grep -v "^time=")
```
Then append paths:
```bash
echo "${ARTIFACTS_URL}/recordings/<name>.mp4"
echo "${ARTIFACTS_URL}/screenshots/<name>.png"
```
Artifact proxy URLs are not permanent. If the sandbox stops, the old
`daytonaproxy` URL will fail even when files still exist in
`/daytona-artifacts`. Restart the sandbox and artifact server, then generate a
fresh URL:
```bash
daytona sandbox start "$SANDBOX"
daytona exec "$SANDBOX" -- 'bash -lc '\''cd /daytona-artifacts && nohup python3 -m http.server 8090 --bind 0.0.0.0 > /tmp/daytona-artifacts-http.log 2>&1 &'\'''
daytona exec "$SANDBOX" -- 'curl -s -I http://127.0.0.1:8090/recordings/<name>.mp4 | sed -n "1,8p"'
daytona preview-url "$SANDBOX" -p 8090
```
Only share the refreshed URL after the local `curl -I` returns `200 OK` with a
non-zero `Content-Length`.
## Before And After Flow
Use before/after recordings for UI regressions or design changes:
```bash
bash .devcontainer/test-on-daytona.sh dev --record-video --recording-name my-feature-before
daytona exec "$SANDBOX" -- 'bash .devcontainer/stop-daytona-recording.sh'
daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && git fetch origin feat/my-branch:feat/my-branch && git checkout feat/my-branch'"
daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && DISPLAY=:99 .devcontainer/start-daytona-recording.sh --detach --output /daytona-artifacts/recordings/my-feature-after.mp4'"
daytona exec "$SANDBOX" -- 'bash .devcontainer/stop-daytona-recording.sh'
```
## Validation Standard
Use these layers in order of priority:
1. **Test evidence**: ambient validated screenshots and observable assertions
are the verdict source.
2. **Custom screenshot index** (optional): named PNGs for presentation after
the evidence-backed test is complete.
3. **Video clips** (optional): short MP4s for motion such as streaming,
animations, or loading states.
Do not report success from a recording or custom screenshot alone. The testkit
test evidence must contain the assertions and validated screenshots for the claimed behavior.
Do not use a video as the primary demo if most of the flow happened through
hidden automation. In that case, mark the artifact as supplementary and rely on
the evidence-backed test for the verdict.
If you discover invalid evidence after the fact, do not reuse the same URL as
if it were valid. Produce new frames with new names and explain in the
PR/comment that the earlier artifact was superseded.
@@ -0,0 +1,96 @@
---
name: daytona-secrets-volume
description: Daytona secrets, provider key, OpenAI key, Anthropic key, real model e2e, voice e2e, eval secrets, /daytona-secrets, openwork-eval-secrets. Use for real provider tests in Daytona.
---
# Daytona Secrets Volume
Use this skill when Daytona tests need provider keys or other eval-only secrets.
Never commit secrets to the repo and never print secret values.
## The Volume
The reusable Daytona volume is:
```text
openwork-eval-secrets:/daytona-secrets
```
Electron sandboxes mount it automatically through `.devcontainer/test-on-daytona.sh`.
The Electron starter sources every file matching:
```text
/daytona-secrets/*.env
```
This is a Daytona reusable volume, not a host directory. You cannot inspect it
directly from the local filesystem. To add, list, or test files, mount it into a
temporary Daytona sandbox or use an existing sandbox that mounted the volume.
## Add A Secret File
Create a local env file, then copy it into the volume:
```bash
bash .devcontainer/setup-daytona-secrets-volume.sh <local-env-file> <name>.env
```
Examples:
```bash
bash .devcontainer/setup-daytona-secrets-volume.sh .newtoken openai.env
bash .devcontainer/setup-daytona-secrets-volume.sh .anthropic anthropic.env
bash .devcontainer/setup-daytona-secrets-volume.sh .google google.env
```
The destination must be a simple `.env` filename such as `openai.env`. The
script copies the file without printing secret values and sets restrictive
permissions. Do not pass secrets as command-line arguments; put them in a local
env file and pass only the filename.
## Expected Env File Shape
Use normal shell env format:
```bash
OPENAI_API_KEY=...
ANTHROPIC_API_KEY=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
```
Only include variables needed by evals. Keep files small and purpose-specific.
## Reload Existing Sandbox
If the sandbox is already running, restart Electron so it reloads `/daytona-secrets/*.env`:
```bash
daytona exec "$SANDBOX" -- "bash -lc 'pkill -f electron || true; pkill -f electron-dev || true; pkill -f opencode || true'"
sleep 3
daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && bash /opt/openwork-daytona/start-daytona-electron.sh --detach'"
```
Do not chain the kill and restart in one `daytona exec` command. The `pkill`
pattern can terminate the exec wrapper itself.
## Verify Without Printing Secrets
Check only filenames or whether expected variables are present:
```bash
daytona exec "$SANDBOX" -- 'ls -la /daytona-secrets'
daytona exec "$SANDBOX" -- "bash -lc 'set -a; source /daytona-secrets/openai.env; test -n \"${OPENAI_API_KEY:-}\"'"
```
Never run commands that print token values.
## Common Gotchas
- Updating the volume does not update a running Electron process. Restart
Electron so `/daytona-secrets/*.env` is sourced again.
- Server sandboxes do not automatically use Electron provider secrets unless the
server helper explicitly mounts and sources them.
- `test -n "$OPENAI_API_KEY"` is safe; `env`, `printenv`, or `cat` is not.
- Secrets in this volume are for evals only. Do not copy them into repo files or
workspace config unless the flow is explicitly testing UI-based provider save.
@@ -0,0 +1,155 @@
---
name: daytona-seeded-cloud-demo
description: Daytona seeded cloud demo, demo credentials, Acme Robotics seed. Use when the user asks to spin up, keep running, seed, or prepare an OpenWork Cloud/Den Daytona demo instance.
---
# Daytona Seeded Cloud Demo
Use this skill to quickly create a persistent-enough OpenWork Cloud/Den Daytona server demo, seed it with Acme Robotics demo data, validate the login, and return copy-pasteable demo details.
## Goal
Start a server-only Daytona sandbox for demos, seed the Den database, keep the sandbox warm, and return URLs plus credentials the user can share.
## Fast Path
Run from the repo root. Prefer a stable sandbox name when the user wants to hand this to someone else:
```bash
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
SANDBOX="openwork-cloud-demo-seeded"
bash .devcontainer/test-server-on-daytona.sh "$BRANCH" --name "$SANDBOX"
```
If the sandbox name already exists, choose a dated suffix instead, for example:
```bash
SANDBOX="openwork-cloud-demo-seeded-$(date +%Y%m%d-%H%M)"
bash .devcontainer/test-server-on-daytona.sh "$BRANCH" --name "$SANDBOX"
```
The helper prints these values. Capture them exactly:
```bash
DEN_WEB_URL="<printed Den Web URL>"
DEN_API_URL="<printed Den API URL>"
```
## Seed Demo Data
Seed the sandbox after the Den stack is healthy. The seed uses the same encryption and auth secrets as `.devcontainer/start-daytona-server.sh`.
```bash
daytona exec "$SANDBOX" -- 'bash -lc '\''cd /workspace && pnpm --filter @openwork/email build && cd /workspace/ee/apps/den-api && OPENWORK_DEV_MODE=1 DATABASE_URL=mysql://root:password@127.0.0.1:3306/openwork_den DEN_DB_ENCRYPTION_KEY=daytona-den-db-encryption-key-please-change-1234567890 BETTER_AUTH_SECRET=daytona-den-auth-secret-please-change-1234567890 BETTER_AUTH_URL="'"$DEN_WEB_URL"'" pnpm exec tsx scripts/seed-demo-org.ts --reset'\'''
```
Expected seeded credentials:
```text
Email: alex@acme.test
Password: OpenWorkDemo123!
```
Expected seed summary:
```text
Org: Acme Robotics
Org slug: acme-robotics-demo
Owner: Alex Chen
Members: 17
Teams: 12
Pending invites: 3
Marketplace plugins: 14
Config objects: 71
```
## Keep It Running
The server helper creates the sandbox with Daytona auto-stop enabled. If the CLI does not support changing auto-stop after creation, start a local keepalive loop that pings Den API every 30 minutes:
```bash
KEEPALIVE_DIR="${TMPDIR:-/tmp}/opencode"
mkdir -p "$KEEPALIVE_DIR"
KEEPALIVE_LOG="$KEEPALIVE_DIR/${SANDBOX}-keepalive.log"
nohup sh -c 'while true; do date; daytona exec '"$SANDBOX"' -- "bash -lc '\''curl -fsS http://127.0.0.1:8788/health >/dev/null'\''"; sleep 1800; done' >"$KEEPALIVE_LOG" 2>&1 &
```
Then confirm it is running:
```bash
ps -axo pid,command | rg "$SANDBOX.*8788/health|${SANDBOX}-keepalive"
```
Stop the keepalive later with the printed PID if the demo is no longer needed.
## Validate
Validate public health through both routes:
```bash
curl -fsS "$DEN_API_URL/health"
curl -fsS "$DEN_WEB_URL/api/den/health"
```
Validate the demo account through Den Web auth. This is the most relevant browser-facing path:
```bash
curl -fsS -X POST "$DEN_WEB_URL/api/auth/sign-in/email" \
-H 'Content-Type: application/json' \
--data '{"email":"alex@acme.test","password":"OpenWorkDemo123!"}'
```
Also validate direct Den API auth if the user will connect desktop or debug handoff:
```bash
curl -fsS -X POST "$DEN_API_URL/api/auth/sign-in/email" \
-H 'Content-Type: application/json' \
--data '{"email":"alex@acme.test","password":"OpenWorkDemo123!"}'
```
Both auth calls should return `redirect: false`, a `token`, and user `Alex Chen`.
## Final Response Template
Return concise demo details in this order:
```markdown
Daytona cloud demo is running and seeded.
**Demo Access**
- Den Web: <DEN_WEB_URL>
- Den API: <DEN_API_URL>
- Sandbox: `<SANDBOX>`
- Sandbox ID: `<id from daytona info>`
**Credentials**
- Email: `alex@acme.test`
- Password: `OpenWorkDemo123!`
**Seeded Data**
- Org: `Acme Robotics`
- Org slug: `acme-robotics-demo`
- Owner: `Alex Chen`
- Members: `17`
- Teams: `12`
- Pending invites: `3`
- Marketplace plugins: `14`
- Config objects: `71`
**Validation**
- Public Den Web health passed.
- Public Den API health passed.
- Seeded login passed through Den Web and Den API auth routes.
- Keepalive loop is running locally and pings the sandbox every 30 minutes.
```
## Troubleshooting
If older docs suggest running the Electron helper in a server-only mode, do not
use that path unless this checkout supports it. Use
`.devcontainer/test-server-on-daytona.sh` for server-only cloud demos.
If sign-in returns `403` for email verification, Den API is not running with `OPENWORK_DEV_MODE=1` or did not restart after env changes. Restart the Den stack and rerun the seed.
If Den Web health passes but auth through Den Web fails while direct Den API auth passes, report that distinction and debug the Den Web proxy separately. Do not claim the browser-facing demo path passed from direct API auth alone.
@@ -0,0 +1,349 @@
---
name: daytona-windows-cert
description: "test on Windows, enterprise CA, corporate certificate, GPO cert, TLS fetch failed, Windows sandbox, daytona windows, self-hosted cert. Use when validating OpenWork Windows enterprise TLS/OS-trust fixes in a Daytona Windows sandbox."
---
# Skill: Daytona Windows Enterprise Certificate Test
Run the verified Windows repro for OpenWork enterprise TLS behavior: install a
fake corporate CA into the Windows machine store, serve healthy and broken HTTPS
control planes, install a Windows build, and prove the desktop app and spawned
runtimes use the operating system trust path.
Use this as the Windows companion to `daytona-electron-test`. For executable
end-to-end or PR verdict evidence, use an app-driving `.e2e.test.ts` test with
`@openwork/testkit`; custom VNC screenshots are supplementary. Reuse the repo
support assets instead of copying their logic: `scripts/support/setup-openwork-tls-repro.ps1`,
`scripts/support/openwork-doctor.ps1`, and `docs/support/enterprise-network-doctor.md`.
## When to use
- User says "test on Windows", "Windows sandbox", or "daytona windows".
- User is validating an enterprise CA, corporate certificate, GPO cert, or
self-hosted cert path on Windows.
- User reports `TLS fetch failed`, `fetch failed`, or a certificate-specific
failure when connecting OpenWork to a self-hosted control plane.
- User needs to prove the Windows app uses OS trust and spawned runtimes receive
the OS trust bundle via `NODE_EXTRA_CA_CERTS`.
## Prereqs
- Daytona CLI must be at least the API version. The verified CLI was v0.194:
```bash
brew upgrade daytonaio/cli/daytona
brew link --overwrite daytona
daytona version
```
- `gh` must be authenticated to `different-ai/openwork` and able to create/delete
temporary public prereleases.
- Have a Windows OpenWork build or installer ready. Keep secrets and customer
materials out of the temporary release asset.
## 1. Create the Windows sandbox
Windows sandboxes are VM-only and are created from Daytona's prebuilt `windows`
snapshot. Available classes are `windows-small` (1 vCPU / 4 GB),
`windows-medium` (2 vCPU / 8 GB), and `windows-large` (4 vCPU / 16 GB). The
verified path used `windows-medium`:
```bash
daytona create --snapshot windows-medium
```
The command prints a sandbox ID and a web terminal URL. Save the ID once:
```bash
SANDBOX_ID="<SANDBOX_ID>"
```
Windows sandboxes may auto-stop. Restart the sandbox before continuing:
```bash
daytona sandbox start <ID>
```
Use the saved shell variable for later commands:
```bash
daytona sandbox start "$SANDBOX_ID"
```
## 2. exec vs VNC (the session-0 trap)
**Important:** `daytona ssh <ID>` is interactive-only and fails from scripts on
the host-key prompt. Use this shape for setup commands instead:
```bash
daytona exec <ID> -- <cmd>
```
For example:
```bash
daytona exec "$SANDBOX_ID" -- whoami
```
`daytona exec` runs as `nt authority\system` in Windows session 0. That is useful
for admin setup, but it **cannot see the interactive VNC user's app UI**, and
`$env:APPDATA` resolves to the SYSTEM profile, not `C:\Users\Administrator`.
Do not inspect app UI state, userData, or installed app settings through SYSTEM
profile paths.
Human GUI access is: Daytona Dashboard -> sandbox -> ⋮ menu -> **VNC** ->
Connect. Use `exec` for setup and logs; use VNC to drive the installed OpenWork
app and observe the user-visible result.
## 3. Get the app build in
For large Windows builds, zip the build, attach it to a temporary **public
prerelease**, and download it inside the VM with the Windows-bundled `curl.exe`
and `tar`. The `curl.exe` 8.x and `tar` binaries ship in the Windows image.
From the repo root on the host, stage a zip that expands under `C:\ow`. Include
the app build plus the support scripts from this repo so the VM reuses the
checked-in harness:
```bash
TAG="openwork-win-cert-repro-$(date +%Y%m%d%H%M%S)"
ZIP="/tmp/${TAG}.zip"
# Put your Windows app build under /tmp/openwork-win-cert-upload/openwork/app
# and include scripts/support/setup-openwork-tls-repro.ps1 plus
# scripts/support/openwork-doctor.ps1 under openwork/scripts/support/.
# Include .opencode/skills/daytona-windows-cert/scripts/ca-probe.js as
# openwork/ca-probe.js.
ditto -c -k --keepParent /tmp/openwork-win-cert-upload/openwork "$ZIP"
gh release create "$TAG" "$ZIP" --repo different-ai/openwork --prerelease
```
The release command shape from the verified session was:
```bash
gh release create <tag> <zip> --repo different-ai/openwork --prerelease
```
Download and extract inside Windows:
```bash
DOWNLOAD_URL="https://github.com/different-ai/openwork/releases/download/${TAG}/$(basename "$ZIP")"
daytona exec "$SANDBOX_ID" -- cmd /c 'mkdir C:\ow 2>NUL'
daytona exec "$SANDBOX_ID" -- cmd /c "curl.exe -L -o C:\ow\app.zip $DOWNLOAD_URL"
daytona exec "$SANDBOX_ID" -- cmd /c 'tar -xf C:\ow\app.zip -C C:\ow'
```
The Windows download/extract shape from the verified session was:
```bash
daytona exec "$SANDBOX_ID" -- cmd /c 'curl.exe -L -o C:\ow\app.zip <release-download-url>'
daytona exec "$SANDBOX_ID" -- cmd /c 'tar -xf C:\ow\app.zip -C C:\ow'
```
If the zip only contains the app, fetch the support scripts from the same branch
instead of rewriting them:
```bash
daytona exec "$SANDBOX_ID" -- cmd /c 'mkdir C:\ow\openwork\scripts\support 2>NUL'
daytona exec "$SANDBOX_ID" -- cmd /c 'curl.exe -L -o C:\ow\openwork\scripts\support\setup-openwork-tls-repro.ps1 https://raw.githubusercontent.com/different-ai/openwork/dev/scripts/support/setup-openwork-tls-repro.ps1'
daytona exec "$SANDBOX_ID" -- cmd /c 'curl.exe -L -o C:\ow\openwork\scripts\support\openwork-doctor.ps1 https://raw.githubusercontent.com/different-ai/openwork/dev/scripts/support/openwork-doctor.ps1'
```
## 4. Stand up the enterprise-TLS repro
`scripts/support/setup-openwork-tls-repro.ps1` creates a fake corporate root and
intermediate, trusts the root in `Cert:\LocalMachine\Root`, maps
`poc.openwork.test` to localhost, and serves:
- `https://poc.openwork.test:8443` — healthy chain.
- `https://poc.openwork.test:9443` — broken chain with the intermediate removed.
Do not run the listeners only inside a one-off `daytona exec`; the PowerShell
listeners die when that exec session closes. Persist them with a scheduled task
that runs as SYSTEM and keeps the session alive:
```bash
ENCODED=$(python3 - <<'PY'
import base64
script = r'''
$ErrorActionPreference = "Stop"
$repo = "C:\ow\openwork"
$cmdPath = "C:\ow\start-openwork-tls-repro.cmd"
$cmd = @"
@echo off
cd /d "$repo"
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\support\setup-openwork-tls-repro.ps1
powershell -NoProfile -ExecutionPolicy Bypass -Command "while (`$true) { Start-Sleep -Seconds 3600 }"
"@
Set-Content -LiteralPath $cmdPath -Value $cmd -Encoding ASCII
schtasks /create /f /sc onstart /ru SYSTEM /tn OpenWorkTlsRepro /tr $cmdPath
schtasks /run /tn OpenWorkTlsRepro
'''
print(base64.b64encode(script.encode("utf-16le")).decode())
PY
)
daytona exec "$SANDBOX_ID" -- powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$ENCODED"
```
Verify the healthy listener is up:
```bash
daytona exec "$SANDBOX_ID" -- cmd /c 'netstat -ano | findstr :8443'
```
Optional diagnostic output from the checked-in doctor script:
```bash
daytona exec "$SANDBOX_ID" -- powershell -NoProfile -ExecutionPolicy Bypass -File 'C:\ow\openwork\scripts\support\openwork-doctor.ps1' -WebUrl https://poc.openwork.test:8443 -ApiUrl https://poc.openwork.test:9443 -ExpectedIssuerMatch "OpenWork TLS Repro"
```
## 5. Verify the fix
### Probe Electron's view of the Windows machine store
Copy `.opencode/skills/daytona-windows-cert/scripts/ca-probe.js` into the VM as
`C:\ow\ca-probe.js`. Its contents are intentionally small and reusable:
```bash
daytona exec "$SANDBOX_ID" -- cmd /c 'copy C:\ow\openwork\ca-probe.js C:\ow\ca-probe.js'
```
```js
const { X509Certificate } = require("node:crypto");
const tls = require("node:tls");
const needle = (process.env.OPENWORK_TLS_REPRO_CA_MATCH || "OpenWork TLS Repro").toLowerCase();
function countMatchingSubjects(certificates) {
let count = 0;
for (const pem of certificates) {
try {
const certificate = new X509Certificate(pem);
if (certificate.subject.toLowerCase().includes(needle)) count += 1;
} catch {
// Ignore entries that are not parseable X.509 certificates.
}
}
return count;
}
const system = tls.getCACertificates("system");
const bundled = tls.getCACertificates("default");
const result = {
systemCount: system.length,
reproInSystem: countMatchingSubjects(system),
defaultCount: bundled.length,
reproInDefault: countMatchingSubjects(bundled),
};
console.log(JSON.stringify(result, null, 2));
if (result.reproInSystem === 0) {
process.exitCode = 1;
}
```
Run Electron in node mode. Adjust the executable path for your unpacked build or
installed app:
```bash
daytona exec "$SANDBOX_ID" -- cmd /c 'set ELECTRON_RUN_AS_NODE=1 && "C:\ow\openwork\app\OpenWork.exe" C:\ow\ca-probe.js'
```
The verified result was:
```json
{"systemCount":42,"reproInSystem":6,"defaultCount":150,"reproInDefault":0}
```
This is the crucial #2562 verification for the GPO/enterprise-CA case: the
Windows system store (`LocalMachine\Root`) contains the repro corporate CA, while
the bundled Mozilla roots do not.
### Verify the installed app via VNC
Drive the installed OpenWork Windows app through VNC, not `daytona exec`.
1. Open Daytona Dashboard -> sandbox -> ⋮ menu -> **VNC** -> Connect.
2. Launch or install OpenWork as the interactive user.
3. Point the self-hosted/control-plane URL at `https://poc.openwork.test:8443`.
The request should succeed.
4. Repeat against `https://poc.openwork.test:9443`. The request should fail with
a named certificate/chain error, not a vague `fetch failed` banner.
Use `daytona-electron-test` for normal Electron driving patterns. If this is PR
evidence, translate the user-visible path and TLS assertions into an
`evals/specs/<slug>.e2e.test.ts` test via `write-a-spec`, run it with
`run-tests`, and use its ambient test evidence for the verdict. The VNC checklist and
captures above remain supplementary setup and debugging evidence.
### Verify the app's generated CA bundle path
The real Windows userData folder is:
```text
C:\Users\<User>\AppData\Roaming\com.differentai.openwork
```
It is **not** `C:\Users\<User>\AppData\Roaming\OpenWork`. Because `exec` runs as
SYSTEM, inspect the interactive user path explicitly:
```bash
daytona exec "$SANDBOX_ID" -- cmd /c 'dir "C:\Users\Administrator\AppData\Roaming\com.differentai.openwork\system-ca-bundle.pem"'
daytona exec "$SANDBOX_ID" -- cmd /c 'findstr /c:"OpenWork TLS Repro" "C:\Users\Administrator\AppData\Roaming\com.differentai.openwork\system-ca-bundle.pem"'
```
Known gotcha: `system-ca-bundle.pem` is written once at first launch and then
memoized. If a CA is added **after** first launch, restart the app before
expecting it to appear. On real fleets the GPO CA is present at boot, so this
usually does not bite customers.
## Shell/quoting gotchas
- zsh strips backslashes in unquoted Windows paths. Quote the whole `cmd /c`
payload:
```bash
daytona exec "$SANDBOX_ID" -- cmd /c 'dir "C:\Users\Administrator\AppData\Roaming\com.differentai.openwork"'
```
- Pipes and `|` inside `daytona exec ... -- powershell -Command '...'` can be
eaten by the intermediate `cmd` layer. Use `powershell -EncodedCommand` with a
base64 UTF-16LE payload for anything with pipes or nested quotes:
```bash
ENCODED=$(python3 - <<'PY'
import base64
command = r'Get-ChildItem Cert:\LocalMachine\Root | Where-Object Subject -like "*OpenWork TLS Repro*"'
print(base64.b64encode(command.encode("utf-16le")).decode())
PY
)
daytona exec "$SANDBOX_ID" -- powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$ENCODED"
```
- `%ERRORLEVEL%` expands at parse time in `cmd` one-liners. Prefer PowerShell and
`$LASTEXITCODE` when you need to propagate exit codes:
```bash
daytona exec "$SANDBOX_ID" -- powershell -NoProfile -ExecutionPolicy Bypass -Command 'curl.exe --version; exit $LASTEXITCODE'
```
- `cmd /c` plus `timeout` fails with "input redirection is not supported" under
`daytona exec`. Use `ping -n` for sleeps:
```bash
daytona exec "$SANDBOX_ID" -- cmd /c 'ping -n 6 127.0.0.1 >NUL'
```
## Cleanup
Stop the scheduled repro, remove the certificates/hosts/bindings through the
checked-in setup script, delete the sandbox, and delete the temporary prerelease:
```bash
daytona exec "$SANDBOX_ID" -- cmd /c 'schtasks /end /tn OpenWorkTlsRepro'
# Core repro cleanup shape: setup-openwork-tls-repro.ps1 -Cleanup
daytona exec "$SANDBOX_ID" -- powershell -NoProfile -ExecutionPolicy Bypass -File 'C:\ow\openwork\scripts\support\setup-openwork-tls-repro.ps1' -Cleanup
daytona sandbox delete <ID>
gh release delete <tag> --yes
```
@@ -0,0 +1,33 @@
const { X509Certificate } = require("node:crypto");
const tls = require("node:tls");
const needle = (process.env.OPENWORK_TLS_REPRO_CA_MATCH || "OpenWork TLS Repro").toLowerCase();
function countMatchingSubjects(certificates) {
let count = 0;
for (const pem of certificates) {
try {
const certificate = new X509Certificate(pem);
if (certificate.subject.toLowerCase().includes(needle)) count += 1;
} catch {
// Ignore entries that are not parseable X.509 certificates.
}
}
return count;
}
const system = tls.getCACertificates("system");
const bundled = tls.getCACertificates("default");
const result = {
systemCount: system.length,
reproInSystem: countMatchingSubjects(system),
defaultCount: bundled.length,
reproInDefault: countMatchingSubjects(bundled),
};
console.log(JSON.stringify(result, null, 2));
if (result.reproInSystem === 0) {
process.exitCode = 1;
}
@@ -0,0 +1,56 @@
---
name: diagnose-a-red-run
description: Test is red, typecheck failed, CI job failed, flaky, timed out, was this already broken. Use to classify any failing check before changing code or calling it pre-existing.
---
# Skill: Diagnose a Red Run
## Capture the branch failure
- Record the exact command, commit SHA, exit code, and passed/failed/skipped
counts. Quote the first actionable failure; do not summarize it away.
- Classify the check: testkit spec, unit suite, typecheck, build, lint, or CI job.
## Run a clean control
Never call a failure pre-existing from memory or from a modified checkout.
```bash
git fetch origin dev
git worktree add /tmp/openwork-dev-control --detach origin/dev
# In that clean worktree, prepare the same prerequisites and run the exact command.
git worktree remove /tmp/openwork-dev-control
```
- Keep tool versions, environment, services, flags, and secrets equivalent.
- Quote the control command, `origin/dev` SHA, exit code, counts, and matching
failure text.
- Classify as pre-existing only when the clean control demonstrates the same
failure. Otherwise classify it as introduced, environment-specific, or
unresolved. A non-reproducing control is not proof of flakiness; repeat both
sides before using that label.
## Classify by signature
- Unit/type/CI-only failure: inspect the first failing job and run its exact
command locally before broadening scope.
- Vision disagreement on identical pixels: claim wording or product ambiguity.
Make the product unambiguous or make the claim factual.
- Timeout with an On-screen dump: read it. It names the state; for example,
`/session` can expose a steer-back race.
- Auth `403 INVALID_ORIGIN`: inspect Den trusted origins.
- Authorize URL points at the real provider: Den booted without mock env.
- Teardown `403 fresh_auth_required`: the session aged; a `freshSession` retry
exists.
## Environment forensics
- Kill by port, not process name. `tsx watch` can orphan its Node child, which
keeps the port while `/health` lies.
- `EADDRINUSE` in logs while health returns 200 means a zombie survived.
- Electron zombies can respawn from a root process; kill the process group.
- Leaked state pollutes organizations; delete leftover connectors between runs.
For a testkit failure, read the test run's last unvalidated artifacts before touching
code. Publish useful red test evidence with `publish-evidence`; it remains human audit,
not a passing verdict.
+48
View File
@@ -0,0 +1,48 @@
---
name: get-env-var
description: "get an env var, fetch a secret, missing env var, missing token/API key, load secrets from Infisical, infisical. Fetch secrets from the team's Infisical workspace into the shell environment so subsequent commands can use them."
---
# Skill: get-env-var
Fetch a secret from the team's Infisical workspace into the current shell so the next command can use it.
## When to use
- A command or script needs an env var that is not set, such as `BLOB_READ_WRITE_TOKEN`.
- A token, API key, or other secret is missing from the environment.
- The user asks to load secrets from Infisical.
## Setup (once per machine)
- Install the CLI on macOS: `brew install infisical/get-cli/infisical`.
- Check auth with `infisical user get`; if it fails, run `infisical login` and complete the browser flow.
- For CI or other non-interactive runs, set `INFISICAL_TOKEN` from a machine identity; the CLI skips login when it is present.
- This repo is already project-linked via tracked `.infisical.json` (`workspaceId: "e9f4542a-8714-46c3-a8fd-99d8cb370aeb"`, empty `defaultEnvironment`). From the repo root, `infisical` defaults to the `dev` environment slug when `--env` is omitted.
## Fetch one secret into the environment
Run from the repo root:
```bash
export NAME="$(infisical secrets get NAME --plain --silent)"
```
- Replace `NAME` with the secret name.
- Add `--env <slug>` for a non-default environment; this repo defaults to `dev`.
- Add `--path /some/folder` when secrets are organized in folders.
## Inject everything into a command
Run the command through Infisical so all project secrets are available only to that process:
```bash
infisical run -- <command>
```
## Rules
- Never echo, print, or otherwise log secret values.
- Never write secrets to files, logs, commit messages, PR bodies, or comments.
- Only use `--plain` inside command substitution, as in `export NAME="$(...)"`.
- If a secret does not exist, STOP and tell the user exactly which secret name and environment to add in Infisical; do not invent values.
+129
View File
@@ -0,0 +1,129 @@
---
name: openwork-models
description: Manage OpenWork inference model aliases, openwork model overlays, discounts, validation, and automated base model refreshes from models.dev. Use when adding, removing, discounting, auditing, or updating OpenWork models, including requests like "update the models" that should trigger the GitHub update-models workflow and report when its PR merges.
---
# OpenWork Models
Use this skill for OpenWork inference model changes. The source of truth for
available upstream models is:
- `ee/apps/inference/src/models/base.json`
- provider key: `openrouter`
- model map: `openrouter.models`
The editable OpenWork model list is:
- `ee/apps/inference/src/models/openwork-models.json`
Managed file:
- `packages/types/src/den/inference.ts`
`ee/apps/inference/scripts/build-models.mjs` reads `openwork-models.json` and
generates the OpenWork provider overlay in memory. It selects the API URL from
`OPENWORK_DEV_MODE`: dev uses `http://127.0.0.1:8791/api/v1`, otherwise prod
uses `https://inference.openworklabs.com/api/v1`.
Do not inspect the full `base.json` in chat. Use the scripts so the large source
model body stays out of context.
## Scripts
Extract/search source models into a temp file:
```bash
node .opencode/skills/openwork-models/scripts/extract-source-models.mjs
node .opencode/skills/openwork-models/scripts/extract-source-models.mjs --query "zai 5.1"
```
Manage OpenWork models:
```bash
node .opencode/skills/openwork-models/scripts/openwork-models.mjs search "zai 5.1"
node .opencode/skills/openwork-models/scripts/openwork-models.mjs add "z-ai/glm-5.1"
node .opencode/skills/openwork-models/scripts/openwork-models.mjs remove "z-ai/glm-5.1"
node .opencode/skills/openwork-models/scripts/openwork-models.mjs discount 0.1 "z-ai/glm-5.1"
node .opencode/skills/openwork-models/scripts/openwork-models.mjs sync
node .opencode/skills/openwork-models/scripts/openwork-models.mjs validate
```
Trigger the remote base model refresh workflow:
```bash
node .opencode/skills/openwork-models/scripts/trigger-update-workflow.mjs
node .opencode/skills/openwork-models/scripts/trigger-update-workflow.mjs --base dev --ref dev
```
## Update Models Workflow
When the user asks to update/refresh/sync the models from models.dev, do not edit
`base.json` manually. Trigger `.github/workflows/update-models.yml` and wait for
the result:
```bash
node .opencode/skills/openwork-models/scripts/trigger-update-workflow.mjs
```
The script dispatches the manual workflow, watches the run, finds the automation
PR, waits until auto-merge completes, and prints the merged PR URL. If the
workflow finds no model changes, report that no PR was created and include the
workflow run URL. If the workflow or PR fails/closes unmerged, report that
failure with the run/PR URL.
## Add Workflow
1. Run `extract-source-models.mjs --query "<user text>"` or
`openwork-models.mjs search "<user text>"`.
2. If there is no good match, ask the user for clarification.
3. If there are multiple plausible matches, list the matching IDs and ask which
one(s) to add.
4. Once exact IDs are known, run `openwork-models.mjs add "<id>"` for each ID.
The script copies the full model block from `openrouter.models`, adds it to
`openwork-models.json`, syncs aliases, and validates.
New aliases use:
```ts
"model/id": {
upstreamModel: "model/id",
displayName: "OpenWork: " + model.name,
enabled: true,
usageFactor: 1,
}
```
Preserve existing `usageFactor` values when syncing. New models default to `1`.
## Remove Workflow
1. Resolve exact IDs with `search` if needed.
2. Run `openwork-models.mjs remove "<id>"`.
3. The script removes the model from `openwork-models.json`, removes the alias
by regenerating the alias map from the model list, and validates.
## Discount Workflow
A usage factor is the charged fraction of normal price:
- `1` means 100% price.
- `0.1` means 10% price, a 90% discount.
Always clarify exact model IDs before changing discounts, especially if the user
provides a partial string or asks for multiple models. Then run:
```bash
node .opencode/skills/openwork-models/scripts/openwork-models.mjs discount <factor> "<id>" ["<id2>"]
```
## Validation
Before finishing, run:
```bash
node .opencode/skills/openwork-models/scripts/openwork-models.mjs validate
node ee/apps/inference/scripts/build-models.mjs
```
Validation checks JSON validity, OpenWork model ID consistency, and alias
coverage in `INFERENCE_MODEL_ALIASES`.
@@ -0,0 +1,64 @@
#!/usr/bin/env node
import { writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const basePath = path.resolve("ee/apps/inference/src/models/base.json")
function readFlag(name) {
const index = process.argv.indexOf(name)
if (index === -1) return null
return process.argv[index + 1] ?? null
}
function normalize(value) {
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "")
}
function terms(value) {
return String(value)
.toLowerCase()
.split(/[^a-z0-9.]+/)
.map((term) => normalize(term))
.filter(Boolean)
}
function scoreModel(query, id, model) {
const compactQuery = normalize(query)
const haystack = normalize(`${id} ${model.name ?? ""} ${model.family ?? ""}`)
const queryTerms = terms(query)
if (!compactQuery) return 0
if (id === query || model.id === query) return 1_000
if (haystack.includes(compactQuery)) return 200 + compactQuery.length
return queryTerms.reduce((score, term) => score + (haystack.includes(term) ? 20 + term.length : 0), 0)
}
const query = readFlag("--query")
const outPath = readFlag("--out") ?? path.join(os.tmpdir(), `openwork-openrouter-models-${Date.now()}.json`)
const base = JSON.parse(await import("node:fs/promises").then((fs) => fs.readFile(basePath, "utf8")))
const sourceModels = base?.openrouter?.models
if (!sourceModels || typeof sourceModels !== "object" || Array.isArray(sourceModels)) {
throw new Error(`${basePath} did not contain openrouter.models`)
}
const entries = Object.entries(sourceModels)
const selectedEntries = query
? entries
.map(([id, model]) => ({ id, model, score: scoreModel(query, id, model) }))
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
.slice(0, 25)
.map((entry) => [entry.id, entry.model])
: entries
const models = Object.fromEntries(selectedEntries)
await writeFile(outPath, `${JSON.stringify(models, null, 2)}\n`)
console.log(`wrote ${selectedEntries.length} model(s) to ${outPath}`)
if (query) {
for (const [id, model] of selectedEntries.slice(0, 10)) {
console.log(`${id}\t${model.name ?? ""}`)
}
}
@@ -0,0 +1,286 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises"
import path from "node:path"
const paths = {
source: path.resolve("ee/apps/inference/src/models/base.json"),
models: path.resolve("ee/apps/inference/src/models/openwork-models.json"),
aliases: path.resolve("packages/types/src/den/inference.ts"),
}
function usage() {
console.log(`Usage:
openwork-models.mjs search <query>
openwork-models.mjs add <query-or-id>
openwork-models.mjs remove <id> [<id2>]
openwork-models.mjs discount <usageFactor> <id> [<id2>]
openwork-models.mjs sync
openwork-models.mjs validate`)
}
async function readJson(filePath) {
return JSON.parse(await readFile(filePath, "utf8"))
}
async function writeJson(filePath, value) {
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
function isRecord(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
function normalize(value) {
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "")
}
function terms(value) {
return String(value)
.toLowerCase()
.split(/[^a-z0-9.]+/)
.map((term) => normalize(term))
.filter(Boolean)
}
function modelText(id, model) {
return `${id} ${model.id ?? ""} ${model.name ?? ""} ${model.family ?? ""}`
}
function scoreModel(query, id, model) {
const compactQuery = normalize(query)
const compactText = normalize(modelText(id, model))
if (!compactQuery) return 0
if (id === query || model.id === query) return 1_000
if (compactText.includes(compactQuery)) return 200 + compactQuery.length
const queryTerms = terms(query)
let score = 0
for (const term of queryTerms) {
if (compactText.includes(term)) score += 20 + term.length
}
return score
}
function displayMatches(matches) {
for (const match of matches.slice(0, 12)) {
console.log(`${match.id}\t${match.model.name ?? ""}`)
}
}
async function readSourceModels() {
const base = await readJson(paths.source)
const sourceModels = base?.openrouter?.models
if (!isRecord(sourceModels)) {
throw new Error(`${paths.source} did not contain openrouter.models`)
}
return sourceModels
}
async function readOpenworkModels() {
const models = await readJson(paths.models)
if (!isRecord(models)) {
throw new Error(`${paths.models} must be a JSON object keyed by model id`)
}
return models
}
function findMatches(sourceModels, query) {
return Object.entries(sourceModels)
.map(([id, model]) => ({ id, model, score: scoreModel(query, id, model) }))
.filter((match) => match.score > 0)
.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))
}
function resolveSingleSourceModel(sourceModels, query) {
if (sourceModels[query]) {
return { id: query, model: sourceModels[query] }
}
const matches = findMatches(sourceModels, query)
if (matches.length === 0) {
console.error(`No source model matched "${query}".`)
process.exitCode = 2
return null
}
const [first, second] = matches
if (first && first.score >= 40 && (!second || first.score - second.score >= 10)) {
return { id: first.id, model: first.model }
}
console.error(`Multiple source models matched "${query}". Please clarify with exact id:`)
displayMatches(matches)
process.exitCode = 2
return null
}
function resolveExactOpenworkIds(openworkModels, ids) {
const resolved = []
for (const id of ids) {
if (openworkModels[id]) {
resolved.push(id)
continue
}
const matches = findMatches(openworkModels, id)
console.error(`OpenWork model "${id}" was not exact. Please clarify with exact id:`)
displayMatches(matches)
process.exitCode = 2
return null
}
return resolved
}
function escapeTsString(value) {
return JSON.stringify(value)
}
async function readUsageFactors() {
const source = await readFile(paths.aliases, "utf8")
const factors = new Map()
const pattern = /"([^"]+)":\s*\{[\s\S]*?usageFactor:\s*([0-9.]+)/g
for (const match of source.matchAll(pattern)) {
factors.set(match[1], Number(match[2]))
}
return factors
}
async function syncAliases(models, usageOverrides = new Map()) {
const source = await readFile(paths.aliases, "utf8")
const existingFactors = await readUsageFactors()
const entries = Object.entries(models).map(([id, model]) => {
const usageFactor = usageOverrides.get(id) ?? existingFactors.get(id) ?? 1
const displayName = `OpenWork: ${model.name}`
return ` ${escapeTsString(id)}: {
upstreamModel: ${escapeTsString(id)},
displayName: ${escapeTsString(displayName)},
enabled: true,
usageFactor: ${usageFactor},
},`
})
const replacement = `export const INFERENCE_MODEL_ALIASES = {\n${entries.join("\n")}\n} as const;`
const aliasesPattern = /export const INFERENCE_MODEL_ALIASES = \{[\s\S]*?\n\} as const;/
if (!aliasesPattern.test(source)) {
throw new Error(`Could not find INFERENCE_MODEL_ALIASES in ${paths.aliases}`)
}
const nextSource = source.replace(aliasesPattern, replacement)
await writeFile(paths.aliases, nextSource)
}
async function syncAll(models, usageOverrides = new Map()) {
await writeJson(paths.models, models)
await syncAliases(models, usageOverrides)
await validate()
}
async function validate() {
const models = await readOpenworkModels()
for (const [id, model] of Object.entries(models)) {
if (!isRecord(model)) throw new Error(`${id} must be an object`)
if (model.id !== id) throw new Error(`${id} must have matching id field`)
if (typeof model.name !== "string" || model.name.length === 0) {
throw new Error(`${id} must have a name`)
}
if (typeof model.family !== "string" || model.family.length === 0) {
throw new Error(`${id} must have a family`)
}
if (!isRecord(model.cost)) {
throw new Error(`${id} must have a cost object`)
}
}
const aliasSource = await readFile(paths.aliases, "utf8")
for (const id of Object.keys(models)) {
if (!aliasSource.includes(`upstreamModel: ${escapeTsString(id)}`)) {
throw new Error(`${id} is missing from INFERENCE_MODEL_ALIASES`)
}
}
const aliasKeys = [...aliasSource.matchAll(/^\s*"([^"]+)": \{/gm)].map((match) => match[1])
for (const aliasKey of aliasKeys) {
if (!models[aliasKey]) {
throw new Error(`${aliasKey} exists in INFERENCE_MODEL_ALIASES but not ${paths.models}`)
}
}
console.log(`validated ${Object.keys(models).length} OpenWork model(s)`)
}
async function search(query) {
const sourceModels = await readSourceModels()
const matches = findMatches(sourceModels, query)
if (matches.length === 0) {
console.log(`No source models matched "${query}".`)
return
}
displayMatches(matches)
}
async function add(query) {
const sourceModels = await readSourceModels()
const selected = resolveSingleSourceModel(sourceModels, query)
if (!selected) return
const models = await readOpenworkModels()
models[selected.id] = selected.model
await syncAll(models)
console.log(`added ${selected.id}`)
}
async function remove(ids) {
const models = await readOpenworkModels()
const resolved = resolveExactOpenworkIds(models, ids)
if (!resolved) return
for (const id of resolved) {
delete models[id]
}
await syncAll(models)
console.log(`removed ${resolved.join(", ")}`)
}
async function discount(factorText, ids) {
const factor = Number(factorText)
if (!Number.isFinite(factor) || factor <= 0) {
throw new Error("usageFactor must be a positive number")
}
const models = await readOpenworkModels()
const resolved = resolveExactOpenworkIds(models, ids)
if (!resolved) return
const currentFactors = await readUsageFactors()
for (const id of resolved) {
currentFactors.set(id, factor)
}
await syncAll(models, currentFactors)
console.log(`set usageFactor=${factor} for ${resolved.join(", ")}`)
}
const [command, ...args] = process.argv.slice(2)
if (!command) {
usage()
process.exit(1)
}
if (command === "search") {
await search(args.join(" "))
} else if (command === "add") {
await add(args.join(" "))
} else if (command === "remove") {
await remove(args)
} else if (command === "discount") {
await discount(args[0], args.slice(1))
} else if (command === "sync") {
await syncAll(await readOpenworkModels())
} else if (command === "validate") {
await validate()
} else {
usage()
process.exit(1)
}
@@ -0,0 +1,160 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process"
const workflow = "update-models.yml"
function readOption(name, fallback) {
const index = process.argv.indexOf(name)
if (index === -1) return fallback
return process.argv[index + 1] ?? fallback
}
function runGh(args, options = {}) {
const stdio = options.stdio ?? "pipe"
const result = spawnSync("gh", args, {
encoding: "utf8",
stdio,
})
if (result.status !== 0) {
const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim()
throw new Error(`gh ${args.join(" ")} failed${details ? `:\n${details}` : ""}`)
}
return typeof result.stdout === "string" ? result.stdout.trim() : ""
}
function readGhJson(args) {
const output = runGh(args)
return output ? JSON.parse(output) : null
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function parsePositiveInteger(value, name) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${name} must be a positive integer`)
}
return parsed
}
async function findRun(input) {
const startedAfterMs = input.startedAt.getTime() - 10_000
for (let attempt = 0; attempt < input.maxPolls; attempt += 1) {
const runs = readGhJson([
"run",
"list",
"--workflow",
workflow,
"--branch",
input.ref,
"--event",
"workflow_dispatch",
"--limit",
"20",
"--json",
"databaseId,status,conclusion,createdAt,url",
])
const match = runs
.filter((run) => new Date(run.createdAt).getTime() >= startedAfterMs)
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())[0]
if (match) return match
await sleep(input.intervalMs)
}
throw new Error(`Timed out waiting for ${workflow} run to appear`)
}
async function findPullRequest(branch, intervalMs) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const prs = readGhJson([
"pr",
"list",
"--state",
"all",
"--head",
branch,
"--limit",
"5",
"--json",
"number,url,state,mergedAt,title",
])
if (prs.length > 0) return prs[0]
await sleep(intervalMs)
}
return null
}
async function waitForMerge(pr, input) {
for (let attempt = 0; attempt < input.maxPolls; attempt += 1) {
const current = readGhJson([
"pr",
"view",
String(pr.number),
"--json",
"number,url,state,mergedAt,title",
])
if (current.mergedAt) return current
if (current.state === "CLOSED") {
throw new Error(`PR closed without merging: ${current.url}`)
}
console.log(`waiting for PR #${current.number} to merge: ${current.url}`)
await sleep(input.intervalMs)
}
throw new Error(`Timed out waiting for PR to merge: ${pr.url}`)
}
const base = readOption("--base", "dev")
const ref = readOption("--ref", base)
const intervalSeconds = parsePositiveInteger(readOption("--interval-seconds", "20"), "interval-seconds")
const timeoutMinutes = parsePositiveInteger(readOption("--timeout-minutes", "90"), "timeout-minutes")
const intervalMs = intervalSeconds * 1000
const maxPolls = Math.ceil((timeoutMinutes * 60) / intervalSeconds)
const startedAt = new Date()
console.log(`dispatching ${workflow} on ref ${ref} with base_branch=${base}`)
runGh(["workflow", "run", workflow, "--ref", ref, "-f", `base_branch=${base}`])
const run = await findRun({ ref, startedAt, intervalMs, maxPolls })
console.log(`watching workflow run: ${run.url}`)
runGh(["run", "watch", String(run.databaseId), "--exit-status"], { stdio: "inherit" })
const completedRun = readGhJson([
"run",
"view",
String(run.databaseId),
"--json",
"databaseId,status,conclusion,url",
])
if (completedRun.conclusion !== "success") {
throw new Error(`Workflow completed with conclusion=${completedRun.conclusion}: ${completedRun.url}`)
}
const branch = `automation/update-models-${run.databaseId}-1`
const pr = await findPullRequest(branch, intervalMs)
if (!pr) {
console.log(`workflow completed with no model changes and no PR: ${completedRun.url}`)
process.exit(0)
}
if (pr.mergedAt) {
console.log(`PR already merged: ${pr.url}`)
process.exit(0)
}
console.log(`waiting for auto-merge on PR #${pr.number}: ${pr.url}`)
const merged = await waitForMerge(pr, { intervalMs, maxPolls })
console.log(`merged PR #${merged.number}: ${merged.url}`)
+71
View File
@@ -0,0 +1,71 @@
---
name: prove-a-pr
description: Prove a PR, prepare merge verification, publish all evidence, check a stacked PR. Use when declaring a PR Passed, Incomplete, or Failed.
---
# Skill: Prove a PR
## Verify the tree that will land
- Run every check on the PR head after its final rebase or cherry-pick. Re-run
and re-publish after any history rewrite; test runs are bound to a commit SHA.
- Before merging a stacked PR, inspect
`gh pr view <n> --json baseRefName,headRefName,headRefOid`. If its base PR
merged first, the stack can merge into the feature branch instead of `dev`;
GitHub then recreates commits with new SHAs and orphans their test evidence.
- Detect stray commits with `git log --oneline <branch> ^origin/dev`. Remedy a
bad stack by cherry-picking only the intended commits onto current `dev`, then
re-run every check and re-publish every test run.
## Produce the agent-first verdict
- Use `write-a-spec` and `run-tests`. Prose, screenshots, and recordings never
decide pass/fail.
- Prefer Daytona for agent-first verification. Attempt the Daytona lane first
when its credentials and service access are available; otherwise run the same
checks locally. Missing Daytona credentials, tooling, or service access is an
expected OSS contributor fallback, not a failed check.
- Determine fallback eligibility before execution: use local only when Daytona
credentials, tooling, or service access are unavailable, or when the user
explicitly requires local.
- Once Daytona is available and selected, a runtime, test, or product failure
does not make Daytona unavailable. Diagnose and repair any red or incomplete
Daytona run in Daytona; never switch lanes to turn the verdict green.
- A local reproduction may aid diagnosis, but it is not fallback and cannot
replace the required Daytona verdict.
- Record whether each check ran on Daytona or locally. When falling back, state
the unavailable Daytona prerequisite without exposing secret values.
- Give every claim an observable assertion and visible test evidence.
- Report only `Passed`, `Incomplete`, or `Failed`. Always quote exact commands,
exit codes, and passed/failed/skipped counts.
- Call a failure pre-existing only after the same command demonstrates it in a
clean `origin/dev` worktree. Quote the control command and matching failure.
## Satisfy local fallback prerequisites
```bash
pnpm --filter @openwork/types build
pnpm --filter @openwork-ee/den-db build
pnpm --filter @openwork/email build
pnpm dev:den:mysql
```
- Local `server()` requires MySQL at `127.0.0.1:3306`; unbuilt workspace
dependencies can make den-api imports fail.
- If the checkout path contains spaces, set `OPENWORK_EVAL_SURFACES_DIR` to a
space-free path; node-gyp and electron-rebuild otherwise fail.
- Set `OPENWORK_EVAL_E2E_TESTS=1` for app-driving E2E tests.
## Publish human verification
- The orchestrator owns publishing after the verdict. After a multi-test run,
publish each head-matching test run:
```bash
pnpm evals:e2e --publish --pr <n> --test-run <dir|name>
```
- Confirm the sticky PR comment shows one section for every claimed test.
- Never use `--force` to paper over a SHA mismatch; re-run on the PR head.
Reserve it for deliberately publishing historical or red test evidence, and call
that exception out in the report and PR comment.
@@ -0,0 +1,48 @@
---
name: publish-evidence
description: Publish test evidence, publish all test runs, update PR verification, or audit red evidence. Use after @openwork/testkit runs.
---
# Skill: Publish Evidence
The orchestrator owns this human-verification step. Publishing makes the
agent-first verdict inspectable; it never decides pass/fail and never reruns a
test.
## Make every claim auditable
- Show the test name and verdict, each claim's assertion evidence, the relevant
test artifacts, the source test run, and the reproduction command.
- Require one sticky-comment section per claimed test. If a claim has no visible
test-evidence section, report the PR `Incomplete`.
- Write the `<!-- test-evidence -->` marker. The publisher recognizes old sticky
markers only to update comments created before the migration.
## Publish the PR head
After a multi-test run, publish each test run whose `gitSha` matches the PR head:
```bash
pnpm evals:e2e --publish --pr <n> --test-run <dir|name>
```
`evals:e2e --publish` judges pending visual validations in the selected test
run, then publishes it. It publishes existing `@openwork/testkit` evidence, not legacy
flows, and never reruns tests.
- Omitting `--test-run` selects the most recent test run; pass it explicitly
when several runs exist so each test's evidence is published deliberately.
- Publishing replaces the sticky comment with the selected test run. Confirm the
final comment shows the test and verdict you intend reviewers to see.
- Exit codes: `0` published, `1` failed claims published (or publish failed),
`2` pending claims still need judging (set a vision key and rerun).
## Refuse misleading evidence
- Never use `--force` to hide a SHA mismatch. Re-run the spec on the PR head.
- Use `--force` only to deliberately publish historical or red test evidence. The
output is annotated; call the exception out explicitly. Red tapes are valid
human-verification artifacts and should be published when they explain a
`Failed` or `Incomplete` verdict.
- Read `BLOB_READ_WRITE_TOKEN` from the environment or the Infisical fallback.
Without it, still post verdicts with a no-screenshots note.
+142
View File
@@ -0,0 +1,142 @@
---
name: release
description: Cut an OpenWork release, release the app, publish a new version, rerun or recover a release tag, verify release assets. Tag-driven GitHub Actions release that makes zero commits to the repo.
---
# Skill: release
Cut an OpenWork release. The "Release App" workflow
(`.github/workflows/release-macos-aarch64.yml`) builds, signs, and publishes
the desktop app assets on the GitHub release. Full runbook:
`docs/RELEASING.md`.
**Versions live in git tags only.** Every committed `package.json` holds the
permanent `0.0.0-dev` placeholder; CI stamps the tag-derived version into the
workspace at build time (`scripts/release/stamp-version.mjs`). A release makes
**zero commits to this repo** — no bump commit, no backfill PR, no packaging
PR.
A release is **done when the run is green and the GitHub release is published**
(not a draft) — never when the tag is created.
---
## Cut a release (default path)
```bash
pnpm release:cut # dispatches Release App with bump=patch
pnpm release:cut minor # or major
pnpm release:cut --version 0.19.0
pnpm release:cut:watch # same as release:cut, then tails the run
```
Equivalent by hand:
```bash
gh workflow run "Release App" --repo different-ai/openwork -f bump=patch
```
The run resolves the next version from existing `v*` tags, creates the tag on
`origin/dev` HEAD, verifies it (`scripts/release/verify-tag.mjs`: stable
format + strictly greater than every other stable tag), stamps the version
into the CI workspace, builds all 18 electron matrix legs, publishes npm +
Daytona + AUR, and flips the draft release public.
The tag ref is created via REST with the org-owned **diff-warden** app token
(a `v*` ruleset bypass actor; `WARDEN_APP_ID` + `WARDEN_PRIVATE_KEY` in the
`warden-clearance` environment). The app's tag retriggers the workflow; that duplicate run is skipped by an
actor guard. If the tag push is rejected, the run fails with instructions —
fix the ruleset bypass or fall back to a manual admin tag push.
## Tag-first (expedited, admins only)
To release a commit that is not yet reviewed onto `dev` (incident response),
push the tag manually — the tag names exactly the code that ships:
```bash
git tag vX.Y.Z <sha>
git push origin vX.Y.Z # v* ruleset grants admins bypass
```
The Expedited Release Audit workflow opens a post-hoc review issue when the
tagged commit is not on `dev`. Never push `dev` directly or bypass its branch
rules.
---
## Watch
```bash
gh run list --repo different-ai/openwork --workflow "Release App" --limit 1
gh run watch <run-id> --repo different-ai/openwork --exit-status --interval 90
```
Publishing is gated on the electron matrix, electron assets, and npm publish.
`Publish AUR` (continue-on-error) and `Build + Push Daytona Snapshot` are
**non-blocking channels**: their failures don't stop the release — rerun the
workflow with the same tag once the channel recovers.
**Rerun an existing tag (recovery)** — transient failures, or replaying
non-blocking channels:
```bash
gh workflow run "Release App" --repo different-ai/openwork -f tag=vX.Y.Z
```
Recovery runs skip tag creation and monotonicity, build source pinned to the
tag, and pick up workflow-file fixes from `dev` automatically (the workflow
definition runs from the dispatched ref; only the checked-out sources are
pinned to the tag).
**If the run fails before the release is published:** land the fix on `dev`
via a normal protected-branch PR and cut the next patch (`pnpm release:cut`).
Only delete/recreate a tag after verifying the GitHub release is still
draft-only:
```bash
git push --delete origin vX.Y.Z
```
---
## Verify
```bash
gh release view vX.Y.Z --repo different-ai/openwork --json assets --jq '.assets[].name'
```
Expect the app assets (`openwork-<platform>-X.Y.Z.*`, `latest*.yml` updater
manifests), including:
- `openwork-mac-arm64-X.Y.Z.dmg`
- `openwork-mac-x64-X.Y.Z.dmg`
- `openwork-win-x64-X.Y.Z.exe`
The desktop updater 404s on `latest*.yml` until the release is published —
that error in a running app during the build window is expected and
self-heals. Spot-check a download URL resolves (302 to release-assets CDN):
```bash
curl -sI "https://github.com/different-ai/openwork/releases/download/vX.Y.Z/openwork-mac-arm64-X.Y.Z.dmg" | head -2
```
Confirm `npm view openwork-server version` matches.
---
## Notes
- Desktop installer fixes only reach users through a new release — the org
install door (`/v1/install/:platform`) 302s to versioned assets.
- den-api discovers published versions from the GitHub Releases API at
runtime (`ee/apps/den-api/src/desktop-releases.ts`): the new version is
live for orgs as soon as the release is published — no den deploy needed.
The committed `generated/desktop-versions.ts` is only a cold-start/offline
fallback.
- AUR publishes by rendering the committed `packaging/aur` template
(pkgver=0.0.0) in the CI workspace and pushing to aur.archlinux.org — the
AUR-side commit is that channel's publish protocol; this repo stays
untouched.
- Native workspace deps must stay converged on one major across all apps —
electron-builder rebuilds every copy it finds (see #3561/#3563 for the
three-release outage this caused).
+78
View File
@@ -0,0 +1,78 @@
---
name: run-tests
description: Run the tests, run one spec, run e2e locally or on Daytona, investigate a skipped spec. Use for executing @openwork/testkit agent-first verification.
---
# Skill: Run Tests
## Run the landable tree
- Check out the exact PR head that will land. After any rebase or cherry-pick,
discard the old verdict and run again.
- Run one test at a time so each failure and ambient test evidence has one owner.
## Choose the execution environment
- Prefer Daytona when Daytona credentials, tooling, and service access are
available. Set `OPENWORK_EVAL_DAYTONA=1` for that run.
- If Daytona is unavailable, run the same spec locally with
`OPENWORK_EVAL_DAYTONA` unset. Missing Daytona access is an expected path for
OSS contributors and does not make the verdict fail or incomplete.
- Determine fallback eligibility before execution: use local only when Daytona
credentials, tooling, or service access are unavailable, or when the user
explicitly requires local.
- Once Daytona is available and selected, a runtime, test, or product failure
does not make Daytona unavailable. Diagnose and repair any red or incomplete
Daytona run in Daytona; never switch lanes to turn the verdict green.
- A local reproduction may aid diagnosis, but it is not fallback and cannot
replace the required Daytona verdict.
- Record the selected lane. For local fallback, report which Daytona
prerequisite was unavailable without printing secret values.
## Prepare local fallback
```bash
pnpm --filter @openwork/types build
pnpm --filter @openwork-ee/den-db build
pnpm --filter @openwork/email build
pnpm dev:den:mysql
```
- Local `server()` requires MySQL at `127.0.0.1:3306`.
- Build those workspace dependencies before local Den; otherwise den-api imports
can fail.
- If the checkout path contains spaces, set `OPENWORK_EVAL_SURFACES_DIR` to a
space-free path before E2E tests. node-gyp and electron-rebuild require it.
## Choose one lane
- Run one app-less PR-lane test:
```bash
pnpm evals:pr specs/<name>.test.ts
```
- Run one app/Den-driving E2E test:
```bash
pnpm evals:e2e <name>
# Fallback:
OPENWORK_EVAL_E2E_TESTS=1 pnpm --dir evals exec vitest run --config vitest.config.ts --project e2e specs/<name>.e2e.test.ts
```
- The commands above use Daytona when `OPENWORK_EVAL_DAYTONA=1` is set and
isolated local resources when it is unset.
## Read the verdict
- Record the exact command, exit code, and passed/failed/skipped counts.
- Report each skip as `skipped — needs: X`; never call it passed. A green command
containing skips makes the overall verdict `Incomplete`.
- Use `Passed`, `Incomplete`, or `Failed` for the overall result.
## Iterate, then cold-boot
- While iterating, reuse a warm Den with `OPENWORK_EVAL_DEN_API_URL`.
- Before declaring `Passed`, remove the reuse override and cold-boot through
`server()` on the same commit.
- Inject secrets with `infisical run --silent --`; never print or echo values.
+260
View File
@@ -0,0 +1,260 @@
---
name: shadcn
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
# shadcn/ui
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
## Current Project Context
```json
!`npx shadcn@latest info --json`
```
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
## Principles
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
## Critical Rules
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
### Styling & Tailwind → [styling.md](./rules/styling.md)
- **`className` for layout, not styling.** Never override component colors or typography.
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
### Forms & Inputs → [forms.md](./rules/forms.md)
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
### Component Structure → [composition.md](./rules/composition.md)
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
- **Callouts use `Alert`.** Don't build custom styled divs.
- **Empty states use `Empty`.** Don't build custom empty state markup.
- **Toast via `sonner`.** Use `toast()` from `sonner`.
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
- **Use `Badge`** instead of custom styled spans.
### Icons → [icons.md](./rules/icons.md)
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
### CLI
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
## Key Patterns
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
```tsx
// Form layout: FieldGroup + Field, not div + Label.
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" />
</Field>
</FieldGroup>
// Validation: data-invalid on Field, aria-invalid on the control.
<Field data-invalid>
<FieldLabel>Email</FieldLabel>
<Input aria-invalid />
<FieldDescription>Invalid email.</FieldDescription>
</Field>
// Icons in buttons: data-icon, no sizing classes.
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
// Spacing: gap-*, not space-y-*.
<div className="flex flex-col gap-4"> // correct
<div className="space-y-4"> // wrong
// Equal dimensions: size-*, not w-* h-*.
<Avatar className="size-10"> // correct
<Avatar className="w-10 h-10"> // wrong
// Status colors: Badge variants or semantic tokens, not raw colors.
<Badge variant="secondary">+20.1%</Badge> // correct
<span className="text-emerald-600">+20.1%</span> // wrong
```
## Component Selection
| Need | Use |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| Button/action | `Button` with appropriate variant |
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
| Command palette | `Command` inside `Dialog` |
| Charts | `Chart` (wraps Recharts) |
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
| Empty states | `Empty` |
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
## Key Fields
The injected project context contains these key fields:
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
See [cli.md — `info` command](./cli.md) for the full field reference.
## Component Docs, Examples, and Usage
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
```bash
npx shadcn@latest docs button dialog select
```
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
## Workflow
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
3. **Find components** — `npx shadcn@latest search`.
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
## Updating Components
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
3. Decide per file based on the diff:
- No local changes → safe to overwrite.
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
- User says "just update everything" → use `--overwrite`, but confirm first.
4. **Never use `--overwrite` without the user's explicit approval.**
## Quick Reference
```bash
# Create a new project.
npx shadcn@latest init --name my-app --preset base-nova
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
# Create a monorepo project.
npx shadcn@latest init --name my-app --preset base-nova --monorepo
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
# Initialize existing project.
npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
# Apply a preset to an existing project.
npx shadcn@latest apply a2r6bw
npx shadcn@latest apply a2r6bw --only theme
npx shadcn@latest apply a2r6bw --only font
npx shadcn@latest apply a2r6bw --only theme,font
# Inspect preset codes and project preset state.
npx shadcn@latest preset decode a2r6bw
npx shadcn@latest preset url a2r6bw
npx shadcn@latest preset open a2r6bw
npx shadcn@latest preset resolve
npx shadcn@latest preset resolve --json
# Add components.
npx shadcn@latest add button card dialog
npx shadcn@latest add @magicui/shimmer-button
npx shadcn@latest add --all
# Preview changes before adding/updating.
npx shadcn@latest add button --dry-run
npx shadcn@latest add button --diff button.tsx
npx shadcn@latest add @acme/form --view button.tsx
# Search registries.
npx shadcn@latest search @shadcn -q "sidebar"
npx shadcn@latest search @tailark -q "stats"
# Get component docs and example URLs.
npx shadcn@latest docs button dialog select
# View registry item details (for items not yet installed).
npx shadcn@latest view @shadcn/button
```
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
## Detailed References
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
- [cli.md](./cli.md) — Commands, flags, presets, templates
- [customization.md](./customization.md) — Theming, CSS variables, extending components
@@ -0,0 +1,5 @@
interface:
display_name: "shadcn/ui"
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
icon_small: "./assets/shadcn-small.png"
icon_large: "./assets/shadcn.png"
+276
View File
@@ -0,0 +1,276 @@
# shadcn CLI Reference
Configuration is read from `components.json`.
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
## Contents
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
- Templates: next, vite, start, react-router, astro
- Presets: named, code, URL formats and fields
- Switching presets
---
## Commands
### `init` — Initialize or create a project
```bash
npx shadcn@latest init [components...] [options]
```
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
| Flag | Short | Description | Default |
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `true` |
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
| `--force` | `-f` | Force overwrite existing configuration | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--name <name>` | `-n` | Name for new project | — |
| `--silent` | `-s` | Mute output | `false` |
| `--rtl` | | Enable RTL support | — |
| `--reinstall` | | Re-install existing UI components | `false` |
| `--monorepo` | | Scaffold a monorepo project | — |
| `--no-monorepo` | | Skip the monorepo prompt | — |
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
### `apply` — Apply a preset to an existing project
```bash
npx shadcn@latest apply [preset] [options]
```
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
| Flag | Short | Description | Default |
| ------------------- | ----- | ------------------------------------------ | ------- |
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--silent` | `-s` | Mute output | `false` |
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
### `add` — Add components
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
```bash
npx shadcn@latest add [components...] [options]
```
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
| Flag | Short | Description | Default |
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--overwrite` | `-o` | Overwrite existing files | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--all` | `-a` | Add all available components | `false` |
| `--path <path>` | `-p` | Target path for the component | — |
| `--silent` | `-s` | Mute output | `false` |
| `--dry-run` | | Preview all changes without writing files | `false` |
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
#### Dry-Run Mode
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
```bash
# Preview all changes.
npx shadcn@latest add button --dry-run
# Show diffs for all files (top 5).
npx shadcn@latest add button --diff
# Show the diff for a specific file.
npx shadcn@latest add button --diff button.tsx
# Show contents for all files (top 5).
npx shadcn@latest add button --view
# Show the full content of a specific file.
npx shadcn@latest add button --view button.tsx
# Works with URLs too.
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
# CSS diffs.
npx shadcn@latest add button --diff globals.css
```
**When to use dry-run:**
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
- Before overwriting existing components — use `--diff` to preview the changes first.
- When the user wants to inspect component source code without installing — use `--view`.
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
#### Smart Merge from Upstream
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
### `search` — Search registries
```bash
npx shadcn@latest search <registries...> [options]
```
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
| Flag | Short | Description | Default |
| ------------------- | ----- | ---------------------- | ------- |
| `--query <query>` | `-q` | Search query | — |
| `--limit <number>` | `-l` | Max items per registry | `100` |
| `--offset <number>` | `-o` | Items to skip | `0` |
| `--cwd <cwd>` | `-c` | Working directory | current |
### `view` — View item details
```bash
npx shadcn@latest view <items...> [options]
```
Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
### `docs` — Get component documentation URLs
```bash
npx shadcn@latest docs <components...> [options]
```
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
Example output for `npx shadcn@latest docs input button`:
```
base radix
input
docs https://ui.shadcn.com/docs/components/radix/input
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
button
docs https://ui.shadcn.com/docs/components/radix/button
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
```
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
### `diff` — Check for updates
Do not use this command. Use `npx shadcn@latest add --diff` instead.
### `info` — Project information
```bash
npx shadcn@latest info [options]
```
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
| Flag | Short | Description | Default |
| ------------- | ----- | ----------------- | ------- |
| `--cwd <cwd>` | `-c` | Working directory | current |
**Project Info fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------ |
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
| `isRSC` | `boolean` | Whether React Server Components are enabled |
| `isTsx` | `boolean` | Whether the project uses TypeScript |
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
| `tailwindCssFile` | `string` | Path to the global CSS file |
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
**Components.json fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
| `rsc` | `boolean` | RSC flag from config |
| `tsx` | `boolean` | TypeScript flag |
| `tailwind.config` | `string` | Tailwind config path |
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
| `registries` | `object` | Configured custom registries |
**Links fields:**
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
### `build` — Build a custom registry
```bash
npx shadcn@latest build [registry] [options]
```
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
| Flag | Short | Description | Default |
| ----------------- | ----- | ----------------- | ------------ |
| `--output <path>` | `-o` | Output directory | `./public/r` |
| `--cwd <cwd>` | `-c` | Working directory | current |
---
## Templates
| Value | Framework | Monorepo support |
| -------------- | -------------- | ---------------- |
| `next` | Next.js | Yes |
| `vite` | Vite | Yes |
| `start` | TanStack Start | Yes |
| `react-router` | React Router | Yes |
| `astro` | Astro | Yes |
| `laravel` | Laravel | No |
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
---
## Presets
Three ways to specify a preset via `--preset`:
1. **Named:** `--preset nova` or `--preset lyra`
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
## Switching Presets
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
+209
View File
@@ -0,0 +1,209 @@
# Customization & Theming
Components reference semantic CSS variable tokens. Change the variables to change every component.
## Contents
- How it works (CSS variables → Tailwind utilities → components)
- Color variables and OKLCH format
- Dark mode setup
- Changing the theme (presets, CSS variables)
- Adding custom colors (Tailwind v3 and v4)
- Border radius
- Customizing components (variants, className, wrappers)
- Checking for updates
---
## How It Works
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
3. Components use these utilities — changing a variable changes all components that reference it.
---
## Color Variables
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
| Variable | Purpose |
| -------------------------------------------- | -------------------------------- |
| `--background` / `--foreground` | Page background and default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary buttons and actions |
| `--secondary` / `--secondary-foreground` | Secondary actions |
| `--muted` / `--muted-foreground` | Muted/disabled states |
| `--accent` / `--accent-foreground` | Hover and accent states |
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
| `--border` | Default border color |
| `--input` | Form input borders |
| `--ring` | Focus ring color |
| `--chart-1` through `--chart-5` | Chart/data visualization |
| `--sidebar-*` | Sidebar-specific colors |
| `--surface` / `--surface-foreground` | Secondary surface |
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
---
## Dark Mode
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
```tsx
import { ThemeProvider } from "next-themes"
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
```
---
## Changing the Theme
```bash
# Apply a preset code from ui.shadcn.com.
npx shadcn@latest apply --preset a2r6bw
# Positional shorthand also works.
npx shadcn@latest apply a2r6bw
# Switch to a named preset and overwrite existing components.
npx shadcn@latest apply --preset nova
# Preserve existing components instead.
npx shadcn@latest init --preset nova --force --no-reinstall
# Use a custom theme URL.
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
```
Or edit CSS variables directly in `globals.css`.
---
## Adding Custom Colors
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
```css
/* 1. Define in the global CSS file. */
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
```
```css
/* 2a. Register with Tailwind v4 (@theme inline). */
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
```js
// 2b. Register with Tailwind v3 (tailwind.config.js).
module.exports = {
theme: {
extend: {
colors: {
warning: "oklch(var(--warning) / <alpha-value>)",
"warning-foreground":
"oklch(var(--warning-foreground) / <alpha-value>)",
},
},
},
}
```
```tsx
// 3. Use in components.
<div className="bg-warning text-warning-foreground">Warning</div>
```
---
## Border Radius
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
---
## Customizing Components
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">
Click
</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="mx-auto max-w-md">...</Card>
```
### 3. Add a new variant
Edit the component source to add a variant via `cva`:
```tsx
// components/ui/button.tsx
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
```
### 4. Wrapper components
Compose shadcn/ui primitives into higher-level components:
```tsx
export function ConfirmDialog({ title, description, onConfirm, children }) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
```
---
## Checking for Updates
```bash
npx shadcn@latest add button --diff
```
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
```bash
npx shadcn@latest add button --dry-run # see all affected files
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
```
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
+47
View File
@@ -0,0 +1,47 @@
{
"skill_name": "shadcn",
"evals": [
{
"id": 1,
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
"files": [],
"expectations": [
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
"No manual dark: color overrides"
]
},
{
"id": 2,
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
"files": [],
"expectations": [
"Includes DialogTitle for accessibility (visible or with sr-only class)",
"Avatar component includes AvatarFallback",
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
"Uses asChild for custom triggers (radix preset)"
]
},
{
"id": 3,
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
"files": [],
"expectations": [
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
"Uses Badge component for percentage change instead of custom styled spans",
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
"Uses gap-* instead of space-y-* or space-x-* for spacing",
"Uses size-* when width and height are equal instead of separate w-* h-*"
]
}
]
}
+94
View File
@@ -0,0 +1,94 @@
# shadcn MCP Server
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
---
## Setup
```bash
shadcn mcp # start the MCP server (stdio)
shadcn mcp init # write config for your editor
```
Editor config files:
| Editor | Config file |
|--------|------------|
| Claude Code | `.mcp.json` |
| Cursor | `.cursor/mcp.json` |
| VS Code | `.vscode/mcp.json` |
| OpenCode | `opencode.json` |
| Codex | `~/.codex/config.toml` (manual) |
---
## Tools
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
### `shadcn:get_project_registries`
Returns registry names from `components.json`. Errors if no `components.json` exists.
**Input:** none
### `shadcn:list_items_in_registries`
Lists all items from one or more registries.
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
### `shadcn:search_items_in_registries`
Fuzzy search across registries.
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
### `shadcn:view_items_in_registries`
View item details including full file contents.
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
### `shadcn:get_item_examples_from_registries`
Find usage examples and demos with source code.
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
### `shadcn:get_add_command_for_items`
Returns the CLI install command.
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
### `shadcn:get_audit_checklist`
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
**Input:** none
---
## Configuring Registries
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
```json
{
"registries": {
"@acme": "https://acme.com/r/{name}.json",
"@private": {
"url": "https://private.com/r/{name}.json",
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
}
}
}
```
- Names must start with `@`.
- URLs must contain `{name}`.
- `${VAR}` references are resolved from environment variables.
Community registry index: `https://ui.shadcn.com/r/registries.json`
@@ -0,0 +1,306 @@
# Base vs Radix
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
## Contents
- Composition: asChild vs render
- Button / trigger as non-button element
- Select (items prop, placeholder, positioning, multiple, object values)
- ToggleGroup (type vs multiple)
- Slider (scalar vs array)
- Accordion (type and defaultValue)
---
## Composition: asChild (radix) vs render (base)
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
**Incorrect:**
```tsx
<DialogTrigger>
<div>
<Button>Open</Button>
</div>
</DialogTrigger>
```
**Correct (radix):**
```tsx
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
```
**Correct (base):**
```tsx
<DialogTrigger render={<Button />}>Open</DialogTrigger>
```
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
---
## Button / trigger as non-button element (base only)
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
**Incorrect (base):** missing `nativeButton={false}`.
```tsx
<Button render={<a href="/docs" />}>Read the docs</Button>
```
**Correct (base):**
```tsx
<Button render={<a href="/docs" />} nativeButton={false}>
Read the docs
</Button>
```
**Correct (radix):**
```tsx
<Button asChild>
<a href="/docs">Read the docs</a>
</Button>
```
Same for triggers whose `render` is not a `Button`:
```tsx
// base.
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
Pick date
</PopoverTrigger>
```
---
## Select
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
**Incorrect (base):**
```tsx
<Select>
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
</Select>
```
**Correct (base):**
```tsx
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
]
<Select items={items}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
```
**Correct (radix):**
```tsx
<Select>
<SelectTrigger>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
```
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
```tsx
// base.
<SelectContent alignItemWithTrigger={false} side="bottom">
// radix.
<SelectContent position="popper">
```
---
## Select — multiple selection and object values (base only)
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
**Correct (base — multiple selection):**
```tsx
<Select items={items} multiple defaultValue={[]}>
<SelectTrigger>
<SelectValue>
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
</SelectValue>
</SelectTrigger>
...
</Select>
```
**Correct (base — object values):**
```tsx
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
<SelectTrigger>
<SelectValue>{(value) => value.name}</SelectValue>
</SelectTrigger>
...
</Select>
```
---
## ToggleGroup
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
**Incorrect (base):**
```tsx
<ToggleGroup type="single" defaultValue="daily">
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
</ToggleGroup>
```
**Correct (base):**
```tsx
// Single (no prop needed), defaultValue is always an array.
<ToggleGroup defaultValue={["daily"]} spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup multiple>
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Correct (radix):**
```tsx
// Single, defaultValue is a string.
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup type="multiple">
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Controlled single value:**
```tsx
// base — wrap/unwrap arrays.
const [value, setValue] = React.useState("normal")
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
// radix — plain string.
const [value, setValue] = React.useState("normal")
<ToggleGroup type="single" value={value} onValueChange={setValue}>
```
---
## Slider
Base accepts a plain number for a single thumb. Radix always requires an array.
**Incorrect (base):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
**Correct (base):**
```tsx
<Slider defaultValue={50} max={100} step={1} />
```
**Correct (radix):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
```tsx
// base.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
// radix.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={setValue} />
```
---
## Accordion
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
**Incorrect (base):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```
**Correct (base):**
```tsx
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
// Multi-select.
<Accordion multiple defaultValue={["item-1", "item-2"]}>
<AccordionItem value="item-1">...</AccordionItem>
<AccordionItem value="item-2">...</AccordionItem>
</Accordion>
```
**Correct (radix):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```
@@ -0,0 +1,195 @@
# Component Composition
## Contents
- Items always inside their Group component
- Callouts use Alert
- Empty states use Empty component
- Toast notifications use sonner
- Choosing between overlay components
- Dialog, Sheet, and Drawer always need a Title
- Card structure
- Button has no isPending or isLoading prop
- TabsTrigger must be inside TabsList
- Avatar always needs AvatarFallback
- Use Separator instead of raw hr or border divs
- Use Skeleton for loading placeholders
- Use Badge instead of custom styled spans
---
## Items always inside their Group component
Never render items directly inside the content container.
**Incorrect:**
```tsx
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
```
**Correct:**
```tsx
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
```
This applies to all group-based components:
| Item | Group |
|------|-------|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
| `MenubarItem` | `MenubarGroup` |
| `ContextMenuItem` | `ContextMenuGroup` |
| `CommandItem` | `CommandGroup` |
---
## Callouts use Alert
```tsx
<Alert>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>Something needs attention.</AlertDescription>
</Alert>
```
---
## Empty states use Empty component
```tsx
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Create Project</Button>
</EmptyContent>
</Empty>
```
---
## Toast notifications use sonner
```tsx
import { toast } from "sonner"
toast.success("Changes saved.")
toast.error("Something went wrong.")
toast("File deleted.", {
action: { label: "Undo", onClick: () => undoDelete() },
})
```
---
## Choosing between overlay components
| Use case | Component |
|----------|-----------|
| Focused task that requires input | `Dialog` |
| Destructive action confirmation | `AlertDialog` |
| Side panel with details or filters | `Sheet` |
| Mobile-first bottom panel | `Drawer` |
| Quick info on hover | `HoverCard` |
| Small contextual content on click | `Popover` |
---
## Dialog, Sheet, and Drawer always need a Title
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
```tsx
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update your profile.</DialogDescription>
</DialogHeader>
...
</DialogContent>
```
---
## Card structure
Use full composition — don't dump everything into `CardContent`:
```tsx
<Card>
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>Manage your team.</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>
<Button>Invite</Button>
</CardFooter>
</Card>
```
---
## Button has no isPending or isLoading prop
Compose with `Spinner` + `data-icon` + `disabled`:
```tsx
<Button disabled>
<Spinner data-icon="inline-start" />
Saving...
</Button>
```
---
## TabsTrigger must be inside TabsList
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
```tsx
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">...</TabsContent>
</Tabs>
```
---
## Avatar always needs AvatarFallback
Always include `AvatarFallback` for when the image fails to load:
```tsx
<Avatar>
<AvatarImage src="/avatar.png" alt="User" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
```
---
## Use existing components instead of custom markup
| Instead of | Use |
|---|---|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
+192
View File
@@ -0,0 +1,192 @@
# Forms & Inputs
## Contents
- Forms use FieldGroup + Field
- InputGroup requires InputGroupInput/InputGroupTextarea
- Buttons inside inputs use InputGroup + InputGroupAddon
- Option sets (2–7 choices) use ToggleGroup
- FieldSet + FieldLegend for grouping related fields
- Field validation and disabled states
---
## Forms use FieldGroup + Field
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
```tsx
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" />
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" type="password" />
</Field>
</FieldGroup>
```
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
**Choosing form controls:**
- Simple text input → `Input`
- Dropdown with predefined options → `Select`
- Searchable dropdown → `Combobox`
- Native HTML select (no JS) → `native-select`
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
- Single choice from few options → `RadioGroup`
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
- OTP/verification code → `InputOTP`
- Multi-line text → `Textarea`
---
## InputGroup requires InputGroupInput/InputGroupTextarea
Never use raw `Input` or `Textarea` inside an `InputGroup`.
**Incorrect:**
```tsx
<InputGroup>
<Input placeholder="Search..." />
</InputGroup>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
</InputGroup>
```
---
## Buttons inside inputs use InputGroup + InputGroupAddon
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
**Incorrect:**
```tsx
<div className="relative">
<Input placeholder="Search..." className="pr-10" />
<Button className="absolute right-0 top-0" size="icon">
<SearchIcon />
</Button>
</div>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<Button size="icon">
<SearchIcon data-icon="inline-start" />
</Button>
</InputGroupAddon>
</InputGroup>
```
---
## Option sets (2–7 choices) use ToggleGroup
Don't manually loop `Button` components with active state.
**Incorrect:**
```tsx
const [selected, setSelected] = useState("daily")
<div className="flex gap-2">
{["daily", "weekly", "monthly"].map((option) => (
<Button
key={option}
variant={selected === option ? "default" : "outline"}
onClick={() => setSelected(option)}
>
{option}
</Button>
))}
</div>
```
**Correct:**
```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
<ToggleGroup spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
</ToggleGroup>
```
Combine with `Field` for labelled toggle groups:
```tsx
<Field orientation="horizontal">
<FieldTitle id="theme-label">Theme</FieldTitle>
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
<ToggleGroupItem value="system">System</ToggleGroupItem>
</ToggleGroup>
</Field>
```
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
---
## FieldSet + FieldLegend for grouping related fields
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
```tsx
<FieldSet>
<FieldLegend variant="label">Preferences</FieldLegend>
<FieldDescription>Select all that apply.</FieldDescription>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox id="dark" />
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>
```
---
## Field validation and disabled states
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
```tsx
// Invalid.
<Field data-invalid>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" aria-invalid />
<FieldDescription>Invalid email address.</FieldDescription>
</Field>
// Disabled.
<Field data-disabled>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" disabled />
</Field>
```
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
+101
View File
@@ -0,0 +1,101 @@
# Icons
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
---
## Icons in Button use data-icon attribute
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="mr-2 size-4" />
Search
</Button>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start"/>
Search
</Button>
<Button>
Next
<ArrowRightIcon data-icon="inline-end"/>
</Button>
```
---
## No sizing classes on icons inside components
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="size-4" data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon className="mr-2 size-4" />
Settings
</DropdownMenuItem>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
```
---
## Pass icons as component objects, not string keys
Use `icon={CheckIcon}`, not a string key to a lookup map.
**Incorrect:**
```tsx
const iconMap = {
check: CheckIcon,
alert: AlertIcon,
}
function StatusBadge({ icon }: { icon: string }) {
const Icon = iconMap[icon]
return <Icon />
}
<StatusBadge icon="check" />
```
**Correct:**
```tsx
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
import { CheckIcon } from "lucide-react"
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
return <Icon />
}
<StatusBadge icon={CheckIcon} />
```
+162
View File
@@ -0,0 +1,162 @@
# Styling & Customization
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
## Contents
- Semantic colors
- Built-in variants first
- className for layout only
- No space-x-* / space-y-*
- Prefer size-* over w-* h-* when equal
- Prefer truncate shorthand
- No manual dark: color overrides
- Use cn() for conditional classes
- No manual z-index on overlay components
---
## Semantic colors
**Incorrect:**
```tsx
<div className="bg-blue-500 text-white">
<p className="text-gray-600">Secondary text</p>
</div>
```
**Correct:**
```tsx
<div className="bg-primary text-primary-foreground">
<p className="text-muted-foreground">Secondary text</p>
</div>
```
---
## No raw color values for status/state indicators
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
**Incorrect:**
```tsx
<span className="text-emerald-600">+20.1%</span>
<span className="text-green-500">Active</span>
<span className="text-red-600">-3.2%</span>
```
**Correct:**
```tsx
<Badge variant="secondary">+20.1%</Badge>
<Badge>Active</Badge>
<span className="text-destructive">-3.2%</span>
```
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
---
## Built-in variants first
**Incorrect:**
```tsx
<Button className="border border-input bg-transparent hover:bg-accent">
Click me
</Button>
```
**Correct:**
```tsx
<Button variant="outline">Click me</Button>
```
---
## className for layout only
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
**Incorrect:**
```tsx
<Card className="bg-blue-100 text-blue-900 font-bold">
<CardContent>Dashboard</CardContent>
</Card>
```
**Correct:**
```tsx
<Card className="max-w-md mx-auto">
<CardContent>Dashboard</CardContent>
</Card>
```
To customize a component's appearance, prefer these approaches in order:
1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
---
## No space-x-* / space-y-*
Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
```tsx
<div className="flex flex-col gap-4">
<Input />
<Input />
<Button>Submit</Button>
</div>
```
---
## Prefer size-* over w-* h-* when equal
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
---
## Prefer truncate shorthand
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
---
## No manual dark: color overrides
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
---
## Use cn() for conditional classes
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
**Incorrect:**
```tsx
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
```
**Correct:**
```tsx
import { cn } from "@/lib/utils"
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
```
---
## No manual z-index on overlay components
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
+50
View File
@@ -0,0 +1,50 @@
---
name: upload-photo
description: upload a photo/image/screenshot, host an image, get a public image URL, put images on Vercel Blob, embed images in a PR/comment/doc. Upload local images to Vercel Blob and print public URLs.
---
# Skill: upload-photo
Upload local image files to Vercel Blob and return public URLs for sharing.
## When to use
Use whenever an image or screenshot needs a public URL, including PR comments, docs, and sharing.
## Requires
`BLOB_READ_WRITE_TOKEN` must be set in the environment. If it is missing, use the `get-env-var` skill to fetch it from Infisical:
```bash
export BLOB_READ_WRITE_TOKEN="$(infisical secrets get BLOB_READ_WRITE_TOKEN --plain --silent)"
```
## Upload
Run the bundled script from the repo root:
```bash
node .opencode/skills/upload-photo/scripts/upload.mjs <file.png> [more files...] [--prefix <path/prefix>] [--stable]
```
It prints one public URL per line (`https://<store>.public.blob.vercel-storage.com/...`). `--prefix` defaults to `uploads/<YYYY-MM-DD>`. By default Vercel Blob appends a random suffix to the pathname for collision safety; `--stable` disables that (`x-add-random-suffix: 0`) for deterministic URLs, so overwrites are possible.
## Embed
Use Markdown:
```markdown
![alt](url)
```
Or HTML for size control in GitHub comments:
```html
<img src="url" width="700">
```
## Rules
- Never log `BLOB_READ_WRITE_TOKEN`.
- The store is public; do not upload anything sensitive.
- Prefer PNG, JPEG, or WebP images.
@@ -0,0 +1,120 @@
import { basename } from "node:path";
import { existsSync, readFileSync } from "node:fs";
const usage = "Usage: node .opencode/skills/upload-photo/scripts/upload.mjs <file.png> [more files...] [--prefix <path/prefix>] [--stable]";
function printUsage(message = null) {
if (message) console.error(message);
console.error(usage);
}
function parseArgs(argv) {
const files = [];
let prefix = `uploads/${new Date().toISOString().slice(0, 10)}`;
let stable = false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--stable") {
stable = true;
continue;
}
if (arg === "--prefix") {
const value = argv[index + 1];
if (!value || value.startsWith("--")) return null;
prefix = value;
index += 1;
continue;
}
if (arg.startsWith("--")) return null;
files.push(arg);
}
if (files.length === 0) return null;
return { files, prefix, stable };
}
function contentTypeFor(file) {
const lower = file.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".avif")) return "image/avif";
return "application/octet-stream";
}
function encodePathname(pathname) {
return pathname.split("/").map((segment) => encodeURIComponent(segment)).join("/");
}
function buildPathname(prefix, file) {
const cleanPrefix = prefix.replace(/^\/+|\/+$/g, "");
return encodePathname([cleanPrefix, basename(file)].filter(Boolean).join("/"));
}
async function uploadFile({ file, prefix, stable, token }) {
if (!existsSync(file)) {
console.error(`File does not exist: ${file}`);
return 1;
}
const pathname = buildPathname(prefix, file);
const headers = {
authorization: `Bearer ${token}`,
"x-content-type": contentTypeFor(file),
};
if (stable) headers["x-add-random-suffix"] = "0";
const response = await fetch(`https://blob.vercel-storage.com/${pathname}`, {
method: "PUT",
headers,
body: readFileSync(file),
});
if (!response.ok) {
const detail = (await response.text()).slice(0, 300);
console.error(`Upload failed (${response.status}) for ${file}: ${detail}`);
return 1;
}
let payload;
try {
payload = await response.json();
} catch {
console.error(`Upload failed for ${file}: response was not JSON`);
return 1;
}
if (!payload || typeof payload.url !== "string" || payload.url.length === 0) {
console.error(`Upload failed for ${file}: response did not include url`);
return 1;
}
console.log(payload.url);
return 0;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args) {
printUsage();
return 1;
}
const token = process.env.BLOB_READ_WRITE_TOKEN;
if (!token) {
console.error("BLOB_READ_WRITE_TOKEN is not set — fetch it with the get-env-var skill: export BLOB_READ_WRITE_TOKEN=\"$(infisical secrets get BLOB_READ_WRITE_TOKEN --plain --silent)\"");
return 1;
}
for (const file of args.files) {
const status = await uploadFile({ file, prefix: args.prefix, stable: args.stable, token });
if (status !== 0) return status;
}
return 0;
}
process.exitCode = await main();
+46
View File
@@ -0,0 +1,46 @@
---
name: write-a-spec
description: Write a spec, new E2E test, or test a feature end to end. Use when authoring an @openwork/testkit test in evals/specs.
---
# Skill: Write a Spec
Write new tests in `evals/specs/**/*.test.ts` and import `test` from
`@openwork/testkit`. App-driving E2E tests use `.e2e.test.ts`; the PR lane excludes
them. Model setup as resources in dependency order: `needs()` → `server()` →
`app()`.
## Use the testkit primitives
- `server()` boots or reuses Den and provisions isolated organizations.
- `app()` boots a signed-in desktop. Use `profileDir` for caller-owned profile
continuity and `localServerDelayMs` for deterministic startup races.
- `inviteMember()` adds a named member to an existing Den.
- `faultProxy()` injects `faults.status()` or `faults.latency()` and exposes the
`requests` log for assertions about attempts and recovery.
- `eventually()` bounds polling and reports its last value or error.
- `readDenClientState()`, `readConnectState()`, and `readConnectStateFile()`
expose client, local-server, and persisted-profile state.
## Claims and witnesses
- Make each claim machine-checkable with an observable assertion and its
explicit negative half. Assert both the intended effect and what must not
happen to another identity, account, request, file, or state.
- Prose is never proof. Screenshots explain an assertion but cannot replace it.
- Describe product behavior, not incidental layout. Claims such as "side by
side" can disagree even when pixels are identical across runs.
- Match claims to what the product actually says on screen. If product and
claim diverge, explicitly change one; never silently bend the claim.
- Never smuggle the answer into the prompt. Assert that the user-facing request
does not contain connector or resource IDs.
## Evidence contract
- Test evidence is ambient: `screenshot()` records screenshot artifacts,
`validate()` records their visual validations whether they pass or fail, and
`recordAssertionEvidence()` holds witness assertions.
- Never create or pass test-evidence recorder handles in test bodies.
- Bound every wait.
- Declare every external requirement in `needs()` so missing dependencies skip
loudly instead of timing out or weakening coverage.