From cf5b042282a8bb70ff9b3ea1db46357e6c4c244c Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Wed, 22 Apr 2026 19:58:47 +1000 Subject: [PATCH 1/8] Add skills --- docs/content/docs/features/chat-ui.mdx | 13 + docs/content/docs/features/meta.json | 1 + docs/content/docs/features/skills.mdx | 166 ++ .../postgres/20250101000000_initial.sql | 77 + .../sqlite/20250101000000_initial.sql | 60 + src/app.rs | 2 + src/cli/bootstrap.rs | 3 +- src/cli/worker.rs | 1 + src/config/limits.rs | 21 + src/db/mod.rs | 17 + src/db/postgres/mod.rs | 2 + src/db/postgres/skills.rs | 727 +++++++ src/db/repos/mod.rs | 2 + src/db/repos/skills.rs | 62 + src/db/sqlite/mod.rs | 2 + src/db/sqlite/skills.rs | 1261 ++++++++++++ src/models/mod.rs | 2 + src/models/skill.rs | 346 ++++ src/openapi.rs | 20 + src/routes/admin/mod.rs | 22 + src/routes/admin/skills.rs | 528 +++++ src/services/mod.rs | 7 + src/services/skills.rs | 140 ++ src/wasm.rs | 2 +- ui/src/api/openapi.json | 1812 +++++++++++++---- .../Admin/SkillFormModal/SkillFormModal.tsx | 538 +++++ ui/src/components/Admin/index.ts | 1 + ui/src/components/ChatHeader/ChatHeader.tsx | 11 +- ui/src/components/ChatInput/ChatInput.tsx | 173 +- .../ChatInput/SlashCommandPopover.tsx | 91 + ui/src/components/ChatView/ChatView.tsx | 4 + .../SkillImportModal/SkillImportModal.tsx | 486 +++++ .../SkillImportModal/filesystemImport.ts | 97 + .../SkillImportModal/githubImport.ts | 315 +++ .../SkillImportModal/parseFrontmatter.ts | 197 ++ .../SkillsButton/SkillOwnerBadge.tsx | 60 + .../components/SkillsButton/SkillsButton.tsx | 359 ++++ ui/src/hooks/useUserSkills.ts | 117 ++ ui/src/pages/admin/skillColumns.tsx | 83 + ui/src/pages/chat/ChatPage.tsx | 21 + ui/src/pages/chat/useChat.ts | 42 + ui/src/pages/chat/utils/skillCache.ts | 43 + ui/src/pages/chat/utils/skillDirectory.ts | 28 + ui/src/pages/chat/utils/skillExecutor.ts | 129 ++ .../pages/chat/utils/slashCommandMatcher.ts | 61 + ui/src/pages/chat/utils/toolExecutors.ts | 2 + ui/src/pages/project/ProjectDetailPage.tsx | 8 +- ui/src/pages/project/SkillsTab.tsx | 114 ++ ui/src/stores/chatUIStore.ts | 34 + 49 files changed, 7932 insertions(+), 378 deletions(-) create mode 100644 docs/content/docs/features/skills.mdx create mode 100644 src/db/postgres/skills.rs create mode 100644 src/db/repos/skills.rs create mode 100644 src/db/sqlite/skills.rs create mode 100644 src/models/skill.rs create mode 100644 src/routes/admin/skills.rs create mode 100644 src/services/skills.rs create mode 100644 ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx create mode 100644 ui/src/components/ChatInput/SlashCommandPopover.tsx create mode 100644 ui/src/components/SkillImportModal/SkillImportModal.tsx create mode 100644 ui/src/components/SkillImportModal/filesystemImport.ts create mode 100644 ui/src/components/SkillImportModal/githubImport.ts create mode 100644 ui/src/components/SkillImportModal/parseFrontmatter.ts create mode 100644 ui/src/components/SkillsButton/SkillOwnerBadge.tsx create mode 100644 ui/src/components/SkillsButton/SkillsButton.tsx create mode 100644 ui/src/hooks/useUserSkills.ts create mode 100644 ui/src/pages/admin/skillColumns.tsx create mode 100644 ui/src/pages/chat/utils/skillCache.ts create mode 100644 ui/src/pages/chat/utils/skillDirectory.ts create mode 100644 ui/src/pages/chat/utils/skillExecutor.ts create mode 100644 ui/src/pages/chat/utils/slashCommandMatcher.ts create mode 100644 ui/src/pages/project/SkillsTab.tsx diff --git a/docs/content/docs/features/chat-ui.mdx b/docs/content/docs/features/chat-ui.mdx index b36c04b..154c81a 100644 --- a/docs/content/docs/features/chat-ui.mdx +++ b/docs/content/docs/features/chat-ui.mdx @@ -273,9 +273,22 @@ Tool execution produces artifacts displayed inline: | `Escape` | Cancel streaming | | `Ctrl+N` | New conversation | +## Skills + +The Skills button in the chat toolbar lets users enable packaged +instructions (following the [Agent Skills](https://agentskills.io) spec) +for the current session. Enabled skills are surfaced to the model as a +single `Skill` tool whose description lists every available skill — the +model picks the right one by description. Users can also invoke a skill +directly by typing `/` in the composer for a typeahead picker. + +Import from a GitHub repo or a local folder via the Skills button's +`+` menu. See [Skills](/docs/features/skills) for the full workflow. + ## Related Features + diff --git a/docs/content/docs/features/meta.json b/docs/content/docs/features/meta.json index f1fbc59..2d9f937 100644 --- a/docs/content/docs/features/meta.json +++ b/docs/content/docs/features/meta.json @@ -22,6 +22,7 @@ "guardrails", "mcp", "mcp-agents", + "skills", "caching" ] } diff --git a/docs/content/docs/features/skills.mdx b/docs/content/docs/features/skills.mdx new file mode 100644 index 0000000..8aae275 --- /dev/null +++ b/docs/content/docs/features/skills.mdx @@ -0,0 +1,166 @@ +--- +title: Skills +description: Package instructions and bundled files that the model can auto-invoke or users can trigger with a slash command. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Skills extend what the model can do by packaging reusable instructions as +portable units. Each skill is a `SKILL.md` file plus optional bundled +scripts, references, and assets. Hadrian implements the +[Agent Skills](https://agentskills.io) open specification so skills you +author for Hadrian also work with other compliant agents and vice versa. + +## What a skill looks like + +Every skill is a directory with at minimum a `SKILL.md` file containing YAML +frontmatter and Markdown instructions: + +```markdown +--- +name: pdf-processing +description: Extract PDF text, fill forms, merge files. Use when handling PDFs. +allowed-tools: Bash(python:*) Read +--- + +# PDF Processing + +## When to use this skill + +Use this skill when the user needs to work with PDF files. + +## How to extract text + +1. Run `scripts/extract.py ` +2. Parse the output into a structured summary. +``` + +Optional bundled files (kept alongside the SKILL.md) become available to the +model on demand: + +```text +pdf-processing/ +├── SKILL.md # Required: instructions + metadata +├── scripts/ # Optional: executable code +│ └── extract.py +├── references/ # Optional: docs +│ └── REFERENCE.md +└── assets/ # Optional: templates, data files + └── template.md +``` + +## Frontmatter fields + +| Field | Required | Description | +| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | +| `name` | Yes | Unique per owner. 1–64 lowercase ASCII alphanumerics or hyphens, no leading/trailing/consecutive hyphens. | +| `description` | Yes | 1–1024 chars. Drives model invocation — front-load trigger phrases ("Use when…", "when the user mentions…"). | +| `user_invocable` | No | Default `true`. Set to `false` to hide from the `/` menu; useful for background-knowledge skills the model auto-loads. | +| `disable_model_invocation` | No | Default `false`. Set to `true` for skills that should only run when the user explicitly picks them. | +| `allowed_tools` | No | Space-separated list (or YAML array) of tool patterns the skill may use. | +| `argument_hint` | No | Shown during `/` autocomplete, e.g. `[issue-number]`. | +| `metadata` | No | Arbitrary key/value pairs preserved verbatim. | + +Any additional keys are preserved in `frontmatter_extra` so unknown +spec-adjacent fields (for third-party agents) round-trip cleanly. + +## How the model invokes a skill + +Hadrian registers a single `Skill` function tool with the model whenever one +or more skills are enabled for the current session. The tool's description +lists each enabled skill by name and description — the model matches user +intent against those entries and calls `Skill({command: ""})` to load +the full instructions. + +The executor runs in the browser: + +1. **First call** — `Skill({command: ""})` returns the skill's + `SKILL.md` body plus a manifest of bundled files. +2. **Follow-up calls** — `Skill({command: "", file: ""})` + returns the contents of a specific bundled file referenced in the + SKILL.md. + +This matches Claude Code's progressive-disclosure model: the main +instructions enter context once; reference files load only when needed. + + + Skills are not injected into the system prompt. The directory lives in the + `Skill` tool's description, so unused skills don't pollute every request. + + +## Ownership & sharing + +Like prompt templates, skills belong to one of four owners: + +| Owner | Visible to | +| -------------- | -------------------------------------------------------------------- | +| `user` | Only the owning user. | +| `project` | All members of the project. | +| `team` | All members of the team. | +| `organization` | All organization members, subject to RBAC rules on `skill:list/read`. | + +Chat surfaces all skills the current user can reach. In the admin UI, +manage project skills from **Project → Skills**. + +## Invoking a skill as a user + +Two equivalent ways: + +- **Slash command.** Type `/` in the chat composer. A popover + lists matching skills; press Enter or Tab to commit. The composer prefixes + your message with a request to use that skill. +- **Skills button.** Click the Skills icon in the chat toolbar to open the + popover. Check a skill to enable it for the session — the model may then + auto-invoke it when relevant. + +## Importing skills + +Use the Skills button's `+` menu to import. + +### From GitHub + +Paste a GitHub URL (e.g. `https://github.com/anthropics/skills`) or +`owner/repo`. Hadrian walks the tree via the public GitHub Contents API, +finds every directory containing a `SKILL.md`, and bundles the adjacent +files. Preview the discovered skills and pick which to import. + + + The unauthenticated GitHub API allows 60 requests/hour. Hadrian caches + directory listings in `sessionStorage` for 10 minutes to soften this, and + surfaces the remaining quota in the import modal. + + +### From the filesystem + +Choose a folder via the file picker (browsers supporting +`` — Chromium-based and Safari). Hadrian reads every +file under directories containing a `SKILL.md`. + +Binary files are rejected in v1 — skill contents are stored as UTF-8 text. + +## Configuration + +Per-skill size and per-owner limits live under `[limits.resource_limits]` in +`hadrian.toml`: + +```toml +[limits.resource_limits] +# Maximum skills per owner (org/team/project/user). Default: 5000. +max_skills_per_owner = 5000 + +# Maximum total size of a skill's files in bytes (SKILL.md + bundled). +# Default: 512000 (500 KiB). Set to 0 for unlimited. +max_skill_bytes = 512_000 +``` + +## v1 limitations + +- **Text-only files.** Binary assets (images, PDFs) are rejected at the API + boundary. Future work: base64 encoding or object-storage offload. +- **Scripts are read-only.** The agent can *read* `scripts/` files through + the `Skill` tool but cannot execute them — there is no client-side + sandbox in the Hadrian chat UI. Skills that require script execution + won't work end-to-end. +- **Model-invocation is frontend-only.** The gateway server does not + inspect skills. Custom clients that hit `/v1/responses` directly must + build the `Skill` tool themselves. diff --git a/migrations_sqlx/postgres/20250101000000_initial.sql b/migrations_sqlx/postgres/20250101000000_initial.sql index 09ed39f..ba62b2b 100644 --- a/migrations_sqlx/postgres/20250101000000_initial.sql +++ b/migrations_sqlx/postgres/20250101000000_initial.sql @@ -1194,3 +1194,80 @@ DO $$ BEGIN CREATE TRIGGER update_service_accounts_updated_at BEFORE UPDATE ON service_accounts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); EXCEPTION WHEN duplicate_object THEN null; END $$; + +-- ====================================================================== +-- Skills +-- ====================================================================== + +-- Agent Skills (https://agentskills.io/specification.md). A skill is a +-- packaged set of instructions (SKILL.md) plus optional bundled files +-- (scripts, references, assets) that the model can auto-invoke or the +-- user can invoke via slash-command / button. +-- +-- Files are stored inline in skill_files with a per-skill total size cap +-- enforced in the service layer (config: limits.resource_limits.max_skill_bytes). + +DO $$ BEGIN + CREATE TYPE skill_owner_type AS ENUM ('organization', 'team', 'project', 'user'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +CREATE TABLE IF NOT EXISTS skills ( + id UUID PRIMARY KEY NOT NULL, + owner_type skill_owner_type NOT NULL, + owner_id UUID NOT NULL, + -- Per spec: 1..=64 chars, [a-z0-9-]+, no leading/trailing/consecutive hyphens + name VARCHAR(64) NOT NULL, + -- Per spec: required, 1..=1024 chars + description VARCHAR(1024) NOT NULL, + -- Optional frontmatter fields (NULL = not set) + user_invocable BOOLEAN, -- defaults to true in code + disable_model_invocation BOOLEAN, -- defaults to false in code + allowed_tools JSONB, -- array of tool names + argument_hint VARCHAR(255), + source_url VARCHAR(2048), -- origin URL (e.g. GitHub) if imported + source_ref VARCHAR(255), -- git ref if imported + frontmatter_extra JSONB, -- unknown/forward-compat keys + -- Cached sum of skill_files.byte_size for fast limit checks + total_bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + UNIQUE(owner_type, owner_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_skills_owner ON skills(owner_type, owner_id); +-- Partial index for non-deleted skills (most queries filter by deleted_at IS NULL) +CREATE INDEX IF NOT EXISTS idx_skills_owner_active ON skills(owner_type, owner_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name); + +DO $$ BEGIN + CREATE TRIGGER update_skills_updated_at BEFORE UPDATE ON skills FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +-- Files bundled into a skill. Every skill must have exactly one row with +-- path = 'SKILL.md' (enforced in service layer). Additional rows hold +-- bundled scripts/references/assets referenced from SKILL.md. +CREATE TABLE IF NOT EXISTS skill_files ( + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + -- Relative path inside the skill directory (e.g. 'SKILL.md', 'scripts/extract.py') + path VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + -- Cached byte length of content for fast total-size aggregation + byte_size BIGINT NOT NULL, + -- MIME type; defaults to 'text/markdown' for SKILL.md, sniffed from + -- extension for others + content_type VARCHAR(127) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY(skill_id, path) +); + +CREATE INDEX IF NOT EXISTS idx_skill_files_skill ON skill_files(skill_id); + +DO $$ BEGIN + CREATE TRIGGER update_skill_files_updated_at BEFORE UPDATE ON skill_files FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +EXCEPTION WHEN duplicate_object THEN null; +END $$; diff --git a/migrations_sqlx/sqlite/20250101000000_initial.sql b/migrations_sqlx/sqlite/20250101000000_initial.sql index c31b1de..4d76781 100644 --- a/migrations_sqlx/sqlite/20250101000000_initial.sql +++ b/migrations_sqlx/sqlite/20250101000000_initial.sql @@ -971,3 +971,63 @@ CREATE INDEX IF NOT EXISTS idx_service_accounts_slug ON service_accounts(slug); -- Partial indexes for non-deleted service accounts (most queries filter by deleted_at IS NULL) CREATE INDEX IF NOT EXISTS idx_service_accounts_org_active ON service_accounts(org_id) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_service_accounts_org_slug_active ON service_accounts(org_id, slug) WHERE deleted_at IS NULL; + +-- ====================================================================== +-- Skills +-- ====================================================================== + +-- Agent Skills (https://agentskills.io/specification.md). A skill is a +-- packaged set of instructions (SKILL.md) plus optional bundled files +-- (scripts, references, assets) that the model can auto-invoke or the +-- user can invoke via slash-command / button. +-- +-- Files are stored inline in skill_files with a per-skill total size cap +-- enforced in the service layer (config: limits.resource_limits.max_skill_bytes). +CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY NOT NULL, + owner_type TEXT NOT NULL CHECK (owner_type IN ('organization', 'team', 'project', 'user')), + owner_id TEXT NOT NULL, + -- Per spec: 1..=64 chars, [a-z0-9-]+, no leading/trailing/consecutive hyphens + name TEXT NOT NULL, + -- Per spec: required, 1..=1024 chars + description TEXT NOT NULL, + -- Optional frontmatter fields (NULL = not set) + user_invocable INTEGER, -- bool (0/1); defaults to true in code + disable_model_invocation INTEGER, -- bool (0/1); defaults to false in code + allowed_tools TEXT, -- JSON array of tool names + argument_hint TEXT, + source_url TEXT, -- origin URL (e.g. GitHub) if imported + source_ref TEXT, -- git ref if imported + frontmatter_extra TEXT, -- JSON object, unknown/forward-compat keys + -- Cached sum of skill_files.byte_size for fast limit checks + total_bytes INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + UNIQUE(owner_type, owner_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_skills_owner ON skills(owner_type, owner_id); +-- Partial index for non-deleted skills (most queries filter by deleted_at IS NULL) +CREATE INDEX IF NOT EXISTS idx_skills_owner_active ON skills(owner_type, owner_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name); + +-- Files bundled into a skill. Every skill must have exactly one row with +-- path = 'SKILL.md' (enforced in service layer). Additional rows hold +-- bundled scripts/references/assets referenced from SKILL.md. +CREATE TABLE IF NOT EXISTS skill_files ( + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + -- Relative path inside the skill directory (e.g. 'SKILL.md', 'scripts/extract.py') + path TEXT NOT NULL, + content TEXT NOT NULL, + -- Cached byte length of content for fast total-size aggregation + byte_size INTEGER NOT NULL, + -- MIME type; defaults to 'text/markdown' for SKILL.md, sniffed from + -- extension for others + content_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(skill_id, path) +); + +CREATE INDEX IF NOT EXISTS idx_skill_files_skill ON skill_files(skill_id); diff --git a/src/app.rs b/src/app.rs index 950d3a9..41fb36b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -429,11 +429,13 @@ impl AppState { ); let max_expr_len = config.auth.rbac.max_expression_length; + let max_skill_bytes = config.limits.resource_limits.max_skill_bytes; let services = services::Services::with_event_bus( db.clone(), file_storage, event_bus.clone(), max_expr_len, + max_skill_bytes, ); (Some(db), Some(services)) } diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs index 4b36225..47a86cd 100644 --- a/src/cli/bootstrap.rs +++ b/src/cli/bootstrap.rs @@ -85,7 +85,8 @@ pub(crate) async fn run_bootstrap(explicit_config_path: Option<&str>, dry_run: b let file_storage: std::sync::Arc = std::sync::Arc::new(services::DatabaseFileStorage::new(db.clone())); let max_cel = config.auth.rbac.max_expression_length; - let services = services::Services::new(db.clone(), file_storage, max_cel); + let max_skill_bytes = config.limits.resource_limits.max_skill_bytes; + let services = services::Services::new(db.clone(), file_storage, max_cel, max_skill_bytes); let api_key_prefix = config.auth.api_key_config().generation_prefix(); let mut summary = Vec::new(); diff --git a/src/cli/worker.rs b/src/cli/worker.rs index 126a014..6f66844 100644 --- a/src/cli/worker.rs +++ b/src/cli/worker.rs @@ -95,6 +95,7 @@ pub(crate) async fn run_worker( db.clone(), file_storage, config.auth.rbac.max_expression_length, + config.limits.resource_limits.max_skill_bytes, ); let vector_stores_service = Arc::new(services.vector_stores.clone()); diff --git a/src/config/limits.rs b/src/config/limits.rs index ba2cfe0..e928796 100644 --- a/src/config/limits.rs +++ b/src/config/limits.rs @@ -99,6 +99,15 @@ pub struct ResourceLimits { #[serde(default = "default_max_templates_per_owner")] pub max_templates_per_owner: u32, + /// Maximum skills per owner (org/team/project/user). Default: 5,000. + #[serde(default = "default_max_skills_per_owner")] + pub max_skills_per_owner: u32, + + /// Maximum total size of a skill's files in bytes (SKILL.md + bundled + /// files). Default: 512,000 (500 KiB). Set to 0 for unlimited. + #[serde(default = "default_max_skill_bytes")] + pub max_skill_bytes: u32, + /// Maximum domain verifications per SSO configuration. Default: 50. #[serde(default = "default_max_domains_per_sso_config")] pub max_domains_per_sso_config: u32, @@ -147,6 +156,8 @@ impl Default for ResourceLimits { max_files_per_vector_store: default_max_files_per_vector_store(), max_conversations_per_owner: default_max_conversations_per_owner(), max_templates_per_owner: default_max_templates_per_owner(), + max_skills_per_owner: default_max_skills_per_owner(), + max_skill_bytes: default_max_skill_bytes(), max_domains_per_sso_config: default_max_domains_per_sso_config(), max_sso_group_mappings_per_org: default_max_sso_group_mappings_per_org(), max_members_per_org: default_max_members_per_org(), @@ -222,6 +233,16 @@ fn default_max_templates_per_owner() -> u32 { 5_000 } +fn default_max_skills_per_owner() -> u32 { + 5_000 +} + +fn default_max_skill_bytes() -> u32 { + // 500 KiB — generous enough for SKILL.md plus a handful of bundled + // scripts/references, small enough to keep tool-result tokens bounded. + 512_000 +} + fn default_max_domains_per_sso_config() -> u32 { 50 } diff --git a/src/db/mod.rs b/src/db/mod.rs index 4ad8c29..d3ded73 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -54,6 +54,7 @@ struct CachedRepos { files: Arc, teams: Arc, templates: Arc, + skills: Arc, #[cfg(feature = "sso")] sso_group_mappings: Arc, #[cfg(feature = "sso")] @@ -132,6 +133,7 @@ impl DbPool { files: Arc::new(sqlite::SqliteFilesRepo::new(pool.clone())), teams: Arc::new(sqlite::SqliteTeamRepo::new(pool.clone())), templates: Arc::new(sqlite::SqliteTemplateRepo::new(pool.clone())), + skills: Arc::new(sqlite::SqliteSkillRepo::new(pool.clone())), #[cfg(feature = "sso")] sso_group_mappings: Arc::new(sqlite::SqliteSsoGroupMappingRepo::new(pool.clone())), #[cfg(feature = "sso")] @@ -170,6 +172,7 @@ impl DbPool { files: Arc::new(sqlite::SqliteFilesRepo::new(pool.clone())), teams: Arc::new(sqlite::SqliteTeamRepo::new(pool.clone())), templates: Arc::new(sqlite::SqliteTemplateRepo::new(pool.clone())), + skills: Arc::new(sqlite::SqliteSkillRepo::new(pool.clone())), #[cfg(feature = "sso")] sso_group_mappings: unreachable!("SSO not supported in WASM builds"), #[cfg(feature = "sso")] @@ -248,6 +251,10 @@ impl DbPool { write_pool.clone(), read_pool.clone(), )), + skills: Arc::new(postgres::PostgresSkillRepo::new( + write_pool.clone(), + read_pool.clone(), + )), #[cfg(feature = "sso")] sso_group_mappings: Arc::new(postgres::PostgresSsoGroupMappingRepo::new( write_pool.clone(), @@ -331,6 +338,7 @@ impl DbPool { files: Arc::new(sqlite::SqliteFilesRepo::new(pool.clone())), teams: Arc::new(sqlite::SqliteTeamRepo::new(pool.clone())), templates: Arc::new(sqlite::SqliteTemplateRepo::new(pool.clone())), + skills: Arc::new(sqlite::SqliteSkillRepo::new(pool.clone())), #[cfg(feature = "sso")] sso_group_mappings: Arc::new(sqlite::SqliteSsoGroupMappingRepo::new( pool.clone(), @@ -434,6 +442,10 @@ impl DbPool { write_pool.clone(), read_pool.clone(), )), + skills: Arc::new(postgres::PostgresSkillRepo::new( + write_pool.clone(), + read_pool.clone(), + )), #[cfg(feature = "sso")] sso_group_mappings: Arc::new(postgres::PostgresSsoGroupMappingRepo::new( write_pool.clone(), @@ -586,6 +598,11 @@ impl DbPool { Arc::clone(&self.repos.templates) } + /// Get skill repository + pub fn skills(&self) -> Arc { + Arc::clone(&self.repos.skills) + } + /// Get SSO group mapping repository #[cfg(feature = "sso")] pub fn sso_group_mappings(&self) -> Arc { diff --git a/src/db/postgres/mod.rs b/src/db/postgres/mod.rs index 4c6fc59..b1f37d4 100644 --- a/src/db/postgres/mod.rs +++ b/src/db/postgres/mod.rs @@ -18,6 +18,7 @@ mod scim_group_mappings; #[cfg(feature = "sso")] mod scim_user_mappings; mod service_accounts; +mod skills; #[cfg(feature = "sso")] mod sso_group_mappings; mod teams; @@ -46,6 +47,7 @@ pub use scim_group_mappings::PostgresScimGroupMappingRepo; #[cfg(feature = "sso")] pub use scim_user_mappings::PostgresScimUserMappingRepo; pub use service_accounts::PostgresServiceAccountRepo; +pub use skills::PostgresSkillRepo; #[cfg(feature = "sso")] pub use sso_group_mappings::PostgresSsoGroupMappingRepo; pub use teams::PostgresTeamRepo; diff --git a/src/db/postgres/skills.rs b/src/db/postgres/skills.rs new file mode 100644 index 0000000..6736066 --- /dev/null +++ b/src/db/postgres/skills.rs @@ -0,0 +1,727 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use crate::{ + db::{ + error::{DbError, DbResult}, + repos::{ + CursorDirection, ListParams, ListResult, PageCursors, SkillRepo, cursor_from_row, + }, + }, + models::{ + CreateSkill, Skill, SkillFile, SkillFileInput, SkillFileManifest, SkillOwnerType, + UpdateSkill, + }, +}; + +const SKILL_COLUMNS: &str = "id, owner_type::TEXT, owner_id, name, description, user_invocable, \ + disable_model_invocation, allowed_tools, argument_hint, source_url, source_ref, \ + frontmatter_extra, total_bytes, created_at, updated_at"; + +/// Same columns as [`SKILL_COLUMNS`] but prefixed with `s.` for aliased queries. +const SKILL_COLUMNS_ALIASED: &str = "s.id, s.owner_type::TEXT, s.owner_id, s.name, s.description, \ + s.user_invocable, s.disable_model_invocation, s.allowed_tools, s.argument_hint, \ + s.source_url, s.source_ref, s.frontmatter_extra, s.total_bytes, s.created_at, s.updated_at"; + +pub struct PostgresSkillRepo { + write_pool: PgPool, + read_pool: PgPool, +} + +impl PostgresSkillRepo { + pub fn new(write_pool: PgPool, read_pool: Option) -> Self { + let read_pool = read_pool.unwrap_or_else(|| write_pool.clone()); + Self { + write_pool, + read_pool, + } + } + + fn sniff_content_type(path: &str) -> &'static str { + let lower = path.to_ascii_lowercase(); + match lower.rsplit_once('.').map(|(_, ext)| ext) { + Some("md") | Some("markdown") => "text/markdown", + Some("py") => "text/x-python", + Some("js") | Some("mjs") | Some("cjs") => "text/javascript", + Some("ts") => "text/typescript", + Some("sh") | Some("bash") => "text/x-shellscript", + Some("json") => "application/json", + Some("yaml") | Some("yml") => "application/yaml", + Some("toml") => "application/toml", + Some("html") | Some("htm") => "text/html", + Some("css") => "text/css", + Some("csv") => "text/csv", + Some("txt") | None => "text/plain", + _ => "text/plain", + } + } + + fn parse_skill(row: &sqlx::postgres::PgRow) -> DbResult { + let owner_type_str: String = row.get("owner_type"); + let owner_type: SkillOwnerType = owner_type_str.parse().map_err(DbError::Internal)?; + + let allowed_tools: Option = row.get("allowed_tools"); + let allowed_tools: Option> = allowed_tools + .map(serde_json::from_value) + .transpose() + .map_err(|e| DbError::Internal(format!("Failed to parse allowed_tools: {}", e)))?; + + let frontmatter_extra: Option = row.get("frontmatter_extra"); + let frontmatter_extra: Option> = frontmatter_extra + .map(serde_json::from_value) + .transpose() + .map_err(|e| { + DbError::Internal(format!("Failed to parse frontmatter_extra: {}", e)) + })?; + + Ok(Skill { + id: row.get("id"), + owner_type, + owner_id: row.get("owner_id"), + name: row.get("name"), + description: row.get("description"), + user_invocable: row.get("user_invocable"), + disable_model_invocation: row.get("disable_model_invocation"), + allowed_tools, + argument_hint: row.get("argument_hint"), + source_url: row.get("source_url"), + source_ref: row.get("source_ref"), + frontmatter_extra, + total_bytes: row.get("total_bytes"), + files: Vec::new(), + files_manifest: Vec::new(), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + } + + fn parse_file(row: &sqlx::postgres::PgRow) -> SkillFile { + SkillFile { + path: row.get("path"), + content: row.get("content"), + byte_size: row.get("byte_size"), + content_type: row.get("content_type"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } + } + + fn parse_manifest(row: &sqlx::postgres::PgRow) -> SkillFileManifest { + SkillFileManifest { + path: row.get("path"), + byte_size: row.get("byte_size"), + content_type: row.get("content_type"), + } + } + + async fn load_files(&self, skill_id: Uuid) -> DbResult> { + let rows = sqlx::query( + r#" + SELECT path, content, byte_size, content_type, created_at, updated_at + FROM skill_files + WHERE skill_id = $1 + ORDER BY path ASC + "#, + ) + .bind(skill_id) + .fetch_all(&self.read_pool) + .await?; + + Ok(rows.iter().map(Self::parse_file).collect()) + } + + async fn attach_manifests(&self, skills: &mut [Skill]) -> DbResult<()> { + if skills.is_empty() { + return Ok(()); + } + + let ids: Vec = skills.iter().map(|s| s.id).collect(); + let rows = sqlx::query( + r#" + SELECT skill_id, path, byte_size, content_type + FROM skill_files + WHERE skill_id = ANY($1) + ORDER BY path ASC + "#, + ) + .bind(&ids) + .fetch_all(&self.read_pool) + .await?; + + let mut by_skill: HashMap> = HashMap::new(); + for row in rows.iter() { + let skill_id: Uuid = row.get("skill_id"); + by_skill + .entry(skill_id) + .or_default() + .push(Self::parse_manifest(row)); + } + + for skill in skills.iter_mut() { + if let Some(manifest) = by_skill.remove(&skill.id) { + skill.files_manifest = manifest; + } + } + Ok(()) + } +} + +/// Shared org-scope filter for list_by_org and get_by_id_and_org. +/// Uses $1 for the org_id (referenced four times). +const ORG_SCOPE_FILTER: &str = r#" + AND ( + (s.owner_type = 'organization' AND s.owner_id = $1) + OR (s.owner_type = 'team' AND EXISTS ( + SELECT 1 FROM teams t WHERE t.id = s.owner_id AND t.org_id = $1 + )) + OR (s.owner_type = 'project' AND EXISTS ( + SELECT 1 FROM projects pr WHERE pr.id = s.owner_id AND pr.org_id = $1 + )) + OR (s.owner_type = 'user' AND EXISTS ( + SELECT 1 FROM org_memberships om WHERE om.user_id = s.owner_id AND om.org_id = $1 + )) + ) +"#; + +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +impl SkillRepo for PostgresSkillRepo { + async fn create(&self, input: CreateSkill) -> DbResult { + let id = Uuid::new_v4(); + let owner_type = input.owner.owner_type(); + let owner_id = input.owner.owner_id(); + + let allowed_tools_json: Option = input + .allowed_tools + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| DbError::Internal(format!("Failed to serialize allowed_tools: {}", e)))?; + let frontmatter_extra_json: Option = input + .frontmatter_extra + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| { + DbError::Internal(format!("Failed to serialize frontmatter_extra: {}", e)) + })?; + + let files_with_size: Vec<(SkillFileInput, i64, String)> = input + .files + .iter() + .map(|f| { + let size = f.content.len() as i64; + let ct = f + .content_type + .clone() + .unwrap_or_else(|| Self::sniff_content_type(&f.path).to_string()); + (f.clone(), size, ct) + }) + .collect(); + let total_bytes: i64 = files_with_size.iter().map(|(_, s, _)| *s).sum(); + + let mut tx = self.write_pool.begin().await?; + + sqlx::query( + r#" + INSERT INTO skills ( + id, owner_type, owner_id, name, description, + user_invocable, disable_model_invocation, allowed_tools, + argument_hint, source_url, source_ref, frontmatter_extra, + total_bytes + ) + VALUES ($1, $2::skill_owner_type, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + "#, + ) + .bind(id) + .bind(owner_type.as_str()) + .bind(owner_id) + .bind(&input.name) + .bind(&input.description) + .bind(input.user_invocable) + .bind(input.disable_model_invocation) + .bind(&allowed_tools_json) + .bind(&input.argument_hint) + .bind(&input.source_url) + .bind(&input.source_ref) + .bind(&frontmatter_extra_json) + .bind(total_bytes) + .execute(&mut *tx) + .await + .map_err(|e| match e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => DbError::Conflict( + format!("Skill with name '{}' already exists for this owner", input.name), + ), + _ => DbError::from(e), + })?; + + for (file, size, content_type) in files_with_size.iter() { + sqlx::query( + r#" + INSERT INTO skill_files ( + skill_id, path, content, byte_size, content_type + ) + VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(id) + .bind(&file.path) + .bind(&file.content) + .bind(*size) + .bind(content_type) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + self.get_by_id(id) + .await? + .ok_or_else(|| DbError::Internal("Skill vanished after create".into())) + } + + async fn get_by_id(&self, id: Uuid) -> DbResult> { + let sql = format!( + "SELECT {cols} FROM skills WHERE id = $1 AND deleted_at IS NULL", + cols = SKILL_COLUMNS + ); + let result = sqlx::query(&sql) + .bind(id) + .fetch_optional(&self.read_pool) + .await?; + + match result { + Some(row) => { + let mut skill = Self::parse_skill(&row)?; + skill.files = self.load_files(id).await?; + Ok(Some(skill)) + } + None => Ok(None), + } + } + + async fn get_by_id_and_org(&self, id: Uuid, org_id: Uuid) -> DbResult> { + // Org id referenced as $1 (4x via filter); skill id as $2. + let sql = format!( + "SELECT {cols} FROM skills s WHERE s.id = $2 AND s.deleted_at IS NULL {scope}", + cols = SKILL_COLUMNS_ALIASED, + scope = ORG_SCOPE_FILTER, + ); + let result = sqlx::query(&sql) + .bind(org_id) + .bind(id) + .fetch_optional(&self.read_pool) + .await?; + + match result { + Some(row) => { + let mut skill = Self::parse_skill(&row)?; + skill.files = self.load_files(id).await?; + Ok(Some(skill)) + } + None => Ok(None), + } + } + + async fn list_by_owner( + &self, + owner_type: SkillOwnerType, + owner_id: Uuid, + params: ListParams, + ) -> DbResult> { + let limit = params.limit.unwrap_or(100); + let fetch_limit = limit + 1; + + if let Some(ref cursor) = params.cursor { + let (comparison, order, should_reverse) = + params.sort_order.cursor_query_params(params.direction); + + let deleted_filter = if params.include_deleted { + "" + } else { + "AND deleted_at IS NULL" + }; + + let sql = format!( + "SELECT {cols} FROM skills \ + WHERE owner_type = $1::skill_owner_type AND owner_id = $2 \ + AND ROW(created_at, id) {cmp} ROW($3, $4) {deleted_filter} \ + ORDER BY created_at {order}, id {order} LIMIT $5", + cols = SKILL_COLUMNS, + cmp = comparison, + deleted_filter = deleted_filter, + order = order, + ); + + let rows = sqlx::query(&sql) + .bind(owner_type.as_str()) + .bind(owner_id) + .bind(cursor.created_at) + .bind(cursor.id) + .bind(fetch_limit) + .fetch_all(&self.read_pool) + .await?; + + let has_more = rows.len() as i64 > limit; + let mut items: Vec = rows + .iter() + .take(limit as usize) + .map(Self::parse_skill) + .collect::>>()?; + + if should_reverse { + items.reverse(); + } + self.attach_manifests(&mut items).await?; + + let cursors = + PageCursors::from_items(&items, has_more, params.direction, Some(cursor), |s| { + cursor_from_row(s.created_at, s.id) + }); + + return Ok(ListResult::new(items, has_more, cursors)); + } + + let deleted_filter = if params.include_deleted { + "" + } else { + "AND deleted_at IS NULL" + }; + let sql = format!( + "SELECT {cols} FROM skills \ + WHERE owner_type = $1::skill_owner_type AND owner_id = $2 {deleted_filter} \ + ORDER BY created_at DESC, id DESC LIMIT $3", + cols = SKILL_COLUMNS, + deleted_filter = deleted_filter + ); + + let rows = sqlx::query(&sql) + .bind(owner_type.as_str()) + .bind(owner_id) + .bind(fetch_limit) + .fetch_all(&self.read_pool) + .await?; + + let has_more = rows.len() as i64 > limit; + let mut items: Vec = rows + .iter() + .take(limit as usize) + .map(Self::parse_skill) + .collect::>>()?; + self.attach_manifests(&mut items).await?; + + let cursors = + PageCursors::from_items(&items, has_more, CursorDirection::Forward, None, |s| { + cursor_from_row(s.created_at, s.id) + }); + + Ok(ListResult::new(items, has_more, cursors)) + } + + async fn list_by_org( + &self, + org_id: Uuid, + params: ListParams, + ) -> DbResult> { + let limit = params.limit.unwrap_or(100); + let fetch_limit = limit + 1; + + if let Some(ref cursor) = params.cursor { + let (comparison, order, should_reverse) = + params.sort_order.cursor_query_params(params.direction); + + // $1 = org_id (referenced 4x in scope filter), $2/$3 = cursor, $4 = limit + let sql = format!( + "SELECT {cols} FROM skills s \ + WHERE s.deleted_at IS NULL AND ROW(s.created_at, s.id) {cmp} ROW($2, $3) \ + {scope} \ + ORDER BY s.created_at {order}, s.id {order} LIMIT $4", + cols = SKILL_COLUMNS_ALIASED, + cmp = comparison, + scope = ORG_SCOPE_FILTER, + order = order, + ); + + let rows = sqlx::query(&sql) + .bind(org_id) + .bind(cursor.created_at) + .bind(cursor.id) + .bind(fetch_limit) + .fetch_all(&self.read_pool) + .await?; + + let has_more = rows.len() as i64 > limit; + let mut items: Vec = rows + .iter() + .take(limit as usize) + .map(Self::parse_skill) + .collect::>>()?; + + if should_reverse { + items.reverse(); + } + self.attach_manifests(&mut items).await?; + + let cursors = + PageCursors::from_items(&items, has_more, params.direction, Some(cursor), |s| { + cursor_from_row(s.created_at, s.id) + }); + + return Ok(ListResult::new(items, has_more, cursors)); + } + + let sql = format!( + "SELECT {cols} FROM skills s \ + WHERE s.deleted_at IS NULL {scope} \ + ORDER BY s.created_at DESC, s.id DESC LIMIT $2", + cols = SKILL_COLUMNS_ALIASED, + scope = ORG_SCOPE_FILTER, + ); + + let rows = sqlx::query(&sql) + .bind(org_id) + .bind(fetch_limit) + .fetch_all(&self.read_pool) + .await?; + + let has_more = rows.len() as i64 > limit; + let mut items: Vec = rows + .iter() + .take(limit as usize) + .map(Self::parse_skill) + .collect::>>()?; + self.attach_manifests(&mut items).await?; + + let cursors = + PageCursors::from_items(&items, has_more, CursorDirection::Forward, None, |s| { + cursor_from_row(s.created_at, s.id) + }); + + Ok(ListResult::new(items, has_more, cursors)) + } + + async fn count_by_owner( + &self, + owner_type: SkillOwnerType, + owner_id: Uuid, + include_deleted: bool, + ) -> DbResult { + let sql = if include_deleted { + "SELECT COUNT(*) AS count FROM skills \ + WHERE owner_type = $1::skill_owner_type AND owner_id = $2" + } else { + "SELECT COUNT(*) AS count FROM skills \ + WHERE owner_type = $1::skill_owner_type AND owner_id = $2 AND deleted_at IS NULL" + }; + + let row = sqlx::query(sql) + .bind(owner_type.as_str()) + .bind(owner_id) + .fetch_one(&self.read_pool) + .await?; + + Ok(row.get::("count")) + } + + async fn update(&self, id: Uuid, input: UpdateSkill) -> DbResult { + let UpdateSkill { + name, + description, + files, + user_invocable, + disable_model_invocation, + allowed_tools, + argument_hint, + source_url, + source_ref, + frontmatter_extra, + } = input; + + let has_changes = name.is_some() + || description.is_some() + || files.is_some() + || user_invocable.is_some() + || disable_model_invocation.is_some() + || allowed_tools.is_some() + || argument_hint.is_some() + || source_url.is_some() + || source_ref.is_some() + || frontmatter_extra.is_some(); + + if !has_changes { + return self.get_by_id(id).await?.ok_or(DbError::NotFound); + } + + let files_with_size: Option> = files.as_ref().map(|fs| { + fs.iter() + .map(|f| { + let size = f.content.len() as i64; + let ct = f + .content_type + .clone() + .unwrap_or_else(|| Self::sniff_content_type(&f.path).to_string()); + (f.clone(), size, ct) + }) + .collect() + }); + let new_total_bytes: Option = files_with_size + .as_ref() + .map(|v| v.iter().map(|(_, s, _)| *s).sum()); + + let allowed_tools_json: Option = allowed_tools + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| DbError::Internal(format!("Failed to serialize allowed_tools: {}", e)))?; + let frontmatter_extra_json: Option = frontmatter_extra + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| { + DbError::Internal(format!("Failed to serialize frontmatter_extra: {}", e)) + })?; + + let mut set_clauses: Vec = vec!["updated_at = NOW()".into()]; + let mut param_idx: i32 = 1; + let mut push = |col: &str, idx: &mut i32| { + set_clauses.push(format!("{} = ${}", col, idx)); + *idx += 1; + }; + + if name.is_some() { + push("name", &mut param_idx); + } + if description.is_some() { + push("description", &mut param_idx); + } + if user_invocable.is_some() { + push("user_invocable", &mut param_idx); + } + if disable_model_invocation.is_some() { + push("disable_model_invocation", &mut param_idx); + } + if allowed_tools.is_some() { + push("allowed_tools", &mut param_idx); + } + if argument_hint.is_some() { + push("argument_hint", &mut param_idx); + } + if source_url.is_some() { + push("source_url", &mut param_idx); + } + if source_ref.is_some() { + push("source_ref", &mut param_idx); + } + if frontmatter_extra.is_some() { + push("frontmatter_extra", &mut param_idx); + } + if new_total_bytes.is_some() { + push("total_bytes", &mut param_idx); + } + + let sql = format!( + "UPDATE skills SET {} WHERE id = ${} AND deleted_at IS NULL", + set_clauses.join(", "), + param_idx + ); + + let mut tx = self.write_pool.begin().await?; + + let mut q = sqlx::query(&sql); + if let Some(ref v) = name { + q = q.bind(v); + } + if let Some(ref v) = description { + q = q.bind(v); + } + if let Some(v) = user_invocable { + q = q.bind(v); + } + if let Some(v) = disable_model_invocation { + q = q.bind(v); + } + if allowed_tools.is_some() { + q = q.bind(allowed_tools_json.clone()); + } + if let Some(ref v) = argument_hint { + q = q.bind(v); + } + if let Some(ref v) = source_url { + q = q.bind(v); + } + if let Some(ref v) = source_ref { + q = q.bind(v); + } + if frontmatter_extra.is_some() { + q = q.bind(frontmatter_extra_json.clone()); + } + if let Some(total) = new_total_bytes { + q = q.bind(total); + } + q = q.bind(id); + + let result = q + .execute(&mut *tx) + .await + .map_err(|e| match e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => { + DbError::Conflict("Skill with this name already exists for this owner".into()) + } + _ => DbError::from(e), + })?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound); + } + + if let Some(new_files) = files_with_size { + sqlx::query("DELETE FROM skill_files WHERE skill_id = $1") + .bind(id) + .execute(&mut *tx) + .await?; + + for (file, size, content_type) in new_files.iter() { + sqlx::query( + r#" + INSERT INTO skill_files ( + skill_id, path, content, byte_size, content_type + ) + VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(id) + .bind(&file.path) + .bind(&file.content) + .bind(*size) + .bind(content_type) + .execute(&mut *tx) + .await?; + } + } + + tx.commit().await?; + + self.get_by_id(id).await?.ok_or(DbError::NotFound) + } + + async fn delete(&self, id: Uuid) -> DbResult<()> { + let result = sqlx::query( + r#" + UPDATE skills SET deleted_at = NOW() + WHERE id = $1 AND deleted_at IS NULL + "#, + ) + .bind(id) + .execute(&self.write_pool) + .await?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound); + } + + Ok(()) + } +} diff --git a/src/db/repos/mod.rs b/src/db/repos/mod.rs index d906bd3..74d6aa0 100644 --- a/src/db/repos/mod.rs +++ b/src/db/repos/mod.rs @@ -19,6 +19,7 @@ mod scim_group_mappings; #[cfg(feature = "sso")] mod scim_user_mappings; mod service_accounts; +mod skills; #[cfg(feature = "sso")] mod sso_group_mappings; mod teams; @@ -49,6 +50,7 @@ pub use scim_group_mappings::*; #[cfg(feature = "sso")] pub use scim_user_mappings::*; pub use service_accounts::*; +pub use skills::*; #[cfg(feature = "sso")] pub use sso_group_mappings::*; pub use teams::*; diff --git a/src/db/repos/skills.rs b/src/db/repos/skills.rs new file mode 100644 index 0000000..696231b --- /dev/null +++ b/src/db/repos/skills.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use super::{ListParams, ListResult}; +use crate::{ + db::error::DbResult, + models::{CreateSkill, Skill, SkillOwnerType, UpdateSkill}, +}; + +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +pub trait SkillRepo: Send + Sync { + /// Create a new skill with its full file set. `input.files` is stored + /// verbatim — callers (service layer) must have already enforced the + /// spec invariants (SKILL.md present, paths valid, total-size limit). + async fn create(&self, input: CreateSkill) -> DbResult; + + /// Get a skill by ID, including all bundled files. + async fn get_by_id(&self, id: Uuid) -> DbResult>; + + /// Get a skill by ID, scoped to a specific organization. + /// + /// Verifies the skill belongs to the given org by checking the owner relationship: + /// - Organization-owned: `owner_id` matches directly + /// - Team-owned: joins through `teams.org_id` + /// - Project-owned: joins through `projects.org_id` + /// - User-owned: joins through `org_memberships` + async fn get_by_id_and_org(&self, id: Uuid, org_id: Uuid) -> DbResult>; + + /// List skills by owner. Results populate `files_manifest` (not `files`). + async fn list_by_owner( + &self, + owner_type: SkillOwnerType, + owner_id: Uuid, + params: ListParams, + ) -> DbResult>; + + /// List all skills accessible within an organization. + /// + /// Returns skills from all scopes within the org: + /// - Organization-owned (owner_id = org_id) + /// - Team-owned (team belongs to org) + /// - Project-owned (project belongs to org) + /// - User-owned (user is a member of org) + async fn list_by_org(&self, org_id: Uuid, params: ListParams) -> DbResult>; + + /// Count skills by owner. + async fn count_by_owner( + &self, + owner_type: SkillOwnerType, + owner_id: Uuid, + include_deleted: bool, + ) -> DbResult; + + /// Update a skill. When `input.files` is `Some(_)`, the full file set is + /// replaced (existing rows in `skill_files` are removed, then the new + /// set is inserted) and `total_bytes` is recomputed. + async fn update(&self, id: Uuid, input: UpdateSkill) -> DbResult; + + /// Soft-delete a skill. + async fn delete(&self, id: Uuid) -> DbResult<()>; +} diff --git a/src/db/sqlite/mod.rs b/src/db/sqlite/mod.rs index 45824c0..5b1d572 100644 --- a/src/db/sqlite/mod.rs +++ b/src/db/sqlite/mod.rs @@ -20,6 +20,7 @@ mod scim_group_mappings; #[cfg(feature = "sso")] mod scim_user_mappings; mod service_accounts; +mod skills; #[cfg(feature = "sso")] mod sso_group_mappings; mod teams; @@ -48,6 +49,7 @@ pub use scim_group_mappings::SqliteScimGroupMappingRepo; #[cfg(feature = "sso")] pub use scim_user_mappings::SqliteScimUserMappingRepo; pub use service_accounts::SqliteServiceAccountRepo; +pub use skills::SqliteSkillRepo; #[cfg(feature = "sso")] pub use sso_group_mappings::SqliteSsoGroupMappingRepo; pub use teams::SqliteTeamRepo; diff --git a/src/db/sqlite/skills.rs b/src/db/sqlite/skills.rs new file mode 100644 index 0000000..7f5ef07 --- /dev/null +++ b/src/db/sqlite/skills.rs @@ -0,0 +1,1261 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use uuid::Uuid; + +use super::{ + backend::{Pool, Row, RowExt, begin, map_unique_violation, query}, + common::parse_uuid, +}; +use crate::{ + db::{ + error::{DbError, DbResult}, + repos::{ + Cursor, CursorDirection, ListParams, ListResult, PageCursors, SkillRepo, + cursor_from_row, truncate_to_millis, + }, + }, + models::{ + CreateSkill, Skill, SkillFile, SkillFileInput, SkillFileManifest, SkillOwnerType, + UpdateSkill, + }, +}; + +const SKILL_COLUMNS: &str = "id, owner_type, owner_id, name, description, user_invocable, \ + disable_model_invocation, allowed_tools, argument_hint, source_url, source_ref, \ + frontmatter_extra, total_bytes, created_at, updated_at"; + +pub struct SqliteSkillRepo { + pool: Pool, +} + +impl SqliteSkillRepo { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + /// Sniff a MIME type from a file path's extension. Falls back to + /// `text/plain` for unknown extensions. + fn sniff_content_type(path: &str) -> &'static str { + let lower = path.to_ascii_lowercase(); + match lower.rsplit_once('.').map(|(_, ext)| ext) { + Some("md") | Some("markdown") => "text/markdown", + Some("py") => "text/x-python", + Some("js") | Some("mjs") | Some("cjs") => "text/javascript", + Some("ts") => "text/typescript", + Some("sh") | Some("bash") => "text/x-shellscript", + Some("json") => "application/json", + Some("yaml") | Some("yml") => "application/yaml", + Some("toml") => "application/toml", + Some("html") | Some("htm") => "text/html", + Some("css") => "text/css", + Some("csv") => "text/csv", + Some("txt") | None => "text/plain", + _ => "text/plain", + } + } + + /// Parse a skill row (no files attached). + fn parse_skill(row: &Row) -> DbResult { + let owner_type_str: String = row.col("owner_type"); + let owner_type: SkillOwnerType = owner_type_str.parse().map_err(DbError::Internal)?; + + let allowed_tools: Option = row.col("allowed_tools"); + let allowed_tools: Option> = allowed_tools + .map(|s| serde_json::from_str(&s)) + .transpose() + .map_err(|e| DbError::Internal(format!("Failed to parse allowed_tools: {}", e)))?; + + let frontmatter_extra: Option = row.col("frontmatter_extra"); + let frontmatter_extra: Option> = frontmatter_extra + .map(|s| serde_json::from_str(&s)) + .transpose() + .map_err(|e| { + DbError::Internal(format!("Failed to parse frontmatter_extra: {}", e)) + })?; + + let user_invocable: Option = row.col("user_invocable"); + let disable_model_invocation: Option = row.col("disable_model_invocation"); + + Ok(Skill { + id: parse_uuid(&row.col::("id"))?, + owner_type, + owner_id: parse_uuid(&row.col::("owner_id"))?, + name: row.col("name"), + description: row.col("description"), + user_invocable: user_invocable.map(|n| n != 0), + disable_model_invocation: disable_model_invocation.map(|n| n != 0), + allowed_tools, + argument_hint: row.col("argument_hint"), + source_url: row.col("source_url"), + source_ref: row.col("source_ref"), + frontmatter_extra, + total_bytes: row.col("total_bytes"), + files: Vec::new(), + files_manifest: Vec::new(), + created_at: row.col("created_at"), + updated_at: row.col("updated_at"), + }) + } + + fn parse_file(row: &Row) -> SkillFile { + SkillFile { + path: row.col("path"), + content: row.col("content"), + byte_size: row.col("byte_size"), + content_type: row.col("content_type"), + created_at: row.col("created_at"), + updated_at: row.col("updated_at"), + } + } + + fn parse_manifest(row: &Row) -> SkillFileManifest { + SkillFileManifest { + path: row.col("path"), + byte_size: row.col("byte_size"), + content_type: row.col("content_type"), + } + } + + /// Load all files for a single skill, sorted by path. + async fn load_files(&self, skill_id: Uuid) -> DbResult> { + let rows = query( + r#" + SELECT path, content, byte_size, content_type, created_at, updated_at + FROM skill_files + WHERE skill_id = ? + ORDER BY path ASC + "#, + ) + .bind(skill_id.to_string()) + .fetch_all(&self.pool) + .await?; + + Ok(rows.iter().map(Self::parse_file).collect()) + } + + /// Load file manifests for many skills and attach them. Used by list + /// endpoints so each returned skill has `files_manifest` populated. + async fn attach_manifests(&self, skills: &mut [Skill]) -> DbResult<()> { + if skills.is_empty() { + return Ok(()); + } + + // Build the IN-clause with placeholders matching the skill count. + let placeholders = std::iter::repeat_n("?", skills.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT skill_id, path, byte_size, content_type FROM skill_files \ + WHERE skill_id IN ({}) ORDER BY path ASC", + placeholders + ); + + let mut q = query(&sql); + for skill in skills.iter() { + q = q.bind(skill.id.to_string()); + } + let rows = q.fetch_all(&self.pool).await?; + + let mut by_skill: HashMap> = HashMap::new(); + for row in rows.iter() { + let skill_id = parse_uuid(&row.col::("skill_id"))?; + by_skill + .entry(skill_id) + .or_default() + .push(Self::parse_manifest(row)); + } + + for skill in skills.iter_mut() { + if let Some(manifest) = by_skill.remove(&skill.id) { + skill.files_manifest = manifest; + } + } + Ok(()) + } + + /// Org-scoped WHERE clause for skills reachable within an organization. + const ORG_SCOPE_FILTER: &'static str = r#" + AND ( + (s.owner_type = 'organization' AND s.owner_id = ?) + OR + (s.owner_type = 'team' AND EXISTS ( + SELECT 1 FROM teams t WHERE t.id = s.owner_id AND t.org_id = ? + )) + OR + (s.owner_type = 'project' AND EXISTS ( + SELECT 1 FROM projects pr WHERE pr.id = s.owner_id AND pr.org_id = ? + )) + OR + (s.owner_type = 'user' AND EXISTS ( + SELECT 1 FROM org_memberships om WHERE om.user_id = s.owner_id AND om.org_id = ? + )) + ) + "#; + + async fn list_by_owner_with_cursor( + &self, + owner_type: SkillOwnerType, + owner_id: Uuid, + params: &ListParams, + cursor: &Cursor, + fetch_limit: i64, + limit: i64, + ) -> DbResult> { + let (comparison, order, should_reverse) = + params.sort_order.cursor_query_params(params.direction); + + let deleted_filter = if params.include_deleted { + "" + } else { + "AND deleted_at IS NULL" + }; + + let sql = format!( + "SELECT {cols} FROM skills \ + WHERE owner_type = ? AND owner_id = ? AND (created_at, id) {cmp} (?, ?) \ + {deleted_filter} \ + ORDER BY created_at {order}, id {order} LIMIT ?", + cols = SKILL_COLUMNS, + cmp = comparison, + deleted_filter = deleted_filter, + order = order, + ); + + let rows = query(&sql) + .bind(owner_type.as_str()) + .bind(owner_id.to_string()) + .bind(cursor.created_at) + .bind(cursor.id.to_string()) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await?; + + let has_more = rows.len() as i64 > limit; + let mut items: Vec = rows + .iter() + .take(limit as usize) + .map(Self::parse_skill) + .collect::>>()?; + + if should_reverse { + items.reverse(); + } + self.attach_manifests(&mut items).await?; + + let cursors = + PageCursors::from_items(&items, has_more, params.direction, Some(cursor), |s| { + cursor_from_row(s.created_at, s.id) + }); + + Ok(ListResult::new(items, has_more, cursors)) + } +} + +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +impl SkillRepo for SqliteSkillRepo { + async fn create(&self, input: CreateSkill) -> DbResult { + let id = Uuid::new_v4(); + let now = truncate_to_millis(chrono::Utc::now()); + let owner_type = input.owner.owner_type(); + let owner_id = input.owner.owner_id(); + + let allowed_tools_json = input + .allowed_tools + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| DbError::Internal(format!("Failed to serialize allowed_tools: {}", e)))?; + let frontmatter_extra_json = input + .frontmatter_extra + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| { + DbError::Internal(format!("Failed to serialize frontmatter_extra: {}", e)) + })?; + + // Compute byte sizes up front so total_bytes matches the file rows. + let files_with_size: Vec<(SkillFileInput, i64, String)> = input + .files + .iter() + .map(|f| { + let size = f.content.len() as i64; + let ct = f + .content_type + .clone() + .unwrap_or_else(|| Self::sniff_content_type(&f.path).to_string()); + (f.clone(), size, ct) + }) + .collect(); + let total_bytes: i64 = files_with_size.iter().map(|(_, s, _)| *s).sum(); + + let mut tx = begin(&self.pool).await?; + + query( + r#" + INSERT INTO skills ( + id, owner_type, owner_id, name, description, + user_invocable, disable_model_invocation, allowed_tools, + argument_hint, source_url, source_ref, frontmatter_extra, + total_bytes, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(id.to_string()) + .bind(owner_type.as_str()) + .bind(owner_id.to_string()) + .bind(&input.name) + .bind(&input.description) + .bind(input.user_invocable.map(|b| if b { 1i64 } else { 0i64 })) + .bind( + input + .disable_model_invocation + .map(|b| if b { 1i64 } else { 0i64 }), + ) + .bind(&allowed_tools_json) + .bind(&input.argument_hint) + .bind(&input.source_url) + .bind(&input.source_ref) + .bind(&frontmatter_extra_json) + .bind(total_bytes) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await + .map_err(map_unique_violation(format!( + "Skill with name '{}' already exists for this owner", + input.name + )))?; + + for (file, size, content_type) in files_with_size.iter() { + query( + r#" + INSERT INTO skill_files ( + skill_id, path, content, byte_size, content_type, + created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(id.to_string()) + .bind(&file.path) + .bind(&file.content) + .bind(*size) + .bind(content_type) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + let mut skill = self + .get_by_id(id) + .await? + .ok_or_else(|| DbError::Internal("Skill vanished after create".into()))?; + // get_by_id already populates files; belt and braces: + if skill.files.is_empty() { + skill.files = self.load_files(id).await?; + } + Ok(skill) + } + + async fn get_by_id(&self, id: Uuid) -> DbResult> { + let sql = format!( + "SELECT {cols} FROM skills WHERE id = ? AND deleted_at IS NULL", + cols = SKILL_COLUMNS + ); + let result = query(&sql) + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await?; + + match result { + Some(row) => { + let mut skill = Self::parse_skill(&row)?; + skill.files = self.load_files(id).await?; + Ok(Some(skill)) + } + None => Ok(None), + } + } + + async fn get_by_id_and_org(&self, id: Uuid, org_id: Uuid) -> DbResult> { + let sql = format!( + "SELECT {cols} FROM skills s \ + WHERE s.id = ? AND s.deleted_at IS NULL {scope}", + cols = SKILL_COLUMNS + .split(", ") + .map(|c| format!("s.{}", c)) + .collect::>() + .join(", "), + scope = Self::ORG_SCOPE_FILTER, + ); + let result = query(&sql) + .bind(id.to_string()) + .bind(org_id.to_string()) + .bind(org_id.to_string()) + .bind(org_id.to_string()) + .bind(org_id.to_string()) + .fetch_optional(&self.pool) + .await?; + + match result { + Some(row) => { + let mut skill = Self::parse_skill(&row)?; + skill.files = self.load_files(id).await?; + Ok(Some(skill)) + } + None => Ok(None), + } + } + + async fn list_by_owner( + &self, + owner_type: SkillOwnerType, + owner_id: Uuid, + params: ListParams, + ) -> DbResult> { + let limit = params.limit.unwrap_or(100); + let fetch_limit = limit + 1; + + if let Some(ref cursor) = params.cursor { + return self + .list_by_owner_with_cursor(owner_type, owner_id, ¶ms, cursor, fetch_limit, limit) + .await; + } + + let deleted_filter = if params.include_deleted { + "" + } else { + "AND deleted_at IS NULL" + }; + let sql = format!( + "SELECT {cols} FROM skills \ + WHERE owner_type = ? AND owner_id = ? {deleted_filter} \ + ORDER BY created_at DESC, id DESC LIMIT ?", + cols = SKILL_COLUMNS, + deleted_filter = deleted_filter + ); + + let rows = query(&sql) + .bind(owner_type.as_str()) + .bind(owner_id.to_string()) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await?; + + let has_more = rows.len() as i64 > limit; + let mut items: Vec = rows + .iter() + .take(limit as usize) + .map(Self::parse_skill) + .collect::>>()?; + self.attach_manifests(&mut items).await?; + + let cursors = + PageCursors::from_items(&items, has_more, CursorDirection::Forward, None, |s| { + cursor_from_row(s.created_at, s.id) + }); + + Ok(ListResult::new(items, has_more, cursors)) + } + + async fn list_by_org( + &self, + org_id: Uuid, + params: ListParams, + ) -> DbResult> { + let limit = params.limit.unwrap_or(100); + let fetch_limit = limit + 1; + let org_str = org_id.to_string(); + + // Prefix every core column with `s.` for the aliased query. + let cols: String = SKILL_COLUMNS + .split(", ") + .map(|c| format!("s.{}", c)) + .collect::>() + .join(", "); + + if let Some(ref cursor) = params.cursor { + let (comparison, order, should_reverse) = + params.sort_order.cursor_query_params(params.direction); + + let sql = format!( + "SELECT {cols} FROM skills s \ + WHERE s.deleted_at IS NULL AND (s.created_at, s.id) {cmp} (?, ?) \ + {scope} \ + ORDER BY s.created_at {order}, s.id {order} LIMIT ?", + cols = cols, + cmp = comparison, + scope = Self::ORG_SCOPE_FILTER, + order = order, + ); + + let rows = query(&sql) + .bind(cursor.created_at) + .bind(cursor.id.to_string()) + .bind(&org_str) + .bind(&org_str) + .bind(&org_str) + .bind(&org_str) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await?; + + let has_more = rows.len() as i64 > limit; + let mut items: Vec = rows + .iter() + .take(limit as usize) + .map(Self::parse_skill) + .collect::>>()?; + + if should_reverse { + items.reverse(); + } + self.attach_manifests(&mut items).await?; + + let cursors = + PageCursors::from_items(&items, has_more, params.direction, Some(cursor), |s| { + cursor_from_row(s.created_at, s.id) + }); + + return Ok(ListResult::new(items, has_more, cursors)); + } + + let sql = format!( + "SELECT {cols} FROM skills s \ + WHERE s.deleted_at IS NULL {scope} \ + ORDER BY s.created_at DESC, s.id DESC LIMIT ?", + cols = cols, + scope = Self::ORG_SCOPE_FILTER, + ); + + let rows = query(&sql) + .bind(&org_str) + .bind(&org_str) + .bind(&org_str) + .bind(&org_str) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await?; + + let has_more = rows.len() as i64 > limit; + let mut items: Vec = rows + .iter() + .take(limit as usize) + .map(Self::parse_skill) + .collect::>>()?; + self.attach_manifests(&mut items).await?; + + let cursors = + PageCursors::from_items(&items, has_more, CursorDirection::Forward, None, |s| { + cursor_from_row(s.created_at, s.id) + }); + + Ok(ListResult::new(items, has_more, cursors)) + } + + async fn count_by_owner( + &self, + owner_type: SkillOwnerType, + owner_id: Uuid, + include_deleted: bool, + ) -> DbResult { + let sql = if include_deleted { + "SELECT COUNT(*) AS count FROM skills WHERE owner_type = ? AND owner_id = ?" + } else { + "SELECT COUNT(*) AS count FROM skills WHERE owner_type = ? AND owner_id = ? \ + AND deleted_at IS NULL" + }; + + let row = query(sql) + .bind(owner_type.as_str()) + .bind(owner_id.to_string()) + .fetch_one(&self.pool) + .await?; + + Ok(row.col::("count")) + } + + async fn update(&self, id: Uuid, input: UpdateSkill) -> DbResult { + let UpdateSkill { + name, + description, + files, + user_invocable, + disable_model_invocation, + allowed_tools, + argument_hint, + source_url, + source_ref, + frontmatter_extra, + } = input; + + let has_changes = name.is_some() + || description.is_some() + || files.is_some() + || user_invocable.is_some() + || disable_model_invocation.is_some() + || allowed_tools.is_some() + || argument_hint.is_some() + || source_url.is_some() + || source_ref.is_some() + || frontmatter_extra.is_some(); + + if !has_changes { + return self.get_by_id(id).await?.ok_or(DbError::NotFound); + } + + let now = truncate_to_millis(chrono::Utc::now()); + + let files_with_size: Option> = files.as_ref().map(|fs| { + fs.iter() + .map(|f| { + let size = f.content.len() as i64; + let ct = f + .content_type + .clone() + .unwrap_or_else(|| Self::sniff_content_type(&f.path).to_string()); + (f.clone(), size, ct) + }) + .collect() + }); + let new_total_bytes: Option = files_with_size + .as_ref() + .map(|v| v.iter().map(|(_, s, _)| *s).sum()); + + let allowed_tools_json = allowed_tools + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| DbError::Internal(format!("Failed to serialize allowed_tools: {}", e)))?; + let frontmatter_extra_json = frontmatter_extra + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| { + DbError::Internal(format!("Failed to serialize frontmatter_extra: {}", e)) + })?; + + let mut set_clauses: Vec<&str> = vec!["updated_at = ?"]; + if name.is_some() { + set_clauses.push("name = ?"); + } + if description.is_some() { + set_clauses.push("description = ?"); + } + if user_invocable.is_some() { + set_clauses.push("user_invocable = ?"); + } + if disable_model_invocation.is_some() { + set_clauses.push("disable_model_invocation = ?"); + } + if allowed_tools.is_some() { + set_clauses.push("allowed_tools = ?"); + } + if argument_hint.is_some() { + set_clauses.push("argument_hint = ?"); + } + if source_url.is_some() { + set_clauses.push("source_url = ?"); + } + if source_ref.is_some() { + set_clauses.push("source_ref = ?"); + } + if frontmatter_extra.is_some() { + set_clauses.push("frontmatter_extra = ?"); + } + if new_total_bytes.is_some() { + set_clauses.push("total_bytes = ?"); + } + + let sql = format!( + "UPDATE skills SET {} WHERE id = ? AND deleted_at IS NULL", + set_clauses.join(", ") + ); + + let mut tx = begin(&self.pool).await?; + + let mut q = query(&sql).bind(now); + if let Some(ref v) = name { + q = q.bind(v); + } + if let Some(ref v) = description { + q = q.bind(v); + } + if let Some(v) = user_invocable { + q = q.bind(if v { 1i64 } else { 0i64 }); + } + if let Some(v) = disable_model_invocation { + q = q.bind(if v { 1i64 } else { 0i64 }); + } + if allowed_tools.is_some() { + q = q.bind(allowed_tools_json.clone()); + } + if let Some(ref v) = argument_hint { + q = q.bind(v); + } + if let Some(ref v) = source_url { + q = q.bind(v); + } + if let Some(ref v) = source_ref { + q = q.bind(v); + } + if frontmatter_extra.is_some() { + q = q.bind(frontmatter_extra_json.clone()); + } + if let Some(total) = new_total_bytes { + q = q.bind(total); + } + q = q.bind(id.to_string()); + + let result = q + .execute(&mut *tx) + .await + .map_err(map_unique_violation( + "Skill with this name already exists for this owner", + ))?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound); + } + + // Replace the file set if provided. + if let Some(new_files) = files_with_size { + query(r#"DELETE FROM skill_files WHERE skill_id = ?"#) + .bind(id.to_string()) + .execute(&mut *tx) + .await?; + + for (file, size, content_type) in new_files.iter() { + query( + r#" + INSERT INTO skill_files ( + skill_id, path, content, byte_size, content_type, + created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(id.to_string()) + .bind(&file.path) + .bind(&file.content) + .bind(*size) + .bind(content_type) + .bind(now) + .bind(now) + .execute(&mut *tx) + .await?; + } + } + + tx.commit().await?; + + self.get_by_id(id).await?.ok_or(DbError::NotFound) + } + + async fn delete(&self, id: Uuid) -> DbResult<()> { + let now = truncate_to_millis(chrono::Utc::now()); + + let result = query( + r#" + UPDATE skills + SET deleted_at = ? + WHERE id = ? AND deleted_at IS NULL + "#, + ) + .bind(now) + .bind(id.to_string()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use sqlx::SqlitePool; + + use super::*; + use crate::models::{SkillFileInput, SkillOwner}; + + async fn create_test_pool() -> SqlitePool { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("Failed to create in-memory SQLite pool"); + + sqlx::query( + r#" + CREATE TABLE skills ( + id TEXT PRIMARY KEY NOT NULL, + owner_type TEXT NOT NULL CHECK (owner_type IN ('organization', 'team', 'project', 'user')), + owner_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + user_invocable INTEGER, + disable_model_invocation INTEGER, + allowed_tools TEXT, + argument_hint TEXT, + source_url TEXT, + source_ref TEXT, + frontmatter_extra TEXT, + total_bytes INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + UNIQUE(owner_type, owner_id, name) + ) + "#, + ) + .execute(&pool) + .await + .expect("Failed to create skills table"); + + sqlx::query( + r#" + CREATE TABLE skill_files ( + skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + path TEXT NOT NULL, + content TEXT NOT NULL, + byte_size INTEGER NOT NULL, + content_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(skill_id, path) + ) + "#, + ) + .execute(&pool) + .await + .expect("Failed to create skill_files table"); + + pool + } + + fn skill_main_file(body: &str) -> SkillFileInput { + SkillFileInput { + path: "SKILL.md".into(), + content: body.into(), + content_type: None, + } + } + + fn create_skill_input(name: &str, body: &str, user_id: Uuid) -> CreateSkill { + CreateSkill { + owner: SkillOwner::User { user_id }, + name: name.into(), + description: "Test skill description.".into(), + files: vec![skill_main_file(body)], + user_invocable: None, + disable_model_invocation: None, + allowed_tools: None, + argument_hint: None, + source_url: None, + source_ref: None, + frontmatter_extra: None, + } + } + + #[tokio::test] + async fn create_skill_stores_main_file_and_total_bytes() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let user_id = Uuid::new_v4(); + + let skill = repo + .create(create_skill_input("code-review", "Review code.", user_id)) + .await + .expect("create should succeed"); + + assert_eq!(skill.name, "code-review"); + assert_eq!(skill.owner_type, SkillOwnerType::User); + assert_eq!(skill.owner_id, user_id); + assert_eq!(skill.files.len(), 1); + assert_eq!(skill.files[0].path, "SKILL.md"); + assert_eq!(skill.files[0].content, "Review code."); + assert_eq!(skill.files[0].content_type, "text/markdown"); + assert_eq!(skill.files[0].byte_size, "Review code.".len() as i64); + assert_eq!(skill.total_bytes, "Review code.".len() as i64); + } + + #[tokio::test] + async fn create_skill_with_bundled_files_sums_total_bytes() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let user_id = Uuid::new_v4(); + + let input = CreateSkill { + owner: SkillOwner::User { user_id }, + name: "pdf-processing".into(), + description: "Extract PDF text.".into(), + files: vec![ + skill_main_file("Use scripts/extract.py."), + SkillFileInput { + path: "scripts/extract.py".into(), + content: "print('ok')".into(), + content_type: None, + }, + SkillFileInput { + path: "references/REFERENCE.md".into(), + content: "# Reference".into(), + content_type: None, + }, + ], + user_invocable: Some(true), + disable_model_invocation: Some(false), + allowed_tools: Some(vec!["Bash(python:*)".into()]), + argument_hint: Some("[file]".into()), + source_url: None, + source_ref: None, + frontmatter_extra: None, + }; + + let expected_total = ("Use scripts/extract.py.".len() + + "print('ok')".len() + + "# Reference".len()) as i64; + + let skill = repo.create(input).await.expect("create should succeed"); + assert_eq!(skill.files.len(), 3); + assert_eq!(skill.total_bytes, expected_total); + + // File paths sorted alphabetically by load_files. + assert_eq!(skill.files[0].path, "SKILL.md"); + assert_eq!(skill.files[1].path, "references/REFERENCE.md"); + assert_eq!(skill.files[2].path, "scripts/extract.py"); + + // Content types sniffed from extension. + assert_eq!(skill.files[0].content_type, "text/markdown"); + assert_eq!(skill.files[1].content_type, "text/markdown"); + assert_eq!(skill.files[2].content_type, "text/x-python"); + + // Frontmatter fields round-tripped. + assert_eq!(skill.user_invocable, Some(true)); + assert_eq!(skill.disable_model_invocation, Some(false)); + assert_eq!(skill.allowed_tools.as_deref(), Some(&["Bash(python:*)".to_string()][..])); + assert_eq!(skill.argument_hint.as_deref(), Some("[file]")); + } + + #[tokio::test] + async fn create_duplicate_name_per_owner_fails() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let user_id = Uuid::new_v4(); + + repo.create(create_skill_input("dup", "a", user_id)) + .await + .expect("first create succeeds"); + + let result = repo + .create(create_skill_input("dup", "b", user_id)) + .await; + assert!(matches!(result, Err(DbError::Conflict(_)))); + } + + #[tokio::test] + async fn same_name_different_owners_succeeds() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let u1 = Uuid::new_v4(); + let u2 = Uuid::new_v4(); + + repo.create(create_skill_input("same", "x", u1)) + .await + .unwrap(); + repo.create(create_skill_input("same", "y", u2)) + .await + .unwrap(); + } + + #[tokio::test] + async fn get_by_id_returns_full_file_contents() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let user_id = Uuid::new_v4(); + + let created = repo + .create(create_skill_input("lookup", "Body.", user_id)) + .await + .unwrap(); + + let fetched = repo.get_by_id(created.id).await.unwrap().unwrap(); + assert_eq!(fetched.id, created.id); + assert_eq!(fetched.files.len(), 1); + assert_eq!(fetched.files[0].content, "Body."); + } + + #[tokio::test] + async fn get_by_id_missing() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + assert!(repo.get_by_id(Uuid::new_v4()).await.unwrap().is_none()); + } + + #[tokio::test] + async fn list_by_owner_populates_files_manifest_but_not_content() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let user_id = Uuid::new_v4(); + + let input = CreateSkill { + owner: SkillOwner::User { user_id }, + name: "s1".into(), + description: "d".into(), + files: vec![ + skill_main_file("body"), + SkillFileInput { + path: "notes.txt".into(), + content: "hi".into(), + content_type: None, + }, + ], + user_invocable: None, + disable_model_invocation: None, + allowed_tools: None, + argument_hint: None, + source_url: None, + source_ref: None, + frontmatter_extra: None, + }; + repo.create(input).await.unwrap(); + + let result = repo + .list_by_owner(SkillOwnerType::User, user_id, ListParams::default()) + .await + .unwrap(); + + assert_eq!(result.items.len(), 1); + let skill = &result.items[0]; + assert!(skill.files.is_empty(), "list should not include file contents"); + assert_eq!(skill.files_manifest.len(), 2); + let paths: Vec<&str> = skill.files_manifest.iter().map(|m| m.path.as_str()).collect(); + assert_eq!(paths, vec!["SKILL.md", "notes.txt"]); + } + + #[tokio::test] + async fn list_by_owner_filters_by_owner() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let u1 = Uuid::new_v4(); + let u2 = Uuid::new_v4(); + + repo.create(create_skill_input("u1-skill", "x", u1)) + .await + .unwrap(); + repo.create(create_skill_input("u2-skill", "y", u2)) + .await + .unwrap(); + + let r1 = repo + .list_by_owner(SkillOwnerType::User, u1, ListParams::default()) + .await + .unwrap(); + let r2 = repo + .list_by_owner(SkillOwnerType::User, u2, ListParams::default()) + .await + .unwrap(); + + assert_eq!(r1.items.len(), 1); + assert_eq!(r1.items[0].name, "u1-skill"); + assert_eq!(r2.items.len(), 1); + assert_eq!(r2.items[0].name, "u2-skill"); + } + + #[tokio::test] + async fn count_by_owner_excludes_deleted() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let user_id = Uuid::new_v4(); + + let a = repo + .create(create_skill_input("a", "a", user_id)) + .await + .unwrap(); + repo.create(create_skill_input("b", "b", user_id)) + .await + .unwrap(); + repo.delete(a.id).await.unwrap(); + + let live = repo + .count_by_owner(SkillOwnerType::User, user_id, false) + .await + .unwrap(); + let all = repo + .count_by_owner(SkillOwnerType::User, user_id, true) + .await + .unwrap(); + assert_eq!(live, 1); + assert_eq!(all, 2); + } + + #[tokio::test] + async fn update_replaces_file_set_and_total_bytes() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let user_id = Uuid::new_v4(); + + let created = repo + .create(create_skill_input("rewrite", "original", user_id)) + .await + .unwrap(); + assert_eq!(created.total_bytes, "original".len() as i64); + + let new_files = vec![ + SkillFileInput { + path: "SKILL.md".into(), + content: "replaced".into(), + content_type: None, + }, + SkillFileInput { + path: "extra.txt".into(), + content: "more".into(), + content_type: None, + }, + ]; + + let updated = repo + .update( + created.id, + UpdateSkill { + files: Some(new_files), + description: Some("Updated desc.".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(updated.description, "Updated desc."); + assert_eq!(updated.files.len(), 2); + let expected = ("replaced".len() + "more".len()) as i64; + assert_eq!(updated.total_bytes, expected); + + // Old file content is gone — no stale paths. + let paths: Vec<&str> = updated.files.iter().map(|f| f.path.as_str()).collect(); + assert_eq!(paths, vec!["SKILL.md", "extra.txt"]); + } + + #[tokio::test] + async fn update_with_no_changes_returns_existing() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let user_id = Uuid::new_v4(); + + let created = repo + .create(create_skill_input("noop", "body", user_id)) + .await + .unwrap(); + + let same = repo + .update(created.id, UpdateSkill::default()) + .await + .unwrap(); + assert_eq!(same.name, "noop"); + assert_eq!(same.total_bytes, created.total_bytes); + } + + #[tokio::test] + async fn update_not_found() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let result = repo + .update( + Uuid::new_v4(), + UpdateSkill { + description: Some("x".into()), + ..Default::default() + }, + ) + .await; + assert!(matches!(result, Err(DbError::NotFound))); + } + + #[tokio::test] + async fn delete_soft_deletes() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let user_id = Uuid::new_v4(); + + let created = repo + .create(create_skill_input("gone", "body", user_id)) + .await + .unwrap(); + + repo.delete(created.id).await.unwrap(); + assert!(repo.get_by_id(created.id).await.unwrap().is_none()); + + let list = repo + .list_by_owner(SkillOwnerType::User, user_id, ListParams::default()) + .await + .unwrap(); + assert!(list.items.is_empty()); + } + + #[tokio::test] + async fn delete_not_found() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + let result = repo.delete(Uuid::new_v4()).await; + assert!(matches!(result, Err(DbError::NotFound))); + } + + #[tokio::test] + async fn different_owner_types_are_scoped() { + let pool = create_test_pool().await; + let repo = SqliteSkillRepo::new(pool); + + let org_id = Uuid::new_v4(); + let team_id = Uuid::new_v4(); + let project_id = Uuid::new_v4(); + let user_id = Uuid::new_v4(); + + for (name, owner) in [ + ("org-skill", SkillOwner::Organization { organization_id: org_id }), + ("team-skill", SkillOwner::Team { team_id }), + ("project-skill", SkillOwner::Project { project_id }), + ("user-skill", SkillOwner::User { user_id }), + ] { + repo.create(CreateSkill { + owner, + name: name.into(), + description: "d".into(), + files: vec![skill_main_file("b")], + user_invocable: None, + disable_model_invocation: None, + allowed_tools: None, + argument_hint: None, + source_url: None, + source_ref: None, + frontmatter_extra: None, + }) + .await + .unwrap(); + } + + let check = |ot, id| { + let repo = &repo; + async move { + repo.list_by_owner(ot, id, ListParams::default()) + .await + .unwrap() + .items + } + }; + + assert_eq!(check(SkillOwnerType::Organization, org_id).await.len(), 1); + assert_eq!(check(SkillOwnerType::Team, team_id).await.len(), 1); + assert_eq!(check(SkillOwnerType::Project, project_id).await.len(), 1); + assert_eq!(check(SkillOwnerType::User, user_id).await.len(), 1); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 7701343..ed3bba0 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -18,6 +18,7 @@ mod ranking_options; #[cfg(feature = "sso")] mod scim; mod service_account; +mod skill; #[cfg(feature = "sso")] mod sso_group_mapping; mod team; @@ -47,6 +48,7 @@ pub use ranking_options::*; #[cfg(feature = "sso")] pub use scim::*; pub use service_account::*; +pub use skill::*; #[cfg(feature = "sso")] pub use sso_group_mapping::*; pub use team::*; diff --git a/src/models/skill.rs b/src/models/skill.rs new file mode 100644 index 0000000..837f07f --- /dev/null +++ b/src/models/skill.rs @@ -0,0 +1,346 @@ +//! Agent Skills per https://agentskills.io/specification.md. +//! +//! A skill is a packaged set of instructions (SKILL.md) plus optional +//! bundled files (scripts, references, assets). Hadrian's extension of the +//! spec is that every skill is owned by an organization, team, project, or +//! user — matching the ownership model used by prompt templates. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use validator::{Validate, ValidationError}; + +/// The filename of the required main instructions file in every skill. +pub const SKILL_MAIN_FILE: &str = "SKILL.md"; + +/// Owner type for skills (organization, team, project, or user). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum SkillOwnerType { + Organization, + Team, + Project, + User, +} + +impl SkillOwnerType { + pub fn as_str(&self) -> &'static str { + match self { + SkillOwnerType::Organization => "organization", + SkillOwnerType::Team => "team", + SkillOwnerType::Project => "project", + SkillOwnerType::User => "user", + } + } +} + +impl std::str::FromStr for SkillOwnerType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "organization" => Ok(SkillOwnerType::Organization), + "team" => Ok(SkillOwnerType::Team), + "project" => Ok(SkillOwnerType::Project), + "user" => Ok(SkillOwnerType::User), + _ => Err(format!("Invalid skill owner type: {}", s)), + } + } +} + +/// Validate skill `name` per https://agentskills.io/specification.md: +/// 1..=64 chars, lowercase ASCII alphanumeric or hyphen, no leading or +/// trailing hyphen, no consecutive hyphens. +pub fn validate_skill_name(name: &str) -> Result<(), ValidationError> { + if !(1..=64).contains(&name.len()) { + return Err(ValidationError::new("skill_name_length")); + } + if name.starts_with('-') || name.ends_with('-') { + return Err(ValidationError::new("skill_name_hyphen_boundary")); + } + if name.contains("--") { + return Err(ValidationError::new("skill_name_consecutive_hyphens")); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(ValidationError::new("skill_name_charset")); + } + Ok(()) +} + +/// Validate a relative skill-file path. No absolute paths, no `..` segments, +/// no empty segments, 1..=255 bytes. +pub fn validate_skill_path(path: &str) -> Result<(), ValidationError> { + if path.is_empty() || path.len() > 255 { + return Err(ValidationError::new("skill_path_length")); + } + if path.starts_with('/') || path.starts_with('\\') { + return Err(ValidationError::new("skill_path_absolute")); + } + for seg in path.split(|c: char| c == '/' || c == '\\') { + if seg.is_empty() || seg == ".." { + return Err(ValidationError::new("skill_path_traversal")); + } + } + Ok(()) +} + +/// A file bundled with a skill. Returned in full detail by get-by-id; list +/// endpoints populate [`SkillFileManifest`] instead. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct SkillFile { + /// Relative path inside the skill, e.g. "SKILL.md" or "scripts/extract.py". + pub path: String, + /// File contents. Text-only in v1 (binary assets unsupported). + pub content: String, + /// Byte length of `content`. + pub byte_size: i64, + /// MIME type, e.g. "text/markdown". + pub content_type: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Lightweight file entry returned by list endpoints — contents omitted. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct SkillFileManifest { + pub path: String, + pub byte_size: i64, + pub content_type: String, +} + +/// A packaged Agent Skill. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct Skill { + pub id: Uuid, + pub owner_type: SkillOwnerType, + pub owner_id: Uuid, + /// Skill name (unique per owner). See [`validate_skill_name`]. + pub name: String, + /// Human-readable description. Used by the model to decide when to + /// invoke the skill. + pub description: String, + + /// If `false`, the skill is hidden from the user-visible slash-command + /// list. `None` = unset (defaults to `true`). + #[serde(skip_serializing_if = "Option::is_none")] + pub user_invocable: Option, + /// If `true`, the model cannot auto-invoke this skill. `None` = unset + /// (defaults to `false`). + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_model_invocation: Option, + /// Tools the skill is allowed to use. Informational; the chat UI may + /// use this to pre-approve tools while the skill is active. + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_tools: Option>, + /// Hint shown during autocomplete to describe expected arguments. + #[serde(skip_serializing_if = "Option::is_none")] + pub argument_hint: Option, + /// Origin URL if imported (e.g. a GitHub tree URL). + #[serde(skip_serializing_if = "Option::is_none")] + pub source_url: Option, + /// Git ref if imported. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_ref: Option, + /// Unknown / forward-compat frontmatter keys preserved verbatim. + #[serde(skip_serializing_if = "Option::is_none")] + pub frontmatter_extra: Option>, + + /// Cached total size across all files (bytes). + pub total_bytes: i64, + + /// Full file contents. Populated by get-by-id endpoints. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub files: Vec, + /// File summary (no contents). Populated by list endpoints. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub files_manifest: Vec, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Owner specification for creating a skill. +#[derive(Debug, Clone, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SkillOwner { + Organization { organization_id: Uuid }, + Team { team_id: Uuid }, + Project { project_id: Uuid }, + User { user_id: Uuid }, +} + +impl SkillOwner { + pub fn owner_type(&self) -> SkillOwnerType { + match self { + SkillOwner::Organization { .. } => SkillOwnerType::Organization, + SkillOwner::Team { .. } => SkillOwnerType::Team, + SkillOwner::Project { .. } => SkillOwnerType::Project, + SkillOwner::User { .. } => SkillOwnerType::User, + } + } + + pub fn owner_id(&self) -> Uuid { + match self { + SkillOwner::Organization { organization_id } => *organization_id, + SkillOwner::Team { team_id } => *team_id, + SkillOwner::Project { project_id } => *project_id, + SkillOwner::User { user_id } => *user_id, + } + } +} + +/// A single file in a create/update request. +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct SkillFileInput { + #[validate(custom(function = "validate_skill_path"))] + pub path: String, + #[validate(length(min = 1))] + pub content: String, + /// Optional MIME type. If omitted, the service sniffs it from the path + /// extension. + #[validate(length(max = 127))] + pub content_type: Option, +} + +/// Request to create a new skill. `files` must contain exactly one entry +/// with `path == "SKILL.md"`; the service layer rejects otherwise. +#[derive(Debug, Clone, Deserialize, Validate)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct CreateSkill { + pub owner: SkillOwner, + #[validate(custom(function = "validate_skill_name"))] + pub name: String, + #[validate(length(min = 1, max = 1024))] + pub description: String, + #[validate(length(min = 1), nested)] + pub files: Vec, + + pub user_invocable: Option, + pub disable_model_invocation: Option, + pub allowed_tools: Option>, + #[validate(length(max = 255))] + pub argument_hint: Option, + #[validate(length(max = 2048))] + pub source_url: Option, + #[validate(length(max = 255))] + pub source_ref: Option, + pub frontmatter_extra: Option>, +} + +/// Request to update a skill. Any field that is `Some(_)` replaces the +/// stored value. When `files` is `Some(_)`, the entire file set is +/// replaced. +#[derive(Debug, Clone, Default, Deserialize, Validate)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct UpdateSkill { + #[validate(custom(function = "validate_skill_name"))] + pub name: Option, + #[validate(length(min = 1, max = 1024))] + pub description: Option, + #[validate(nested)] + pub files: Option>, + + pub user_invocable: Option, + pub disable_model_invocation: Option, + pub allowed_tools: Option>, + #[validate(length(max = 255))] + pub argument_hint: Option, + #[validate(length(max = 2048))] + pub source_url: Option, + #[validate(length(max = 255))] + pub source_ref: Option, + pub frontmatter_extra: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skill_name_accepts_valid_examples() { + for name in ["pdf-processing", "data-analysis", "code-review", "a", "abc123"] { + assert!(validate_skill_name(name).is_ok(), "expected {name:?} to be valid"); + } + } + + #[test] + fn skill_name_rejects_bad_examples() { + for name in [ + "", + "PDF-Processing", + "-pdf", + "pdf-", + "pdf--processing", + "pdf_processing", + "pdf processing", + &"x".repeat(65), + ] { + assert!( + validate_skill_name(name).is_err(), + "expected {name:?} to be invalid" + ); + } + } + + #[test] + fn skill_path_accepts_valid_examples() { + for path in [ + "SKILL.md", + "scripts/extract.py", + "references/REFERENCE.md", + "assets/template.txt", + "a/b/c/d.txt", + ] { + assert!(validate_skill_path(path).is_ok(), "expected {path:?} to be valid"); + } + } + + #[test] + fn skill_path_rejects_bad_examples() { + for path in [ + "", + "/absolute/path.md", + "\\windows\\style.md", + "../escape.md", + "ok/../escape.md", + "double//slash.md", + &"x".repeat(256), + ] { + assert!( + validate_skill_path(path).is_err(), + "expected {path:?} to be invalid" + ); + } + } + + #[test] + fn skill_owner_type_roundtrips() { + for ot in [ + SkillOwnerType::Organization, + SkillOwnerType::Team, + SkillOwnerType::Project, + SkillOwnerType::User, + ] { + assert_eq!(ot.as_str().parse::().unwrap(), ot); + } + } + + #[test] + fn skill_owner_extracts_type_and_id() { + let org = Uuid::new_v4(); + let owner = SkillOwner::Organization { organization_id: org }; + assert_eq!(owner.owner_type(), SkillOwnerType::Organization); + assert_eq!(owner.owner_id(), org); + } +} diff --git a/src/openapi.rs b/src/openapi.rs index dd0231c..729c9bd 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -363,6 +363,7 @@ requests_per_minute = 120 (name = "model-pricing", description = "Configure per-model pricing for cost tracking. Pricing can be set globally, per-provider, per-organization, per-project, or per-user."), (name = "conversations", description = "Store and manage chat conversation history. Conversations can be owned by users or projects and support multiple models."), (name = "templates", description = "Manage reusable prompt templates. Templates can be owned by organizations, teams, projects, or users and include metadata for configuration."), + (name = "skills", description = "Manage Agent Skills (https://agentskills.io). A skill packages a SKILL.md instruction file plus optional bundled scripts, references, and assets. Skills can be user-invoked via slash command or auto-invoked by the model via the frontend Skill tool."), (name = "audit-logs", description = "Query audit logs for admin operations. All sensitive operations like API key creation, user permission changes, and resource modifications are logged."), (name = "teams", description = "Teams group users within an organization for easier permission management. Users can belong to multiple teams, and projects can be assigned to a team."), (name = "service_accounts", description = "Service accounts are machine identities that can own API keys and carry roles for RBAC evaluation. They enable unified authorization across human users and automated systems."), @@ -586,6 +587,15 @@ requests_per_minute = 120 admin::templates::list_by_team, admin::templates::list_by_project, admin::templates::list_by_user, + // Admin routes - Skills + admin::skills::create, + admin::skills::get, + admin::skills::update, + admin::skills::delete, + admin::skills::list_by_org, + admin::skills::list_by_team, + admin::skills::list_by_project, + admin::skills::list_by_user, // Admin routes - Provider Management admin::providers::list_circuit_breakers, admin::providers::get_circuit_breaker, @@ -889,6 +899,16 @@ requests_per_minute = 120 models::TemplateOwner, models::TemplateOwnerType, admin::templates::TemplateListResponse, + // Admin models - Skill + models::Skill, + models::SkillFile, + models::SkillFileInput, + models::SkillFileManifest, + models::CreateSkill, + models::UpdateSkill, + models::SkillOwner, + models::SkillOwnerType, + admin::skills::SkillListResponse, // Admin routes - DLQ admin::dlq::DlqListQuery, admin::dlq::DlqEntryResponse, diff --git a/src/routes/admin/mod.rs b/src/routes/admin/mod.rs index eb4c652..3db555e 100644 --- a/src/routes/admin/mod.rs +++ b/src/routes/admin/mod.rs @@ -32,6 +32,7 @@ pub mod sessions; pub mod sso_connections; #[cfg(feature = "sso")] pub mod sso_group_mappings; +pub mod skills; pub mod teams; pub mod templates; pub mod ui_config; @@ -610,6 +611,27 @@ pub(crate) fn admin_v1_routes() -> Router { get(templates::list_by_project), ) .route("/users/{user_id}/templates", get(templates::list_by_user)) + // Skills + .route("/skills", post(skills::create)) + .route( + "/skills/{id}", + get(skills::get) + .merge(patch(skills::update)) + .merge(delete(skills::delete)), + ) + .route( + "/organizations/{org_slug}/skills", + get(skills::list_by_org), + ) + .route( + "/organizations/{org_slug}/teams/{team_slug}/skills", + get(skills::list_by_team), + ) + .route( + "/organizations/{org_slug}/projects/{project_slug}/skills", + get(skills::list_by_project), + ) + .route("/users/{user_id}/skills", get(skills::list_by_user)) // Provider management .route( "/providers/circuit-breakers", diff --git a/src/routes/admin/skills.rs b/src/routes/admin/skills.rs new file mode 100644 index 0000000..c93cab5 --- /dev/null +++ b/src/routes/admin/skills.rs @@ -0,0 +1,528 @@ +use axum::{ + Extension, Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use axum_valid::Valid; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use uuid::Uuid; + +use super::{AuditActor, error::AdminError, organizations::ListQuery}; +use crate::{ + AppState, + middleware::{AdminAuth, AuthzContext, ClientInfo}, + models::{CreateAuditLog, CreateSkill, Skill, SkillOwnerType, UpdateSkill}, + openapi::PaginationMeta, + services::Services, +}; + +/// Paginated list of skills. +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct SkillListResponse { + /// List of skills (file contents omitted; see `files_manifest` on each). + pub data: Vec, + /// Pagination metadata. + pub pagination: PaginationMeta, +} + +fn get_services(state: &AppState) -> Result<&Services, AdminError> { + state.services.as_ref().ok_or(AdminError::ServicesRequired) +} + +/// Map a skill's owner to (org_id, project_id) for audit log correlation. +fn audit_owner(skill: &Skill) -> (Option, Option) { + match skill.owner_type { + SkillOwnerType::Organization => (Some(skill.owner_id), None), + SkillOwnerType::Project => (None, Some(skill.owner_id)), + SkillOwnerType::Team | SkillOwnerType::User => (None, None), + } +} + +/// Create a skill. +#[cfg_attr(feature = "utoipa", utoipa::path( + post, + path = "/admin/v1/skills", + tag = "skills", + operation_id = "skill_create", + request_body = CreateSkill, + responses( + (status = 201, description = "Skill created", body = Skill), + (status = 400, description = "Invalid skill (missing SKILL.md, duplicate path, or size limit exceeded)", body = crate::openapi::ErrorResponse), + (status = 404, description = "Owner not found", body = crate::openapi::ErrorResponse), + (status = 409, description = "Skill with this name already exists for this owner", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.skills.create", skip(state, admin_auth, authz, input))] +pub async fn create( + State(state): State, + Extension(admin_auth): Extension, + Extension(authz): Extension, + Extension(client_info): Extension, + Valid(Json(input)): Valid>, +) -> Result<(StatusCode, Json), AdminError> { + let services = get_services(&state)?; + let actor = AuditActor::from(&admin_auth); + + authz.require("skill", "create", None, None, None, None)?; + + // Enforce per-owner skill count limit. + let max = state.config.limits.resource_limits.max_skills_per_owner; + if max > 0 { + let count = services + .skills + .count_by_owner(input.owner.owner_type(), input.owner.owner_id(), false) + .await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Owner has reached the maximum number of skills ({max})" + ))); + } + } + + let skill = services.skills.create(input).await?; + + let (org_id, project_id) = audit_owner(&skill); + let _ = services + .audit_logs + .create(CreateAuditLog { + actor_type: actor.actor_type, + actor_id: actor.actor_id, + action: "skill.create".to_string(), + resource_type: "skill".to_string(), + resource_id: skill.id, + org_id, + project_id, + details: json!({ + "name": skill.name, + "owner_type": skill.owner_type, + "owner_id": skill.owner_id, + "file_count": skill.files.len(), + "total_bytes": skill.total_bytes, + }), + ip_address: client_info.ip_address, + user_agent: client_info.user_agent, + }) + .await; + + Ok((StatusCode::CREATED, Json(skill))) +} + +/// Get a skill by ID (with full file contents). +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = "/admin/v1/skills/{id}", + tag = "skills", + operation_id = "skill_get", + params(("id" = Uuid, Path, description = "Skill ID")), + responses( + (status = 200, description = "Skill found", body = Skill), + (status = 404, description = "Skill not found", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.skills.get", skip(state, authz), fields(%id))] +pub async fn get( + State(state): State, + Extension(authz): Extension, + Path(id): Path, +) -> Result, AdminError> { + let services = get_services(&state)?; + + authz.require("skill", "read", None, None, None, None)?; + + let skill = services + .skills + .get_by_id(id) + .await? + .ok_or_else(|| AdminError::NotFound("Skill not found".to_string()))?; + + Ok(Json(skill)) +} + +/// Update a skill. +/// +/// When `files` is provided, the full file set is replaced. +#[cfg_attr(feature = "utoipa", utoipa::path( + patch, + path = "/admin/v1/skills/{id}", + tag = "skills", + operation_id = "skill_update", + params(("id" = Uuid, Path, description = "Skill ID")), + request_body = UpdateSkill, + responses( + (status = 200, description = "Skill updated", body = Skill), + (status = 400, description = "Invalid skill (missing SKILL.md, duplicate path, or size limit exceeded)", body = crate::openapi::ErrorResponse), + (status = 404, description = "Skill not found", body = crate::openapi::ErrorResponse), + (status = 409, description = "Skill with this name already exists for this owner", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.skills.update", skip(state, admin_auth, authz, input), fields(%id))] +pub async fn update( + State(state): State, + Extension(admin_auth): Extension, + Extension(authz): Extension, + Extension(client_info): Extension, + Path(id): Path, + Valid(Json(input)): Valid>, +) -> Result, AdminError> { + let services = get_services(&state)?; + let actor = AuditActor::from(&admin_auth); + + authz.require("skill", "update", None, None, None, None)?; + + // Capture a redacted change summary for the audit log (avoids logging + // full file contents). + let changes = json!({ + "name": input.name, + "description": input.description, + "files": input.files.as_ref().map(|fs| json!({ + "count": fs.len(), + "total_bytes": fs.iter().map(|f| f.content.len() as i64).sum::(), + "paths": fs.iter().map(|f| &f.path).collect::>(), + })), + "user_invocable": input.user_invocable, + "disable_model_invocation": input.disable_model_invocation, + "allowed_tools": input.allowed_tools, + "argument_hint": input.argument_hint, + "source_url": input.source_url, + "source_ref": input.source_ref, + }); + + let skill = services.skills.update(id, input).await?; + + let (org_id, project_id) = audit_owner(&skill); + let _ = services + .audit_logs + .create(CreateAuditLog { + actor_type: actor.actor_type, + actor_id: actor.actor_id, + action: "skill.update".to_string(), + resource_type: "skill".to_string(), + resource_id: skill.id, + org_id, + project_id, + details: json!({ + "name": skill.name, + "changes": changes, + }), + ip_address: client_info.ip_address, + user_agent: client_info.user_agent, + }) + .await; + + Ok(Json(skill)) +} + +/// Soft-delete a skill. +#[cfg_attr(feature = "utoipa", utoipa::path( + delete, + path = "/admin/v1/skills/{id}", + tag = "skills", + operation_id = "skill_delete", + params(("id" = Uuid, Path, description = "Skill ID")), + responses( + (status = 200, description = "Skill deleted"), + (status = 404, description = "Skill not found", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.skills.delete", skip(state, admin_auth, authz), fields(%id))] +pub async fn delete( + State(state): State, + Extension(admin_auth): Extension, + Extension(authz): Extension, + Extension(client_info): Extension, + Path(id): Path, +) -> Result, AdminError> { + let services = get_services(&state)?; + let actor = AuditActor::from(&admin_auth); + + authz.require("skill", "delete", None, None, None, None)?; + + // Capture details before deletion for the audit log. + let skill = services + .skills + .get_by_id(id) + .await? + .ok_or_else(|| AdminError::NotFound("Skill not found".to_string()))?; + + let (org_id, project_id) = audit_owner(&skill); + let name = skill.name.clone(); + let owner_type = skill.owner_type; + let owner_id = skill.owner_id; + + services.skills.delete(id).await?; + + let _ = services + .audit_logs + .create(CreateAuditLog { + actor_type: actor.actor_type, + actor_id: actor.actor_id, + action: "skill.delete".to_string(), + resource_type: "skill".to_string(), + resource_id: id, + org_id, + project_id, + details: json!({ + "name": name, + "owner_type": owner_type, + "owner_id": owner_id, + }), + ip_address: client_info.ip_address, + user_agent: client_info.user_agent, + }) + .await; + + Ok(Json(())) +} + +/// List skills by organization. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = "/admin/v1/organizations/{org_slug}/skills", + tag = "skills", + operation_id = "skill_list_by_org", + params( + ("org_slug" = String, Path, description = "Organization slug"), + ListQuery, + ), + responses( + (status = 200, description = "List of skills", body = SkillListResponse), + (status = 400, description = "Invalid cursor or direction", body = crate::openapi::ErrorResponse), + (status = 404, description = "Organization not found", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.skills.list_by_org", skip(state, authz, query), fields(%org_slug))] +pub async fn list_by_org( + State(state): State, + Extension(authz): Extension, + Path(org_slug): Path, + Query(query): Query, +) -> Result, AdminError> { + let services = get_services(&state)?; + + let org = services + .organizations + .get_by_slug(&org_slug) + .await? + .ok_or_else(|| AdminError::NotFound(format!("Organization '{}' not found", org_slug)))?; + + authz.require( + "skill", + "list", + None, + Some(&org.id.to_string()), + None, + None, + )?; + + let limit = query.limit.unwrap_or(100); + let params = query.try_into_with_cursor()?; + + let result = services.skills.list_by_org(org.id, params).await?; + + let pagination = PaginationMeta::with_cursors( + limit, + result.has_more, + result.cursors.next.map(|c| c.encode()), + result.cursors.prev.map(|c| c.encode()), + ); + + Ok(Json(SkillListResponse { + data: result.items, + pagination, + })) +} + +/// List skills by team. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = "/admin/v1/organizations/{org_slug}/teams/{team_slug}/skills", + tag = "skills", + operation_id = "skill_list_by_team", + params( + ("org_slug" = String, Path, description = "Organization slug"), + ("team_slug" = String, Path, description = "Team slug"), + ListQuery, + ), + responses( + (status = 200, description = "List of skills", body = SkillListResponse), + (status = 400, description = "Invalid cursor or direction", body = crate::openapi::ErrorResponse), + (status = 404, description = "Organization or team not found", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.skills.list_by_team", skip(state, authz, query), fields(%org_slug, %team_slug))] +pub async fn list_by_team( + State(state): State, + Extension(authz): Extension, + Path((org_slug, team_slug)): Path<(String, String)>, + Query(query): Query, +) -> Result, AdminError> { + let services = get_services(&state)?; + + let org = services + .organizations + .get_by_slug(&org_slug) + .await? + .ok_or_else(|| AdminError::NotFound(format!("Organization '{}' not found", org_slug)))?; + + let team = services + .teams + .get_by_slug(org.id, &team_slug) + .await? + .ok_or_else(|| { + AdminError::NotFound(format!( + "Team '{}' not found in organization '{}'", + team_slug, org_slug + )) + })?; + + authz.require( + "skill", + "list", + None, + Some(&org.id.to_string()), + Some(&team.id.to_string()), + None, + )?; + + let limit = query.limit.unwrap_or(100); + let params = query.try_into_with_cursor()?; + + let result = services + .skills + .list_by_owner(SkillOwnerType::Team, team.id, params) + .await?; + + let pagination = PaginationMeta::with_cursors( + limit, + result.has_more, + result.cursors.next.map(|c| c.encode()), + result.cursors.prev.map(|c| c.encode()), + ); + + Ok(Json(SkillListResponse { + data: result.items, + pagination, + })) +} + +/// List skills by project. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = "/admin/v1/organizations/{org_slug}/projects/{project_slug}/skills", + tag = "skills", + operation_id = "skill_list_by_project", + params( + ("org_slug" = String, Path, description = "Organization slug"), + ("project_slug" = String, Path, description = "Project slug"), + ListQuery, + ), + responses( + (status = 200, description = "List of skills", body = SkillListResponse), + (status = 400, description = "Invalid cursor or direction", body = crate::openapi::ErrorResponse), + (status = 404, description = "Organization or project not found", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.skills.list_by_project", skip(state, authz, query), fields(%org_slug, %project_slug))] +pub async fn list_by_project( + State(state): State, + Extension(authz): Extension, + Path((org_slug, project_slug)): Path<(String, String)>, + Query(query): Query, +) -> Result, AdminError> { + let services = get_services(&state)?; + + let org = services + .organizations + .get_by_slug(&org_slug) + .await? + .ok_or_else(|| AdminError::NotFound(format!("Organization '{}' not found", org_slug)))?; + + let project = services + .projects + .get_by_slug(org.id, &project_slug) + .await? + .ok_or_else(|| { + AdminError::NotFound(format!( + "Project '{}' not found in organization '{}'", + project_slug, org_slug + )) + })?; + + authz.require( + "skill", + "list", + None, + Some(&org.id.to_string()), + None, + Some(&project.id.to_string()), + )?; + + let limit = query.limit.unwrap_or(100); + let params = query.try_into_with_cursor()?; + + let result = services + .skills + .list_by_owner(SkillOwnerType::Project, project.id, params) + .await?; + + let pagination = PaginationMeta::with_cursors( + limit, + result.has_more, + result.cursors.next.map(|c| c.encode()), + result.cursors.prev.map(|c| c.encode()), + ); + + Ok(Json(SkillListResponse { + data: result.items, + pagination, + })) +} + +/// List skills by user. +#[cfg_attr(feature = "utoipa", utoipa::path( + get, + path = "/admin/v1/users/{user_id}/skills", + tag = "skills", + operation_id = "skill_list_by_user", + params( + ("user_id" = Uuid, Path, description = "User ID"), + ListQuery, + ), + responses( + (status = 200, description = "List of skills", body = SkillListResponse), + (status = 400, description = "Invalid cursor or direction", body = crate::openapi::ErrorResponse), + ) +))] +#[tracing::instrument(name = "admin.skills.list_by_user", skip(state, authz, query), fields(%user_id))] +pub async fn list_by_user( + State(state): State, + Extension(authz): Extension, + Path(user_id): Path, + Query(query): Query, +) -> Result, AdminError> { + let services = get_services(&state)?; + + authz.require("skill", "list", None, None, None, None)?; + + let limit = query.limit.unwrap_or(100); + let params = query.try_into_with_cursor()?; + + let result = services + .skills + .list_by_owner(SkillOwnerType::User, user_id, params) + .await?; + + let pagination = PaginationMeta::with_cursors( + limit, + result.has_more, + result.cursors.next.map(|c| c.encode()), + result.cursors.prev.map(|c| c.encode()), + ); + + Ok(Json(SkillListResponse { + data: result.items, + pagination, + })) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 1bdc587..982051f 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -33,6 +33,7 @@ mod scim_configs; #[cfg(feature = "sso")] mod scim_provisioning; mod service_accounts; +mod skills; #[cfg(feature = "sso")] mod sso_group_mappings; mod teams; @@ -99,6 +100,7 @@ pub use scim_configs::{OrgScimConfigError, OrgScimConfigService}; #[cfg(feature = "sso")] pub use scim_provisioning::ScimProvisioningService; pub use service_accounts::ServiceAccountService; +pub use skills::SkillService; #[cfg(feature = "sso")] pub use sso_group_mappings::SsoGroupMappingService; pub use teams::TeamService; @@ -129,6 +131,7 @@ pub struct Services { pub model_pricing: ModelPricingService, pub conversations: ConversationService, pub templates: TemplateService, + pub skills: SkillService, pub audit_logs: AuditLogService, pub access_reviews: AccessReviewService, pub vector_stores: VectorStoresService, @@ -152,6 +155,7 @@ impl Services { db: Arc, file_storage: Arc, max_expression_length: usize, + max_skill_bytes: u32, ) -> Self { Self { organizations: OrganizationService::new(db.clone()), @@ -164,6 +168,7 @@ impl Services { model_pricing: ModelPricingService::new(db.clone()), conversations: ConversationService::new(db.clone()), templates: TemplateService::new(db.clone()), + skills: SkillService::new(db.clone(), max_skill_bytes), audit_logs: AuditLogService::new(db.clone()), access_reviews: AccessReviewService::new(db.clone()), vector_stores: VectorStoresService::new(db.clone()), @@ -189,6 +194,7 @@ impl Services { file_storage: Arc, event_bus: Arc, max_expression_length: usize, + max_skill_bytes: u32, ) -> Self { Self { organizations: OrganizationService::new(db.clone()), @@ -201,6 +207,7 @@ impl Services { model_pricing: ModelPricingService::new(db.clone()), conversations: ConversationService::new(db.clone()), templates: TemplateService::new(db.clone()), + skills: SkillService::new(db.clone(), max_skill_bytes), audit_logs: AuditLogService::with_event_bus(db.clone(), event_bus), access_reviews: AccessReviewService::new(db.clone()), vector_stores: VectorStoresService::new(db.clone()), diff --git a/src/services/skills.rs b/src/services/skills.rs new file mode 100644 index 0000000..68701f5 --- /dev/null +++ b/src/services/skills.rs @@ -0,0 +1,140 @@ +use std::{collections::HashSet, sync::Arc}; + +use uuid::Uuid; + +use crate::{ + db::{DbError, DbPool, DbResult, ListParams, repos::ListResult}, + models::{CreateSkill, SKILL_MAIN_FILE, Skill, SkillFileInput, SkillOwnerType, UpdateSkill}, +}; + +/// Service layer for skill operations. Enforces spec invariants on top of the +/// raw repo: +/// - Exactly one file must have path == "SKILL.md". +/// - No duplicate paths within a skill. +/// - Total file size must not exceed the configured `max_skill_bytes` limit. +#[derive(Clone)] +pub struct SkillService { + db: Arc, + max_skill_bytes: u32, +} + +impl SkillService { + pub fn new(db: Arc, max_skill_bytes: u32) -> Self { + Self { + db, + max_skill_bytes, + } + } + + fn validate_files(&self, files: &[SkillFileInput]) -> DbResult<()> { + if files.is_empty() { + return Err(DbError::Validation( + "Skill must contain at least one file".into(), + )); + } + + let main_count = files.iter().filter(|f| f.path == SKILL_MAIN_FILE).count(); + if main_count == 0 { + return Err(DbError::Validation(format!( + "Skill must contain a `{}` file", + SKILL_MAIN_FILE + ))); + } + if main_count > 1 { + return Err(DbError::Validation(format!( + "Skill must not contain more than one `{}` file", + SKILL_MAIN_FILE + ))); + } + + let mut seen: HashSet<&str> = HashSet::with_capacity(files.len()); + for file in files { + if !seen.insert(file.path.as_str()) { + return Err(DbError::Validation(format!( + "Duplicate file path in skill: {}", + file.path + ))); + } + } + + // Byte-size limit is configured at runtime; skip the check when set to 0 + // (meaning "unlimited", matching the convention used by other resource + // limits in ResourceLimits). + if self.max_skill_bytes > 0 { + let total: u64 = files.iter().map(|f| f.content.len() as u64).sum(); + if total > self.max_skill_bytes as u64 { + return Err(DbError::Validation(format!( + "Skill files total {} bytes, exceeding the configured limit of {} bytes", + total, self.max_skill_bytes + ))); + } + } + + Ok(()) + } + + /// Create a new skill after enforcing invariants on its file set. + pub async fn create(&self, input: CreateSkill) -> DbResult { + self.validate_files(&input.files)?; + self.db.skills().create(input).await + } + + /// Get a skill by ID (with full file contents). + pub async fn get_by_id(&self, id: Uuid) -> DbResult> { + self.db.skills().get_by_id(id).await + } + + /// Get a skill by ID, scoped to a specific organization. + pub async fn get_by_id_and_org(&self, id: Uuid, org_id: Uuid) -> DbResult> { + self.db.skills().get_by_id_and_org(id, org_id).await + } + + /// List skills by owner with pagination (file contents omitted). + pub async fn list_by_owner( + &self, + owner_type: SkillOwnerType, + owner_id: Uuid, + params: ListParams, + ) -> DbResult> { + self.db + .skills() + .list_by_owner(owner_type, owner_id, params) + .await + } + + /// List all skills accessible within an organization. + pub async fn list_by_org( + &self, + org_id: Uuid, + params: ListParams, + ) -> DbResult> { + self.db.skills().list_by_org(org_id, params).await + } + + /// Count skills by owner. + pub async fn count_by_owner( + &self, + owner_type: SkillOwnerType, + owner_id: Uuid, + include_deleted: bool, + ) -> DbResult { + self.db + .skills() + .count_by_owner(owner_type, owner_id, include_deleted) + .await + } + + /// Update a skill. If `input.files` is provided, invariants are enforced + /// and the full file set is replaced. + pub async fn update(&self, id: Uuid, input: UpdateSkill) -> DbResult { + if let Some(ref files) = input.files { + self.validate_files(files)?; + } + self.db.skills().update(id, input).await + } + + /// Soft-delete a skill by ID. + pub async fn delete(&self, id: Uuid) -> DbResult<()> { + self.db.skills().delete(id).await + } +} diff --git a/src/wasm.rs b/src/wasm.rs index 0243e09..524c591 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -90,7 +90,7 @@ impl HadrianGateway { let db = Arc::new(db::DbPool::from_wasm_sqlite(pool)); let file_storage: Arc = Arc::new(services::DatabaseFileStorage::new(db.clone())); - let svc = services::Services::new(db.clone(), file_storage, 1024); + let svc = services::Services::new(db.clone(), file_storage, 1024, 512_000); // Bootstrap default user and org (auth=none) let default_user_id = match crate::app::AppState::ensure_default_user(&svc).await { diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index 4b44c8f..27831b7 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -206,21 +206,21 @@ "value": { "name": "Production API Key", "owner": { - "org_id": "550e8400-e29b-41d4-a716-446655440000", - "type": "organization" + "type": "organization", + "org_id": "550e8400-e29b-41d4-a716-446655440000" } } }, "Project key with budget": { "summary": "Create a project-scoped key with budget limits", "value": { - "budget_limit_cents": 10000, - "budget_period": "monthly", "name": "Dev Team Key", "owner": { - "project_id": "123e4567-e89b-12d3-a456-426614174000", - "type": "project" - } + "type": "project", + "project_id": "123e4567-e89b-12d3-a456-426614174000" + }, + "budget_limit_cents": 10000, + "budget_period": "monthly" } }, "Service account key": { @@ -228,20 +228,20 @@ "value": { "name": "CI/CD Pipeline Key", "owner": { - "service_account_id": "8d0e7891-3456-78ef-9012-345678901234", - "type": "service_account" + "type": "service_account", + "service_account_id": "8d0e7891-3456-78ef-9012-345678901234" } } }, "User key with expiration": { "summary": "Create a user-scoped key with expiration", "value": { - "expires_at": "2025-12-31T23:59:59Z", "name": "Personal Key", "owner": { "type": "user", "user_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7" - } + }, + "expires_at": "2025-12-31T23:59:59Z" } } } @@ -259,17 +259,17 @@ }, "example": { "api_key": { - "budget_limit": null, - "budget_period": null, - "created_at": "2025-01-15T10:30:00Z", - "expires_at": null, "id": "550e8400-e29b-41d4-a716-446655440001", - "key_prefix": "gw_live_abc", "name": "Production API Key", + "key_prefix": "gw_live_abc", "owner": { - "org_id": "550e8400-e29b-41d4-a716-446655440000", - "type": "organization" + "type": "organization", + "org_id": "550e8400-e29b-41d4-a716-446655440000" }, + "budget_limit": null, + "budget_period": null, + "created_at": "2025-01-15T10:30:00Z", + "expires_at": null, "revoked_at": null }, "key": "gw_live_abc123def456ghi789jkl012mno345pqr678" @@ -384,15 +384,15 @@ }, "example": { "api_key": { - "created_at": "2025-01-15T10:30:00Z", "id": "550e8400-e29b-41d4-a716-446655440002", - "key_prefix": "gw_live_xyz", "name": "Production API Key (rotated)", + "key_prefix": "gw_live_xyz", "owner": { - "org_id": "550e8400-e29b-41d4-a716-446655440000", - "type": "organization" + "type": "organization", + "org_id": "550e8400-e29b-41d4-a716-446655440000" }, - "rotated_from_key_id": "550e8400-e29b-41d4-a716-446655440001" + "rotated_from_key_id": "550e8400-e29b-41d4-a716-446655440001", + "created_at": "2025-01-15T10:30:00Z" }, "key": "gw_live_xyz123abc456def789ghi012jkl345mno678" } @@ -6012,6 +6012,116 @@ } } }, + "/admin/v1/organizations/{org_slug}/projects/{project_slug}/skills": { + "get": { + "tags": [ + "skills" + ], + "summary": "List skills by project.", + "operationId": "skill_list_by_project", + "parameters": [ + { + "name": "org_slug", + "in": "path", + "description": "Organization slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "project_slug", + "in": "path", + "description": "Project slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of results to return", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "cursor", + "in": "query", + "description": "Cursor for keyset pagination. Encoded as base64 string.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "direction", + "in": "query", + "description": "Pagination direction: \"forward\" (default) or \"backward\".", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "include_deleted", + "in": "query", + "description": "Include soft-deleted records in results", + "required": false, + "schema": { + "type": [ + "boolean", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "List of skills", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillListResponse" + } + } + } + }, + "400": { + "description": "Invalid cursor or direction", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Organization or project not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/admin/v1/organizations/{org_slug}/projects/{project_slug}/templates": { "get": { "tags": [ @@ -8341,13 +8451,13 @@ } } }, - "/admin/v1/organizations/{org_slug}/sso-config": { + "/admin/v1/organizations/{org_slug}/skills": { "get": { "tags": [ - "sso" + "skills" ], - "summary": "Get the SSO configuration for an organization", - "operationId": "org_sso_config_get", + "summary": "List skills by organization.", + "operationId": "skill_list_by_org", "parameters": [ { "name": "org_slug", @@ -8357,82 +8467,70 @@ "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "SSO config found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OrgSsoConfig" - } - } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of results to return", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" } }, - "403": { - "description": "Access denied", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } + { + "name": "cursor", + "in": "query", + "description": "Cursor for keyset pagination. Encoded as base64 string.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] } }, - "404": { - "description": "Organization or SSO config not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } + { + "name": "direction", + "in": "query", + "description": "Pagination direction: \"forward\" (default) or \"backward\".", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] } - } - } - }, - "post": { - "tags": [ - "sso" - ], - "summary": "Create a new SSO configuration for an organization", - "description": "Each organization can have at most one SSO configuration. Creating a config\nfor an organization that already has one will result in a 409 Conflict error.", - "operationId": "org_sso_config_create", - "parameters": [ + }, { - "name": "org_slug", - "in": "path", - "description": "Organization slug", - "required": true, + "name": "include_deleted", + "in": "query", + "description": "Include soft-deleted records in results", + "required": false, "schema": { - "type": "string" + "type": [ + "boolean", + "null" + ] } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateOrgSsoConfig" - } - } - }, - "required": true - }, "responses": { - "201": { - "description": "SSO config created", + "200": { + "description": "List of skills", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrgSsoConfig" + "$ref": "#/components/schemas/SkillListResponse" } } } }, - "403": { - "description": "Access denied", + "400": { + "description": "Invalid cursor or direction", "content": { "application/json": { "schema": { @@ -8450,25 +8548,17 @@ } } } - }, - "409": { - "description": "Organization already has an SSO config", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } - }, - "delete": { + } + }, + "/admin/v1/organizations/{org_slug}/sso-config": { + "get": { "tags": [ "sso" ], - "summary": "Delete the SSO configuration for an organization", - "operationId": "org_sso_config_delete", + "summary": "Get the SSO configuration for an organization", + "operationId": "org_sso_config_get", "parameters": [ { "name": "org_slug", @@ -8482,7 +8572,128 @@ ], "responses": { "200": { - "description": "SSO config deleted" + "description": "SSO config found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgSsoConfig" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Organization or SSO config not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "sso" + ], + "summary": "Create a new SSO configuration for an organization", + "description": "Each organization can have at most one SSO configuration. Creating a config\nfor an organization that already has one will result in a 409 Conflict error.", + "operationId": "org_sso_config_create", + "parameters": [ + { + "name": "org_slug", + "in": "path", + "description": "Organization slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrgSsoConfig" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "SSO config created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgSsoConfig" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Organization not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Organization already has an SSO config", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "sso" + ], + "summary": "Delete the SSO configuration for an organization", + "operationId": "org_sso_config_delete", + "parameters": [ + { + "name": "org_slug", + "in": "path", + "description": "Organization slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "SSO config deleted" }, "403": { "description": "Access denied", @@ -10349,13 +10560,13 @@ } } }, - "/admin/v1/organizations/{org_slug}/teams/{team_slug}/templates": { + "/admin/v1/organizations/{org_slug}/teams/{team_slug}/skills": { "get": { "tags": [ - "templates" + "skills" ], - "summary": "List templates by team", - "operationId": "template_list_by_team", + "summary": "List skills by team.", + "operationId": "skill_list_by_team", "parameters": [ { "name": "org_slug", @@ -10427,11 +10638,11 @@ ], "responses": { "200": { - "description": "List of templates", + "description": "List of skills", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateListResponse" + "$ref": "#/components/schemas/SkillListResponse" } } } @@ -10459,13 +10670,13 @@ } } }, - "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage": { + "/admin/v1/organizations/{org_slug}/teams/{team_slug}/templates": { "get": { "tags": [ - "usage" + "templates" ], - "summary": "Get usage summary for a team", - "operationId": "usage_get_team_summary", + "summary": "List templates by team", + "operationId": "template_list_by_team", "parameters": [ { "name": "org_slug", @@ -10486,9 +10697,22 @@ } }, { - "name": "start_date", + "name": "limit", "in": "query", - "description": "Start date (YYYY-MM-DD)", + "description": "Maximum number of results to return", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + }, + { + "name": "cursor", + "in": "query", + "description": "Cursor for keyset pagination. Encoded as base64 string.", "required": false, "schema": { "type": [ @@ -10498,9 +10722,9 @@ } }, { - "name": "end_date", + "name": "direction", "in": "query", - "description": "End date (YYYY-MM-DD)", + "description": "Pagination direction: \"forward\" (default) or \"backward\".", "required": false, "schema": { "type": [ @@ -10508,21 +10732,43 @@ "null" ] } + }, + { + "name": "include_deleted", + "in": "query", + "description": "Include soft-deleted records in results", + "required": false, + "schema": { + "type": [ + "boolean", + "null" + ] + } } ], "responses": { "200": { - "description": "Usage summary", + "description": "List of templates", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UsageSummaryResponse" + "$ref": "#/components/schemas/TemplateListResponse" + } + } + } + }, + "400": { + "description": "Invalid cursor or direction", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } }, "404": { - "description": "Team not found", + "description": "Organization or team not found", "content": { "application/json": { "schema": { @@ -10534,13 +10780,13 @@ } } }, - "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date": { + "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage": { "get": { "tags": [ "usage" ], - "summary": "Get usage by date for a team", - "operationId": "usage_get_team_by_date", + "summary": "Get usage summary for a team", + "operationId": "usage_get_team_summary", "parameters": [ { "name": "org_slug", @@ -10587,14 +10833,11 @@ ], "responses": { "200": { - "description": "Daily usage breakdown", + "description": "Usage summary", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DailySpendResponse" - } + "$ref": "#/components/schemas/UsageSummaryResponse" } } } @@ -10612,13 +10855,13 @@ } } }, - "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-model": { + "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date": { "get": { "tags": [ "usage" ], - "summary": "Get usage by date and model for a team", - "operationId": "usage_get_team_by_date_model", + "summary": "Get usage by date for a team", + "operationId": "usage_get_team_by_date", "parameters": [ { "name": "org_slug", @@ -10665,28 +10908,38 @@ ], "responses": { "200": { - "description": "Daily usage breakdown by model", + "description": "Daily usage breakdown", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DailyModelSpendResponse" + "$ref": "#/components/schemas/DailySpendResponse" } } } } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-pricing-source": { + "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-model": { "get": { "tags": [ "usage" ], - "summary": "Get usage by date and pricing source for a team", - "operationId": "usage_get_team_by_date_pricing_source", + "summary": "Get usage by date and model for a team", + "operationId": "usage_get_team_by_date_model", "parameters": [ { "name": "org_slug", @@ -10733,13 +10986,13 @@ ], "responses": { "200": { - "description": "Daily usage breakdown by pricing source", + "description": "Daily usage breakdown by model", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DailyPricingSourceSpendResponse" + "$ref": "#/components/schemas/DailyModelSpendResponse" } } } @@ -10748,13 +11001,13 @@ } } }, - "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-project": { + "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-pricing-source": { "get": { "tags": [ "usage" ], - "summary": "Get usage by date and project for a team", - "operationId": "usage_get_team_by_date_project", + "summary": "Get usage by date and pricing source for a team", + "operationId": "usage_get_team_by_date_pricing_source", "parameters": [ { "name": "org_slug", @@ -10801,38 +11054,28 @@ ], "responses": { "200": { - "description": "Daily usage breakdown by project", + "description": "Daily usage breakdown by pricing source", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DailyProjectSpendResponse" + "$ref": "#/components/schemas/DailyPricingSourceSpendResponse" } } } } - }, - "404": { - "description": "Team not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } }, - "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-provider": { + "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-project": { "get": { "tags": [ "usage" ], - "summary": "Get usage by date and provider for a team", - "operationId": "usage_get_team_by_date_provider", + "summary": "Get usage by date and project for a team", + "operationId": "usage_get_team_by_date_project", "parameters": [ { "name": "org_slug", @@ -10879,28 +11122,106 @@ ], "responses": { "200": { - "description": "Daily usage breakdown by provider", + "description": "Daily usage breakdown by project", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/DailyProviderSpendResponse" + "$ref": "#/components/schemas/DailyProjectSpendResponse" } } } } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, - "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-user": { + "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-provider": { "get": { "tags": [ "usage" ], - "summary": "Get usage by date and user for a team", - "operationId": "usage_get_team_by_date_user", + "summary": "Get usage by date and provider for a team", + "operationId": "usage_get_team_by_date_provider", + "parameters": [ + { + "name": "org_slug", + "in": "path", + "description": "Organization slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "team_slug", + "in": "path", + "description": "Team slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "start_date", + "in": "query", + "description": "Start date (YYYY-MM-DD)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "end_date", + "in": "query", + "description": "End date (YYYY-MM-DD)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Daily usage breakdown by provider", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DailyProviderSpendResponse" + } + } + } + } + } + } + } + }, + "/admin/v1/organizations/{org_slug}/teams/{team_slug}/usage/by-date-user": { + "get": { + "tags": [ + "usage" + ], + "summary": "Get usage by date and user for a team", + "operationId": "usage_get_team_by_date_user", "parameters": [ { "name": "org_slug", @@ -13235,27 +13556,56 @@ } } }, - "/admin/v1/sso-connections": { - "get": { + "/admin/v1/skills": { + "post": { "tags": [ - "sso" + "skills" ], - "summary": "List configured SSO connections", - "description": "Returns read-only information about SSO connections configured in the gateway.\nSSO connections are defined in the config file (hadrian.toml), not the database.\nCurrently only one OIDC connection is supported per deployment.", - "operationId": "sso_connections_list", + "summary": "Create a skill.", + "operationId": "skill_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSkill" + } + } + }, + "required": true + }, "responses": { - "200": { - "description": "List of SSO connections", + "201": { + "description": "Skill created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SsoConnectionsResponse" + "$ref": "#/components/schemas/Skill" } } } }, - "403": { - "description": "Access denied", + "400": { + "description": "Invalid skill (missing SKILL.md, duplicate path, or size limit exceeded)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Owner not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Skill with this name already exists for this owner", "content": { "application/json": { "schema": { @@ -13267,37 +13617,38 @@ } } }, - "/admin/v1/sso-connections/{name}": { + "/admin/v1/skills/{id}": { "get": { "tags": [ - "sso" + "skills" ], - "summary": "Get a specific SSO connection by name", - "operationId": "sso_connection_get", + "summary": "Get a skill by ID (with full file contents).", + "operationId": "skill_get", "parameters": [ { - "name": "name", + "name": "id", "in": "path", - "description": "SSO connection name", + "description": "Skill ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], "responses": { "200": { - "description": "SSO connection found", + "description": "Skill found", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SsoConnection" + "$ref": "#/components/schemas/Skill" } } } }, - "403": { - "description": "Access denied", + "404": { + "description": "Skill not found", "content": { "application/json": { "schema": { @@ -13305,9 +13656,33 @@ } } } + } + } + }, + "delete": { + "tags": [ + "skills" + ], + "summary": "Soft-delete a skill.", + "operationId": "skill_delete", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Skill ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Skill deleted" }, "404": { - "description": "SSO connection not found", + "description": "Skill not found", "content": { "application/json": { "schema": { @@ -13317,38 +13692,59 @@ } } } - } - }, - "/admin/v1/templates": { - "post": { + }, + "patch": { "tags": [ - "templates" + "skills" + ], + "summary": "Update a skill.", + "description": "When `files` is provided, the full file set is replaced.", + "operationId": "skill_update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Skill ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Create a template", - "operationId": "template_create", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTemplate" + "$ref": "#/components/schemas/UpdateSkill" } } }, "required": true }, "responses": { - "201": { - "description": "Template created", + "200": { + "description": "Skill updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Template" + "$ref": "#/components/schemas/Skill" + } + } + } + }, + "400": { + "description": "Invalid skill (missing SKILL.md, duplicate path, or size limit exceeded)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } }, "404": { - "description": "Owner not found", + "description": "Skill not found", "content": { "application/json": { "schema": { @@ -13358,7 +13754,7 @@ } }, "409": { - "description": "Template with this name already exists", + "description": "Skill with this name already exists for this owner", "content": { "application/json": { "schema": { @@ -13370,38 +13766,27 @@ } } }, - "/admin/v1/templates/{id}": { + "/admin/v1/sso-connections": { "get": { "tags": [ - "templates" - ], - "summary": "Get a template by ID", - "operationId": "template_get", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Template ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } + "sso" ], + "summary": "List configured SSO connections", + "description": "Returns read-only information about SSO connections configured in the gateway.\nSSO connections are defined in the config file (hadrian.toml), not the database.\nCurrently only one OIDC connection is supported per deployment.", + "operationId": "sso_connections_list", "responses": { "200": { - "description": "Template found", + "description": "List of SSO connections", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Template" + "$ref": "#/components/schemas/SsoConnectionsResponse" } } } }, - "404": { - "description": "Template not found", + "403": { + "description": "Access denied", "content": { "application/json": { "schema": { @@ -13411,31 +13796,49 @@ } } } - }, - "delete": { + } + }, + "/admin/v1/sso-connections/{name}": { + "get": { "tags": [ - "templates" + "sso" ], - "summary": "Delete a template", - "operationId": "template_delete", + "summary": "Get a specific SSO connection by name", + "operationId": "sso_connection_get", "parameters": [ { - "name": "id", + "name": "name", "in": "path", - "description": "Template ID", + "description": "SSO connection name", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], "responses": { "200": { - "description": "Template deleted" + "description": "SSO connection found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SsoConnection" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "404": { - "description": "Template not found", + "description": "SSO connection not found", "content": { "application/json": { "schema": { @@ -13445,38 +13848,28 @@ } } } - }, - "patch": { + } + }, + "/admin/v1/templates": { + "post": { "tags": [ "templates" ], - "summary": "Update a template", - "operationId": "template_update", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Template ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], + "summary": "Create a template", + "operationId": "template_create", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateTemplate" + "$ref": "#/components/schemas/CreateTemplate" } } }, "required": true }, "responses": { - "200": { - "description": "Template updated", + "201": { + "description": "Template created", "content": { "application/json": { "schema": { @@ -13486,7 +13879,145 @@ } }, "404": { - "description": "Template not found", + "description": "Owner not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Template with this name already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/admin/v1/templates/{id}": { + "get": { + "tags": [ + "templates" + ], + "summary": "Get a template by ID", + "operationId": "template_get", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Template ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Template found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Template" + } + } + } + }, + "404": { + "description": "Template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "templates" + ], + "summary": "Delete a template", + "operationId": "template_delete", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Template ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Template deleted" + }, + "404": { + "description": "Template not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "patch": { + "tags": [ + "templates" + ], + "summary": "Update a template", + "operationId": "template_update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Template ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTemplate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Template updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Template" + } + } + } + }, + "404": { + "description": "Template not found", "content": { "application/json": { "schema": { @@ -15713,13 +16244,13 @@ } } }, - "/admin/v1/users/{user_id}/templates": { + "/admin/v1/users/{user_id}/skills": { "get": { "tags": [ - "templates" + "skills" ], - "summary": "List templates by user", - "operationId": "template_list_by_user", + "summary": "List skills by user.", + "operationId": "skill_list_by_user", "parameters": [ { "name": "user_id", @@ -15783,11 +16314,11 @@ ], "responses": { "200": { - "description": "List of templates", + "description": "List of skills", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateListResponse" + "$ref": "#/components/schemas/SkillListResponse" } } } @@ -15805,13 +16336,13 @@ } } }, - "/admin/v1/users/{user_id}/usage": { + "/admin/v1/users/{user_id}/templates": { "get": { "tags": [ - "usage" + "templates" ], - "summary": "Get usage summary for a user", - "operationId": "usage_get_user_summary", + "summary": "List templates by user", + "operationId": "template_list_by_user", "parameters": [ { "name": "user_id", @@ -15824,21 +16355,22 @@ } }, { - "name": "start_date", + "name": "limit", "in": "query", - "description": "Start date (YYYY-MM-DD)", + "description": "Maximum number of results to return", "required": false, "schema": { "type": [ - "string", + "integer", "null" - ] + ], + "format": "int64" } }, { - "name": "end_date", + "name": "cursor", "in": "query", - "description": "End date (YYYY-MM-DD)", + "description": "Cursor for keyset pagination. Encoded as base64 string.", "required": false, "schema": { "type": [ @@ -15846,54 +16378,11 @@ "null" ] } - } - ], - "responses": { - "200": { - "description": "Usage summary", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UsageSummaryResponse" - } - } - } - }, - "404": { - "description": "User not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/admin/v1/users/{user_id}/usage/by-date": { - "get": { - "tags": [ - "usage" - ], - "summary": "Get usage by date for a user", - "operationId": "usage_get_user_by_date", - "parameters": [ - { - "name": "user_id", - "in": "path", - "description": "User ID", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } }, { - "name": "start_date", + "name": "direction", "in": "query", - "description": "Start date (YYYY-MM-DD)", + "description": "Pagination direction: \"forward\" (default) or \"backward\".", "required": false, "schema": { "type": [ @@ -15903,13 +16392,13 @@ } }, { - "name": "end_date", + "name": "include_deleted", "in": "query", - "description": "End date (YYYY-MM-DD)", + "description": "Include soft-deleted records in results", "required": false, "schema": { "type": [ - "string", + "boolean", "null" ] } @@ -15917,20 +16406,17 @@ ], "responses": { "200": { - "description": "Daily usage breakdown", + "description": "List of templates", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DailySpendResponse" - } + "$ref": "#/components/schemas/TemplateListResponse" } } } }, - "404": { - "description": "User not found", + "400": { + "description": "Invalid cursor or direction", "content": { "application/json": { "schema": { @@ -15942,13 +16428,13 @@ } } }, - "/admin/v1/users/{user_id}/usage/by-date-model": { + "/admin/v1/users/{user_id}/usage": { "get": { "tags": [ "usage" ], - "summary": "Get usage by date and model for a user", - "operationId": "usage_get_user_by_date_model", + "summary": "Get usage summary for a user", + "operationId": "usage_get_user_summary", "parameters": [ { "name": "user_id", @@ -15987,14 +16473,21 @@ ], "responses": { "200": { - "description": "Daily usage breakdown by model", + "description": "Usage summary", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DailyModelSpendResponse" - } + "$ref": "#/components/schemas/UsageSummaryResponse" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -16002,13 +16495,143 @@ } } }, - "/admin/v1/users/{user_id}/usage/by-date-pricing-source": { + "/admin/v1/users/{user_id}/usage/by-date": { "get": { "tags": [ "usage" ], - "summary": "Get usage by date and pricing source for a user", - "operationId": "usage_get_user_by_date_pricing_source", + "summary": "Get usage by date for a user", + "operationId": "usage_get_user_by_date", + "parameters": [ + { + "name": "user_id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "start_date", + "in": "query", + "description": "Start date (YYYY-MM-DD)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "end_date", + "in": "query", + "description": "End date (YYYY-MM-DD)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Daily usage breakdown", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DailySpendResponse" + } + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/admin/v1/users/{user_id}/usage/by-date-model": { + "get": { + "tags": [ + "usage" + ], + "summary": "Get usage by date and model for a user", + "operationId": "usage_get_user_by_date_model", + "parameters": [ + { + "name": "user_id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "start_date", + "in": "query", + "description": "Start date (YYYY-MM-DD)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "end_date", + "in": "query", + "description": "End date (YYYY-MM-DD)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Daily usage breakdown by model", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DailyModelSpendResponse" + } + } + } + } + } + } + } + }, + "/admin/v1/users/{user_id}/usage/by-date-pricing-source": { + "get": { + "tags": [ + "usage" + ], + "summary": "Get usage by date and pricing source for a user", + "operationId": "usage_get_user_by_date_pricing_source", "parameters": [ { "name": "user_id", @@ -16535,78 +17158,78 @@ "Simple": { "summary": "Simple text completion", "value": { + "model": "openai/gpt-4o", "messages": [ { - "content": "Hello, how are you?", - "role": "user" + "role": "user", + "content": "Hello, how are you?" } - ], - "model": "openai/gpt-4o" + ] } }, "Streaming": { "summary": "Streaming completion", "value": { + "model": "openai/gpt-4o", "messages": [ { - "content": "Write a short poem about coding.", - "role": "user" + "role": "user", + "content": "Write a short poem about coding." } ], - "model": "openai/gpt-4o", "stream": true } }, "With system prompt": { "summary": "Completion with system prompt and parameters", "value": { - "max_tokens": 500, + "model": "anthropic/claude-sonnet-4-20250514", "messages": [ { - "content": "You are a helpful assistant.", - "role": "system" + "role": "system", + "content": "You are a helpful assistant." }, { - "content": "Explain quantum computing in simple terms.", - "role": "user" + "role": "user", + "content": "Explain quantum computing in simple terms." } ], - "model": "anthropic/claude-sonnet-4-20250514", + "max_tokens": 500, "temperature": 0.7 } }, "With tools": { "summary": "Completion with function calling", "value": { + "model": "openai/gpt-4o", "messages": [ { - "content": "What's the weather in San Francisco?", - "role": "user" + "role": "user", + "content": "What's the weather in San Francisco?" } ], - "model": "openai/gpt-4o", - "tool_choice": "auto", "tools": [ { + "type": "function", "function": { - "description": "Get the current weather for a location", "name": "get_weather", + "description": "Get the current weather for a location", "parameters": { + "type": "object", "properties": { "location": { - "description": "City name", - "type": "string" + "type": "string", + "description": "City name" } }, "required": [ "location" - ], - "type": "object" + ] } - }, - "type": "function" + } } - ] + ], + "tool_choice": "auto" } } } @@ -16660,12 +17283,12 @@ "example": { "error": { "code": "rate_limit_exceeded", + "message": "Rate limit exceeded: 100 requests per minute", "details": { "limit": 100, - "retry_after_secs": 30, - "window": "minute" - }, - "message": "Rate limit exceeded: 100 requests per minute" + "window": "minute", + "retry_after_secs": 30 + } } } } @@ -16763,20 +17386,20 @@ "Multiple texts": { "summary": "Embed multiple texts in one request", "value": { - "dimensions": 1024, + "model": "openai/text-embedding-3-large", "input": [ "First document to embed", "Second document to embed", "Third document to embed" ], - "model": "openai/text-embedding-3-large" + "dimensions": 1024 } }, "Single text": { "summary": "Embed a single text string", "value": { - "input": "Hello world", - "model": "openai/text-embedding-3-small" + "model": "openai/text-embedding-3-small", + "input": "Hello world" } } } @@ -21995,6 +22618,82 @@ } } }, + "CreateSkill": { + "type": "object", + "description": "Request to create a new skill. `files` must contain exactly one entry\nwith `path == \"SKILL.md\"`; the service layer rejects otherwise.", + "required": [ + "owner", + "name", + "description", + "files" + ], + "properties": { + "allowed_tools": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "argument_hint": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "disable_model_invocation": { + "type": [ + "boolean", + "null" + ] + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillFileInput" + } + }, + "frontmatter_extra": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "propertyNames": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/SkillOwner" + }, + "source_ref": { + "type": [ + "string", + "null" + ] + }, + "source_url": { + "type": [ + "string", + "null" + ] + }, + "user_invocable": { + "type": [ + "boolean", + "null" + ] + } + } + }, "CreateSpeechRequest": { "type": "object", "description": "Create speech request (POST /v1/audio/speech)", @@ -28274,6 +28973,319 @@ } } }, + "Skill": { + "type": "object", + "description": "A packaged Agent Skill.", + "required": [ + "id", + "owner_type", + "owner_id", + "name", + "description", + "total_bytes", + "created_at", + "updated_at" + ], + "properties": { + "allowed_tools": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Tools the skill is allowed to use. Informational; the chat UI may\nuse this to pre-approve tools while the skill is active." + }, + "argument_hint": { + "type": [ + "string", + "null" + ], + "description": "Hint shown during autocomplete to describe expected arguments." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string", + "description": "Human-readable description. Used by the model to decide when to\ninvoke the skill." + }, + "disable_model_invocation": { + "type": [ + "boolean", + "null" + ], + "description": "If `true`, the model cannot auto-invoke this skill. `None` = unset\n(defaults to `false`)." + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillFile" + }, + "description": "Full file contents. Populated by get-by-id endpoints." + }, + "files_manifest": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillFileManifest" + }, + "description": "File summary (no contents). Populated by list endpoints." + }, + "frontmatter_extra": { + "type": [ + "object", + "null" + ], + "description": "Unknown / forward-compat frontmatter keys preserved verbatim.", + "additionalProperties": {}, + "propertyNames": { + "type": "string" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "Skill name (unique per owner). See [`validate_skill_name`]." + }, + "owner_id": { + "type": "string", + "format": "uuid" + }, + "owner_type": { + "$ref": "#/components/schemas/SkillOwnerType" + }, + "source_ref": { + "type": [ + "string", + "null" + ], + "description": "Git ref if imported." + }, + "source_url": { + "type": [ + "string", + "null" + ], + "description": "Origin URL if imported (e.g. a GitHub tree URL)." + }, + "total_bytes": { + "type": "integer", + "format": "int64", + "description": "Cached total size across all files (bytes)." + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_invocable": { + "type": [ + "boolean", + "null" + ], + "description": "If `false`, the skill is hidden from the user-visible slash-command\nlist. `None` = unset (defaults to `true`)." + } + } + }, + "SkillFile": { + "type": "object", + "description": "A file bundled with a skill. Returned in full detail by get-by-id; list\nendpoints populate [`SkillFileManifest`] instead.", + "required": [ + "path", + "content", + "byte_size", + "content_type", + "created_at", + "updated_at" + ], + "properties": { + "byte_size": { + "type": "integer", + "format": "int64", + "description": "Byte length of `content`." + }, + "content": { + "type": "string", + "description": "File contents. Text-only in v1 (binary assets unsupported)." + }, + "content_type": { + "type": "string", + "description": "MIME type, e.g. \"text/markdown\"." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string", + "description": "Relative path inside the skill, e.g. \"SKILL.md\" or \"scripts/extract.py\"." + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "SkillFileInput": { + "type": "object", + "description": "A single file in a create/update request.", + "required": [ + "path", + "content" + ], + "properties": { + "content": { + "type": "string" + }, + "content_type": { + "type": [ + "string", + "null" + ], + "description": "Optional MIME type. If omitted, the service sniffs it from the path\nextension." + }, + "path": { + "type": "string" + } + } + }, + "SkillFileManifest": { + "type": "object", + "description": "Lightweight file entry returned by list endpoints — contents omitted.", + "required": [ + "path", + "byte_size", + "content_type" + ], + "properties": { + "byte_size": { + "type": "integer", + "format": "int64" + }, + "content_type": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "SkillListResponse": { + "type": "object", + "description": "Paginated list of skills.", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Skill" + }, + "description": "List of skills (file contents omitted; see `files_manifest` on each)." + }, + "pagination": { + "$ref": "#/components/schemas/PaginationMeta", + "description": "Pagination metadata." + } + } + }, + "SkillOwner": { + "oneOf": [ + { + "type": "object", + "required": [ + "organization_id", + "type" + ], + "properties": { + "organization_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "organization" + ] + } + } + }, + { + "type": "object", + "required": [ + "team_id", + "type" + ], + "properties": { + "team_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "team" + ] + } + } + }, + { + "type": "object", + "required": [ + "project_id", + "type" + ], + "properties": { + "project_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "project" + ] + } + } + }, + { + "type": "object", + "required": [ + "user_id", + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "user" + ] + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + } + ], + "description": "Owner specification for creating a skill." + }, + "SkillOwnerType": { + "type": "string", + "description": "Owner type for skills (organization, team, project, or user).", + "enum": [ + "organization", + "team", + "project", + "user" + ] + }, "SortOrder": { "type": "string", "description": "Sort order for list queries.\n\nOpenAI-compatible sort order parameter for paginated list endpoints.", @@ -30937,6 +31949,82 @@ } } }, + "UpdateSkill": { + "type": "object", + "description": "Request to update a skill. Any field that is `Some(_)` replaces the\nstored value. When `files` is `Some(_)`, the entire file set is\nreplaced.", + "properties": { + "allowed_tools": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "argument_hint": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "disable_model_invocation": { + "type": [ + "boolean", + "null" + ] + }, + "files": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/SkillFileInput" + } + }, + "frontmatter_extra": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "propertyNames": { + "type": "string" + } + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "source_ref": { + "type": [ + "string", + "null" + ] + }, + "source_url": { + "type": [ + "string", + "null" + ] + }, + "user_invocable": { + "type": [ + "boolean", + "null" + ] + } + } + }, "UpdateSsoGroupMapping": { "type": "object", "description": "Request to update an existing SSO group mapping.\n\nAll fields are optional - only provided fields will be updated.", @@ -32861,6 +33949,10 @@ "name": "templates", "description": "Manage reusable prompt templates. Templates can be owned by organizations, teams, projects, or users and include metadata for configuration." }, + { + "name": "skills", + "description": "Manage Agent Skills (https://agentskills.io). A skill packages a SKILL.md instruction file plus optional bundled scripts, references, and assets. Skills can be user-invoked via slash command or auto-invoked by the model via the frontend Skill tool." + }, { "name": "audit-logs", "description": "Query audit logs for admin operations. All sensitive operations like API key creation, user permission changes, and resource modifications are logged." diff --git a/ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx b/ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx new file mode 100644 index 0000000..805efcd --- /dev/null +++ b/ui/src/components/Admin/SkillFormModal/SkillFormModal.tsx @@ -0,0 +1,538 @@ +import { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Brain, Trash2, ChevronDown, ChevronRight } from "lucide-react"; + +import type { + CreateSkill, + Skill, + SkillFileInput, + SkillFileManifest, + SkillOwner, + UpdateSkill, +} from "@/api/generated/types.gen"; +import { skillCreate, skillGet, skillUpdate } from "@/api/generated/sdk.gen"; +import { Button } from "@/components/Button/Button"; +import { FormField } from "@/components/FormField/FormField"; +import { Input } from "@/components/Input/Input"; +import { Switch } from "@/components/Switch/Switch"; +import { + Modal, + ModalClose, + ModalHeader, + ModalTitle, + ModalContent, + ModalFooter, +} from "@/components/Modal/Modal"; + +const SKILL_MAIN_FILE = "SKILL.md"; + +/** + * Matches the server-side `validate_skill_name` (src/models/skill.rs): + * 1..=64 chars, lowercase ASCII alphanumeric or hyphen, no leading or + * trailing hyphen, no consecutive hyphens. + */ +const skillFormSchema = z.object({ + name: z + .string() + .min(1, "Name is required") + .max(64, "Name must be 64 characters or less") + .regex(/^[a-z0-9-]+$/, "Use lowercase letters, digits, and hyphens only") + .refine( + (s) => !s.startsWith("-") && !s.endsWith("-"), + "Name must not start or end with a hyphen" + ) + .refine((s) => !s.includes("--"), "Consecutive hyphens are not allowed"), + description: z + .string() + .min(1, "Description is required") + .max(1024, "Description must be 1024 characters or less"), + body: z.string().min(1, "SKILL.md body is required"), + argument_hint: z.string().max(255).optional(), + allowed_tools_text: z.string().optional(), + user_invocable: z.enum(["inherit", "true", "false"]), + disable_model_invocation: z.enum(["inherit", "true", "false"]), +}); + +type SkillFormValues = z.infer; + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`; + return `${(n / (1024 * 1024)).toFixed(2)} MiB`; +} + +function parseTriState(v: SkillFormValues["user_invocable"]): boolean | undefined { + return v === "inherit" ? undefined : v === "true"; +} + +function triStateFromBool(b: boolean | null | undefined): SkillFormValues["user_invocable"] { + if (b === null || b === undefined) return "inherit"; + return b ? "true" : "false"; +} + +/** Parse a space- or comma-separated list of allowed-tools strings. */ +function parseAllowedToolsText(text: string | undefined): string[] | undefined { + if (!text) return undefined; + const trimmed = text.trim(); + if (!trimmed) return undefined; + return trimmed + .split(/[\s,]+/) + .map((t) => t.trim()) + .filter(Boolean); +} + +function formatAllowedTools(tools: string[] | null | undefined): string { + if (!tools || tools.length === 0) return ""; + return tools.join(" "); +} + +export interface SkillFormModalProps { + open: boolean; + onClose: () => void; + editingSkill?: Skill | null; + ownerOverride: SkillOwner; + onSaved?: (skill: Skill) => void; +} + +export function SkillFormModal({ + open, + onClose, + editingSkill, + ownerOverride, + onSaved, +}: SkillFormModalProps) { + const queryClient = useQueryClient(); + const isEditing = !!editingSkill; + const [showAdvanced, setShowAdvanced] = useState(false); + + /** + * Bundled files (everything except SKILL.md). Initialized from + * `editingSkill.files_manifest` and then, after loading the full skill, + * from `editingSkill.files`. Users can delete individual entries. + */ + const [bundledFiles, setBundledFiles] = useState([]); + const [bundledManifest, setBundledManifest] = useState([]); + const [isLoadingFiles, setIsLoadingFiles] = useState(false); + + const form = useForm({ + resolver: zodResolver(skillFormSchema), + defaultValues: { + name: "", + description: "", + body: "", + argument_hint: "", + allowed_tools_text: "", + user_invocable: "inherit", + disable_model_invocation: "inherit", + }, + }); + + // Reset form when the modal opens with a new target. + useEffect(() => { + if (!open) return; + + if (editingSkill) { + const mainFile = editingSkill.files?.find((f) => f.path === SKILL_MAIN_FILE); + form.reset({ + name: editingSkill.name, + description: editingSkill.description, + body: mainFile?.content ?? "", + argument_hint: editingSkill.argument_hint ?? "", + allowed_tools_text: formatAllowedTools(editingSkill.allowed_tools), + user_invocable: triStateFromBool(editingSkill.user_invocable), + disable_model_invocation: triStateFromBool(editingSkill.disable_model_invocation), + }); + + // Files may not be loaded yet if the skill came from a list endpoint. + setBundledManifest( + (editingSkill.files_manifest ?? []).filter((f) => f.path !== SKILL_MAIN_FILE) + ); + setBundledFiles( + (editingSkill.files ?? []) + .filter((f) => f.path !== SKILL_MAIN_FILE) + .map((f) => ({ + path: f.path, + content: f.content, + content_type: f.content_type, + })) + ); + + // If body isn't populated (list response only), fetch full skill. + if (!mainFile) { + setIsLoadingFiles(true); + skillGet({ path: { id: editingSkill.id } }) + .then((res) => { + if (res.data) { + const main = res.data.files?.find((f) => f.path === SKILL_MAIN_FILE); + if (main) { + form.setValue("body", main.content, { shouldDirty: false }); + } + setBundledFiles( + (res.data.files ?? []) + .filter((f) => f.path !== SKILL_MAIN_FILE) + .map((f) => ({ + path: f.path, + content: f.content, + content_type: f.content_type, + })) + ); + } + }) + .finally(() => setIsLoadingFiles(false)); + } + } else { + form.reset({ + name: "", + description: "", + body: "", + argument_hint: "", + allowed_tools_text: "", + user_invocable: "inherit", + disable_model_invocation: "inherit", + }); + setBundledFiles([]); + setBundledManifest([]); + } + setShowAdvanced(false); + }, [open, editingSkill, form]); + + const createMutation = useMutation({ + mutationFn: async (data: CreateSkill) => { + const response = await skillCreate({ body: data }); + if (response.error) { + throw new Error( + typeof response.error === "object" && "message" in response.error + ? String(response.error.message) + : "Failed to create skill" + ); + } + return response.data as Skill; + }, + onSuccess: (skill) => { + queryClient.invalidateQueries({ queryKey: [{ _id: "skillListByOrg" }] }); + queryClient.invalidateQueries({ queryKey: [{ _id: "skillListByTeam" }] }); + queryClient.invalidateQueries({ queryKey: [{ _id: "skillListByProject" }] }); + queryClient.invalidateQueries({ queryKey: [{ _id: "skillListByUser" }] }); + onSaved?.(skill); + onClose(); + }, + }); + + const updateMutation = useMutation({ + mutationFn: async ({ id, data }: { id: string; data: UpdateSkill }) => { + const response = await skillUpdate({ path: { id }, body: data }); + if (response.error) { + throw new Error( + typeof response.error === "object" && "message" in response.error + ? String(response.error.message) + : "Failed to update skill" + ); + } + return response.data as Skill; + }, + onSuccess: (skill) => { + queryClient.invalidateQueries({ queryKey: [{ _id: "skillListByOrg" }] }); + queryClient.invalidateQueries({ queryKey: [{ _id: "skillListByTeam" }] }); + queryClient.invalidateQueries({ queryKey: [{ _id: "skillListByProject" }] }); + queryClient.invalidateQueries({ queryKey: [{ _id: "skillListByUser" }] }); + onSaved?.(skill); + onClose(); + }, + }); + + const isLoading = createMutation.isPending || updateMutation.isPending || isLoadingFiles; + const error = createMutation.error || updateMutation.error; + + const bodySize = useMemo(() => form.watch("body").length, [form]); + + const handleRemoveBundledFile = (path: string) => { + setBundledFiles((prev) => prev.filter((f) => f.path !== path)); + setBundledManifest((prev) => prev.filter((f) => f.path !== path)); + }; + + const handleSubmit = form.handleSubmit((data) => { + const files: SkillFileInput[] = [ + { + path: SKILL_MAIN_FILE, + content: data.body, + content_type: "text/markdown", + }, + ...bundledFiles, + ]; + + const allowedTools = parseAllowedToolsText(data.allowed_tools_text); + const argumentHint = data.argument_hint?.trim() || undefined; + + if (isEditing && editingSkill) { + const payload: UpdateSkill = { + name: data.name, + description: data.description, + files, + user_invocable: parseTriState(data.user_invocable), + disable_model_invocation: parseTriState(data.disable_model_invocation), + allowed_tools: allowedTools, + argument_hint: argumentHint, + }; + updateMutation.mutate({ id: editingSkill.id, data: payload }); + } else { + const payload: CreateSkill = { + owner: ownerOverride, + name: data.name, + description: data.description, + files, + user_invocable: parseTriState(data.user_invocable), + disable_model_invocation: parseTriState(data.disable_model_invocation), + allowed_tools: allowedTools, + argument_hint: argumentHint, + }; + createMutation.mutate(payload); + } + }); + + const handleClose = () => { + if (!isLoading) { + form.reset(); + createMutation.reset(); + updateMutation.reset(); + onClose(); + } + }; + + // Bundled file paths to render — prefer full file entries (after load) + // over the lighter manifest. + const bundledRows = + bundledFiles.length > 0 + ? bundledFiles.map((f) => ({ path: f.path, byte_size: f.content.length })) + : bundledManifest.map((f) => ({ path: f.path, byte_size: f.byte_size })); + + return ( + + +
+ + + + {isEditing ? "Edit Skill" : "New Skill"} + + + + +
+ {error && ( +
+ {error.message} +
+ )} + + + { + // Force lowercase and drop any char the server would reject. + const normalized = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""); + if (e.target.value !== normalized) { + form.setValue("name", normalized, { + shouldValidate: true, + shouldDirty: true, + }); + } + }, + })} + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + placeholder="e.g., code-review" + /> + + + + + + + +