sophhub 0.4.30 → 0.4.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/restore-state/skill.json +16 -0
- package/skills/restore-state/src/SKILL.md +82 -0
- package/skills/restore-state/src/scripts/restore.py +396 -0
- package/skills/sophclaw-skill-creator/skill.json +15 -4
- package/skills/sophclaw-skill-creator/src/SKILL.md +20 -184
- package/skills/sophclaw-skill-creator/src/pyproject.toml +1 -1
- package/skills/sophclaw-skill-creator/src/references/path-a-create.md +208 -0
- package/skills/sophclaw-skill-creator/src/references/path-b-backfill.md +111 -0
- package/skills/sophclaw-skill-creator/src/scripts/generate.py +5 -2
package/package.json
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "restore-state",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"types": ["store"],
|
|
5
|
+
"displayName": "状态恢复",
|
|
6
|
+
"description": "从 ZIP 备份文件恢复 SophClaw 完整状态。当用户要求恢复数据、还原备份、恢复账号或上传了备份 ZIP 文件时触发。支持恢复 agent 配置、会话历史、workspace 文件、语义记忆等全部状态。",
|
|
7
|
+
"changelog": [
|
|
8
|
+
{
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"date": "2026-05-27",
|
|
11
|
+
"changes": ["从 moltbot 迁移:ZIP 备份解析、openclaw.json 合并、会话恢复、workspace 恢复"]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"createdAt": "2026-05-27",
|
|
15
|
+
"updatedAt": "2026-05-27"
|
|
16
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: restore-state
|
|
3
|
+
description: 从 ZIP 备份文件恢复 SophClaw 完整状态。当用户说要"恢复数据"、"还原备份"、"恢复我的账号"、"restore"并上传了 ZIP 文件时触发。支持恢复 agent 配置、会话历史、workspace 文件、语义记忆等全部状态。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Restore State
|
|
7
|
+
|
|
8
|
+
从用户上传的 ZIP 备份文件恢复 SophClaw 完整状态到当前空白容器。
|
|
9
|
+
|
|
10
|
+
## 前置检查
|
|
11
|
+
|
|
12
|
+
在开始恢复之前,先确认以下几点:
|
|
13
|
+
|
|
14
|
+
1. 用户已通过聊天上传了一个 `.zip` 文件(从 SophClaw 前端下载的备份包)
|
|
15
|
+
2. 当前容器是空白状态(新创建的容器,不包含用户之前的数据)
|
|
16
|
+
3. 如果当前容器已有重要数据,警告用户会被部分覆盖
|
|
17
|
+
|
|
18
|
+
向用户简短确认:
|
|
19
|
+
|
|
20
|
+
- "我将用你上传的备份文件恢复你的 SophClaw 状态,包括 agent 配置、会话历史和 workspace 文件。当前容器的某些数据会被覆盖。确认继续吗?"
|
|
21
|
+
|
|
22
|
+
用户确认后继续。
|
|
23
|
+
|
|
24
|
+
## 定位上传的 ZIP 文件
|
|
25
|
+
|
|
26
|
+
用户通过 SophClaw Web UI 上传的文件会被保存在 `~/.openclaw/media/inbound/` 目录下,文件名格式为 `<原始名称>---<uuid>.zip`。
|
|
27
|
+
|
|
28
|
+
**第一步:查找最新上传的 ZIP 文件**
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
ls -lt ~/.openclaw/media/inbound/*.zip 2>/dev/null | head -5
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
如果用户刚上传了一个恢复用的 ZIP,最新的文件就是它(文件名包含恢复相关的关键词如 `restore`、`backup`、`openclaw` 等)。
|
|
35
|
+
|
|
36
|
+
如果找到明确的文件,直接使用它的完整路径。如果有多个 ZIP,选择最新的那个。
|
|
37
|
+
|
|
38
|
+
**第二步:如果 media/inbound 目录没有 ZIP**
|
|
39
|
+
|
|
40
|
+
可能用户是通过其他方式提供的文件路径。请用户确认文件位置,然后用 `ls` 验证。
|
|
41
|
+
|
|
42
|
+
## 执行恢复
|
|
43
|
+
|
|
44
|
+
找到 ZIP 文件路径后,运行:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python3 skills/restore-state/src/scripts/restore.py "<找到的zip文件完整路径>"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
也可以不加参数直接运行,脚本会自动搜索 `~/.openclaw/media/inbound/` 中最新的 ZIP:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python3 skills/restore-state/src/scripts/restore.py
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 解释恢复结果
|
|
57
|
+
|
|
58
|
+
脚本会输出恢复摘要,包括:
|
|
59
|
+
- 恢复了哪些 agent(main、assistant、beauty、vip-qa 等)
|
|
60
|
+
- 恢复了多少条会话记录
|
|
61
|
+
- 恢复了哪些 workspace
|
|
62
|
+
- 是否有跳过的文件及原因
|
|
63
|
+
|
|
64
|
+
向用户报告摘要。
|
|
65
|
+
|
|
66
|
+
## 下一步提示
|
|
67
|
+
|
|
68
|
+
提醒用户:
|
|
69
|
+
|
|
70
|
+
- "由于更新了配置,SophClaw 会自动重新加载。等待约 10 秒后刷新前端页面即可看到恢复的会话和 agent。"
|
|
71
|
+
|
|
72
|
+
## 异常处理
|
|
73
|
+
|
|
74
|
+
| 场景 | 处理方式 |
|
|
75
|
+
|------|----------|
|
|
76
|
+
| ZIP 文件路径不存在 | 用 `ls ~/.openclaw/media/inbound/` 查看可用文件,选最新的 ZIP |
|
|
77
|
+
| ZIP 文件格式损坏 | 请用户重新下载备份文件后再试 |
|
|
78
|
+
| 未找到任何 ZIP 文件 | 请用户重新上传备份文件到聊天中 |
|
|
79
|
+
| Python 或依赖缺失 | 运行 `which python3` 确认解释器存在 |
|
|
80
|
+
| 权限被拒绝写入 ~/.openclaw | 检查当前用户和目录权限 |
|
|
81
|
+
| openclaw.json 解析失败 | 检查备份文件是否完整,尝试用 `python3 -m json.tool` 验证 JSON |
|
|
82
|
+
| Gateway 重载失败 | 手动运行 `openclaw daemon restart` |
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SophClaw state restore tool.
|
|
3
|
+
|
|
4
|
+
Extracts a SophClaw backup ZIP and merges its contents into the current
|
|
5
|
+
state directory (~/.openclaw by default).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 restore.py [backup.zip]
|
|
9
|
+
|
|
10
|
+
If no path is given, auto-discovers the latest ZIP in ~/.openclaw/media/inbound/.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import zipfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_state_dir() -> Path:
|
|
23
|
+
"""Resolve the SophClaw state directory, same logic as SophClaw's resolveStateDir."""
|
|
24
|
+
env_override = os.environ.get("OPENCLAW_STATE_DIR")
|
|
25
|
+
if env_override:
|
|
26
|
+
return Path(env_override).expanduser().resolve()
|
|
27
|
+
|
|
28
|
+
home = Path.home()
|
|
29
|
+
candidates = [
|
|
30
|
+
home / ".openclaw",
|
|
31
|
+
home / ".clawdbot",
|
|
32
|
+
home / ".moltbot",
|
|
33
|
+
]
|
|
34
|
+
for candidate in candidates:
|
|
35
|
+
if candidate.exists():
|
|
36
|
+
return candidate
|
|
37
|
+
|
|
38
|
+
return home / ".openclaw"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def auto_discover_zip(state_dir: Path) -> Path | None:
|
|
42
|
+
"""Find the most recent ZIP file in the media/inbound directory."""
|
|
43
|
+
inbound = state_dir / "media" / "inbound"
|
|
44
|
+
if not inbound.is_dir():
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
zip_files = sorted(
|
|
48
|
+
inbound.glob("*.zip"),
|
|
49
|
+
key=lambda p: p.stat().st_mtime,
|
|
50
|
+
reverse=True,
|
|
51
|
+
)
|
|
52
|
+
if not zip_files:
|
|
53
|
+
return None
|
|
54
|
+
return zip_files[0]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Config keys to RESTORE from the backup (user-created content and preferences).
|
|
58
|
+
RESTORE_BACKUP_KEYS = {
|
|
59
|
+
"agents",
|
|
60
|
+
"models",
|
|
61
|
+
"skills",
|
|
62
|
+
"tools",
|
|
63
|
+
"commands",
|
|
64
|
+
"messages",
|
|
65
|
+
"session",
|
|
66
|
+
"identity",
|
|
67
|
+
"cron",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def deep_merge(base: dict, overlay: dict) -> dict:
|
|
72
|
+
"""Recursively merge overlay into base. overlay values win on conflict."""
|
|
73
|
+
result = dict(base)
|
|
74
|
+
for key, value in overlay.items():
|
|
75
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
76
|
+
result[key] = deep_merge(result[key], value)
|
|
77
|
+
else:
|
|
78
|
+
result[key] = value
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def merge_openclaw_json(backup_path: Path, state_dir: Path) -> dict:
|
|
83
|
+
"""Merge backup openclaw.json with the current one.
|
|
84
|
+
|
|
85
|
+
Backup values take precedence for user-config keys (agents, models, etc.).
|
|
86
|
+
Container runtime keys (gateway, channels, plugins) are kept from current.
|
|
87
|
+
"""
|
|
88
|
+
current_path = state_dir / "openclaw.json"
|
|
89
|
+
if not current_path.exists():
|
|
90
|
+
with open(backup_path, "r", encoding="utf-8") as f:
|
|
91
|
+
return json.load(f)
|
|
92
|
+
|
|
93
|
+
with open(current_path, "r", encoding="utf-8") as f:
|
|
94
|
+
current = json.load(f)
|
|
95
|
+
with open(backup_path, "r", encoding="utf-8") as f:
|
|
96
|
+
backup = json.load(f)
|
|
97
|
+
|
|
98
|
+
merged = dict(current)
|
|
99
|
+
|
|
100
|
+
for key in RESTORE_BACKUP_KEYS:
|
|
101
|
+
if key in backup:
|
|
102
|
+
if key in merged and isinstance(merged[key], dict) and isinstance(backup[key], dict):
|
|
103
|
+
merged[key] = deep_merge(merged[key], backup[key])
|
|
104
|
+
else:
|
|
105
|
+
merged[key] = backup[key]
|
|
106
|
+
|
|
107
|
+
return merged
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def merge_sessions_json(backup_path: Path, current_path: Path) -> tuple[int, int]:
|
|
111
|
+
"""Merge backup sessions.json into the current one.
|
|
112
|
+
|
|
113
|
+
Backup entries win on session ID conflict.
|
|
114
|
+
Returns (backup_session_count, merged_total_count).
|
|
115
|
+
"""
|
|
116
|
+
current_sessions: dict = {}
|
|
117
|
+
if current_path.exists():
|
|
118
|
+
with open(current_path, "r", encoding="utf-8") as f:
|
|
119
|
+
current_sessions = json.load(f)
|
|
120
|
+
|
|
121
|
+
with open(backup_path, "r", encoding="utf-8") as f:
|
|
122
|
+
backup_sessions = json.load(f)
|
|
123
|
+
|
|
124
|
+
merged = dict(current_sessions)
|
|
125
|
+
for session_id, entry in backup_sessions.items():
|
|
126
|
+
merged[session_id] = entry
|
|
127
|
+
|
|
128
|
+
current_path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
with open(current_path, "w", encoding="utf-8") as f:
|
|
130
|
+
json.dump(merged, f, ensure_ascii=False, indent=2)
|
|
131
|
+
f.write("\n")
|
|
132
|
+
|
|
133
|
+
return len(backup_sessions), len(merged)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def copy_file(src: Path, dst: Path) -> bool:
|
|
137
|
+
"""Copy a file from src to dst, creating parent dirs as needed."""
|
|
138
|
+
try:
|
|
139
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
shutil.copy2(src, dst)
|
|
141
|
+
return True
|
|
142
|
+
except OSError as e:
|
|
143
|
+
print(f" [WARN] Failed to copy {src} -> {dst}: {e}", file=sys.stderr)
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def copy_directory(src: Path, dst: Path) -> tuple[int, int]:
|
|
148
|
+
"""Recursively copy directory contents from src to dst."""
|
|
149
|
+
copied = 0
|
|
150
|
+
skipped = 0
|
|
151
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
for item in src.rglob("*"):
|
|
153
|
+
rel = item.relative_to(src)
|
|
154
|
+
target = dst / rel
|
|
155
|
+
if item.is_dir():
|
|
156
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
continue
|
|
158
|
+
if copy_file(item, target):
|
|
159
|
+
copied += 1
|
|
160
|
+
else:
|
|
161
|
+
skipped += 1
|
|
162
|
+
return copied, skipped
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_backup_path(args: list[str], state_dir: Path) -> tuple[Path, str]:
|
|
166
|
+
"""Resolve the backup ZIP path from CLI args or auto-discovery.
|
|
167
|
+
|
|
168
|
+
Returns (path, source_description).
|
|
169
|
+
"""
|
|
170
|
+
if len(args) >= 2:
|
|
171
|
+
user_path = Path(args[1]).resolve()
|
|
172
|
+
if not user_path.exists():
|
|
173
|
+
print(f"Error: backup file not found: {user_path}", file=sys.stderr)
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
if not zipfile.is_zipfile(user_path):
|
|
176
|
+
print(f"Error: not a valid ZIP file: {user_path}", file=sys.stderr)
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
return user_path, "user-specified"
|
|
179
|
+
|
|
180
|
+
# Auto-discover from media/inbound
|
|
181
|
+
discovered = auto_discover_zip(state_dir)
|
|
182
|
+
if discovered is None:
|
|
183
|
+
print(
|
|
184
|
+
"Error: no backup ZIP path provided and no ZIP files found in "
|
|
185
|
+
f"{state_dir / 'media' / 'inbound'}",
|
|
186
|
+
file=sys.stderr,
|
|
187
|
+
)
|
|
188
|
+
print(
|
|
189
|
+
"Usage: python3 restore.py <backup.zip>\n"
|
|
190
|
+
" or upload a ZIP via SophClaw chat and run without arguments.",
|
|
191
|
+
file=sys.stderr,
|
|
192
|
+
)
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
return discovered, "auto-discovered"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main():
|
|
199
|
+
state_dir = resolve_state_dir()
|
|
200
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
|
|
202
|
+
zip_path, source = resolve_backup_path(sys.argv, state_dir)
|
|
203
|
+
|
|
204
|
+
print(f"State directory: {state_dir}")
|
|
205
|
+
print(f"Backup ZIP ({source}): {zip_path}")
|
|
206
|
+
print(f" Size: {zip_path.stat().st_size / 1024:.1f} KB")
|
|
207
|
+
print()
|
|
208
|
+
|
|
209
|
+
summary = {
|
|
210
|
+
"agents_restored": [],
|
|
211
|
+
"sessions_total": 0,
|
|
212
|
+
"sessions_merged": 0,
|
|
213
|
+
"files_copied": 0,
|
|
214
|
+
"files_skipped": 0,
|
|
215
|
+
"workspaces_restored": [],
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
with tempfile.TemporaryDirectory(prefix="sophclaw-restore-") as tmpdir:
|
|
219
|
+
tmp = Path(tmpdir)
|
|
220
|
+
print("Extracting ZIP...")
|
|
221
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
222
|
+
zf.extractall(tmp)
|
|
223
|
+
|
|
224
|
+
top_items = list(tmp.iterdir())
|
|
225
|
+
print(f" Extracted {len(top_items)} top-level items")
|
|
226
|
+
|
|
227
|
+
# === 1. openclaw.json ===
|
|
228
|
+
backup_config = tmp / "openclaw.json"
|
|
229
|
+
if backup_config.exists():
|
|
230
|
+
print("\n--- Config ---")
|
|
231
|
+
print(" Merging openclaw.json...")
|
|
232
|
+
merged = merge_openclaw_json(backup_config, state_dir)
|
|
233
|
+
|
|
234
|
+
config_target = state_dir / "openclaw.json"
|
|
235
|
+
with open(config_target, "w", encoding="utf-8") as f:
|
|
236
|
+
json.dump(merged, f, ensure_ascii=False, indent=2)
|
|
237
|
+
f.write("\n")
|
|
238
|
+
print(f" Written: {config_target}")
|
|
239
|
+
|
|
240
|
+
agents_list = merged.get("agents", {}).get("list", [])
|
|
241
|
+
for agent in agents_list:
|
|
242
|
+
summary["agents_restored"].append(agent.get("id", "unknown"))
|
|
243
|
+
print(f" Agents restored: {', '.join(summary['agents_restored']) or 'none'}")
|
|
244
|
+
else:
|
|
245
|
+
print(" [WARN] No openclaw.json found in backup")
|
|
246
|
+
|
|
247
|
+
# === 2. Agents (sessions, models, runtime-stats) ===
|
|
248
|
+
backup_agents = tmp / "agents"
|
|
249
|
+
if backup_agents.is_dir():
|
|
250
|
+
print("\n--- Agents ---")
|
|
251
|
+
current_agents = state_dir / "agents"
|
|
252
|
+
|
|
253
|
+
for agent_dir in sorted(backup_agents.iterdir()):
|
|
254
|
+
if not agent_dir.is_dir():
|
|
255
|
+
continue
|
|
256
|
+
agent_name = agent_dir.name
|
|
257
|
+
|
|
258
|
+
# Merge sessions.json
|
|
259
|
+
backup_sessions_json = agent_dir / "sessions" / "sessions.json"
|
|
260
|
+
current_sessions_json = current_agents / agent_name / "sessions" / "sessions.json"
|
|
261
|
+
if backup_sessions_json.exists():
|
|
262
|
+
backup_count, merged_count = merge_sessions_json(
|
|
263
|
+
backup_sessions_json, current_sessions_json
|
|
264
|
+
)
|
|
265
|
+
summary["sessions_merged"] += backup_count
|
|
266
|
+
summary["sessions_total"] += merged_count
|
|
267
|
+
print(
|
|
268
|
+
f" [{agent_name}] sessions: {backup_count} from backup, "
|
|
269
|
+
f"{merged_count} total after merge"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Copy session transcript .jsonl files
|
|
273
|
+
backup_sessions_dir = agent_dir / "sessions"
|
|
274
|
+
if backup_sessions_dir.is_dir():
|
|
275
|
+
jsonl_count = 0
|
|
276
|
+
for session_file in backup_sessions_dir.iterdir():
|
|
277
|
+
if session_file.suffix == ".jsonl" and session_file.name != "sessions.json":
|
|
278
|
+
target = current_agents / agent_name / "sessions" / session_file.name
|
|
279
|
+
if copy_file(session_file, target):
|
|
280
|
+
jsonl_count += 1
|
|
281
|
+
if jsonl_count > 0:
|
|
282
|
+
print(f" [{agent_name}] transcripts: {jsonl_count} files copied")
|
|
283
|
+
|
|
284
|
+
# Copy non-session agent files (models.json, runtime-stats, etc.)
|
|
285
|
+
for item in agent_dir.rglob("*"):
|
|
286
|
+
if item.is_dir():
|
|
287
|
+
continue
|
|
288
|
+
rel = item.relative_to(agent_dir)
|
|
289
|
+
if rel.parts[0] == "sessions":
|
|
290
|
+
continue
|
|
291
|
+
target = current_agents / agent_name / rel
|
|
292
|
+
if copy_file(item, target):
|
|
293
|
+
summary["files_copied"] += 1
|
|
294
|
+
else:
|
|
295
|
+
print("\n [INFO] No agents directory in backup")
|
|
296
|
+
|
|
297
|
+
# === 3. Workspace directories ===
|
|
298
|
+
print("\n--- Workspaces ---")
|
|
299
|
+
for item in sorted(tmp.iterdir()):
|
|
300
|
+
if not item.is_dir():
|
|
301
|
+
continue
|
|
302
|
+
if item.name.startswith("workspace"):
|
|
303
|
+
ws_name = item.name
|
|
304
|
+
current_ws = state_dir / ws_name
|
|
305
|
+
copied, skipped = copy_directory(item, current_ws)
|
|
306
|
+
summary["files_copied"] += copied
|
|
307
|
+
summary["files_skipped"] += skipped
|
|
308
|
+
summary["workspaces_restored"].append(ws_name)
|
|
309
|
+
print(f" [{ws_name}] {copied} files, {skipped} skipped")
|
|
310
|
+
|
|
311
|
+
# === 4. Memory SQLite files ===
|
|
312
|
+
backup_memory = tmp / "memory"
|
|
313
|
+
if backup_memory.is_dir():
|
|
314
|
+
print("\n--- Memory ---")
|
|
315
|
+
current_memory = state_dir / "memory"
|
|
316
|
+
for db_file in sorted(backup_memory.iterdir()):
|
|
317
|
+
if db_file.suffix == ".sqlite":
|
|
318
|
+
target = current_memory / db_file.name
|
|
319
|
+
if copy_file(db_file, target):
|
|
320
|
+
summary["files_copied"] += 1
|
|
321
|
+
print(f" [{db_file.name}] restored")
|
|
322
|
+
|
|
323
|
+
# === 5. Loose files (identity, canvas, logs, media, config backups, etc.) ===
|
|
324
|
+
LOOSE_PATTERNS = [
|
|
325
|
+
"identity",
|
|
326
|
+
"canvas",
|
|
327
|
+
"logs",
|
|
328
|
+
"media",
|
|
329
|
+
"openclaw.json.bak",
|
|
330
|
+
"openclaw.json.bak.1",
|
|
331
|
+
"openclaw.json.bak.2",
|
|
332
|
+
"openclaw.json.bak.3",
|
|
333
|
+
"openclaw.json.bak.4",
|
|
334
|
+
"openclaw.json.last-good",
|
|
335
|
+
"jwt.json",
|
|
336
|
+
"update-check.json",
|
|
337
|
+
]
|
|
338
|
+
|
|
339
|
+
print("\n--- Other Files ---")
|
|
340
|
+
for pattern in LOOSE_PATTERNS:
|
|
341
|
+
src = tmp / pattern
|
|
342
|
+
if src.exists():
|
|
343
|
+
dst = state_dir / pattern
|
|
344
|
+
if src.is_dir():
|
|
345
|
+
copied, skipped = copy_directory(src, dst)
|
|
346
|
+
summary["files_copied"] += copied
|
|
347
|
+
summary["files_skipped"] += skipped
|
|
348
|
+
print(f" [{pattern}/] {copied} files")
|
|
349
|
+
else:
|
|
350
|
+
if copy_file(src, dst):
|
|
351
|
+
summary["files_copied"] += 1
|
|
352
|
+
print(f" [{pattern}] copied")
|
|
353
|
+
|
|
354
|
+
# Handle any remaining items not explicitly covered
|
|
355
|
+
handled = set(LOOSE_PATTERNS) | {"agents", "memory", "openclaw.json"}
|
|
356
|
+
for ws_name in summary["workspaces_restored"]:
|
|
357
|
+
handled.add(ws_name)
|
|
358
|
+
|
|
359
|
+
for item in sorted(tmp.iterdir()):
|
|
360
|
+
if item.name in handled:
|
|
361
|
+
continue
|
|
362
|
+
dst = state_dir / item.name
|
|
363
|
+
if item.is_dir():
|
|
364
|
+
copied, skipped = copy_directory(item, dst)
|
|
365
|
+
summary["files_copied"] += copied
|
|
366
|
+
summary["files_skipped"] += skipped
|
|
367
|
+
if copied > 0:
|
|
368
|
+
print(f" [{item.name}/] {copied} files")
|
|
369
|
+
else:
|
|
370
|
+
if copy_file(item, dst):
|
|
371
|
+
summary["files_copied"] += 1
|
|
372
|
+
print(f" [{item.name}] copied")
|
|
373
|
+
|
|
374
|
+
# === Summary ===
|
|
375
|
+
print()
|
|
376
|
+
print("=" * 50)
|
|
377
|
+
print("Restore Complete")
|
|
378
|
+
print("=" * 50)
|
|
379
|
+
print(f" Agents: {', '.join(summary['agents_restored']) or 'none'}")
|
|
380
|
+
print(f" Sessions: {summary['sessions_merged']} from backup ({summary['sessions_total']} total)")
|
|
381
|
+
print(f" Workspaces: {', '.join(summary['workspaces_restored']) or 'none'}")
|
|
382
|
+
print(f" Files: {summary['files_copied']} copied, {summary['files_skipped']} skipped")
|
|
383
|
+
print()
|
|
384
|
+
|
|
385
|
+
# Touch config to trigger chokidar-based hot reload
|
|
386
|
+
config_target = state_dir / "openclaw.json"
|
|
387
|
+
if config_target.exists():
|
|
388
|
+
os.utime(config_target, None)
|
|
389
|
+
print("Config updated. SophClaw will auto-reload within a few seconds.")
|
|
390
|
+
print("Refresh your browser page to see the restored state.")
|
|
391
|
+
|
|
392
|
+
return 0
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
sys.exit(main())
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sophclaw-skill-creator",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"types": ["
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"types": ["private"],
|
|
5
5
|
"displayName": "Skill 创建向导",
|
|
6
|
-
"description": "在 SophClaw
|
|
6
|
+
"description": "在 SophClaw 仓库中创建/修改 Skill 或为已有 Skill 补全 design.md。两条路径:路径 A(创建/修改)三步流程:需求对齐→方案对齐→生成代码;路径 B(补全 design.md)五步流程:确定目标→阅读代码→逆向整理→逐节确认→写入文档",
|
|
7
7
|
"changelog": [
|
|
8
|
+
{
|
|
9
|
+
"version": "1.1.0",
|
|
10
|
+
"date": "2026-05-28",
|
|
11
|
+
"changes": [
|
|
12
|
+
"新增路径 B:为已有 skill 补全 design.md(五步流程:确定目标→阅读代码→逆向整理→逐节确认→写入文档)",
|
|
13
|
+
"SKILL.md 拆分为入口+分流,路径 A/B 详细步骤拆分到 references/path-a-create.md 和 references/path-b-backfill.md",
|
|
14
|
+
"新增 <skills_dir> 自动定位机制,支持 workspace root 为 sophclaw-skills/ 或上级目录",
|
|
15
|
+
"路径 A 新增复用检查步骤:遍历已有 skill 发现可复用代码",
|
|
16
|
+
"generate.py 新增 --output-dir 参数,支持指定输出目录"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
8
19
|
{
|
|
9
20
|
"version": "1.0.0",
|
|
10
21
|
"date": "2026-05-25",
|
|
@@ -14,5 +25,5 @@
|
|
|
14
25
|
}
|
|
15
26
|
],
|
|
16
27
|
"createdAt": "2026-05-25",
|
|
17
|
-
"updatedAt": "2026-05-
|
|
28
|
+
"updatedAt": "2026-05-28"
|
|
18
29
|
}
|
|
@@ -1,205 +1,41 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sophclaw-skill-creator
|
|
3
|
-
description: 在 SophClaw 仓库中创建新 skill。当用户说"创建skill""新建skill""开发一个skill""帮我做一个skill"
|
|
3
|
+
description: 在 SophClaw 仓库中创建新 skill / 修改已有 skill / 为已有 skill 补全 design.md。当用户说"创建skill""新建skill""开发一个skill""帮我做一个skill""修改skill""补充design.md""补全设计文档"时使用。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# SophClaw Skill Creator
|
|
7
7
|
|
|
8
|
-
在 SophClaw
|
|
8
|
+
在 SophClaw 仓库中交互式创建或维护 skill。
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## 定位 skills 目录
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
digraph skill_create {
|
|
14
|
-
rankdir=TB;
|
|
15
|
-
|
|
16
|
-
start [label="用户请求创建 skill", shape=oval];
|
|
17
|
-
step1 [label="第一步:需求对齐\n自适应提问,明确需求", shape=box];
|
|
18
|
-
spec1 [label="产出 design.md「需求说明」", shape=box];
|
|
19
|
-
user_ok1 [label="用户确认?", shape=diamond];
|
|
20
|
-
step2 [label="第二步:方案对齐\n设计结构 + 打磨 description", shape=box];
|
|
21
|
-
spec2 [label="产出 design.md「方案设计」+「测试用例」", shape=box];
|
|
22
|
-
user_ok2 [label="用户确认?", shape=diamond];
|
|
23
|
-
step3 [label="第三步:生成代码\ngenerate.py 生成骨架", shape=box];
|
|
24
|
-
edit [label="用户编辑 SKILL.md", shape=box];
|
|
25
|
-
validate [label="validate.py 校验", shape=box];
|
|
26
|
-
check [label="有 ERROR?", shape=diamond];
|
|
27
|
-
done [label="完成 ✅", shape=oval];
|
|
28
|
-
|
|
29
|
-
start -> step1;
|
|
30
|
-
step1 -> spec1;
|
|
31
|
-
spec1 -> user_ok1;
|
|
32
|
-
user_ok1 -> step1 [label="否,修改"];
|
|
33
|
-
user_ok1 -> step2 [label="是"];
|
|
34
|
-
step2 -> spec2;
|
|
35
|
-
spec2 -> user_ok2;
|
|
36
|
-
user_ok2 -> step2 [label="否,修改"];
|
|
37
|
-
user_ok2 -> step3 [label="是"];
|
|
38
|
-
step3 -> edit;
|
|
39
|
-
edit -> validate;
|
|
40
|
-
validate -> check;
|
|
41
|
-
check -> edit [label="是,修复"];
|
|
42
|
-
check -> done [label="否"];
|
|
43
|
-
}
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
## 第一步:需求对齐
|
|
47
|
-
|
|
48
|
-
自适应提问,帮用户把 skill 想清楚。每次只问一个问题,多问"为什么"。至少覆盖:
|
|
49
|
-
|
|
50
|
-
1. **功能**:这个 skill 做什么?输入是什么?输出是什么?
|
|
51
|
-
2. **触发场景**:用户会怎么说话来触发它?(这是 description 的核心素材)
|
|
52
|
-
3. **上线平台**:确定 type,按以下决策树选择:
|
|
53
|
-
|
|
54
|
-
```dot
|
|
55
|
-
digraph type_select {
|
|
56
|
-
rankdir=TB;
|
|
57
|
-
|
|
58
|
-
q1 [label="用户能自己安装?", shape=diamond];
|
|
59
|
-
q2 [label="默认所有用户可见?", shape=diamond];
|
|
60
|
-
q3 [label="已确定长期维护?", shape=diamond];
|
|
61
|
-
|
|
62
|
-
store [label="store\n商店可安装\n需中文 displayName/description", shape=box];
|
|
63
|
-
builtin [label="builtin\n系统内置,默认可用", shape=box];
|
|
64
|
-
private [label="private\n内部使用,CLI 不可见", shape=box];
|
|
65
|
-
tmp [label="tmp\n临时/未分类,CLI 不可见", shape=box];
|
|
66
|
-
|
|
67
|
-
q1 -> q2 [label="否"];
|
|
68
|
-
q1 -> store [label="是"];
|
|
69
|
-
q2 -> builtin [label="是"];
|
|
70
|
-
q2 -> q3 [label="否"];
|
|
71
|
-
q3 -> private [label="是"];
|
|
72
|
-
q3 -> tmp [label="否/不确定"];
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
- `store`:**必须**有中文 displayName 和 description
|
|
77
|
-
- `private` / `tmp`:CLI list/download 不可见
|
|
78
|
-
4. **是否需要脚本**:涉及 API 调用?复杂数据处理?还是纯指令型的 SKILL.md 就够?
|
|
79
|
-
5. **是否需要 references/assets**:有大量参考文档?有模板文件?
|
|
80
|
-
|
|
81
|
-
确认需求后,产出 `design.md` 的「需求说明」部分,格式:
|
|
82
|
-
|
|
83
|
-
```markdown
|
|
84
|
-
# <skill-name> 设计文档
|
|
85
|
-
|
|
86
|
-
## 一、需求说明
|
|
87
|
-
- 功能描述
|
|
88
|
-
- 触发场景(用户怎么说话会触发)
|
|
89
|
-
- 输入/输出约定
|
|
90
|
-
- 上线平台及 type 选择理由
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
用户确认后进入下一步。
|
|
94
|
-
|
|
95
|
-
## 第二步:方案对齐
|
|
96
|
-
|
|
97
|
-
基于需求,设计 skill 结构。重点:
|
|
98
|
-
|
|
99
|
-
### 目录结构设计
|
|
100
|
-
|
|
101
|
-
讨论并确定需要哪些资源目录(scripts/references/assets),产出目录树。
|
|
102
|
-
|
|
103
|
-
### description 打磨(核心)
|
|
104
|
-
|
|
105
|
-
这是最容易出问题的地方。**description 是 LLM 匹配 skill 的唯一依据**,必须包含功能+触发场景。
|
|
106
|
-
|
|
107
|
-
**公式**:`功能 + 触发场景 = 好的 description`
|
|
108
|
-
|
|
109
|
-
正例:
|
|
110
|
-
```yaml
|
|
111
|
-
description: 压缩 PNG/JPEG 图片。当用户要求压缩图片、减小图片体积、优化图片大小时使用。
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
反例:
|
|
115
|
-
```yaml
|
|
116
|
-
# ❌ 太长,没有触发场景
|
|
117
|
-
description: 这是一个功能强大的图片处理工具,支持多种格式的压缩、旋转、裁剪、滤镜等操作
|
|
118
|
-
|
|
119
|
-
# ❌ 只写了功能,没写何时触发
|
|
120
|
-
description: 图片压缩工具
|
|
121
|
-
|
|
122
|
-
# ❌ 写了实现细节
|
|
123
|
-
description: 使用 Pillow 库对图片进行有损压缩,支持调整质量和尺寸
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
**与用户协作打磨**:如果用户给的 description 太长或缺少触发场景,指出问题并帮助精简。
|
|
127
|
-
|
|
128
|
-
### 方案产出
|
|
129
|
-
|
|
130
|
-
在 `design.md` 中追加「方案设计」和「测试用例」:
|
|
131
|
-
|
|
132
|
-
```markdown
|
|
133
|
-
## 二、方案设计
|
|
134
|
-
- 目录结构
|
|
135
|
-
- 是否需要脚本及理由
|
|
136
|
-
- SKILL.md 关键设计决策
|
|
137
|
-
- 注意事项
|
|
138
|
-
|
|
139
|
-
## 三、测试用例
|
|
140
|
-
| 编号 | 测试场景 | 触发语句 | 预期匹配 | 预期行为 | 预期输出 |
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
测试用例至少覆盖:正常场景、不触发场景、边界场景。
|
|
144
|
-
|
|
145
|
-
用户确认后进入下一步。
|
|
146
|
-
|
|
147
|
-
## 第三步:生成代码
|
|
148
|
-
|
|
149
|
-
### 生成骨架
|
|
12
|
+
触发后,先确定 sophclaw-skills 仓库的 skills 目录位置:
|
|
150
13
|
|
|
151
14
|
```bash
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
--display-name <中文名称> \
|
|
156
|
-
--skill-description <中文描述> \
|
|
157
|
-
--resources scripts,references,assets
|
|
15
|
+
# 检查常见路径
|
|
16
|
+
ls skills/ 2>/dev/null && echo "FOUND:skills/" || echo "NOT_FOUND:skills/"
|
|
17
|
+
ls sophclaw-skills/skills/ 2>/dev/null && echo "FOUND:sophclaw-skills/skills/" || echo "NOT_FOUND:sophclaw-skills/skills/"
|
|
158
18
|
```
|
|
159
19
|
|
|
160
|
-
-
|
|
161
|
-
-
|
|
20
|
+
- 如果 `skills/` 存在且有 skill.json 文件 → 说明用户直接打开了 `sophclaw-skills/` 作为 workspace root
|
|
21
|
+
- 如果 `sophclaw-skills/skills/` 存在 → 说明用户打开了上一级目录
|
|
22
|
+
- 都不存在 → 请用户提供 skills 目录的绝对路径,赋给 `<skills_dir>`
|
|
162
23
|
|
|
163
|
-
|
|
24
|
+
后续所有操作中,用 `<skills_dir>` 指代这个路径。以下路径中的 `<skills_dir>` 均需替换为实际路径。
|
|
164
25
|
|
|
165
|
-
|
|
166
|
-
- description 已在第二步打磨好,直接填入 frontmatter
|
|
167
|
-
- 路径用 `{baseDir}/scripts/xxx.py`(不含 `src/`)
|
|
168
|
-
- 命令示范用 `uv run`,不用 `python`
|
|
169
|
-
- 不提及其他 skill
|
|
170
|
-
- 不写实现细节
|
|
26
|
+
## 路径选择
|
|
171
27
|
|
|
172
|
-
|
|
28
|
+
触发后先判断用户意图,分流到对应路径:
|
|
173
29
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
```dot
|
|
179
|
-
digraph validate_loop {
|
|
180
|
-
rankdir=TB;
|
|
181
|
-
|
|
182
|
-
run [label="运行 validate.py", shape=box];
|
|
183
|
-
has_err [label="有 ERROR?", shape=diamond];
|
|
184
|
-
has_warn [label="有 WARNING?", shape=diamond];
|
|
185
|
-
fix_err [label="修复 ERROR(必须)", shape=box];
|
|
186
|
-
review [label="审视 WARNING\n决定是否修改", shape=box];
|
|
187
|
-
done [label="通过 ✅", shape=oval];
|
|
188
|
-
|
|
189
|
-
run -> has_err;
|
|
190
|
-
has_err -> fix_err [label="是"];
|
|
191
|
-
fix_err -> run [label="重新校验"];
|
|
192
|
-
has_err -> has_warn [label="否"];
|
|
193
|
-
has_warn -> review [label="是"];
|
|
194
|
-
review -> run [label="修改后重新校验"];
|
|
195
|
-
has_warn -> done [label="否"];
|
|
196
|
-
}
|
|
197
|
-
```
|
|
30
|
+
| 用户意图 | 关键词 | 走哪条路径 |
|
|
31
|
+
|---------|--------|-----------|
|
|
32
|
+
| 创建新 skill / 修改已有 skill | "创建skill""新建skill""开发skill""修改skill" | 路径 A |
|
|
33
|
+
| 为已有 skill 补全 design.md | "补充design.md""补全设计文档" | 路径 B |
|
|
198
34
|
|
|
199
|
-
-
|
|
200
|
-
-
|
|
35
|
+
- **路径 A(创建/修改 skill)**:需求对齐 → 方案对齐 → 生成代码。详见 [references/path-a-create.md](references/path-a-create.md)
|
|
36
|
+
- **路径 B(补全 design.md)**:阅读代码 → 逆向整理 → 逐节确认 → 写入文档。详见 [references/path-b-backfill.md](references/path-b-backfill.md)
|
|
201
37
|
|
|
202
|
-
|
|
38
|
+
确定路径后,**只读取对应路径的 reference 文件**,不要把另一条路径加载到上下文。
|
|
203
39
|
|
|
204
40
|
## 关键规范速查
|
|
205
41
|
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# SophClaw Skill Creator — 路径 A:创建新 skill / 修改已有 skill
|
|
2
|
+
|
|
3
|
+
适用于 skill 不存在或需要改动 skill 本身(代码、配置、SKILL.md)。
|
|
4
|
+
|
|
5
|
+
三步流程:需求对齐 → 方案对齐 → 生成代码。
|
|
6
|
+
|
|
7
|
+
```dot
|
|
8
|
+
digraph skill_create {
|
|
9
|
+
rankdir=TB;
|
|
10
|
+
|
|
11
|
+
start [label="用户请求创建/修改 skill", shape=oval];
|
|
12
|
+
step1 [label="第一步:需求对齐\n自适应提问,明确需求", shape=box];
|
|
13
|
+
spec1 [label="产出 design.md「需求说明」", shape=box];
|
|
14
|
+
user_ok1 [label="用户确认?", shape=diamond];
|
|
15
|
+
step2 [label="第二步:方案对齐\n设计结构 + 打磨 description", shape=box];
|
|
16
|
+
spec2 [label="产出 design.md「方案设计」+「测试用例」", shape=box];
|
|
17
|
+
user_ok2 [label="用户确认?", shape=diamond];
|
|
18
|
+
step3 [label="第三步:生成代码\ngenerate.py 生成骨架", shape=box];
|
|
19
|
+
edit [label="用户编辑 SKILL.md", shape=box];
|
|
20
|
+
validate [label="validate.py 校验", shape=box];
|
|
21
|
+
check [label="有 ERROR?", shape=diamond];
|
|
22
|
+
done [label="完成 ✅", shape=oval];
|
|
23
|
+
|
|
24
|
+
start -> step1;
|
|
25
|
+
step1 -> spec1;
|
|
26
|
+
spec1 -> user_ok1;
|
|
27
|
+
user_ok1 -> step1 [label="否,修改"];
|
|
28
|
+
user_ok1 -> step2 [label="是"];
|
|
29
|
+
step2 -> spec2;
|
|
30
|
+
spec2 -> user_ok2;
|
|
31
|
+
user_ok2 -> step2 [label="否,修改"];
|
|
32
|
+
user_ok2 -> step3 [label="是"];
|
|
33
|
+
step3 -> edit;
|
|
34
|
+
edit -> validate;
|
|
35
|
+
validate -> check;
|
|
36
|
+
check -> edit [label="是,修复"];
|
|
37
|
+
check -> done [label="否"];
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 第一步:需求对齐
|
|
42
|
+
|
|
43
|
+
自适应提问,帮用户把 skill 想清楚。每次只问一个问题,多问"为什么"。至少覆盖:
|
|
44
|
+
|
|
45
|
+
1. **功能**:这个 skill 做什么?输入是什么?输出是什么?
|
|
46
|
+
2. **触发场景**:用户会怎么说话来触发它?(这是 description 的核心素材)
|
|
47
|
+
3. **上线平台**:确定 type,按以下决策树选择:
|
|
48
|
+
|
|
49
|
+
```dot
|
|
50
|
+
digraph type_select {
|
|
51
|
+
rankdir=TB;
|
|
52
|
+
|
|
53
|
+
q1 [label="用户能自己安装?", shape=diamond];
|
|
54
|
+
q2 [label="默认所有用户可见?", shape=diamond];
|
|
55
|
+
q3 [label="已确定长期维护?", shape=diamond];
|
|
56
|
+
|
|
57
|
+
store [label="store\n商店可安装\n需中文 displayName/description", shape=box];
|
|
58
|
+
builtin [label="builtin\n系统内置,默认可用", shape=box];
|
|
59
|
+
private [label="private\n内部使用,CLI 不可见", shape=box];
|
|
60
|
+
tmp [label="tmp\n临时/未分类,CLI 不可见", shape=box];
|
|
61
|
+
|
|
62
|
+
q1 -> q2 [label="否"];
|
|
63
|
+
q1 -> store [label="是"];
|
|
64
|
+
q2 -> builtin [label="是"];
|
|
65
|
+
q2 -> q3 [label="否"];
|
|
66
|
+
q3 -> private [label="是"];
|
|
67
|
+
q3 -> tmp [label="否/不确定"];
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `store`:**必须**有中文 displayName 和 description
|
|
72
|
+
- `private` / `tmp`:CLI list/download 不可见
|
|
73
|
+
4. **是否需要脚本**:涉及 API 调用?复杂数据处理?还是纯指令型的 SKILL.md 就够?
|
|
74
|
+
5. **是否需要 references/assets**:有大量参考文档?有模板文件?
|
|
75
|
+
|
|
76
|
+
确认需求后,产出 `design.md` 的「需求说明」部分,格式:
|
|
77
|
+
|
|
78
|
+
```markdown
|
|
79
|
+
# <skill-name> 设计文档
|
|
80
|
+
|
|
81
|
+
## 一、需求说明
|
|
82
|
+
- 功能描述
|
|
83
|
+
- 触发场景(用户怎么说话会触发)
|
|
84
|
+
- 输入/输出约定
|
|
85
|
+
- 上线平台及 type 选择理由
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
用户确认后进入下一步。
|
|
89
|
+
|
|
90
|
+
## 第二步:方案对齐
|
|
91
|
+
|
|
92
|
+
基于需求,设计 skill 结构。重点:
|
|
93
|
+
|
|
94
|
+
### 复用检查(先做,避免重复造轮子)
|
|
95
|
+
|
|
96
|
+
在讨论具体方案之前,先检查已有 skill 是否有可复用的代码或模式:
|
|
97
|
+
|
|
98
|
+
1. **完整读取已有 skill**:遍历 `<skills_dir>/` 下所有子目录,读取每个 skill 的全部文件(skill.json、SKILL.md、scripts/ 下的脚本)
|
|
99
|
+
2. **判断复用价值**:根据第一步确认的需求,找出哪些 skill 的代码或设计模式可以复用(如 API 调用封装、输出格式、错误处理、目录结构等)
|
|
100
|
+
3. **向用户呈现**:列出可复用的 skill 及具体复用点,问用户是否采纳
|
|
101
|
+
|
|
102
|
+
> **为什么先做复用检查?** 仓库里已有数十个 skill,很多基础能力已有成熟的实现。直接复用可以减少代码量、保持风格一致、避免引入新的 bug 模式。
|
|
103
|
+
|
|
104
|
+
### 目录结构设计
|
|
105
|
+
|
|
106
|
+
讨论并确定需要哪些资源目录(scripts/references/assets),产出目录树。
|
|
107
|
+
|
|
108
|
+
### description 打磨(核心)
|
|
109
|
+
|
|
110
|
+
这是最容易出问题的地方。**description 是 LLM 匹配 skill 的唯一依据**,必须包含功能+触发场景。
|
|
111
|
+
|
|
112
|
+
**公式**:`功能 + 触发场景 = 好的 description`
|
|
113
|
+
|
|
114
|
+
正例:
|
|
115
|
+
```yaml
|
|
116
|
+
description: 压缩 PNG/JPEG 图片。当用户要求压缩图片、减小图片体积、优化图片大小时使用。
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
反例:
|
|
120
|
+
```yaml
|
|
121
|
+
# ❌ 太长,没有触发场景
|
|
122
|
+
description: 这是一个功能强大的图片处理工具,支持多种格式的压缩、旋转、裁剪、滤镜等操作
|
|
123
|
+
|
|
124
|
+
# ❌ 只写了功能,没写何时触发
|
|
125
|
+
description: 图片压缩工具
|
|
126
|
+
|
|
127
|
+
# ❌ 写了实现细节
|
|
128
|
+
description: 使用 Pillow 库对图片进行有损压缩,支持调整质量和尺寸
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**与用户协作打磨**:如果用户给的 description 太长或缺少触发场景,指出问题并帮助精简。
|
|
132
|
+
|
|
133
|
+
### 方案产出
|
|
134
|
+
|
|
135
|
+
在 `design.md` 中追加「方案设计」和「测试用例」:
|
|
136
|
+
|
|
137
|
+
```markdown
|
|
138
|
+
## 二、方案设计
|
|
139
|
+
- 目录结构
|
|
140
|
+
- 是否需要脚本及理由
|
|
141
|
+
- SKILL.md 关键设计决策
|
|
142
|
+
- 注意事项
|
|
143
|
+
|
|
144
|
+
## 三、测试用例
|
|
145
|
+
| 编号 | 测试场景 | 触发语句 | 预期匹配 | 预期行为 | 预期输出 |
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
测试用例至少覆盖:正常场景、不触发场景、边界场景。
|
|
149
|
+
|
|
150
|
+
用户确认后进入下一步。
|
|
151
|
+
|
|
152
|
+
## 第三步:生成代码
|
|
153
|
+
|
|
154
|
+
### 生成骨架
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
uv run {baseDir}/scripts/generate.py \
|
|
158
|
+
--name <skill-name> \
|
|
159
|
+
--type <builtin|store|private|tmp> \
|
|
160
|
+
--display-name <中文名称> \
|
|
161
|
+
--skill-description <中文描述> \
|
|
162
|
+
--resources scripts,references,assets \
|
|
163
|
+
--output-dir <skills_dir>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- `--display-name` 和 `--skill-description`:store 类型必填
|
|
167
|
+
- `--resources`:可选,逗号分隔,按需创建
|
|
168
|
+
|
|
169
|
+
### 用户编辑
|
|
170
|
+
|
|
171
|
+
生成后提醒用户编辑 `src/SKILL.md` 填充具体内容。编辑注意事项:
|
|
172
|
+
- description 已在第二步打磨好,直接填入 frontmatter
|
|
173
|
+
- 路径用 `{baseDir}/scripts/xxx.py`(不含 `src/`)
|
|
174
|
+
- 命令示范用 `uv run`,不用 `python`
|
|
175
|
+
- 不提及其他 skill
|
|
176
|
+
- 不写实现细节
|
|
177
|
+
|
|
178
|
+
### 校验
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
uv run {baseDir}/scripts/validate.py <skills_dir>/<skill-name>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```dot
|
|
185
|
+
digraph validate_loop {
|
|
186
|
+
rankdir=TB;
|
|
187
|
+
|
|
188
|
+
run [label="运行 validate.py", shape=box];
|
|
189
|
+
has_err [label="有 ERROR?", shape=diamond];
|
|
190
|
+
has_warn [label="有 WARNING?", shape=diamond];
|
|
191
|
+
fix_err [label="修复 ERROR(必须)", shape=box];
|
|
192
|
+
review [label="审视 WARNING\n决定是否修改", shape=box];
|
|
193
|
+
done [label="通过 ✅", shape=oval];
|
|
194
|
+
|
|
195
|
+
run -> has_err;
|
|
196
|
+
has_err -> fix_err [label="是"];
|
|
197
|
+
fix_err -> run [label="重新校验"];
|
|
198
|
+
has_err -> has_warn [label="否"];
|
|
199
|
+
has_warn -> review [label="是"];
|
|
200
|
+
review -> run [label="修改后重新校验"];
|
|
201
|
+
has_warn -> done [label="否"];
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- **ERROR**:结构问题,必须修复(skill.json 缺失、name 不一致、store 缺少中文名等)
|
|
206
|
+
- **WARNING**:内容建议,建议审视修改(description 偏长、路径含 src/、用了 python 而非 uv run 等)
|
|
207
|
+
|
|
208
|
+
STATUS=succeeded 当且仅当无 ERROR。
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# SophClaw Skill Creator — 路径 B:补全 design.md
|
|
2
|
+
|
|
3
|
+
从已有代码逆向分析,为缺少 `design.md` 的 skill 补全设计文档。
|
|
4
|
+
|
|
5
|
+
流程:逆向分析 → 对齐确认 → 生成文档。
|
|
6
|
+
|
|
7
|
+
```dot
|
|
8
|
+
digraph design_backfill {
|
|
9
|
+
rankdir=TB;
|
|
10
|
+
|
|
11
|
+
start [label="用户要求补全 design.md", shape=oval];
|
|
12
|
+
identify [label="第一步:确定目标 skill\n确认 skill 名称", shape=box];
|
|
13
|
+
read [label="第二步:阅读已有代码\nskill.json + SKILL.md + 脚本 + 资源", shape=box];
|
|
14
|
+
draft [label="第三步:逆向整理\n从代码中还原需求和方案", shape=box];
|
|
15
|
+
discuss [label="第四步:逐节确认\n与用户对齐理解", shape=box];
|
|
16
|
+
write [label="第五步:写入 design.md", shape=box];
|
|
17
|
+
done [label="完成 ✅", shape=oval];
|
|
18
|
+
|
|
19
|
+
start -> identify;
|
|
20
|
+
identify -> read;
|
|
21
|
+
read -> draft;
|
|
22
|
+
draft -> discuss;
|
|
23
|
+
discuss -> draft [label="用户纠正/补充"];
|
|
24
|
+
discuss -> write [label="用户确认"];
|
|
25
|
+
write -> done;
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 第一步:确定目标 skill
|
|
30
|
+
|
|
31
|
+
用户可能说"给 restore-state 补一个 design.md"、"补充 XXX 的设计文档"。先确认:
|
|
32
|
+
|
|
33
|
+
1. skill 名称是什么?
|
|
34
|
+
2. 确认路径 `<skills_dir>/<name>/` 存在(用 `ls <skills_dir>/<name>/` 验证)
|
|
35
|
+
3. 确认 `design.md` 确实不存在(不覆盖已有文件)
|
|
36
|
+
|
|
37
|
+
## 第二步:阅读已有代码
|
|
38
|
+
|
|
39
|
+
完整读取目标 skill 的以下文件:
|
|
40
|
+
|
|
41
|
+
| 文件 | 获取什么信息 |
|
|
42
|
+
|------|-------------|
|
|
43
|
+
| `<skills_dir>/<name>/skill.json` | name、version、types、displayName、description、changelog |
|
|
44
|
+
| `<skills_dir>/<name>/src/SKILL.md` | 触发描述、前置检查、用法、输出格式、异常处理 |
|
|
45
|
+
| `<skills_dir>/<name>/src/scripts/*.py` | 核心逻辑、关键设计决策、配置常量 |
|
|
46
|
+
| `<skills_dir>/<name>/src/pyproject.toml` | 依赖、Python 版本要求 |
|
|
47
|
+
| `<skills_dir>/<name>/src/references/*` | 参考文档 |
|
|
48
|
+
| `<skills_dir>/<name>/src/assets/*` | 模板/资源文件 |
|
|
49
|
+
|
|
50
|
+
**这一步不问用户任何问题**,全靠阅读代码收集信息。
|
|
51
|
+
|
|
52
|
+
## 第三步:逆向整理
|
|
53
|
+
|
|
54
|
+
从代码中还原设计文档所需的内容:
|
|
55
|
+
|
|
56
|
+
**还原需求说明**(从 skill.json + SKILL.md):
|
|
57
|
+
- 功能描述 ← SKILL.md 的功能说明
|
|
58
|
+
- 触发场景 ← SKILL.md 的 description + 触发条件描述
|
|
59
|
+
- 输入/输出约定 ← SKILL.md 的输入输出描述 + 脚本的 exit code 逻辑
|
|
60
|
+
|
|
61
|
+
**还原方案设计**(从脚本代码 + 目录结构):
|
|
62
|
+
- 目录结构 ← `ls` 查看实际文件树
|
|
63
|
+
- 是否需要脚本及理由 ← 存在 `src/scripts/` 则"需要"
|
|
64
|
+
- SKILL.md 关键设计决策 ← 代码中的硬编码常量、特殊处理逻辑、注释说明
|
|
65
|
+
- 注意事项 ← SKILL.md 的异常处理表 + 脚本的错误分支
|
|
66
|
+
|
|
67
|
+
**还原测试用例**(从 SKILL.md 的异常处理表 + 脚本边界逻辑):
|
|
68
|
+
- 正常场景 ← SKILL.md 的用法示例
|
|
69
|
+
- 异常场景 ← SKILL.md 的异常处理表 + 脚本中的错误处理分支
|
|
70
|
+
- 不触发场景 ← 从 description 反推边界(相近但不应触发的场景,如"备份数据"对 restore-state)
|
|
71
|
+
|
|
72
|
+
## 第四步:逐节确认
|
|
73
|
+
|
|
74
|
+
将整理好的内容分节呈现给用户,每节确认后再继续下一节:
|
|
75
|
+
|
|
76
|
+
1. 先呈现「需求说明」,问:"功能描述和触发场景我整理得对吗?有没有遗漏或需要补充的?"
|
|
77
|
+
2. 再呈现「方案设计」,问:"架构和关键设计决策我理解得对吗?"
|
|
78
|
+
3. 最后呈现「测试用例」,问:"测试用例是否完整?是否需要增删?"
|
|
79
|
+
|
|
80
|
+
**与路径A的共同点**:都需要与用户对齐,确保理解正确。
|
|
81
|
+
**与路径A的不同点**:这里是先基于代码给出初步判断再确认,而非从零开始提问。
|
|
82
|
+
|
|
83
|
+
## 第五步:写入 design.md
|
|
84
|
+
|
|
85
|
+
用户逐节确认后,写入 `<skills_dir>/<name>/design.md`。
|
|
86
|
+
|
|
87
|
+
**格式严格遵循** [references/skill-notes.md](references/skill-notes.md) **§十「design.md 规范」**:
|
|
88
|
+
|
|
89
|
+
```markdown
|
|
90
|
+
# <skill-name> 设计文档
|
|
91
|
+
|
|
92
|
+
## 一、需求说明
|
|
93
|
+
- 功能描述
|
|
94
|
+
- 触发场景(用户怎么说话会触发)
|
|
95
|
+
- 输入/输出约定
|
|
96
|
+
- 上线平台及 type 选择理由
|
|
97
|
+
|
|
98
|
+
## 二、方案设计
|
|
99
|
+
- 目录结构
|
|
100
|
+
- 是否需要脚本及理由
|
|
101
|
+
- SKILL.md 关键设计决策
|
|
102
|
+
- 注意事项
|
|
103
|
+
|
|
104
|
+
## 三、测试用例
|
|
105
|
+
| 编号 | 测试场景 | 触发语句 | 预期匹配 | 预期行为 | 预期输出 |
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**补充说明**(在规范结构基础上可酌情扩充):
|
|
109
|
+
- 「需求说明」可追加"核心能力""不触发场景"小节,帮助完善边界描述
|
|
110
|
+
- 「方案设计」可追加"架构""职责划分"小节,补充架构图和组件分工表
|
|
111
|
+
- 以上为可选扩充,规范中的 4 个要点和表头必须保留
|
|
@@ -127,12 +127,13 @@ def parse_resources(raw_resources):
|
|
|
127
127
|
return deduped, ""
|
|
128
128
|
|
|
129
129
|
|
|
130
|
-
def generate_skill(skill_name, skill_type, display_name, skill_description, resources):
|
|
130
|
+
def generate_skill(skill_name, skill_type, display_name, skill_description, resources, output_dir=None):
|
|
131
131
|
"""Create the full skill directory structure."""
|
|
132
132
|
today = date.today().isoformat()
|
|
133
133
|
skill_title = title_case_skill_name(skill_name)
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
base = Path(output_dir) if output_dir else Path.cwd() / "skills"
|
|
136
|
+
skill_dir = base / skill_name
|
|
136
137
|
src_dir = skill_dir / "src"
|
|
137
138
|
|
|
138
139
|
# Check for existing directory
|
|
@@ -209,6 +210,7 @@ def main():
|
|
|
209
210
|
parser.add_argument("--display-name", default="", help="Chinese display name (required for store)")
|
|
210
211
|
parser.add_argument("--skill-description", default="", help="Chinese description (required for store)")
|
|
211
212
|
parser.add_argument("--resources", default="", help="Comma-separated: scripts,references,assets")
|
|
213
|
+
parser.add_argument("--output-dir", default="", help="Output directory for the skill (defaults to ./skills/)")
|
|
212
214
|
args = parser.parse_args()
|
|
213
215
|
|
|
214
216
|
# Validate name
|
|
@@ -252,6 +254,7 @@ def main():
|
|
|
252
254
|
display_name=args.display_name.strip(),
|
|
253
255
|
skill_description=args.skill_description.strip(),
|
|
254
256
|
resources=resources,
|
|
257
|
+
output_dir=args.output_dir.strip() or None,
|
|
255
258
|
)
|
|
256
259
|
|
|
257
260
|
if not success:
|