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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_multi_document_skills.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"crates/devup-mcp/Cargo.toml": "Minor"
},
"note": "A skill can now be more than one file, and devfive-frontend is carried as one. devup_figma_export returns one screen's TSX, but a project is a tree of routes, and knowing devup-ui does not tell an agent which file that TSX becomes. The gap is not hypothetical: a vinext project built from service-template was implemented as a Vite SPA with hand-rolled state routing and authored CSS files, by an agent that had been given the devup-ui skill and nothing else. devup-ui is a styling skill and correctly says nothing about project structure, so the rules that would have caught it were in devfive-frontend, which no bare machine had. A third origin, own, carries it. An embedded skill is copied from another DevFive repository and pins the commit it copied, so scripts/refresh-skills.mjs can move it forward; an own skill is authored in this repository, has no second copy to drift from, and pins no commit - printing 'at unknown' would read as a lost revision rather than one that never existed. The distinction is an origin rather than a special case so both install through exactly the same path. The manifest now records documents[{path,bytes,sha256}] per skill instead of one digest per skill, and the layout is skills/<name>/<relpath> for every origin, so a document's source path and its install path are the same string and cannot disagree. install() stages a skill's documents in one OutputTransaction: a SKILL.md that survived while its four references did not is the failure this feature exists to avoid, because it looks installed and its links go nowhere. Only the entry document is annotated with provenance; references are written byte for byte, which keeps the manifest digest true of the installed file. Two things surfaced while building it. .gitattributes matched skills/*.md, and a git pattern containing a slash does not let * cross one, so moving the documents into per-skill directories silently dropped the -text attribute that the file's own comment says exists to stop a Windows checkout from failing the integrity check on that platform alone; it now matches skills/**/*.md, verified with git check-attr. And refresh-skills.mjs skipped own skills entirely, which left no way to update a digest after editing a document this repository authors - the trap that makes someone write a SHA-256 by hand. For an own skill the direction reverses, the file on disk is the truth, and the script reseals the manifest from it; --check reports the disagreement and exits 1. The integration tests spelled out how many skills exist, so adding one failed four tests that had nothing to say about it; they now derive the counts from the report. devfive-frontend also gained the two rules the SPA incident needed - vite.config.ts does not mean Vite, because vinext runs Next App Router on Vite, and no .css or .scss belongs in application source - plus the extraction rule from devup-ui issue 663, verified against the extractor: an inline object literal indexed at a style prop extracts to static classes, an external object referenced by name becomes a CSS variable, and an external object of css() results is neither, because css() already extracted at its own call site and className is never a style-extraction source.",
"date": "2026-09-14T21:30:00+09:00"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_skills_fetch_first.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"crates/devup-mcp/Cargo.toml": "Minor"
},
"note": "devup_skills install now prefers each embedded skill's current upstream documents and falls back to the copy in the binary when fetching fails. A bare machine is the reason skill delivery exists, and is also where a download is least likely to work, so freshness cannot become an installation prerequisite. The CLI already had an ETag-aware fetcher; it now accepts a URL so the MCP installer can derive each document's address from its manifest repository and path instead of accidentally fetching devup-ui for every skill. The CLI still installs devup-ui to its existing HOME targets. MCP writes stay project-local and use the server's existing OutputPolicy and one OutputTransaction. A four-second budget covers the entire install's fetch work, rather than multiplying a timeout by the number of skills. One failed document falls back to the whole embedded skill, so an updated entry is not paired with old references. Network errors, HTTP errors and missing ETags report an embedded source and the fallback reason without warnings; a 404 additionally warns that the manifest path may have moved. Fetched files cannot claim the vendored commit: their entry note and response reuse sourceUrl, etag, fetchedAt and sha256 provenance, with the digest describing upstream bytes before annotation. The note stays after frontmatter so the installed skill still loads. Own skills still come only from the binary, and external skills are never fetched, written or installed by a shell command. DEVUP_MCP_SKILLS_OFFLINE is read once and bypasses the HTTP client. MCP integration tests set it in child processes before startup, while injected upstreams exercise fetched and failure paths without opening a socket. Status, resource reads and self-check cannot enter the fetch path.",
"date": "2026-09-14T13:26:37.677275+00:00"
}
2 changes: 1 addition & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
# 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/**/*.md -text
crates/devup-mcp/src/server/skills/manifest.json -text
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ devup-mcp가 돌려주는 TSX는 **devup-ui 코드**입니다. devup-mcp만 깔

