Compare commits

...

13 Commits

Author SHA1 Message Date
Patrick Buckley 52d59cf7b7 chore: bump version to 0.8.2 2026-03-17 02:20:44 -07:00
Patrick Buckley 2dc885ab4d fix: output guard detects single secret-bearing env lines (#115)
* fix: output guard detects single secret-bearing env lines

The credential leak check required 3+ env-style lines before flagging.
A single AWS_SECRET_ACCESS_KEY=... line was missed. Now flags whenever
any env line has a secret-bearing key name (SECRET, KEY, TOKEN,
PASSWORD, CREDENTIAL), regardless of how many total env lines exist.

* fix: tighten env secret key matching, add tests

Tighten _RE_ENV_SECRET_KEY to word-boundary segments so MONKEY/TURKEY
don't false-positive. Use any() for short-circuit. Add test for single
secret line detection and substring false-positive prevention.
2026-03-17 02:19:22 -07:00
Patrick Buckley 14488f43e0 feat: metacognitive nudge on tool error — search memories for guidance
Add tool_error nudge type that fires when a tool returns an error,
prompting the model to search memories for prior feedback about the
tool or error pattern before retrying.

- Gated on nudges config (respects nudges=false)
- Only fires when memories exist (no noise on fresh workstreams)
- Broad error detection: Error*, *error:*, Command timed out, Unknown tool
- Nudge wording aligned to memory(action='search') convention
- Respects existing cooldown (5 min) and rate limiting
- 4 new tests
2026-03-17 02:06:10 -07:00
Patrick Buckley 90f2070146 chore: bump version to 0.8.1 2026-03-17 01:28:14 -07:00
Patrick Buckley 1e551830ea fix: allow deleting installed (readonly) skills
Readonly guard should prevent editing content, not uninstalling.
Remove readonly check from admin_delete_skill so batch-installed
skills can be individually deleted. Enable delete button in UI
for all skills regardless of readonly flag.
2026-03-17 01:26:44 -07:00
Patrick Buckley 84cc212ecd ui: tighten category and risk columns (100px -> 80px) 2026-03-17 01:26:44 -07:00
Patrick Buckley da4025d338 ui: skills table — category first, risk column, remove variables
- Move category column before name
- Remove variables column (rarely useful in table view)
- Add dedicated RISK column with scan badge, unicode shape indicators
  (checkmark/triangle/diamond/warning), and multi-line tooltip showing
  composite score and flagged axes from scan report
- Risk badge is keyboard-focusable (tabindex=0) with aria-label
- Unscanned skills show em-dash placeholder at 40% opacity
- Balanced grid: 100px 1.5fr 100px 120px
- Risk + category hidden on mobile (<700px)
2026-03-17 01:26:44 -07:00
Patrick Buckley 88085c29ff fix: normalize install response + review fixes
Address 5 Copilot review items + code review findings:

- Normalize install endpoint to always return envelope response:
  {installed: [...], skipped: [...], total: N} — eliminates dual
  response shape (single SkillInfo vs batch). Breaking change to
  install endpoint response, SDKs and OpenAPI spec updated.
- Add SkillInstallResponse + SkillInstallSkipped Pydantic models
- POST /resources spec now correctly documents response_code=201
- SQLite count_skill_resources_bulk chunks IN clause at 900 to stay
  under SQLITE_MAX_VARIABLE_NUMBER (999)
- Fix installDiscoveredSkill() JS handler for envelope response
- Add error key to 409 duplicate response for error handler compat
- Update Python SDK install_skill return type (dict, not SkillInfo)
- Add TypeScript SkillInstallResponse + SkillInstallSkipped types
- Regenerate openapi-console.json
- Update all install tests for envelope response shape
2026-03-17 01:26:44 -07:00
Patrick Buckley 3152667a0c fix: update test_skill_sources for 5-tuple _parse_github_url
_parse_github_url now returns (owner, repo, branch, path, branch_explicit).
Update all test unpackings and add assertions for branch_explicit.
2026-03-17 01:26:44 -07:00
Patrick Buckley 4b44d88401 fix: harden batch skill install — 7 review items + OpenAPI snapshot
- Race condition: wrap create_prompt_template in try/except, append
  to skipped on conflict instead of crashing
- HTTP timeout: per-request timeout (10s+5s connect) instead of shared
  15s pool; parallelize SKILL.md and resource fetches with semaphore
  (5 concurrent)
- Branch detection: return branch_explicit from _parse_github_url(),
  eliminate duplicated regex matching and type: ignore comments
- Content-length: check len(resp.content) after fetch instead of
  unreliable content-length header; add size check in batch path
- Rate limits: _check_rate_limit() inspects x-ratelimit-remaining,
  raises actionable error on 403, warns when remaining < 10
- Root resources: fix _find_resource_files skipping root-level
  resources like scripts/foo.sh for root SKILL.md
- resource_count: pass accurate count in update and install responses
- Regenerate openapi-console.json with new resource endpoints
2026-03-17 01:26:44 -07:00
Patrick Buckley 8957b9ce0e feat: batch install skills from multi-skill GitHub repos
When a GitHub repo URL has no root SKILL.md (monorepo pattern like
anthropics/skills), automatically scan the repo tree for all SKILL.md
files and install every discovered skill in one operation.

- Add fetch_skills_from_github_repo() — scans recursive tree, parses
  each SKILL.md, collects per-skill resources via shared helpers
- Extract _find_resource_files() and _fetch_resource_contents() to
  eliminate duplication between single and batch fetch paths
- Extend admin_skill_install to fall back to batch scanning when
  single-skill fetch returns 404
- Each skill gets a specific source_url pointing to its subdirectory
- Backward compatible: single-skill repos return same response shape
- Frontend handles both shapes with contextual toast messages
- Filter tree scan to URL path subtree when path is provided
- Cap at 50 skills per repo scan

Also addresses review feedback:
- Fix path prefix check (scripts/ not scriptsX/)
- Use count_skill_resources_bulk for single skill GET
- Add content field to SkillResourceInfo schema
- Fix OpenAPI spec paths ({path} not {path:path})
- Check r.ok on resource upload promises
- Preserve / in URL-encoded paths (split/map/join pattern)
- URL-encode path in Python SDK delete method
- Fix test_install_not_found to mock batch fallback
- Fix test_search_empty_results for required q param
- Narrow except clause to ValueError in batch parser
2026-03-17 01:26:44 -07:00
Patrick Buckley 28a6b0dd33 feat: skill resources — API, admin UI, runtime injection, and SDK
Complete the resource surface for skills (scripts/, references/, assets/):

- 4 admin API endpoints: list, get, create, delete skill resources
- Storage: delete_skill_resource_by_path + count_skill_resources_bulk
- Admin UI: resource count badge in skills table, resource sections in
  create/edit modals with add/delete, readonly guard for installed skills
- Runtime: _load_skills populates skill resources, _init_system_messages
  injects <skill-resources> catalog (inlined if <8KB)
- Python SDK: list/create/delete_skill_resource (async + sync)
- TypeScript SDK: listSkillResources, createSkillResource, deleteSkillResource
- Path traversal protection (normpath + .. rejection + null byte check)
- Block empty skill discover searches (frontend toast + backend 400)
- Rename MCP "Registry" tab to "Discover" for consistency with skills
- Move Skills + MCP Servers into new "Extensions" sidebar group
- 25 tests (7 storage, 16 API + 2 security)
2026-03-17 01:26:44 -07:00
Patrick Buckley 7bc17cc072 fix: populate func_args for all tools in intent judge evaluation
The heuristic engine was seeing empty {} for web_fetch, web_search,
watch, notify, task, and load_skill — only bash, file ops, and MCP
tools had their arguments forwarded. The judge could not pattern-match
on URLs, queries, commands, or messages for these tools.
2026-03-16 19:33:16 -07:00
28 changed files with 2043 additions and 183 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.8.0"
version = "0.8.2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+325 -2
View File
@@ -1821,7 +1821,7 @@
},
"/v1/api/admin/skills/install": {
"post": {
"summary": "Install a skill from an external source",
"summary": "Install skill(s) from an external source",
"operationId": "v1_api_admin_skills_install_post",
"tags": [
"Admin"
@@ -1842,7 +1842,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillInfo"
"$ref": "#/components/schemas/SkillInstallResponse"
}
}
}
@@ -2470,6 +2470,195 @@
}
}
},
"/v1/api/admin/skills/{skill_id}/resources": {
"get": {
"summary": "List resource files for a skill",
"operationId": "v1_api_admin_skills_{skill_id}_resources_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "skill_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListSkillResourcesResponse"
}
}
}
}
}
},
"post": {
"summary": "Upload a resource file to a skill",
"operationId": "v1_api_admin_skills_{skill_id}_resources_post",
"tags": [
"Admin"
],
"parameters": [
{
"name": "skill_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateSkillResourceRequest"
}
}
}
},
"responses": {
"201": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillResourceInfo"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/skills/{skill_id}/resources/{path}": {
"get": {
"summary": "Get a single skill resource by path",
"operationId": "v1_api_admin_skills_{skill_id}_resources_{path}_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "skill_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillResourceInfo"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
},
"delete": {
"summary": "Delete a skill resource by path",
"operationId": "v1_api_admin_skills_{skill_id}_resources_{path}_delete",
"tags": [
"Admin"
],
"parameters": [
{
"name": "skill_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/memories": {
"get": {
"summary": "List structured memories",
@@ -6506,6 +6695,35 @@
"title": "SkillInstallRequest",
"type": "object"
},
"SkillInstallResponse": {
"properties": {
"installed": {
"items": {
"$ref": "#/components/schemas/SkillInfo"
},
"title": "Installed",
"type": "array"
},
"skipped": {
"default": [],
"items": {
"$ref": "#/components/schemas/SkillInstallSkipped"
},
"title": "Skipped",
"type": "array"
},
"total": {
"default": 0,
"title": "Total",
"type": "integer"
}
},
"required": [
"installed"
],
"title": "SkillInstallResponse",
"type": "object"
},
"SkillInfo": {
"properties": {
"template_id": {
@@ -6680,6 +6898,11 @@
"title": "Scan Version",
"type": "string"
},
"resource_count": {
"default": 0,
"title": "Resource Count",
"type": "integer"
},
"created": {
"title": "Created",
"type": "string"
@@ -6703,6 +6926,24 @@
"title": "SkillInfo",
"type": "object"
},
"SkillInstallSkipped": {
"properties": {
"name": {
"title": "Name",
"type": "string"
},
"reason": {
"title": "Reason",
"type": "string"
}
},
"required": [
"name",
"reason"
],
"title": "SkillInstallSkipped",
"type": "object"
},
"SkillVersionInfo": {
"properties": {
"id": {
@@ -7153,6 +7394,88 @@
"title": "ListSkillVersionsResponse",
"type": "object"
},
"SkillResourceInfo": {
"properties": {
"resource_id": {
"title": "Resource Id",
"type": "string"
},
"skill_id": {
"title": "Skill Id",
"type": "string"
},
"path": {
"title": "Path",
"type": "string"
},
"content": {
"default": "",
"title": "Content",
"type": "string"
},
"content_type": {
"default": "text/plain",
"title": "Content Type",
"type": "string"
},
"size": {
"default": 0,
"title": "Size",
"type": "integer"
},
"created": {
"title": "Created",
"type": "string"
}
},
"required": [
"resource_id",
"skill_id",
"path",
"created"
],
"title": "SkillResourceInfo",
"type": "object"
},
"CreateSkillResourceRequest": {
"properties": {
"path": {
"title": "Path",
"type": "string"
},
"content": {
"title": "Content",
"type": "string"
},
"content_type": {
"default": "text/plain",
"title": "Content Type",
"type": "string"
}
},
"required": [
"path",
"content"
],
"title": "CreateSkillResourceRequest",
"type": "object"
},
"ListSkillResourcesResponse": {
"properties": {
"resources": {
"items": {
"$ref": "#/components/schemas/SkillResourceInfo"
},
"title": "Resources",
"type": "array"
}
},
"required": [
"resources"
],
"title": "ListSkillResourcesResponse",
"type": "object"
},
"SkillSummary": {
"properties": {
"name": {
+29 -1
View File
@@ -21,6 +21,7 @@ import type {
CreateRoleOptions,
CreateScheduleRequest,
CreateSkillRequest,
CreateSkillResourceRequest,
ImportMcpConfigResponse,
ListAdminMemoriesResponse,
ListMcpServersResponse,
@@ -28,6 +29,7 @@ import type {
ListSchedulesResponse,
ListSettingSchemaResponse,
ListSettingsResponse,
ListSkillResourcesResponse,
ListSkillsResponse,
McpServerDetail,
RegistryInstallRequest,
@@ -35,6 +37,8 @@ import type {
SkillDiscoverResponse,
SkillInfo,
SkillInstallRequest,
SkillInstallResponse,
SkillResourceInfo,
NodeDetailResponse,
NodesOptions,
OrgInfo,
@@ -293,6 +297,30 @@ export class TurnstoneConsole extends BaseClient {
await this.request("DELETE", `/v1/api/admin/skills/${skillId}`);
}
async listSkillResources(skillId: string): Promise<SkillResourceInfo[]> {
const resp = await this.request<ListSkillResourcesResponse>(
"GET",
`/v1/api/admin/skills/${skillId}/resources`,
);
return resp.resources;
}
async createSkillResource(
skillId: string,
body: CreateSkillResourceRequest,
): Promise<SkillResourceInfo> {
return this.request("POST", `/v1/api/admin/skills/${skillId}/resources`, {
json: body,
});
}
async deleteSkillResource(skillId: string, path: string): Promise<void> {
await this.request(
"DELETE",
`/v1/api/admin/skills/${skillId}/resources/${path.split("/").map(encodeURIComponent).join("/")}`,
);
}
// -- Governance: Usage & Audit ----------------------------------------------
async getUsage(opts: UsageQueryOptions): Promise<UsageResponse> {
@@ -457,7 +485,7 @@ export class TurnstoneConsole extends BaseClient {
});
}
async installSkill(body: SkillInstallRequest): Promise<SkillInfo> {
async installSkill(body: SkillInstallRequest): Promise<SkillInstallResponse> {
return this.request("POST", "/v1/api/admin/skills/install", {
json: body,
});
+5
View File
@@ -131,6 +131,9 @@ export type {
CreateSkillRequest,
UpdateSkillRequest,
ListSkillsResponse,
SkillResourceInfo,
ListSkillResourcesResponse,
CreateSkillResourceRequest,
UsageBreakdownItem,
UsageResponse,
UsageQueryOptions,
@@ -175,6 +178,8 @@ export type {
SkillDiscoverListing,
SkillDiscoverResponse,
SkillInstallRequest,
SkillInstallResponse,
SkillInstallSkipped,
} from "./types.js";
// SSE parser (for advanced usage)
+32
View File
@@ -187,6 +187,7 @@ export interface SkillInfo {
notify_on_complete: string;
enabled: boolean;
allowed_tools: string;
resource_count: number;
created: string;
updated: string;
}
@@ -242,6 +243,26 @@ export interface ListSkillsResponse {
skills: SkillInfo[];
}
export interface SkillResourceInfo {
resource_id: string;
skill_id: string;
path: string;
content?: string;
content_type: string;
size: number;
created: string;
}
export interface ListSkillResourcesResponse {
resources: SkillResourceInfo[];
}
export interface CreateSkillResourceRequest {
path: string;
content: string;
content_type?: string;
}
// ---------------------------------------------------------------------------
// Server API — Health
// ---------------------------------------------------------------------------
@@ -857,6 +878,17 @@ export interface SkillInstallRequest {
url?: string;
}
export interface SkillInstallSkipped {
name: string;
reason: string;
}
export interface SkillInstallResponse {
installed: SkillInfo[];
skipped: SkillInstallSkipped[];
total: number;
}
// -- Console API: System Settings -------------------------------------------
export interface SettingInfo {
+23
View File
@@ -6,6 +6,7 @@ from turnstone.core.metacognition import (
NUDGE_DENIAL,
NUDGE_RESUME,
NUDGE_START,
NUDGE_TOOL_ERROR,
detect_completion,
detect_correction,
format_nudge,
@@ -263,5 +264,27 @@ class TestFormatNudge:
def test_start(self):
assert format_nudge("start") == NUDGE_START
def test_tool_error(self):
assert format_nudge("tool_error") == NUDGE_TOOL_ERROR
def test_invalid(self):
assert format_nudge("invalid") == ""
class TestToolErrorNudge:
def test_fires(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=3) is True
def test_cooldown(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=3) is True
assert should_nudge("tool_error", state, message_count=6, memory_count=3) is False
def test_not_on_first_message(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=1, memory_count=3) is False
def test_not_with_zero_memories(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=0) is False
+11
View File
@@ -184,6 +184,17 @@ class TestEnvSecretFalsePositives:
r = evaluate_output("APP_NAME=myapp\nSECRET_KEY=abc123\nAPI_TOKEN=xyz789\nDEBUG=true")
assert "env_file_leak" in r.flags
def test_single_secret_env_line(self) -> None:
"""A single AWS_SECRET_ACCESS_KEY=... line should trigger."""
r = evaluate_output("AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
assert "env_file_leak" in r.flags
assert r.risk_level == "high"
def test_substring_key_no_false_positive(self) -> None:
"""MONKEY=banana should not trigger (KEY is a substring, not a segment)."""
r = evaluate_output("MONKEY=banana\nTURKEY=gobble\nDONKEY=hee-haw")
assert "env_file_leak" not in r.flags
class TestOutputAssessment:
"""Verify OutputAssessment structure."""
+24 -10
View File
@@ -175,11 +175,15 @@ class TestSkillDiscover:
instance = mock_cls.return_value
instance.search = AsyncMock(return_value=[])
resp = client.get("/v1/api/admin/skills/discover")
resp = client.get("/v1/api/admin/skills/discover", params={"q": "test"})
assert resp.status_code == 200
assert resp.json()["skills"] == []
def test_search_empty_query_rejected(self, client: TestClient) -> None:
resp = client.get("/v1/api/admin/skills/discover")
assert resp.status_code == 400
def test_search_permission_denied(self, client_no_perm: TestClient) -> None:
resp = client_no_perm.get("/v1/api/admin/skills/discover")
assert resp.status_code == 403
@@ -241,10 +245,13 @@ class TestSkillInstall:
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "test-skill"
assert data["origin"] == "source"
assert data["readonly"] is True
assert data["source_url"] == "https://github.com/owner/repo"
assert data["total"] == 1
assert len(data["installed"]) == 1
skill = data["installed"][0]
assert skill["name"] == "test-skill"
assert skill["origin"] == "source"
assert skill["readonly"] is True
assert skill["source_url"] == "https://github.com/owner/repo"
def test_install_from_skills_sh(self, client: TestClient) -> None:
package = _sample_package()
@@ -265,7 +272,7 @@ class TestSkillInstall:
)
assert resp.status_code == 200
assert resp.json()["name"] == "test-skill"
assert resp.json()["installed"][0]["name"] == "test-skill"
def test_install_invalid_source(self, client: TestClient) -> None:
resp = client.post(
@@ -345,10 +352,17 @@ class TestSkillInstall:
assert resp.status_code == 409
def test_install_not_found(self, client: TestClient) -> None:
with patch(
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
) as mock_fetch:
with (
patch(
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
) as mock_fetch,
patch(
"turnstone.core.skill_sources.fetch_skills_from_github_repo",
new_callable=AsyncMock,
) as mock_batch,
):
mock_fetch.side_effect = SkillNotFoundError("SKILL.md not found")
mock_batch.side_effect = SkillNotFoundError("No SKILL.md files found")
resp = client.post(
"/v1/api/admin/skills/install",
@@ -391,7 +405,7 @@ class TestSkillInstall:
)
assert resp.status_code == 200
skill_id = resp.json()["template_id"]
skill_id = resp.json()["installed"][0]["template_id"]
resources = storage.list_skill_resources(skill_id)
assert len(resources) == 1
assert resources[0]["path"] == "scripts/setup.sh"
+298
View File
@@ -0,0 +1,298 @@
"""Tests for skill resource admin API endpoints."""
from __future__ import annotations
import uuid
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 starlette.requests import Request
from starlette.responses import Response
from turnstone.console.server import (
admin_create_skill_resource,
admin_delete_skill_resource,
admin_get_skill,
admin_get_skill_resource,
admin_list_skill_resources,
admin_list_skills,
)
from turnstone.core.auth import AuthResult
from turnstone.core.storage._sqlite import SQLiteBackend
# ---------------------------------------------------------------------------
# Auth middleware
# ---------------------------------------------------------------------------
class _InjectAuthMiddleware(BaseHTTPMiddleware):
"""Inject an admin auth result with admin.skills permission."""
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)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_ROUTES = [
Mount(
"/v1",
routes=[
Route("/api/admin/skills", admin_list_skills),
Route("/api/admin/skills/{skill_id}", admin_get_skill),
Route(
"/api/admin/skills/{skill_id}/resources",
admin_list_skill_resources,
),
Route(
"/api/admin/skills/{skill_id}/resources",
admin_create_skill_resource,
methods=["POST"],
),
Route(
"/api/admin/skills/{skill_id}/resources/{path:path}",
admin_get_skill_resource,
),
Route(
"/api/admin/skills/{skill_id}/resources/{path:path}",
admin_delete_skill_resource,
methods=["DELETE"],
),
],
),
]
@pytest.fixture()
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
@pytest.fixture()
def client(storage):
app = Starlette(
routes=_ROUTES,
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.auth_storage = storage
return TestClient(app)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_test_skill(storage: SQLiteBackend, *, readonly: bool = False) -> str:
"""Create a minimal skill in storage and return its template_id."""
skill_id = uuid.uuid4().hex
storage.create_prompt_template(
template_id=skill_id,
name=f"test-skill-{skill_id[:8]}",
category="general",
content="Test skill content.",
variables="[]",
is_default=False,
org_id="",
created_by="test",
readonly=readonly,
)
return skill_id
# ---------------------------------------------------------------------------
# Tests: List resources
# ---------------------------------------------------------------------------
class TestListSkillResources:
def test_list_empty(self, client, storage):
skill_id = _create_test_skill(storage)
resp = client.get(f"/v1/api/admin/skills/{skill_id}/resources")
assert resp.status_code == 200
data = resp.json()
assert data["resources"] == []
def test_list_with_resources(self, client, storage):
skill_id = _create_test_skill(storage)
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content")
resp = client.get(f"/v1/api/admin/skills/{skill_id}/resources")
assert resp.status_code == 200
resources = resp.json()["resources"]
assert len(resources) == 1
assert resources[0]["path"] == "scripts/a.sh"
assert "content" not in resources[0] # Content NOT in list view
def test_skill_not_found(self, client):
resp = client.get("/v1/api/admin/skills/nonexistent/resources")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Tests: Create resource
# ---------------------------------------------------------------------------
class TestCreateSkillResource:
def test_create_valid(self, client, storage):
skill_id = _create_test_skill(storage)
resp = client.post(
f"/v1/api/admin/skills/{skill_id}/resources",
json={"path": "scripts/setup.sh", "content": "#!/bin/bash\necho hello"},
)
assert resp.status_code == 201
data = resp.json()
assert data["path"] == "scripts/setup.sh"
assert data["size"] > 0
def test_invalid_path(self, client, storage):
skill_id = _create_test_skill(storage)
resp = client.post(
f"/v1/api/admin/skills/{skill_id}/resources",
json={"path": "malicious/file.sh", "content": "x"},
)
assert resp.status_code == 400
def test_path_traversal_rejected(self, client, storage):
skill_id = _create_test_skill(storage)
resp = client.post(
f"/v1/api/admin/skills/{skill_id}/resources",
json={"path": "scripts/../../etc/passwd", "content": "x"},
)
assert resp.status_code == 400
def test_null_byte_in_path_rejected(self, client, storage):
skill_id = _create_test_skill(storage)
resp = client.post(
f"/v1/api/admin/skills/{skill_id}/resources",
json={"path": "scripts/a\x00.sh", "content": "x"},
)
assert resp.status_code == 400
def test_duplicate_409(self, client, storage):
skill_id = _create_test_skill(storage)
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content")
resp = client.post(
f"/v1/api/admin/skills/{skill_id}/resources",
json={"path": "scripts/a.sh", "content": "new"},
)
assert resp.status_code == 409
def test_size_cap(self, client, storage):
skill_id = _create_test_skill(storage)
resp = client.post(
f"/v1/api/admin/skills/{skill_id}/resources",
json={"path": "scripts/big.sh", "content": "x" * (100 * 1024 + 1)},
)
assert resp.status_code == 400
def test_max_count(self, client, storage):
skill_id = _create_test_skill(storage)
for i in range(10):
storage.create_skill_resource(uuid.uuid4().hex, skill_id, f"scripts/s{i}.sh", "content")
resp = client.post(
f"/v1/api/admin/skills/{skill_id}/resources",
json={"path": "scripts/extra.sh", "content": "x"},
)
assert resp.status_code == 400
def test_readonly_skill_blocked(self, client, storage):
skill_id = _create_test_skill(storage, readonly=True)
resp = client.post(
f"/v1/api/admin/skills/{skill_id}/resources",
json={"path": "scripts/a.sh", "content": "x"},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Tests: Get resource
# ---------------------------------------------------------------------------
class TestGetSkillResource:
def test_get_existing(self, client, storage):
skill_id = _create_test_skill(storage)
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "hello world")
resp = client.get(
f"/v1/api/admin/skills/{skill_id}/resources/scripts/a.sh",
)
assert resp.status_code == 200
data = resp.json()
assert data["content"] == "hello world"
assert data["path"] == "scripts/a.sh"
def test_not_found(self, client, storage):
skill_id = _create_test_skill(storage)
resp = client.get(
f"/v1/api/admin/skills/{skill_id}/resources/scripts/nope.sh",
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Tests: Delete resource
# ---------------------------------------------------------------------------
class TestDeleteSkillResource:
def test_delete_existing(self, client, storage):
skill_id = _create_test_skill(storage)
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content")
resp = client.delete(
f"/v1/api/admin/skills/{skill_id}/resources/scripts/a.sh",
)
assert resp.status_code == 200
assert storage.get_skill_resource(skill_id, "scripts/a.sh") is None
def test_not_found(self, client, storage):
skill_id = _create_test_skill(storage)
resp = client.delete(
f"/v1/api/admin/skills/{skill_id}/resources/scripts/nope.sh",
)
assert resp.status_code == 404
def test_readonly_blocked(self, client, storage):
skill_id = _create_test_skill(storage, readonly=True)
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content")
resp = client.delete(
f"/v1/api/admin/skills/{skill_id}/resources/scripts/a.sh",
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Tests: Resource count in skill responses
# ---------------------------------------------------------------------------
class TestResourceCountInSkillResponse:
def test_list_includes_count(self, client, storage):
skill_id = _create_test_skill(storage)
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "a")
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/b.sh", "b")
resp = client.get("/v1/api/admin/skills")
skills = resp.json()["skills"]
skill = [s for s in skills if s["template_id"] == skill_id][0]
assert skill["resource_count"] == 2
def test_get_includes_count(self, client, storage):
skill_id = _create_test_skill(storage)
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "a")
resp = client.get(f"/v1/api/admin/skills/{skill_id}")
assert resp.json()["resource_count"] == 1
+66
View File
@@ -0,0 +1,66 @@
"""Tests for skill resource storage operations."""
from __future__ import annotations
import uuid
import pytest
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture()
def storage(tmp_path):
"""Fresh SQLite backend for each test."""
return SQLiteBackend(str(tmp_path / "test.db"))
class TestDeleteSkillResourceByPath:
def test_delete_existing(self, storage):
skill_id = uuid.uuid4().hex
rid = uuid.uuid4().hex
storage.create_skill_resource(rid, skill_id, "scripts/a.sh", "#!/bin/bash")
assert storage.delete_skill_resource_by_path(skill_id, "scripts/a.sh") is True
assert storage.get_skill_resource(skill_id, "scripts/a.sh") is None
def test_delete_not_found(self, storage):
assert storage.delete_skill_resource_by_path("nonexistent", "scripts/a.sh") is False
def test_delete_wrong_path(self, storage):
skill_id = uuid.uuid4().hex
rid = uuid.uuid4().hex
storage.create_skill_resource(rid, skill_id, "scripts/a.sh", "content")
assert storage.delete_skill_resource_by_path(skill_id, "scripts/b.sh") is False
# Original still exists
assert storage.get_skill_resource(skill_id, "scripts/a.sh") is not None
def test_delete_only_target(self, storage):
"""Deleting one resource doesn't affect others for the same skill."""
skill_id = uuid.uuid4().hex
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "a")
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/b.sh", "b")
assert storage.delete_skill_resource_by_path(skill_id, "scripts/a.sh") is True
assert storage.get_skill_resource(skill_id, "scripts/b.sh") is not None
assert len(storage.list_skill_resources(skill_id)) == 1
class TestListSkillResources:
def test_ordering(self, storage):
skill_id = uuid.uuid4().hex
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/z.sh", "z")
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "assets/a.txt", "a")
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "references/m.md", "m")
rows = storage.list_skill_resources(skill_id)
paths = [r["path"] for r in rows]
assert paths == sorted(paths)
def test_empty(self, storage):
assert storage.list_skill_resources("nonexistent") == []
def test_size_from_content(self, storage):
skill_id = uuid.uuid4().hex
content = "x" * 500
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", content)
rows = storage.list_skill_resources(skill_id)
assert len(rows) == 1
assert len(rows[0]["content"]) == 500
+9 -5
View File
@@ -20,19 +20,23 @@ class TestParseGitHubUrl:
"""GitHub URL parsing."""
def test_simple_repo(self) -> None:
owner, repo, branch, path = _parse_github_url("https://github.com/owner/repo")
owner, repo, branch, path, explicit = _parse_github_url("https://github.com/owner/repo")
assert owner == "owner"
assert repo == "repo"
assert branch == "main"
assert path == ""
assert explicit is False
def test_repo_with_branch(self) -> None:
owner, repo, branch, path = _parse_github_url("https://github.com/owner/repo/tree/develop")
owner, repo, branch, path, explicit = _parse_github_url(
"https://github.com/owner/repo/tree/develop"
)
assert branch == "develop"
assert path == ""
assert explicit is True
def test_repo_with_path(self) -> None:
owner, repo, branch, path = _parse_github_url(
owner, repo, branch, path, _explicit = _parse_github_url(
"https://github.com/owner/repo/tree/main/skills/code-review"
)
assert owner == "owner"
@@ -41,14 +45,14 @@ class TestParseGitHubUrl:
assert path == "skills/code-review"
def test_blob_url(self) -> None:
owner, repo, branch, path = _parse_github_url(
owner, repo, branch, path, _explicit = _parse_github_url(
"https://github.com/owner/repo/blob/main/SKILL.md"
)
assert branch == "main"
assert path == "SKILL.md"
def test_invalid_url(self) -> None:
owner, repo, branch, path = _parse_github_url("https://gitlab.com/owner/repo")
owner, repo, branch, path, _explicit = _parse_github_url("https://gitlab.com/owner/repo")
assert owner == ""
+3 -3
View File
@@ -846,8 +846,8 @@ class TestSkillAPI:
resp = api_client.delete("/v1/api/admin/skills/missing")
assert resp.status_code == 404
def test_delete_skill_readonly_rejected(self, api_client, api_storage):
"""Deleting a readonly skill returns 403."""
def test_delete_skill_readonly_allowed(self, api_client, api_storage):
"""Deleting a readonly (installed) skill is allowed — uninstall."""
_create_template(
api_storage,
"s1",
@@ -858,7 +858,7 @@ class TestSkillAPI:
readonly=True,
)
resp = api_client.delete("/v1/api/admin/skills/s1")
assert resp.status_code == 403
assert resp.status_code == 200
def test_skill_field_on_workstream_create(self, api_storage):
"""Console workstream creation accepts 'skill' field in request body."""
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.7.0"
__version__ = "0.8.2"
+37
View File
@@ -310,6 +310,7 @@ class SkillInfo(BaseModel):
scan_status: str = ""
scan_report: str = "{}"
scan_version: str = ""
resource_count: int = 0
created: str
updated: str
@@ -383,6 +384,31 @@ class ListSkillVersionsResponse(BaseModel):
versions: list[SkillVersionInfo]
# ---------------------------------------------------------------------------
# Governance: Skill Resources
# ---------------------------------------------------------------------------
class SkillResourceInfo(BaseModel):
resource_id: str
skill_id: str
path: str
content: str = ""
content_type: str = "text/plain"
size: int = 0
created: str
class ListSkillResourcesResponse(BaseModel):
resources: list[SkillResourceInfo]
class CreateSkillResourceRequest(BaseModel):
path: str
content: str
content_type: str = "text/plain"
# ---------------------------------------------------------------------------
# Governance: Usage
# ---------------------------------------------------------------------------
@@ -685,6 +711,17 @@ class SkillInstallRequest(BaseModel):
url: str = "" # for github
class SkillInstallSkipped(BaseModel):
name: str
reason: str
class SkillInstallResponse(BaseModel):
installed: list[SkillInfo]
skipped: list[SkillInstallSkipped] = []
total: int = 0
# ---------------------------------------------------------------------------
# Admin: MCP Registry
# ---------------------------------------------------------------------------
+43 -2
View File
@@ -23,6 +23,7 @@ from turnstone.api.console_schemas import (
CreateMcpServerRequest,
CreateRoleRequest,
CreateSkillRequest,
CreateSkillResourceRequest,
CreateToolPolicyRequest,
ImportMcpConfigRequest,
ImportMcpConfigResponse,
@@ -35,6 +36,7 @@ from turnstone.api.console_schemas import (
ListRolesResponse,
ListSettingSchemaResponse,
ListSettingsResponse,
ListSkillResourcesResponse,
ListSkillsResponse,
ListSkillVersionsResponse,
ListToolPoliciesResponse,
@@ -53,6 +55,8 @@ from turnstone.api.console_schemas import (
SkillDiscoverResponse,
SkillInfo,
SkillInstallRequest,
SkillInstallResponse,
SkillResourceInfo,
SkillVersionInfo,
ToolPolicyInfo,
UpdateMcpServerRequest,
@@ -501,9 +505,9 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
EndpointSpec(
"/v1/api/admin/skills/install",
"POST",
"Install a skill from an external source",
"Install skill(s) from an external source",
request_model=SkillInstallRequest,
response_model=SkillInfo,
response_model=SkillInstallResponse,
error_codes=[400, 404, 409, 502],
tags=["Admin"],
),
@@ -639,6 +643,39 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
"Re-scan a skill for security signals",
tags=["Admin"],
),
# --- Governance: Skill Resources ---
EndpointSpec(
"/v1/api/admin/skills/{skill_id}/resources",
"GET",
"List resource files for a skill",
response_model=ListSkillResourcesResponse,
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/skills/{skill_id}/resources",
"POST",
"Upload a resource file to a skill",
request_model=CreateSkillResourceRequest,
response_model=SkillResourceInfo,
response_code=201,
error_codes=[400, 404, 409],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/skills/{skill_id}/resources/{path}",
"GET",
"Get a single skill resource by path",
response_model=SkillResourceInfo,
error_codes=[404],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/skills/{skill_id}/resources/{path}",
"DELETE",
"Delete a skill resource by path",
error_codes=[404],
tags=["Admin"],
),
# --- Admin: Memories ---
EndpointSpec(
"/v1/api/admin/memories",
@@ -885,12 +922,16 @@ _ALL_MODELS: list[type[BaseModel]] = [
RegistryInstallRequest,
SkillDiscoverResponse,
SkillInstallRequest,
SkillInstallResponse,
SkillInfo,
SkillVersionInfo,
CreateSkillRequest,
UpdateSkillRequest,
ListSkillsResponse,
ListSkillVersionsResponse,
SkillResourceInfo,
CreateSkillResourceRequest,
ListSkillResourcesResponse,
SkillSummary,
ListSkillSummaryResponse,
]
+309 -69
View File
@@ -2248,7 +2248,7 @@ def _parse_skill_session_config(body: dict[str, Any]) -> tuple[dict[str, Any], J
return fields, None
def _skill_to_response(r: dict[str, Any]) -> dict[str, Any]:
def _skill_to_response(r: dict[str, Any], resource_count: int = 0) -> dict[str, Any]:
"""Convert a storage skill dict to a JSON-safe response dict."""
import contextlib
import json as _json
@@ -2289,6 +2289,7 @@ def _skill_to_response(r: dict[str, Any]) -> dict[str, Any]:
"scan_status": r.get("scan_status", ""),
"scan_report": r.get("scan_report", "{}"),
"scan_version": r.get("scan_version", ""),
"resource_count": resource_count,
"created": r.get("created", ""),
"updated": r.get("updated", ""),
}
@@ -2310,7 +2311,9 @@ async def admin_list_skills(request: Request) -> JSONResponse:
offset = _parse_int(params, "offset", 0, minimum=0, maximum=100000)
rows = storage.list_prompt_templates(limit=limit, offset=offset)
total = storage.count_prompt_templates()
skills = [_skill_to_response(r) for r in rows]
skill_ids = [r["template_id"] for r in rows]
rc_map = storage.count_skill_resources_bulk(skill_ids) if skill_ids else {}
skills = [_skill_to_response(r, resource_count=rc_map.get(r["template_id"], 0)) for r in rows]
return JSONResponse({"skills": skills, "total": total})
@@ -2330,7 +2333,8 @@ async def admin_get_skill(request: Request) -> JSONResponse:
skill = storage.get_prompt_template(skill_id)
if skill is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
return JSONResponse(_skill_to_response(skill))
rc_map = storage.count_skill_resources_bulk([skill_id])
return JSONResponse(_skill_to_response(skill, resource_count=rc_map.get(skill_id, 0)))
async def admin_create_skill(request: Request) -> JSONResponse:
@@ -2530,7 +2534,8 @@ async def admin_update_skill(request: Request) -> JSONResponse:
)
updated_skill = storage.get_prompt_template(skill_id)
return JSONResponse(_skill_to_response(updated_skill))
rc_map = storage.count_skill_resources_bulk([skill_id])
return JSONResponse(_skill_to_response(updated_skill, resource_count=rc_map.get(skill_id, 0)))
async def admin_delete_skill(request: Request) -> JSONResponse:
@@ -2550,8 +2555,6 @@ async def admin_delete_skill(request: Request) -> JSONResponse:
existing = storage.get_prompt_template(skill_id)
if existing is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
if existing.get("readonly"):
return JSONResponse({"error": "MCP-sourced skills are read-only"}, status_code=403)
storage.delete_skill_resources(skill_id)
storage.delete_skill_versions(skill_id)
@@ -2829,6 +2832,192 @@ async def admin_rescan_skill(request: Request) -> JSONResponse:
)
# ---------------------------------------------------------------------------
# Admin: Skill Resources
# ---------------------------------------------------------------------------
_ALLOWED_RESOURCE_DIRS = ("scripts/", "references/", "assets/")
_MAX_RESOURCE_SIZE = 100 * 1024 # 100KB
_MAX_RESOURCES_PER_SKILL = 10
async def admin_list_skill_resources(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills/{skill_id}/resources — list resources."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
skill = storage.get_prompt_template(skill_id)
if skill is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
rows = storage.list_skill_resources(skill_id)
resources = [
{
"resource_id": r.get("resource_id", ""),
"skill_id": r.get("skill_id", ""),
"path": r.get("path", ""),
"content_type": r.get("content_type", "text/plain"),
"size": len(r.get("content", "")),
"created": r.get("created", ""),
}
for r in rows
]
return JSONResponse({"resources": resources})
async def admin_get_skill_resource(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills/{skill_id}/resources/{path:path} — get one resource."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
path = request.path_params["path"]
resource = storage.get_skill_resource(skill_id, path)
if resource is None:
return JSONResponse({"error": "Resource not found"}, status_code=404)
return JSONResponse(
{
"resource_id": resource.get("resource_id", ""),
"skill_id": resource.get("skill_id", ""),
"path": resource.get("path", ""),
"content": resource.get("content", ""),
"content_type": resource.get("content_type", "text/plain"),
"size": len(resource.get("content", "")),
"created": resource.get("created", ""),
}
)
async def admin_create_skill_resource(request: Request) -> JSONResponse:
"""POST /v1/api/admin/skills/{skill_id}/resources — upload resource."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
skill = storage.get_prompt_template(skill_id)
if skill is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
if skill.get("readonly"):
return JSONResponse({"error": "Installed skills are read-only"}, status_code=403)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
path = str(body.get("path", "")).strip()
content = str(body.get("content", ""))
content_type = str(body.get("content_type", "text/plain")).strip()[:64]
if not path:
return JSONResponse({"error": "path is required"}, status_code=400)
# Normalize and reject path traversal
import posixpath
path = posixpath.normpath(path)
if ".." in path.split("/") or "\x00" in path:
return JSONResponse({"error": "Invalid path"}, status_code=400)
if not any(path.startswith(d) for d in _ALLOWED_RESOURCE_DIRS):
return JSONResponse(
{"error": "path must start with scripts/, references/, or assets/"},
status_code=400,
)
if len(content) > _MAX_RESOURCE_SIZE:
return JSONResponse(
{"error": f"Resource exceeds {_MAX_RESOURCE_SIZE // 1024}KB limit"},
status_code=400,
)
existing = storage.list_skill_resources(skill_id)
if len(existing) >= _MAX_RESOURCES_PER_SKILL:
return JSONResponse(
{"error": f"Maximum {_MAX_RESOURCES_PER_SKILL} resources per skill"},
status_code=400,
)
if storage.get_skill_resource(skill_id, path) is not None:
return JSONResponse({"error": "Resource path already exists"}, status_code=409)
resource_id = uuid.uuid4().hex
storage.create_skill_resource(
resource_id=resource_id,
skill_id=skill_id,
path=path,
content=content,
content_type=content_type,
)
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "skill_resource.create", "skill", skill_id, {"path": path}, ip)
created = storage.get_skill_resource(skill_id, path)
return JSONResponse(
{
"resource_id": resource_id,
"skill_id": skill_id,
"path": path,
"content_type": content_type,
"size": len(content),
"created": (created or {}).get("created", ""),
},
status_code=201,
)
async def admin_delete_skill_resource(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/skills/{skill_id}/resources/{path:path} — delete resource."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
skill = storage.get_prompt_template(skill_id)
if skill is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
if skill.get("readonly"):
return JSONResponse({"error": "Installed skills are read-only"}, status_code=403)
path = request.path_params["path"]
deleted = storage.delete_skill_resource_by_path(skill_id, path)
if not deleted:
return JSONResponse({"error": "Resource not found"}, status_code=404)
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "skill_resource.delete", "skill", skill_id, {"path": path}, ip)
return JSONResponse({"status": "ok"})
# ---------------------------------------------------------------------------
# Admin: Skill Discovery
# ---------------------------------------------------------------------------
@@ -2870,6 +3059,8 @@ async def admin_skill_discover(request: Request) -> JSONResponse:
return err
q = str(request.query_params.get("q", "")).strip()
if not q:
return JSONResponse({"error": "Search query is required"}, status_code=400)
try:
limit = max(1, min(int(request.query_params.get("limit", "20")), 100))
except (ValueError, TypeError):
@@ -2924,6 +3115,7 @@ async def admin_skill_install(request: Request) -> JSONResponse:
SkillSourceError,
SkillsShClient,
fetch_skill_from_github,
fetch_skills_from_github_repo,
)
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
@@ -2951,12 +3143,16 @@ async def admin_skill_install(request: Request) -> JSONResponse:
discovery_url = _get_discovery_url(request)
client = SkillsShClient(base_url=discovery_url)
github_url = await client.resolve_github_url(skill_id_param)
package = await fetch_skill_from_github(github_url)
packages = [await fetch_skill_from_github(github_url)]
else:
url = str(body.get("url", "")).strip()
if not url:
return JSONResponse({"error": "url is required"}, status_code=400)
package = await fetch_skill_from_github(url)
try:
packages = [await fetch_skill_from_github(url)]
except SkillNotFoundError:
# No root SKILL.md — try scanning for a multi-skill repo
packages = await fetch_skills_from_github_repo(url)
except SkillNotFoundError as exc:
return JSONResponse({"error": str(exc)}, status_code=404)
except SkillSourceError as exc:
@@ -2964,75 +3160,100 @@ async def admin_skill_install(request: Request) -> JSONResponse:
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
# Check for duplicate by source_url
source_url = package.listing.source_url
if source_url:
existing = storage.get_skill_by_source_url(source_url)
if existing:
return JSONResponse(
{"error": f"Skill from '{source_url}' is already installed"},
status_code=409,
)
# Check for duplicate by name
if storage.get_prompt_template_by_name(package.parsed.name):
return JSONResponse(
{"error": f"Skill name '{package.parsed.name}' already exists"},
status_code=409,
)
import json as _json
audit_uid, ip = _audit_context(request)
skill_id = uuid.uuid4().hex
parsed = package.parsed
tags_str = _json.dumps(parsed.tags)
allowed_tools_str = _json.dumps(parsed.allowed_tools)
content = parsed.content[:32768]
token_estimate = len(content) // 4 if content else 0
installed: list[dict[str, Any]] = []
skipped: list[dict[str, str]] = []
storage.create_prompt_template(
template_id=skill_id,
name=parsed.name,
category="general",
content=content,
variables="[]",
is_default=False,
org_id="",
created_by=audit_uid,
origin="source",
readonly=True,
description=parsed.description,
tags=tags_str,
source_url=source_url,
version=parsed.version,
author=parsed.author,
activation="named",
token_estimate=token_estimate,
allowed_tools=allowed_tools_str,
)
for package in packages:
pkg_source_url = package.listing.source_url
# Store bundled resources
for path, content in package.resources.items():
storage.create_skill_resource(
resource_id=uuid.uuid4().hex,
skill_id=skill_id,
path=path,
content=content,
# Check for duplicate by source_url
if pkg_source_url and storage.get_skill_by_source_url(pkg_source_url):
skipped.append({"name": package.parsed.name, "reason": "already installed"})
continue
# Check for duplicate by name
if storage.get_prompt_template_by_name(package.parsed.name):
skipped.append({"name": package.parsed.name, "reason": "name exists"})
continue
skill_id = uuid.uuid4().hex
parsed = package.parsed
tags_str = _json.dumps(parsed.tags)
allowed_tools_str = _json.dumps(parsed.allowed_tools)
content = parsed.content[:32768]
token_estimate = len(content) // 4 if content else 0
try:
storage.create_prompt_template(
template_id=skill_id,
name=parsed.name,
category="general",
content=content,
variables="[]",
is_default=False,
org_id="",
created_by=audit_uid,
origin="source",
readonly=True,
description=parsed.description,
tags=tags_str,
source_url=pkg_source_url,
version=parsed.version,
author=parsed.author,
activation="named",
token_estimate=token_estimate,
allowed_tools=allowed_tools_str,
)
except Exception:
skipped.append({"name": parsed.name, "reason": "conflict"})
continue
# Store bundled resources
for res_path, res_content in package.resources.items():
storage.create_skill_resource(
resource_id=uuid.uuid4().hex,
skill_id=skill_id,
path=res_path,
content=res_content,
)
record_audit(
storage,
audit_uid,
"skill.install",
"skill",
skill_id,
{"name": parsed.name, "source": source, "source_url": pkg_source_url},
ip,
)
record_audit(
storage,
audit_uid,
"skill.install",
"skill",
skill_id,
{"name": parsed.name, "source": source, "source_url": source_url},
ip,
)
skill = storage.get_prompt_template(skill_id)
if skill:
installed.append(_skill_to_response(skill, resource_count=len(package.resources)))
skill = storage.get_prompt_template(skill_id)
return JSONResponse(_skill_to_response(skill))
if not installed and skipped:
# All skills were duplicates
return JSONResponse(
{
"error": "All skills already installed",
"installed": [],
"skipped": skipped,
"total": len(packages),
},
status_code=409,
)
# Consistent envelope for both single and batch installs
return JSONResponse(
{
"installed": installed,
"skipped": skipped,
"total": len(packages),
}
)
# ---------------------------------------------------------------------------
@@ -4361,6 +4582,25 @@ def create_app(
"/api/admin/skills/{skill_id}/versions",
admin_list_skill_versions,
),
# Governance: Skill Resources
Route(
"/api/admin/skills/{skill_id}/resources",
admin_list_skill_resources,
),
Route(
"/api/admin/skills/{skill_id}/resources",
admin_create_skill_resource,
methods=["POST"],
),
Route(
"/api/admin/skills/{skill_id}/resources/{path:path}",
admin_get_skill_resource,
),
Route(
"/api/admin/skills/{skill_id}/resources/{path:path}",
admin_delete_skill_resource,
methods=["DELETE"],
),
# Governance: Memories
Route("/api/admin/memories", admin_list_memories),
Route("/api/admin/memories/search", admin_search_memories),
+363 -29
View File
@@ -14,6 +14,7 @@ var _govAuditOffset = 0;
var _skillCurrentView = "installed";
var _skillDiscoverResults = [];
var _skillDiscoverQuery = "";
var _pendingResources = [];
var _giTrapHandler = null;
var _giTriggerEl = null;
@@ -687,13 +688,6 @@ function _renderGovSkills(items) {
var html = "";
for (var i = 0; i < items.length; i++) {
var t = items[i];
var vars = "";
try {
var vlist = JSON.parse(t.variables || "[]");
vars = vlist.join(", ");
} catch (e) {
vars = t.variables;
}
var activationBadge = "";
var activation = t.activation || "named";
if (activation === "default") {
@@ -714,7 +708,8 @@ function _renderGovSkills(items) {
: "";
var catBadge =
'<span class="scope-badge">' + escapeHtml(t.category) + "</span>";
var scanBadge = "";
// Build risk column content with tooltip
var riskCell = "";
if (t.scan_status) {
var scanClass =
{
@@ -724,36 +719,78 @@ function _renderGovSkills(items) {
high: "scope-scan-high",
critical: "scope-scan-critical",
}[t.scan_status] || "";
scanBadge =
' <span class="scope-badge ' +
var scanIcon =
{
safe: "\u2713 ",
low: "",
medium: "\u25B2 ",
high: "\u25C6 ",
critical: "\u26A0 ",
}[t.scan_status] || "";
var tipParts = [];
try {
var report = JSON.parse(t.scan_report || "{}");
if (report.composite != null) {
tipParts.push("Score: " + report.composite.toFixed(2));
}
var axes = ["content", "supply_chain", "vulnerability", "capability"];
for (var ai = 0; ai < axes.length; ai++) {
var d = (report.details || {})[axes[ai]] || {};
if (d.flags && d.flags.length) {
tipParts.push(
axes[ai].replace(/_/g, " ") + ": " + d.flags.join(", "),
);
}
}
} catch (e) {}
var tipText = tipParts.length ? tipParts.join("\n") : t.scan_status;
riskCell =
'<span class="scope-badge ' +
scanClass +
'">' +
'" tabindex="0" role="button" aria-label="Risk: ' +
escapeHtml(t.scan_status) +
(tipParts.length ? ". " + escapeHtml(tipParts.join(". ")) : "") +
'" title="' +
escapeHtml(tipText) +
'">' +
escapeHtml(scanIcon + t.scan_status) +
"</span>";
} else {
riskCell =
'<span class="scope-badge" style="opacity:0.4" title="Not scanned">\u2014</span>';
}
var resBadge = "";
if (t.resource_count > 0) {
resBadge =
' <span class="scope-badge" title="' +
t.resource_count +
' bundled resource(s)">' +
t.resource_count +
" res</span>";
}
var editDisabled = t.readonly ? " disabled" : "";
var deleteDisabled = t.readonly ? " disabled" : "";
var deleteDisabled = "";
html +=
'<div class="admin-row" role="listitem">' +
'<span class="admin-col admin-col-tmcat">' +
catBadge +
"</span>" +
'<span class="admin-col admin-col-tmname">' +
escapeHtml(t.name) +
" " +
activationBadge +
defBadge +
originBadge +
scanBadge +
resBadge +
(t.description
? '<br><span class="admin-col-subtitle">' +
escapeHtml(t.description) +
"</span>"
: "") +
"</span>" +
'<span class="admin-col admin-col-tmcat">' +
catBadge +
'<span class="admin-col admin-col-tmrisk">' +
riskCell +
"</span>" +
'<span class="admin-col admin-col-tmvars"><code>' +
escapeHtml(vars || "\u2014") +
"</code></span>" +
'<span class="admin-col admin-col-actions">' +
'<button class="admin-btn-action" data-edit-tmpl="' +
escapeHtml(t.template_id) +
@@ -857,6 +894,9 @@ function showCreateTemplateModal() {
document.getElementById("csk-allowed-tools").disabled = this.checked;
});
document.getElementById("create-template-error").style.display = "none";
// Clear resource list
_pendingResources = [];
_renderPendingResources();
document.getElementById("ctm-name").focus();
_ctmTrapHandler = _installTrap(
"create-template-overlay",
@@ -942,10 +982,39 @@ function submitCreateTemplate() {
});
return r.json();
})
.then(function () {
hideCreateTemplateModal();
showToast("Skill created");
loadGovSkills();
.then(function (data) {
if (_pendingResources.length && data && data.template_id) {
var promises = _pendingResources.map(function (res) {
return authFetch(
"/v1/api/admin/skills/" + data.template_id + "/resources",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(res),
},
).then(function (r) {
if (!r.ok) throw new Error("Upload failed for " + res.path);
return r.json();
});
});
Promise.all(promises)
.then(function () {
hideCreateTemplateModal();
showToast(
"Skill created with " + _pendingResources.length + " resource(s)",
);
loadGovSkills();
})
.catch(function () {
hideCreateTemplateModal();
showToast("Skill created (some resources failed)");
loadGovSkills();
});
} else {
hideCreateTemplateModal();
showToast("Skill created");
loadGovSkills();
}
})
.catch(function (e) {
var el = document.getElementById("create-template-error");
@@ -1111,6 +1180,11 @@ function showEditTemplateModal(tmplId) {
});
};
}
// --- Skill Resources ---
var resSection = document.getElementById("etm-resources-section");
if (resSection) {
_loadSkillResources(tmplId, tmpl.readonly || false);
}
_etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box");
}
@@ -1123,6 +1197,245 @@ function hideEditTemplateModal() {
_etmTriggerEl = null;
}
// ---------------------------------------------------------------------------
// Skill Resources
// ---------------------------------------------------------------------------
function _loadSkillResources(skillId, readonly) {
var container = document.getElementById("etm-resources-list");
var addBtn = document.getElementById("etm-add-resource-btn");
var addForm = document.getElementById("etm-add-resource-form");
if (!container) return;
container.innerHTML = '<div class="dashboard-empty">Loading...</div>';
if (addBtn) addBtn.style.display = readonly ? "none" : "";
if (addForm) addForm.style.display = "none";
authFetch("/v1/api/admin/skills/" + skillId + "/resources")
.then(function (r) {
if (!r.ok) throw new Error("Failed");
return r.json();
})
.then(function (data) {
var resources = data.resources || [];
if (!resources.length) {
container.innerHTML =
'<div class="dashboard-empty">No resource files</div>';
return;
}
var html = "";
for (var i = 0; i < resources.length; i++) {
var res = resources[i];
var sizeStr =
res.size > 1024
? (res.size / 1024).toFixed(1) + " KB"
: res.size + " B";
html +=
'<div role="listitem" style="display:flex;align-items:center;padding:4px 0;gap:8px">' +
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><code>' +
escapeHtml(res.path) +
"</code></span>" +
'<span style="width:80px;text-align:right;opacity:0.6">' +
sizeStr +
"</span>" +
'<span style="width:60px;text-align:right">' +
(readonly
? ""
: '<button class="admin-btn-danger" data-del-res="' +
escapeHtml(res.path) +
'" style="font-size:0.85em" aria-label="Delete resource ' +
escapeHtml(res.path) +
'">delete</button>') +
"</span></div>";
}
container.innerHTML = html;
if (!readonly) {
container.querySelectorAll("[data-del-res]").forEach(function (btn) {
btn.addEventListener("click", function () {
var path = this.getAttribute("data-del-res");
showConfirmModal(
"Delete Resource",
'Delete "' + path + '"?',
"Delete",
function () {
authFetch(
"/v1/api/admin/skills/" +
skillId +
"/resources/" +
path.split("/").map(encodeURIComponent).join("/"),
{ method: "DELETE" },
)
.then(function (r) {
if (!r.ok) throw new Error();
return r.json();
})
.then(function () {
showToast("Resource deleted");
_loadSkillResources(skillId, readonly);
loadGovSkills();
var addBtn = document.getElementById(
"etm-add-resource-btn",
);
if (addBtn) addBtn.focus();
})
.catch(function () {
showToast("Failed to delete resource");
});
},
);
});
});
}
})
.catch(function () {
container.innerHTML =
'<div class="dashboard-empty">Failed to load resources</div>';
});
}
function _showAddResourceForm(skillId) {
var form = document.getElementById("etm-add-resource-form");
if (!form) return;
form.style.display = "";
document.getElementById("etm-res-path").value = "";
document.getElementById("etm-res-content").value = "";
document.getElementById("etm-res-content-type").value = "text/plain";
document.getElementById("etm-res-submit").onclick = function () {
var path = (document.getElementById("etm-res-path").value || "").trim();
var content = document.getElementById("etm-res-content").value || "";
var contentType = document.getElementById("etm-res-content-type").value;
if (!path || !content) {
showToast("Path and content are required");
return;
}
if (
!path.startsWith("scripts/") &&
!path.startsWith("references/") &&
!path.startsWith("assets/")
) {
showToast("Path must start with scripts/, references/, or assets/");
return;
}
this.disabled = true;
this.textContent = "Uploading\u2026";
authFetch("/v1/api/admin/skills/" + skillId + "/resources", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: path,
content: content,
content_type: contentType,
}),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Failed");
});
return r.json();
})
.then(function () {
showToast("Resource added");
form.style.display = "none";
_loadSkillResources(skillId, false);
loadGovSkills();
})
.catch(function (e) {
showToast(e.message || "Failed to add resource");
})
.finally(function () {
var btn = document.getElementById("etm-res-submit");
if (btn) {
btn.disabled = false;
btn.textContent = "Upload";
}
});
};
}
// ---------------------------------------------------------------------------
// Pending resources (create modal)
// ---------------------------------------------------------------------------
function _renderPendingResources() {
var container = document.getElementById("ctm-resources-list");
if (!container) return;
if (!_pendingResources.length) {
container.innerHTML =
'<div class="dashboard-empty">No resource files yet</div>';
return;
}
var html = "";
for (var i = 0; i < _pendingResources.length; i++) {
var r = _pendingResources[i];
var sizeStr =
r.content.length > 1024
? (r.content.length / 1024).toFixed(1) + " KB"
: r.content.length + " B";
html +=
'<div role="listitem" style="display:flex;align-items:center;padding:4px 0;gap:8px">' +
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><code>' +
escapeHtml(r.path) +
"</code></span>" +
'<span style="width:80px;text-align:right;opacity:0.6">' +
sizeStr +
"</span>" +
'<span style="width:60px;text-align:right">' +
'<button class="admin-btn-danger" data-remove-res="' +
i +
'" style="font-size:0.85em" aria-label="Remove resource ' +
escapeHtml(r.path) +
'">remove</button>' +
"</span></div>";
}
container.innerHTML = html;
container.querySelectorAll("[data-remove-res]").forEach(function (btn) {
btn.addEventListener("click", function () {
var idx = parseInt(this.getAttribute("data-remove-res"), 10);
_pendingResources.splice(idx, 1);
_renderPendingResources();
});
});
}
function _addPendingResource() {
var path = (document.getElementById("ctm-res-path").value || "").trim();
var content = document.getElementById("ctm-res-content").value || "";
var contentType = document.getElementById("ctm-res-content-type").value;
if (!path || !content) {
showToast("Path and content are required");
return;
}
if (
!path.startsWith("scripts/") &&
!path.startsWith("references/") &&
!path.startsWith("assets/")
) {
showToast("Path must start with scripts/, references/, or assets/");
return;
}
if (
_pendingResources.some(function (r) {
return r.path === path;
})
) {
showToast("Resource path already added");
return;
}
if (_pendingResources.length >= 10) {
showToast("Maximum 10 resources per skill");
return;
}
_pendingResources.push({
path: path,
content: content,
content_type: contentType,
});
document.getElementById("ctm-res-path").value = "";
document.getElementById("ctm-res-content").value = "";
_renderPendingResources();
document.getElementById("ctm-res-path").focus();
}
function submitEditTemplate() {
var id = document.getElementById("etm-id").value;
var content = document.getElementById("etm-content").value;
@@ -1762,6 +2075,10 @@ function switchSkillView(view) {
function searchSkillDiscover() {
var q = (document.getElementById("skill-discover-q").value || "").trim();
if (!q) {
showToast("Enter a search query");
return;
}
_skillDiscoverResults = [];
_skillDiscoverQuery = q;
@@ -1931,13 +2248,14 @@ function installDiscoveredSkill(skill) {
return r.json();
})
.then(function (data) {
var tierMsg = data.scan_status ? " [" + data.scan_status + "]" : "";
var first = (data.installed && data.installed[0]) || {};
var tierMsg = first.scan_status ? " [" + first.scan_status + "]" : "";
showToast("Skill installed: " + (skill.name || skill.id) + tierMsg);
// Mark as installed in results with scan data
for (var j = 0; j < _skillDiscoverResults.length; j++) {
if (_skillDiscoverResults[j].id === skill.id) {
_skillDiscoverResults[j].installed = true;
_skillDiscoverResults[j].scan_status = data.scan_status || "";
_skillDiscoverResults[j].scan_status = first.scan_status || "";
break;
}
}
@@ -2003,12 +2321,28 @@ function submitGitHubImport() {
})
.then(function (data) {
hideGitHubImportModal();
var tierMsg = data.scan_status ? " [" + data.scan_status + "]" : "";
showToast("Skill installed: " + (data.name || "") + tierMsg);
// Refresh if we're on discover view
if (_skillCurrentView === "discover") {
searchSkillDiscover();
var count = data.installed.length;
var skipCount = (data.skipped || []).length;
var msg;
if (count === 1 && !skipCount) {
var name = data.installed[0].name || "";
var tierMsg = data.installed[0].scan_status
? " [" + data.installed[0].scan_status + "]"
: "";
msg = "Skill installed: " + name + tierMsg;
} else if (count === 0 && skipCount) {
msg =
"All " +
skipCount +
" skill" +
(skipCount !== 1 ? "s" : "") +
" already installed";
} else {
msg = count + " skill" + (count !== 1 ? "s" : "") + " installed";
if (skipCount) msg += " (" + skipCount + " already installed)";
}
showToast(msg);
loadGovSkills();
})
.catch(function (e) {
errEl.textContent = e.message;
+36 -4
View File
@@ -95,7 +95,11 @@
<div class="admin-sidebar-group-label" aria-hidden="true">Governance</div>
<button id="tab-roles" class="admin-nav" data-tab="roles" role="tab" aria-selected="false" aria-controls="admin-roles" tabindex="-1" onclick="switchAdminTab('roles')">Roles</button>
<button id="tab-policies" class="admin-nav" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
</div>
<div class="admin-sidebar-group" data-group="extensions" role="group" aria-label="Extensions">
<div class="admin-sidebar-group-label" aria-hidden="true">Extensions</div>
<button id="tab-skills" class="admin-nav" data-tab="skills" role="tab" aria-selected="false" aria-controls="admin-skills" tabindex="-1" onclick="switchAdminTab('skills')">Skills</button>
<button id="tab-mcp" class="admin-nav" data-tab="mcp" role="tab" aria-selected="false" aria-controls="admin-mcp" tabindex="-1" onclick="switchAdminTab('mcp')">MCP Servers</button>
</div>
<div class="admin-sidebar-group" data-group="observe" role="group" aria-label="Observe">
<div class="admin-sidebar-group-label" aria-hidden="true">Observe</div>
@@ -106,7 +110,6 @@
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
<button id="tab-mcp" class="admin-nav" data-tab="mcp" role="tab" aria-selected="false" aria-controls="admin-mcp" tabindex="-1" onclick="switchAdminTab('mcp')">MCP Servers</button>
</div>
</nav>
<div id="admin-sidebar-backdrop" class="admin-sidebar-backdrop" aria-hidden="true"></div>
@@ -269,9 +272,9 @@
<!-- Installed view -->
<div id="skill-view-installed" role="tabpanel" aria-labelledby="skill-tab-installed">
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-tmname">NAME</span>
<span class="admin-col admin-col-tmcat">CATEGORY</span>
<span class="admin-col admin-col-tmvars">VARIABLES</span>
<span class="admin-col admin-col-tmname">NAME</span>
<span class="admin-col admin-col-tmrisk">RISK</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-skills-table" role="list" aria-label="Skills" aria-live="polite">
@@ -406,7 +409,7 @@
<span class="section-header">MCP</span>
<div class="mcp-view-toggle" role="tablist" aria-label="MCP view">
<button class="mcp-view-btn active" data-mcp-view="servers" role="tab" aria-selected="true" aria-controls="mcp-view-servers" tabindex="0" onclick="switchMcpView('servers')">Servers</button>
<button class="mcp-view-btn" data-mcp-view="registry" role="tab" aria-selected="false" aria-controls="mcp-view-registry" tabindex="-1" onclick="switchMcpView('registry')">Registry</button>
<button class="mcp-view-btn" data-mcp-view="registry" role="tab" aria-selected="false" aria-controls="mcp-view-registry" tabindex="-1" onclick="switchMcpView('registry')">Discover</button>
</div>
<span id="mcp-servers-toolbar">
<button id="mcp-sync-btn" class="admin-action-btn admin-action-btn-ghost" onclick="reloadMcpNodes()" title="Push MCP server config to all cluster nodes and reconnect">Sync to Nodes</button>
@@ -898,6 +901,19 @@ window.TURNSTONE_KB_SHORTCUTS = [
<input id="csk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
<label class="admin-checkbox"><input id="csk-enabled" type="checkbox" checked> Enabled</label>
</details>
<details class="admin-details">
<summary>Resources <span class="label-hint">optional bundled files (scripts, references, assets)</span></summary>
<div id="ctm-resources-list" role="list" aria-live="polite" aria-label="Pending resources"></div>
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">
<label for="ctm-res-path">Path</label>
<input id="ctm-res-path" type="text" placeholder="scripts/setup.sh or references/guide.md">
<label for="ctm-res-content-type">Content Type</label>
<input id="ctm-res-content-type" type="text" value="text/plain">
<label for="ctm-res-content">Content</label>
<textarea id="ctm-res-content" rows="4" placeholder="Resource file content"></textarea>
<button type="button" class="admin-btn-action" onclick="_addPendingResource()">Add Resource</button>
</div>
</details>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideCreateTemplateModal()">Cancel</button>
<button id="ctm-submit" class="modal-submit" onclick="submitCreateTemplate()">Create</button>
@@ -966,6 +982,22 @@ window.TURNSTONE_KB_SHORTCUTS = [
<div id="etm-scan-report" aria-labelledby="etm-scan-heading"></div>
<button type="button" id="etm-rescan-btn" class="admin-btn-action" style="margin-top:8px">Re-scan</button>
</div>
<details id="etm-resources-section" class="admin-details">
<summary>Resources <span class="label-hint">bundled files for this skill</span></summary>
<div id="etm-resources-list" role="list" aria-live="polite" aria-label="Skill resources"></div>
<button type="button" id="etm-add-resource-btn" class="admin-btn-action" style="margin-top:8px" onclick="_showAddResourceForm(document.getElementById('etm-id').value)">Add Resource</button>
<div id="etm-add-resource-form" style="display:none">
<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">
<label for="etm-res-path">Path</label>
<input id="etm-res-path" type="text" placeholder="scripts/setup.sh or references/guide.md">
<label for="etm-res-content-type">Content Type</label>
<input id="etm-res-content-type" type="text" value="text/plain">
<label for="etm-res-content">Content</label>
<textarea id="etm-res-content" rows="4" placeholder="Resource file content"></textarea>
<button type="button" id="etm-res-submit" class="admin-btn-action">Upload</button>
</div>
</div>
</details>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideEditTemplateModal()">Cancel</button>
<button id="etm-submit" class="modal-submit" onclick="submitEditTemplate()">Save</button>
+2 -2
View File
@@ -1367,7 +1367,7 @@
========================================================================== */
#admin-skills .admin-colheaders,
#admin-skills .admin-row {
grid-template-columns: 1.5fr 100px 1fr 140px;
grid-template-columns: 80px 1.5fr 80px 120px;
}
/* ==========================================================================
Governance: Audit grid
@@ -1652,7 +1652,7 @@
#admin-skills .admin-colheaders, #admin-skills .admin-row {
grid-template-columns: 1fr 100px;
}
.admin-col-tmcat, .admin-col-tmvars { display: none; }
.admin-col-tmcat, .admin-col-tmrisk { display: none; }
#admin-audit .admin-colheaders, #admin-audit .admin-row {
grid-template-columns: 60px 1fr 100px;
}
+10
View File
@@ -44,12 +44,19 @@ NUDGE_START = (
"user's request to find applicable context, preferences, or guidance."
)
NUDGE_TOOL_ERROR = (
"A tool just returned an error. Before retrying, check your memories — "
"the user may have given feedback about this tool or error pattern in a "
"previous session. Use memory(action='search') to find relevant guidance."
)
_NUDGE_MAP: dict[str, str] = {
"correction": NUDGE_CORRECTION,
"denial": NUDGE_DENIAL,
"resume": NUDGE_RESUME,
"completion": NUDGE_COMPLETION,
"start": NUDGE_START,
"tool_error": NUDGE_TOOL_ERROR,
}
# ---------------------------------------------------------------------------
@@ -153,6 +160,9 @@ def should_nudge(
# Start nudge only on first message
if nudge_type == "start" and message_count != 1:
return False
# Tool error nudge only if there are memories to search
if nudge_type == "tool_error" and memory_count == 0:
return False
# Resume/start nudge only if there are memories to recall
if nudge_type in ("resume", "start") and memory_count == 0:
return False
+5 -4
View File
@@ -54,7 +54,10 @@ _RE_CONNECTION_STRING = re.compile(
r"(?:postgresql|mysql|mongodb|redis|amqp)://[^:@\s]+:[^@\s]+@",
)
_RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+")
_RE_ENV_SECRET_KEY = re.compile(r"SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL", re.IGNORECASE)
_RE_ENV_SECRET_KEY = re.compile(
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL)(?:_|$)|(?:^|_)KEY(?:_|$)",
re.IGNORECASE,
)
# (pattern, redact_label) — ordered most-specific first for redaction.
_CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
@@ -218,9 +221,7 @@ def _check_credentials(
risk = "high"
env_lines = _RE_ENV_SECRET_LINE.findall(text)
if len(env_lines) >= 3 and any(
_RE_ENV_SECRET_KEY.search(ln.split("=", 1)[0]) for ln in env_lines
):
if any(_RE_ENV_SECRET_KEY.search(ln.split("=", 1)[0]) for ln in env_lines):
_add_flag(flags, "credential_leak")
flags.append("env_file_leak")
ann.append("Output contains .env-style assignments with secret-bearing keys.")
+75 -1
View File
@@ -347,6 +347,7 @@ class ChatSession:
# Skill: explicit name overrides is_default skills
self._skill_name: str | None = skill
self._skill_content: str | None = None
self._skill_resources: dict[str, str] = {}
self._load_skills()
self._init_system_messages()
self._save_config()
@@ -405,6 +406,9 @@ class ChatSession:
if skill_data:
self._skill_content = _render_template(skill_data["content"], context)
self._check_skill_budget(skill_data)
self._skill_resources = self._load_skill_resources(
skill_data.get("template_id", "")
)
if skill_data.get("scan_status") in ("high", "critical"):
scan_tier = skill_data["scan_status"]
log.warning(
@@ -419,6 +423,7 @@ class ChatSession:
else:
log.warning("skill.not_found", name=self._skill_name)
self._skill_content = None
self._skill_resources = {}
else:
defaults = list_default_skills()
if defaults:
@@ -426,6 +431,7 @@ class ChatSession:
self._skill_content = "\n\n".join(parts)
else:
self._skill_content = None
self._skill_resources = {}
def set_skill(self, name: str | None) -> None:
"""Set or clear the active skill."""
@@ -444,6 +450,18 @@ class ChatSession:
context_window=self.context_window,
)
def _load_skill_resources(self, skill_id: str) -> dict[str, str]:
"""Load bundled resources for a skill and return {path: content}."""
if not skill_id:
return {}
try:
storage = get_storage()
rows = storage.list_skill_resources(skill_id)
return {r["path"]: r.get("content", "") for r in rows}
except Exception:
log.warning("skill_resources.load_failed", skill_id=skill_id, exc_info=True)
return {}
# -- MCP tool refresh ----------------------------------------------------
def _on_mcp_tools_changed(self) -> None:
@@ -820,6 +838,24 @@ class ChatSession:
tpl = tpl[:_MAX_SKILL_CONTENT]
dev_parts.append("")
dev_parts.append(tpl)
if self._skill_resources:
lines = ["<skill-resources>"]
total_size = 0
for rpath, rcontent in sorted(self._skill_resources.items()):
size_kb = f"{len(rcontent) / 1024:.1f}KB"
total_size += len(rcontent)
lines.append(f"- {rpath} ({size_kb})")
if total_size <= 8192:
for rpath, rcontent in sorted(self._skill_resources.items()):
lines.append(f"\n--- {rpath} ---")
lines.append(rcontent)
else:
lines.append(
"Resource content omitted (total exceeds 8KB). "
"Resource files are listed above by path and size."
)
lines.append("</skill-resources>")
dev_parts.append("\n".join(lines))
if self.instructions:
dev_parts.append("")
dev_parts.append(self.instructions)
@@ -1176,6 +1212,29 @@ class ChatSession:
_tname,
tool_call_id=tc_id,
)
# Metacognitive nudge: check memories on tool error
if (
self._memory_config.nudges
and any(
isinstance(out, str)
and (
out.startswith("Error")
or " error: " in out[:50]
or out.startswith("Command timed out")
or out.startswith("Unknown tool:")
)
for _, out in results
)
and should_nudge(
"tool_error",
self._metacog_state,
message_count=len(self.messages),
memory_count=self._visible_memory_count(),
cooldown_secs=self._memory_config.nudge_cooldown,
)
):
self._pending_nudge.append(format_nudge("tool_error"))
self._init_system_messages()
# Inject user feedback from approval prompt (e.g. "y, use full path")
if user_feedback:
self.messages.append({"role": "user", "content": user_feedback})
@@ -1873,9 +1932,24 @@ class ChatSession:
it["func_args"] = {"command": it.get("command", "")}
elif name in ("write_file", "edit_file", "read_file"):
it["func_args"] = {"path": it.get("path", "")}
elif name == "web_fetch":
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":
it["func_args"] = {"action": it.get("action", ""), "name": it.get("name", "")}
elif name == "watch":
it["func_args"] = {
"action": it.get("action", ""),
"command": it.get("command", ""),
"name": it.get("watch_name", ""),
}
elif name == "notify":
it["func_args"] = {"message": it.get("message", "")[:200]}
elif name == "task":
it["func_args"] = {"prompt": it.get("prompt", "")[:200]}
elif it.get("mcp_args"):
it["func_args"] = it["mcp_args"]
# Other tools: func_args stays absent → judge defaults to {}
def _on_verdict(verdict: object) -> None:
"""Callback from the daemon judge thread."""
+213 -43
View File
@@ -6,6 +6,7 @@ and :func:`fetch_skill_from_github` for fetching SKILL.md from GitHub repos.
from __future__ import annotations
import asyncio
import logging
import os
import re
@@ -120,22 +121,91 @@ class SkillsShClient:
return url
def _parse_github_url(url: str) -> tuple[str, str, str, str]:
"""Parse a GitHub URL into (owner, repo, branch, path).
def _parse_github_url(url: str) -> tuple[str, str, str, str, bool]:
"""Parse a GitHub URL into (owner, repo, branch, path, branch_explicit).
Returns ("", "", "", "") if URL doesn't match.
Returns ("", "", "", "", False) if URL doesn't match.
"""
m = _GITHUB_URL_RE.match(url)
if not m:
return ("", "", "", "")
return ("", "", "", "", False)
return (
m.group("owner"),
m.group("repo"),
m.group("branch") or "main",
m.group("path") or "",
bool(m.group("branch")),
)
def _find_resource_files(
tree_items: list[dict[str, Any]], skill_md_dir: str
) -> list[dict[str, str]]:
"""Filter tree items to resource files relative to a SKILL.md directory."""
resource_files: list[dict[str, str]] = []
for item in tree_items:
if item.get("type") != "blob":
continue
item_path: str = item.get("path", "")
rel_path = item_path
if skill_md_dir:
if not item_path.startswith(f"{skill_md_dir}/"):
continue
rel_path = item_path[len(skill_md_dir) + 1 :]
first_seg = rel_path.split("/")[0] if "/" in rel_path else ""
if first_seg not in _RESOURCE_DIRS:
continue
ext = os.path.splitext(rel_path)[1].lower()
if ext not in _TEXT_EXTENSIONS:
continue
size = item.get("size", 0)
if size > _MAX_RESOURCE_SIZE:
continue
resource_files.append({"path": rel_path, "full_path": item_path})
return resource_files[:_MAX_RESOURCE_FILES]
def _check_rate_limit(resp: httpx.Response) -> None:
"""Raise SkillSourceError with guidance if GitHub rate limit is hit."""
if resp.status_code == 403:
remaining = resp.headers.get("x-ratelimit-remaining", "")
if remaining == "0":
raise SkillSourceError(
"GitHub API rate limit exceeded. "
"Set TURNSTONE_GITHUB_TOKEN env var for higher limits (5000 req/hr)."
)
remaining = resp.headers.get("x-ratelimit-remaining", "")
if remaining and remaining.isdigit() and int(remaining) < 10:
logger.warning("GitHub API rate limit low: %s remaining", remaining)
_FETCH_CONCURRENCY = 5
async def _fetch_resource_contents(
client: httpx.AsyncClient,
raw_base: str,
resource_files: list[dict[str, str]],
) -> dict[str, str]:
"""Fetch content for a list of resource files (concurrent)."""
if not resource_files:
return {}
sem = asyncio.Semaphore(_FETCH_CONCURRENCY)
async def _fetch_one(rf: dict[str, str]) -> tuple[str, str] | None:
async with sem:
try:
resp = await client.get(f"{raw_base}/{rf['full_path']}")
if resp.status_code == 200:
return rf["path"], resp.text
except httpx.HTTPError:
pass
return None
results = await asyncio.gather(*[_fetch_one(rf) for rf in resource_files])
return {path: content for r in results if r is not None for path, content in [r]}
async def fetch_skill_from_github(url: str) -> SkillPackage:
"""Fetch a SKILL.md and bundled resources from a GitHub repository.
@@ -147,7 +217,7 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
Uses ``TURNSTONE_GITHUB_TOKEN`` env var for authenticated requests
(60 5000 req/hr rate limit headroom).
"""
owner, repo, branch, path = _parse_github_url(url)
owner, repo, branch, path, branch_explicit = _parse_github_url(url)
if not owner:
raise SkillSourceError(f"Could not parse GitHub URL: {url}")
@@ -157,7 +227,6 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
headers["Authorization"] = f"Bearer {token}"
# When branch isn't specified in URL, try main then master
branch_explicit = bool(_GITHUB_URL_RE.match(url) and _GITHUB_URL_RE.match(url).group("branch")) # type: ignore[union-attr]
branches_to_try = [branch] if branch_explicit else ["main", "master"]
api_base = f"https://api.github.com/repos/{owner}/{repo}"
@@ -187,7 +256,10 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
skill_md_content = ""
skill_md_dir = ""
resolved_branch = branch
async with httpx.AsyncClient(follow_redirects=True, timeout=15.0, headers=headers) as client:
_timeout = httpx.Timeout(10.0, connect=5.0)
async with httpx.AsyncClient(
follow_redirects=True, timeout=_timeout, headers=headers
) as client:
# Try each branch × candidate combination
for try_branch in branches_to_try:
raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{try_branch}"
@@ -195,10 +267,9 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
try:
resp = await client.get(f"{raw_base}/{candidate}")
if resp.status_code == 200:
content_len = int(resp.headers.get("content-length", "0"))
if content_len > _MAX_SKILL_MD_SIZE:
if len(resp.content) > _MAX_SKILL_MD_SIZE:
continue
skill_md_content = resp.text[:_MAX_SKILL_MD_SIZE]
skill_md_content = resp.text
# Directory containing the SKILL.md
parts = candidate.rsplit("/", 1)
skill_md_dir = parts[0] if len(parts) > 1 else ""
@@ -224,51 +295,150 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
f"{api_base}/git/trees/{resolved_branch}",
params={"recursive": "1"},
)
_check_rate_limit(tree_resp)
if tree_resp.status_code == 200 and len(tree_resp.content) < 2 * 1024 * 1024:
tree_data = tree_resp.json()
resource_files: list[dict[str, Any]] = []
for item in tree_data.get("tree", []):
if item.get("type") != "blob":
continue
item_path: str = item.get("path", "")
# Filter to resource dirs relative to SKILL.md location
rel_path = item_path
if skill_md_dir:
if not item_path.startswith(f"{skill_md_dir}/"):
continue
rel_path = item_path[len(skill_md_dir) + 1 :]
# Check if it's in a resource directory
first_seg = rel_path.split("/")[0] if "/" in rel_path else ""
if first_seg not in _RESOURCE_DIRS:
continue
# Filter to text-safe extensions only
ext = os.path.splitext(rel_path)[1].lower()
if ext not in _TEXT_EXTENSIONS:
continue
size = item.get("size", 0)
if size > _MAX_RESOURCE_SIZE:
continue
resource_files.append({"path": rel_path, "full_path": item_path})
# Fetch up to MAX files
for rf in resource_files[:_MAX_RESOURCE_FILES]:
try:
content_resp = await client.get(f"{raw_base}/{rf['full_path']}")
if content_resp.status_code == 200:
resources[rf["path"]] = content_resp.text
except httpx.HTTPError:
continue
rf = _find_resource_files(tree_data.get("tree", []), skill_md_dir)
resources = await _fetch_resource_contents(client, raw_base, rf)
except httpx.HTTPError:
logger.debug("Failed to fetch resource tree for %s/%s", owner, repo)
# Build a per-skill source URL pointing to the specific subdirectory
if skill_md_dir:
specific_url = f"https://github.com/{owner}/{repo}/tree/{resolved_branch}/{skill_md_dir}"
else:
specific_url = url
listing = SkillListing(
id=f"{owner}/{repo}/{parsed.name}",
name=parsed.name,
description=parsed.description,
author=parsed.author,
source="github",
source_url=url,
source_url=specific_url,
tags=parsed.tags,
)
return SkillPackage(listing=listing, parsed=parsed, resources=resources)
_MAX_SKILLS_PER_REPO = 50
async def fetch_skills_from_github_repo(url: str) -> list[SkillPackage]:
"""Scan a GitHub repo for all SKILL.md files and return each as a package.
Used when a repo-level URL has no root SKILL.md (monorepo pattern).
"""
owner, repo, branch, url_path, branch_explicit = _parse_github_url(url)
if not owner:
raise SkillSourceError(f"Could not parse GitHub URL: {url}")
url_path = url_path.rstrip("/")
headers: dict[str, str] = {"Accept": "application/vnd.github.v3+json"}
token = os.environ.get("TURNSTONE_GITHUB_TOKEN", "")
if token:
headers["Authorization"] = f"Bearer {token}"
branches_to_try = [branch] if branch_explicit else ["main", "master"]
api_base = f"https://api.github.com/repos/{owner}/{repo}"
_timeout = httpx.Timeout(10.0, connect=5.0)
async with httpx.AsyncClient(
follow_redirects=True, timeout=_timeout, headers=headers
) as client:
# Find the tree with all SKILL.md files
tree_data: dict[str, Any] = {}
resolved_branch = branch
for try_branch in branches_to_try:
try:
resp = await client.get(
f"{api_base}/git/trees/{try_branch}",
params={"recursive": "1"},
)
_check_rate_limit(resp)
if resp.status_code == 200 and len(resp.content) < 2 * 1024 * 1024:
tree_data = resp.json()
resolved_branch = try_branch
break
except httpx.HTTPError:
continue
if not tree_data:
raise SkillSourceError(f"Could not fetch repo tree for {owner}/{repo}")
# Find all SKILL.md files in the tree (filtered to URL path if provided)
skill_md_paths: list[str] = []
tree_items = tree_data.get("tree", [])
for item in tree_items:
if item.get("type") != "blob":
continue
p: str = item.get("path", "")
if not (p.endswith("/SKILL.md") or p == "SKILL.md"):
continue
if url_path and not p.startswith(f"{url_path}/") and p != url_path:
continue
skill_md_paths.append(p)
if not skill_md_paths:
raise SkillNotFoundError(f"No SKILL.md files found in {owner}/{repo}")
# Cap to prevent abuse
skill_md_paths = skill_md_paths[:_MAX_SKILLS_PER_REPO]
raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{resolved_branch}"
# Fetch all SKILL.md files concurrently
sem = asyncio.Semaphore(_FETCH_CONCURRENCY)
async def _fetch_skill_md(p: str) -> tuple[str, str] | None:
async with sem:
try:
r = await client.get(f"{raw_base}/{p}")
if r.status_code == 200 and len(r.content) <= _MAX_SKILL_MD_SIZE:
return p, r.text
except httpx.HTTPError:
pass
return None
md_results = await asyncio.gather(*[_fetch_skill_md(p) for p in skill_md_paths])
packages: list[SkillPackage] = []
for result in md_results:
if result is None:
continue
skill_md_path, content = result
# Determine directory containing this SKILL.md
parts = skill_md_path.rsplit("/", 1)
skill_md_dir = parts[0] if len(parts) > 1 else ""
# Parse — skip if invalid
try:
parsed = parse_skill_md(content)
except ValueError:
logger.debug("Skipping invalid SKILL.md at %s", skill_md_path)
continue
# Collect resources for this skill (concurrent via helper)
rf = _find_resource_files(tree_items, skill_md_dir)
resources = await _fetch_resource_contents(client, raw_base, rf)
specific_url = (
f"https://github.com/{owner}/{repo}/tree/{resolved_branch}/{skill_md_dir}"
if skill_md_dir
else url
)
listing = SkillListing(
id=f"{owner}/{repo}/{parsed.name}",
name=parsed.name,
description=parsed.description,
author=parsed.author,
source="github",
source_url=specific_url,
tags=parsed.tags,
)
packages.append(SkillPackage(listing=listing, parsed=parsed, resources=resources))
return packages
+27
View File
@@ -1773,6 +1773,33 @@ class PostgreSQLBackend:
conn.commit()
return result.rowcount
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(skill_resources).where(
sa.and_(
skill_resources.c.skill_id == skill_id,
skill_resources.c.path == path,
)
)
)
conn.commit()
return result.rowcount > 0
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
if not skill_ids:
return {}
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(
skill_resources.c.skill_id,
sa.func.count().label("cnt"),
)
.where(skill_resources.c.skill_id.in_(skill_ids))
.group_by(skill_resources.c.skill_id)
).fetchall()
return {r[0]: r[1] for r in rows}
# -- Skill versions --------------------------------------------------------
def create_skill_version(
+8
View File
@@ -658,6 +658,14 @@ class StorageBackend(Protocol):
"""Delete all resource files for a skill. Returns count deleted."""
...
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
"""Delete a single resource file by skill_id and path. Returns True if found."""
...
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
"""Count resources per skill in a single query. Returns {skill_id: count}."""
...
# -- Skill versions --------------------------------------------------------
def create_skill_version(
+34
View File
@@ -1797,6 +1797,40 @@ class SQLiteBackend:
conn.commit()
return result.rowcount
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(skill_resources).where(
sa.and_(
skill_resources.c.skill_id == skill_id,
skill_resources.c.path == path,
)
)
)
conn.commit()
return result.rowcount > 0
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
if not skill_ids:
return {}
result: dict[str, int] = {}
# Chunk to stay under SQLite's max variable limit (999)
chunk_size = 900
with self._engine.connect() as conn:
for i in range(0, len(skill_ids), chunk_size):
chunk = skill_ids[i : i + chunk_size]
rows = conn.execute(
sa.select(
skill_resources.c.skill_id,
sa.func.count().label("cnt"),
)
.where(skill_resources.c.skill_id.in_(chunk))
.group_by(skill_resources.c.skill_id)
).fetchall()
for r in rows:
result[r[0]] = r[1]
return result
# -- Skill versions --------------------------------------------------------
def create_skill_version(
+53 -5
View File
@@ -38,7 +38,6 @@ from turnstone.api.console_schemas import (
RoleInfo,
SettingInfo,
SkillDiscoverResponse,
SkillInfo,
ToolPolicyInfo,
UsageResponse,
)
@@ -454,6 +453,36 @@ class AsyncTurnstoneConsole(_BaseClient):
"DELETE", f"/v1/api/admin/skills/{skill_id}", response_model=StatusResponse
)
async def list_skill_resources(self, skill_id: str) -> list[dict[str, Any]]:
"""List resource files for a skill."""
resp = await self._request("GET", f"/v1/api/admin/skills/{skill_id}/resources")
resources: list[dict[str, Any]] = resp.get("resources", [])
return resources
async def create_skill_resource(
self,
skill_id: str,
path: str,
content: str,
content_type: str = "text/plain",
) -> dict[str, Any]:
"""Upload a resource file to a skill."""
body: dict[str, Any] = {"path": path, "content": content, "content_type": content_type}
return await self._request(
"POST", f"/v1/api/admin/skills/{skill_id}/resources", json_body=body
)
async def delete_skill_resource(self, skill_id: str, path: str) -> StatusResponse:
"""Delete a skill resource by path."""
from urllib.parse import quote
encoded = quote(path, safe="/")
return await self._request(
"DELETE",
f"/v1/api/admin/skills/{skill_id}/resources/{encoded}",
response_model=StatusResponse,
)
# -- governance: usage & audit -------------------------------------------
async def get_usage(
@@ -769,8 +798,11 @@ class AsyncTurnstoneConsole(_BaseClient):
*,
skill_id: str = "",
url: str = "",
) -> SkillInfo:
"""Install a skill from an external source."""
) -> dict[str, Any]:
"""Install skill(s) from an external source.
Returns ``{installed: [...], skipped: [...], total: int}``.
"""
body: dict[str, Any] = {"source": source}
if skill_id:
body["skill_id"] = skill_id
@@ -780,7 +812,6 @@ class AsyncTurnstoneConsole(_BaseClient):
"POST",
"/v1/api/admin/skills/install",
json_body=body,
response_model=SkillInfo,
)
@@ -1040,6 +1071,23 @@ class TurnstoneConsole:
def delete_skill(self, skill_id: str) -> StatusResponse:
return self._runner.run(self._async.delete_skill(skill_id))
def list_skill_resources(self, skill_id: str) -> list[dict[str, Any]]:
return self._runner.run(self._async.list_skill_resources(skill_id))
def create_skill_resource(
self,
skill_id: str,
path: str,
content: str,
content_type: str = "text/plain",
) -> dict[str, Any]:
return self._runner.run(
self._async.create_skill_resource(skill_id, path, content, content_type)
)
def delete_skill_resource(self, skill_id: str, path: str) -> StatusResponse:
return self._runner.run(self._async.delete_skill_resource(skill_id, path))
# -- governance: usage & audit -------------------------------------------
def get_usage(
@@ -1219,7 +1267,7 @@ class TurnstoneConsole:
*,
skill_id: str = "",
url: str = "",
) -> SkillInfo:
) -> dict[str, Any]:
return self._runner.run(self._async.install_skill(source, skill_id=skill_id, url=url))
# -- lifecycle -----------------------------------------------------------
Generated
+1 -1
View File
@@ -2168,7 +2168,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "0.8.0"
version = "0.8.2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },