Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions kagent-api-server/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# CHANGELOG

## 2026-04-16
- skills API 重构为技能仓库管理接口:
- `GET /api/skills*` 响应改为返回 `repositoryName/gitUrl/syncStatus/commitId/lastSyncedAt/syncError`。
- `POST /api/skills` 改为接收 Git 仓库地址,并在创建后自动执行首次同步。
- `PATCH /api/skills/{skill_id}` 改为更新仓库名称、描述与 Git 地址;Git 地址变更时重置同步状态。
- 新增 `POST /api/skills/{skill_id}/sync` 主动同步接口。
- skills 相关活动日志名称提取逻辑同步更新为 `repositoryName`。

## 2026-02-24
- `/api/projects/item` 项目标识解析增强:
- 新增对请求头 `x-kagent-project-id` 的支持,允许通过请求头传递项目上下文。
Expand Down
4 changes: 2 additions & 2 deletions kagent-api-server/src/middleware/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ async fn lookup_resource_snapshot(
.ok()??;
Some(ResourceSnapshot {
project_id: Some(kagent_core::projects::external_project_id(row.project_id)),
resource_name: Some(row.skill_name),
resource_name: Some(row.repository_name),
})
}
"mcp_server" => {
Expand Down Expand Up @@ -359,7 +359,7 @@ fn extract_resource_name_from_body(
let key = match resource_type {
"project" => "name",
"credential" => "name",
"skill" => "skillName",
"skill" => "repositoryName",
"mcp_server" => "name",
"code_source" => "name",
"model_provider" => "providerName",
Expand Down
130 changes: 55 additions & 75 deletions kagent-api-server/src/routes/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use axum::Json;
use axum::Router;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::routing::{get, post};

use crate::error::AppError;
use crate::middleware::project_access::ProjectMember;
Expand All @@ -17,18 +17,21 @@ pub(crate) fn router() -> Router<AppState> {
ApiRoute::SkillsItem.path(),
get(get_skill).patch(patch_skill).delete(delete_skill),
)
.route("/api/skills/{skill_id}/sync", post(sync_skill))
}

// ===== DTO =====

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SkillDto {
id: String,
project_id: String,
skill_name: String,
repository_name: String,
description: String,
resource_id: Option<String>,
git_url: Option<String>,
sync_status: kagent_db::entities::skills::SkillSyncStatus,
commit_id: Option<String>,
last_synced_at: Option<i64>,
sync_error: Option<String>,
created_at: i64,
updated_at: i64,
}
Expand All @@ -38,20 +41,19 @@ impl From<kagent_db::entities::skills::Model> for SkillDto {
Self {
id: kagent_core::skills::external_skill_id(m.id),
project_id: kagent_core::projects::external_project_id(m.project_id),
skill_name: m.skill_name,
repository_name: m.repository_name,
description: m.description,
resource_id: m
.resource_id
.map(kagent_core::resources::external_resource_id),
git_url: m.git_url,
sync_status: m.sync_status,
commit_id: m.commit_id,
last_synced_at: m.last_synced_at.map(|value| value.timestamp_millis()),
sync_error: m.sync_error,
created_at: m.created_at.timestamp_millis(),
updated_at: m.updated_at.timestamp_millis(),
}
}
}

// ===== handlers =====

/// 列出项目下的技能
async fn list_skills(
ProjectMember { project_id, .. }: ProjectMember,
State(state): State<AppState>,
Expand All @@ -68,46 +70,24 @@ async fn list_skills(
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateSkillBody {
skill_name: String,
repository_name: String,
#[serde(default)]
description: String,
resource_id: Option<String>,
git_url: String,
}

/// 创建技能
async fn create_skill(
ProjectMember { project_id, .. }: ProjectMember,
State(state): State<AppState>,
Json(body): Json<CreateSkillBody>,
) -> Result<Json<SkillDto>, AppError> {
let resource_id = match body.resource_id.as_deref() {
Some(value) if value.trim().is_empty() => None,
Some(value) => Some(
kagent_core::resources::parse_resource_id(value.trim())
.map_err(|e| AppError::bad_request(format!("invalid resource id: {e}")))?,
),
None => None,
};

if let Some(resource_id) = resource_id {
let row = kagent_core::resources::get_resource_by_id(state.db.conn(), resource_id)
.await
.map_err(|e| AppError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("check resource failed: {e:#}"),
})?
.ok_or_else(|| AppError::not_found("resource not found"))?;
if row.project_id != project_id.uuid() {
return Err(AppError::not_found("resource not found"));
}
}

let result = kagent_core::skills::create_skill(
state.db.conn(),
project_id,
kagent_core::skills::CreateSkillInput {
skill_name: body.skill_name,
repository_name: body.repository_name,
description: body.description,
resource_id,
git_url: body.git_url,
},
)
.await;
Expand All @@ -121,7 +101,7 @@ async fn create_skill(
|| msg.contains("UNIQUE constraint failed")
{
return Err(AppError::conflict(
"skill name already exists in this project",
"repository name already exists in this project",
));
}
Err(AppError {
Expand All @@ -132,7 +112,6 @@ async fn create_skill(
}
}

/// 获取技能详情
async fn get_skill(
State(state): State<AppState>,
axum::Extension(user): axum::Extension<crate::middleware::auth::CurrentUser>,
Expand All @@ -148,7 +127,6 @@ async fn get_skill(
})?
.ok_or_else(|| AppError::not_found("skill not found"))?;

// 通过资源所属的 project_id 验证权限
let project_id = kagent_db::ids::ProjectId::new(row.project_id);
require_project_member(state.db.conn(), project_id, user.id).await?;

Expand All @@ -158,12 +136,11 @@ async fn get_skill(
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct PatchSkillBody {
skill_name: Option<String>,
repository_name: Option<String>,
description: Option<String>,
resource_id: Option<Option<String>>,
git_url: Option<String>,
}

/// 更新技能
async fn patch_skill(
State(state): State<AppState>,
axum::Extension(user): axum::Extension<crate::middleware::auth::CurrentUser>,
Expand All @@ -172,17 +149,7 @@ async fn patch_skill(
) -> Result<Json<SkillDto>, AppError> {
let skill_id = kagent_core::skills::parse_skill_id(&skill_id)
.map_err(|e| AppError::bad_request(format!("invalid skill id: {e}")))?;
let resource_id = match body.resource_id {
Some(Some(value)) if value.trim().is_empty() => Some(None),
Some(Some(value)) => Some(Some(
kagent_core::resources::parse_resource_id(value.trim())
.map_err(|e| AppError::bad_request(format!("invalid resource id: {e}")))?,
)),
Some(None) => Some(None),
None => None,
};

// 先加载现有数据,验证权限
let existing = kagent_core::skills::get_skill_by_id(state.db.conn(), skill_id)
.await
.map_err(|e| AppError {
Expand All @@ -191,30 +158,16 @@ async fn patch_skill(
})?
.ok_or_else(|| AppError::not_found("skill not found"))?;

// 通过资源所属的 project_id 验证权限
let project_id = kagent_db::ids::ProjectId::new(existing.project_id);
require_project_member(state.db.conn(), project_id, user.id).await?;

if let Some(Some(resource_id)) = resource_id {
let row = kagent_core::resources::get_resource_by_id(state.db.conn(), resource_id)
.await
.map_err(|e| AppError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("check resource failed: {e:#}"),
})?
.ok_or_else(|| AppError::not_found("resource not found"))?;
if row.project_id != project_id.uuid() {
return Err(AppError::not_found("resource not found"));
}
}

let result = kagent_core::skills::update_skill(
state.db.conn(),
skill_id,
kagent_core::skills::UpdateSkillInput {
skill_name: body.skill_name,
repository_name: body.repository_name,
description: body.description,
resource_id,
git_url: body.git_url,
},
)
.await;
Expand All @@ -229,7 +182,7 @@ async fn patch_skill(
|| msg.contains("UNIQUE constraint failed")
{
return Err(AppError::conflict(
"skill name already exists in this project",
"repository name already exists in this project",
));
}
Err(AppError {
Expand All @@ -240,7 +193,6 @@ async fn patch_skill(
}
}

/// 删除技能
async fn delete_skill(
State(state): State<AppState>,
axum::Extension(user): axum::Extension<crate::middleware::auth::CurrentUser>,
Expand All @@ -249,7 +201,6 @@ async fn delete_skill(
let skill_id = kagent_core::skills::parse_skill_id(&skill_id)
.map_err(|e| AppError::bad_request(format!("invalid skill id: {e}")))?;

// 先获取资源验证权限
let existing = kagent_core::skills::get_skill_by_id(state.db.conn(), skill_id)
.await
.map_err(|e| AppError {
Expand All @@ -258,7 +209,6 @@ async fn delete_skill(
})?
.ok_or_else(|| AppError::not_found("skill not found"))?;

// 通过资源所属的 project_id 验证权限
let project_id = kagent_db::ids::ProjectId::new(existing.project_id);
require_project_member(state.db.conn(), project_id, user.id).await?;

Expand All @@ -274,3 +224,33 @@ async fn delete_skill(
Err(AppError::not_found("skill not found"))
}
}

async fn sync_skill(
State(state): State<AppState>,
axum::Extension(user): axum::Extension<crate::middleware::auth::CurrentUser>,
Path(skill_id): Path<String>,
) -> Result<Json<SkillDto>, AppError> {
let skill_id = kagent_core::skills::parse_skill_id(&skill_id)
.map_err(|e| AppError::bad_request(format!("invalid skill id: {e}")))?;

let existing = kagent_core::skills::get_skill_by_id(state.db.conn(), skill_id)
.await
.map_err(|e| AppError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("get skill failed: {e:#}"),
})?
.ok_or_else(|| AppError::not_found("skill not found"))?;

let project_id = kagent_db::ids::ProjectId::new(existing.project_id);
require_project_member(state.db.conn(), project_id, user.id).await?;

let row = kagent_core::skills::sync_skill_repository(state.db.conn(), skill_id)
.await
.map_err(|e| AppError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("sync skill failed: {e:#}"),
})?
.ok_or_else(|| AppError::not_found("skill not found"))?;

Ok(Json(SkillDto::from(row)))
}
7 changes: 7 additions & 0 deletions kagent-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# CHANGELOG

## 2026-04-16
- skills 模块重构为技能仓库管理:
- `CreateSkillInput` / `UpdateSkillInput` 改为接收 `repository_name + git_url`。
- 新增本地仓库目录解析与 Git 同步流程,仓库统一落在 `${KAGENT_HOME:-~/.kagent}/skills/<skill_uuid>`。
- 创建技能时会自动触发首次同步,并持久化 `sync_status/commit_id/last_synced_at/sync_error`。
- 新增手动同步入口 `sync_skill_repository`,支持复用已有本地 checkout 执行更新。

## 2026-02-12
- workspaces 生成 codex 配置快照时默认注入 kagent MCP server:
- 默认添加 `mcp_servers.kagent`(stdio 命令:`kagent mcp`)。
Expand Down
Loading
Loading