mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat: Agent Skills standard compliance + frontend spec fields
Brings skills implementation into full compliance with agentskills.io: Parser: - Read `allowed-tools` (hyphenated, standard) only; stored as `allowed_tools` internally — no underscore fallback - Reject consecutive hyphens in skill names - Extract author/version from standard `metadata:` map with top-level fallback; null-safe (no "None" string for bare YAML keys) - Truncate description at 1024 chars, compatibility at 500 chars (spec caps) with log warnings - Lenient parsing mode (lenient=True) for cross-client import: sanitizes names, returns None on skip, malformed-YAML colon-value retry - Type overloads: strict mode returns ParsedSkill, lenient returns ParsedSkill | None Session: - `<available-skills>` XML catalog in system messages for activation="search" skills (disabled ones filtered out, capped at 30) Tool rename: - `load_skill` tool → `skill` (JSON, session preparers/executors, approval labels, tests, docs) Storage (migration 023): - Add `license` and `compatibility` columns to prompt_templates - skill_license / compatibility params on create_prompt_template across protocol, SQLite, PostgreSQL backends - Add to SKILL_MUTABLE for update_prompt_template API + server: - SkillInfo, CreateSkillRequest, UpdateSkillRequest: license + compatibility fields - Create/update/install endpoints extract and persist both fields - Install endpoint maps parsed.license + parsed.compatibility from imported SKILL.md (previously discarded) - _skill_to_response() includes both fields Admin UI: - Create + edit modals: version, license, compatibility fields - Readonly (imported) skills: "edit" → "view" button, modal title "View Skill", all fields disabled, Save hidden, Cancel → "Close", collapsibles auto-expand, focus on Close button - :disabled CSS for dark-theme modal inputs (bg-highlight, cursor not-allowed, dimmed text) - Fix addEventListener stacking on auto-approve checkboxes → .onchange SDK: license + compatibility on SkillInfo, CreateSkillRequest, UpdateSkillRequest TypeScript interfaces Docs: governance.md, judge.md, tools.md, README, diagram updated
This commit is contained in:
committed by
Patrick Buckley
parent
52d59cf7b7
commit
dc464ac313
@@ -145,7 +145,7 @@ Turnstone includes a built-in governance layer for enterprise deployments — ma
|
||||
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
|
||||
- **OIDC SSO** — single sign-on via any OpenID Connect provider (Okta, Azure AD, Google, Keycloak); Authorization Code Flow with PKCE, auto-provisioning, claim-based role mapping with demotion propagation; see [docs/oidc.md](docs/oidc.md)
|
||||
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
|
||||
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, version history, external discovery (skills.sh / GitHub), and runtime `load_skill` tool for model-driven skill activation
|
||||
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, version history, external discovery (skills.sh / GitHub), and runtime `skill` tool for model-driven skill activation
|
||||
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
|
||||
- **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ package "Core Modules" as core #181825 {
|
||||
}
|
||||
|
||||
package "Session Runtime" as runtime #181825 {
|
||||
rectangle "load_skill tool\nsession.py" as loadtool
|
||||
rectangle "skill tool\nsession.py" as loadtool
|
||||
rectangle "set_skill()\nsession.py" as setskill
|
||||
rectangle "_load_skills()\nsession.py" as loadskills
|
||||
}
|
||||
@@ -80,6 +80,7 @@ importui --> install : POST (github source)
|
||||
' Annotations
|
||||
note right of parser
|
||||
YAML frontmatter -> ParsedSkill
|
||||
allowed-tools (standard) -> allowed_tools (internal)
|
||||
Anthropic + Hermes tag formats
|
||||
Name validation (lowercase+hyphens)
|
||||
end note
|
||||
|
||||
+6
-2
@@ -70,7 +70,7 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
`{{node_id}}` (server node ID). Unrecognized placeholders are kept as-is.
|
||||
- **Runtime switching**: `/template <name>` to switch, `/template clear` to revert
|
||||
to defaults, `/template` to show current. Persisted across resume.
|
||||
- **Model-driven loading**: The `load_skill` built-in tool lets the model
|
||||
- **Model-driven loading**: The `skill` built-in tool lets the model
|
||||
discover and activate skills mid-conversation. `search` action finds skills
|
||||
by query (auto-approved); `load` action activates by name (requires user
|
||||
approval since it changes session behavior). Main session only.
|
||||
@@ -83,11 +83,15 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
precedence on name collision. MCP-synced content updates reset `is_default` to
|
||||
prevent compromised servers from injecting defaults. Admin UI shows origin badge
|
||||
and disables edit/delete for MCP-sourced skills.
|
||||
- **Spec fields**: Skills support the full Agent Skills standard frontmatter:
|
||||
`name`, `description`, `license`, `compatibility`, `metadata` (author, version),
|
||||
`allowed-tools`. The `license` and `compatibility` fields are preserved on import
|
||||
and editable in the admin UI. See https://agentskills.io/specification.
|
||||
- **Security scanning**: Skills are automatically scanned at creation and update
|
||||
time. The scanner evaluates four risk axes: content risk (command execution,
|
||||
data exfiltration), supply chain risk (pipe-to-shell, transitive installs),
|
||||
vulnerability risk (prompt injection, insecure credentials), and declared
|
||||
capability risk (from `allowed_tools`). Results populate the `scan_status`
|
||||
capability risk (from `allowed-tools` in SKILL.md). Results populate the `scan_status`
|
||||
(safe/low/medium/high/critical) and `scan_report` (JSON breakdown) columns.
|
||||
These fields are system-managed and cannot be overwritten via the admin API.
|
||||
- **Discovery**: External skills can be discovered and installed from registries:
|
||||
|
||||
+1
-1
@@ -299,7 +299,7 @@ four independent risk axes:
|
||||
obfuscation, download-execute chains, executable URLs from untrusted domains
|
||||
3. **Vulnerability risk** — prompt injection patterns, insecure credential
|
||||
handling, third-party content exposure (indirect prompt injection surface)
|
||||
4. **Declared capability risk** — parsed from the skill's `allowed_tools` field.
|
||||
4. **Declared capability risk** — parsed from `allowed-tools` in the skill's SKILL.md.
|
||||
`Bash(*)` (unrestricted shell) is high risk. `Bash(git:*)` is low.
|
||||
Read-only tools are safe.
|
||||
|
||||
|
||||
+2
-2
@@ -492,7 +492,7 @@ data.get("mergedAt") is not None
|
||||
|
||||
---
|
||||
|
||||
### load_skill
|
||||
### skill
|
||||
|
||||
Discover and activate skills at runtime during a conversation. The model can
|
||||
search for available skills and load one by name, replacing the current active
|
||||
@@ -543,7 +543,7 @@ pre-configure skills at workstream creation.
|
||||
| `watch` | Monitor | No (create) | No | No | `command` |
|
||||
| `read_resource`| MCP | No | Yes | Yes | `uri` |
|
||||
| `use_prompt` | MCP | No | Yes | Yes | `name` |
|
||||
| `load_skill` | Skills | No (load) | No | No | `name` |
|
||||
| `skill` | Skills | No (load) | No | No | `name` |
|
||||
| `tool_search`| Search | Yes | No | No | `query` |
|
||||
|
||||
---
|
||||
|
||||
@@ -187,6 +187,8 @@ export interface SkillInfo {
|
||||
notify_on_complete: string;
|
||||
enabled: boolean;
|
||||
allowed_tools: string;
|
||||
license: string;
|
||||
compatibility: string;
|
||||
resource_count: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
@@ -214,6 +216,8 @@ export interface CreateSkillRequest {
|
||||
notify_on_complete?: string;
|
||||
enabled?: boolean;
|
||||
allowed_tools?: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSkillRequest {
|
||||
@@ -237,6 +241,8 @@ export interface UpdateSkillRequest {
|
||||
notify_on_complete?: string;
|
||||
enabled?: boolean;
|
||||
allowed_tools?: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
}
|
||||
|
||||
export interface ListSkillsResponse {
|
||||
|
||||
+143
-46
@@ -1,4 +1,4 @@
|
||||
"""Tests for the load_skill built-in tool."""
|
||||
"""Tests for the skill built-in tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,25 +9,25 @@ from turnstone.core.tools import BUILTIN_TOOL_NAMES, PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
class TestToolRegistration:
|
||||
"""Verify load_skill is registered correctly."""
|
||||
"""Verify skill is registered correctly."""
|
||||
|
||||
def test_in_builtin_tool_names(self) -> None:
|
||||
assert "load_skill" in BUILTIN_TOOL_NAMES
|
||||
assert "skill" in BUILTIN_TOOL_NAMES
|
||||
|
||||
def test_not_agent_tool(self) -> None:
|
||||
from turnstone.core.tools import AGENT_TOOLS
|
||||
|
||||
names = {t["function"]["name"] for t in AGENT_TOOLS}
|
||||
assert "load_skill" not in names
|
||||
assert "skill" not in names
|
||||
|
||||
def test_not_task_agent_tool(self) -> None:
|
||||
from turnstone.core.tools import TASK_AGENT_TOOLS
|
||||
|
||||
names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
|
||||
assert "load_skill" not in names
|
||||
assert "skill" not in names
|
||||
|
||||
def test_has_primary_key(self) -> None:
|
||||
assert PRIMARY_KEY_MAP.get("load_skill") == "name"
|
||||
assert PRIMARY_KEY_MAP.get("skill") == "name"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -82,12 +82,12 @@ def _make_session(skills: list[dict[str, Any]] | None = None):
|
||||
|
||||
|
||||
class TestPrepareLoadSkill:
|
||||
"""Test _prepare_load_skill validation and item dict shape."""
|
||||
"""Test _prepare_skill validation and item dict shape."""
|
||||
|
||||
def test_load_valid(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
assert item["func_name"] == "load_skill"
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
assert item["func_name"] == "skill"
|
||||
assert item["action"] == "load"
|
||||
assert item["name"] == "code-review"
|
||||
assert item["needs_approval"] is True
|
||||
@@ -96,19 +96,19 @@ class TestPrepareLoadSkill:
|
||||
|
||||
def test_load_missing_name(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load"})
|
||||
item = session._prepare_skill("call-1", {"action": "load"})
|
||||
assert "error" in item
|
||||
assert "name" in item["error"].lower()
|
||||
assert item["needs_approval"] is False
|
||||
|
||||
def test_load_empty_name(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": ""})
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": ""})
|
||||
assert "error" in item
|
||||
|
||||
def test_search_with_query(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code review"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
|
||||
assert item["action"] == "search"
|
||||
assert item["query"] == "code review"
|
||||
assert item["needs_approval"] is False
|
||||
@@ -116,30 +116,30 @@ class TestPrepareLoadSkill:
|
||||
|
||||
def test_search_without_query(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search"})
|
||||
item = session._prepare_skill("call-1", {"action": "search"})
|
||||
assert item["action"] == "search"
|
||||
assert item["query"] == ""
|
||||
assert item["needs_approval"] is False
|
||||
|
||||
def test_invalid_action(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "delete"})
|
||||
item = session._prepare_skill("call-1", {"action": "delete"})
|
||||
assert "error" in item
|
||||
assert "delete" in item["error"]
|
||||
|
||||
def test_empty_action(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": ""})
|
||||
item = session._prepare_skill("call-1", {"action": ""})
|
||||
assert "error" in item
|
||||
|
||||
def test_header_for_load(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
assert "my-skill" in item["header"]
|
||||
|
||||
def test_header_for_search(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "testing"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "testing"})
|
||||
assert "testing" in item["header"]
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestPrepareLoadSkill:
|
||||
|
||||
|
||||
class TestExecLoadSkill:
|
||||
"""Test _exec_load_skill execution logic."""
|
||||
"""Test _exec_skill execution logic."""
|
||||
|
||||
def test_load_existing_skill(self) -> None:
|
||||
skills = [
|
||||
@@ -164,8 +164,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session(skills)
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert call_id == "call-1"
|
||||
assert "code-review" in result
|
||||
@@ -177,8 +177,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session([])
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "nope"})
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "nope"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "not found" in result.lower()
|
||||
assert session._set_skill_called == []
|
||||
@@ -188,8 +188,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session(skills)
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "test"})
|
||||
session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "test"})
|
||||
session._exec_skill(item)
|
||||
|
||||
session.ui.on_tool_result.assert_called_once()
|
||||
|
||||
@@ -216,10 +216,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "code"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "code-review" in result
|
||||
# docs-writer shouldn't match "code" query
|
||||
@@ -241,10 +241,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search"})
|
||||
item = session._prepare_skill("call-1", {"action": "search"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
# Should be limited to 10
|
||||
assert result.count("skill-") == 10
|
||||
@@ -254,10 +254,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = []
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "nonexistent"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "nonexistent"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "no skills found" in result.lower()
|
||||
|
||||
@@ -276,21 +276,21 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "risky"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "risky"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "high" in result
|
||||
|
||||
def test_search_storage_failure_returns_empty(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "test"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "test"})
|
||||
|
||||
with patch(
|
||||
"turnstone.core.storage._registry.get_storage", side_effect=RuntimeError("no storage")
|
||||
):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "no skills found" in result.lower()
|
||||
|
||||
@@ -307,10 +307,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session(skills)
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill(
|
||||
"call-1", {"action": "load", "name": "disabled-skill"}
|
||||
)
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "disabled-skill"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "not found" in result.lower()
|
||||
assert session._set_skill_called == []
|
||||
@@ -321,8 +319,8 @@ class TestExecLoadSkill:
|
||||
session._skill_name = "active"
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "active"})
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "active"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "already active" in result.lower()
|
||||
assert session._set_skill_called == []
|
||||
@@ -352,10 +350,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search"})
|
||||
item = session._prepare_skill("call-1", {"action": "search"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "enabled-skill" in result
|
||||
assert "disabled-skill" not in result
|
||||
@@ -375,14 +373,113 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code review"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "code-review" in result
|
||||
|
||||
def test_preparer_load_has_approval_label(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
assert item["approval_label"] == "load_skill__my-skill"
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
assert item["approval_label"] == "skill__my-skill"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Skill Catalog Disclosure (Agent Skills standard compliance)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillCatalogDisclosure:
|
||||
"""Verify <available-skills> catalog appears in system messages."""
|
||||
|
||||
def _build_session_with_system_messages(
|
||||
self,
|
||||
search_skills: list[dict[str, Any]] | None = None,
|
||||
) -> Any:
|
||||
"""Build a session and call _init_system_messages to get dev_parts."""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
session = ChatSession.__new__(ChatSession)
|
||||
ui = MagicMock()
|
||||
session.ui = ui
|
||||
session.model = "test-model"
|
||||
session._ws_id = "ws-test"
|
||||
session._node_id = "node-1"
|
||||
session._skill_name = None
|
||||
session._skill_content = None
|
||||
session._skill_resources = {}
|
||||
session._applied_skill_content = None
|
||||
session.context_window = 128000
|
||||
session.messages = []
|
||||
session._config = {}
|
||||
session.creative_mode = False
|
||||
session.instructions = ""
|
||||
session.system_messages = []
|
||||
session._agent_system_messages = []
|
||||
session.reasoning_effort = "medium"
|
||||
session._pending_nudge = []
|
||||
session._tool_search = None
|
||||
session._mcp_client = None
|
||||
session._notify_on_complete = "{}"
|
||||
|
||||
# Memory stubs
|
||||
session._memory_config = MagicMock()
|
||||
session._memory_config.fetch_limit = 0
|
||||
session._user_id = ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.session.list_skills_by_activation",
|
||||
return_value=search_skills or [],
|
||||
),
|
||||
patch.object(session, "_get_visible_memories", return_value=[]),
|
||||
):
|
||||
session._init_system_messages()
|
||||
|
||||
return session
|
||||
|
||||
def test_catalog_present_with_search_skills(self) -> None:
|
||||
skills = [
|
||||
{"name": "pdf-processing", "description": "Extract PDF text and forms."},
|
||||
{"name": "data-analysis", "description": "Analyze datasets."},
|
||||
]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "<available-skills>" in content
|
||||
assert "pdf-processing" in content
|
||||
assert "data-analysis" in content
|
||||
assert "</available-skills>" in content
|
||||
|
||||
def test_catalog_omitted_when_no_search_skills(self) -> None:
|
||||
session = self._build_session_with_system_messages(search_skills=[])
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "<available-skills>" not in content
|
||||
|
||||
def test_catalog_capped_at_30(self) -> None:
|
||||
skills = [{"name": f"skill-{i:03d}", "description": f"Desc {i}"} for i in range(50)]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
# Should include first 30, not all 50
|
||||
assert "skill-029" in content
|
||||
assert "skill-030" not in content
|
||||
|
||||
def test_catalog_escapes_html(self) -> None:
|
||||
skills = [
|
||||
{"name": "xss-test", "description": "Handle <script> & 'quotes'."},
|
||||
]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "<script>" in content
|
||||
assert "<script>" not in content.replace("<available-skills>", "").replace(
|
||||
"</available-skills>", ""
|
||||
).replace("<skill>", "").replace("</skill>", "").replace("<name>", "").replace(
|
||||
"</name>", ""
|
||||
).replace("<description>", "").replace("</description>", "")
|
||||
|
||||
def test_catalog_includes_hint(self) -> None:
|
||||
skills = [{"name": "test", "description": "Test skill."}]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "/skill" in content
|
||||
|
||||
+286
-6
@@ -18,7 +18,7 @@ description: Automated code review skill
|
||||
author: Test Author
|
||||
version: 2.0.0
|
||||
tags: [python, review, quality]
|
||||
allowed_tools: [read_file, list_directory]
|
||||
allowed-tools: [read_file, list_directory]
|
||||
license: MIT
|
||||
compatibility: ">=0.7"
|
||||
---
|
||||
@@ -187,13 +187,13 @@ Content.
|
||||
|
||||
|
||||
class TestAllowedTools:
|
||||
"""Verify allowed_tools parsing."""
|
||||
"""Verify allowed-tools parsing (Agent Skills standard hyphenated field)."""
|
||||
|
||||
def test_list_format(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: tools-list
|
||||
allowed_tools: [bash, read_file]
|
||||
allowed-tools: [bash, read_file]
|
||||
---
|
||||
|
||||
Content.
|
||||
@@ -201,11 +201,12 @@ Content.
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["bash", "read_file"]
|
||||
|
||||
def test_csv_format(self) -> None:
|
||||
def test_space_delimited_format(self) -> None:
|
||||
"""Standard format per Agent Skills spec."""
|
||||
raw = """\
|
||||
---
|
||||
name: tools-csv
|
||||
allowed_tools: "bash, read_file, write_file"
|
||||
name: tools-space
|
||||
allowed-tools: "bash read_file write_file"
|
||||
---
|
||||
|
||||
Content.
|
||||
@@ -219,6 +220,19 @@ Content.
|
||||
name: no-tools
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == []
|
||||
|
||||
def test_underscore_key_not_read(self) -> None:
|
||||
"""allowed_tools (underscore) is not a SKILL.md field — ignored by parser."""
|
||||
raw = """\
|
||||
---
|
||||
name: legacy-key
|
||||
allowed_tools: [bash, read_file]
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
@@ -247,3 +261,269 @@ class TestValidateSkillName:
|
||||
assert validate_skill_name("HAS-UPPER") is not None
|
||||
assert validate_skill_name("has space") is not None
|
||||
assert validate_skill_name("-leading-hyphen") is not None
|
||||
|
||||
def test_consecutive_hyphens_rejected(self) -> None:
|
||||
"""Agent Skills spec: consecutive hyphens not allowed."""
|
||||
assert validate_skill_name("foo--bar") is not None
|
||||
assert "consecutive hyphens" in (validate_skill_name("a--b") or "")
|
||||
# Single hyphens are fine
|
||||
assert validate_skill_name("foo-bar") is None
|
||||
|
||||
|
||||
# -- Agent Skills Standard Compliance Tests -----------------------------------
|
||||
|
||||
|
||||
class TestStandardAllowedTools:
|
||||
"""Agent Skills spec: 'allowed-tools' (hyphenated), space-delimited."""
|
||||
|
||||
def test_list_format(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: standard-tools
|
||||
allowed-tools: ["Bash(git:*)", "Bash(jq:*)", "Read"]
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["Bash(git:*)", "Bash(jq:*)", "Read"]
|
||||
|
||||
def test_space_delimited(self) -> None:
|
||||
"""Standard format: space-delimited string."""
|
||||
raw = """\
|
||||
---
|
||||
name: space-tools
|
||||
allowed-tools: "Bash(git:*) Bash(jq:*) Read"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["Bash(git:*)", "Bash(jq:*)", "Read"]
|
||||
|
||||
def test_mixed_space_comma_delimiters(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: mixed-delim
|
||||
allowed-tools: "Read, Write Bash"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["Read", "Write", "Bash"]
|
||||
|
||||
|
||||
class TestStandardMetadataNesting:
|
||||
"""Standard puts author/version under metadata map."""
|
||||
|
||||
def test_metadata_author(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: nested-author
|
||||
description: Test skill
|
||||
metadata:
|
||||
author: example-org
|
||||
version: "2.0"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.author == "example-org"
|
||||
assert result.version == "2.0"
|
||||
|
||||
def test_top_level_takes_precedence(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: precedence
|
||||
description: Test skill
|
||||
author: top-level
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
author: nested
|
||||
version: "2.0"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.author == "top-level"
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
def test_metadata_version_only(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: version-only
|
||||
description: Test
|
||||
metadata:
|
||||
version: "3.5.1"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.version == "3.5.1"
|
||||
assert result.author == ""
|
||||
|
||||
def test_null_author_uses_default(self) -> None:
|
||||
"""YAML null/bare key must not produce the string 'None'."""
|
||||
raw = """\
|
||||
---
|
||||
name: null-author
|
||||
description: Test
|
||||
author:
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.author == ""
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
def test_null_version_uses_default(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: null-version
|
||||
description: Test
|
||||
version:
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
|
||||
class TestStandardFieldLengths:
|
||||
"""Spec caps: description <= 1024, compatibility <= 500."""
|
||||
|
||||
def test_description_truncated_at_1024(self) -> None:
|
||||
long_desc = "x" * 1200
|
||||
raw = f"""\
|
||||
---
|
||||
name: long-desc
|
||||
description: "{long_desc}"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert len(result.description) == 1024
|
||||
|
||||
def test_compatibility_truncated_at_500(self) -> None:
|
||||
long_compat = "y" * 600
|
||||
raw = f"""\
|
||||
---
|
||||
name: long-compat
|
||||
description: Short
|
||||
compatibility: "{long_compat}"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert len(result.compatibility) == 500
|
||||
|
||||
def test_short_fields_unchanged(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: short
|
||||
description: Brief
|
||||
compatibility: Requires git
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.description == "Brief"
|
||||
assert result.compatibility == "Requires git"
|
||||
|
||||
|
||||
class TestLenientMode:
|
||||
"""Lenient parsing for cross-client skill ingestion."""
|
||||
|
||||
def test_invalid_name_sanitized(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: Invalid_Name!
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw, lenient=True)
|
||||
assert result is not None
|
||||
assert result.name == "invalidname"
|
||||
|
||||
def test_unsalvageable_name_returns_none(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: "!!!"
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
assert parse_skill_md(raw, lenient=True) is None
|
||||
|
||||
def test_missing_description_returns_none(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: no-desc
|
||||
---
|
||||
"""
|
||||
assert parse_skill_md(raw, lenient=True) is None
|
||||
|
||||
def test_broken_yaml_returns_none(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: [broken: yaml: {{{
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
assert parse_skill_md(raw, lenient=True) is None
|
||||
|
||||
def test_malformed_yaml_colon_in_description_recovers(self) -> None:
|
||||
"""Standard recommends retrying unquoted colon values."""
|
||||
raw = """\
|
||||
---
|
||||
name: colon-desc
|
||||
description: Use this skill when: the user asks about PDFs
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw, lenient=True)
|
||||
# The frontmatter library may parse this fine, but if not,
|
||||
# the retry mechanism should recover.
|
||||
assert result is not None
|
||||
assert result.name == "colon-desc"
|
||||
assert "PDF" in result.description
|
||||
|
||||
def test_strict_mode_still_raises(self) -> None:
|
||||
"""Default strict mode unchanged."""
|
||||
raw = """\
|
||||
---
|
||||
name: Invalid_Name!
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
parse_skill_md(raw)
|
||||
|
||||
def test_consecutive_hyphens_lenient(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: foo--bar
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw, lenient=True)
|
||||
assert result is not None
|
||||
assert "--" not in result.name
|
||||
|
||||
@@ -1156,6 +1156,49 @@ class TestSkillSessionConfigApplication:
|
||||
parsed_tools = json.loads(tpl["allowed_tools"])
|
||||
assert parsed_tools == ["bash", "read_file", "write_file"]
|
||||
|
||||
def test_license_compatibility_roundtrip(self, db):
|
||||
"""Agent Skills spec fields license and compatibility round-trip."""
|
||||
db.create_prompt_template(
|
||||
template_id="spec1",
|
||||
name="spec-fields-skill",
|
||||
category="general",
|
||||
content="Spec test.",
|
||||
skill_license="Apache-2.0",
|
||||
compatibility="Requires git, docker, jq",
|
||||
)
|
||||
tpl = db.get_skill_by_name("spec-fields-skill")
|
||||
assert tpl is not None
|
||||
assert tpl["license"] == "Apache-2.0"
|
||||
assert tpl["compatibility"] == "Requires git, docker, jq"
|
||||
|
||||
def test_license_compatibility_default_empty(self, db):
|
||||
"""license and compatibility default to empty string."""
|
||||
db.create_prompt_template(
|
||||
template_id="spec2",
|
||||
name="no-spec-fields",
|
||||
category="general",
|
||||
content="No spec fields.",
|
||||
)
|
||||
tpl = db.get_skill_by_name("no-spec-fields")
|
||||
assert tpl is not None
|
||||
assert tpl["license"] == ""
|
||||
assert tpl["compatibility"] == ""
|
||||
|
||||
def test_update_license_compatibility(self, db):
|
||||
"""license and compatibility can be updated."""
|
||||
db.create_prompt_template(
|
||||
template_id="spec3",
|
||||
name="updatable-spec",
|
||||
category="general",
|
||||
content="Test.",
|
||||
)
|
||||
db.update_prompt_template("spec3", license="MIT")
|
||||
db.update_prompt_template("spec3", compatibility="Python 3.11+")
|
||||
tpl = db.get_prompt_template("spec3")
|
||||
assert tpl is not None
|
||||
assert tpl["license"] == "MIT"
|
||||
assert tpl["compatibility"] == "Python 3.11+"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Migration behavior tests
|
||||
|
||||
@@ -112,7 +112,7 @@ class TestToolsMetadata:
|
||||
"watch": "command",
|
||||
"read_resource": "uri",
|
||||
"use_prompt": "name",
|
||||
"load_skill": "name",
|
||||
"skill": "name",
|
||||
}
|
||||
assert expected == PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
@@ -307,6 +307,8 @@ class SkillInfo(BaseModel):
|
||||
notify_on_complete: str = "{}"
|
||||
enabled: bool = True
|
||||
allowed_tools: str = "[]"
|
||||
license: str = ""
|
||||
compatibility: str = ""
|
||||
scan_status: str = ""
|
||||
scan_report: str = "{}"
|
||||
scan_version: str = ""
|
||||
@@ -337,6 +339,8 @@ class CreateSkillRequest(BaseModel):
|
||||
notify_on_complete: str = "{}"
|
||||
enabled: bool = True
|
||||
allowed_tools: str = "[]"
|
||||
license: str = ""
|
||||
compatibility: str = ""
|
||||
|
||||
|
||||
class UpdateSkillRequest(BaseModel):
|
||||
@@ -360,6 +364,8 @@ class UpdateSkillRequest(BaseModel):
|
||||
notify_on_complete: str | None = None
|
||||
enabled: bool | None = None
|
||||
allowed_tools: str | None = None
|
||||
license: str | None = None
|
||||
compatibility: str | None = None
|
||||
|
||||
|
||||
class ListSkillsResponse(BaseModel):
|
||||
|
||||
@@ -2286,6 +2286,8 @@ def _skill_to_response(r: dict[str, Any], resource_count: int = 0) -> dict[str,
|
||||
"notify_on_complete": r.get("notify_on_complete", "{}"),
|
||||
"enabled": r.get("enabled", True),
|
||||
"allowed_tools": r.get("allowed_tools", "[]"),
|
||||
"license": r.get("license", ""),
|
||||
"compatibility": r.get("compatibility", ""),
|
||||
"scan_status": r.get("scan_status", ""),
|
||||
"scan_report": r.get("scan_report", "{}"),
|
||||
"scan_version": r.get("scan_version", ""),
|
||||
@@ -2370,6 +2372,8 @@ async def admin_create_skill(request: Request) -> JSONResponse:
|
||||
org_id = str(body.get("org_id", "")).strip()[:64]
|
||||
author = str(body.get("author", "")).strip()[:256]
|
||||
version = str(body.get("version", "1.0.0")).strip()[:64]
|
||||
license_val = str(body.get("license", "")).strip()
|
||||
compatibility = str(body.get("compatibility", "")).strip()[:500]
|
||||
|
||||
raw_tags = body.get("tags", [])
|
||||
if isinstance(raw_tags, list):
|
||||
@@ -2418,6 +2422,8 @@ async def admin_create_skill(request: Request) -> JSONResponse:
|
||||
tags=tags_str,
|
||||
version=version,
|
||||
author=author,
|
||||
skill_license=license_val,
|
||||
compatibility=compatibility,
|
||||
activation=activation,
|
||||
token_estimate=token_estimate,
|
||||
**session_fields,
|
||||
@@ -2497,6 +2503,10 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
updates["author"] = str(body["author"]).strip()[:256]
|
||||
if "version" in body:
|
||||
updates["version"] = str(body["version"]).strip()[:64]
|
||||
if "license" in body:
|
||||
updates["license"] = str(body["license"]).strip()
|
||||
if "compatibility" in body:
|
||||
updates["compatibility"] = str(body["compatibility"]).strip()[:500]
|
||||
if "tags" in body:
|
||||
raw_tags = body["tags"]
|
||||
if isinstance(raw_tags, list):
|
||||
@@ -3203,6 +3213,8 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
source_url=pkg_source_url,
|
||||
version=parsed.version,
|
||||
author=parsed.author,
|
||||
skill_license=parsed.license,
|
||||
compatibility=parsed.compatibility,
|
||||
activation="named",
|
||||
token_estimate=token_estimate,
|
||||
allowed_tools=allowed_tools_str,
|
||||
|
||||
@@ -768,7 +768,7 @@ function _renderGovSkills(items) {
|
||||
t.resource_count +
|
||||
" res</span>";
|
||||
}
|
||||
var editDisabled = t.readonly ? " disabled" : "";
|
||||
var editLabel = t.readonly ? "view" : "edit";
|
||||
var deleteDisabled = "";
|
||||
html +=
|
||||
'<div class="admin-row" role="listitem">' +
|
||||
@@ -794,9 +794,9 @@ function _renderGovSkills(items) {
|
||||
'<span class="admin-col admin-col-actions">' +
|
||||
'<button class="admin-btn-action" data-edit-tmpl="' +
|
||||
escapeHtml(t.template_id) +
|
||||
'"' +
|
||||
editDisabled +
|
||||
">edit</button>" +
|
||||
'">' +
|
||||
editLabel +
|
||||
"</button>" +
|
||||
'<button class="admin-btn-danger" data-delete-tmpl="' +
|
||||
escapeHtml(t.template_id) +
|
||||
'" data-tmpl-name="' +
|
||||
@@ -870,6 +870,9 @@ function showCreateTemplateModal() {
|
||||
document.getElementById("skill-description").value = "";
|
||||
document.getElementById("skill-tags").value = "";
|
||||
document.getElementById("skill-author").value = "";
|
||||
document.getElementById("skill-version").value = "";
|
||||
document.getElementById("skill-license").value = "";
|
||||
document.getElementById("skill-compatibility").value = "";
|
||||
document.getElementById("skill-activation").value = "named";
|
||||
document.getElementById("ctm-content").value = "";
|
||||
document.getElementById("ctm-variables").textContent = "(none)";
|
||||
@@ -888,11 +891,9 @@ function showCreateTemplateModal() {
|
||||
document.getElementById("csk-allowed-tools").value = "";
|
||||
document.getElementById("csk-allowed-tools").disabled = false;
|
||||
document.getElementById("csk-enabled").checked = true;
|
||||
document
|
||||
.getElementById("csk-auto-approve")
|
||||
.addEventListener("change", function () {
|
||||
document.getElementById("csk-allowed-tools").disabled = this.checked;
|
||||
});
|
||||
document.getElementById("csk-auto-approve").onchange = function () {
|
||||
document.getElementById("csk-allowed-tools").disabled = this.checked;
|
||||
};
|
||||
document.getElementById("create-template-error").style.display = "none";
|
||||
// Clear resource list
|
||||
_pendingResources = [];
|
||||
@@ -960,6 +961,11 @@ function submitCreateTemplate() {
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("skill-author").value || "").trim(),
|
||||
version: (document.getElementById("skill-version").value || "").trim(),
|
||||
license: (document.getElementById("skill-license").value || "").trim(),
|
||||
compatibility: (
|
||||
document.getElementById("skill-compatibility").value || ""
|
||||
).trim(),
|
||||
activation: document.getElementById("skill-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
@@ -1052,6 +1058,9 @@ function showEditTemplateModal(tmplId) {
|
||||
}
|
||||
document.getElementById("etm-tags").value = tagsDisplay;
|
||||
document.getElementById("etm-author").value = tmpl.author || "";
|
||||
document.getElementById("etm-version").value = tmpl.version || "";
|
||||
document.getElementById("etm-license").value = tmpl.license || "";
|
||||
document.getElementById("etm-compatibility").value = tmpl.compatibility || "";
|
||||
document.getElementById("etm-activation").value = tmpl.activation || "named";
|
||||
document.getElementById("etm-content").value = tmpl.content;
|
||||
_updateVarsDisplay("etm-content", "etm-variables");
|
||||
@@ -1086,11 +1095,9 @@ function showEditTemplateModal(tmplId) {
|
||||
document.getElementById("esk-allowed-tools").disabled =
|
||||
tmpl.auto_approve || false;
|
||||
document.getElementById("esk-enabled").checked = tmpl.enabled !== false;
|
||||
document
|
||||
.getElementById("esk-auto-approve")
|
||||
.addEventListener("change", function () {
|
||||
document.getElementById("esk-allowed-tools").disabled = this.checked;
|
||||
});
|
||||
document.getElementById("esk-auto-approve").onchange = function () {
|
||||
document.getElementById("esk-allowed-tools").disabled = this.checked;
|
||||
};
|
||||
document.getElementById("edit-template-error").style.display = "none";
|
||||
// Scan report section
|
||||
var scanSection = document.getElementById("etm-scan-section");
|
||||
@@ -1180,12 +1187,59 @@ function showEditTemplateModal(tmplId) {
|
||||
});
|
||||
};
|
||||
}
|
||||
// --- Readonly mode for imported skills ---
|
||||
var isReadonly = tmpl.readonly || false;
|
||||
var editTitle = document.getElementById("edit-template-title");
|
||||
if (editTitle)
|
||||
editTitle.textContent = isReadonly ? "View Skill" : "Edit Skill";
|
||||
var submitBtn = document.getElementById("etm-submit");
|
||||
if (submitBtn) submitBtn.style.display = isReadonly ? "none" : "";
|
||||
[
|
||||
"etm-name",
|
||||
"etm-category",
|
||||
"etm-description",
|
||||
"etm-tags",
|
||||
"etm-author",
|
||||
"etm-version",
|
||||
"etm-license",
|
||||
"etm-compatibility",
|
||||
"etm-activation",
|
||||
"etm-content",
|
||||
"etm-default",
|
||||
"esk-model",
|
||||
"esk-temperature",
|
||||
"esk-reasoning-effort",
|
||||
"esk-max-tokens",
|
||||
"esk-token-budget",
|
||||
"esk-agent-max-turns",
|
||||
"esk-auto-approve",
|
||||
"esk-allowed-tools",
|
||||
"esk-enabled",
|
||||
].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.disabled = isReadonly;
|
||||
});
|
||||
var cancelBtn = document.querySelector("#edit-template-box .modal-cancel");
|
||||
if (cancelBtn) cancelBtn.textContent = isReadonly ? "Close" : "Cancel";
|
||||
// Auto-expand collapsibles in view mode
|
||||
if (isReadonly) {
|
||||
var details = document.querySelectorAll(
|
||||
"#edit-template-box .admin-details",
|
||||
);
|
||||
for (var d = 0; d < details.length; d++) details[d].open = true;
|
||||
}
|
||||
// --- Skill Resources ---
|
||||
var resSection = document.getElementById("etm-resources-section");
|
||||
if (resSection) {
|
||||
_loadSkillResources(tmplId, tmpl.readonly || false);
|
||||
_loadSkillResources(tmplId, isReadonly);
|
||||
}
|
||||
_etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box");
|
||||
// Focus management
|
||||
if (isReadonly) {
|
||||
if (cancelBtn) cancelBtn.focus();
|
||||
} else {
|
||||
document.getElementById("etm-name").focus();
|
||||
}
|
||||
}
|
||||
|
||||
function hideEditTemplateModal() {
|
||||
@@ -1477,6 +1531,11 @@ function submitEditTemplate() {
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("etm-author").value || "").trim(),
|
||||
version: (document.getElementById("etm-version").value || "").trim(),
|
||||
license: (document.getElementById("etm-license").value || "").trim(),
|
||||
compatibility: (
|
||||
document.getElementById("etm-compatibility").value || ""
|
||||
).trim(),
|
||||
activation: document.getElementById("etm-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
|
||||
@@ -866,6 +866,12 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<input id="skill-tags" type="text" placeholder="Comma-separated tags">
|
||||
<label for="skill-author">Author</label>
|
||||
<input id="skill-author" type="text" placeholder="Author name">
|
||||
<label for="skill-version">Version</label>
|
||||
<input id="skill-version" type="text" placeholder="1.0.0">
|
||||
<label for="skill-license">License</label>
|
||||
<input id="skill-license" type="text" placeholder="MIT, Apache-2.0, etc.">
|
||||
<label for="skill-compatibility">Compatibility <span class="label-hint">environment requirements, max 500 chars</span></label>
|
||||
<input id="skill-compatibility" type="text" placeholder="Requires git, docker, etc." maxlength="500">
|
||||
<label for="skill-activation">Activation</label>
|
||||
<select id="skill-activation">
|
||||
<option value="named">Named</option>
|
||||
@@ -942,6 +948,12 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<input id="etm-tags" type="text" placeholder="Comma-separated tags">
|
||||
<label for="etm-author">Author</label>
|
||||
<input id="etm-author" type="text" placeholder="Author name">
|
||||
<label for="etm-version">Version</label>
|
||||
<input id="etm-version" type="text" placeholder="1.0.0">
|
||||
<label for="etm-license">License</label>
|
||||
<input id="etm-license" type="text" placeholder="MIT, Apache-2.0, etc.">
|
||||
<label for="etm-compatibility">Compatibility <span class="label-hint">environment requirements, max 500 chars</span></label>
|
||||
<input id="etm-compatibility" type="text" placeholder="Requires git, docker, etc." maxlength="500">
|
||||
<label for="etm-activation">Activation</label>
|
||||
<select id="etm-activation">
|
||||
<option value="named">Named</option>
|
||||
|
||||
@@ -1166,6 +1166,14 @@
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
.admin-modal input:disabled, .admin-modal select:disabled, .admin-modal textarea:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
background: var(--bg-highlight);
|
||||
border-color: var(--border);
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.admin-modal label.admin-checkbox input:disabled { opacity: 0.4; }
|
||||
.admin-modal input::placeholder, .admin-modal textarea::placeholder { color: var(--fg-dim); opacity: 0.6; }
|
||||
.admin-modal textarea { resize: vertical; min-height: 40px; }
|
||||
.admin-modal [role="alert"] { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
|
||||
|
||||
+47
-23
@@ -39,6 +39,7 @@ from turnstone.core.memory import (
|
||||
get_skill_by_name,
|
||||
get_workstream_display_name,
|
||||
list_default_skills,
|
||||
list_skills_by_activation,
|
||||
list_structured_memories,
|
||||
list_workstreams_with_history,
|
||||
load_messages,
|
||||
@@ -856,6 +857,29 @@ class ChatSession:
|
||||
)
|
||||
lines.append("</skill-resources>")
|
||||
dev_parts.append("\n".join(lines))
|
||||
# Skill catalog: disclose search-activated skills so the model
|
||||
# knows they exist (Agent Skills standard progressive disclosure).
|
||||
try:
|
||||
search_skills = [
|
||||
s for s in list_skills_by_activation("search") if s.get("enabled", True)
|
||||
]
|
||||
except Exception:
|
||||
search_skills = []
|
||||
if search_skills:
|
||||
catalog_lines = ["<available-skills>"]
|
||||
for sk in search_skills[:30]:
|
||||
sk_name = _html_escape(sk.get("name", ""))
|
||||
sk_desc = _html_escape(sk.get("description", "")[:200])
|
||||
catalog_lines.append(
|
||||
f" <skill><name>{sk_name}</name><description>{sk_desc}</description></skill>"
|
||||
)
|
||||
catalog_lines.append("</available-skills>")
|
||||
catalog_lines.append(
|
||||
"Additional skills are available. When a task matches a skill "
|
||||
"description, ask the user to activate it with `/skill <name>`, "
|
||||
"or use `/skill search <query>` to find relevant skills."
|
||||
)
|
||||
dev_parts.append("\n".join(catalog_lines))
|
||||
if self.instructions:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(self.instructions)
|
||||
@@ -1936,7 +1960,7 @@ class ChatSession:
|
||||
it["func_args"] = {"url": it.get("url", ""), "question": it.get("question", "")}
|
||||
elif name == "web_search":
|
||||
it["func_args"] = {"query": it.get("query", ""), "topic": it.get("topic", "")}
|
||||
elif name == "load_skill":
|
||||
elif name == "skill":
|
||||
it["func_args"] = {"action": it.get("action", ""), "name": it.get("name", "")}
|
||||
elif name == "watch":
|
||||
it["func_args"] = {
|
||||
@@ -2203,7 +2227,7 @@ class ChatSession:
|
||||
"watch": self._prepare_watch,
|
||||
"read_resource": self._prepare_read_resource,
|
||||
"use_prompt": self._prepare_use_prompt,
|
||||
"load_skill": self._prepare_load_skill,
|
||||
"skill": self._prepare_skill,
|
||||
}
|
||||
preparer = preparers.get(func_name)
|
||||
if not preparer:
|
||||
@@ -3027,10 +3051,10 @@ class ChatSession:
|
||||
"limit": max(1, min(limit, 50)),
|
||||
}
|
||||
|
||||
# -- load_skill prepare/execute --------------------------------------------
|
||||
# -- skill prepare/execute -------------------------------------------------
|
||||
|
||||
def _prepare_load_skill(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare a load_skill action (load or search)."""
|
||||
def _prepare_skill(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare a skill action (load or search)."""
|
||||
action = (args.get("action") or "").strip().lower()
|
||||
|
||||
if action == "load":
|
||||
@@ -3038,20 +3062,20 @@ class ChatSession:
|
||||
if not name:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "load_skill",
|
||||
"header": "\u2717 load_skill: name is required",
|
||||
"func_name": "skill",
|
||||
"header": "\u2717 skill: name is required",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: 'name' is required for load action",
|
||||
}
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "load_skill",
|
||||
"header": f"\u2699 load_skill: {name}",
|
||||
"func_name": "skill",
|
||||
"header": f"\u2699 skill: {name}",
|
||||
"preview": "",
|
||||
"needs_approval": True,
|
||||
"approval_label": f"load_skill__{name}",
|
||||
"execute": self._exec_load_skill,
|
||||
"approval_label": f"skill__{name}",
|
||||
"execute": self._exec_skill,
|
||||
"action": "load",
|
||||
"name": name,
|
||||
}
|
||||
@@ -3060,26 +3084,26 @@ class ChatSession:
|
||||
query = (args.get("query") or "").strip()
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "load_skill",
|
||||
"func_name": "skill",
|
||||
"header": f"\u2699 skill search{': ' + query[:80] if query else ''}",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"execute": self._exec_load_skill,
|
||||
"execute": self._exec_skill,
|
||||
"action": "search",
|
||||
"query": query,
|
||||
}
|
||||
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "load_skill",
|
||||
"header": "\u2717 load_skill: invalid action",
|
||||
"func_name": "skill",
|
||||
"header": "\u2717 skill: invalid action",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": f"Error: action must be 'load' or 'search', got '{action}'",
|
||||
}
|
||||
|
||||
def _exec_load_skill(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Execute a load_skill action."""
|
||||
def _exec_skill(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Execute a skill action."""
|
||||
call_id = item["call_id"]
|
||||
action = item["action"]
|
||||
|
||||
@@ -3088,12 +3112,12 @@ class ChatSession:
|
||||
skill_data = get_skill_by_name(name)
|
||||
if not skill_data or not skill_data.get("enabled", True):
|
||||
msg = f"Error: skill '{name}' not found"
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
if self._skill_name == name:
|
||||
msg = f"Skill '{name}' is already active"
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
self.set_skill(name)
|
||||
@@ -3106,7 +3130,7 @@ class ChatSession:
|
||||
if scan:
|
||||
parts.append(f"Security tier: {scan}")
|
||||
msg = "\n".join(parts)
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
# action == "search"
|
||||
@@ -3116,7 +3140,7 @@ class ChatSession:
|
||||
|
||||
rows = get_storage().list_prompt_templates(limit=50)
|
||||
except Exception:
|
||||
log.warning("load_skill.search_storage_error", exc_info=True)
|
||||
log.warning("skill.search_storage_error", exc_info=True)
|
||||
rows = []
|
||||
|
||||
# Filter out disabled skills
|
||||
@@ -3160,7 +3184,7 @@ class ChatSession:
|
||||
|
||||
if not rows:
|
||||
msg = "No skills found" + (f" matching '{query}'" if query else "")
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
lines = [f"Found {len(rows)} skill(s):", ""]
|
||||
@@ -3182,7 +3206,7 @@ class ChatSession:
|
||||
lines.append(line)
|
||||
|
||||
msg = "\n".join(lines)
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
# -- MCP tool prepare/execute ----------------------------------------------
|
||||
|
||||
+144
-21
@@ -2,19 +2,40 @@
|
||||
|
||||
Pure functions, no I/O. Accepts raw SKILL.md text and returns a
|
||||
:class:`ParsedSkill` dataclass.
|
||||
|
||||
Compliant with the Agent Skills specification (https://agentskills.io/specification).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
import frontmatter
|
||||
|
||||
# Name validation: lowercase letters, digits, hyphens, max 64 chars
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Name validation: lowercase letters, digits, hyphens, max 64 chars.
|
||||
# Note: consecutive hyphens checked separately (not expressible in a
|
||||
# single character-class regex without a lookahead).
|
||||
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,62}[a-z0-9]$|^[a-z0-9]$")
|
||||
|
||||
# Split allowed-tools on whitespace or commas (standard uses spaces,
|
||||
# legacy turnstone format uses commas). Tool expressions must not
|
||||
# contain internal whitespace (e.g. "Bash(git:*)" not "Bash(git: *)").
|
||||
_LIST_SPLIT_RE = re.compile(r"[\s,]+")
|
||||
|
||||
# Malformed YAML recovery: match a bare ``description:`` line whose
|
||||
# value contains an unquoted colon (the most common cross-client issue).
|
||||
_BARE_DESC_RE = re.compile(r"^(description:\s*)(.+)$", re.MULTILINE)
|
||||
|
||||
# Field length caps from the Agent Skills specification.
|
||||
_MAX_DESCRIPTION_LEN = 1024
|
||||
_MAX_COMPATIBILITY_LEN = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedSkill:
|
||||
@@ -55,13 +76,39 @@ def _extract_tags(meta: dict[str, Any]) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _extract_list(meta: dict[str, Any], key: str) -> list[str]:
|
||||
"""Extract a list of strings from frontmatter, with fallback."""
|
||||
val = meta.get(key)
|
||||
if isinstance(val, list):
|
||||
return [str(v) for v in val if v]
|
||||
if isinstance(val, str) and val:
|
||||
return [v.strip() for v in val.split(",") if v.strip()]
|
||||
def _extract_str(meta: dict[str, Any], key: str, default: str = "") -> str:
|
||||
"""Extract a string field, checking top-level then ``metadata.*`` fallback.
|
||||
|
||||
Handles YAML ``null`` / bare keys gracefully (returns *default*
|
||||
rather than the string ``"None"``).
|
||||
"""
|
||||
raw = meta.get(key)
|
||||
val = str(raw).strip() if raw is not None else ""
|
||||
if val:
|
||||
return val
|
||||
# Standard puts author/version under metadata map
|
||||
nested = meta.get("metadata")
|
||||
if isinstance(nested, dict):
|
||||
raw = nested.get(key)
|
||||
val = str(raw).strip() if raw is not None else ""
|
||||
if val:
|
||||
return val
|
||||
return default
|
||||
|
||||
|
||||
def _extract_list(meta: dict[str, Any], *keys: str) -> list[str]:
|
||||
"""Extract a list of strings from frontmatter.
|
||||
|
||||
Tries each *key* in order (first match wins). String values are
|
||||
split on whitespace or commas to handle both the Agent Skills
|
||||
standard (space-delimited) and legacy comma-delimited formats.
|
||||
"""
|
||||
for key in keys:
|
||||
val = meta.get(key)
|
||||
if isinstance(val, list):
|
||||
return [str(v) for v in val if v]
|
||||
if isinstance(val, str) and val:
|
||||
return [v for v in _LIST_SPLIT_RE.split(val) if v]
|
||||
return []
|
||||
|
||||
|
||||
@@ -71,22 +118,66 @@ def validate_skill_name(name: str) -> str | None:
|
||||
return "name is required"
|
||||
if len(name) > 64:
|
||||
return f"name exceeds 64 characters ({len(name)})"
|
||||
if "--" in name:
|
||||
return "name must not contain consecutive hyphens"
|
||||
if not _NAME_RE.match(name):
|
||||
return "name must be lowercase alphanumeric with hyphens (e.g. 'code-review')"
|
||||
return None
|
||||
|
||||
|
||||
def parse_skill_md(raw: str) -> ParsedSkill:
|
||||
"""Parse SKILL.md (YAML frontmatter + markdown body).
|
||||
def _try_parse_frontmatter(raw: str) -> frontmatter.Post:
|
||||
"""Parse YAML frontmatter with a single malformed-YAML retry.
|
||||
|
||||
Handles missing or malformed frontmatter gracefully — returns a
|
||||
``ParsedSkill`` with defaults for any missing fields.
|
||||
|
||||
Raises ``ValueError`` if ``name`` is missing or invalid.
|
||||
The most common cross-client issue is unquoted description values
|
||||
containing colons (e.g. ``description: Use when: the user asks``).
|
||||
On initial failure, wrap the description value in quotes and retry.
|
||||
"""
|
||||
try:
|
||||
post = frontmatter.loads(raw)
|
||||
return frontmatter.loads(raw)
|
||||
except Exception:
|
||||
pass # fall through to retry
|
||||
|
||||
# Retry: quote the description line
|
||||
def _quote_desc(m: re.Match[str]) -> str:
|
||||
prefix = m.group(1)
|
||||
value = m.group(2).strip()
|
||||
escaped = value.replace('"', '\\"')
|
||||
return f'{prefix}"{escaped}"'
|
||||
|
||||
fixed = _BARE_DESC_RE.sub(_quote_desc, raw)
|
||||
if fixed != raw:
|
||||
try:
|
||||
return frontmatter.loads(fixed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ValueError("Failed to parse SKILL.md YAML frontmatter")
|
||||
|
||||
|
||||
@overload
|
||||
def parse_skill_md(raw: str, *, lenient: Literal[False] = ...) -> ParsedSkill: ...
|
||||
|
||||
|
||||
@overload
|
||||
def parse_skill_md(raw: str, *, lenient: Literal[True]) -> ParsedSkill | None: ...
|
||||
|
||||
|
||||
def parse_skill_md(raw: str, *, lenient: bool = False) -> ParsedSkill | None:
|
||||
"""Parse SKILL.md (YAML frontmatter + markdown body).
|
||||
|
||||
When *lenient* is ``False`` (default — strict mode), raises
|
||||
``ValueError`` on missing/invalid name or unparseable YAML.
|
||||
|
||||
When *lenient* is ``True`` (for external import / cross-client
|
||||
ingestion), logs warnings and returns ``None`` for unskippable
|
||||
failures instead of raising.
|
||||
"""
|
||||
try:
|
||||
post = _try_parse_frontmatter(raw)
|
||||
except Exception as exc:
|
||||
if lenient:
|
||||
log.warning("skill_parser.yaml_failed", error=str(exc))
|
||||
return None
|
||||
raise ValueError(f"Failed to parse SKILL.md frontmatter: {exc}") from exc
|
||||
|
||||
meta: dict[str, Any] = dict(post.metadata)
|
||||
@@ -96,7 +187,16 @@ def parse_skill_md(raw: str) -> ParsedSkill:
|
||||
name = str(meta.get("name", "")).strip().lower()
|
||||
name_err = validate_skill_name(name)
|
||||
if name_err:
|
||||
raise ValueError(name_err)
|
||||
if lenient:
|
||||
log.warning("skill_parser.name_invalid", name=name, error=name_err)
|
||||
# Try to salvage: strip invalid chars, truncate
|
||||
sanitized = re.sub(r"[^a-z0-9-]", "", name).strip("-")
|
||||
sanitized = re.sub(r"-{2,}", "-", sanitized)[:64].strip("-")
|
||||
if not sanitized or validate_skill_name(sanitized):
|
||||
return None
|
||||
name = sanitized
|
||||
else:
|
||||
raise ValueError(name_err)
|
||||
|
||||
# Description — frontmatter or first paragraph of body
|
||||
description = str(meta.get("description", "")).strip()
|
||||
@@ -107,15 +207,38 @@ def parse_skill_md(raw: str) -> ParsedSkill:
|
||||
first_line = first_line.lstrip("# ").strip()
|
||||
description = first_line[:256]
|
||||
|
||||
if not description and lenient:
|
||||
log.warning("skill_parser.no_description", name=name)
|
||||
return None
|
||||
|
||||
# Spec caps
|
||||
if len(description) > _MAX_DESCRIPTION_LEN:
|
||||
log.warning(
|
||||
"skill_parser.description_truncated",
|
||||
name=name,
|
||||
length=len(description),
|
||||
)
|
||||
description = description[:_MAX_DESCRIPTION_LEN]
|
||||
|
||||
compatibility = str(meta.get("compatibility", "")).strip()
|
||||
if len(compatibility) > _MAX_COMPATIBILITY_LEN:
|
||||
log.warning(
|
||||
"skill_parser.compatibility_truncated",
|
||||
name=name,
|
||||
length=len(compatibility),
|
||||
)
|
||||
compatibility = compatibility[:_MAX_COMPATIBILITY_LEN]
|
||||
|
||||
return ParsedSkill(
|
||||
name=name,
|
||||
description=description,
|
||||
content=body,
|
||||
tags=_extract_tags(meta),
|
||||
author=str(meta.get("author", "")).strip(),
|
||||
version=str(meta.get("version", "1.0.0")).strip(),
|
||||
allowed_tools=_extract_list(meta, "allowed_tools"),
|
||||
author=_extract_str(meta, "author"),
|
||||
version=_extract_str(meta, "version", default="1.0.0"),
|
||||
# Standard uses "allowed-tools" (hyphenated); stored internally as allowed_tools
|
||||
allowed_tools=_extract_list(meta, "allowed-tools"),
|
||||
license=str(meta.get("license", "")).strip(),
|
||||
compatibility=str(meta.get("compatibility", "")).strip(),
|
||||
compatibility=compatibility,
|
||||
raw_frontmatter=meta,
|
||||
)
|
||||
|
||||
@@ -1508,6 +1508,8 @@ class PostgreSQLBackend:
|
||||
notify_on_complete: str = "{}",
|
||||
enabled: bool = True,
|
||||
allowed_tools: str = "[]",
|
||||
skill_license: str = "",
|
||||
compatibility: str = "",
|
||||
) -> None:
|
||||
# Sync is_default from activation when activation is explicitly set
|
||||
if activation == "default":
|
||||
@@ -1540,6 +1542,8 @@ class PostgreSQLBackend:
|
||||
"activation": activation,
|
||||
"token_estimate": token_estimate,
|
||||
"allowed_tools": allowed_tools,
|
||||
"license": skill_license,
|
||||
"compatibility": compatibility,
|
||||
"scan_status": scan_status,
|
||||
"scan_report": scan_report,
|
||||
"scan_version": scan_version,
|
||||
|
||||
@@ -579,6 +579,8 @@ class StorageBackend(Protocol):
|
||||
notify_on_complete: str = "{}",
|
||||
enabled: bool = True,
|
||||
allowed_tools: str = "[]",
|
||||
skill_license: str = "",
|
||||
compatibility: str = "",
|
||||
) -> None:
|
||||
"""Create a prompt template (skill)."""
|
||||
...
|
||||
|
||||
@@ -311,6 +311,8 @@ prompt_templates = sa.Table(
|
||||
sa.Column("activation", sa.Text, nullable=False, server_default="named"),
|
||||
sa.Column("token_estimate", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("allowed_tools", sa.Text, nullable=False, server_default="[]"), # JSON array
|
||||
sa.Column("license", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("compatibility", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("scan_status", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("scan_report", sa.Text, nullable=False, server_default="{}"), # JSON
|
||||
sa.Column("installed_at", sa.Text, nullable=False, server_default=""),
|
||||
|
||||
@@ -1532,6 +1532,8 @@ class SQLiteBackend:
|
||||
notify_on_complete: str = "{}",
|
||||
enabled: bool = True,
|
||||
allowed_tools: str = "[]",
|
||||
skill_license: str = "",
|
||||
compatibility: str = "",
|
||||
) -> None:
|
||||
# Sync is_default from activation when activation is explicitly set
|
||||
if activation == "default":
|
||||
@@ -1564,6 +1566,8 @@ class SQLiteBackend:
|
||||
"activation": activation,
|
||||
"token_estimate": token_estimate,
|
||||
"allowed_tools": allowed_tools,
|
||||
"license": skill_license,
|
||||
"compatibility": compatibility,
|
||||
"scan_status": scan_status,
|
||||
"scan_report": scan_report,
|
||||
"scan_version": scan_version,
|
||||
|
||||
@@ -54,6 +54,8 @@ SKILL_MUTABLE = frozenset(
|
||||
"notify_on_complete",
|
||||
"enabled",
|
||||
"allowed_tools",
|
||||
"license",
|
||||
"compatibility",
|
||||
"scan_version",
|
||||
"scan_status",
|
||||
"scan_report",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add license and compatibility columns to prompt_templates.
|
||||
|
||||
Agent Skills standard (agentskills.io) defines license and compatibility
|
||||
as optional SKILL.md frontmatter fields. These were parsed but discarded
|
||||
prior to this migration.
|
||||
|
||||
Revision ID: 023
|
||||
Revises: 022
|
||||
Create Date: 2026-03-17
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "023"
|
||||
down_revision = "022"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"prompt_templates",
|
||||
sa.Column("license", sa.Text, nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"prompt_templates",
|
||||
sa.Column("compatibility", sa.Text, nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("prompt_templates", "compatibility")
|
||||
op.drop_column("prompt_templates", "license")
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "load_skill",
|
||||
"name": "skill",
|
||||
"description": "Load or search for skills. Actions: 'load' activates a skill by name (replaces current skill), 'search' finds available skills by query.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
Reference in New Issue
Block a user