feat(skills): paste SKILL.md to auto-fill the Create Skill modal (#477)

* feat(skills): paste SKILL.md to auto-fill the Create Skill modal

When a user pastes an Anthropic-style SKILL.md (YAML frontmatter +
markdown body) into the Create Skill content textarea, the frontend
sniffs the leading ``---``, posts the raw text to a new backend parse
endpoint, and populates name / description / tags / author / version /
license / compatibility / allowed_tools from the parsed fields.  The
textarea is left with the body only (frontmatter stripped), and a toast
reports how many fields were set vs. kept (already-typed values are
preserved).

Backend
- ``POST /v1/api/admin/skills/parse`` (admin.skills permission) wraps
  the existing ``turnstone.core.skill_parser.parse_skill_md`` so admin
  imports and external installs share one parser.  ``ParseSkillRequest``
  / ``ParseSkillResponse`` schemas added; OpenAPI spec + sync/async
  console SDK methods updated.
- Hardening: 32 KiB cap on ``raw`` (Pydantic ``max_length`` + handler
  enforcement); ``Content-Length`` pre-check returns 413 before any body
  buffering; parse offloaded via ``asyncio.to_thread`` so deeply-nested
  YAML cannot stall the event loop.

Frontend (turnstone/console/static)
- New paste handler with optimistic paint (raw text shown immediately,
  textarea disabled + ``aria-busy`` flipped, hint switches to
  "Parsing...") so the round-trip is visible on slow networks.
- ``AbortController`` + generation guard (``_ctmPasteController``) so a
  fresh paste or modal close cancels a stale fetch — the previous
  handler's callbacks see the controller has been replaced and bail
  before touching the DOM.
- Non-destructive overwrite: ``_setSkillFormField`` returns "filled" /
  "skipped" / "absent" and refuses to clobber non-empty values.  Toast
  reports counts.
- Bumps ``#toast`` z-index above modal overlays (was 200 vs. modal 600
  — toasts fired while a modal was open were invisible).  Console-wide
  fix exposed by this being the first feature to fire toasts mid-modal.

HTML / CSS
- New ``.skill-paste-hint`` line above the textarea announcing the
  affordance, sized to match surrounding ``.label-hint`` text.
- ``aria-describedby`` ties the hint to the textarea; ``aria-live=
  "polite"`` announces the busy-state transition to screen readers.
- "Skill Content" heading hint reworded "system message — ..." →
  "available: ..." and the variables row label "Variables" → "Used"
  to disambiguate available vs. in-use template variables.

Tests
- 11 new cases in ``tests/test_skill_parse_api.py``: happy paths
  (full / minimal / nested-metadata / unquoted-colon recovery),
  malformed YAML 400, missing/blank/missing-name 400, RBAC 403, raw
  body 32 KiB cap (Content-Length pre-check), chunked-encoding bypass
  forces the application-layer cap.  Test pins ``raw_frontmatter``
  omission so a future ``dataclasses.asdict`` refactor can't silently
  leak the full YAML dict back to clients.

Validation
- 5146 / 5146 ``pytest -k "not live"`` pass.
- ``ruff`` + ``mypy`` clean on changed sources.
- ``node -c`` clean on governance.js.
- Two-stage code review (full pipeline + bug+quality re-review of the
  fix patches) applied; all confirmed findings addressed.

* fix(skills): Copilot PR #477 review fixes (cumulative bug-1, bug-2, q-1)

bug-1 (server.py): Content-Length pre-check was clamped to 32 KiB —
the same number as the per-string char cap on ``raw``.  A legitimate
``raw`` of exactly 32 KiB produces a JSON body well above 32 KiB once
the ``{"raw":"..."}`` wrapper and any escaping is added, so valid
near-max requests were 413'd.  New constant
``_PARSE_SKILL_MAX_BODY_BYTES = _PARSE_SKILL_MAX_CHARS * 4`` admits the
wrapper + multibyte expansion while still refusing obviously oversized
payloads early; the per-string ``len(raw)`` check stays authoritative.

