mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3094,17 +3094,33 @@
|
||||
<h3 class="skill-spec-heading">
|
||||
Skill Content
|
||||
<span class="label-hint"
|
||||
>system message — {{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…
|
||||
</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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user