diff --git a/.changepacks/changepack_log_agent_skills.json b/.changepacks/changepack_log_agent_skills.json new file mode 100644 index 00000000..81e8549a --- /dev/null +++ b/.changepacks/changepack_log_agent_skills.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "devup_skills reports which agent skills this workspace is missing for the code devup-mcp emits, and installs the ones devup-mcp carries. The TSX an export returns is devup-ui code, and on a machine that has devup-mcp and nothing else the receiving agent has never seen devup-ui: it does not know the components are compile-time placeholders, that $token refers to devup.json, or that a style prop takes a responsive array. It guesses, and this server can neither see nor correct the guesses. Returning the rules in a response does not fix that, because a document handed over once is read once; every agent runtime already has a loader that reads SKILL.md from a directory and surfaces it on its own triggers for the rest of that session and every session after it. So the tool reports install state - a concrete gap - and writes the documents rather than the prose. Two origins, handled differently on purpose. devup-ui, vespera and vespertide are DevFive's own, so their canonical SKILL.md is vendored into the binary and installs with no network, which matters because a bare machine is exactly where a download is least likely to work; the manifest records each embedded commit and SHA-256 and the URL of the current revision, because a vendored copy goes stale and scripts/refresh-skills.mjs is how it stops being stale. vercel-react-best-practices and vercel-react-view-transitions are not vendored: vercel-labs/agent-skills publishes no LICENSE file, so its content is not devup-mcp's to redistribute, and they are multi-file anyway - one is a SKILL.md plus an AGENTS.md and some seventy rule files, about 350 KB - so copying them was never the right shape. For those the publisher's own command is handed over and never executed, because a design-to-code server that shells out to a package installer turns one compromised registry entry into arbitrary execution on every machine that ever exported a screen. Skills install project-locally, preferring an existing .claude/skills, .opencode/skill or .agents/skills, through the same OutputPolicy and one OutputTransaction as every other file this server writes, so a skill lands under an allowed write root or not at all. The provenance comment is placed after the YAML frontmatter rather than before it: a SKILL.md opens with --- at byte zero and a comment in front of it leaves a file that is listed as installed and silently never loads, which is worse than no install because nothing shows up to say so. Inducement is deliberately narrow - one line in instructions, kept inside the existing 1,200-byte budget, and a skillGap on devup_ui_validate only when there are violations and the skill is genuinely absent, since telling someone who already has it to install it is the noise that teaches them to skip the field. Verified against the release binary over real stdio on a bare workspace: five reported missing, three written and confirmed on disk, two handed over as commands, the gap closing to three installed and the skillGap disappearing.", + "date": "2026-09-14T20:00:00+09:00" +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..840f6409 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# The vendored agent-skill documents are byte-exact copies of their upstream +# SKILL.md, embedded with `include_str!` and verified against a recorded +# SHA-256. Git must therefore never translate their line endings: a Windows +# checkout under the default `core.autocrlf=true` adds one byte per line, which +# made the integrity check fail on that platform alone while passing on Linux +# and macOS. `-text` is stronger than `eol=lf` on purpose - it says these bytes +# are not git's to touch in either direction. +crates/devup-mcp/src/server/skills/*.md -text +crates/devup-mcp/src/server/skills/manifest.json -text diff --git a/README.md b/README.md index 2e100515..12e310aa 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ Rust-native MCP server that reads Figma designs and generates DevupUI artifacts. ## 도구 -Figma 쪽 4개, 프로젝트 쪽 5개, 모두 9개입니다. +Figma 쪽 4개, 프로젝트 쪽 5개, 스킬 1개, 모두 10개입니다. - `devup_figma_export`: Figma를 한 번 수집해 요청한 `outputs`만 투영합니다. TSX가 산출물이고, `componentTsx`·`responsiveTsx`·`devup.json`·source map·asset manifest·reference PNG를 같은 수집에서 함께 얻거나, `cache.artifactId`로 재수집 없이 추가 투영할 수 있습니다. raw snapshot·raw payload는 구현이 아니라 진단에 쓰는 것이라 `debug: true`로만 열립니다. - `devup_figma_search`: page, section, frame, component를 이름으로 탐색. URL에 `node-id`가 있으면 **그 노드와 그 아래로 범위를 좁히고**, 없으면 파일 전체를 검색합니다. 둘 중 무엇을 했는지는 응답의 `scope`가 알려줍니다 - `devup_figma_explore`: 링크된 요구사항/라벨 주변의 실제 화면 후보를 공간 순서로 탐색 - `devup_figma_auth`: 연결 상태 확인, 브라우저 OAuth 로그인, 로그아웃, 사전 등록 자격증명 주입(`configure`), 연결 실패 원인을 실측해 보고하는 `doctor` +- `devup_skills`: devup-mcp가 내놓는 코드에 필요한 에이전트 스킬이 이 워크스페이스에 있는지 보고(`status`)하고, devup-mcp가 품고 있는 것을 설치(`install`). **텍스트를 응답에 실어 보내는 게 아니라 스킬 디렉터리에 설치해서 에이전트 자신의 로더가 읽게 합니다** — 한 번 읽은 문서는 한 번 쓰이지만, 설치된 스킬은 이후 모든 세션에 계속 적용됩니다 - `devup_project_context`: 프로젝트의 실제 `devup.json` 토큰, `openapi.json` 엔드포인트, Vespertide 모델을 읽음. 중첩 체크아웃과 빌드 산출물 디렉터리는 스캔에서 제외하고 무엇을 제외했는지 보고 - `devup_ui_validate`: 생성한 TSX를 프로젝트의 실제 `devup.json`에 대조해 검증. `ok`는 개수가 아니라 심각도로 판정 - `devup_stack_diff`: DB 모델부터 생성된 API 클라이언트까지의 층간 드리프트 탐지. 모든 발견은 명시적 `confidence`를 가짐 @@ -200,6 +201,35 @@ Figma MCP Catalog에 승인된 client(예: 직접 waitlist로 등록해 발급 자격증명이 해석되면 `devup_figma_auth { "action": "login" }`은 registration 엔드포인트를 전혀 호출하지 않고 바로 authorization_code + PKCE 흐름으로 진입합니다. 자격증명이 없으면 DCR을 시도하고, 403이면 그대로 보고합니다. DCR 요청의 `client_name` 기본값은 `"Codex"`입니다(`DEFAULT_CLIENT_NAME`). allowlist는 이름을 정확히 일치시켜 판정하고 `"devup-mcp"`는 거기에 없으므로, 그 이름으로 보내면 등록이 403으로 거절되어 direct 경로 자체가 성립하지 않습니다. 이 등록은 Figma에게 devup-mcp가 아니라 Codex로 기록됩니다. 본인 client가 카탈로그에 승인되면 `--figma-client-name` 또는 `DEVUP_FIGMA_CLIENT_NAME`으로 그 이름을 넘기세요. `client_secret`은 로그, 에러, MCP 응답, `doctor` 출력 어디에도 노출되지 않으며 `doctor`는 `credentialSource`로 존재 여부만 보고합니다. +## 에이전트 스킬 — 빈 PC에서 헛짓거리하지 않게 + +devup-mcp가 돌려주는 TSX는 **devup-ui 코드**입니다. devup-mcp만 깔린 기계의 에이전트는 devup-ui를 본 적이 없습니다 — `@devup-ui/react` 컴포넌트가 빌드 타임 placeholder라는 것도, `$token`이 `devup.json`을 가리킨다는 것도, 스타일 prop이 반응형 배열을 받는다는 것도 모릅니다. 그래서 지어냅니다. **이 서버는 그 추측을 볼 수도 고칠 수도 없습니다.** + +응답에 규칙을 붙여 보내는 것으로는 부족합니다. 에이전트 런타임에는 이미 `SKILL.md`를 읽어 자기 트리거로 꺼내 주는 로더가 있고, **한 번 던져준 문서는 한 번 읽히지만 설치된 스킬은 그 뒤 모든 세션에 계속 적용**됩니다. 그래서 `devup_skills`는 텍스트를 던지지 않고 **설치 여부를 보고하고 설치합니다.** + +```json +{ "action": "status" } +``` + +스킬마다 `installed`와, 아니라면 그것을 메우는 **한 가지 동작**을 돌려줍니다. 출처에 따라 동작이 다릅니다. + +| 스킬 | 출처 | devup-mcp가 하는 일 | +|---|---|---| +| `devup-ui` · `vespera` · `vespertide` | dev-five-git (우리 것) | **바이너리에 내장.** `{"action":"install"}`이 네트워크 없이 스킬 디렉터리에 씁니다 | +| `vercel-react-best-practices` · `vercel-react-view-transitions` | vercel-labs/agent-skills | **내장하지 않음.** 설치 명령 `npx skills add vercel-labs/agent-skills`를 넘길 뿐, 실행하지 않습니다 | + +vercel 것을 내장하지 않는 이유는 두 가지입니다. **`vercel-labs/agent-skills`에는 LICENSE 파일이 없어** 재배포할 권리가 없고, 그 스킬들은 단일 파일이 아니라 `SKILL.md` + `AGENTS.md` + 규칙 파일 수십 개(합쳐 ~350 KB)라서 애초에 던져줄 물건이 아니라 설치할 물건입니다. + +**devup-mcp는 그 명령을 대신 실행하지 않습니다.** 디자인→코드 서버가 패키지 설치기를 실행하면, 레지스트리 항목 하나가 오염됐을 때 화면을 export한 모든 기계에서 임의 실행이 됩니다. + +설치 위치는 프로젝트 안입니다 — 이미 있는 것을 우선해 `.claude/skills`, `.opencode/skill`, `.agents/skills` 순으로 고릅니다. 프로젝트 루트는 devup-mcp가 쓸 수 있는 유일한 곳이라 새 권한이 필요 없고, 스킬이 저장소를 따라다닙니다. 이미 깔려 있으면 다시 쓰지 않습니다. + +내장본은 각 레포의 `SKILL.md`를 그대로 복사한 것이고, 응답과 설치된 파일 모두 **어느 커밋인지와 최신본 URL**을 함께 답니다. 사본은 낡습니다 — 그게 내장의 정직한 비용이고, `node scripts/refresh-skills.mjs`가 그걸 갱신하는 방법입니다(`--check`는 쓰지 않고 드리프트만 보고). 주석은 YAML frontmatter **뒤에** 들어갑니다. `---`는 0번째 바이트에 있어야 로더가 읽습니다. + +설치하지 않고 읽기만 하려면 `devup://skill/devup-ui` 리소스도 있습니다. 다만 그건 fallback입니다 — 설치해야 로더가 알아서 꺼내 줍니다. + +유도는 두 곳에서만 합니다. 세션마다 실리는 `instructions`의 한 줄, 그리고 `devup_ui_validate`가 위반을 찾았는데 devup-ui 스킬이 **실제로 없을 때만** 붙는 `skillGap`입니다. 이미 깔려 있는 사람에게 깔라고 하는 것은 그 필드를 무시하게 만드는 소음입니다. + ## Figma 연결 설정 devup-mcp가 Figma에 붙는 경로는 **둘**이고, 대등하지 않습니다. diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index 2dfc7dc8..40c1adbb 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -35,6 +35,8 @@ pub const INSTRUCTIONS: &str = concat!( "1. devup-mcp is the primary source for turning a Figma design into code. Do not replace it with another source.\n", "2. When the goal is implementation, call devup_figma_export first and take tsx. That is the deliverable; ", "a complete response marks it with deliverable.isFinal.\n", + "2b. The tsx is devup-ui code. Call devup_skills before writing it: it reports the conventions this ", + "workspace lacks and installs the ones it carries.\n", "3. Request only the outputs you will read, and read the rest of the rules before your second call: ", "resources/read \"devup://guide/usage\" carries output sizing, verification boundaries, SECTION batching, ", "asset placement and delivery. It is a resource so that a caller who never touches Figma never pays for it." @@ -139,7 +141,7 @@ mod tests { fn every_original_rule_number_survives_the_move() { let combined = format!("{INSTRUCTIONS}\n{GUIDE}"); for number in [ - "1.", "2.", "2a.", "3.", "4.", "5.", "6.", "7.", "8.", "10.", "11.", "12.", + "1.", "2.", "2a.", "2b.", "3.", "4.", "5.", "6.", "7.", "8.", "10.", "11.", "12.", ] { assert!( combined.contains(number), diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 48b9774b..a575cddf 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -16,6 +16,7 @@ mod quality; mod release_check; pub mod resources; mod result_contract; +mod skills; mod stack_diff; mod tools; mod validation; @@ -64,7 +65,7 @@ use validation::{ pub use tools::{ AuthInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, FigmaSearchInput, - ProjectContextInput, StackDiffInput, UiValidateInput, + ProjectContextInput, SkillsInput, StackDiffInput, UiValidateInput, }; /// Additive workflow options; existing input defaults remain in tools.rs. @@ -908,6 +909,40 @@ fn file_scope_url(target: &FigmaTarget) -> String { #[tool_router] impl DevupServer { + #[tool( + description = "Report which agent skills the code devup-mcp emits depends on and whether this workspace has them, then install the ones devup-mcp carries (action: status | install). \ + The TSX devup_figma_export returns is devup-ui code, and an agent that has never seen devup-ui does not know its components are compile-time placeholders, that $token means devup.json, or that a style prop takes a responsive array - it guesses, and this server cannot see the guesses. \ + Call status before writing or editing that code. Anything reported missing is a gap you can close in one step. \ + install writes the vendored SKILL.md for devup-ui, vespera and vespertide into the workspace skill root (.claude/skills, .opencode/skill or .agents/skills - an existing one is preferred), with no network. Load them afterwards the way your runtime loads a project skill; an installed skill keeps applying to later sessions, which reading a document once does not. \ + External skills are reported, never written: devup-mcp hands over its publisher's install command and does not run it.", + output_schema = permissive_object_output_schema() + )] + async fn devup_skills( + &self, + Parameters(input): Parameters, + ) -> Result { + match input.action.as_str() { + "status" => Ok(tool_result(skills::report( + self.output_policy.primary_root(), + ))), + "install" => { + let outcome = + skills::install(&self.output_policy, &input.names).map_err(to_mcp_error)?; + // The state after the write, from the same reader `status` + // uses. An install that reports what it meant to do rather than + // what is now on disk is the report that cannot be trusted. + let mut result = outcome; + result["state"] = skills::report(self.output_policy.primary_root()); + Ok(tool_result(result)) + } + other => Err(to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + format!("action must be status or install, not {other}."), + false, + ))), + } + } + #[tool( description = "Check, start, or clear Figma Remote MCP OAuth, or inject a pre-registered client credential to skip Dynamic Client Registration (action: status | login | logout | configure | doctor)", output_schema = permissive_object_output_schema() @@ -1609,6 +1644,24 @@ impl DevupServer { .clone(), ); } + // A violation is a located, proven gap in devup-ui knowledge, which + // makes this the one moment where naming the skill is a measurement + // rather than a nudge. Raised only when the skill is actually absent: + // telling a caller who already has it to install it is the noise that + // teaches them to skip the field. + let workspace = self.output_policy.primary_root(); + if !report.violations.is_empty() + && skills::installed_paths(workspace, "devup-ui").is_empty() + && let Some(skill) = skills::find_by_name("devup-ui") + { + result["skillGap"] = json!({ + "skill": "devup-ui", + "why": "This code broke devup-ui rules, and the devup-ui skill is not installed in \ + this workspace. Installing it puts the rules in front of you while you \ + write, instead of after this tool has already refused the result.", + "install": skill.install_action(workspace), + }); + } Ok(tool_result(result)) } diff --git a/crates/devup-mcp/src/server/output.rs b/crates/devup-mcp/src/server/output.rs index 6bdf65ef..89d61541 100644 --- a/crates/devup-mcp/src/server/output.rs +++ b/crates/devup-mcp/src/server/output.rs @@ -126,6 +126,14 @@ impl OutputPolicy { }) } + /// The root a relative `outputPath` lands in. Named so a caller can ask + /// where it is allowed to work without first resolving a file inside it - + /// `devup_skills` reports install state for the workspace, which is this + /// directory and nowhere else. + pub fn primary_root(&self) -> &Path { + &self.roots[0].display_path + } + pub fn resolve(&self, requested: &str) -> Result { let path = Path::new(requested); if requested.trim().is_empty() { diff --git a/crates/devup-mcp/src/server/resources.rs b/crates/devup-mcp/src/server/resources.rs index a66207b3..8e1774b0 100644 --- a/crates/devup-mcp/src/server/resources.rs +++ b/crates/devup-mcp/src/server/resources.rs @@ -8,6 +8,7 @@ use serde_json::Value; use super::artifacts::{ArtifactStore, AttachedOutputManifest}; use super::guide; +use super::skills; const LIST_PAGE_SIZE: usize = 50; @@ -76,6 +77,12 @@ pub async fn list_output_resources( .map(manifest_resource) .collect::>(); listed.push(guide_resource()); + // The embedded skills sit beside the guide, after it, for the same reason: + // a caller indexing into this list by position must keep its positions. + // They are a fallback here, not the main road - `devup_skills` installs + // them into the runtime's own loader, and this is for reading one without + // installing it. + listed.extend(skills::all().iter().filter_map(skill_resource)); if offset > listed.len() { return Err(invalid_request()); } @@ -94,6 +101,23 @@ fn guide_resource() -> Resource { .with_mime_type(guide::GUIDE_MIME_TYPE) } +/// Only the embedded skills are readable here. An external one has no bytes in +/// this binary, so publishing a URI for it would advertise a document that +/// cannot be served. +fn skill_resource(skill: &'static skills::Skill) -> Option { + skill.text?; + Some( + Resource::new(skill.uri.clone(), skill.resource_name.clone()) + .with_title(skill.record.title.clone()) + .with_description(format!( + "{} Installing it with devup_skills is better than reading it here: your skill \ + loader then applies it on its own triggers, in this session and later ones.", + skill.record.description + )) + .with_mime_type(skills::MIME_TYPE), + ) +} + pub fn resource_templates() -> ListResourceTemplatesResult { ListResourceTemplatesResult::with_all_items(vec![ ResourceTemplate::new( @@ -124,6 +148,15 @@ pub async fn read_output_resource( ResourceContents::text(guide::GUIDE, uri).with_mime_type(guide::GUIDE_MIME_TYPE), ])); } + // Same reasoning as the guide, and the same independence from artifacts: a + // skill is readable in a session that has exported nothing at all. + if let Some(skill) = skills::find_by_uri(uri) + && let Some(document) = skill.document() + { + return Ok(ReadResourceResult::new(vec![ + ResourceContents::text(document, uri).with_mime_type(skills::MIME_TYPE), + ])); + } match ResourceAddress::parse(uri)? { ResourceAddress::Manifest { artifact_id, diff --git a/crates/devup-mcp/src/server/skills.rs b/crates/devup-mcp/src/server/skills.rs new file mode 100644 index 00000000..2ab122be --- /dev/null +++ b/crates/devup-mcp/src/server/skills.rs @@ -0,0 +1,717 @@ +//! The skills an agent needs for the code devup-mcp emits, and whether they +//! are installed. +//! +//! devup-mcp hands back devup-ui TSX. On a machine that has devup-mcp and +//! nothing else, the agent receiving that TSX has never seen devup-ui: it does +//! not know that `@devup-ui/react` components are compile-time placeholders, +//! that `$token` refers to `devup.json`, or that a style prop takes a +//! responsive array. It guesses, and the guesses are wrong in ways this server +//! can neither see nor correct. +//! +//! The fix is not to paste the rules into a response. Every agent runtime +//! already has a skill loader that reads `SKILL.md` files from a directory and +//! surfaces them by their own triggers, at the moment they apply. A blob +//! returned once is read once; an installed skill keeps working for the rest of +//! the session and every session after it. So this module's job is to report +//! **whether each skill is installed** and hand over the one action that +//! installs it - a concrete gap the agent can close, rather than advice. +//! +//! ## Two origins, and why they are handled differently +//! +//! `embedded` skills are DevFive's own - devup-ui, vespera, vespertide. Their +//! canonical `SKILL.md` is vendored into the binary, so installing them needs +//! no network. That matters because the situation this exists for, a bare +//! machine, is the one in which a download is least likely to work. +//! +//! `external` skills belong to someone else. vercel-labs/agent-skills publishes +//! **no LICENSE file**, so its content is all-rights-reserved and devup-mcp +//! neither vendors nor redistributes it. They are also multi-file - one of them +//! is a `SKILL.md` plus an `AGENTS.md` and some seventy rule files - so copying +//! them was never the right shape anyway. devup-mcp reports where they come +//! from and the command their publisher provides, and the agent runs it. +//! +//! devup-mcp never executes that command. A design-to-code server that shells +//! out to a package installer is both out of character and a way to turn one +//! compromised registry entry into arbitrary execution on every machine that +//! ever exported a screen. + +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use serde::Deserialize; + +pub const MIME_TYPE: &str = "text/markdown"; + +/// The registry. Parsed rather than duplicated into consts so that refreshing a +/// skill touches one file a script can write, instead of a JSON file and a Rust +/// literal that can disagree about which commit is in the binary. +pub const MANIFEST_JSON: &str = include_str!("skills/manifest.json"); + +/// Name to text, for the embedded origin only. `include_str!` needs a literal +/// path, so this is the one place the set is spelled out; +/// [`tests::embedded_and_external_entries_are_each_well_formed`] holds it to the +/// manifest in both directions. +const EMBEDDED: &[(&str, &str)] = &[ + ("devup-ui", include_str!("skills/devup-ui.md")), + ("vespera", include_str!("skills/vespera.md")), + ("vespertide", include_str!("skills/vespertide.md")), +]; + +/// Where agent runtimes keep project-local skills, in the order they are +/// preferred when none exists yet. +/// +/// Project-local rather than the user's home directory on purpose: the project +/// root is already the only place devup-mcp is allowed to write, so installing +/// here needs no new permission, and the skills travel with the repository +/// instead of being a thing each machine has to be told about separately. +/// +/// Each entry is a directory holding one subdirectory per skill, each with a +/// `SKILL.md` inside - the layout every one of these runtimes reads. +pub const SKILL_ROOTS: &[&str] = &[".claude/skills", ".opencode/skill", ".agents/skills"]; + +/// Joins a `SKILL_ROOTS` entry onto a project directory one component at a +/// time. +/// +/// `Path::join` treats `".claude/skills"` as a single component and keeps the +/// forward slash verbatim, so on Windows the reported path came back as +/// `C:\work\.claude/skills\devup-ui\SKILL.md`. It opens either way, but a path +/// a reader has to squint at is one they cannot confidently compare to what +/// their editor shows them. +fn join_root(project: &Path, root: &str) -> PathBuf { + root.split('/') + .fold(project.to_path_buf(), |path, part| path.join(part)) +} + +/// The byte offset just past a leading YAML frontmatter block, if there is one. +/// +/// Only a `---` line at byte zero opens a block, which is what every skill +/// loader requires; a `---` further down is a horizontal rule and closing on it +/// would cut the document in half. +fn frontmatter_end(text: &str) -> Option { + let rest = text + .strip_prefix("---\n") + .or(text.strip_prefix("---\r\n"))?; + let opened = text.len() - rest.len(); + let mut offset = opened; + for line in rest.split_inclusive('\n') { + offset += line.len(); + if matches!(line.trim_end_matches(['\r', '\n']), "---") { + return Some(offset); + } + } + None +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Origin { + /// Vendored into this binary; installable with no network. + Embedded, + /// Someone else's, installed from source by the agent. + External, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillRecord { + pub name: String, + pub origin: Origin, + pub title: String, + pub description: String, + /// Which devup-mcp output or input this project's rules govern - the reason + /// to install it, rather than a second copy of the description. + pub used_for: String, + pub repo: String, + pub path: String, + pub source_url: String, + pub latest_url: String, + + // Embedded only. + pub commit: Option, + pub committed_at: Option, + pub sha256: Option, + pub bytes: Option, + + // External only. + pub install_command: Option, + pub license: Option, + pub license_note: Option, +} + +#[derive(Debug, Deserialize)] +struct Manifest { + skills: Vec, +} + +pub struct Skill { + pub record: SkillRecord, + pub uri: String, + /// The MCP resource name. Prefixed so it cannot collide with the usage + /// guide or with a generated output manifest. + pub resource_name: String, + /// `Some` for the embedded origin, `None` for external. + pub text: Option<&'static str>, +} + +static SKILLS: LazyLock> = LazyLock::new(|| { + let manifest: Manifest = + serde_json::from_str(MANIFEST_JSON).expect("the vendored skill manifest is valid JSON"); + manifest + .skills + .into_iter() + .map(|record| { + let text = (record.origin == Origin::Embedded).then(|| { + EMBEDDED + .iter() + .find(|(name, _)| *name == record.name) + .map(|(_, text)| *text) + .expect("every embedded manifest entry has a document") + }); + Skill { + uri: uri_for(&record.name), + resource_name: format!("devup-skill-{}", record.name), + text, + record, + } + }) + .collect() +}); + +pub fn uri_for(name: &str) -> String { + format!("devup://skill/{name}") +} + +pub fn all() -> &'static [Skill] { + &SKILLS +} + +pub fn find_by_uri(uri: &str) -> Option<&'static Skill> { + SKILLS.iter().find(|skill| skill.uri == uri) +} + +pub fn find_by_name(name: &str) -> Option<&'static Skill> { + SKILLS.iter().find(|skill| skill.record.name == name) +} + +/// Which skill roots already exist under `project`, in preference order. +/// +/// Existence is the signal. A repository that already has `.claude/skills` has +/// answered the question of which runtime it is for, and guessing differently +/// would install into a directory nothing reads. +pub fn existing_roots(project: &Path) -> Vec { + SKILL_ROOTS + .iter() + .map(|root| join_root(project, root)) + .filter(|path| path.is_dir()) + .collect() +} + +/// Where a skill's `SKILL.md` lives under a given root. +pub fn install_path(root: &Path, name: &str) -> PathBuf { + root.join(name).join("SKILL.md") +} + +/// The root an install would use: the first that already exists, else the +/// first known convention. +pub fn target_root(project: &Path) -> PathBuf { + existing_roots(project) + .into_iter() + .next() + .unwrap_or_else(|| join_root(project, SKILL_ROOTS[0])) +} + +/// Every place this skill could already be installed under `project`. +/// +/// All roots are checked, not just the preferred one: a skill installed by hand +/// into `.agents/skills` is installed, and reporting it missing would have the +/// agent write a second copy that then drifts from the first. +pub fn installed_paths(project: &Path, name: &str) -> Vec { + SKILL_ROOTS + .iter() + .map(|root| install_path(&join_root(project, root), name)) + .filter(|path| path.is_file()) + .collect() +} + +impl Skill { + /// The provenance a reader needs alongside the text: which revision this + /// is, and where the current one lives. Carried in the body rather than + /// returned beside it, because the installed file outlives this response + /// and a reader who finds rules on disk with no revision cannot tell + /// whether to trust them over the repository. + /// + /// Placed **after** the YAML frontmatter, never before it. A `SKILL.md` + /// begins with `---` and every skill loader reads that delimiter at byte + /// zero; a comment in front of it makes the frontmatter unparseable, and an + /// installed skill that does not load is worse than no install at all - + /// it looks done. This is the whole feature's failure mode, so the offset + /// is computed rather than assumed, and falls back to prepending only for a + /// document that has no frontmatter to protect. + /// + /// `None` for external skills, which have no text here to carry. + pub fn document(&self) -> Option { + let text = self.text?; + let r = &self.record; + let note = format!( + "", + repo = r.repo, + path = r.path, + commit = r.commit.as_deref().unwrap_or("unknown"), + at = r.committed_at.as_deref().unwrap_or("unknown"), + source = r.source_url, + latest = r.latest_url, + used = r.used_for, + ); + Some(match frontmatter_end(text) { + Some(end) => format!("{}\n{note}\n{}", &text[..end], &text[end..]), + None => format!("{note}\n\n{text}"), + }) + } + + /// The one action that closes the gap, in the imperative, with everything + /// needed to carry it out. + pub fn install_action(&self, project: &Path) -> serde_json::Value { + let installed = installed_paths(project, &self.record.name); + if !installed.is_empty() { + let paths = installed + .iter() + .map(|path| path.display().to_string()) + .collect::>(); + return serde_json::json!({ + "installed": true, + "paths": paths, + "action": null, + "how": "Already installed. Your skill loader picks it up by its own triggers; \ + nothing to do.", + }); + } + match self.record.origin { + Origin::Embedded => { + let roots = existing_roots(project); + let target = target_root(project); + serde_json::json!({ + "installed": false, + "paths": [], + "action": "devup_skills", + "arguments": {"action": "install", "names": [self.record.name]}, + "writesTo": install_path(&target, &self.record.name).display().to_string(), + "how": "Call devup_skills with action \"install\". The document is inside this \ + binary, so it needs no network. Then load it the way your runtime loads \ + a project skill.", + "rootChoice": if roots.is_empty() { + format!("No skill root exists yet, so {} is created.", SKILL_ROOTS[0]) + } else { + format!("Using the existing root {}.", target.display()) + }, + }) + } + Origin::External => serde_json::json!({ + "installed": false, + "paths": [], + "action": "run-this-yourself", + "command": self.record.install_command, + "how": "devup-mcp does not vendor or run this. Run the command yourself, then load \ + the skill the way your runtime loads an installed skill.", + "whyNotBundled": self.record.license_note, + "source": self.record.source_url, + }), + } + } +} + +/// What every caller of `devup_skills` gets back: the gap, per skill. +/// +/// The shape is deliberately the same for `status` and after `install`, so the +/// second call is how the agent confirms the first one worked rather than +/// something it has to take on trust. +pub fn report(project: &Path) -> serde_json::Value { + let entries = all() + .iter() + .map(|skill| { + let r = &skill.record; + let mut entry = serde_json::json!({ + "name": r.name, + "origin": match r.origin { Origin::Embedded => "embedded", Origin::External => "external" }, + "title": r.title, + "description": r.description, + "whyYouNeedIt": r.used_for, + "source": r.source_url, + "latest": r.latest_url, + }); + if let Some(commit) = &r.commit { + entry["embeddedRevision"] = serde_json::json!({ + "commit": commit, + "committedAt": r.committed_at, + "bytes": r.bytes, + "sha256": r.sha256, + "note": "The revision inside this binary. It does not move when the \ + repository does; `latest` is where a newer one would be.", + }); + entry["offlineRead"] = serde_json::json!(skill.uri); + } + if r.origin == Origin::External { + entry["license"] = serde_json::json!(r.license); + } + let action = skill.install_action(project); + entry["installed"] = action["installed"].clone(); + entry["installState"] = action; + entry + }) + .collect::>(); + let missing = entries + .iter() + .filter(|entry| entry["installed"] == false) + .count(); + serde_json::json!({ + "workspace": project.display().to_string(), + "skillRoots": { + "known": SKILL_ROOTS, + "existing": existing_roots(project) + .iter() + .map(|path| path.display().to_string()) + .collect::>(), + "wouldUse": target_root(project).display().to_string(), + }, + "installedCount": entries.len() - missing, + "missingCount": missing, + "skills": entries, + "how": "These are the conventions for the code devup-mcp emits and reads. Install the \ + missing ones, then let your own skill loader surface them - an installed skill \ + keeps applying for every later session, which is the thing reading a document \ + once does not do.", + "boundary": "devup-mcp installs only what it carries. It does not download anything and \ + does not run any install command; for an external skill the command is \ + yours to run.", + }) +} + +/// Writes the vendored documents for the embedded skills that are missing. +/// +/// Goes through the same [`OutputPolicy`] every other file this server writes +/// goes through, so a skill lands under an allowed write root or not at all, +/// and through one [`OutputTransaction`], so a partial install does not leave +/// half a set behind. +/// +/// [`OutputPolicy`]: super::output::OutputPolicy +/// [`OutputTransaction`]: super::output::OutputTransaction +pub fn install( + policy: &super::output::OutputPolicy, + requested: &[String], +) -> Result { + use devup_mcp_figma::{DevupError, ErrorCode}; + + let project = policy.primary_root().to_path_buf(); + if let Some(unknown) = requested.iter().find(|name| find_by_name(name).is_none()) { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!("{unknown} is not a skill devup-mcp knows about."), + false, + serde_json::json!({ + "known": all().iter().map(|s| &s.record.name).collect::>(), + }), + )); + } + + let wanted: Vec<&Skill> = all() + .iter() + .filter(|skill| requested.is_empty() || requested.contains(&skill.record.name)) + .collect(); + + // Named explicitly or not, an external skill cannot be written from here. + // Saying so per skill, rather than refusing the call, keeps a plain + // `install` with no names working as "install everything you can". + let external = wanted + .iter() + .filter(|skill| skill.record.origin == Origin::External) + .map(|skill| { + serde_json::json!({ + "name": skill.record.name, + "command": skill.record.install_command, + "why": skill.record.license_note, + "source": skill.record.source_url, + }) + }) + .collect::>(); + + let root = target_root(&project); + let mut transaction = super::output::OutputTransaction::new(); + let mut written = Vec::new(); + let mut already = Vec::new(); + for skill in wanted + .iter() + .filter(|skill| skill.record.origin == Origin::Embedded) + { + let name = &skill.record.name; + if !installed_paths(&project, name).is_empty() { + already.push(name.clone()); + continue; + } + let target = policy.resolve(&install_path(&root, name).display().to_string())?; + let path = target.display_path().display().to_string(); + let document = skill + .document() + .expect("an embedded skill always has a document"); + transaction.stage(format!("skill:{name}"), target, document.as_bytes())?; + written.push(serde_json::json!({"name": name, "path": path})); + } + transaction.commit()?; + + Ok(serde_json::json!({ + "installed": written, + "alreadyPresent": already, + "notInstallable": external, + "root": root.display().to_string(), + "nextAction": if written.is_empty() && external.is_empty() { + serde_json::Value::Null + } else { + serde_json::json!({ + "how": "Load the newly installed skills the way your runtime loads a project \ + skill, and run any command under notInstallable yourself. Call \ + devup_skills again to confirm the state changed.", + }) + }, + "boundary": "Only the documents devup-mcp carries were written. Nothing was downloaded \ + and no install command was executed.", + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + + fn scratch(label: &str) -> PathBuf { + let path = + std::env::temp_dir().join(format!("devup-skills-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("scratch directory"); + path + } + + /// Each origin has its own obligations, and a record that mixes them will + /// mislead. An embedded entry must carry the revision it embeds and match it + /// byte for byte; an external entry must carry no bytes at all and must say + /// how to install it instead. + #[test] + fn embedded_and_external_entries_are_each_well_formed() { + let manifest: Manifest = serde_json::from_str(MANIFEST_JSON).unwrap(); + let embedded = manifest + .skills + .iter() + .filter(|record| record.origin == Origin::Embedded) + .count(); + assert_eq!( + embedded, + EMBEDDED.len(), + "manifest and embedded set disagree on how many documents are in the binary" + ); + + for record in &manifest.skills { + match record.origin { + Origin::Embedded => { + let (_, text) = EMBEDDED + .iter() + .find(|(name, _)| *name == record.name) + .unwrap_or_else(|| panic!("{} is embedded in name only", record.name)); + assert_eq!( + Some(text.len()), + record.bytes, + "{}: byte count", + record.name + ); + let digest: String = Sha256::digest(text.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + assert_eq!( + Some(&digest), + record.sha256.as_ref(), + "{}: the vendored document was edited without updating its record. \ + The source of truth is {}, not this copy.", + record.name, + record.repo + ); + let commit = record + .commit + .as_deref() + .expect("embedded entries pin a commit"); + assert!( + record.source_url.contains(commit), + "{}: sourceUrl does not pin {commit}", + record.name + ); + } + Origin::External => { + assert!( + !EMBEDDED.iter().any(|(name, _)| *name == record.name), + "{}: external content must not be in the binary", + record.name + ); + assert!( + record.bytes.is_none() && record.sha256.is_none(), + "{}: an external entry claims embedded bytes", + record.name + ); + assert!( + record + .install_command + .as_deref() + .is_some_and(|command| !command.trim().is_empty()), + "{}: external with no way to install it is a dead end", + record.name + ); + assert!( + record.license_note.is_some(), + "{}: not bundling someone else's work has to say why", + record.name + ); + } + } + } + } + + /// The reported gap has to match the disk, in both directions, or the + /// inducement is noise: a false "missing" makes the agent write a duplicate + /// copy, and a false "installed" leaves it guessing devup-ui forever. + #[test] + fn install_state_follows_the_disk_across_every_known_root() { + let project = scratch("state"); + let skill = find_by_name("devup-ui").expect("devup-ui is registered"); + + let missing = skill.install_action(&project); + assert_eq!(missing["installed"], false); + assert_eq!(missing["action"], "devup_skills"); + + // Installed by hand into the last root, not the preferred one. + let root = join_root(&project, SKILL_ROOTS[SKILL_ROOTS.len() - 1]); + let path = install_path(&root, "devup-ui"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "# installed by hand").unwrap(); + + let found = skill.install_action(&project); + assert_eq!( + found["installed"], true, + "a hand-installed skill is installed" + ); + assert_eq!(found["action"], serde_json::Value::Null); + assert_eq!(found["paths"].as_array().unwrap().len(), 1); + + let _ = std::fs::remove_dir_all(&project); + } + + /// An external skill is never answered with a devup-mcp call, because there + /// is nothing here to install. It has to hand over its publisher's command + /// and say why the bytes are not ours to ship. + #[test] + fn an_external_skill_hands_over_the_command_and_never_a_local_write() { + let project = scratch("external"); + let skill = find_by_name("vercel-react-best-practices").expect("registered"); + assert!( + skill.text.is_none(), + "external content is not in the binary" + ); + assert!(skill.document().is_none()); + + let action = skill.install_action(&project); + assert_eq!(action["action"], "run-this-yourself"); + assert_eq!(action["command"], "npx skills add vercel-labs/agent-skills"); + assert!( + action["writesTo"].is_null(), + "devup-mcp writes nothing for it" + ); + assert!( + action["whyNotBundled"] + .as_str() + .unwrap() + .contains("no LICENSE") + ); + + let _ = std::fs::remove_dir_all(&project); + } + + /// An existing root is the project answering which runtime it is for. + /// Guessing differently installs into a directory nothing reads. + #[test] + fn an_existing_skill_root_is_preferred_over_the_default() { + let project = scratch("root"); + let chosen = join_root(&project, ".opencode/skill"); + std::fs::create_dir_all(&chosen).unwrap(); + + let skill = find_by_name("devup-ui").unwrap(); + let action = skill.install_action(&project); + let writes_to = action["writesTo"].as_str().unwrap(); + assert!( + writes_to.contains(".opencode"), + "existing root ignored; would write to {writes_to}" + ); + + let _ = std::fs::remove_dir_all(&project); + } + + /// devup-mcp emits devup-ui TSX, so that is the skill whose absence produced + /// the wrong code this module exists to stop. + #[test] + fn devup_ui_is_embedded_addressable_and_carries_its_age() { + let skill = find_by_uri("devup://skill/devup-ui").expect("addressable"); + assert_eq!(skill.resource_name, "devup-skill-devup-ui"); + let text = skill.text.expect("embedded"); + assert!(text.contains("Cannot run on the runtime")); + + let document = skill + .document() + .expect("embedded documents carry provenance"); + assert!(document.contains(skill.record.commit.as_deref().unwrap())); + } + + /// The failure this feature cannot survive: an installed skill that does + /// not load. + /// + /// A `SKILL.md` opens with `---` and every loader reads that delimiter at + /// byte zero. Putting the provenance comment in front of it leaves a file + /// that looks installed, is listed as installed, and is silently never + /// applied - which is worse than not installing it, because nothing shows + /// up to say so. + #[test] + fn an_installed_document_still_opens_with_its_frontmatter() { + for skill in all().iter().filter(|skill| skill.text.is_some()) { + let document = skill.document().expect("embedded"); + assert!( + document.starts_with("---\n") || document.starts_with("---\r\n"), + "{}: frontmatter no longer opens the file", + skill.record.name + ); + // The note landed inside the body, and the original frontmatter + // keys are still inside the block rather than pushed out of it. + let end = frontmatter_end(&document).expect("the block still closes"); + let head = &document[..end]; + assert!(head.contains("name:"), "{}: {head}", skill.record.name); + assert!( + !head.contains("Vendored from"), + "{}: the note must sit outside the frontmatter block", + skill.record.name + ); + assert!(document.contains("Vendored from dev-five-git/")); + // Nothing of the original was dropped on the way through. The note + // splits it, so the two halves are checked rather than the whole. + let text = skill.text.unwrap(); + let split = frontmatter_end(text).expect("a vendored skill has frontmatter"); + assert!(document.contains(&text[..split]), "{}", skill.record.name); + assert!(document.ends_with(&text[split..]), "{}", skill.record.name); + } + } + + /// A document with no frontmatter has nothing to protect, so the note goes + /// where a reader sees it first. + #[test] + fn a_document_without_frontmatter_is_not_mistaken_for_one() { + assert_eq!(frontmatter_end("# Plain\n\nbody\n"), None); + assert_eq!(frontmatter_end("---\nname: x\n---\nbody\n"), Some(16)); + // A horizontal rule further down does not close a block that never + // opened. + assert_eq!(frontmatter_end("intro\n\n---\n\nmore\n"), None); + } +} diff --git a/crates/devup-mcp/src/server/skills/devup-ui.md b/crates/devup-mcp/src/server/skills/devup-ui.md new file mode 100644 index 00000000..7f3c8005 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/devup-ui.md @@ -0,0 +1,505 @@ +--- +name: devup-ui +description: | + Zero-runtime CSS-in-JS preprocessor for React. Transforms JSX styles to static CSS at build time. + + TRIGGER WHEN: + - Writing/modifying Devup UI components (Box, Flex, Grid, Text, Button, etc.) + - Using styling APIs: css(), globalCss(), keyframes() + - Configuring devup.json theme (colors, typography, length, shadow, extends) + - Setting up build plugins (Vite, Next.js, Webpack, Rsbuild, Bun) + - Debugging "Cannot run on the runtime" errors + - Working with responsive arrays, pseudo-selectors (_hover, _dark, etc.) + - Using polymorphic `as` prop or `selectors` prop + - Working with @devup-ui/components (Button, Input, Select, Toggle, etc.) + - Using responsive length tokens ($containerX, $gutter) or shadow tokens ($card, $sm) +--- + +# Devup UI + +Build-time CSS extraction. No runtime JS for styling. + +## Critical: Components Are Compile-Time Only + +All `@devup-ui/react` components throw `Error('Cannot run on the runtime')`. They are **placeholders** that build plugins transform to native HTML elements with classNames. + +```tsx +// BEFORE BUILD (what you write): + + +// AFTER BUILD (what runs in browser): +
// + CSS: .a{background:red} .b{padding:16px} .c:hover{background:blue} +``` + +## Components + +### @devup-ui/react (Layout Primitives) + +All are polymorphic (accept `as` prop). Default element is `
` unless noted. + +| Component | Default Element | Purpose | +|-----------|----------------|---------| +| `Box` | `div` | Base layout primitive, accepts all style props | +| `Flex` | `div` | Flexbox container (shorthand for `display: flex`) | +| `Grid` | `div` | CSS Grid container | +| `VStack` | `div` | Vertical stack (flex column) | +| `Center` | `div` | Centered content | +| `Text` | `p` | Text/typography | +| `Image` | `img` | Image element | +| `Input` | `input` | Input element | +| `Button` | `button` | Button element | +| `ThemeScript` | -- | SSR theme hydration (add to ``) | + +### @devup-ui/components (Pre-built UI) + +Higher-level components with built-in behavior. These are **runtime components** (not compile-time only). + +| Component | Key Props | +|-----------|-----------| +| `Button` | `variant` (`primary`/`default`), `size` (`sm`/`md`/`lg`), `loading`, `danger`, `icon`, `colors` | +| `Checkbox` | `children` (label), `onChange(checked)`, `colors` | +| `Input` | `error`, `errorMessage`, `allowClear`, `icon`, `typography`, `colors` | +| `Textarea` | `error`, `errorMessage`, `typography`, `colors` | +| `Radio` | `variant` (`default`/`button`), `colors` | +| `RadioGroup` | `options[]`, `direction` (`row`/`column`), `variant`, `value`, `onChange` | +| `Toggle` | `variant` (`default`/`switch`), `value`, `onChange(boolean)`, `colors` | +| `Select` | `type` (`default`/`radio`/`checkbox`), `options[]`, `value`, `onChange`, `colors` | +| `Stepper` | `min`, `max`, `type` (`input`/`text`), `value`, `onValueChange` | + +**Select compound:** `SelectTrigger`, `SelectContainer`, `SelectOption`, `SelectDivider` +**Stepper compound:** `StepperContainer`, `StepperDecreaseButton`, `StepperIncreaseButton`, `StepperInput` +**Hooks:** `useSelect()`, `useStepper()` + +All components accept a `colors` prop object for runtime color customization via CSS variables. + +## Style Prop Syntax + +### Shorthand Props (ALWAYS prefer these) + +**Spacing (unitless number x 4 = px)** + +| Shorthand | CSS Property | +|-----------|-------------| +| `m`, `mt`, `mr`, `mb`, `ml`, `mx`, `my` | margin-* | +| `p`, `pt`, `pr`, `pb`, `pl`, `px`, `py` | padding-* | + +**Sizing** + +| Shorthand | CSS Property | +|-----------|-------------| +| `w` | width | +| `h` | height | +| `minW`, `maxW` | min-width, max-width | +| `minH`, `maxH` | min-height, max-height | +| `boxSize` | width + height (same value) | + +**Background** + +| Shorthand | CSS Property | +|-----------|-------------| +| `bg` | background | +| `bgColor` | background-color | +| `bgImage`, `bgImg`, `backgroundImg` | background-image | +| `bgSize` | background-size | +| `bgPosition`, `bgPos` | background-position | +| `bgPositionX`, `bgPosX` | background-position-x | +| `bgPositionY`, `bgPosY` | background-position-y | +| `bgRepeat` | background-repeat | +| `bgAttachment` | background-attachment | +| `bgClip` | background-clip | +| `bgOrigin` | background-origin | +| `bgBlendMode` | background-blend-mode | + +**Border** + +| Shorthand | CSS Property | +|-----------|-------------| +| `borderTopRadius` | border-top-left-radius + border-top-right-radius | +| `borderBottomRadius` | border-bottom-left-radius + border-bottom-right-radius | +| `borderLeftRadius` | border-top-left-radius + border-bottom-left-radius | +| `borderRightRadius` | border-top-right-radius + border-bottom-right-radius | + +**Layout & Position** + +| Shorthand | CSS Property | +|-----------|-------------| +| `flexDir` | flex-direction | +| `pos` | position | +| `positioning` | Helper: `"top"`, `"bottom-right"`, etc. (sets edges to 0) | +| `objectPos` | object-position | +| `offsetPos` | offset-position | +| `maskPos` | mask-position | +| `maskImg` | mask-image | + +**Typography** + +| Shorthand | Effect | +|-----------|--------| +| `typography` | Applies theme typography token (fontFamily, fontSize, fontWeight, lineHeight, letterSpacing) | + +All standard CSS properties from `csstype` are also accepted directly (e.g., `display`, `gap`, `opacity`, `transform`, `animation`, etc.). + +### Spacing Scale (unitless number x 4 = px) + +```tsx + // padding: 4px + // padding: 16px + // padding: 16px (unitless string also x 4) + // padding: 20px (with unit = exact value) +``` + +### Responsive Arrays (5 breakpoints) + +```tsx +// [mobile, mid, tablet, mid, PC] - 5 levels +// Use indices 0, 2, 4 most frequently. Use null to skip. + + // mobile=red, tablet=blue, PC=yellow + // mobile=8px, tablet=16px, PC=24px + // mobile=100%, tablet+=50% +``` + +### Pseudo-Selectors (underscore prefix) + +```tsx + +``` + +All CSS pseudo-classes and pseudo-elements from `csstype` are supported with `_camelCase` naming. + +### Group Selectors + +Mark a parent with the `data-group` attribute, then children can react to that parent's state: + +```tsx + + Changes when parent hovered + + + +``` + +Available: `_groupHover`, `_groupFocus`, `_groupActive`, `_groupDisabled`. + +> The legacy `role="group"` parent marker is still matched for backward +> compatibility but will be removed in v2. Use `data-group` for new code so +> `role="group"` stays reserved for genuine ARIA grouping semantics. + +### Theme Selectors + +```tsx + + +``` + +### At-Rules (Media, Container, Supports) + +```tsx +// Underscore prefix syntax + + + + + + +// @ prefix syntax (equivalent) + +``` + +### Custom Selectors + +```tsx +"' }, + "&:nth-child(2n)": { bg: "gray" }, +}} /> +``` + +### Dynamic Values = CSS Variables + +```tsx +// Static value -> class + // className="a" + .a{background:red} + +// Dynamic value -> CSS variable + // className="a" style={{"--a":props.color}} + .a{background:var(--a)} + +// Conditional -> preserved + // className={isActive ? "a" : "b"} +``` + +### Responsive + Pseudo Combined + +```tsx + +// Alternative syntax: + +``` + +## Special Props + +### `as` (Polymorphic Element) + +Changes the rendered HTML element or renders a custom component: + +```tsx + // renders
+ // renders + // renders with extracted styles + // conditional element type +``` + +### `props` (Pass-Through to `as` Component) + +When `as` is a custom component, use `props` to pass component-specific props: + +```tsx + +``` + +### `styleVars` (Manual CSS Variable Injection) + +```tsx + +``` + +### `styleOrder` (CSS Cascade Priority) + +Controls specificity when combining `className` with direct props. **Required** when mixing `css()` classNames with inline style props. + +```tsx + +// Conditional styleOrder + +``` + +## Styling APIs + +### css() Returns className String (NOT object) + +```tsx +import { css, globalCss, keyframes } from "@devup-ui/react"; +import clsx from "clsx"; + +// css() returns a className STRING +const cardStyle = css({ bg: "white", p: 4, borderRadius: "8px" }); +
+ +// Combine with clsx +const baseStyle = css({ p: 4, borderRadius: "8px" }); +const activeStyle = css({ bg: "$primary", color: "white" }); + +``` + +### globalCss() and keyframes() + +```tsx +globalCss({ body: { margin: 0 }, "*": { boxSizing: "border-box" } }); + +const spin = keyframes({ from: { transform: "rotate(0)" }, to: { transform: "rotate(360deg)" } }); + +``` + +### Dynamic Values with Custom Components + +`css()` only accepts **static values**. For dynamic values on custom components, use ``: + +```tsx +// WRONG - css() cannot handle dynamic values + + +// CORRECT - Box with as prop handles dynamic values via CSS variables + +``` + +## Theme (devup.json) + +```json +{ + "extends": ["./base-theme.json"], + "theme": { + "colors": { + "default": { "primary": "#0070f3", "text": "#000", "bg": "#fff" }, + "dark": { "primary": "#3291ff", "text": "#fff", "bg": "#111" } + }, + "typography": { + "heading": { + "fontFamily": "Pretendard", + "fontSize": "24px", + "fontWeight": 700, + "lineHeight": 1.3, + "letterSpacing": "-0.02em" + }, + "body": [ + { "fontSize": "14px", "lineHeight": 1.5 }, + null, + { "fontSize": "16px", "lineHeight": 1.6 } + ] + }, + "length": { + "default": { + "containerX": ["16px", null, "32px"], + "gutter": ["8px", null, "16px"] + } + }, + "shadow": { + "default": { + "card": ["0 1px 2px #0003", null, null, "0 4px 8px #0003"], + "sm": "0 1px 2px rgba(0,0,0,0.05)" + } + } + } +} +``` + +- **Colors**: Use with `$` prefix in JSX props: `` +- **Typography**: Use with `$` prefix: `` +- **Length**: Responsive length tokens: ``, `` +- **Shadow**: Responsive shadow tokens: `` +- **extends**: Inherit from base config files (deep merge, last wins) +- **Responsive typography/length/shadow**: Use arrays with `null` for unchanged breakpoints + +### Length & Shadow Token Behavior + +Length and shadow tokens support responsive arrays like typography. The key distinction is how `$token` behaves depending on syntax: + +| Syntax | Behavior | Classes | +|--------|----------|---------| +| `px="$containerX"` | Expands to all defined breakpoints | Multiple | +| `px={"$containerX"}` | Expands to all defined breakpoints | Multiple | +| `px={["$containerX"]}` | Single value at index 0 only | 1 | +| `px={["8px", null, "$containerX"]}` | `8px` at index 0, token at index 2 | 2 | + +Both `"$token"` and `{"$token"}` expand the responsive token. Only `{["$token"]}` inside a responsive array keeps it as a single class — because the array itself defines the breakpoint levels. + +Theme types are auto-generated via module augmentation of `DevupTheme` and `DevupThemeTypography`. + +### Theme API + +```tsx +import { useTheme, setTheme, getTheme, initTheme, ThemeScript } from "@devup-ui/react"; + +setTheme("dark"); // Switch theme (sets data-theme + localStorage) +const theme = getTheme(); // Get current theme name +const theme = useTheme(); // React hook (reactive) +initTheme(); // Initialize on startup (auto-detect system preference) + // SSR hydration script (add to , prevents FOUC) +``` + +## Build Plugin Setup + +### Vite + +```ts +import DevupUI from "@devup-ui/vite-plugin"; +export default defineConfig({ plugins: [react(), DevupUI()] }); +``` + +### Next.js + +```ts +import { DevupUI } from "@devup-ui/next-plugin"; +export default DevupUI({ /* Next.js config */ }); +``` + +### Rsbuild + +```ts +import DevupUI from "@devup-ui/rsbuild-plugin"; +export default defineConfig({ plugins: [DevupUI()] }); +``` + +### Webpack + +```ts +import { DevupUIWebpackPlugin } from "@devup-ui/webpack-plugin"; +// Add to plugins array +``` + +### Bun + +```ts +import { plugin } from "@devup-ui/bun-plugin"; +// Auto-registers, always uses singleCss: true +``` + +### Plugin Options + +```ts +DevupUI({ + singleCss: true, // Single CSS file (recommended for Turbopack) + include: ["@devup/hello"], // Process external libs using @devup-ui + prefix: "du", // Class name prefix (e.g., "du-a" instead of "a") + debug: true, // Enable debug logging + importAliases: { // Redirect imports from other CSS-in-JS libs + "@emotion/styled": "styled", // default: enabled + "styled-components": "styled", // default: enabled + "@vanilla-extract/css": true, // default: enabled + }, +}) +``` + +## $token Scope + +`$token` values (colors, length, shadow) only work in **JSX props**. Use `var(--token)` in external objects. + +```tsx +// CORRECT - $token in JSX prop + + + + + +// WRONG - $token in external object (won't be transformed) +const colors = { active: '$primary' } + // broken! + +// CORRECT - var(--token) in external object +const colors = { active: 'var(--primary)' } + +``` + +## Inline Variant Pattern (Preferred) + +Use inline object indexing instead of external config objects: + +```tsx +// PREFERRED - inline object indexing (build-time extractable) + + +// AVOID - external config object (becomes dynamic, uses CSS variables) +const sizeStyles = { lg: { h: '48px' }, md: { h: '40px' } } + +``` + +## Anti-Patterns (NEVER do) + +| Wrong | Right | Why | +|-------|-------|-----| +| `` | `` | style prop bypasses extraction | +| `` | `` | css() returns string, not object | +| `css({ bg: variable })` | `` or `` | css()/globalCss() only accept static values | +| `$color` in external object | `var(--color)` in external object | $color only transformed in JSX props | +| No build plugin configured | Configure plugin first | Components throw at runtime without transformation | +| `as any` on style props | Fix types properly | Type errors indicate real issues | +| `@ts-ignore` / `@ts-expect-error` | Fix the type issue | Suppression hides real problems | +| `background="red"` | `bg="red"` | Always use shorthands | +| `padding={4}` | `p={4}` | Always use shorthands | +| `width="100%"` | `w="100%"` | Always use shorthands | +| `styled("div", {...})` | `` | Use Box component with props, not styled() | +| `stylex.create({...})` | `` | Use Box component with props, not stylex | diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json new file mode 100644 index 00000000..c2ed5b43 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -0,0 +1,78 @@ +{ + "note": "The skills an agent needs for the code devup-mcp emits. Two origins. 'embedded' skills are DevFive's own: the canonical SKILL.md is vendored here so a machine with only devup-mcp installed can install them with no network, and commit/sha256 say which revision. 'external' skills belong to someone else and are NOT vendored - devup-mcp reports where they come from and the command that installs them, and never runs that command itself.", + "skills": [ + { + "name": "devup-ui", + "origin": "embedded", + "title": "devup-ui conventions", + "description": "Zero-runtime CSS-in-JS for React: Box/Flex/Text primitives, css()/globalCss()/keyframes(), devup.json theme tokens, responsive arrays and pseudo-selectors.", + "usedFor": "The TSX devup_figma_export returns is devup-ui code. Without this the agent does not know its components are compile-time placeholders, that $token means devup.json, or that a style prop takes a responsive array.", + "repo": "dev-five-git/devup-ui", + "path": "SKILL.md", + "commit": "b0d61a2d5e21f491ed3283f858eea51794dd7df9", + "committedAt": "2026-05-25T11:11:38Z", + "sha256": "0ac264b12124e093399ea2588d5b2291110db4ec9426482256eac9fdb75ac1aa", + "bytes": 16629, + "sourceUrl": "https://github.com/dev-five-git/devup-ui/blob/b0d61a2d5e21f491ed3283f858eea51794dd7df9/SKILL.md", + "latestUrl": "https://github.com/dev-five-git/devup-ui/blob/HEAD/SKILL.md" + }, + { + "name": "vespera", + "origin": "embedded", + "title": "vespera conventions", + "description": "FastAPI-like DX for Rust/Axum: route handlers, Schema derivation and OpenAPI generation.", + "usedFor": "The openapi.json devup_project_context reads under scope api is generated by vespera routes.", + "repo": "dev-five-git/vespera", + "path": "SKILL.md", + "commit": "4030b6c053896fce34477b73616216c86d4b7f85", + "committedAt": "2026-06-24T08:21:03Z", + "sha256": "44db39f98cbb2520ca9a610a59753f39be2e226da7ee11a940234800cf98b27f", + "bytes": 23635, + "sourceUrl": "https://github.com/dev-five-git/vespera/blob/4030b6c053896fce34477b73616216c86d4b7f85/SKILL.md", + "latestUrl": "https://github.com/dev-five-git/vespera/blob/HEAD/SKILL.md" + }, + { + "name": "vespertide", + "origin": "embedded", + "title": "vespertide conventions", + "description": "JSON database schema definitions and migration plans: tables, columns, constraints and ENUM types.", + "usedFor": "The models/*.json devup_project_context reads under scope db are vespertide schemas.", + "repo": "dev-five-git/vespertide", + "path": "SKILL.md", + "commit": "586426bcfc388557fe9b916c941adb43ab04317e", + "committedAt": "2026-03-06T06:11:16Z", + "sha256": "7d79f9814819d107038181628a370a11e095a7314a63a0a6c5b5a9bcab6fbfdf", + "bytes": 18057, + "sourceUrl": "https://github.com/dev-five-git/vespertide/blob/586426bcfc388557fe9b916c941adb43ab04317e/SKILL.md", + "latestUrl": "https://github.com/dev-five-git/vespertide/blob/HEAD/SKILL.md" + }, + { + "name": "vercel-react-best-practices", + "origin": "external", + "title": "React/Next.js performance (Vercel Engineering)", + "description": "40+ rules across 8 categories: eliminating waterfalls, bundle size, re-render cost, server fetching. Multi-file: SKILL.md plus an AGENTS.md and ~70 rule files.", + "usedFor": "The TSX devup_figma_export returns becomes React components; these are the performance rules they are then judged by.", + "repo": "vercel-labs/agent-skills", + "path": "skills/react-best-practices", + "installCommand": "npx skills add vercel-labs/agent-skills", + "license": "none-declared", + "licenseNote": "vercel-labs/agent-skills publishes no LICENSE file, so devup-mcp does not vendor or redistribute its content. It is installed from source by the agent.", + "sourceUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-best-practices", + "latestUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-best-practices" + }, + { + "name": "vercel-react-view-transitions", + "origin": "external", + "title": "React View Transitions (Vercel Engineering)", + "description": "React's View Transition API: , addTransitionType, CSS pseudo-elements, shared element transitions, Next.js integration. Multi-file: SKILL.md plus AGENTS.md and reference files.", + "usedFor": "Figma prototype reactions become animations; this is how to implement them with the View Transition API rather than by hand.", + "repo": "vercel-labs/agent-skills", + "path": "skills/react-view-transitions", + "installCommand": "npx skills add vercel-labs/agent-skills", + "license": "none-declared", + "licenseNote": "vercel-labs/agent-skills publishes no LICENSE file, so devup-mcp does not vendor or redistribute its content. It is installed from source by the agent.", + "sourceUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-view-transitions", + "latestUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-view-transitions" + } + ] +} diff --git a/crates/devup-mcp/src/server/skills/vespera.md b/crates/devup-mcp/src/server/skills/vespera.md new file mode 100644 index 00000000..0a3473cb --- /dev/null +++ b/crates/devup-mcp/src/server/skills/vespera.md @@ -0,0 +1,730 @@ +--- +name: vespera +description: Build APIs with Vespera - FastAPI-like DX for Rust/Axum. Covers route handlers, Schema derivation, and OpenAPI generation. +--- + +# Vespera Usage Guide + +Vespera = FastAPI DX for Rust. Zero-config OpenAPI 3.1 generation via compile-time macro scanning. + +## Quick Start + +```rust +use vespera::{vespera, Serve, Schema, Validated, axum::Json}; +use axum::extract::Path; +use serde::{Deserialize, Serialize}; +use garde::Validate; + +// 1. Custom types — derive Schema for OpenAPI inclusion. +// Add `garde::Validate` to opt into 422 validation. +#[derive(Serialize, Deserialize, Schema, Validate)] +pub struct CreateUser { + #[garde(length(min = 3, max = 32))] + pub name: String, + #[garde(email)] + pub email: String, +} + +// 2. Route handlers — MUST be `pub async fn`. +#[vespera::route(get, path = "/{id}", tags = ["users"])] +pub async fn get_user(Path(id): Path) -> Json { /* ... */ } + +// 3. Validated extractor → automatic 422 on bad input. +#[vespera::route(post, tags = ["users"])] +pub async fn create_user( + Validated(Json(req)): Validated>, +) -> Json<&'static str> { + // `req` already passed validation. Failures never reach here. + Json("ok") +} + +// 4. Main — one-liner `.serve()` from the `Serve` extension trait. +#[tokio::main] +async fn main() -> std::io::Result<()> { + vespera!( + openapi = "openapi.json", // writes file at compile time + title = "My API", + version = "1.0.0", + docs_url = "/docs", // Swagger UI + redoc_url = "/redoc" // ReDoc alternative + ) + .serve("0.0.0.0:3000") + .await +} +``` + +--- + +## Request Validation (`Validated` → `422`) + +Wrap any extractor with `Validated<...>` to enforce `garde::Validate` **before** +the handler runs. Vespera converts validation failures into a canonical +`422 Unprocessable Entity` response — no per-handler error mapping. + +```rust +use vespera::{Validated, axum::Json}; + +#[vespera::route(post)] +pub async fn create( + Validated(Json(req)): Validated>, +) -> Json<&'static str> { + Json("ok") +} +``` + +**Response on validation failure (status `422`, content-type `application/json`):** + +```json +{ + "errors": [ + { "path": "name", "message": "length is lower than 3" }, + { "path": "email", "message": "not a valid email" } + ] +} +``` + +### Supported wrappers + +| Wrapper | Validates | +|---|---| +| `Validated>` | JSON body | +| `Validated>` | URL-encoded form body | +| `Validated>` | URL query string | +| `Validated>` | Path parameters | + +### Requirements + +- `T` (or the inner type of `Json`, `Form`, …) must implement + `garde::Validate`. +- Derive `garde::Validate` and annotate fields with `#[garde(...)]` rules + (`length`, `email`, `range`, `pattern`, custom, …). +- Vespera's `#[derive(Schema)]` continues to drive the OpenAPI spec — the two + derives compose cleanly on the same struct. + +### JNI / Binary wire integration + +When a `Validated` rejection crosses the JNI boundary, the JSON envelope +(`{"errors":[...]}`) is **hoisted** into the binary wire-format header as +`"validation_errors": [...]`. Java decoders inspect the field directly +without re-parsing the body. See +`crates/vespera/tests/jni_validation.rs` for the pinned contract. + +--- + +## One-Liner Server Startup (`Serve`) + +`vespera::Serve` is an extension trait on `axum::Router`. It replaces the +standard `TcpListener::bind` + `axum::serve(...)` dance with a single chained +call: + +```rust +use vespera::{vespera, Serve}; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + vespera!(title = "My API") + .serve("0.0.0.0:3000") + .await +} +``` + +- `addr` accepts anything `tokio::net::ToSocketAddrs` accepts — strings + (`"0.0.0.0:3000"`), tuples (`("127.0.0.1", 8080)`), `SocketAddr`, etc. +- Works on **any** `axum::Router`, including the output of `Router::merge`, + `Router::nest`, or `vespera!(...)` itself. +- Returns `std::io::Result<()>` — propagate with `?` from `main`. + +--- + +## Type Mapping Reference + +| Rust Type | OpenAPI Schema | Notes | +|-----------|----------------|-------| +| `String`, `&str` | `string` | | +| `i8`-`i128`, `u8`-`u128` | `integer` | | +| `f32`, `f64` | `number` | | +| `bool` | `boolean` | | +| `Vec` | `array` + items | | +| `BTreeSet`, `HashSet` | `array` + items + `uniqueItems: true` | Set types | +| `Option` | T (nullable context) | Parent marks as optional | +| `HashMap` | `object` + additionalProperties | | +| `Uuid` | `string` + `format: uuid` | | +| `Decimal` | `string` + `format: decimal` | | +| `NaiveDate` | `string` + `format: date` | | +| `NaiveTime` | `string` + `format: time` | | +| `DateTime`, `DateTimeWithTimeZone` | `string` + `format: date-time` | | +| `FieldData` | `string` + `format: binary` | File upload field | +| `()` | empty response | 204 No Content | +| Custom struct | `$ref` | Must derive Schema | + +## Extractor Mapping Reference + +| Axum Extractor | OpenAPI Location | Notes | +|----------------|------------------|-------| +| `Path` | path parameter | T can be tuple or struct | +| `Query` | query parameters | Struct fields become params | +| `Json` | requestBody | application/json | +| `Form` | requestBody | application/x-www-form-urlencoded | +| `TypedMultipart` | requestBody | multipart/form-data — typed with schema | +| `Multipart` | requestBody | multipart/form-data — untyped, generic object | +| `State` | **ignored** | Internal, not API | +| `Extension` | **ignored** | Internal, not API | +| `TypedHeader` | header parameter | | +| `HeaderMap` | **ignored** | Too dynamic | + +--- + +## Route Handler Requirements + +```rust +// ❌ Private function - NOT discovered +async fn get_users() -> Json> { ... } + +// ❌ Non-async function - NOT supported +pub fn get_users() -> Json> { ... } + +// ✅ Must be pub async fn +pub async fn get_users() -> Json> { ... } +``` + +--- + +## File Structure → URL Mapping + +``` +src/routes/ +├── mod.rs → / (root routes) +├── users.rs → /users +├── posts.rs → /posts +└── admin/ + ├── mod.rs → /admin + └── stats.rs → /admin/stats +``` + +Handler path is: `{file_path} + {#[route] path}` + +```rust +// In src/routes/users.rs +#[vespera::route(get, path = "/{id}")] +pub async fn get_user(...) // → GET /users/{id} +``` + +--- + +## Serde Integration + +Vespera respects serde attributes: + +```rust +#[derive(Serialize, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] // ✅ Respected in schema +pub struct UserResponse { + user_id: u32, // → "userId" in JSON Schema + + #[serde(rename = "fullName")] // ✅ Respected + name: String, // → "fullName" in JSON Schema + + #[serde(default)] // ✅ Recognized (does NOT affect `required` — only Option does) + bio: Option, + + #[serde(skip)] // ✅ Excluded from schema + internal_id: u64, +} +``` + +--- + +## Debugging Tips + +### Schema Not Appearing + +1. Check `#[derive(Schema)]` on the type +2. Check type is used in a route handler's input/output +3. Check for generic types - all type params need Schema + +```rust +// Generic types need Schema on all params +#[derive(Schema)] +struct Paginated { // T must also derive Schema + items: Vec, + total: u32, +} +``` + +### Macro Expansion + +```bash +# See what vespera! generates +cargo expand + +# Validate OpenAPI output +npx @apidevtools/swagger-cli validate openapi.json +``` + +--- + +## Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `VESPERA_DIR` | Route folder name | `routes` | +| `VESPERA_OPENAPI` | OpenAPI output path | none | +| `VESPERA_TITLE` | API title | `API` | +| `VESPERA_VERSION` | API version | `CARGO_PKG_VERSION` | +| `VESPERA_DOCS_URL` | Swagger UI path | none | +| `VESPERA_REDOC_URL` | ReDoc path | none | +| `VESPERA_SERVER_URL` | Server URL | `http://localhost:3000` | + +--- + +## schema_type! Macro (RECOMMENDED) + +> **ALWAYS prefer `schema_type!` over manually defining request/response structs.** +> +> Benefits: +> - Single source of truth (your model) +> - Auto-generated `From` impl for easy conversion +> - Automatic type resolution (enums, custom types → absolute paths) +> - SeaORM relation support (HasOne, BelongsTo, HasMany) +> - No manual field synchronization + +### Best Practices + +| DO | DON'T | +|----|-------| +| Use `pick` to select only needed fields | Define manual structs that duplicate Model fields | +| Use `omit` to exclude sensitive fields | Use `name` parameter unnecessarily | +| Use full `crate::models::...` paths | Rely on implicit module resolution | +| Define schema near route handlers | Scatter schemas across unrelated files | + +**Primary Parameters (USE THESE):** +- `pick = [...]` - Allowlist: include ONLY these fields +- `omit = [...]` - Denylist: exclude these fields +- `omit_default` - Auto-omit fields with DB defaults (primary_key, default_value) + +**Advanced Parameters (USE SPARINGLY):** +- `partial` - For PATCH endpoints only +- `rename` - Only when API naming differs from model +- `add` - Only when truly new fields needed (breaks `From` impl) +- `name` - **AVOID** unless same-file Model reference (see below) + +### Why Not Manual Structs? + +```rust +// ❌ BAD: Manual struct definition - requires sync with Model +#[derive(Serialize, Deserialize, Schema)] +pub struct UserResponse { + pub id: i32, + pub name: String, + pub email: String, + // Forgot to add new field? Schema out of sync! +} + +// ✅ GOOD: Derive from Model - always in sync +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); +``` + +### Basic Syntax + +```rust +// Pick specific fields +schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]); + +// Omit specific fields +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash", "internal_id"]); + +// Add new fields (NOTE: no From impl generated when using add) +schema_type!(UpdateUserRequest from crate::models::user::Model, pick = ["name"], add = [("id": i32)]); + +// Rename fields +schema_type!(UserDTO from crate::models::user::Model, rename = [("id", "user_id")]); + +// Partial updates (all fields become Option) +schema_type!(UserPatch from crate::models::user::Model, partial); + +// Partial updates (specific fields only) +schema_type!(UserPatch from crate::models::user::Model, partial = ["name", "email"]); + +// Auto-omit fields with DB defaults (primary_key, default_value = "...") +schema_type!(CreatePostRequest from crate::models::post::Model, omit_default); + +// Combine omit_default with add +schema_type!(CreateItemRequest from crate::models::item::Model, omit_default, add = [("tags": Vec)]); + +// Custom serde rename strategy +schema_type!(UserSnakeCase from crate::models::user::Model, rename_all = "snake_case"); + +// Custom OpenAPI schema name +schema_type!(Schema from Model, name = "UserSchema"); + +// Skip Schema derive (won't appear in OpenAPI) +schema_type!(InternalDTO from Model, ignore); + +// Disable Clone derive +schema_type!(LargeResponse from SomeType, clone = false); +``` + +### Same-File Model Reference (When to Use `name`) + +> **The `name` parameter is ONLY needed for same-file Model references.** +> For cross-file references, use full paths and descriptive struct names instead. + +When defining Schema in the same file as Model (common for SeaORM entities): + +```rust +// In src/models/user.rs +pub struct Model { + pub id: i32, + pub name: String, + pub status: UserStatus, // Custom enum - auto-resolved to absolute path +} + +pub enum UserStatus { Active, Inactive } + +// ✅ CORRECT: Same-file reference - use `name` for OpenAPI schema name +vespera::schema_type!(Schema from Model, name = "UserSchema"); + +// ❌ WRONG: Using `name` for cross-file reference +// schema_type!(Schema from crate::models::user::Model, name = "UserResponse"); +// ✅ CORRECT: Use descriptive struct name instead +// schema_type!(UserResponse from crate::models::user::Model, omit = ["password"]); +``` + +**Why avoid `name` for cross-file references?** +- The struct name itself becomes the OpenAPI schema name +- `UserResponse` is clearer than `Schema` with `name = "UserResponse"` +- Less parameters = less complexity + +### Cross-File References + +Reference structs from other files using full module paths: + +```rust +// In src/routes/users.rs +use vespera::schema_type; + +// Reference model from src/models/user.rs +schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]); +``` + +The macro reads the source file at compile time - no special annotations needed on the source struct. + +### Auto-Generated From Impl + +When `add` is NOT used, `schema_type!` generates a `From` impl for easy conversion: + +```rust +// This: +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); + +// Generates: +pub struct UserResponse { id, name, email, created_at } + +impl From for UserResponse { + fn from(source: crate::models::user::Model) -> Self { + Self { id: source.id, name: source.name, ... } + } +} + +// Usage: +let model: Model = db.find_user(id).await?; +Json(model.into()) // Easy conversion! +``` + +**Note:** `From` is NOT generated when `add` is used (can't auto-populate added fields). + +### Parameters + +**Recommended (Primary):** + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `pick` | Include only these fields | `pick = ["name", "email"]` | +| `omit` | Exclude these fields | `omit = ["password"]` | +| `omit_default` | Auto-omit fields with DB defaults | `omit_default` (bare keyword) | + +**Situational (Use When Needed):** + +| Parameter | Description | When to Use | +|-----------|-------------|-------------| +| `partial` | Make fields optional | PATCH endpoints only | +| `rename` | Rename fields | API naming differs from model | +| `rename_all` | Serde rename strategy | Different casing needed | +| `add` | Add new fields | New fields not in model (breaks `From` impl) | +| `multipart` | Derive `Multipart` | Multipart form-data endpoints | + +**Avoid (Special Cases Only):** + +| Parameter | Description | When to Use | +|-----------|-------------|-------------| +| `name` | Custom OpenAPI schema name | **Same-file Model reference only** | +| `ignore` | Skip Schema derive | Internal DTOs not for OpenAPI | +| `clone` | Control Clone derive | Large structs where Clone is expensive | + +### SeaORM Integration (RECOMMENDED) + +`schema_type!` has first-class SeaORM support with automatic relation handling: + +```rust +// src/models/memo.rs +#[derive(Clone, Debug, DeriveEntityModel)] +#[sea_orm(table_name = "memo")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub title: String, + pub user_id: i32, + pub status: MemoStatus, // Custom enum + pub user: BelongsTo, // → Option> + pub comments: HasMany, // → Vec + pub created_at: DateTimeWithTimeZone, // → chrono::DateTime +} + +#[derive(EnumIter, DeriveActiveEnum, Serialize, Deserialize, Schema)] +pub enum MemoStatus { Draft, Published, Archived } + +// Generates Schema with proper types - no imports needed! +vespera::schema_type!(Schema from Model, name = "MemoSchema"); +``` + +**Automatic Type Conversions:** + +| SeaORM Type | Generated Type | Notes | +|-------------|---------------|-------| +| `HasOne` | `Box` or `Option>` | Based on FK nullability | +| `BelongsTo` | `Option>` | Always optional | +| `HasMany` | `Vec` | | +| `DateTimeWithTimeZone` | `vespera::chrono::DateTime` | No SeaORM import needed | +| Custom enums | `crate::module::EnumName` | Auto-resolved to absolute path | + +**Circular Reference Handling:** Automatically detected and handled by inlining fields. + +**Database Defaults in OpenAPI:** Fields with `#[sea_orm(default_value = "...")]` or `#[sea_orm(primary_key)]` automatically get `default` values in the generated OpenAPI schema. SQL functions like `NOW()` and `gen_random_uuid()` are mapped to type-appropriate defaults. + +**Required Logic:** `required` is determined **solely by nullability** (`Option`). Fields with `#[serde(default)]` or `#[serde(skip_serializing_if)]` are still `required` unless they are `Option`. + +### Same-File Relation Adapters + +When a route file defines a local response DTO for a relation, Vespera can preserve unchanged handler code while still generating the right OpenAPI. + +Example: + +```rust +#[derive(Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct UserInArticle { + pub id: Uuid, + pub name: String, + pub email: String, + pub profile_image: Option, +} + +#[derive(Serialize, vespera::Schema)] +#[serde(rename_all = "camelCase")] +pub struct CategoryInArticle { + pub id: i64, + pub name: String, + pub parent_category_id: Option, + pub is_active: bool, + pub is_menu: bool, +} + +schema_type!( + ArticleResponse from crate::models::article::Model, + relation_adapters = [ + ("user", UserInArticle), + ("category", CategoryInArticle), + ], + add = [("article_review_users": Vec)] +); + +Ok(ArticleResponse { + user: user.into(), + category: category.into(), + article_review_users, + .. +}) +``` + +Rules: + +- Only applies to single-value relations (`HasOne` / `BelongsTo`) +- Must be opted in explicitly with `relation_adapters = [("field", AdapterStruct)]` +- The adapter struct name is used verbatim; Vespera does not infer adapter names by convention +- Missing explicitly named adapter structs are compile errors +- Vespera generates local compile adapters so `Option.into()` works without changing the route +- OpenAPI references the adapter DTO's own schema (`UserInArticle`, `CategoryInArticle`), honoring any `#[schema(name = "...")]` override +- Single-value relations not listed in `relation_adapters` keep the default base-schema relation type +- `HasMany` relations remain excluded by default unless explicitly `pick`ed or `add`ed + +### Complete Example + +```rust +// ============================================ +// src/models/user.rs (SeaORM entity) +// ============================================ +#[derive(Clone, Debug, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub email: String, + pub status: UserStatus, + pub password_hash: String, // Never expose! + pub created_at: DateTimeWithTimeZone, +} + +// ✅ Same-file: use `name` parameter for OpenAPI schema name +vespera::schema_type!(Schema from Model, name = "UserSchema"); + +// ============================================ +// src/routes/users.rs (Route handlers) +// ============================================ +use vespera::schema_type; + +// ✅ Cross-file: use descriptive struct names + pick/omit +// NO `name` parameter needed - struct name = OpenAPI schema name +schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]); +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); +schema_type!(UserPatch from crate::models::user::Model, omit = ["password_hash", "id"], partial); + +#[vespera::route(get, path = "/{id}")] +pub async fn get_user(Path(id): Path, State(db): State) -> Json { + let user = User::find_by_id(id).one(&db).await.unwrap().unwrap(); + Json(user.into()) // From impl handles conversion +} + +#[vespera::route(patch, path = "/{id}")] +pub async fn patch_user( + Path(id): Path, + Json(patch): Json, // All fields are Option +) -> Json { + // Apply partial update... +} +``` + +### Multipart Mode (`multipart`) + +Generate `Multipart` structs from existing multipart request types: + +```rust +use vespera::multipart::{FieldData, TypedMultipart}; +use vespera::{Multipart, Schema}; +use tempfile::NamedTempFile; + +// Base multipart struct (manually defined) +#[derive(Multipart, Schema)] +pub struct CreateUploadRequest { + pub name: String, + #[form_data(limit = "10MiB")] + pub thumbnail: Option>, + #[form_data(limit = "50MiB")] + pub document: Option>, + pub tags: Option, +} + +// Derive a partial update struct via schema_type! +// - Derives Multipart (not serde) +// - All fields become Option (partial) +// - "document" field excluded +// - #[form_data(limit = "10MiB")] preserved from source +schema_type!(PatchUploadRequest from CreateUploadRequest, multipart, partial, omit = ["document"]); +``` + +**What `multipart` mode changes:** + +| Aspect | Normal Mode | Multipart Mode | +|--------|------------|----------------| +| Derives | `Serialize`, `Deserialize` | `Multipart` | +| Struct attrs | `#[serde(rename_all=...)]` | None | +| Field attrs | `#[serde(...)]` preserved | `#[form_data(...)]` preserved | +| Relation fields | Included (BelongsTo/HasOne) | **Skipped** (can't represent in forms) | +| `From` impl | Auto-generated | **Not generated** | + +**OpenAPI rename alignment:** The schema parser reads `#[form_data(field_name = "...")]` and `#[serde(rename_all = "...")]` for multipart structs, ensuring OpenAPI field names match runtime multipart parsing. + +**Dependencies required in your Cargo.toml:** +```toml +vespera = "0.1" # Includes multipart support natively +tempfile = "3" # For NamedTempFile file uploads +``` + +### Quick Reference + +```rust +// ✅ RECOMMENDED PATTERNS +schema_type!(CreateUserRequest from crate::models::user::Model, pick = ["name", "email"]); +schema_type!(CreatePostRequest from crate::models::post::Model, omit_default); +schema_type!(UserResponse from crate::models::user::Model, omit = ["password_hash"]); +schema_type!(UserListItem from crate::models::user::Model, pick = ["id", "name"]); + +// ✅ MULTIPART PATTERNS +schema_type!(PatchUpload from CreateUploadRequest, multipart, partial); +schema_type!(SmallUpload from CreateUploadRequest, multipart, omit = ["document"]); + +// ⚠️ USE SPARINGLY +schema_type!(UserPatch from crate::models::user::Model, partial); // PATCH only +schema_type!(Schema from Model, name = "UserSchema"); // Same-file only + +// ❌ AVOID +schema_type!(Schema from crate::models::user::Model, name = "UserResponse"); // Use struct name! +``` + +--- + +## Merging Multiple Vespera Apps + +Combine routes and OpenAPI specs from multiple apps at compile time. + +### export_app! Macro + +Export an app for merging: + +```rust +// Child crate (e.g., third/src/lib.rs) +mod routes; + +// Basic - scans "routes" folder by default +vespera::export_app!(ThirdApp); + +// Custom directory +vespera::export_app!(ThirdApp, dir = "api"); +``` + +Generates: +- `ThirdApp::OPENAPI_SPEC: &'static str` - OpenAPI JSON +- `ThirdApp::router() -> Router` - Axum router + +### merge Parameter + +Merge child apps in parent: + +```rust +let app = vespera!( + openapi = "openapi.json", + docs_url = "/docs", + merge = [third::ThirdApp, other::OtherApp] +) +.with_state(state); +``` + +**What happens:** +1. Child routers merged into parent router +2. OpenAPI specs merged (paths, schemas, tags) +3. Swagger UI shows all routes + +### How It Works (Compile-Time) + +``` +Child compilation (export_app!): + 1. Scan routes/ folder + 2. Generate OpenAPI spec + 3. Write to target/vespera/{Name}.openapi.json + +Parent compilation (vespera! with merge): + 1. Generate parent OpenAPI spec + 2. Read child specs from target/vespera/ + 3. Merge all specs together + 4. Write merged openapi.json +``` diff --git a/crates/devup-mcp/src/server/skills/vespertide.md b/crates/devup-mcp/src/server/skills/vespertide.md new file mode 100644 index 00000000..55c51b6e --- /dev/null +++ b/crates/devup-mcp/src/server/skills/vespertide.md @@ -0,0 +1,585 @@ +--- +name: vespertide +description: Define database schemas in JSON and generate migration plans. Use this skill when creating or modifying database models, defining tables with columns, constraints, and ENUM types for Vespertide-based projects. +--- + +# Vespertide Database Schema Definition + +Declarative database schema management. Define tables in JSON, generate typed migrations and SQL. + +## Schema Validation (MANDATORY) + +Every model file MUST include `$schema`. Before saving: ensure no IDE validation errors, then run `vespertide diff`. + +```json +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "table_name", + "columns": [] +} +``` + +## Post-Edit Validation (MANDATORY) + +After EVERY model edit, run: + +```bash +vespertide diff # Check for parsing errors and expected changes +vespertide sql # Preview generated SQL for correctness +``` + +Only proceed to `vespertide revision` after both pass cleanly. + +--- + +## Installation + +```bash +cargo install vespertide-cli +``` + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `vespertide init` | Initialize `vespertide.json` with defaults | +| `vespertide new ` | Create model template with `$schema` | +| `vespertide new -f yaml` | Create model in specific format (`json`/`yaml`/`yml`) | +| `vespertide diff` | Show pending changes between migrations and models | +| `vespertide sql` | Preview SQL for next migration (default: postgres) | +| `vespertide sql -b mysql` | SQL for specific backend (`postgres`/`mysql`/`sqlite`) | +| `vespertide log` | Show SQL per applied migration | +| `vespertide log -b mysql` | Migration log for specific backend | +| `vespertide status` | Show config and sync overview | +| `vespertide revision -m "msg"` | Create migration file | +| `vespertide revision -m "msg" --fill-with table.col=value` | Create migration with fill values (repeatable) | +| `vespertide export --orm seaorm` | Export to ORM code (`seaorm`/`sqlalchemy`/`sqlmodel`) | +| `vespertide export --orm seaorm -d out/` | Export to custom directory | + +--- + +## Configuration (`vespertide.json`) + +```json +{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "modelFormat": "json", + "migrationFormat": "json", + "migrationFilenamePattern": "%04v_%m", + "modelExportDir": "src/models", + "prefix": "", + "seaorm": { + "extraEnumDerives": ["vespera::Schema"], + "extraModelDerives": [], + "enumNamingCase": "camel", + "vesperaSchemaType": true + } +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `modelsDir` | string | `"models"` | Model JSON files directory | +| `migrationsDir` | string | `"migrations"` | Migration files directory | +| `tableNamingCase` | string | `"snake"` | `snake` / `camel` / `pascal` | +| `columnNamingCase` | string | `"snake"` | `snake` / `camel` / `pascal` | +| `modelFormat` | string | `"json"` | `json` / `yaml` / `yml` | +| `migrationFormat` | string | `"json"` | `json` / `yaml` / `yml` | +| `migrationFilenamePattern` | string | `"%04v_%m"` | `%v`=version, `%m`=message | +| `modelExportDir` | string | `"src/models"` | ORM export output directory | +| `prefix` | string | `""` | Prefix for all table names | + +**SeaORM Config** (`seaorm` nested object): + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `extraEnumDerives` | string[] | `["vespera::Schema"]` | Extra derives for generated enums | +| `extraModelDerives` | string[] | `[]` | Extra derives for entity models | +| `enumNamingCase` | string | `"camel"` | Serde `rename_all` for enums | +| `vesperaSchemaType` | bool | `true` | Generate `vespera::schema_type!` calls | + +--- + +## Exported ORM Files (DO NOT EDIT) + +> **CRITICAL**: Files in `src/models/` (e.g. `src/models/*.rs`, `src/models/*.py`) are AUTO-GENERATED by `vespertide export`. **NEVER manually create or edit these files.** Always edit source models in `models/*.json`, then regenerate: + +```bash +# Edit models/*.json -> regenerate +vespertide export --orm seaorm +``` + +## Migration / Revision Files (DO NOT EDIT) + +> **CRITICAL**: Migration files in the `migrations/` directory are AUTO-GENERATED by `vespertide revision`. **NEVER manually create, edit, or modify revision files.** Always edit source models in `models/*.json`, then create a new revision: + +```bash +# Edit models/*.json -> create revision +vespertide revision -m "describe your change" +``` + +**Only exception**: Adding `fill_with` values when prompted (NOT NULL columns without defaults). + +```json +{ + "type": "add_column", + "table": "user", + "column": { + "name": "status", + "type": "text", + "nullable": false + }, + "fill_with": "'active'" +} +``` + +--- + +## Model Structure + +```json +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "table_name", + "description": "Optional table description", + "columns": [ /* ColumnDef[] */ ], + "constraints": [ /* Optional: CHECK constraints only */ ] +} +``` + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `name` | yes | string | Table name (snake_case) | +| `columns` | yes | array | Column definitions | +| `description` | no | string | Table documentation | +| `constraints` | no | array | Table-level constraints (CHECK only) | + +--- + +## Column Definition + +### Required Fields + +```json +{ "name": "column_name", "type": "ColumnType", "nullable": false } +``` + +### Optional Fields + +| Field | Type | Description | +|-------|------|-------------| +| `default` | string \| boolean \| number | Default value | +| `comment` | string | Column documentation | +| `primary_key` | boolean \| object | Inline primary key | +| `unique` | boolean \| string \| string[] | Inline unique constraint | +| `index` | boolean \| string \| string[] | Inline index | +| `foreign_key` | string \| object | Inline foreign key | + +--- + +## Column Types + +### Simple Types (string values) + +| Type | SQL | Type | SQL | +|------|-----|------|-----| +| `"small_int"` | SMALLINT | `"text"` | TEXT | +| `"integer"` | INTEGER | `"boolean"` | BOOLEAN | +| `"big_int"` | BIGINT | `"uuid"` | UUID | +| `"real"` | REAL | `"json"` | JSON | +| `"double_precision"` | DOUBLE PRECISION | `"bytea"` | BYTEA | +| `"date"` | DATE | `"inet"` | INET | +| `"time"` | TIME | `"cidr"` | CIDR | +| `"timestamp"` | TIMESTAMP | `"macaddr"` | MACADDR | +| `"timestamptz"` | TIMESTAMPTZ | `"xml"` | XML | +| `"interval"` | INTERVAL | | | + +### Complex Types (object values) + +```json +{ "kind": "varchar", "length": 255 } +{ "kind": "char", "length": 2 } +{ "kind": "numeric", "precision": 10, "scale": 2 } +{ "kind": "enum", "name": "...", "values": [...] } +{ "kind": "custom", "custom_type": "..." } +``` + +### Enum Types (RECOMMENDED for status/category fields) + +**String Enum** (PostgreSQL native enum): +```json +{ + "name": "status", + "type": { + "kind": "enum", + "name": "article_status", + "values": ["draft", "review", "published", "archived"] + }, + "nullable": false, + "default": "'draft'" +} +``` + +**Integer Enum** (stored as INTEGER -- no DB migration needed for new values): +```json +{ + "name": "role", + "type": { + "kind": "enum", + "name": "user_role", + "values": [ + { "name": "guest", "value": 0 }, + { "name": "user", "value": 10 }, + { "name": "moderator", "value": 50 }, + { "name": "admin", "value": 100 } + ] + }, + "nullable": false, + "default": 0 +} +``` + +> **Tip**: Leave gaps in integer values (0, 10, 50, 100) to allow inserting new values without renumbering. + +| Scenario | Recommended Type | +|----------|------------------| +| Status fields (order_status, user_status) | String or Integer enum | +| Categories with fixed values | String enum | +| Priority/severity levels | Integer enum | +| Roles with potential expansion | Integer enum | + +--- + +## Inline Constraints (PREFERRED) + +> Always define constraints on columns. Use table-level `constraints` ONLY for CHECK expressions. + +### Primary Key + +```json +{ "name": "id", "type": "integer", "nullable": false, "primary_key": true } +{ "name": "id", "type": "integer", "nullable": false, "primary_key": { "auto_increment": true } } +``` + +### Unique + +```json +{ "name": "email", "type": "text", "nullable": false, "unique": true } +``` + +Named composite unique: +```json +{ "name": "tenant_id", "type": "integer", "nullable": false, "unique": ["uq_tenant_user"] }, +{ "name": "username", "type": "text", "nullable": false, "unique": ["uq_tenant_user"] } +``` + +### Index + +```json +{ "name": "email", "type": "text", "nullable": false, "index": true } +``` + +Composite index: +```json +{ "name": "user_id", "type": "integer", "nullable": false, "index": ["idx_user_created"] }, +{ "name": "created_at", "type": "timestamptz", "nullable": false, "index": ["idx_user_created"] } +``` + +### Foreign Key + +Object syntax (recommended): +```json +{ + "name": "user_id", + "type": "integer", + "nullable": false, + "foreign_key": { + "ref_table": "user", + "ref_columns": ["id"], + "on_delete": "cascade", + "on_update": null + }, + "index": true +} +``` + +Shorthand syntax: +```json +{ "name": "user_id", "type": "integer", "nullable": false, "foreign_key": "user.id", "index": true } +``` + +**Reference Actions** (snake_case): `"cascade"`, `"restrict"`, `"set_null"`, `"set_default"`, `"no_action"` + +> Always add `"index": true` on foreign key columns for query performance. + +### Composite Primary Key (Inline) + +Both columns with `"primary_key": true` create a single composite primary key: +```json +{ + "columns": [ + { "name": "user_id", "type": "integer", "nullable": false, "primary_key": true }, + { "name": "role_id", "type": "integer", "nullable": false, "primary_key": true } + ] +} +``` + +--- + +## Table-Level Constraints (CHECK only) + +> Use inline constraints for everything else (PK, unique, index, FK). + +```json +"constraints": [ + { "type": "check", "name": "check_positive_amount", "expr": "amount > 0" }, + { "type": "check", "name": "check_dates", "expr": "end_date > start_date" } +] +``` + +--- + +## Default Values + +| Type | Example | Notes | +|------|---------|-------| +| String literal | `"'pending'"` | Single quotes inside string | +| Boolean | `true` / `false` | Native JSON boolean | +| Integer | `0` | Native JSON number | +| Float | `0.0` | Native JSON number | +| SQL function | `"NOW()"` | No quotes around function | +| UUID generation | `"gen_random_uuid()"` | PostgreSQL | + +--- + +## Runtime Migration Macro + +Use `vespertide_migration!` to run migrations at application startup: + +```toml +[dependencies] +vespertide = "0.1" +sea-orm = { version = "2.0.0-rc", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } +``` + +```rust +use sea_orm::Database; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let db = Database::connect("postgres://user:pass@localhost/mydb").await?; + vespertide::vespertide_migration!(db).await?; + Ok(()) +} +``` + +The macro generates database-specific SQL at compile time for zero-runtime overhead. + +--- + +## Complete Examples + +### User Table with Enum Status + +```json +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "user", + "columns": [ + { "name": "id", "type": "integer", "nullable": false, "primary_key": { "auto_increment": true } }, + { "name": "email", "type": "text", "nullable": false, "unique": true, "index": true }, + { "name": "name", "type": { "kind": "varchar", "length": 100 }, "nullable": false }, + { + "name": "status", + "type": { "kind": "enum", "name": "user_status", "values": ["pending", "active", "suspended", "deleted"] }, + "nullable": false, + "default": "'pending'" + }, + { "name": "metadata", "type": "json", "nullable": true }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" }, + { "name": "updated_at", "type": "timestamptz", "nullable": true } + ] +} +``` + +### Order Table with Integer Enum and CHECK + +```json +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "order", + "columns": [ + { "name": "id", "type": "uuid", "nullable": false, "primary_key": true, "default": "gen_random_uuid()" }, + { + "name": "customer_id", + "type": "integer", + "nullable": false, + "foreign_key": { "ref_table": "customer", "ref_columns": ["id"], "on_delete": "restrict" }, + "index": true + }, + { "name": "total", "type": { "kind": "numeric", "precision": 10, "scale": 2 }, "nullable": false }, + { + "name": "priority", + "type": { + "kind": "enum", + "name": "order_priority", + "values": [ + { "name": "low", "value": 0 }, + { "name": "normal", "value": 10 }, + { "name": "high", "value": 20 }, + { "name": "urgent", "value": 30 } + ] + }, + "nullable": false, + "default": 10 + }, + { + "name": "status", + "type": { "kind": "enum", "name": "order_status", "values": ["pending", "confirmed", "shipped", "delivered", "cancelled"] }, + "nullable": false, + "default": "'pending'" + }, + { "name": "notes", "type": "text", "nullable": true }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()" } + ], + "constraints": [ + { "type": "check", "name": "check_total_positive", "expr": "total >= 0" } + ] +} +``` + +### Many-to-Many Join Table + +```json +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "user_role", + "columns": [ + { + "name": "user_id", + "type": "integer", + "nullable": false, + "primary_key": true, + "foreign_key": { "ref_table": "user", "ref_columns": ["id"], "on_delete": "cascade" } + }, + { + "name": "role_id", + "type": "integer", + "nullable": false, + "primary_key": true, + "foreign_key": { "ref_table": "role", "ref_columns": ["id"], "on_delete": "cascade" }, + "index": true + }, + { "name": "granted_at", "type": "timestamptz", "nullable": false, "default": "NOW()" }, + { "name": "granted_by", "type": "integer", "nullable": true, "foreign_key": "user.id" } + ] +} +``` + +--- + +## Workflow Summary + +```bash +# 1. Create model +vespertide new user + +# 2. Edit models/user.json + +# 3. Validate +vespertide diff # Check changes +vespertide sql # Preview SQL + +# 4. Create migration +vespertide revision -m "create user table" + +# 5. Export ORM code (if needed) +vespertide export --orm seaorm +``` + +--- + +## Guidelines Summary + +### MUST DO + +1. Always include `$schema` in every model file +2. Always specify `nullable` on every column +3. Run `vespertide diff` + `vespertide sql` after every model edit +4. Index foreign key columns (`"index": true`) +5. Use inline constraints (`primary_key`, `unique`, `index`, `foreign_key` on columns) + +### SHOULD DO + +1. Use enums for status/category fields (prefer over text + CHECK) +2. Use integer enums for expandable value sets (no migration needed) +3. Use `timestamptz` over `timestamp` (timezone-aware) +4. Use `json` type for JSON data (cross-backend compatible) + +### MUST NOT DO + +1. **Manually create/edit/modify revision (migration) files** -- use `vespertide revision` only +2. **Manually create/edit `src/models/*.rs` (or `*.py`) files** -- use `vespertide export` to regenerate +3. Use PascalCase for reference actions -- use `"cascade"` not `"Cascade"` +4. Skip schema validation +5. Add NOT NULL columns without `default` or `fill_with` +6. Use table-level constraints for anything except CHECK +7. Use `jsonb` type -- use `json` instead (not supported cross-backend) +8. Use `custom` types -- breaks cross-database compatibility +9. Use array types -- use a join table instead + +--- + +## Naming Conventions + +| Item | Convention | Example | +|------|------------|---------| +| Tables | snake_case | `user_role` | +| Columns | snake_case | `created_at` | +| Indexes | `ix_{table}__{columns}` | `ix_user__email` | +| Unique | `uq_{table}__{columns}` | `uq_user__email` | +| Foreign Key | `fk_{table}__{columns}` | `fk_post__author_id` | +| Check | `check_{description}` | `check_positive_amount` | +| Enums | snake_case | `order_status` | + +> **Note**: Auto-generated constraint names use double underscore `__` as separator. + +--- + +## Quick Reference + +``` +SIMPLE TYPES COMPLEX TYPES +---------------------------------------- ---------------------------------------- +integer, big_int, small_int Numbers { "kind": "varchar", "length": N } +real, double_precision Floats { "kind": "char", "length": N } +text Strings { "kind": "numeric", "precision": P, "scale": S } +boolean Flags { "kind": "enum", "name": "...", "values": [...] } +date, time, timestamp Time { "kind": "custom", "custom_type": "..." } +timestamptz, interval Time+ +uuid UUIDs REFERENCE ACTIONS (snake_case!) +json JSON ---------------------------------------- +bytea Binary cascade, restrict, set_null, +inet, cidr, macaddr Network set_default, no_action +xml XML + +CONSTRAINT TYPES (inline preferred) DATABASE BACKENDS +---------------------------------------- ---------------------------------------- +primary_key, unique, index, postgres (default), mysql, sqlite +foreign_key, check +``` + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| Invalid enum in `on_delete` | PascalCase used | Use `"cascade"` not `"Cascade"` | +| Missing required property | `nullable` omitted | Add `"nullable": true/false` | +| Unknown column type | Typo in type name | Check column types table above | +| FK validation failed | Referenced table missing | Create referenced table first | +| NOT NULL without default | Adding column to existing table | Add `default` or use `fill_with` in revision | diff --git a/crates/devup-mcp/src/server/tools.rs b/crates/devup-mcp/src/server/tools.rs index 5cb5fe41..62eb156c 100644 --- a/crates/devup-mcp/src/server/tools.rs +++ b/crates/devup-mcp/src/server/tools.rs @@ -8,6 +8,28 @@ use std::collections::BTreeMap; /// (`clientId`, optional `clientSecret`) so later `login` calls skip Dynamic /// Client Registration entirely; the secret is stored in the OS credential /// store and never echoed back. See `server::diagnostics`. +/// `status` answers which skills the code devup-mcp emits needs and which of +/// them this workspace actually has. `install` writes the embedded ones. +/// +/// An external skill is never installed by this tool: its publisher ships no +/// licence, so devup-mcp reports the command rather than the bytes, and running +/// that command is the caller's. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SkillsInput { + #[serde(default = "default_skills_action")] + #[schemars(extend("enum" = super::validation::SKILL_ACTIONS))] + pub action: String, + /// Which skills to install. Empty installs every embedded skill that is + /// missing, which is the usual case on a machine that has just been set up. + #[serde(default)] + pub names: Vec, +} + +fn default_skills_action() -> String { + "status".to_owned() +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct AuthInput { diff --git a/crates/devup-mcp/src/server/validation.rs b/crates/devup-mcp/src/server/validation.rs index 1dcf7441..874d3bf7 100644 --- a/crates/devup-mcp/src/server/validation.rs +++ b/crates/devup-mcp/src/server/validation.rs @@ -19,6 +19,11 @@ use super::{ /// agent to guess `scope` and `delivery` and find out by being refused. pub(crate) const AUTH_ACTIONS: [&str; 5] = ["status", "login", "logout", "configure", "doctor"]; +/// `status` reads the workspace and writes nothing. `install` writes the +/// vendored documents for the embedded skills; it can never install an +/// external one, because those bytes are not devup-mcp's to ship. +pub(crate) const SKILL_ACTIONS: [&str; 2] = ["status", "install"]; + pub(crate) const COLLECTION_SCOPES: [&str; 3] = ["node", "page", "file"]; pub(crate) const ROOT_LAYOUTS: [&str; 2] = ["standalone", "embedded"]; diff --git a/crates/devup-mcp/tests/figma_explore.rs b/crates/devup-mcp/tests/figma_explore.rs index afb4107a..8bbbdf3e 100644 --- a/crates/devup-mcp/tests/figma_explore.rs +++ b/crates/devup-mcp/tests/figma_explore.rs @@ -191,7 +191,15 @@ async fn related_nodes_reuse_one_explore_projection_without_changing_the_request // make them two differently sized reads of the same page. assert_eq!(screen["cache"]["reuseKind"], "related-node"); assert_eq!(screen["cache"]["avoidedFigmaToolCalls"], 1); - assert_eq!(screen["cache"]["ageSeconds"], 0); + // Freshness, not a stopwatch. `ageSeconds` is whole seconds off the wall + // clock, so two calls that happen to straddle a second boundary report 1 + // while having done nothing different - which is how this failed on the + // slower CI runners and passed on the faster one. + assert!( + screen["cache"]["ageSeconds"].as_u64().unwrap() <= 1, + "the reused artifact should be seconds old at most: {}", + screen["cache"] + ); assert!(screen["cache"]["remainingTtlSeconds"].as_u64().unwrap() > 0); assert_eq!(screen["cache"]["originCollection"]["figmaToolCalls"], 1); assert_eq!(screen["collection"]["figmaToolCalls"], 0); diff --git a/crates/devup-mcp/tests/resource_delivery.rs b/crates/devup-mcp/tests/resource_delivery.rs index df1bd136..4f849e70 100644 --- a/crates/devup-mcp/tests/resource_delivery.rs +++ b/crates/devup-mcp/tests/resource_delivery.rs @@ -281,11 +281,23 @@ async fn resource_protocol_lists_manifests_and_round_trips_chunks() -> anyhow::R let manifest = &attached[0]; let listed = list_output_resources(&store, None).await?; - // One generated output plus the static usage guide, which is always listed - // and always last so that manifest positions keep their meaning. - assert_eq!(listed.resources.len(), 2, "{:?}", listed.resources); + // One generated output, then the static entries - the usage guide and the + // embedded skills. Generated outputs come first and keep their positions, + // which is the contract a caller holding a manifest link depends on; how + // many static entries follow is not part of it, so this asserts the shape + // rather than a count that changes whenever a skill is added. assert_eq!(listed.resources[0].uri, manifest.manifest_uri); - assert_eq!(listed.resources[1].uri, "devup://guide/usage"); + let statics = &listed.resources[1..]; + assert!( + statics.iter().any(|r| r.uri == "devup://guide/usage"), + "{statics:?}" + ); + assert!( + statics + .iter() + .all(|r| r.uri.starts_with("devup://guide/") || r.uri.starts_with("devup://skill/")), + "only static entries may follow the generated outputs: {statics:?}" + ); assert_eq!( listed.resources[0].mime_type.as_deref(), Some("application/json") @@ -392,8 +404,18 @@ async fn reserved_resources_stay_invisible_until_publication() -> anyhow::Result transaction.commit()?; reservation.commit(); - // The published manifest plus the always-listed usage guide. - assert_eq!(listing.await??.resources.len(), 2); + // The published manifest, first, plus the static entries behind it. + let listed = listing.await??; + assert_eq!( + listed + .resources + .iter() + .filter(|r| !r.uri.starts_with("devup://guide/") && !r.uri.starts_with("devup://skill/")) + .count(), + 1, + "exactly one output is published: {:?}", + listed.resources + ); assert!(reading.await?.is_ok()); assert_eq!(fs::read(root.join("Component.tsx"))?, b"reserved"); @@ -448,12 +470,25 @@ async fn failed_file_commit_does_not_publish_or_evict_lru_resources() -> anyhow: assert!(store.get(&unrelated.artifact_id).await.is_some()); assert!(read_output_resource(&store, &manifest_uri).await.is_err()); - // A failed commit must publish no output. The usage guide is static rather - // than published, so it is the only thing that may remain listed - and its - // presence is what proves the list itself still works. + // A failed commit must publish no output. The usage guide and the embedded + // skills are static rather than published, so they are the only things that + // may remain listed - and their presence is what proves the list itself + // still works. let listed = list_output_resources(&store, None).await?; - assert_eq!(listed.resources.len(), 1, "{:?}", listed.resources); - assert_eq!(listed.resources[0].uri, "devup://guide/usage"); + assert!( + listed + .resources + .iter() + .all(|r| r.uri.starts_with("devup://guide/") || r.uri.starts_with("devup://skill/")), + "a failed commit published something: {:?}", + listed.resources + ); + assert!( + listed + .resources + .iter() + .any(|r| r.uri == "devup://guide/usage") + ); drop(policy); fs::remove_dir_all(root)?; diff --git a/crates/devup-mcp/tests/skills_install.rs b/crates/devup-mcp/tests/skills_install.rs new file mode 100644 index 00000000..35c2ddda --- /dev/null +++ b/crates/devup-mcp/tests/skills_install.rs @@ -0,0 +1,324 @@ +//! The skill gap an agent can actually close. +//! +//! devup-mcp hands back devup-ui TSX. On a machine that has devup-mcp and +//! nothing else the receiving agent has never seen devup-ui, guesses, and this +//! server cannot see the guesses. So it reports the gap and installs what it +//! carries, and these tests hold that report to the disk it claims to describe. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use devup_mcp::server::{DevupAuth, DevupServer, Services}; +use devup_mcp_figma::{AuthStatus, DevupError, FigmaUpstream, ReadToolCall, UpstreamResult}; +use rmcp::{ + ServiceExt, + model::{CallToolRequestParams, ReadResourceRequestParams}, +}; +use serde_json::{Map, Value, json}; + +/// Nothing here reaches Figma. Installing a skill is a local write of bytes +/// already in the binary, and a test that needed a network to prove that would +/// be proving the wrong thing. +struct Offline; + +#[async_trait] +impl DevupAuth for Offline { + async fn status(&self) -> Result { + Ok(AuthStatus::Disconnected) + } + async fn login(&self) -> Result { + panic!("installing a skill must never authenticate") + } + async fn logout(&self) -> Result { + Ok(AuthStatus::Disconnected) + } +} + +#[async_trait] +impl FigmaUpstream for Offline { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec![]) + } + async fn call_read_tool(&self, _: ReadToolCall) -> Result { + panic!("installing a skill must never read Figma") + } +} + +fn scratch(label: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("devup-skills-it-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("scratch workspace"); + path +} + +async fn session(workspace: &Path, body: F) -> anyhow::Result +where + F: AsyncFnOnce(&rmcp::service::RunningService) -> anyhow::Result, +{ + let server = DevupServer::with_output_roots( + Services::new(Arc::new(Offline), Arc::new(Offline)), + vec![workspace.to_path_buf()], + )?; + let (server_transport, client_transport) = tokio::io::duplex(512 * 1024); + let task = tokio::spawn(async move { + server.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(client_transport).await?; + let out = body(&client).await; + client.cancel().await?; + let _ = task.await; + out +} + +async fn call( + client: &rmcp::service::RunningService, + tool: &str, + arguments: Value, +) -> anyhow::Result { + let arguments: Map = arguments.as_object().cloned().unwrap(); + let result = client + .call_tool(CallToolRequestParams::new(tool.to_owned()).with_arguments(arguments)) + .await?; + Ok(result.structured_content.expect("a structured response")) +} + +fn skill<'a>(report: &'a Value, name: &str) -> &'a Value { + report["skills"] + .as_array() + .expect("skills array") + .iter() + .find(|entry| entry["name"] == name) + .unwrap_or_else(|| panic!("{name} is not in the report")) +} + +/// The whole point, end to end: a bare workspace reports the gap, one call +/// closes it, and the state afterwards is read from the disk rather than +/// asserted by the call that did the writing. +#[tokio::test] +async fn a_bare_workspace_reports_the_gap_and_one_call_closes_it() -> anyhow::Result<()> { + let workspace = scratch("cycle"); + let result = session(&workspace, async |client| { + let before = call(client, "devup_skills", json!({"action": "status"})).await?; + assert_eq!(before["missingCount"], 5, "{before}"); + assert_eq!(before["installedCount"], 0); + assert_eq!(skill(&before, "devup-ui")["installed"], false); + + let installed = call(client, "devup_skills", json!({"action": "install"})).await?; + let after = installed["state"].clone(); + Ok((before, installed, after)) + }) + .await?; + let (_, installed, after) = result; + + // Only what devup-mcp carries. The two vercel skills are someone else's + // bytes and stay someone else's. + let written = installed["installed"].as_array().unwrap(); + assert_eq!(written.len(), 3, "{installed}"); + for entry in written { + let path = Path::new(entry["path"].as_str().unwrap()); + assert!( + path.is_file(), + "{} was reported but not written", + path.display() + ); + let body = std::fs::read_to_string(path)?; + assert!( + body.contains("Vendored from dev-five-git/"), + "an installed skill must carry the revision it came from" + ); + } + + assert_eq!(after["installedCount"], 3); + assert_eq!(after["missingCount"], 2, "the two external skills remain"); + assert_eq!(skill(&after, "devup-ui")["installed"], true); + assert_eq!( + skill(&after, "vercel-react-best-practices")["installed"], + false + ); + + let _ = std::fs::remove_dir_all(&workspace); + Ok(()) +} + +/// An external skill is reported, never written. Its publisher ships no +/// licence, so the bytes are not devup-mcp's to redistribute - and the command +/// that does install it is handed over rather than run. +#[tokio::test] +async fn external_skills_are_handed_over_as_a_command_and_never_written() -> anyhow::Result<()> { + let workspace = scratch("external"); + session(&workspace, async |client| { + let installed = call( + client, + "devup_skills", + json!({"action": "install", "names": ["vercel-react-view-transitions"]}), + ) + .await?; + + assert!( + installed["installed"].as_array().unwrap().is_empty(), + "nothing of theirs may be written: {installed}" + ); + let refused = &installed["notInstallable"][0]; + assert_eq!(refused["name"], "vercel-react-view-transitions"); + assert_eq!( + refused["command"], + "npx skills add vercel-labs/agent-skills" + ); + assert!( + refused["why"].as_str().unwrap().contains("no LICENSE"), + "declining to ship someone's work has to say why: {refused}" + ); + assert!( + installed["boundary"] + .as_str() + .unwrap() + .contains("no install command was executed") + ); + Ok(()) + }) + .await?; + + // The refusal is not a quiet no-op that left files behind. + for root in [".claude/skills", ".opencode/skill", ".agents/skills"] { + let path = workspace + .join(root) + .join("vercel-react-view-transitions") + .join("SKILL.md"); + assert!(!path.exists(), "{} should not exist", path.display()); + } + + let _ = std::fs::remove_dir_all(&workspace); + Ok(()) +} + +/// A workspace that already has a skill root has answered which runtime it is +/// for. Installing into a different one would write where nothing reads. +#[tokio::test] +async fn an_existing_skill_root_is_the_one_used() -> anyhow::Result<()> { + let workspace = scratch("root"); + std::fs::create_dir_all(workspace.join(".opencode/skill"))?; + + session(&workspace, async |client| { + let installed = call( + client, + "devup_skills", + json!({"action": "install", "names": ["devup-ui"]}), + ) + .await?; + let path = installed["installed"][0]["path"].as_str().unwrap(); + assert!(path.contains(".opencode"), "wrote to {path}"); + Ok(()) + }) + .await?; + + assert!( + workspace + .join(".opencode/skill/devup-ui/SKILL.md") + .is_file() + ); + assert!( + !workspace.join(".claude/skills").exists(), + "the default root must not be created when another already exists" + ); + + let _ = std::fs::remove_dir_all(&workspace); + Ok(()) +} + +/// Reading a skill without installing it still has to work, and what comes back +/// has to say how old it is - a caller handed rules with no revision cannot +/// tell whether to trust them over the repository. +#[tokio::test] +async fn an_embedded_skill_is_readable_as_a_resource_with_its_provenance() -> anyhow::Result<()> { + let workspace = scratch("resource"); + session(&workspace, async |client| { + let listed = client.list_resources(Default::default()).await?; + assert!( + listed + .resources + .iter() + .any(|resource| resource.uri == "devup://skill/devup-ui"), + "the embedded skills must be listed" + ); + assert!( + !listed + .resources + .iter() + .any(|resource| resource.uri.contains("vercel-")), + "a URI must not be advertised for content this binary does not hold" + ); + + let read = client + .read_resource(ReadResourceRequestParams::new("devup://skill/devup-ui")) + .await?; + let text = match &read.contents[0] { + rmcp::model::ResourceContents::TextResourceContents { text, .. } => text.clone(), + other => panic!("a skill is text, got {other:?}"), + }; + assert!(text.contains("Vendored from dev-five-git/devup-ui")); + assert!(text.contains("Cannot run on the runtime")); + Ok(()) + }) + .await?; + + let _ = std::fs::remove_dir_all(&workspace); + Ok(()) +} + +/// A second install is not a second copy. Reporting an already-present skill as +/// missing would have the agent write a duplicate that then drifts. +#[tokio::test] +async fn installing_twice_changes_nothing_the_second_time() -> anyhow::Result<()> { + let workspace = scratch("twice"); + session(&workspace, async |client| { + let first = call(client, "devup_skills", json!({"action": "install"})).await?; + assert_eq!(first["installed"].as_array().unwrap().len(), 3); + + let second = call(client, "devup_skills", json!({"action": "install"})).await?; + assert!( + second["installed"].as_array().unwrap().is_empty(), + "{second}" + ); + assert_eq!(second["alreadyPresent"].as_array().unwrap().len(), 3); + assert_eq!(second["state"]["installedCount"], 3); + Ok(()) + }) + .await?; + + let _ = std::fs::remove_dir_all(&workspace); + Ok(()) +} + +/// An unknown name is a caller mistake worth naming, with the set that would +/// have worked - not a silent success that installs nothing. +#[tokio::test] +async fn an_unknown_skill_name_is_refused_with_the_known_set() -> anyhow::Result<()> { + let workspace = scratch("unknown"); + session(&workspace, async |client| { + let arguments: Map = json!({"action": "install", "names": ["react"]}) + .as_object() + .cloned() + .unwrap(); + let result = client + .call_tool( + CallToolRequestParams::new("devup_skills".to_owned()).with_arguments(arguments), + ) + .await?; + assert_eq!(result.is_error, Some(true), "{result:?}"); + let error = &result.structured_content.expect("a structured refusal")["error"]; + assert_eq!(error["code"], "DEVUP_INVALID_INPUT"); + assert!(error["message"].as_str().unwrap().contains("react")); + // The set that would have worked, so the next call is a correction + // rather than another guess. + let known = error["details"]["known"].as_array().unwrap(); + assert_eq!(known.len(), 5, "{known:?}"); + assert!(known.iter().any(|name| name == "devup-ui")); + Ok(()) + }) + .await?; + + let _ = std::fs::remove_dir_all(&workspace); + Ok(()) +} diff --git a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs index 8cfbd642..edb66f96 100644 --- a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs +++ b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs @@ -209,8 +209,8 @@ fn tools_list_over_raw_stdio_has_no_boolean_schemas_and_object_output_types() -> .expect("tools/list result must contain a tools array"); assert_eq!( tools.len(), - 9, - "expected all 9 devup-mcp tools (4 devup_figma_* + devup_project_context + devup_ui_validate + devup_stack_diff + devup_visual_compare + devup_feature_trace) to be listed: {tools:?}" + 10, + "expected all 10 devup-mcp tools (4 devup_figma_* + devup_skills + devup_project_context + devup_ui_validate + devup_stack_diff + devup_visual_compare + devup_feature_trace) to be listed: {tools:?}" ); let mut boolean_schema_hits = Vec::new(); diff --git a/crates/devup-mcp/tests/stdio_smoke.rs b/crates/devup-mcp/tests/stdio_smoke.rs index c6d4da13..666f7a2e 100644 --- a/crates/devup-mcp/tests/stdio_smoke.rs +++ b/crates/devup-mcp/tests/stdio_smoke.rs @@ -76,7 +76,8 @@ async fn fresh_binary_initializes_lists_tools_and_reports_auth_status() -> anyho assert!(names.contains(&"devup_figma_explore")); assert!(names.contains(&"devup_visual_compare")); assert!(names.contains(&"devup_feature_trace")); - assert_eq!(names.len(), 9); + assert!(names.contains(&"devup_skills")); + assert_eq!(names.len(), 10); send( &mut stdin, diff --git a/crates/devup-mcp/tests/stdio_tools.rs b/crates/devup-mcp/tests/stdio_tools.rs index 0740f2ba..16564425 100644 --- a/crates/devup-mcp/tests/stdio_tools.rs +++ b/crates/devup-mcp/tests/stdio_tools.rs @@ -103,11 +103,18 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { assert_eq!(resources.list_changed, None); // This used to assert an empty list, which recorded the fact that the only // resources were per-artifact outputs and a fresh session had none. The - // usage guide is now a static resource, so a fresh session lists exactly - // it, and the artifact outputs still come first when they exist. + // usage guide and the embedded skills are static resources, so a fresh + // session lists exactly those, and the artifact outputs still come first + // when they exist. let listed = client.list_all_resources().await?; - assert_eq!(listed.len(), 1, "{listed:?}"); - assert_eq!(listed[0].uri, "devup://guide/usage"); + assert!( + listed + .iter() + .all(|r| r.uri.starts_with("devup://guide/") || r.uri.starts_with("devup://skill/")), + "a fresh session has no generated outputs: {listed:?}" + ); + assert!(listed.iter().any(|r| r.uri == "devup://guide/usage")); + assert!(listed.iter().any(|r| r.uri == "devup://skill/devup-ui")); assert_eq!(client.list_all_resource_templates().await?.len(), 2); let tools = client.list_all_tools().await?; assert!( @@ -166,6 +173,7 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { "devup_figma_export", "devup_figma_search", "devup_project_context", + "devup_skills", "devup_stack_diff", "devup_ui_validate", "devup_visual_compare", diff --git a/scripts/refresh-skills.mjs b/scripts/refresh-skills.mjs new file mode 100644 index 00000000..d2fcdaf1 --- /dev/null +++ b/scripts/refresh-skills.mjs @@ -0,0 +1,92 @@ +// Re-vendors the embedded SKILL.md documents from their source repositories. +// +// The documents under `crates/devup-mcp/src/server/skills/` are copies. The +// repository each one names is the source of truth, and a copy goes stale the +// moment that repository moves - which is the honest cost of shipping them in +// the binary, and the reason this script exists rather than a note asking +// someone to remember. +// +// Run it, commit what changed, and the integrity test in `skills.rs` will +// confirm the manifest and the documents agree. Editing a vendored document by +// hand instead will fail that test, which is the point: the fix belongs +// upstream, not in the copy. +// +// node scripts/refresh-skills.mjs # rewrite the embedded documents +// node scripts/refresh-skills.mjs --check # report drift, write nothing +// +// External skills are listed and never fetched. vercel-labs/agent-skills ships +// no LICENSE, so its content is not devup-mcp's to redistribute; the manifest +// carries its install command instead. + +import { createHash } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const skillDir = resolve(here, '../crates/devup-mcp/src/server/skills') +const manifestPath = join(skillDir, 'manifest.json') +const check = process.argv.includes('--check') + +const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex') + +async function github(path, raw = false) { + const response = await fetch(`https://api.github.com/${path}`, { + headers: { + accept: raw ? 'application/vnd.github.raw' : 'application/vnd.github+json', + 'user-agent': 'devup-mcp-refresh-skills', + ...(process.env.GITHUB_TOKEN + ? { authorization: `Bearer ${process.env.GITHUB_TOKEN}` } + : {}), + }, + }) + if (!response.ok) { + throw new Error(`GET ${path} -> ${response.status} ${response.statusText}`) + } + return raw ? response.text() : response.json() +} + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) +let drifted = 0 + +for (const entry of manifest.skills) { + if (entry.origin !== 'embedded') { + console.log(`- ${entry.name}: external, install with \`${entry.installCommand}\``) + continue + } + + const text = await github(`repos/${entry.repo}/contents/${entry.path}`, true) + const [head] = await github( + `repos/${entry.repo}/commits?path=${encodeURIComponent(entry.path)}&per_page=1`, + ) + const digest = sha256(text) + + if (digest === entry.sha256) { + console.log(`= ${entry.name}: unchanged at ${entry.commit.slice(0, 12)}`) + continue + } + + drifted += 1 + console.log( + `~ ${entry.name}: ${entry.commit.slice(0, 12)} -> ${head.sha.slice(0, 12)} ` + + `(${entry.bytes} -> ${Buffer.byteLength(text, 'utf8')} bytes)`, + ) + if (check) continue + + writeFileSync(join(skillDir, `${entry.name}.md`), text, 'utf8') + entry.commit = head.sha + entry.committedAt = head.commit.committer.date + entry.sha256 = digest + entry.bytes = Buffer.byteLength(text, 'utf8') + entry.sourceUrl = `https://github.com/${entry.repo}/blob/${head.sha}/${entry.path}` +} + +if (!check && drifted > 0) { + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + console.log(`\nRewrote ${drifted} document(s) and the manifest. Commit both.`) +} else if (check && drifted > 0) { + console.error(`\n${drifted} vendored document(s) are behind their source.`) + process.exit(1) +} else { + console.log('\nEvery vendored document matches its source.') +}