fix(skills): per-agent skill enablement with workspace symlinks (#14247)

### What this PR does

Before this PR:
Skills were enabled/disabled globally — toggling a skill affected all
agents. Symlinks were created in a global `.claude/skills/` directory.
Backup restore from another machine could cause EACCES errors when
workspace paths referenced non-existent directories.

After this PR:
Each agent has its own set of enabled skills tracked via the
`agent_skills` join table. Symlinks are created per-agent at
`{workspace}/.claude/skills/`. Workspace paths are validated before
symlink operations, preventing EACCES on restored backups.

### Why we need it and why it was done in this way

The following tradeoffs were made:
- Added a new `agent_skills` join table rather than modifying the
existing `skills` table, to cleanly separate per-agent state from the
global skill library
- A data migration seeds `agent_skills` from the legacy
`skills.is_enabled` column so existing users keep their enabled skills
after upgrading
- The legacy `is_enabled` column is kept for schema compatibility but no
longer read or written

The following alternatives were considered:
- Storing per-agent skill config in the `agents` table JSON blob —
rejected because a normalized join table is cleaner for queries and FK
cascade deletes

### Breaking changes

None. The data migration transparently upgrades existing skill
enablement state to the per-agent model.

### Special notes for your reviewer

- The `agent_skills` schema migration is `0006_famous_fallen_one.sql`
- The data migration (`migrateSkillsPerAgent.ts`) is idempotent and
handles fresh DBs gracefully
- Workspace path validation (`directoryExists` check) in
`getAgentWorkspace` and `enableForAllAgents` prevents EACCES when
`accessible_paths` contains paths from another machine (backup restore
scenario)
- Minor React perf optimizations in `SkillsSettings.tsx`: parallelized
batch uninstall, removed unnecessary `useMemo`, lazy `useState`
initializers

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [ ] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

```release-note
NONE
```

---------

Signed-off-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
This commit is contained in:
SuYao
2026-04-16 12:20:02 +08:00
committed by GitHub
parent 68d32c7f73
commit 0477bd0ad2
32 changed files with 3187 additions and 246 deletions

View File

@@ -0,0 +1,13 @@
CREATE TABLE `agent_skills` (
`agent_id` text NOT NULL,
`skill_id` text NOT NULL,
`is_enabled` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
PRIMARY KEY(`agent_id`, `skill_id`),
FOREIGN KEY (`agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`skill_id`) REFERENCES `skills`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_agent_skills_agent_id` ON `agent_skills` (`agent_id`);--> statement-breakpoint
CREATE INDEX `idx_agent_skills_skill_id` ON `agent_skills` (`skill_id`);

View File

@@ -0,0 +1 @@
ALTER TABLE `agents` ADD `deleted_at` text;

View File

@@ -0,0 +1,972 @@
{
"version": "6",
"dialect": "sqlite",
"id": "498cd185-e5ec-4a74-ab5b-43205e43fe10",
"prevId": "7bf3927c-ffdf-4f66-8d86-3fe7567af50e",
"tables": {
"agents": {
"name": "agents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_skills": {
"name": "agent_skills",
"columns": {
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"skill_id": {
"name": "skill_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_enabled": {
"name": "is_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_agent_skills_agent_id": {
"name": "idx_agent_skills_agent_id",
"columns": [
"agent_id"
],
"isUnique": false
},
"idx_agent_skills_skill_id": {
"name": "idx_agent_skills_skill_id",
"columns": [
"skill_id"
],
"isUnique": false
}
},
"foreignKeys": {
"agent_skills_agent_id_agents_id_fk": {
"name": "agent_skills_agent_id_agents_id_fk",
"tableFrom": "agent_skills",
"tableTo": "agents",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"agent_skills_skill_id_skills_id_fk": {
"name": "agent_skills_skill_id_skills_id_fk",
"tableFrom": "agent_skills",
"tableTo": "skills",
"columnsFrom": [
"skill_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"agent_skills_agent_id_skill_id_pk": {
"columns": [
"agent_id",
"skill_id"
],
"name": "agent_skills_agent_id_skill_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"channel_task_subscriptions": {
"name": "channel_task_subscriptions",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"cts_channel_id_idx": {
"name": "cts_channel_id_idx",
"columns": [
"channel_id"
],
"isUnique": false
},
"cts_task_id_idx": {
"name": "cts_task_id_idx",
"columns": [
"task_id"
],
"isUnique": false
}
},
"foreignKeys": {
"channel_task_subscriptions_channel_id_channels_id_fk": {
"name": "channel_task_subscriptions_channel_id_channels_id_fk",
"tableFrom": "channel_task_subscriptions",
"tableTo": "channels",
"columnsFrom": [
"channel_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"channel_task_subscriptions_task_id_scheduled_tasks_id_fk": {
"name": "channel_task_subscriptions_task_id_scheduled_tasks_id_fk",
"tableFrom": "channel_task_subscriptions",
"tableTo": "scheduled_tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"channel_task_subscriptions_channel_id_task_id_pk": {
"columns": [
"channel_id",
"task_id"
],
"name": "channel_task_subscriptions_channel_id_task_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"channels": {
"name": "channels",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"active_chat_ids": {
"name": "active_chat_ids",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"permission_mode": {
"name": "permission_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"channels_agent_id_idx": {
"name": "channels_agent_id_idx",
"columns": [
"agent_id"
],
"isUnique": false
},
"channels_type_idx": {
"name": "channels_type_idx",
"columns": [
"type"
],
"isUnique": false
},
"channels_session_id_idx": {
"name": "channels_session_id_idx",
"columns": [
"session_id"
],
"isUnique": false
}
},
"foreignKeys": {
"channels_agent_id_agents_id_fk": {
"name": "channels_agent_id_agents_id_fk",
"tableFrom": "channels",
"tableTo": "agents",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"channels_session_id_sessions_id_fk": {
"name": "channels_session_id_sessions_id_fk",
"tableFrom": "channels",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"channels_type_check": {
"name": "channels_type_check",
"value": "\"channels\".\"type\" IN ('telegram', 'feishu', 'qq', 'wechat', 'discord', 'slack')"
},
"channels_permission_mode_check": {
"name": "channels_permission_mode_check",
"value": "\"channels\".\"permission_mode\" IS NULL OR \"channels\".\"permission_mode\" IN ('default', 'acceptEdits', 'bypassPermissions', 'plan')"
}
}
},
"session_messages": {
"name": "session_messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_session_id": {
"name": "agent_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"migrations": {
"name": "migrations",
"columns": {
"version": {
"name": "version",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"executed_at": {
"name": "executed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"slash_commands": {
"name": "slash_commands",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"skills": {
"name": "skills",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"folder_name": {
"name": "folder_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"namespace": {
"name": "namespace",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_hash": {
"name": "content_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_enabled": {
"name": "is_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"skills_folder_name_unique": {
"name": "skills_folder_name_unique",
"columns": [
"folder_name"
],
"isUnique": true
},
"idx_skills_source": {
"name": "idx_skills_source",
"columns": [
"source"
],
"isUnique": false
},
"idx_skills_is_enabled": {
"name": "idx_skills_is_enabled",
"columns": [
"is_enabled"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scheduled_tasks": {
"name": "scheduled_tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_type": {
"name": "schedule_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_value": {
"name": "schedule_value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timeout_minutes": {
"name": "timeout_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 2
},
"next_run": {
"name": "next_run",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_run": {
"name": "last_run",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_result": {
"name": "last_result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_run_logs": {
"name": "task_run_logs",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"run_at": {
"name": "run_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"result": {
"name": "result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,979 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bd05ea4b-4ff1-4666-a98d-7ce82fe68548",
"prevId": "498cd185-e5ec-4a74-ab5b-43205e43fe10",
"tables": {
"agents": {
"name": "agents",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_skills": {
"name": "agent_skills",
"columns": {
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"skill_id": {
"name": "skill_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_enabled": {
"name": "is_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_agent_skills_agent_id": {
"name": "idx_agent_skills_agent_id",
"columns": [
"agent_id"
],
"isUnique": false
},
"idx_agent_skills_skill_id": {
"name": "idx_agent_skills_skill_id",
"columns": [
"skill_id"
],
"isUnique": false
}
},
"foreignKeys": {
"agent_skills_agent_id_agents_id_fk": {
"name": "agent_skills_agent_id_agents_id_fk",
"tableFrom": "agent_skills",
"tableTo": "agents",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"agent_skills_skill_id_skills_id_fk": {
"name": "agent_skills_skill_id_skills_id_fk",
"tableFrom": "agent_skills",
"tableTo": "skills",
"columnsFrom": [
"skill_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"agent_skills_agent_id_skill_id_pk": {
"columns": [
"agent_id",
"skill_id"
],
"name": "agent_skills_agent_id_skill_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"channel_task_subscriptions": {
"name": "channel_task_subscriptions",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"cts_channel_id_idx": {
"name": "cts_channel_id_idx",
"columns": [
"channel_id"
],
"isUnique": false
},
"cts_task_id_idx": {
"name": "cts_task_id_idx",
"columns": [
"task_id"
],
"isUnique": false
}
},
"foreignKeys": {
"channel_task_subscriptions_channel_id_channels_id_fk": {
"name": "channel_task_subscriptions_channel_id_channels_id_fk",
"tableFrom": "channel_task_subscriptions",
"tableTo": "channels",
"columnsFrom": [
"channel_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"channel_task_subscriptions_task_id_scheduled_tasks_id_fk": {
"name": "channel_task_subscriptions_task_id_scheduled_tasks_id_fk",
"tableFrom": "channel_task_subscriptions",
"tableTo": "scheduled_tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"channel_task_subscriptions_channel_id_task_id_pk": {
"columns": [
"channel_id",
"task_id"
],
"name": "channel_task_subscriptions_channel_id_task_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"channels": {
"name": "channels",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"active_chat_ids": {
"name": "active_chat_ids",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"permission_mode": {
"name": "permission_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"channels_agent_id_idx": {
"name": "channels_agent_id_idx",
"columns": [
"agent_id"
],
"isUnique": false
},
"channels_type_idx": {
"name": "channels_type_idx",
"columns": [
"type"
],
"isUnique": false
},
"channels_session_id_idx": {
"name": "channels_session_id_idx",
"columns": [
"session_id"
],
"isUnique": false
}
},
"foreignKeys": {
"channels_agent_id_agents_id_fk": {
"name": "channels_agent_id_agents_id_fk",
"tableFrom": "channels",
"tableTo": "agents",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"channels_session_id_sessions_id_fk": {
"name": "channels_session_id_sessions_id_fk",
"tableFrom": "channels",
"tableTo": "sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {
"channels_type_check": {
"name": "channels_type_check",
"value": "\"channels\".\"type\" IN ('telegram', 'feishu', 'qq', 'wechat', 'discord', 'slack')"
},
"channels_permission_mode_check": {
"name": "channels_permission_mode_check",
"value": "\"channels\".\"permission_mode\" IS NULL OR \"channels\".\"permission_mode\" IN ('default', 'acceptEdits', 'bypassPermissions', 'plan')"
}
}
},
"session_messages": {
"name": "session_messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_session_id": {
"name": "agent_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "''"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"migrations": {
"name": "migrations",
"columns": {
"version": {
"name": "version",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tag": {
"name": "tag",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"executed_at": {
"name": "executed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_type": {
"name": "agent_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessible_paths": {
"name": "accessible_paths",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"instructions": {
"name": "instructions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plan_model": {
"name": "plan_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"small_model": {
"name": "small_model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"mcps": {
"name": "mcps",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"allowed_tools": {
"name": "allowed_tools",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"slash_commands": {
"name": "slash_commands",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"configuration": {
"name": "configuration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"skills": {
"name": "skills",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"folder_name": {
"name": "folder_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"namespace": {
"name": "namespace",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_hash": {
"name": "content_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_enabled": {
"name": "is_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"skills_folder_name_unique": {
"name": "skills_folder_name_unique",
"columns": [
"folder_name"
],
"isUnique": true
},
"idx_skills_source": {
"name": "idx_skills_source",
"columns": [
"source"
],
"isUnique": false
},
"idx_skills_is_enabled": {
"name": "idx_skills_is_enabled",
"columns": [
"is_enabled"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scheduled_tasks": {
"name": "scheduled_tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_type": {
"name": "schedule_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_value": {
"name": "schedule_value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timeout_minutes": {
"name": "timeout_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 2
},
"next_run": {
"name": "next_run",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_run": {
"name": "last_run",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_result": {
"name": "last_result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_run_logs": {
"name": "task_run_logs",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"run_at": {
"name": "run_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"result": {
"name": "result",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -42,7 +42,21 @@
"tag": "0005_normal_doomsday",
"version": "6",
"when": 1775032005049
},
{
"idx": 6,
"version": "6",
"when": 1776154617146,
"tag": "0006_famous_fallen_one",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1776236285350,
"tag": "0007_strange_galactus",
"breakpoints": true
}
],
"version": "7"
}
}

View File

@@ -32,11 +32,17 @@ vi.mock('../utils', () => ({
toAsarUnpackedPath: vi.fn((filePath: string) => filePath)
}))
const mockRepo = {
getByFolderName: vi.fn(),
delete: vi.fn(),
insert: vi.fn()
}
// vi.mock factories are hoisted above top-level declarations, so use
// `vi.hoisted` to give the factories safe references to the mock fns.
const { mockRepo, mockEnableForAllAgents } = vi.hoisted(() => ({
mockRepo: {
getByFolderName: vi.fn(),
delete: vi.fn(),
insert: vi.fn(),
updateMetadata: vi.fn()
},
mockEnableForAllAgents: vi.fn()
}))
vi.mock('../services/agents/skills/SkillRepository', () => ({
SkillRepository: {
@@ -44,6 +50,12 @@ vi.mock('../services/agents/skills/SkillRepository', () => ({
}
}))
vi.mock('../services/agents/skills/SkillService', () => ({
skillService: {
enableForAllAgents: mockEnableForAllAgents
}
}))
vi.mock('../utils/markdownParser', () => ({
parseSkillMetadata: vi.fn(() =>
Promise.resolve({
@@ -89,10 +101,6 @@ describe('installBuiltinSkills', () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.cp).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
// ensureSymlink: readlink fails (no existing link)
vi.mocked(fs.readlink).mockRejectedValueOnce(new Error('ENOENT'))
vi.mocked(fs.rm).mockRejectedValueOnce(new Error('ENOENT'))
vi.mocked(fs.symlink).mockResolvedValue(undefined)
// computeHash: SKILL.md read
vi.mocked(fs.readFile).mockResolvedValueOnce('# My Skill' as any)
@@ -105,20 +113,22 @@ describe('installBuiltinSkills', () => {
{ recursive: true }
)
expect(fs.writeFile).toHaveBeenCalledWith(path.join(globalSkillsPath, 'my-skill', '.version'), '2.0.0', 'utf-8')
// With the per-agent model no global symlink is created — skills are
// linked per-agent via SkillService.enableForAllAgents.
expect(fs.symlink).not.toHaveBeenCalled()
})
it('should register built-in skill in DB', async () => {
it('should register built-in skill in DB with legacy is_enabled=false and fan out to agents', async () => {
vi.mocked(fs.access).mockResolvedValueOnce(undefined)
vi.mocked(fs.readdir).mockResolvedValueOnce([{ name: 'my-skill', isDirectory: () => true }] as any)
vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT')) // .version
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.cp).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
vi.mocked(fs.readlink).mockRejectedValueOnce(new Error('ENOENT'))
vi.mocked(fs.rm).mockRejectedValueOnce(new Error('ENOENT'))
vi.mocked(fs.symlink).mockResolvedValue(undefined)
vi.mocked(fs.readFile).mockResolvedValueOnce('# My Skill' as any) // computeHash
mockRepo.insert.mockResolvedValueOnce({ id: 'new-skill-id', folderName: 'my-skill' })
await installBuiltinSkills()
expect(mockRepo.getByFolderName).toHaveBeenCalledWith('my-skill')
@@ -126,9 +136,12 @@ describe('installBuiltinSkills', () => {
expect.objectContaining({
folder_name: 'my-skill',
source: 'builtin',
is_enabled: true
// Legacy column — deliberately false in the new per-agent model.
is_enabled: false
})
)
// First install of this builtin → fan out to every existing agent.
expect(mockEnableForAllAgents).toHaveBeenCalledWith('new-skill-id', 'my-skill')
})
it('should skip skills that are already up to date', async () => {
@@ -136,9 +149,6 @@ describe('installBuiltinSkills', () => {
vi.mocked(fs.readdir).mockResolvedValueOnce([{ name: 'my-skill', isDirectory: () => true }] as any)
// .version file returns current app version
vi.mocked(fs.readFile).mockResolvedValueOnce('2.0.0' as any)
// ensureSymlink: symlink already points to correct target
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.readlink).mockResolvedValueOnce(path.join(globalSkillsPath, 'my-skill'))
// DB already has the skill
mockRepo.getByFolderName.mockResolvedValueOnce({ id: 'existing', isEnabled: true })
@@ -147,6 +157,8 @@ describe('installBuiltinSkills', () => {
expect(fs.cp).not.toHaveBeenCalled()
// Should not re-insert since files are up to date and DB row exists
expect(mockRepo.insert).not.toHaveBeenCalled()
// And should not re-fan-out — user per-agent choices are preserved.
expect(mockEnableForAllAgents).not.toHaveBeenCalled()
})
it('should update skills when app version is newer', async () => {
@@ -157,9 +169,6 @@ describe('installBuiltinSkills', () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.cp).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
vi.mocked(fs.readlink).mockRejectedValueOnce(new Error('ENOENT'))
vi.mocked(fs.rm).mockRejectedValueOnce(new Error('ENOENT'))
vi.mocked(fs.symlink).mockResolvedValue(undefined)
vi.mocked(fs.readFile).mockResolvedValueOnce('# My Skill' as any) // computeHash
await installBuiltinSkills()
@@ -172,16 +181,13 @@ describe('installBuiltinSkills', () => {
expect(fs.writeFile).toHaveBeenCalledWith(path.join(globalSkillsPath, 'my-skill', '.version'), '2.0.0', 'utf-8')
})
it('should preserve enabled state when updating existing built-in skill', async () => {
it('should not fan out to all agents when updating an existing built-in skill', async () => {
vi.mocked(fs.access).mockResolvedValueOnce(undefined)
vi.mocked(fs.readdir).mockResolvedValueOnce([{ name: 'my-skill', isDirectory: () => true }] as any)
vi.mocked(fs.readFile).mockResolvedValueOnce('1.0.0' as any) // older version
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.cp).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
vi.mocked(fs.readlink).mockRejectedValueOnce(new Error('ENOENT'))
vi.mocked(fs.rm).mockRejectedValueOnce(new Error('ENOENT'))
vi.mocked(fs.symlink).mockResolvedValue(undefined)
vi.mocked(fs.readFile).mockResolvedValueOnce('# My Skill' as any) // computeHash
mockRepo.getByFolderName.mockResolvedValueOnce({
@@ -192,13 +198,18 @@ describe('installBuiltinSkills', () => {
await installBuiltinSkills()
expect(mockRepo.delete).toHaveBeenCalledWith('existing-id')
expect(mockRepo.insert).toHaveBeenCalledWith(
// Existing builtin: update metadata in-place (preserves skill ID and agent_skills rows).
expect(mockRepo.updateMetadata).toHaveBeenCalledWith(
'existing-id',
expect.objectContaining({
is_enabled: false,
created_at: 1000
name: 'Test Skill'
})
)
// Must NOT delete+insert — that would cascade-drop agent_skills rows.
expect(mockRepo.delete).not.toHaveBeenCalled()
expect(mockRepo.insert).not.toHaveBeenCalled()
// Existing builtin: do NOT re-fan-out; per-agent state survives the metadata refresh.
expect(mockEnableForAllAgents).not.toHaveBeenCalled()
})
it('should skip entries with path traversal in name', async () => {
@@ -224,14 +235,14 @@ describe('installBuiltinSkills', () => {
expect(fs.cp).not.toHaveBeenCalled()
})
it('should register DB row even when files are up to date but row is missing', async () => {
it('should register DB row and fan out even when files are up to date but row is missing', async () => {
vi.mocked(fs.access).mockResolvedValueOnce(undefined)
vi.mocked(fs.readdir).mockResolvedValueOnce([{ name: 'my-skill', isDirectory: () => true }] as any)
vi.mocked(fs.readFile).mockResolvedValueOnce('2.0.0' as any) // up to date
vi.mocked(fs.mkdir).mockResolvedValue(undefined as any)
vi.mocked(fs.readlink).mockResolvedValueOnce(path.join(globalSkillsPath, 'my-skill'))
mockRepo.getByFolderName.mockResolvedValueOnce(null) // but missing from DB
vi.mocked(fs.readFile).mockResolvedValueOnce('# My Skill' as any) // computeHash
mockRepo.insert.mockResolvedValueOnce({ id: 'reinserted-id', folderName: 'my-skill' })
await installBuiltinSkills()
@@ -244,5 +255,7 @@ describe('installBuiltinSkills', () => {
source: 'builtin'
})
)
// And since the row was missing, we fan out so existing agents get it.
expect(mockEnableForAllAgents).toHaveBeenCalledWith('reinserted-id', 'my-skill')
})
})

View File

@@ -1050,9 +1050,9 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params))
// Global Skills
ipcMain.handle(IpcChannel.Skill_List, async () => {
ipcMain.handle(IpcChannel.Skill_List, async (_, agentId?: string) => {
try {
const data = await skillService.list()
const data = await skillService.list(agentId)
return { success: true, data }
} catch (error) {
logger.error('Failed to list skills', { error })
@@ -1082,6 +1082,16 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
ipcMain.handle(IpcChannel.Skill_Toggle, async (_, options) => {
try {
if (
!options ||
typeof options.skillId !== 'string' ||
!options.skillId ||
typeof options.agentId !== 'string' ||
!options.agentId ||
typeof options.isEnabled !== 'boolean'
) {
return { success: false, error: 'Invalid toggle options' }
}
const data = await skillService.toggle(options)
return { success: true, data }
} catch (error) {
@@ -1132,6 +1142,9 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
ipcMain.handle(IpcChannel.Skill_ListLocal, async (_, workdir: string) => {
try {
if (!workdir || typeof workdir !== 'string') {
return { success: false, error: 'Invalid workdir' }
}
const data = await skillService.listLocal(workdir)
return { success: true, data }
} catch (error) {

View File

@@ -137,8 +137,12 @@ describe('SkillsServer', () => {
expect(mockSkillInstall).toHaveBeenCalledWith({
installSource: 'claude-plugins:owner/repo/gh-create-pr'
})
expect(mockSkillToggle).toHaveBeenCalledWith({ skillId: 'skill-1', isEnabled: true })
expect(result.content[0].text).toContain('Skill installed and enabled')
expect(mockSkillToggle).toHaveBeenCalledWith({
skillId: 'skill-1',
agentId: 'agent_1',
isEnabled: true
})
expect(result.content[0].text).toContain('Skill installed and enabled for this agent')
expect(result.content[0].text).toContain('gh-create-pr')
})
@@ -199,7 +203,9 @@ describe('SkillsServer', () => {
const server = createServer('agent_1')
const result = await callTool(server, { action: 'list' })
expect(mockSkillList).toHaveBeenCalled()
// list is scoped to the current agent so enablement reflects
// the per-agent state, not a shared global flag.
expect(mockSkillList).toHaveBeenCalledWith('agent_1')
const parsed = JSON.parse(result.content[0].text)
expect(parsed).toHaveLength(2)
// Each entry must include the absolute path so the model can patch the
@@ -328,9 +334,13 @@ describe('SkillsServer', () => {
const result = await callTool(server, { action: 'register', name: 'my-skill' })
expect(mockSkillInstallFromDirectory).toHaveBeenCalledWith({ directoryPath: '/global-skills/my-skill' })
expect(mockSkillToggle).toHaveBeenCalledWith({ skillId: 'skill-2', isEnabled: true })
expect(mockSkillToggle).toHaveBeenCalledWith({
skillId: 'skill-2',
agentId: 'agent_1',
isEnabled: true
})
expect(result.content[0].text).toContain('My Skill')
expect(result.content[0].text).toContain('registered and enabled')
expect(result.content[0].text).toContain('registered and enabled for this agent')
})
it('should error when SKILL.md is missing from directory', async () => {

View File

@@ -198,14 +198,20 @@ class SkillsServer {
const installed = await skillService.install({
installSource: `claude-plugins:${identifier}`
})
const enabled = await skillService.toggle({ skillId: installed.id, isEnabled: true })
// Enable the freshly-installed skill for the CURRENT agent only. Other
// agents remain untouched — skill enablement is per-agent.
const enabled = await skillService.toggle({
skillId: installed.id,
agentId: this.agentId,
isEnabled: true
})
logger.info('Skill installed via tool', { agentId: this.agentId, identifier, name: installed.name })
return {
content: [
{
type: 'text' as const,
text: `Skill installed${enabled?.isEnabled ? ' and enabled' : ' (warning: failed to enable)'}:\n Name: ${installed.name}\n Description: ${installed.description ?? 'N/A'}\n Folder: ${installed.folderName}\n Enabled: ${enabled?.isEnabled ?? false}`
text: `Skill installed${enabled?.isEnabled ? ' and enabled for this agent' : ' (warning: failed to enable)'}:\n Name: ${installed.name}\n Description: ${installed.description ?? 'N/A'}\n Folder: ${installed.folderName}\n Enabled: ${enabled?.isEnabled ?? false}`
}
]
}
@@ -224,7 +230,7 @@ class SkillsServer {
}
private async listSkills() {
const skills = await skillService.list()
const skills = await skillService.list(this.agentId)
if (skills.length === 0) {
return { content: [{ type: 'text' as const, text: 'No skills installed.' }] }
@@ -338,7 +344,13 @@ class SkillsServer {
}
const installed = await skillService.installFromDirectory({ directoryPath: skillDir })
const enabled = await skillService.toggle({ skillId: installed.id, isEnabled: true })
// Same per-agent scope as installSkill above — register only enables the
// skill for the current agent, not globally.
const enabled = await skillService.toggle({
skillId: installed.id,
agentId: this.agentId,
isEnabled: true
})
logger.info('Skill registered via tool', {
agentId: this.agentId,
@@ -350,7 +362,7 @@ class SkillsServer {
{
type: 'text' as const,
text: [
`Skill "${installed.name}" registered${enabled?.isEnabled ? ' and enabled' : ' (warning: failed to enable)'}.`,
`Skill "${installed.name}" registered${enabled?.isEnabled ? ' and enabled for this agent' : ' (warning: failed to enable)'}.`,
` Folder: ${installed.folderName}`,
` Description: ${installed.description ?? 'N/A'}`,
` Enabled: ${enabled?.isEnabled ?? false}`

View File

@@ -47,6 +47,16 @@ const DATA_MIGRATIONS: DataMigration[] = [
logger.warn('Some messages failed to migrate', { errors: result.errors })
}
}
},
{
version: 10002,
tag: 'data_0002_skills_per_agent',
description: 'Seed agent_skills from legacy skills.is_enabled and create per-agent symlinks',
migrate: async (db) => {
const { runSkillsPerAgentMigration } = await import('./migrateSkillsPerAgent')
const result = await runSkillsPerAgentMigration(db)
logger.info('Skills per-agent migration result', result)
}
}
]

View File

@@ -0,0 +1,143 @@
/**
* Data migration: seed the `agent_skills` join table from the legacy
* `skills.is_enabled` global flag, and create per-agent workspace symlinks
* so existing users keep their previously-enabled skills after upgrading
* to the per-agent model.
*
* For every skill row where `is_enabled = true`, the skill is enabled for
* every existing agent. Agents created after this migration runs go through
* `SkillService.initSkillsForAgent`, which also seeds builtin skills.
*/
import * as path from 'node:path'
import { loggerService } from '@logger'
import { eq } from 'drizzle-orm'
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import { app } from 'electron'
import type * as schema from './schema'
import { agentSkillsTable, agentsTable, skillsTable } from './schema'
const logger = loggerService.withContext('migrateSkillsPerAgent')
export async function runSkillsPerAgentMigration(db: LibSQLDatabase<typeof schema>): Promise<{
agentsProcessed: number
skillsSeeded: number
symlinksCreated: number
}> {
const enabledSkills = await db.select().from(skillsTable).where(eq(skillsTable.is_enabled, true))
if (enabledSkills.length === 0) {
logger.info('No legacy-enabled skills to migrate')
return { agentsProcessed: 0, skillsSeeded: 0, symlinksCreated: 0 }
}
const agents = await db.select().from(agentsTable)
if (agents.length === 0) {
logger.info('No existing agents — skipping seed (new agents handled by initSkillsForAgent)')
return { agentsProcessed: 0, skillsSeeded: 0, symlinksCreated: 0 }
}
// Load once here to avoid repeated require cycles, and to keep this migration
// self-contained in case SkillService's internal structure changes later.
const fs = await import('node:fs/promises')
let skillsSeeded = 0
let symlinksCreated = 0
for (const agent of agents) {
const workspace = parseFirstAccessiblePath(agent.accessible_paths)
for (const skill of enabledSkills) {
// Insert (or update) the join row as enabled.
const now = Date.now()
await db
.insert(agentSkillsTable)
.values({
agent_id: agent.id,
skill_id: skill.id,
is_enabled: true,
created_at: now,
updated_at: now
})
.onConflictDoUpdate({
target: [agentSkillsTable.agent_id, agentSkillsTable.skill_id],
set: { is_enabled: true, updated_at: now }
})
skillsSeeded++
if (!workspace) continue
try {
// Validate workspace exists on this machine (may be from a restored backup)
const wsExists = await fs.stat(workspace).then(
(s) => s.isDirectory(),
() => false
)
if (!wsExists) continue
const target = path.join(getSkillsStorageRoot(), skill.folder_name)
const linkPath = path.join(workspace, '.claude', 'skills', skill.folder_name)
await fs.mkdir(path.dirname(linkPath), { recursive: true })
let existingIsCorrect = false
try {
const stat = await fs.lstat(linkPath)
if (stat.isSymbolicLink()) {
const existing = await fs.readlink(linkPath)
if (existing === target) {
existingIsCorrect = true
} else {
await fs.rm(linkPath, { recursive: true })
}
} else if (stat.isDirectory()) {
// Real directory — may be a user-authored local skill. Skip to
// avoid accidentally deleting user content (same policy as
// SkillService.linkSkill).
logger.warn('Migration: skipping non-symlink directory', { linkPath })
continue
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
if (!existingIsCorrect) {
await fs.symlink(target, linkPath, 'junction')
symlinksCreated++
}
} catch (error) {
logger.warn('Failed to create per-agent symlink during migration', {
agentId: agent.id,
skillId: skill.id,
error: error instanceof Error ? error.message : String(error)
})
}
}
}
return { agentsProcessed: agents.length, skillsSeeded, symlinksCreated }
}
function parseFirstAccessiblePath(serialized: string | null | undefined): string | undefined {
if (!serialized) return undefined
try {
const paths = JSON.parse(serialized) as unknown
if (Array.isArray(paths) && paths.length > 0 && typeof paths[0] === 'string') {
return paths[0]
}
} catch {
// Fall through
}
return undefined
}
/**
* Resolve the global skills storage root without pulling in the main-process
* `getDataPath` helper (which hits Electron's `app` module during migration
* startup, at a point where the `app.ready` event may not have fired yet).
*
* Mirrors `getDataPath('Skills')` — `userData/Data/Skills`.
*/
function getSkillsStorageRoot(): string {
return path.join(app.getPath('userData'), 'Data', 'Skills')
}

View File

@@ -0,0 +1,44 @@
/**
* Drizzle ORM schema for agent_skills join table.
*
* Replaces the legacy global `skills.is_enabled` flag with per-agent
* enablement state. A row here means: "skill X is enabled for agent Y,
* with a workspace symlink created under agent Y's workdir".
*
* Only rows with `is_enabled = true` correspond to an actual symlink on
* disk. Rows with `is_enabled = false` may also exist to remember an
* explicit user choice.
*/
import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { agentsTable } from './agents.schema'
import { skillsTable } from './skills.schema'
export const agentSkillsTable = sqliteTable(
'agent_skills',
{
agent_id: text('agent_id')
.notNull()
.references(() => agentsTable.id, { onDelete: 'cascade' }),
skill_id: text('skill_id')
.notNull()
.references(() => skillsTable.id, { onDelete: 'cascade' }),
is_enabled: integer('is_enabled', { mode: 'boolean' }).notNull().default(false),
created_at: integer('created_at')
.notNull()
.$defaultFn(() => Date.now()),
updated_at: integer('updated_at')
.notNull()
.$defaultFn(() => Date.now())
.$onUpdateFn(() => Date.now())
},
(t) => [
primaryKey({ columns: [t.agent_id, t.skill_id] }),
index('idx_agent_skills_agent_id').on(t.agent_id),
index('idx_agent_skills_skill_id').on(t.skill_id)
]
)
export type AgentSkillRow = typeof agentSkillsTable.$inferSelect
export type InsertAgentSkillRow = typeof agentSkillsTable.$inferInsert

View File

@@ -9,6 +9,7 @@ export const agentsTable = sqliteTable('agents', {
type: text('type').notNull(),
name: text('name').notNull(),
description: text('description'),
deleted_at: text('deleted_at'),
accessible_paths: text('accessible_paths'), // JSON array of directory paths the agent can access
instructions: text('instructions'),

View File

@@ -3,6 +3,7 @@
*/
export * from './agents.schema'
export * from './agentSkills.schema'
export * from './channels.schema'
export * from './messages.schema'
export * from './migrations.schema'

View File

@@ -10,17 +10,31 @@ import type {
UpdateAgentResponse
} from '@types'
import { AgentBaseSchema } from '@types'
import { asc, count, desc, eq, sql } from 'drizzle-orm'
import { and, asc, count, desc, eq, isNull, sql } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { type AgentRow, agentsTable, type InsertAgentRow, sessionsTable } from '../database/schema'
import {
type AgentRow,
agentSkillsTable,
agentsTable,
channelsTable,
type InsertAgentRow,
scheduledTasksTable,
sessionsTable
} from '../database/schema'
import type { AgentModelField } from '../errors'
import { skillService } from '../skills/SkillService'
import { CHERRY_CLAW_AGENT_ID, isBuiltinAgentId } from './builtin/BuiltinAgentIds'
import { seedWorkspaceTemplates } from './cherryclaw/seedWorkspace'
const logger = loggerService.withContext('AgentService')
export type BuiltinAgentInitResult =
| { agentId: string; skippedReason?: undefined }
| { agentId: null; skippedReason: 'deleted' | 'no_model' }
export class AgentService extends BaseService {
static readonly DEFAULT_AGENT_ID = 'cherry-claw-default'
static readonly DEFAULT_AGENT_ID = CHERRY_CLAW_AGENT_ID
private static instance: AgentService | null = null
private readonly modelFields: AgentModelField[] = ['model', 'plan_model', 'small_model']
@@ -84,18 +98,39 @@ export class AgentService extends BaseService {
}
}
// Auto-enable every builtin skill for the new agent — they ship with the
// app and users expect them to work without manual opt-in. Non-builtin
// skills default to disabled and must be enabled explicitly.
try {
await skillService.initSkillsForAgent(agent.id, agent.accessible_paths?.[0])
} catch (error) {
logger.warn('Failed to seed builtin skills for new agent', {
agentId: agent.id,
error: error instanceof Error ? error.message : String(error)
})
}
return agent
}
async getAgent(id: string): Promise<GetAgentResponse | null> {
private async findAgentRow(id: string, options: { includeDeleted?: boolean } = {}): Promise<AgentRow | undefined> {
const database = await this.getDatabase()
const result = await database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
const whereClause = options.includeDeleted
? eq(agentsTable.id, id)
: and(eq(agentsTable.id, id), isNull(agentsTable.deleted_at))
if (!result[0]) {
const result = await database.select().from(agentsTable).where(whereClause).limit(1)
return result[0]
}
async getAgent(id: string): Promise<GetAgentResponse | null> {
const row = await this.findAgentRow(id)
if (!row) {
return null
}
const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse
const agent = this.deserializeJsonFields(row) as GetAgentResponse
const { tools, legacyIdMap } = await this.listMcpTools(agent.type, agent.mcps)
agent.tools = tools
agent.allowed_tools = this.normalizeAllowedTools(agent.allowed_tools, agent.tools, legacyIdMap)
@@ -106,7 +141,8 @@ export class AgentService extends BaseService {
async listAgents(options: ListOptions = {}): Promise<{ agents: AgentEntity[]; total: number }> {
// Build query with pagination
const database = await this.getDatabase()
const totalResult = await database.select({ count: count() }).from(agentsTable)
const visibleAgents = isNull(agentsTable.deleted_at)
const totalResult = await database.select({ count: count() }).from(agentsTable).where(visibleAgents)
const sortBy = options.sortBy || 'sort_order'
const orderBy = options.orderBy || (sortBy === 'sort_order' ? 'asc' : 'desc')
@@ -117,8 +153,12 @@ export class AgentService extends BaseService {
// Use created_at DESC as secondary sort for tie-breaking (e.g., after migration when all sort_order = 0)
const baseQuery =
sortBy === 'sort_order'
? database.select().from(agentsTable).orderBy(orderFn(sortField), desc(agentsTable.created_at))
: database.select().from(agentsTable).orderBy(orderFn(sortField))
? database
.select()
.from(agentsTable)
.where(visibleAgents)
.orderBy(orderFn(sortField), desc(agentsTable.created_at))
: database.select().from(agentsTable).where(visibleAgents).orderBy(orderFn(sortField))
const result =
options.limit !== undefined
@@ -159,17 +199,18 @@ export class AgentService extends BaseService {
| { name?: string; description?: string; instructions?: string; configuration?: Record<string, unknown> }
| undefined
>
}): Promise<string | null> {
}): Promise<BuiltinAgentInitResult> {
const { id, builtinRole, provisionWorkspace } = opts
try {
const database = await this.getDatabase()
const existing = await database
.select({ id: agentsTable.id })
.from(agentsTable)
.where(eq(agentsTable.id, id))
.limit(1)
const existing = await this.findAgentRow(id, { includeDeleted: true })
if (existing.length > 0) {
if (existing?.deleted_at) {
logger.info(`Built-in ${builtinRole} agent was deleted by user — skipping recreation`, { id })
return { agentId: null, skippedReason: 'deleted' }
}
if (existing) {
// Sync localized description/instructions on every startup (language may have changed)
const resolvedPaths = this.resolveAccessiblePaths([], id)
const workspace = resolvedPaths[0]
@@ -180,14 +221,14 @@ export class AgentService extends BaseService {
if (agentConfig.instructions) updateData.instructions = agentConfig.instructions
await database.update(agentsTable).set(updateData).where(eq(agentsTable.id, id))
}
return id
return { agentId: id }
}
const modelsRes = await modelsService.getModels({ providerType: 'anthropic', limit: 1 })
const firstModel = modelsRes.data?.[0]
if (!firstModel) {
logger.info(`No Anthropic-compatible models available yet — skipping ${builtinRole} creation`)
return null
return { agentId: null, skippedReason: 'no_model' }
}
// Resolve workspace path first so provisioner can copy template files
@@ -237,11 +278,20 @@ export class AgentService extends BaseService {
await tx.insert(agentsTable).values(insertData)
})
try {
await skillService.initSkillsForAgent(id, resolvedPaths?.[0])
} catch (error) {
logger.warn('Failed to seed builtin skills for built-in agent', {
agentId: id,
error: error instanceof Error ? error.message : String(error)
})
}
logger.info(`Created built-in ${builtinRole} agent`, { id })
return id
return { agentId: id }
} catch (error) {
logger.error(`Failed to init built-in ${builtinRole} agent`, error as Error)
return null
return { agentId: null, skippedReason: 'no_model' }
}
}
@@ -250,25 +300,26 @@ export class AgentService extends BaseService {
* Called once at app startup. Safe to call multiple times — skips if the agent already exists.
* Returns the agent ID if created or already present, or null if no compatible model is available yet.
*/
async initDefaultCherryClawAgent(): Promise<string | null> {
async initDefaultCherryClawAgent(): Promise<BuiltinAgentInitResult> {
const id = AgentService.DEFAULT_AGENT_ID
try {
const database = await this.getDatabase()
const existing = await database
.select({ id: agentsTable.id })
.from(agentsTable)
.where(eq(agentsTable.id, id))
.limit(1)
const existing = await this.findAgentRow(id, { includeDeleted: true })
if (existing.length > 0) {
return id
if (existing?.deleted_at) {
logger.info('Default CherryClaw agent was deleted by user — skipping recreation', { id })
return { agentId: null, skippedReason: 'deleted' }
}
if (existing) {
return { agentId: id }
}
const modelsRes = await modelsService.getModels({ providerType: 'anthropic', limit: 1 })
const firstModel = modelsRes.data?.[0]
if (!firstModel) {
logger.info('No Anthropic-compatible models available yet — skipping default CherryClaw creation')
return null
return { agentId: null, skippedReason: 'no_model' }
}
const now = new Date().toISOString()
@@ -323,11 +374,20 @@ export class AgentService extends BaseService {
await seedWorkspaceTemplates(workspace)
}
try {
await skillService.initSkillsForAgent(id, workspace)
} catch (error) {
logger.warn('Failed to seed builtin skills for CherryClaw agent', {
agentId: id,
error: error instanceof Error ? error.message : String(error)
})
}
logger.info('Created default CherryClaw agent', { id })
return id
return { agentId: id }
} catch (error) {
logger.error('Failed to init default CherryClaw agent', error as Error)
return null
return { agentId: null, skippedReason: 'no_model' }
}
}
@@ -386,7 +446,11 @@ export class AgentService extends BaseService {
// Read the raw agent row before updating — getAgent() normalizes allowed_tools
// (legacy ID → canonical ID), but sessions store the original format. We need
// the raw DB values so string comparison against sessions is accurate.
const rawRows = await database.select().from(agentsTable).where(eq(agentsTable.id, id)).limit(1)
const rawRows = await database
.select()
.from(agentsTable)
.where(and(eq(agentsTable.id, id), isNull(agentsTable.deleted_at)))
.limit(1)
const rawOldAgent = rawRows[0]
await database.update(agentsTable).set(updateData).where(eq(agentsTable.id, id))
@@ -478,20 +542,35 @@ export class AgentService extends BaseService {
async deleteAgent(id: string): Promise<boolean> {
const database = await this.getDatabase()
const agent = await this.findAgentRow(id)
if (!agent) {
return false
}
if (isBuiltinAgentId(id)) {
const now = new Date().toISOString()
await database.transaction(async (tx) => {
await tx.delete(agentSkillsTable).where(eq(agentSkillsTable.agent_id, id))
await tx.delete(scheduledTasksTable).where(eq(scheduledTasksTable.agent_id, id))
await tx.delete(sessionsTable).where(eq(sessionsTable.agent_id, id))
await tx.update(channelsTable).set({ agentId: null }).where(eq(channelsTable.agentId, id))
await tx.update(agentsTable).set({ deleted_at: now, updated_at: now }).where(eq(agentsTable.id, id))
})
return true
}
const result = await database.delete(agentsTable).where(eq(agentsTable.id, id))
return result.rowsAffected > 0
}
async agentExists(id: string): Promise<boolean> {
const database = await this.getDatabase()
const result = await database
.select({ id: agentsTable.id })
.from(agentsTable)
.where(eq(agentsTable.id, id))
.limit(1)
const result = await this.findAgentRow(id)
return result.length > 0
return !!result
}
}

View File

@@ -13,7 +13,7 @@ import {
type ListOptions,
type UpdateSessionRequest
} from '@types'
import { and, asc, count, desc, eq, type SQL, sql } from 'drizzle-orm'
import { and, asc, count, desc, eq, isNull, type SQL, sql } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema'
@@ -114,7 +114,11 @@ export class SessionService extends BaseService {
// The database foreign key constraint will handle this
const database = await this.getDatabase()
const agents = await database.select().from(agentsTable).where(eq(agentsTable.id, agentId)).limit(1)
const agents = await database
.select()
.from(agentsTable)
.where(and(eq(agentsTable.id, agentId), isNull(agentsTable.deleted_at)))
.limit(1)
if (!agents[0]) {
throw new Error('Agent not found')
}

View File

@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetModels, mockInitSkillsForAgent } = vi.hoisted(() => ({
mockGetModels: vi.fn(),
mockInitSkillsForAgent: vi.fn()
}))
vi.mock('@main/apiServer/services/mcp', () => ({
mcpApiService: {
getServerInfo: vi.fn()
}
}))
vi.mock('@main/apiServer/utils', () => ({
validateModelId: vi.fn()
}))
vi.mock('@main/utils', () => ({
getDataPath: vi.fn(() => '/mock/data')
}))
vi.mock('@main/apiServer/services/models', () => ({
modelsService: {
getModels: mockGetModels
}
}))
vi.mock('@logger', () => ({
loggerService: {
withContext: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}))
}
}))
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp'),
getAppPath: vi.fn(() => '/app')
},
BrowserWindow: vi.fn(),
dialog: {},
ipcMain: {},
nativeTheme: {
on: vi.fn(),
themeSource: 'system',
shouldUseDarkColors: false
},
screen: {},
session: {},
shell: {}
}))
vi.mock('@electron-toolkit/utils', () => ({
is: {
dev: true,
macOS: false,
windows: false,
linux: true
}
}))
vi.mock('../../skills/SkillService', () => ({
skillService: {
initSkillsForAgent: mockInitSkillsForAgent
}
}))
import { AgentService } from '../AgentService'
function createSelectQuery(rows: unknown[]) {
return {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn().mockResolvedValue(rows)
}))
}))
}
}
describe('AgentService built-in agent lifecycle', () => {
const service = AgentService.getInstance()
beforeEach(() => {
vi.clearAllMocks()
})
it('skips recreating a built-in agent that was soft-deleted by the user', async () => {
const database = {
select: vi.fn(() =>
createSelectQuery([{ id: 'cherry-assistant-default', deleted_at: '2026-04-15T00:00:00.000Z' }])
)
}
vi.spyOn(service as never, 'getDatabase').mockResolvedValue(database as never)
const result = await service.initBuiltinAgent({
id: 'cherry-assistant-default',
builtinRole: 'assistant',
provisionWorkspace: vi.fn()
})
expect(result).toEqual({ agentId: null, skippedReason: 'deleted' })
expect(mockGetModels).not.toHaveBeenCalled()
})
it('soft-deletes built-in agents while preserving the row', async () => {
const deleteWhere = vi.fn().mockResolvedValue({ rowsAffected: 1 })
const txDelete = vi.fn(() => ({ where: deleteWhere }))
const updateWhere = vi.fn().mockResolvedValue(undefined)
const txUpdateSet = vi.fn(() => ({ where: updateWhere }))
const txUpdate = vi.fn(() => ({ set: txUpdateSet }))
const database = {
select: vi.fn(() => createSelectQuery([{ id: 'cherry-claw-default', deleted_at: null }])),
transaction: vi.fn(async (callback: (tx: unknown) => Promise<void>) =>
callback({ delete: txDelete, update: txUpdate })
),
delete: vi.fn(() => ({ where: deleteWhere }))
}
vi.spyOn(service as never, 'getDatabase').mockResolvedValue(database as never)
const deleted = await service.deleteAgent('cherry-claw-default')
expect(deleted).toBe(true)
expect(database.transaction).toHaveBeenCalledTimes(1)
expect(txDelete).toHaveBeenCalledTimes(3)
expect(txUpdate).toHaveBeenCalledTimes(2)
expect(database.delete).not.toHaveBeenCalled()
expect(txUpdateSet).toHaveBeenCalledWith(expect.objectContaining({ agentId: null }))
expect(txUpdateSet).toHaveBeenCalledWith(
expect.objectContaining({
deleted_at: expect.any(String),
updated_at: expect.any(String)
})
)
})
})

View File

@@ -8,12 +8,17 @@
import { loggerService } from '@logger'
import { installBuiltinSkills } from '@main/utils/builtinSkills'
import type { BuiltinAgentInitResult } from '../AgentService'
import { agentService } from '../AgentService'
import { schedulerService } from '../SchedulerService'
import { sessionService } from '../SessionService'
import { CHERRY_ASSISTANT_AGENT_ID, CHERRY_CLAW_AGENT_ID } from './BuiltinAgentIds'
import { provisionBuiltinAgent } from './BuiltinAgentProvisioner'
const logger = loggerService.withContext('BuiltinAgentBootstrap')
const RETRY_DELAYS_MS = [5000, 15000, 30000]
const retryAttempts = new Map<string, number>()
const retryTimers = new Map<string, NodeJS.Timeout>()
/**
* Initialize all built-in skills and agents. Safe to call multiple times (idempotent).
@@ -27,24 +32,85 @@ export async function bootstrapBuiltinAgents(): Promise<void> {
} catch (error) {
logger.error('Failed to install built-in skills', error as Error)
}
await Promise.all([initCherryClaw(), initCherryAssistant()])
}
function clearRetry(agentId: string): void {
const timer = retryTimers.get(agentId)
if (timer) {
clearTimeout(timer)
retryTimers.delete(agentId)
}
retryAttempts.delete(agentId)
}
function scheduleRetry(agentId: string, label: string, initFn: () => Promise<void>): void {
if (retryTimers.has(agentId)) {
return
}
const attempt = retryAttempts.get(agentId) ?? 0
const delay = RETRY_DELAYS_MS[attempt]
if (delay === undefined) {
logger.info(`Built-in ${label} bootstrap retries exhausted`, { agentId, attempts: attempt })
return
}
retryAttempts.set(agentId, attempt + 1)
logger.info(`Scheduling built-in ${label} bootstrap retry`, {
agentId,
attempt: attempt + 1,
delayMs: delay
})
const timer = setTimeout(() => {
retryTimers.delete(agentId)
void initFn()
}, delay)
retryTimers.set(agentId, timer)
}
async function ensureDefaultSession(agentId: string, label: string): Promise<void> {
const { total } = await sessionService.listSessions(agentId, { limit: 1 })
if (total === 0) {
await sessionService.createSession(agentId, {})
logger.info(`Default session created for ${label} agent`)
}
}
async function handleInitResult(
agentId: string,
label: string,
result: BuiltinAgentInitResult,
initFn: () => Promise<void>,
onReady?: (resolvedAgentId: string) => Promise<void>
): Promise<void> {
if (result.agentId) {
clearRetry(agentId)
await ensureDefaultSession(result.agentId, label)
if (onReady) {
await onReady(result.agentId)
}
return
}
if (result.skippedReason === 'deleted') {
clearRetry(agentId)
return
}
scheduleRetry(agentId, label, initFn)
}
// ── CherryClaw ──────────────────────────────────────────────────────
async function initCherryClaw(): Promise<void> {
try {
const agentId = await agentService.initDefaultCherryClawAgent()
if (!agentId) return
// Ensure the default agent has at least one session
const { total } = await sessionService.listSessions(agentId, { limit: 1 })
if (total === 0) {
await sessionService.createSession(agentId, {})
logger.info('Default session created for CherryClaw agent')
}
await schedulerService.ensureHeartbeatTask(agentId, 30)
const result = await agentService.initDefaultCherryClawAgent()
await handleInitResult(CHERRY_CLAW_AGENT_ID, 'CherryClaw', result, initCherryClaw, async (agentId) => {
await schedulerService.ensureHeartbeatTask(agentId, 30)
})
} catch (error) {
logger.warn('Failed to init CherryClaw agent:', error as Error)
}
@@ -52,23 +118,16 @@ async function initCherryClaw(): Promise<void> {
// ── Cherry Assistant ────────────────────────────────────────────────
export const CHERRY_ASSISTANT_AGENT_ID = 'cherry-assistant-default'
export { CHERRY_ASSISTANT_AGENT_ID }
async function initCherryAssistant(): Promise<void> {
try {
const agentId = await agentService.initBuiltinAgent({
const result = await agentService.initBuiltinAgent({
id: CHERRY_ASSISTANT_AGENT_ID,
builtinRole: 'assistant',
provisionWorkspace: provisionBuiltinAgent
})
if (!agentId) return
// Ensure the assistant agent has at least one session
const { total } = await sessionService.listSessions(agentId, { limit: 1 })
if (total === 0) {
await sessionService.createSession(agentId, {})
logger.info('Default session created for Cherry Assistant agent')
}
await handleInitResult(CHERRY_ASSISTANT_AGENT_ID, 'Cherry Assistant', result, initCherryAssistant)
} catch (error) {
logger.warn('Failed to init Cherry Assistant agent:', error as Error)
}

View File

@@ -0,0 +1,8 @@
export const CHERRY_CLAW_AGENT_ID = 'cherry-claw-default'
export const CHERRY_ASSISTANT_AGENT_ID = 'cherry-assistant-default'
const BUILTIN_AGENT_IDS = new Set([CHERRY_CLAW_AGENT_ID, CHERRY_ASSISTANT_AGENT_ID])
export function isBuiltinAgentId(id: string): boolean {
return BUILTIN_AGENT_IDS.has(id)
}

View File

@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockInstallBuiltinSkills,
mockInitDefaultCherryClawAgent,
mockInitBuiltinAgent,
mockListSessions,
mockCreateSession,
mockEnsureHeartbeatTask
} = vi.hoisted(() => ({
mockInstallBuiltinSkills: vi.fn(),
mockInitDefaultCherryClawAgent: vi.fn(),
mockInitBuiltinAgent: vi.fn(),
mockListSessions: vi.fn(),
mockCreateSession: vi.fn(),
mockEnsureHeartbeatTask: vi.fn()
}))
vi.mock('@main/utils/builtinSkills', () => ({
installBuiltinSkills: mockInstallBuiltinSkills
}))
vi.mock('../../AgentService', () => ({
agentService: {
initDefaultCherryClawAgent: mockInitDefaultCherryClawAgent,
initBuiltinAgent: mockInitBuiltinAgent
}
}))
vi.mock('../../SessionService', () => ({
sessionService: {
listSessions: mockListSessions,
createSession: mockCreateSession
}
}))
vi.mock('../../SchedulerService', () => ({
schedulerService: {
ensureHeartbeatTask: mockEnsureHeartbeatTask
}
}))
vi.mock('../BuiltinAgentProvisioner', () => ({
provisionBuiltinAgent: vi.fn()
}))
describe('bootstrapBuiltinAgents', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.resetModules()
mockInstallBuiltinSkills.mockResolvedValue(undefined)
mockListSessions.mockResolvedValue({ total: 0 })
mockCreateSession.mockResolvedValue({ id: 'session_1' })
mockEnsureHeartbeatTask.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('retries built-in bootstrap when no model is available yet', async () => {
mockInitDefaultCherryClawAgent
.mockResolvedValueOnce({ agentId: null, skippedReason: 'no_model' })
.mockResolvedValueOnce({ agentId: 'cherry-claw-default' })
mockInitBuiltinAgent.mockResolvedValue({ agentId: null, skippedReason: 'deleted' })
const { bootstrapBuiltinAgents } = await import('../BuiltinAgentBootstrap')
await bootstrapBuiltinAgents()
expect(mockInitDefaultCherryClawAgent).toHaveBeenCalledTimes(1)
expect(mockCreateSession).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(5000)
expect(mockInitDefaultCherryClawAgent).toHaveBeenCalledTimes(2)
expect(mockListSessions).toHaveBeenCalledWith('cherry-claw-default', { limit: 1 })
expect(mockCreateSession).toHaveBeenCalledWith('cherry-claw-default', {})
expect(mockEnsureHeartbeatTask).toHaveBeenCalledWith('cherry-claw-default', 30)
})
it('does not retry built-in agents deleted by the user', async () => {
mockInitDefaultCherryClawAgent.mockResolvedValue({ agentId: null, skippedReason: 'deleted' })
mockInitBuiltinAgent.mockResolvedValue({ agentId: null, skippedReason: 'deleted' })
const { bootstrapBuiltinAgents } = await import('../BuiltinAgentBootstrap')
await bootstrapBuiltinAgents()
await vi.advanceTimersByTimeAsync(60000)
expect(mockInitDefaultCherryClawAgent).toHaveBeenCalledTimes(1)
expect(mockInitBuiltinAgent).toHaveBeenCalledTimes(1)
expect(mockCreateSession).not.toHaveBeenCalled()
expect(mockEnsureHeartbeatTask).not.toHaveBeenCalled()
})
})

View File

@@ -53,6 +53,7 @@ import type {
AgentStreamEvent,
AgentThinkingOptions
} from '../../interfaces/AgentStreamInterface'
import { skillService } from '../../skills/SkillService'
import { agentService } from '../AgentService'
import { isProvisioned, provisionBuiltinAgent } from '../builtin/BuiltinAgentProvisioner'
import { channelService } from '../ChannelService'
@@ -122,6 +123,19 @@ class ClaudeCodeService implements AgentServiceInterface {
return aiStream
}
// Sync per-agent skill symlinks in this workspace with the `agent_skills`
// DB state before we spin up the SDK. This repairs drift from external
// edits (user deleted a symlink, workspace was moved, etc.) so Claude
// Code sees exactly the set of skills the agent should have enabled.
try {
await skillService.reconcileAgentSkills(session.agent_id, cwd)
} catch (error) {
logger.warn('Failed to reconcile agent skills before session start', {
agentId: session.agent_id,
error: error instanceof Error ? error.message : String(error)
})
}
// Validate model info
const modelInfo = await validateModelId(session.model)
if (!modelInfo.valid) {
@@ -203,7 +217,9 @@ class ClaudeCodeService implements AgentServiceInterface {
ELECTRON_NO_ATTACH_CONSOLE: '1',
// Set CLAUDE_CONFIG_DIR to app's userData directory to avoid path encoding issues
// on Windows when the username contains non-ASCII characters (e.g., Chinese characters)
// This prevents the SDK from using the user's home directory which may have encoding problems
// This prevents the SDK from using the user's home directory which may have encoding problems.
// Per-agent skills live in `<cwd>/.claude/skills/` and are picked up by the SDK's
// project-level skill loading layer — no need to point CLAUDE_CONFIG_DIR at the workspace.
CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude'),
ENABLE_TOOL_SEARCH: 'auto',
CHERRY_STUDIO_BUN_PATH: bunPath,

View File

@@ -0,0 +1,84 @@
import { loggerService } from '@logger'
import { and, eq } from 'drizzle-orm'
import { BaseService } from '../BaseService'
import { type AgentSkillRow, agentSkillsTable } from '../database/schema'
const logger = loggerService.withContext('AgentSkillRepository')
/**
* Database repository for the `agent_skills` join table.
*
* Each row records whether a given skill is enabled for a given agent.
* Only rows with `is_enabled = true` correspond to an actual symlink under
* the agent's workspace `.claude/skills/` directory.
*/
export class AgentSkillRepository extends BaseService {
private static instance: AgentSkillRepository | null = null
static getInstance(): AgentSkillRepository {
if (!AgentSkillRepository.instance) {
AgentSkillRepository.instance = new AgentSkillRepository()
}
return AgentSkillRepository.instance
}
async getByAgentId(agentId: string): Promise<AgentSkillRow[]> {
const db = await this.getDatabase()
return db.select().from(agentSkillsTable).where(eq(agentSkillsTable.agent_id, agentId))
}
async getBySkillId(skillId: string): Promise<AgentSkillRow[]> {
const db = await this.getDatabase()
return db.select().from(agentSkillsTable).where(eq(agentSkillsTable.skill_id, skillId))
}
async get(agentId: string, skillId: string): Promise<AgentSkillRow | null> {
const db = await this.getDatabase()
const rows = await db
.select()
.from(agentSkillsTable)
.where(and(eq(agentSkillsTable.agent_id, agentId), eq(agentSkillsTable.skill_id, skillId)))
.limit(1)
return rows[0] ?? null
}
async upsert(agentId: string, skillId: string, isEnabled: boolean): Promise<void> {
const db = await this.getDatabase()
const now = Date.now()
// SQLite upsert via ON CONFLICT on the composite primary key.
await db
.insert(agentSkillsTable)
.values({
agent_id: agentId,
skill_id: skillId,
is_enabled: isEnabled,
created_at: now,
updated_at: now
})
.onConflictDoUpdate({
target: [agentSkillsTable.agent_id, agentSkillsTable.skill_id],
set: { is_enabled: isEnabled, updated_at: now }
})
logger.info('Agent skill upserted', { agentId, skillId, isEnabled })
}
async delete(agentId: string, skillId: string): Promise<void> {
const db = await this.getDatabase()
await db
.delete(agentSkillsTable)
.where(and(eq(agentSkillsTable.agent_id, agentId), eq(agentSkillsTable.skill_id, skillId)))
}
async deleteByAgentId(agentId: string): Promise<void> {
const db = await this.getDatabase()
await db.delete(agentSkillsTable).where(eq(agentSkillsTable.agent_id, agentId))
}
async deleteBySkillId(skillId: string): Promise<void> {
const db = await this.getDatabase()
await db.delete(agentSkillsTable).where(eq(agentSkillsTable.skill_id, skillId))
}
}

View File

@@ -67,6 +67,18 @@ export class SkillRepository extends BaseService {
return this.rowToInstalledSkill(updated[0])
}
async updateMetadata(
id: string,
data: { name: string; description: string | null; author: string | null; tags: string | null; content_hash: string }
): Promise<void> {
const db = await this.getDatabase()
await db
.update(skillsTable)
.set({ ...data, updated_at: Date.now() })
.where(eq(skillsTable.id, id))
logger.info('Skill metadata updated', { id, name: data.name })
}
async delete(id: string): Promise<boolean> {
const db = await this.getDatabase()
const result = await db.delete(skillsTable).where(eq(skillsTable.id, id))

View File

@@ -16,9 +16,12 @@ import type {
SkillInstallOptions,
SkillToggleOptions
} from '@types'
import { eq } from 'drizzle-orm'
import { app, net } from 'electron'
import StreamZip from 'node-stream-zip'
import { agentsTable } from '../database/schema'
import { AgentSkillRepository } from './AgentSkillRepository'
import { SkillInstaller } from './SkillInstaller'
import { SkillRepository } from './SkillRepository'
@@ -33,22 +36,27 @@ const MAX_FILES_COUNT = 1000
const MAX_FOLDER_NAME_LENGTH = 80
/**
* Global skill management service.
* Skill management service.
*
* Skills are stored in {userData}/global-skills/{folderName}/ (inert storage).
* When enabled, a symlink is created at {userData}/.claude/skills/{folderName}/
* pointing to the global storage, making the skill discoverable by Claude Code.
* Skills are stored in `{dataPath}/Skills/{folderName}/` (inert global library).
* When enabled **for a specific agent**, a symlink is created at
* `{agentWorkspace}/.claude/skills/{folderName}/` pointing to the library,
* making the skill discoverable by Claude Code running against that workspace.
*
* Metadata is tracked in the `skills` DB table.
* Skill library metadata lives in the `skills` table. Per-agent enablement
* state lives in the `agent_skills` join table. The legacy `skills.is_enabled`
* column is kept for schema compatibility only and is no longer read or written.
*/
export class SkillService {
private static instance: SkillService | null = null
private readonly repository: SkillRepository
private readonly agentSkillRepository: AgentSkillRepository
private readonly installer: SkillInstaller
private constructor() {
this.repository = SkillRepository.getInstance()
this.agentSkillRepository = AgentSkillRepository.getInstance()
this.installer = new SkillInstaller()
logger.info('SkillService initialized')
}
@@ -64,25 +72,164 @@ export class SkillService {
// Public API
// ===========================================================================
async list(): Promise<InstalledSkill[]> {
return this.repository.list()
/**
* List installed skills.
*
* When `agentId` is provided, each skill's `isEnabled` field reflects the
* per-agent enablement state from `agent_skills`. Without `agentId`, the
* field is forced to `false` — the legacy global flag is ignored.
*/
async list(agentId?: string): Promise<InstalledSkill[]> {
const skills = await this.repository.list()
if (!agentId) {
return skills.map((s) => ({ ...s, isEnabled: false }))
}
const agentSkillRows = await this.agentSkillRepository.getByAgentId(agentId)
const enabledMap = new Map<string, boolean>()
for (const row of agentSkillRows) {
enabledMap.set(row.skill_id, row.is_enabled)
}
return skills.map((s) => ({ ...s, isEnabled: enabledMap.get(s.id) ?? false }))
}
/**
* Enable or disable a skill for a specific agent.
*
* Updates the `agent_skills` join row and creates / removes the
* corresponding symlink under `{agentWorkspace}/.claude/skills/`.
*/
async toggle(options: SkillToggleOptions): Promise<InstalledSkill | null> {
const skill = await this.repository.getById(options.skillId)
if (!skill) return null
// Update DB
const updated = await this.repository.toggleEnabled(options.skillId, options.isEnabled)
const workspace = await this.getAgentWorkspace(options.agentId)
// Create or remove symlink
if (options.isEnabled) {
await this.linkSkill(skill.folderName)
// Update DB first. On a well-known workspace we keep the row in sync even
// if filesystem ops fail below, so a retry / reconcile can recover.
await this.agentSkillRepository.upsert(options.agentId, options.skillId, options.isEnabled)
if (workspace) {
try {
if (options.isEnabled) {
await this.linkSkill(skill.folderName, workspace)
} else {
await this.unlinkSkill(skill.folderName, workspace)
}
} catch (error) {
// Roll back DB state so it stays consistent with the filesystem
await this.agentSkillRepository.upsert(options.agentId, options.skillId, !options.isEnabled).catch((e) => {
logger.error('Failed to roll back agent_skills after symlink error', {
agentId: options.agentId,
skillId: options.skillId,
error: e instanceof Error ? e.message : String(e)
})
})
logger.error('Failed to (un)link skill for agent', {
agentId: options.agentId,
skillId: options.skillId,
isEnabled: options.isEnabled,
error: error instanceof Error ? error.message : String(error)
})
throw error
}
} else {
await this.unlinkSkill(skill.folderName)
logger.warn('Skipping skill symlink: agent has no resolvable workspace', {
agentId: options.agentId,
skillId: options.skillId
})
}
return updated
return { ...skill, isEnabled: options.isEnabled }
}
/**
* Seed skill enablement for a freshly created agent.
*
* Every skill marked `source = 'builtin'` is auto-enabled for the new
* agent — they ship with Cherry Studio and users expect them to work
* everywhere. Other skills default to disabled.
*/
async initSkillsForAgent(agentId: string, workspace: string | undefined): Promise<void> {
const skills = await this.repository.list()
const builtinSkills = skills.filter((s) => s.source === 'builtin')
if (builtinSkills.length === 0) return
for (const skill of builtinSkills) {
await this.agentSkillRepository.upsert(agentId, skill.id, true)
if (workspace) {
try {
await this.linkSkill(skill.folderName, workspace)
} catch (error) {
logger.warn('Failed to link builtin skill for new agent', {
agentId,
skillId: skill.id,
error: error instanceof Error ? error.message : String(error)
})
}
}
}
logger.info('Seeded builtin skills for agent', { agentId, count: builtinSkills.length })
}
/**
* Enable a skill across every existing agent and create per-workspace symlinks.
* Used when a new builtin skill lands (e.g. during startup seeding or app upgrade)
* — builtins are meant to be ambiently available everywhere.
*
* Public because `installBuiltinSkills` inserts skills via the repository
* directly and then needs to fan out the enablement afterwards.
*/
async enableForAllAgents(skillId: string, folderName: string): Promise<void> {
const database = await this.repository.getDatabase()
const agents = await database
.select({ id: agentsTable.id, accessible_paths: agentsTable.accessible_paths })
.from(agentsTable)
for (const agent of agents) {
await this.agentSkillRepository.upsert(agent.id, skillId, true)
const workspace = this.parseFirstAccessiblePath(agent.accessible_paths)
if (!workspace || !(await directoryExists(workspace))) continue
try {
await this.linkSkill(folderName, workspace)
} catch (error) {
logger.warn('Failed to link builtin skill for agent', {
agentId: agent.id,
skillId,
error: error instanceof Error ? error.message : String(error)
})
}
}
logger.info('Enabled skill for all agents', { skillId, folderName, agentCount: agents.length })
}
/**
* Ensure the workspace's `.claude/skills/` directory matches the `agent_skills`
* DB state for the given agent. Called at session start so that external
* file-system drift (user moved the workspace, manually deleted links, etc.)
* gets corrected automatically.
*/
async reconcileAgentSkills(agentId: string, workspace: string): Promise<void> {
if (!workspace) return
const agentSkillRows = await this.agentSkillRepository.getByAgentId(agentId)
const enabledFolders = new Set<string>()
// Ensure enabled skills have symlinks
for (const row of agentSkillRows) {
if (!row.is_enabled) continue
const skill = await this.repository.getById(row.skill_id)
if (!skill) continue
enabledFolders.add(skill.folderName)
try {
await this.linkSkill(skill.folderName, workspace)
} catch (error) {
logger.warn('Reconcile: failed to link skill', {
agentId,
skillId: row.skill_id,
error: error instanceof Error ? error.message : String(error)
})
}
}
}
async readFile(skillId: string, filename: string): Promise<string | null> {
@@ -143,12 +290,28 @@ export class SkillService {
throw new Error(`Skill not found: ${skillId}`)
}
// Remove symlink first
await this.unlinkSkill(skill.folderName)
// Remove symlinks from every agent workspace that had this skill enabled,
// before we lose the join rows to the cascade delete below.
const agentSkillRows = await this.agentSkillRepository.getBySkillId(skillId)
for (const row of agentSkillRows) {
if (!row.is_enabled) continue
const workspace = await this.getAgentWorkspace(row.agent_id)
if (!workspace) continue
try {
await this.unlinkSkill(skill.folderName, workspace)
} catch (error) {
logger.warn('Failed to unlink skill during uninstall', {
skillId,
agentId: row.agent_id,
error: error instanceof Error ? error.message : String(error)
})
}
}
// Remove from global storage
const skillPath = this.getSkillStoragePath(skill.folderName)
await this.installer.uninstall(skillPath)
// FK cascade on skill_id deletes agent_skills rows automatically.
await this.repository.delete(skillId)
logger.info('Skill uninstalled', { skillId, folderName: skill.folderName })
}
@@ -232,21 +395,26 @@ export class SkillService {
// ===========================================================================
/**
* Create a symlink from .claude/skills/{folderName} → global-skills/{folderName}
* Create a symlink from `{workspace}/.claude/skills/{folderName}`
* global skills storage (`{dataPath}/Skills/{folderName}`).
*/
async linkSkill(folderName: string): Promise<void> {
async linkSkill(folderName: string, workspace: string): Promise<void> {
const target = this.getSkillStoragePath(folderName)
const linkPath = this.getSkillLinkPath(folderName)
const linkPath = this.getSkillLinkPath(folderName, workspace)
try {
// Ensure .claude/skills/ directory exists
await fs.promises.mkdir(path.dirname(linkPath), { recursive: true })
// Remove existing link/directory if present
// Remove existing symlink if present; refuse to overwrite real directories
// to avoid destroying user-authored content.
try {
const stat = await fs.promises.lstat(linkPath)
if (stat.isSymbolicLink() || stat.isDirectory()) {
await fs.promises.rm(linkPath, { recursive: true })
if (stat.isSymbolicLink()) {
await fs.promises.rm(linkPath)
} else if (stat.isDirectory()) {
logger.warn('Refusing to overwrite non-symlink directory for skill', { folderName, linkPath })
return
}
} catch {
// Does not exist, fine
@@ -257,6 +425,7 @@ export class SkillService {
} catch (error) {
logger.error('Failed to link skill', {
folderName,
linkPath,
error: error instanceof Error ? error.message : String(error)
})
throw error
@@ -264,21 +433,22 @@ export class SkillService {
}
/**
* Remove the symlink at .claude/skills/{folderName}
* Remove the symlink at `{workspace}/.claude/skills/{folderName}`.
*/
async unlinkSkill(folderName: string): Promise<void> {
const linkPath = this.getSkillLinkPath(folderName)
async unlinkSkill(folderName: string, workspace: string): Promise<void> {
const linkPath = this.getSkillLinkPath(folderName, workspace)
try {
const stat = await fs.promises.lstat(linkPath)
if (stat.isSymbolicLink()) {
await fs.promises.unlink(linkPath)
logger.info('Skill unlinked', { folderName })
logger.info('Skill unlinked', { folderName, linkPath })
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Failed to unlink skill', {
folderName,
linkPath,
error: error instanceof Error ? error.message : String(error)
})
throw error
@@ -410,15 +580,26 @@ export class SkillService {
await fs.promises.mkdir(path.dirname(destPath), { recursive: true })
await this.installer.install(skillDir, destPath)
const tags = metadata.tags ? JSON.stringify(metadata.tags) : null
if (existing) {
// Update existing skill
await this.repository.delete(existing.id)
// Update metadata in-place to preserve the skill ID and its agent_skills
// rows — a delete+insert would cascade-drop per-agent enablement state.
await this.repository.updateMetadata(existing.id, {
name: metadata.name,
description: metadata.description ?? null,
author: metadata.author ?? null,
tags,
content_hash: contentHash
})
const updated = (await this.repository.getById(existing.id))!
logger.info('Skill updated', { id: existing.id, name: metadata.name, folderName, source })
return updated
}
const isBuiltin = source === 'builtin'
const id = randomUUID()
const now = Date.now()
const tags = metadata.tags ? JSON.stringify(metadata.tags) : null
const skill = await this.repository.insert({
id,
@@ -431,14 +612,17 @@ export class SkillService {
author: metadata.author ?? null,
tags,
content_hash: contentHash,
is_enabled: isBuiltin,
// Legacy column: no longer consumed. Per-agent state lives in agent_skills.
is_enabled: false,
created_at: now,
updated_at: now
})
// Built-in skills are always linked
// Built-in skills are auto-enabled for every existing agent — they ship
// with the app and users expect them to work without manual opt-in.
// For non-builtin sources, enablement happens per-agent via toggle().
if (isBuiltin) {
await this.linkSkill(folderName)
await this.enableForAllAgents(skill.id, folderName)
}
logger.info('Skill installed', { id, name: metadata.name, folderName, source })
@@ -589,9 +773,42 @@ export class SkillService {
return path.join(getDataPath('Skills'), folderName)
}
/** Symlink location: {userData}/.claude/skills/{folderName} */
private getSkillLinkPath(folderName: string): string {
return path.join(app.getPath('userData'), '.claude', 'skills', folderName)
/** Symlink location for a given agent workspace: `{workspace}/.claude/skills/{folderName}` */
private getSkillLinkPath(folderName: string, workspace: string): string {
return path.join(workspace, '.claude', 'skills', folderName)
}
/**
* Resolve an agent's primary workspace (`accessible_paths[0]`) for symlink
* operations. Returns `undefined` when the agent has no usable workspace
* — callers should skip filesystem work in that case.
*/
private async getAgentWorkspace(agentId: string): Promise<string | undefined> {
const database = await this.repository.getDatabase()
const rows = await database
.select({ accessible_paths: agentsTable.accessible_paths })
.from(agentsTable)
.where(eq(agentsTable.id, agentId))
.limit(1)
const workspace = this.parseFirstAccessiblePath(rows[0]?.accessible_paths)
if (!workspace) return undefined
// Workspace may reference a path from a different machine (e.g. restored backup).
// Skip if it doesn't exist locally to avoid EACCES on mkdir.
if (!(await directoryExists(workspace))) return undefined
return workspace
}
private parseFirstAccessiblePath(serialized: string | null | undefined): string | undefined {
if (!serialized) return undefined
try {
const paths = JSON.parse(serialized) as unknown
if (Array.isArray(paths) && paths.length > 0 && typeof paths[0] === 'string') {
return paths[0]
}
} catch {
// Fall through
}
return undefined
}
private sanitizeFolderName(folderName: string): string {

View File

@@ -7,6 +7,7 @@ import { parseSkillMetadata } from '@main/utils/markdownParser'
import { app } from 'electron'
import { SkillRepository } from '../services/agents/skills/SkillRepository'
import { skillService } from '../services/agents/skills/SkillService'
import { getDataPath, toAsarUnpackedPath } from '.'
const logger = loggerService.withContext('builtinSkills')
@@ -14,26 +15,26 @@ const logger = loggerService.withContext('builtinSkills')
const VERSION_FILE = '.version'
/**
* Copy built-in skills from app resources to the global-skills storage
* directory, then create symlinks in .claude/skills/ so they are
* discoverable by Claude Code.
* Copy built-in skills from app resources to the global skills storage
* directory and register them in the `skills` DB table.
*
* Storage: {userData}/Data/Skills/{folderName}/
* Symlink: {userData}/.claude/skills/{folderName}/ → storage
*
* Per-agent enablement is handled separately: each existing agent gets a
* symlink at `{agentWorkspace}/.claude/skills/{folderName}/` via
* `skillService.enableForAllAgents` for any **newly registered** builtin
* (i.e. first-run or app-upgrade that adds a new builtin). Already-registered
* builtins are left alone so user per-agent choices survive upgrades.
*
* Each installed skill gets a `.version` file recording the app version that
* installed it. On subsequent launches the bundled version is compared with
* the installed version — the skill is overwritten only when the app ships a
* newer version.
*
* Built-in skills are also registered in the `skills` DB table so they appear
* in the SkillsSettings UI alongside user-installed skills.
* the installed version — the skill files are overwritten only when the app
* ships a newer version.
*/
// TODO: v2-backup
export async function installBuiltinSkills(): Promise<void> {
const resourceSkillsPath = toAsarUnpackedPath(path.join(app.getAppPath(), 'resources', 'skills'))
const globalSkillsPath = getDataPath('Skills')
const linkBasePath = path.join(app.getPath('userData'), '.claude', 'skills')
const appVersion = app.getVersion()
try {
@@ -50,67 +51,35 @@ export async function installBuiltinSkills(): Promise<void> {
})
let installed = 0
await Promise.all(
dirs.map(async (entry) => {
const destPath = path.join(globalSkillsPath, entry.name)
const filesUpdated = !(await isUpToDate(destPath, appVersion))
// Process sequentially to avoid interleaved delete+insert on the skills
// table when multiple builtins require a metadata refresh.
for (const entry of dirs) {
const destPath = path.join(globalSkillsPath, entry.name)
const filesUpdated = !(await isUpToDate(destPath, appVersion))
if (filesUpdated) {
await fs.mkdir(destPath, { recursive: true })
await fs.cp(path.join(resourceSkillsPath, entry.name), destPath, { recursive: true })
await fs.writeFile(path.join(destPath, VERSION_FILE), appVersion, 'utf-8')
installed++
}
if (filesUpdated) {
await fs.mkdir(destPath, { recursive: true })
await fs.cp(path.join(resourceSkillsPath, entry.name), destPath, { recursive: true })
await fs.writeFile(path.join(destPath, VERSION_FILE), appVersion, 'utf-8')
installed++
}
// Ensure symlink exists: .claude/skills/{name} → global-skills/{name}
await ensureSymlink(destPath, path.join(linkBasePath, entry.name))
// Ensure the skill is registered in the DB
await syncBuiltinSkillToDb(entry.name, destPath, filesUpdated)
})
)
// Register (or refresh) the DB row; fan the skill out to existing agents
// only when this is the first time we see it.
await syncBuiltinSkillToDb(entry.name, destPath, filesUpdated)
}
if (installed > 0) {
logger.info('Built-in skills installed', { installed, version: appVersion })
}
}
/**
* Create a symlink if it doesn't already exist or points to the wrong target.
*/
async function ensureSymlink(target: string, linkPath: string): Promise<void> {
try {
await fs.mkdir(path.dirname(linkPath), { recursive: true })
// Check existing link
try {
const existing = await fs.readlink(linkPath)
if (existing === target) return // already correct
// Wrong target — remove and recreate
await fs.rm(linkPath, { recursive: true })
} catch {
// Doesn't exist or not a symlink — remove if something else is there
try {
await fs.rm(linkPath, { recursive: true })
} catch {
// nothing there
}
}
await fs.symlink(target, linkPath, 'junction')
} catch (error) {
logger.warn('Failed to create symlink for built-in skill', {
target,
linkPath,
error: error instanceof Error ? error.message : String(error)
})
}
}
/**
* Ensure a built-in skill has a corresponding row in the `skills` DB table.
* If the row already exists and files were not updated, skip.
* If files were updated or the row is missing, upsert.
* If files were updated the metadata is refreshed. If the row is missing
* entirely (first time we see this builtin) the skill is fanned out to every
* existing agent's workspace.
*/
async function syncBuiltinSkillToDb(folderName: string, destPath: string, filesUpdated: boolean): Promise<void> {
try {
@@ -122,28 +91,40 @@ async function syncBuiltinSkillToDb(folderName: string, destPath: string, filesU
const metadata = await parseSkillMetadata(destPath, folderName, 'skills')
const contentHash = await computeHash(destPath)
const tags = metadata.tags ? JSON.stringify(metadata.tags) : null
if (existing) {
// Delete and re-insert to update metadata
await repo.delete(existing.id)
// Update metadata in-place to preserve the skill ID and its agent_skills
// rows (per-agent enablement state survives app upgrades).
await repo.updateMetadata(existing.id, {
name: metadata.name,
description: metadata.description ?? null,
author: metadata.author ?? null,
tags,
content_hash: contentHash
})
} else {
const now = Date.now()
const inserted = await repo.insert({
name: metadata.name,
description: metadata.description ?? null,
folder_name: folderName,
source: 'builtin',
source_url: null,
namespace: null,
author: metadata.author ?? null,
tags,
content_hash: contentHash,
is_enabled: false,
created_at: now,
updated_at: now
})
// Fan out to every agent on first install only.
await skillService.enableForAllAgents(inserted.id, folderName)
}
const now = Date.now()
await repo.insert({
name: metadata.name,
description: metadata.description ?? null,
folder_name: folderName,
source: 'builtin',
source_url: null,
namespace: null,
author: metadata.author ?? null,
tags: metadata.tags ? JSON.stringify(metadata.tags) : null,
content_hash: contentHash,
is_enabled: existing?.isEnabled ?? true,
created_at: existing ? existing.createdAt : now,
updated_at: now
})
logger.info('Built-in skill synced to DB', { folderName })
logger.info('Built-in skill synced to DB', { folderName, firstInstall: !existing })
} catch (error) {
logger.warn('Failed to sync built-in skill to DB', {
folderName,

View File

@@ -768,7 +768,8 @@ const api = {
}
},
skill: {
list: (): Promise<SkillResult<InstalledSkill[]>> => ipcRenderer.invoke(IpcChannel.Skill_List),
list: (agentId?: string): Promise<SkillResult<InstalledSkill[]>> =>
ipcRenderer.invoke(IpcChannel.Skill_List, agentId),
install: (options: SkillInstallOptions): Promise<SkillResult<InstalledSkill>> =>
ipcRenderer.invoke(IpcChannel.Skill_Install, options),
uninstall: (skillId: string): Promise<SkillResult<void>> => ipcRenderer.invoke(IpcChannel.Skill_Uninstall, skillId),

View File

@@ -3,9 +3,14 @@ import type { InstalledSkill, SkillSearchResult } from '@types'
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Hook to manage globally installed skills.
* Hook to manage installed skills.
*
* Pass `agentId` to get per-agent enablement state and to scope toggle calls
* to that agent. Without `agentId`, the hook returns the global skill library
* with `isEnabled` forced to false — callers without an agent context (e.g.
* the global Settings → Skills page) should rely on uninstall only.
*/
export function useInstalledSkills() {
export function useInstalledSkills(agentId?: string) {
const [skills, setSkills] = useState<InstalledSkill[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -14,7 +19,7 @@ export function useInstalledSkills() {
setLoading(true)
setError(null)
try {
const result = await window.api.skill.list()
const result = await window.api.skill.list(agentId)
if (result.success) {
setSkills(result.data)
} else {
@@ -25,7 +30,7 @@ export function useInstalledSkills() {
} finally {
setLoading(false)
}
}, [])
}, [agentId])
useEffect(() => {
void refresh()
@@ -33,8 +38,14 @@ export function useInstalledSkills() {
const toggle = useCallback(
async (skillId: string, isEnabled: boolean) => {
if (!agentId) {
// Without an agent context there is nothing to toggle — per-agent
// enablement has no target. Callers that want to toggle must scope
// to an agent.
return false
}
try {
const result = await window.api.skill.toggle({ skillId, isEnabled })
const result = await window.api.skill.toggle({ skillId, agentId, isEnabled })
if (result.success) {
await refresh()
}
@@ -43,7 +54,7 @@ export function useInstalledSkills() {
return false
}
},
[refresh]
[agentId, refresh]
)
const uninstall = useCallback(

View File

@@ -24,6 +24,7 @@ const ResourceQuickPanelManager = ({ context }: ManagerProps) => {
quickPanel,
quickPanelController,
accessiblePaths,
agentId: session?.agentId,
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
},
'manager'

View File

@@ -35,17 +35,17 @@ interface Params {
quickPanel: ToolQuickPanelApi
quickPanelController: ToolQuickPanelController
accessiblePaths: string[]
agentId?: string
setText: React.Dispatch<React.SetStateAction<string>>
}
export const useResourcePanel = (params: Params, role: 'button' | 'manager' = 'button') => {
const { quickPanel, quickPanelController, accessiblePaths, setText } = params
const { quickPanel, quickPanelController, accessiblePaths, agentId, setText } = params
const { registerTrigger, registerRootMenu } = quickPanel
const { open, close, updateList, isVisible, symbol } = quickPanelController
const { t } = useTranslation()
const { skills, loading: skillsLoading } = useInstalledSkills()
const enabledSkills = useMemo(() => skills.filter((s) => s.isEnabled), [skills])
const { skills: enabledSkills, loading: skillsLoading } = useInstalledSkills(agentId)
const [fileList, setFileList] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false)

View File

@@ -4,7 +4,7 @@ import { useInstalledSkills } from '@renderer/hooks/useSkills'
import type { InstalledSkill, LocalSkill } from '@types'
import { Button, Card, type CardProps, Empty, Spin, Switch, Tag } from 'antd'
import { Plus, Puzzle } from 'lucide-react'
import { type FC, memo, useCallback, useEffect, useMemo, useState } from 'react'
import { type FC, memo, useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { type AgentOrSessionSettingsProps, SettingsContainer, SettingsItem, SettingsTitle } from '../../shared'
@@ -34,7 +34,7 @@ const SkillCard = memo<{
onToggle: (skill: InstalledSkill, checked: boolean) => void
}>(({ skill, toggling, onToggle }) => {
const { t } = useTranslation()
const handleChange = useCallback((checked: boolean) => onToggle(skill, checked), [skill, onToggle])
const handleChange = useEffectEvent((checked: boolean) => onToggle(skill, checked))
return (
<Card
@@ -86,12 +86,22 @@ const LocalSkillCard = memo<{ plugin: LocalSkill }>(({ plugin }) => (
LocalSkillCard.displayName = 'LocalSkillCard'
/**
* Agent Skills Settings - shows globally installed skills with enable/disable toggle
* and local skills from .claude/skills/.
* Agent Skills Settings - shows the global skill library with a per-agent
* enable/disable toggle, plus local skills from the agent workspace
* `.claude/skills/` directory.
*
* The `isEnabled` field in each skill reflects the state from `agent_skills`
* for the current agent — toggling only affects this agent's workspace.
*/
export const InstalledSkillsSettings: FC<AgentOrSessionSettingsProps> = ({ agentBase }) => {
const { t } = useTranslation()
const { skills, loading, error, toggle } = useInstalledSkills()
// Skills are enabled per-agent, not per-session. When the settings popup is
// opened from a session, `agentBase` is a session object and its parent
// agent id lives on `agent_id`. When opened from an agent, `agentBase.id`
// is the agent id.
const agentId =
agentBase && 'agent_id' in agentBase && typeof agentBase.agent_id === 'string' ? agentBase.agent_id : agentBase?.id
const { skills, loading, error, toggle } = useInstalledSkills(agentId)
const [filter, setFilter] = useState('')
const [togglingId, setTogglingId] = useState<string | null>(null)
const [localPlugins, setLocalSkills] = useState<LocalSkill[]>([])

View File

@@ -224,7 +224,7 @@ const SkillsSettings: FC = () => {
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [fileContent, setFileContent] = useState<string | null>(null)
const [loadingContent, setLoadingContent] = useState(false)
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set())
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => new Set())
// Search state (online registry)
const [searchQuery, setSearchQuery] = useState('')
@@ -236,7 +236,7 @@ const SkillsSettings: FC = () => {
// Multi-select state
const [multiSelectMode, setMultiSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
// Search tab state
const [searchTab, setSearchTab] = useState<SkillSearchSource>('claude-plugins.dev')
@@ -375,9 +375,7 @@ const SkillsSettings: FC = () => {
title: t('settings.skills.confirmBatchUninstall', { count: toDelete.length }),
centered: true,
onOk: async () => {
for (const skill of toDelete) {
await uninstall(skill.id)
}
await Promise.all(toDelete.map((skill) => uninstall(skill.id)))
setSelectedIds(new Set())
setMultiSelectMode(false)
setSelectedSkill(null)
@@ -449,11 +447,7 @@ const SkillsSettings: FC = () => {
setSelectedSkill(null)
}, [])
const selectedFileName = useMemo(() => {
if (!selectedFile) return null
const parts = selectedFile.split('/')
return parts[parts.length - 1]
}, [selectedFile])
const selectedFileName = selectedFile ? selectedFile.split('/').pop()! : null
const handleCloseSearch = useCallback(() => {
setSearchQuery('')

View File

@@ -164,6 +164,7 @@ export interface SkillInstallOptions {
export interface SkillToggleOptions {
skillId: string
agentId: string
isEnabled: boolean
}