bug-2 (governance.js): hideCreateTemplateModal aborted the inflight
paste controller and nulled the global, but the handler's ``.catch``
and ``.finally`` guard each DOM mutation behind ``_isCurrent()`` —
both bail when the controller has been nulled, leaving the textarea
``disabled`` + ``aria-busy`` and the hint stuck on "Parsing…".
Reopening the modal landed on a poisoned state.  The second-pass
review's q-2 cleanup that dropped the show-side defensive reset
missed this scenario — the verifier's reachability argument confused
"controller is null" with "UI state is reset"; the two are
independent.  Hide now resets the paste-induced visible state
alongside the abort.

q-1 (console_spec.py): error_codes for the parse endpoint listed only
400; handler also returns 413 for oversized bodies.  Added 413; kept
403 implicit per the convention sibling admin endpoints follow.

Test fixup: bumped the Content-Length test payload to 200 KB so it
clearly exceeds the new 128 KB pre-check threshold; otherwise it was
falling through to the per-string check and duplicating
test_oversized_raw_chunked_returns_413's coverage.

(cherry picked from commit 0a8083e6d5)
This commit is contained in:
Patrick Buckley
2026-05-04 16:12:53 -07:00
parent b8fadad94f
commit d16c911750
9 changed files with 605 additions and 10 deletions
+232
View File
@@ -0,0 +1,232 @@
"""Tests for the SKILL.md parse admin API endpoint.
The endpoint is a thin permission-checked wrapper around
``turnstone.core.skill_parser.parse_skill_md``. These tests cover the
routing, auth, and error-handling layers — parser semantics live in
``test_skill_parser.py``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from collections.abc import Iterator
from starlette.requests import Request
from starlette.responses import Response
from turnstone.console.server import admin_parse_skill
from turnstone.core.auth import AuthResult
class _InjectAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset({"read", "write", "approve", "admin.skills"}),
)
return await call_next(request)
class _InjectAuthNoSkillsMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="jwt",
permissions=frozenset({"read", "write", "approve"}),
)
return await call_next(request)
_ROUTES = [
Mount(
"/v1",
routes=[
Route("/api/admin/skills/parse", admin_parse_skill, methods=["POST"]),
],
),
]
@pytest.fixture
def client() -> TestClient:
app = Starlette(
routes=_ROUTES,
middleware=[Middleware(_InjectAuthMiddleware)],
)
return TestClient(app)
@pytest.fixture
def client_no_perm() -> TestClient:
app = Starlette(
routes=_ROUTES,
middleware=[Middleware(_InjectAuthNoSkillsMiddleware)],
)
return TestClient(app)
_FULL_SKILL = """\
---
name: code-review
description: Automated code review skill
author: Test Author
version: 2.0.0
tags: [python, review, quality]
allowed-tools: [read_file, list_directory]
license: MIT
compatibility: ">=0.7"
---
# Code Review
Review code for best practices.
"""
_MINIMAL_SKILL = """\
---
name: minimal
---
Just some content.
"""
class TestParseSkill:
def test_parses_full_frontmatter(self, client: TestClient) -> None:
resp = client.post("/v1/api/admin/skills/parse", json={"raw": _FULL_SKILL})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "code-review"
assert data["description"] == "Automated code review skill"
assert data["author"] == "Test Author"
assert data["version"] == "2.0.0"
assert data["tags"] == ["python", "review", "quality"]
assert data["allowed_tools"] == ["read_file", "list_directory"]
assert data["license"] == "MIT"
assert data["compatibility"] == ">=0.7"
assert "# Code Review" in data["content"]
# Frontmatter should not leak into the body.
assert "name: code-review" not in data["content"]
# ParsedSkill carries raw_frontmatter (the full YAML dict) but the
# handler whitelists fields by hand to avoid leaking arbitrary keys.
# Pin that contract — a future refactor to dataclasses.asdict would
# silently break it without this assertion.
assert "raw_frontmatter" not in data
def test_parses_minimal_frontmatter(self, client: TestClient) -> None:
resp = client.post("/v1/api/admin/skills/parse", json={"raw": _MINIMAL_SKILL})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "minimal"
assert data["description"] == "Just some content."
assert data["version"] == "1.0.0"
assert data["tags"] == []
assert data["allowed_tools"] == []
assert data["license"] == ""
def test_anthropic_nested_metadata_tags(self, client: TestClient) -> None:
# Anthropic-style skill puts tags under metadata.tags rather than
# at the top level — the parser must handle both layouts.
raw = """\
---
name: nested-meta
description: A skill using nested metadata
metadata:
tags: [alpha, beta]
author: Anthropic
version: 3.1.4
---
Body.
"""
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
assert resp.status_code == 200
data = resp.json()
assert data["tags"] == ["alpha", "beta"]
assert data["author"] == "Anthropic"
assert data["version"] == "3.1.4"
def test_unquoted_colon_in_description(self, client: TestClient) -> None:
# Common cross-client mistake: ``description: Use when: the user...``
# The parser retries with the description value quoted.
raw = """\
---
name: colon-desc
description: Use when: the user asks for a review
---
Body.
"""
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "colon-desc"
assert "Use when" in data["description"]
def test_missing_name_returns_400(self, client: TestClient) -> None:
raw = """\
---
description: No name field
---
Body.
"""
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
assert resp.status_code == 400
assert "name" in resp.json()["error"].lower()
def test_missing_raw_returns_400(self, client: TestClient) -> None:
resp = client.post("/v1/api/admin/skills/parse", json={})
assert resp.status_code == 400
assert "raw" in resp.json()["error"].lower()
def test_blank_raw_returns_400(self, client: TestClient) -> None:
resp = client.post("/v1/api/admin/skills/parse", json={"raw": " \n"})
assert resp.status_code == 400
def test_invalid_yaml_returns_400(self, client: TestClient) -> None:
# YAML that the malformed-description retry can't fix.
raw = "---\nname: [not, valid, here\n---\nBody.\n"
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
assert resp.status_code == 400
def test_requires_admin_skills_permission(self, client_no_perm: TestClient) -> None:
resp = client_no_perm.post("/v1/api/admin/skills/parse", json={"raw": _MINIMAL_SKILL})
assert resp.status_code == 403
def test_oversized_content_length_returns_413(self, client: TestClient) -> None:
# Content-Length pre-check rejects oversized bodies before they're
# buffered into memory. Caps worker memory against an admin-token
# holder spraying multi-GB JSON. The threshold is generous (~4×
# the per-string cap) so payload here must clearly exceed it.
oversized = "a" * 200_000
resp = client.post("/v1/api/admin/skills/parse", json={"raw": oversized})
assert resp.status_code == 413
def test_oversized_raw_chunked_returns_413(self, client: TestClient) -> None:
# When the client sends Transfer-Encoding: chunked there is no
# Content-Length header, so the pre-check is skipped and the
# application-layer cap is the only line of defence. httpx switches
# to chunked when the body is a generator.
def _gen() -> Iterator[bytes]:
yield b'{"raw":"' + b"a" * 33_000 + b'"}'
resp = client.post(
"/v1/api/admin/skills/parse",
content=_gen(),
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 413
assert "raw" in resp.json()["error"].lower()
+28
View File
@@ -765,6 +765,34 @@ class SkillDiscoverResponse(BaseModel):
skills: list[SkillDiscoverListing]
class ParseSkillRequest(BaseModel):
raw: str = Field(
min_length=1,
max_length=32_768,
description=(
"Raw SKILL.md text — YAML frontmatter delimited by ``---`` "
"followed by the markdown body. Capped at 32 KiB to match "
"``admin_create_skill``'s ``content`` ceiling and to bound "
"the synchronous YAML parser's worst-case CPU cost. The "
"handler reuses the Python parser at "
"``turnstone.core.skill_parser`` so admin UIs and external "
"import paths agree on field extraction."
),
)
class ParseSkillResponse(BaseModel):
name: str
description: str
content: str
tags: list[str] = Field(default_factory=list)
author: str = ""
version: str = "1.0.0"
allowed_tools: list[str] = Field(default_factory=list)
license: str = ""
compatibility: str = ""
class SkillInstallRequest(BaseModel):
source: str # "skills.sh" or "github"
skill_id: str = "" # for skills.sh
+13
View File
@@ -77,6 +77,8 @@ from turnstone.api.console_schemas import (
NodeMetadataResponse,
OrgInfo,
OutputAssessmentInfo,
ParseSkillRequest,
ParseSkillResponse,
RegistryInstallRequest,
RegistrySearchResponse,
RoleInfo,
@@ -552,6 +554,15 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[400, 404, 409, 502],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/skills/parse",
"POST",
"Parse a SKILL.md document and return its frontmatter fields and body",
request_model=ParseSkillRequest,
response_model=ParseSkillResponse,
error_codes=[400, 413],
tags=["Admin"],
),
# --- Governance: Skills ---
EndpointSpec(
"/v1/api/admin/skills",
@@ -1594,6 +1605,8 @@ _ALL_MODELS: list[type[BaseModel]] = [
SkillInstallResponse,
SkillInfo,
SkillVersionInfo,
ParseSkillRequest,
ParseSkillResponse,
CreateSkillRequest,
UpdateSkillRequest,
ListSkillsResponse,
+79
View File
@@ -6975,6 +6975,80 @@ def _get_discovery_url(request: Request) -> str:
return DEFAULT_DISCOVERY_URL
# Authoritative cap on ``raw`` itself — code points, matching the Pydantic
# ``max_length`` on ``ParseSkillRequest.raw`` and the ``content[:32768]``
# truncation in ``admin_create_skill``.
_PARSE_SKILL_MAX_CHARS = 32_768
# Generous coarse Content-Length pre-check. The HTTP body carries the JSON
# wrapper (``{"raw":"..."}`` + escaping) plus any UTF-8 multi-byte expansion,
# so a legitimate max-length ``raw`` produces a body well above the char cap
# — rejecting at exactly 32 KiB would 413 valid near-max requests. This
# threshold only needs to refuse obviously oversized payloads before
# ``request.json()`` buffers them; the per-string ``len(raw)`` check below
# is the authoritative limit.
_PARSE_SKILL_MAX_BODY_BYTES = _PARSE_SKILL_MAX_CHARS * 4
async def admin_parse_skill(request: Request) -> JSONResponse:
"""POST /v1/api/admin/skills/parse — parse SKILL.md frontmatter + body.
Used by the admin UI's create/edit skill modals: when a user pastes a
full SKILL.md document, the frontend posts the raw text here and uses
the returned fields to populate the form, then drops the body into
the content textarea. Reusing the Python parser keeps admin imports
and external skill installs in lockstep on edge cases (Hermes/nested
metadata layouts, malformed-YAML recovery, length caps).
Hardening: size cap + threadpool offload guard against deeply-nested
YAML; alias amplification is not a concern with ``safe_load``.
"""
from turnstone.core.auth import require_permission
from turnstone.core.skill_parser import parse_skill_md
from turnstone.core.web_helpers import read_json_or_400
err = require_permission(request, "admin.skills")
if err:
return err
# Reject oversized bodies before buffering — protects worker memory from
# an admin token spraying multi-GB JSON. Threshold is generous enough
# to admit a max-length ``raw`` plus its JSON wrapper and escaping; the
# per-string check below enforces the exact 32 KiB rule.
cl = request.headers.get("content-length")
if cl and cl.isdigit() and int(cl) > _PARSE_SKILL_MAX_BODY_BYTES:
return JSONResponse({"error": "request body too large"}, status_code=413)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
raw = body.get("raw")
if not isinstance(raw, str) or not raw.strip():
return JSONResponse({"error": "raw is required"}, status_code=400)
if len(raw) > _PARSE_SKILL_MAX_CHARS:
return JSONResponse({"error": "raw exceeds 32 KiB"}, status_code=413)
try:
parsed = await asyncio.to_thread(parse_skill_md, raw)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
return JSONResponse(
{
"name": parsed.name,
"description": parsed.description,
"content": parsed.content,
"tags": list(parsed.tags),
"author": parsed.author,
"version": parsed.version,
"allowed_tools": list(parsed.allowed_tools),
"license": parsed.license,
"compatibility": parsed.compatibility,
}
)
async def admin_skill_discover(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills/discover — search external skill registries."""
from turnstone.core.auth import require_permission
@@ -11036,6 +11110,11 @@ def create_app(
admin_skill_install,
methods=["POST"],
),
Route(
"/api/admin/skills/parse",
admin_parse_skill,
methods=["POST"],
),
# Governance: Skills
Route("/api/admin/skills", admin_list_skills),
Route("/api/admin/skills", admin_create_skill, methods=["POST"]),
+196 -2
View File
@@ -839,6 +839,179 @@ function _renderGovSkills(items) {
});
}
// ---------------------------------------------------------------------------
// SKILL.md paste auto-fill — sniff frontmatter on paste, hit the parse
// endpoint, and populate the form so users don't have to retype name /
// description / tags / etc. when importing an Anthropic-style skill.
// ---------------------------------------------------------------------------
// Trigger on any paste whose first non-whitespace bytes look like an opening
// YAML frontmatter delimiter — restrictive enough to ignore normal markdown
// pastes, permissive enough to catch CRLF and trailing-space variants.
var _SKILL_FRONTMATTER_RE = /^---\s*\r?\n/;
var _SKILL_FIELD_MAP = {
name: "ctm-name",
description: "skill-description",
tags: "skill-tags",
author: "skill-author",
version: "skill-version",
license: "skill-license",
compatibility: "skill-compatibility",
allowed_tools: "csk-allowed-tools",
};
// Inflight paste-parse fetch — referenced from hideCreateTemplateModal so a
// modal close cancels the request, and from _handleSkillContentPaste so a
// fresh paste supersedes the previous one. Acts as a generation token: any
// callback that observes _ctmPasteController != its captured controller knows
// the modal moved on and must not touch the DOM.
var _ctmPasteController = null;
// Returns "filled" if we set the value, "skipped" if the field was already
// non-empty (we don't clobber user input), or "absent" if we couldn't find or
// match the option. Tracking this lets the caller report what actually
// happened so the user knows whether their pre-typed values survived.
function _setSkillFormField(id, value) {
var el = document.getElementById(id);
if (!el) return "absent";
if (el.value && String(el.value).trim()) return "skipped";
if (el.tagName === "SELECT") {
// License is a fixed option list — only set the value if it matches an
// option. Custom licenses fall through to the default "— not specified —"
// and the user can edit manually.
for (var i = 0; i < el.options.length; i++) {
if (el.options[i].value === value) {
el.value = value;
return "filled";
}
}
return "absent";
}
el.value = value;
return "filled";
}
function _applyParsedSkill(parsed, contentTextarea, fieldMap) {
// The textarea is the explicit paste target — replacing its full content
// matches the user's mental model ("I pasted a SKILL.md, the body should
// become the content"). Side metadata fields use the non-destructive
// _setSkillFormField rule below so half-typed values aren't lost.
contentTextarea.value = parsed.content || "";
contentTextarea.dispatchEvent(new Event("input", { bubbles: true }));
var filled = 0;
var skipped = 0;
function _apply(id, value) {
var outcome = _setSkillFormField(id, value);
if (outcome === "filled") filled++;
else if (outcome === "skipped") skipped++;
}
if (parsed.name) _apply(fieldMap.name, parsed.name);
if (parsed.description) _apply(fieldMap.description, parsed.description);
if (parsed.tags && parsed.tags.length)
_apply(fieldMap.tags, parsed.tags.join(", "));
if (parsed.author) _apply(fieldMap.author, parsed.author);
if (parsed.version) _apply(fieldMap.version, parsed.version);
if (parsed.license) _apply(fieldMap.license, parsed.license);
if (parsed.compatibility)
_apply(fieldMap.compatibility, parsed.compatibility);
if (parsed.allowed_tools && parsed.allowed_tools.length)
_apply(fieldMap.allowed_tools, parsed.allowed_tools.join(", "));
return { filled: filled, skipped: skipped };
}
function _setSkillPasteHintBusy(busy) {
var hint = document.getElementById("ctm-paste-hint");
if (!hint) return;
var rest = hint.querySelector(".skill-paste-hint-rest");
var busyEl = hint.querySelector(".skill-paste-hint-busy");
if (rest) rest.style.display = busy ? "none" : "";
if (busyEl) busyEl.style.display = busy ? "" : "none";
}
function _handleSkillContentPaste(event, fieldMap) {
var clipboard = event.clipboardData || window.clipboardData;
if (!clipboard) return;
var text = clipboard.getData("text/plain");
if (!text || !_SKILL_FRONTMATTER_RE.test(text)) return;
event.preventDefault();
var textarea = event.target;
// Cancel any prior paste fetch — a fresh paste supersedes whatever was in
// flight. The previous handler's callbacks see _ctmPasteController !=
// their captured controller and bail before touching the DOM.
if (_ctmPasteController) _ctmPasteController.abort();
var controller = new AbortController();
_ctmPasteController = controller;
// Optimistic paint — drop the raw text into the textarea immediately so the
// user sees their paste landed, then disable the field and flip the hint
// line into a "Parsing..." state. On a slow network the round-trip can
// stretch past 400ms; without a visible state the user thinks nothing
// happened and re-pastes (or hits Create with empty fields).
textarea.value = text;
textarea.disabled = true;
textarea.setAttribute("aria-busy", "true");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
_setSkillPasteHintBusy(true);
function _isCurrent() {
return _ctmPasteController === controller;
}
authFetch("/v1/api/admin/skills/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ raw: text }),
signal: controller.signal,
})
.then(function (r) {
return r.json().then(function (d) {
return { ok: r.ok, data: d };
});
})
.then(function (res) {
if (!_isCurrent()) return;
if (res.ok) {
var counts = _applyParsedSkill(res.data, textarea, fieldMap);
var msg = "Populated from SKILL.md";
if (counts.skipped) {
msg += " (" + counts.filled + " set, " + counts.skipped + " kept)";
}
showToast(msg);
} else {
// Frontmatter looked plausible but the parser rejected it (missing
// name, malformed YAML beyond the retry, etc). The raw text is
// already in the textarea from the optimistic paint above so the
// user can fix the YAML in place.
showToast(
"Couldn't parse SKILL.md: " +
((res.data && res.data.error) || "unknown error"),
"error",
);
}
})
.catch(function (err) {
if (!_isCurrent()) return;
// AbortError fires when the modal closed or a fresher paste superseded
// this one — silent, the new lifecycle owns the UI.
if (err && err.name === "AbortError") return;
showToast("Network error — pasted as plain text", "error");
})
.finally(function () {
if (!_isCurrent()) return;
_ctmPasteController = null;
textarea.disabled = false;
textarea.removeAttribute("aria-busy");
_setSkillPasteHintBusy(false);
textarea.focus();
});
}
function _detectTemplateVars(content) {
var matches = content.match(/\{\{(\w+)\}\}/g) || [];
var seen = {};
@@ -874,11 +1047,15 @@ function showCreateTemplateModal() {
document.getElementById("skill-license").value = "";
document.getElementById("skill-compatibility").value = "";
document.getElementById("skill-activation").value = "named";
document.getElementById("ctm-content").value = "";
var ctmContent = document.getElementById("ctm-content");
ctmContent.value = "";
document.getElementById("ctm-variables").textContent = "(none)";
document.getElementById("ctm-content").oninput = function () {
ctmContent.oninput = function () {
_updateVarsDisplay("ctm-content", "ctm-variables");
};
ctmContent.onpaste = function (event) {
_handleSkillContentPaste(event, _SKILL_FIELD_MAP);
};
document.getElementById("ctm-default").checked = false;
// Session config fields
document.getElementById("csk-model").value = "";
@@ -907,6 +1084,23 @@ function showCreateTemplateModal() {
}
function hideCreateTemplateModal() {
// Cancel any inflight paste-parse so a late response can't reach into a
// closed (or freshly reopened) modal and clobber state. AbortController
// also short-circuits the .then chain — see _handleSkillContentPaste.
// After abort, the handler's .catch/.finally bail via _isCurrent() before
// resetting the textarea, so we proactively restore the paste-induced
// visible state here. Otherwise reopening would land on a disabled
// textarea stuck on "Parsing…".
if (_ctmPasteController) {
_ctmPasteController.abort();
_ctmPasteController = null;
var ctmContent = document.getElementById("ctm-content");
if (ctmContent) {
ctmContent.disabled = false;
ctmContent.removeAttribute("aria-busy");
}
_setSkillPasteHintBusy(false);
}
document.getElementById("create-template-overlay").style.display = "none";
_ctmTrapHandler = _removeTrap(_ctmTrapHandler);
if (_ctmTriggerEl && _ctmTriggerEl.focus) {
+21 -5
View File
@@ -3094,17 +3094,33 @@
<h3 class="skill-spec-heading">
Skill Content
<span class="label-hint"
>system message &mdash; {{model}}, {{ws_id}},
{{node_id}}</span
>available: {{model}}, {{ws_id}}, {{node_id}}</span
>
</h3>
<div
class="skill-paste-hint"
id="ctm-paste-hint"
aria-live="polite"
>
<span class="skill-paste-hint-rest">
Tip: paste a SKILL.md (with <code>---</code> frontmatter) to
auto-fill the form.
</span>
<span
class="skill-paste-hint-busy"
style="display: none"
>
Parsing SKILL.md&hellip;
</span>
</div>
<textarea
id="ctm-content"
class="skill-content-area"
aria-describedby="ctm-paste-hint"
placeholder="You are a code reviewer using {{model}}..."
></textarea>
<div class="skill-vars-row">
<span class="skill-vars-label">Variables</span>
<span class="skill-vars-label">Used</span>
<div
id="ctm-variables"
class="skill-vars-display label-hint"
@@ -3379,12 +3395,12 @@
<h3 class="skill-spec-heading">
Skill Content
<span class="label-hint"
>{{model}}, {{ws_id}}, {{node_id}}</span
>available: {{model}}, {{ws_id}}, {{node_id}}</span
>
</h3>
<textarea id="etm-content" class="skill-content-area"></textarea>
<div class="skill-vars-row">
<span class="skill-vars-label">Variables</span>
<span class="skill-vars-label">Used</span>
<div
id="etm-variables"
class="skill-vars-display label-hint"
+22 -2
View File
@@ -908,11 +908,14 @@
}
/* ==========================================================================
Toast override — position above cluster status bar
Toast override — position above cluster status bar AND above admin modal
overlays (which sit at z-index 600). Without this, toasts fired while a
modal is open — e.g. paste-to-fill on the Create Skill modal — render
behind the dimmed backdrop and never reach the user.
========================================================================== */
#toast {
bottom: 56px;
z-index: 200;
z-index: 700;
color: var(--fg-bright);
border-color: var(--border-strong);
}
@@ -1909,6 +1912,23 @@ h3.skill-spec-heading {
opacity: 1;
}
/* Tip line above the Skill Content textarea announcing the paste-to-fill
affordance. Sized to match the dim hint on the heading rather than the
default body text, so it doesn't out-shout the rest of the modal. */
.skill-paste-hint {
font-size: 11px;
color: var(--fg-dim);
margin: -2px 0 6px;
line-height: 1.5;
}
.skill-paste-hint code {
font-size: 10.5px;
padding: 0 4px;
background: var(--code-bg);
border-radius: 2px;
color: var(--fg);
}
.skill-spec-section-content {
flex: 1;
display: flex;
+1 -1
View File
@@ -202,7 +202,7 @@ def parse_skill_md(raw: str, *, lenient: bool = False) -> ParsedSkill | None:
raw_desc = meta.get("description")
description = str(raw_desc).strip() if raw_desc is not None else ""
if not description and body:
first_line = body.split("\n")[0].strip()
first_line = body.split("\n", 1)[0].strip()
# Skip markdown headings
if first_line.startswith("#"):
first_line = first_line.lstrip("# ").strip()
+13
View File
@@ -38,6 +38,7 @@ from turnstone.api.console_schemas import (
McpServerDetail,
NodeDetailResponse,
OrgInfo,
ParseSkillResponse,
RegistrySearchResponse,
RoleInfo,
SettingInfo,
@@ -742,6 +743,15 @@ class AsyncTurnstoneConsole(_BaseClient):
"POST", "/v1/api/admin/skills", json_body=body, response_model=SkillInfo
)
async def parse_skill(self, raw: str) -> ParseSkillResponse:
"""Parse a SKILL.md document into structured fields without persisting."""
return await self._request(
"POST",
"/v1/api/admin/skills/parse",
json_body={"raw": raw},
response_model=ParseSkillResponse,
)
async def get_skill(self, skill_id: str) -> SkillInfo:
"""Get a skill by ID."""
return await self._request(
@@ -1526,6 +1536,9 @@ class TurnstoneConsole:
def create_skill(self, name: str, content: str, **kwargs: Any) -> SkillInfo:
return self._runner.run(self._async.create_skill(name, content, **kwargs))
def parse_skill(self, raw: str) -> ParseSkillResponse:
return self._runner.run(self._async.parse_skill(raw))
def get_skill(self, skill_id: str) -> SkillInfo:
return self._runner.run(self._async.get_skill(skill_id))