| 스킬 | 출처 | devup-mcp가 하는 일 |
|---|---|---|
| `devup-ui` · `vespera` · `vespertide` | dev-five-git (우리 것) | **바이너리에 내장.** `{"action":"install"}`이 네트워크 없이 스킬 디렉터리에 씁니다 |
| `devup-ui` · `vespera` · `vespertide` | dev-five-git (우리 것) | **바이너리에 내장.** `{"action":"install"}`이 upstream 최신 문서를 우선 가져오고, 실패하면 내장본을 스킬 디렉터리에 씁니다 |
| `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)라서 애초에 던져줄 물건이 아니라 설치할 물건입니다.
Expand All @@ -224,6 +224,12 @@ vercel 것을 내장하지 않는 이유는 두 가지입니다. **`vercel-labs/

설치 위치는 프로젝트 안입니다 — 이미 있는 것을 우선해 `.claude/skills`, `.opencode/skill`, `.agents/skills` 순으로 고릅니다. 프로젝트 루트는 devup-mcp가 쓸 수 있는 유일한 곳이라 새 권한이 필요 없고, 스킬이 저장소를 따라다닙니다. 이미 깔려 있으면 다시 쓰지 않습니다.

설치 응답의 `installed[].source`는 `fetched` 또는 `embedded`이며, 내장본을 썼다면 `reason`도 반환합니다. 가져오기는 manifest의 저장소와 문서 경로에서 만든 `https://raw.githubusercontent.com/REPO/HEAD/PATH`를 사용하며, 호출 전체의 네트워크 대기는 최대 4초입니다. 네트워크 오류, HTTP 오류, ETag 누락은 내장본으로 돌아가고, 404는 upstream 경로가 바뀌었을 수 있으므로 `warnings`에도 알립니다. 여러 문서 중 하나라도 실패하면 그 스킬 전체를 내장본으로 설치합니다.

`DEVUP_MCP_SKILLS_OFFLINE=1`로 서버를 시작하면 가져오기를 생략합니다. 이 설정은 프로세스에서 한 번 읽으며 `0`, `false`, 빈 값은 가져오기를 허용합니다. `own` 스킬은 항상 바이너리에서, `external` 스킬은 여전히 설치 명령 안내만 합니다. `--self-check`와 스킬 resource 읽기, `status`는 가져오기를 실행하지 않습니다. 별도 CLI인 `--install-skills`의 devup-ui/HOME 설치 계약은 그대로입니다.

가져온 문서의 주석은 고정 커밋을 주장하지 않고 URL, 가져온 시각(Unix seconds), ETag, 주석 추가 전 SHA-256을 기록합니다. 응답의 `installed[].documents[].provenance`도 같은 정보를 담습니다. 내장본을 설치하거나 resource로 읽을 때는 기존 커밋 정보를 유지합니다.

내장본은 각 레포의 `SKILL.md`를 그대로 복사한 것이고, 응답과 설치된 파일 모두 **어느 커밋인지와 최신본 URL**을 함께 답니다. 사본은 낡습니다 — 그게 내장의 정직한 비용이고, `node scripts/refresh-skills.mjs`가 그걸 갱신하는 방법입니다(`--check`는 쓰지 않고 드리프트만 보고). 주석은 YAML frontmatter **뒤에** 들어갑니다. `---`는 0번째 바이트에 있어야 로더가 읽습니다.

설치하지 않고 읽기만 하려면 `devup://skill/devup-ui` 리소스도 있습니다. 다만 그건 fallback입니다 — 설치해야 로더가 알아서 꺼내 줍니다.
Expand Down
7 changes: 4 additions & 3 deletions crates/devup-mcp/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,7 @@ impl DevupServer {
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. \
install fetches current documents for embedded skills, falling back to the binary on failure, and writes own skills from the binary into the workspace skill root (.claude/skills, .opencode/skill or .agents/skills - an existing one is preferred), reporting source and any fallback reason (DEVUP_MCP_SKILLS_OFFLINE=1 disables fetching). 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()
)]
Expand All @@ -926,8 +926,9 @@ impl DevupServer {
self.output_policy.primary_root(),
))),
"install" => {
let outcome =
skills::install(&self.output_policy, &input.names).map_err(to_mcp_error)?;
let outcome = skills::install(&self.output_policy, &input.names)
.await
.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.
Expand Down
26 changes: 20 additions & 6 deletions crates/devup-mcp/src/server/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,18 +101,32 @@ 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.
/// Only the skills this binary carries 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.
///
/// One URI serves one document - the entry `SKILL.md`. A multi-document skill's
/// references are not addressable here, which is the second reason installing
/// beats reading: `devup_skills` writes the whole set, so the links inside the
/// document it writes resolve.
fn skill_resource(skill: &'static skills::Skill) -> Option<Resource> {
skill.text?;
skill.entry_text()?;
let extra = skill.record.documents.len().saturating_sub(1);
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
loader then applies it on its own triggers, in this session and later ones{}.",
skill.record.description,
if extra == 0 {
String::new()
} else {
format!(
", and this URI serves only SKILL.md while the install also writes its \
{extra} reference document(s)"
)
}
))
.with_mime_type(skills::MIME_TYPE),
)
Expand Down
Loading