mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 20:30:52 +08:00
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:
13
resources/database/drizzle/0006_famous_fallen_one.sql
Normal file
13
resources/database/drizzle/0006_famous_fallen_one.sql
Normal 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`);
|
||||
1
resources/database/drizzle/0007_strange_galactus.sql
Normal file
1
resources/database/drizzle/0007_strange_galactus.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `agents` ADD `deleted_at` text;
|
||||
972
resources/database/drizzle/meta/0006_snapshot.json
Normal file
972
resources/database/drizzle/meta/0006_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
979
resources/database/drizzle/meta/0007_snapshot.json
Normal file
979
resources/database/drizzle/meta/0007_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
143
src/main/services/agents/database/migrateSkillsPerAgent.ts
Normal file
143
src/main/services/agents/database/migrateSkillsPerAgent.ts
Normal 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')
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export * from './agents.schema'
|
||||
export * from './agentSkills.schema'
|
||||
export * from './channels.schema'
|
||||
export * from './messages.schema'
|
||||
export * from './migrations.schema'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
141
src/main/services/agents/services/__tests__/AgentService.test.ts
Normal file
141
src/main/services/agents/services/__tests__/AgentService.test.ts
Normal 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)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
84
src/main/services/agents/skills/AgentSkillRepository.ts
Normal file
84
src/main/services/agents/skills/AgentSkillRepository.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -24,6 +24,7 @@ const ResourceQuickPanelManager = ({ context }: ManagerProps) => {
|
||||
quickPanel,
|
||||
quickPanelController,
|
||||
accessiblePaths,
|
||||
agentId: session?.agentId,
|
||||
setText: onTextChange as React.Dispatch<React.SetStateAction<string>>
|
||||
},
|
||||
'manager'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -164,6 +164,7 @@ export interface SkillInstallOptions {
|
||||
|
||||
export interface SkillToggleOptions {
|
||||
skillId: string
|
||||
agentId: string
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user