源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
description: Say hello stranger
|
||||
---
|
||||
hello stranger
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+399
@@ -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.`
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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}`)
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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-*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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">` |
|
||||
@@ -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`.
|
||||
@@ -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} />
|
||||
```
|
||||
@@ -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]`.
|
||||
@@ -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
|
||||

|
||||
```
|
||||
|
||||
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();
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user