@aiyiran/myclaw 1.1.44 → 1.1.46
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/assets/myclaw-artifacts.js +15 -26
- package/package.json +1 -1
- package/server/sync_workspace.py +72 -41
- package/skills/yiran-course-template-pipeline/scripts/build_template_index.py +56 -19
- package/skills/yiran-course-template-pipeline/scripts/identify_template.py +176 -0
- package/skills/yiran-course-template-pipeline/scripts/migrate_templates.py +104 -0
- package/skills/yiran-course-template-pipeline/scripts/move_template_task.py +59 -59
- package/skills/yiran-course-template-pipeline/scripts/publish_template.py +51 -37
- package/skills/yiran-course-template-pipeline/template-index.json +185 -191
- package/skills/yiran-course-template-pipeline/template-index.md +28 -14
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
migrate_templates.py
|
|
4
|
+
|
|
5
|
+
将旧版平铺结构迁移到版本化结构:
|
|
6
|
+
templates/{folder}/*.html → templates/{folder}/v1/*.html
|
|
7
|
+
|
|
8
|
+
同时为没有 _id 的模板颁发 ID,写入 __teacher__.json。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python3 scripts/migrate_templates.py [--dry-run]
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
21
|
+
TEMPLATES_DIR = ROOT / 'templates'
|
|
22
|
+
INDEX_SCRIPT = ROOT / 'scripts' / 'build_template_index.py'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_json(path: Path) -> dict:
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
28
|
+
except Exception:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_json(path: Path, data: dict):
|
|
33
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def gen_id() -> str:
|
|
37
|
+
return secrets.token_hex(4)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def has_version_subdir(folder: Path) -> bool:
|
|
41
|
+
for d in folder.iterdir():
|
|
42
|
+
if d.is_dir() and re.match(r'^v\d+$', d.name):
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
dry_run = '--dry-run' in sys.argv
|
|
49
|
+
if dry_run:
|
|
50
|
+
print('🔍 DRY RUN 模式,不实际修改文件\n')
|
|
51
|
+
|
|
52
|
+
if not TEMPLATES_DIR.exists():
|
|
53
|
+
print('❌ templates/ 目录不存在')
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
folders = sorted(d for d in TEMPLATES_DIR.iterdir() if d.is_dir())
|
|
57
|
+
migrated = 0
|
|
58
|
+
|
|
59
|
+
for folder in folders:
|
|
60
|
+
if has_version_subdir(folder):
|
|
61
|
+
print(f'✓ 跳过(已版本化): {folder.name}')
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# 列出所有直接子文件(排除目录)
|
|
65
|
+
files = [f for f in folder.iterdir() if f.is_file()]
|
|
66
|
+
if not files:
|
|
67
|
+
print(f'⚠️ 空目录,跳过: {folder.name}')
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
teacher_path = folder / '__teacher__.json'
|
|
71
|
+
teacher = load_json(teacher_path)
|
|
72
|
+
|
|
73
|
+
# 补充 _id
|
|
74
|
+
if not teacher.get('_id'):
|
|
75
|
+
teacher['_id'] = gen_id()
|
|
76
|
+
print(f'🆔 颁发 ID {teacher["_id"]}: {folder.name}')
|
|
77
|
+
|
|
78
|
+
# 补充 _version
|
|
79
|
+
if not teacher.get('_version'):
|
|
80
|
+
teacher['_version'] = 1
|
|
81
|
+
|
|
82
|
+
if not dry_run:
|
|
83
|
+
# 先写回 __teacher__.json
|
|
84
|
+
save_json(teacher_path, teacher)
|
|
85
|
+
|
|
86
|
+
# 创建 v1/ 并移动所有文件
|
|
87
|
+
v1_dir = folder / 'v1'
|
|
88
|
+
v1_dir.mkdir(exist_ok=True)
|
|
89
|
+
for f in files:
|
|
90
|
+
shutil.move(str(f), str(v1_dir / f.name))
|
|
91
|
+
|
|
92
|
+
print(f'📦 迁移: {folder.name} → {folder.name}/v1/')
|
|
93
|
+
migrated += 1
|
|
94
|
+
|
|
95
|
+
print(f'\n{"[DRY RUN] " if dry_run else ""}共迁移 {migrated} 个模板')
|
|
96
|
+
|
|
97
|
+
if not dry_run and migrated > 0:
|
|
98
|
+
import subprocess
|
|
99
|
+
subprocess.run(['python3', str(INDEX_SCRIPT)], check=True)
|
|
100
|
+
print('✅ 索引已重建')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == '__main__':
|
|
104
|
+
main()
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
move_template_task.py
|
|
4
|
+
|
|
5
|
+
将模板源目录纳入 pipeline(复制,不删除原目录)。
|
|
6
|
+
|
|
7
|
+
流程:
|
|
8
|
+
1. 校验必需文件
|
|
9
|
+
2. 调用 identify_template.py(颁发/校准身份,写回源目录)
|
|
10
|
+
3. 读取更新后的 __teacher__.json,拼出目标路径
|
|
11
|
+
4. 复制到 templates/{folder_name}/v{N}/
|
|
12
|
+
5. 重建 template-index.json
|
|
13
|
+
|
|
14
|
+
用法:
|
|
15
|
+
python3 move_template_task.py <模板目录> [目标根目录]
|
|
16
|
+
"""
|
|
2
17
|
import json
|
|
3
18
|
import re
|
|
4
19
|
import shutil
|
|
@@ -8,86 +23,71 @@ from pathlib import Path
|
|
|
8
23
|
|
|
9
24
|
ROOT = Path(__file__).resolve().parent.parent
|
|
10
25
|
DEFAULT_TARGET = ROOT / 'templates'
|
|
26
|
+
IDENTIFY_SCRIPT = ROOT / 'scripts' / 'identify_template.py'
|
|
11
27
|
INDEX_SCRIPT = ROOT / 'scripts' / 'build_template_index.py'
|
|
12
|
-
REQUIRED_FILES = [
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
with path.open('r', encoding='utf-8') as f:
|
|
17
|
-
return json.load(f)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def save_json(path, data):
|
|
21
|
-
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def normalize_category(teacher):
|
|
25
|
-
category = str(teacher['任务类别']).upper()
|
|
26
|
-
if category not in {'A', 'B', 'C'}:
|
|
27
|
-
raise ValueError('任务类别只能是 A、B、C')
|
|
28
|
-
return category
|
|
29
|
-
|
|
28
|
+
REQUIRED_FILES = [
|
|
29
|
+
'__demo__.html', '__student__.json', '__teacher__.json',
|
|
30
|
+
'__student-view__.html', '__teacher-view__.html',
|
|
31
|
+
]
|
|
30
32
|
|
|
31
|
-
def sanitize_name(name):
|
|
32
|
-
return str(name).replace('/', '_').replace(' ', '_')
|
|
33
33
|
|
|
34
|
+
def load_json(path: Path) -> dict:
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
37
|
+
except Exception:
|
|
38
|
+
return {}
|
|
34
39
|
|
|
35
|
-
def find_next_available_number(category, target_root):
|
|
36
|
-
used = set()
|
|
37
|
-
pattern = re.compile(r'^([a-zA-Z])(\d{3})_')
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
continue
|
|
42
|
-
m = pattern.match(folder.name)
|
|
43
|
-
if not m:
|
|
44
|
-
continue
|
|
45
|
-
if m.group(1).upper() != category:
|
|
46
|
-
continue
|
|
47
|
-
used.add(int(m.group(2)))
|
|
48
|
-
|
|
49
|
-
for num in range(100, 1000):
|
|
50
|
-
if num not in used:
|
|
51
|
-
return f'{num:03d}'
|
|
52
|
-
raise RuntimeError(f'{category} 类编号已用尽')
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def task_folder_name(teacher, target_root):
|
|
56
|
-
category = normalize_category(teacher)
|
|
57
|
-
number = find_next_available_number(category, target_root)
|
|
58
|
-
teacher['任务编号'] = number
|
|
59
|
-
name = sanitize_name(teacher['任务名称'])
|
|
60
|
-
return f'{category.lower()}{number}_{name}'
|
|
41
|
+
def sanitize_name(name: str) -> str:
|
|
42
|
+
return re.sub(r'[/\\ ]', '_', str(name).strip())
|
|
61
43
|
|
|
62
44
|
|
|
63
45
|
def main():
|
|
64
46
|
if len(sys.argv) < 2:
|
|
65
|
-
print('用法: python3 move_template_task.py
|
|
47
|
+
print('用法: python3 move_template_task.py <模板目录> [目标根目录]')
|
|
66
48
|
sys.exit(1)
|
|
67
49
|
|
|
68
50
|
source_dir = Path(sys.argv[1]).expanduser().resolve()
|
|
69
51
|
target_root = Path(sys.argv[2]).expanduser().resolve() if len(sys.argv) > 2 else DEFAULT_TARGET
|
|
70
52
|
|
|
53
|
+
if not source_dir.is_dir():
|
|
54
|
+
print(f'❌ 目录不存在: {source_dir}')
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
# 1. 校验必需文件
|
|
71
58
|
for filename in REQUIRED_FILES:
|
|
72
59
|
if not (source_dir / filename).exists():
|
|
73
|
-
|
|
60
|
+
print(f'❌ 缺少必需文件: {filename}')
|
|
61
|
+
sys.exit(1)
|
|
74
62
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
# 2. 颁发/校准身份(写回源目录的 __teacher__.json)
|
|
64
|
+
print('\n── 身份认证 ────────────────────────────────')
|
|
65
|
+
subprocess.run([sys.executable, str(IDENTIFY_SCRIPT), str(source_dir)], check=True)
|
|
66
|
+
|
|
67
|
+
# 3. 读取更新后的身份信息
|
|
68
|
+
teacher = load_json(source_dir / '__teacher__.json')
|
|
69
|
+
category = teacher.get('任务类别', '').upper()
|
|
70
|
+
number = teacher.get('任务编号', '100')
|
|
71
|
+
name = sanitize_name(teacher.get('任务名称', 'untitled'))
|
|
72
|
+
version = teacher.get('_version', 1)
|
|
73
|
+
|
|
74
|
+
folder_name = f'{category.lower()}{number}_{name}'
|
|
75
|
+
version_str = f'v{version}'
|
|
76
|
+
target_dir = target_root / folder_name / version_str
|
|
80
77
|
|
|
81
|
-
target_dir = target_root / folder_name
|
|
82
78
|
if target_dir.exists():
|
|
83
|
-
|
|
79
|
+
print(f'❌ 目标目录已存在(重复操作?): {target_dir}')
|
|
80
|
+
sys.exit(1)
|
|
84
81
|
|
|
85
|
-
|
|
86
|
-
print(f'
|
|
87
|
-
|
|
82
|
+
# 4. 复制到 templates/{folder}/v{N}/
|
|
83
|
+
print(f'\n── 复制文件 ────────────────────────────────')
|
|
84
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
shutil.copytree(str(source_dir), str(target_dir))
|
|
86
|
+
print(f'✅ 已复制到: {target_dir}')
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
print('
|
|
88
|
+
# 5. 重建 index
|
|
89
|
+
print(f'\n── 重建索引 ────────────────────────────────')
|
|
90
|
+
subprocess.run([sys.executable, str(INDEX_SCRIPT)], check=True)
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
if __name__ == '__main__':
|
|
@@ -4,85 +4,99 @@ publish_template.py
|
|
|
4
4
|
|
|
5
5
|
发布模板文件夹到七牛 CDN,完成后重建并上传 template-index.json。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
2. 重建 template-index.json 并上传到 CDN
|
|
7
|
+
CDN 路径:{CDN_PREFIX}/{folder_name}/{version}/{filename}
|
|
8
|
+
版本号从 templates/{folder}/v{N}/ 子目录读取(以本地最新版本为准)。
|
|
10
9
|
|
|
11
10
|
用法:
|
|
12
11
|
python3 scripts/publish_template.py <模板文件夹路径>
|
|
13
12
|
python3 scripts/publish_template.py --all
|
|
14
|
-
|
|
15
|
-
例:
|
|
16
|
-
python3 scripts/publish_template.py templates/a103_做一个介绍页面
|
|
17
|
-
python3 scripts/publish_template.py --all
|
|
18
13
|
"""
|
|
19
|
-
|
|
20
14
|
import argparse
|
|
15
|
+
import re
|
|
21
16
|
import subprocess
|
|
22
17
|
import sys
|
|
23
18
|
from pathlib import Path
|
|
24
19
|
|
|
25
20
|
from qiniu import Auth, put_file_v2, CdnManager
|
|
26
21
|
|
|
27
|
-
# ── 七牛配置(与 server 保持一致)──────────────────────────────────────────
|
|
28
22
|
QINIU_KEY = "T3tgxM7EMx1j4VESw4m4PIfFXoOvBo-wQEOQewXX"
|
|
29
23
|
QINIU_SECRET = "PVZvlKVOjX2RqlV2ILMg-QwpNMssOlpVbaEzypz0"
|
|
30
24
|
QINIU_BUCKET = "yiran1"
|
|
31
25
|
CDN_DOMAIN = "https://cdn.yiranlaoshi.com"
|
|
32
|
-
|
|
26
|
+
CDN_INDEX_KEY = "myclaw/live/yiran/skills/yiran-playground-template-use/template-index.json"
|
|
27
|
+
CDN_PREFIX = "myclaw/live/yiran/skills/yiran-playground-template-use/templates"
|
|
33
28
|
|
|
34
29
|
q = Auth(QINIU_KEY, QINIU_SECRET)
|
|
35
30
|
cdn_manager = CdnManager(q)
|
|
36
31
|
|
|
37
32
|
|
|
38
|
-
def
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
def get_latest_version_dir(folder: Path):
|
|
34
|
+
"""返回最新 v{N} 子目录和版本号,没有则返回 (None, 0)"""
|
|
35
|
+
max_v = 0
|
|
36
|
+
result = None
|
|
37
|
+
for d in folder.iterdir():
|
|
38
|
+
if d.is_dir():
|
|
39
|
+
m = re.match(r'^v(\d+)$', d.name)
|
|
40
|
+
if m:
|
|
41
|
+
v = int(m.group(1))
|
|
42
|
+
if v > max_v:
|
|
43
|
+
max_v = v
|
|
44
|
+
result = d
|
|
45
|
+
return result, max_v
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def upload_file(local_path: Path, cdn_key: str) -> str:
|
|
49
|
+
token = q.upload_token(QINIU_BUCKET, cdn_key, 3600, policy={'insertOnly': 0})
|
|
50
|
+
ret, info = put_file_v2(token, cdn_key, str(local_path))
|
|
43
51
|
if info.status_code != 200:
|
|
44
|
-
raise RuntimeError(
|
|
45
|
-
cdn_url =
|
|
52
|
+
raise RuntimeError('上传失败 {}: {}'.format(local_path.name, info))
|
|
53
|
+
cdn_url = '{}/{}'.format(CDN_DOMAIN, cdn_key)
|
|
46
54
|
cdn_manager.refresh_urls([cdn_url])
|
|
47
55
|
return cdn_url
|
|
48
56
|
|
|
49
57
|
|
|
50
|
-
def publish(folder_path):
|
|
58
|
+
def publish(folder_path: Path):
|
|
51
59
|
folder_name = folder_path.name
|
|
52
|
-
|
|
60
|
+
version_dir, version_num = get_latest_version_dir(folder_path)
|
|
61
|
+
|
|
62
|
+
if not version_dir:
|
|
63
|
+
print('⚠️ {} 没有版本子目录,跳过'.format(folder_name))
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
version_str = 'v{}'.format(version_num)
|
|
67
|
+
files = [f for f in version_dir.rglob('*') if f.is_file() and f.name != '.sync-done']
|
|
53
68
|
|
|
54
69
|
if not files:
|
|
55
|
-
print(
|
|
70
|
+
print('⚠️ 文件夹为空: {}/{}'.format(folder_name, version_str))
|
|
56
71
|
return
|
|
57
72
|
|
|
58
|
-
print(
|
|
73
|
+
print('📂 发布: {}/{}'.format(folder_name, version_str))
|
|
59
74
|
for f in sorted(files):
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
rel = f.relative_to(version_dir)
|
|
76
|
+
cdn_key = '{}/{}/{}/{}'.format(CDN_PREFIX, folder_name, version_str, rel.as_posix())
|
|
77
|
+
cdn_url = upload_file(f, cdn_key)
|
|
78
|
+
print(' ⬆️ {} → {}'.format(rel, cdn_url))
|
|
64
79
|
|
|
80
|
+
print('✅ 完成: {}/{}\n'.format(folder_name, version_str))
|
|
65
81
|
|
|
66
82
|
|
|
67
83
|
def publish_index(pipeline_root: Path):
|
|
68
|
-
"""重建 template-index.json 并上传到 CDN。"""
|
|
69
84
|
index_script = pipeline_root / 'scripts' / 'build_template_index.py'
|
|
70
|
-
print(
|
|
85
|
+
print('🔄 重建 template-index.json ...')
|
|
71
86
|
subprocess.run(['python3', str(index_script)], check=True)
|
|
72
87
|
|
|
73
88
|
index_file = pipeline_root / 'template-index.json'
|
|
74
89
|
if not index_file.is_file():
|
|
75
|
-
print(
|
|
90
|
+
print('⚠️ template-index.json 未生成,跳过上传')
|
|
76
91
|
return
|
|
77
92
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
ret, info = put_file_v2(token, key, str(index_file))
|
|
93
|
+
token = q.upload_token(QINIU_BUCKET, CDN_INDEX_KEY, 3600, policy={'insertOnly': 0})
|
|
94
|
+
ret, info = put_file_v2(token, CDN_INDEX_KEY, str(index_file))
|
|
81
95
|
if info.status_code != 200:
|
|
82
|
-
raise RuntimeError(
|
|
83
|
-
cdn_url =
|
|
96
|
+
raise RuntimeError('index 上传失败: {}'.format(info))
|
|
97
|
+
cdn_url = '{}/{}'.format(CDN_DOMAIN, CDN_INDEX_KEY)
|
|
84
98
|
cdn_manager.refresh_urls([cdn_url])
|
|
85
|
-
print(
|
|
99
|
+
print(' ⬆️ 已上传索引 → {}'.format(cdn_url))
|
|
86
100
|
|
|
87
101
|
|
|
88
102
|
def main():
|
|
@@ -98,15 +112,15 @@ def main():
|
|
|
98
112
|
templates_dir = pipeline_root / 'templates'
|
|
99
113
|
folders = sorted(d for d in templates_dir.iterdir() if d.is_dir())
|
|
100
114
|
if not folders:
|
|
101
|
-
print(
|
|
115
|
+
print('⚠️ templates/ 下没有找到任何模板文件夹')
|
|
102
116
|
sys.exit(0)
|
|
103
|
-
print(
|
|
117
|
+
print('📦 共 {} 个模板,开始全量发布...\n'.format(len(folders)))
|
|
104
118
|
for folder in folders:
|
|
105
119
|
publish(folder)
|
|
106
120
|
else:
|
|
107
121
|
folder = Path(args.folder).expanduser().resolve()
|
|
108
122
|
if not folder.is_dir():
|
|
109
|
-
print(
|
|
123
|
+
print('❌ 路径不存在或不是文件夹: {}'.format(folder))
|
|
110
124
|
sys.exit(1)
|
|
111
125
|
publish(folder)
|
|
112
126
|
|