@lark-apaas/miaoda-cli 0.1.24-alpha.495fda7 → 0.1.24-alpha.4ac18f6

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.
@@ -26,10 +26,11 @@ function registerSkillsSync(parent) {
26
26
  .option('--version <ver>', 'coding-steering 包版本或 dist-tag,缺省 latest')
27
27
  .option('--local', '走本地 dev / agent 输出形态:拷到 .agents/skills 平铺 + 创建 .claude/skills 软链。' +
28
28
  '不传时退回老形态:拷到 .agent/skills/steering/<stack>/skills/(跟沙箱端 update-skills.sh 对齐)', false)
29
- .option('--type <type>', '走 sandbox-skills 分支:miaoda | miaoda-modern | miaoda-design | openclaw。' +
30
- '有值时忽略 --version/--local,改从 @lark-apaas/coding-<pkg>-sandbox 拉对应包写入 .agent/skills/')
29
+ .option('--type <type>', '走 sandbox 分支:miaoda | miaoda-modern | miaoda-design | openclaw。有值时忽略 --local,' +
30
+ '从 @lark-apaas/coding-<pkg>-sandbox-skills 拉包平铺写入,并额外同步 steering ' +
31
+ '到 .agent/skills/steering/<stack>/(--version 对后者仍生效)')
31
32
  .option('--tgz-key <key>', '配合 --type 使用;老 TCC skillsUpdateTgzKey 值透传,' +
32
- '从 skills_versions/all_<X>.tgz 反推出 dist-tag lane-<X>;缺省走 @latest')
33
+ '从 skills_versions/all_<X>.tgz 反推出 lane 名 X,再过滤出 -alpha.<X> 版本;缺省走 @latest')
33
34
  .addHelpText('after', `
34
35
  前置要求
35
36
  当前目录(或 --dir)已走过 'miaoda app init'(.spark/meta.json 含 stack)
@@ -45,15 +46,28 @@ function registerSkillsSync(parent) {
45
46
  .agents/tech.md
46
47
  .claude/skills → ../.agents/skills(软链,让 Claude Code 识别同一份)
47
48
 
49
+ 传 --type(沙箱链路,两部分都同步):
50
+ .agent/skills/<skill>/... ← 业务线 skill,平铺
51
+ .agent/skills/steering/<stack>/skills/<skill>/ ← steering,同上面默认形态
52
+ (openclaw 的业务线部分落 skills/ 而非 .agent/skills/)
53
+
48
54
  JSON 输出
49
55
  {"data": {"stack": "...", "version": "...", "syncedSkills": [...], "techSynced": true|false,
50
56
  "claudeSkillsLink": "created|updated|noop|conflict" // 仅 --local 时}}
51
57
 
58
+ 传 --type 时:
59
+ {"data": {"mode": "sandbox", "type": "...", "packageName": "...", "version": "...",
60
+ "lane": "...", "syncedTop": [...], "targetDir": "...",
61
+ "steering": {"status": "synced|skipped|failed", ...}}}
62
+ steering 失败或无 stack 不影响整体成功——业务线 skill 已同步,不该因增量部分阻断
63
+
52
64
  示例
53
65
  $ miaoda skills sync # 拉 @latest
54
66
  $ miaoda skills sync --local # 本地 dev / agent 用
55
67
  $ miaoda skills sync --version 0.2.0 # 显式指定版本
56
68
  $ miaoda skills sync --dir ./my-app
69
+ # 沙箱链路(由 feida-ai 下发):业务线 skill + steering 一次同步到位
70
+ $ miaoda skills sync --type miaoda-modern --tgz-key 'skills_versions/all_demo-lane.tgz'
57
71
  `);
58
72
  cmd.action((0, shared_1.withHelp)(cmd, async (rawOpts) => {
59
73
  await (0, index_1.handleSkillsSync)({
@@ -7,9 +7,61 @@ exports.handleSkillsSync = handleSkillsSync;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
8
  const coding_steering_1 = require("../../../utils/coding-steering");
9
9
  const sandbox_skills_1 = require("../../../utils/sandbox-skills");
10
+ const file_ops_1 = require("../../../utils/file-ops");
10
11
  const spark_meta_1 = require("../../../utils/spark-meta");
11
12
  const error_1 = require("../../../utils/error");
12
13
  const output_1 = require("../../../utils/output");
14
+ /**
15
+ * sandbox 分支下补同步 steering(`.agent/skills/steering/<stack>/`)。
16
+ *
17
+ * **绝不抛错**,因为它在 sandbox 链路里是「附加项」而非门禁:
18
+ * - 无 `.spark/meta.json` / 无 stack → skipped。对齐老 cp-skills.sh 的
19
+ * `[ -n "$STACK" ] && [ "$STACK" != "null" ]` 判断(无 stack 就跳过 steering)。
20
+ * - 拉包 / 写盘失败 → failed,但仍让整条 sync 算成功。
21
+ *
22
+ * 为什么不能抛:feida-ai 侧 TCC 可配 `allowFailure: false`,一抛就阻断 agent 启动。
23
+ * 平铺业务线 skill 已经同步好了,不该因为 steering 这个增量部分让用户开不了会话。
24
+ */
25
+ function syncSteeringForSandbox(params) {
26
+ let stack;
27
+ try {
28
+ const meta = (0, spark_meta_1.readSparkMeta)(params.targetDir);
29
+ if (meta.stack === undefined || meta.stack === '' || meta.stack === 'null') {
30
+ return { status: 'skipped', reason: '.spark/meta.json missing stack' };
31
+ }
32
+ stack = meta.stack;
33
+ }
34
+ catch (err) {
35
+ return { status: 'skipped', reason: `read .spark/meta.json failed: ${err.message}` };
36
+ }
37
+ // 版本口径与下方本地分支保持一致:--version > BOE 的 dist-tag dev > latest
38
+ const isBoe = process.env.FORCE_FRAMEWORK_ENVIRONMENT === 'boe';
39
+ const effectiveVersion = params.version ?? (isBoe ? 'dev' : undefined);
40
+ try {
41
+ const r = (0, coding_steering_1.syncCodingSteering)({
42
+ stack,
43
+ targetDir: params.targetDir,
44
+ version: effectiveVersion,
45
+ // 必须显式传 mode,不能落到 inferMode():它靠 env MIAODA_DEP_CACHE_DIR 嗅探,
46
+ // 而 mode 决定读包内哪个 layer(sandbox→`skills/`,local→`skills_local/`)。
47
+ // `--type` 分支本身就是沙箱语义,嗅探失手会去读不存在的 skills_local/、
48
+ // 静默同步 0 个 skill。
49
+ mode: 'sandbox',
50
+ // 同理固定 nested:落 .agent/skills/steering/<stack>/,与老链路一致
51
+ outputLayout: 'nested',
52
+ });
53
+ return {
54
+ status: 'synced',
55
+ stack,
56
+ version: r.version,
57
+ syncedSkills: r.syncedSkills,
58
+ techSynced: r.techSynced,
59
+ };
60
+ }
61
+ catch (err) {
62
+ return { status: 'failed', reason: err.message };
63
+ }
64
+ }
13
65
  /**
14
66
  * miaoda skills sync [--dir <path>] [--version <ver>]
15
67
  *
@@ -27,11 +79,39 @@ const output_1 = require("../../../utils/output");
27
79
  async function handleSkillsSync(opts) {
28
80
  await Promise.resolve();
29
81
  const targetDir = node_path_1.default.resolve(opts.dir ?? process.cwd());
30
- // --type 走 sandbox-skills 分支(迁仓后沙箱内 update-skills.sh 调用),跟本地 dev 的
31
- // steering 同步逻辑并列——两条路径的目标 / 拉包 / 目录结构完全不同,用 --type 显式路由。
82
+ // --type 走 sandbox-skills 分支(迁仓后沙箱内由 feida-ai 直接下发调用)。
83
+ //
84
+ // 这条分支必须**同时**同步两部分,缺一不可 —— 线上老链路 update-skills.sh 也是两步
85
+ // (自己 rsync 平铺,再于第 230 行调不带 --type 的 `miaoda skills sync` 写 steering):
86
+ // ① syncSandboxSkills:平铺业务线 skill,rsync 式对齐(保 .keep/.config/steering)
87
+ // ② steering:`.agent/skills/steering/<stack>/`,按 .spark/meta.json 的 stack 过滤
88
+ // ③ lockDirReadonly:锁只读收尾(update-skills.sh:241),仅沙箱侧路径
89
+ //
90
+ // 历史 bug:早期这里 ① 完就 return,steering 既不同步、又被当时的整目录清空删掉,
91
+ // 沙箱少掉整批技术栈 skill(BOE 实测 scanAllSkills 从 59 掉到 34)。
92
+ //
93
+ // `.keep` 语义(update-skills.sh:149):目标目录挂了 .keep 表示「我手改过,别覆盖」,
94
+ // ① 会跳过并回报 skippedByKeepFile,此时 ② 也必须跳过 —— 否则 steering 仍被覆写,
95
+ // 冻结只冻结了一半。
32
96
  if (opts.type !== undefined && opts.type !== '') {
33
97
  const { type, tgzKey } = (0, sandbox_skills_1.validateSandboxSyncOpts)({ type: opts.type, tgzKey: opts.tgzKey });
34
98
  const result = (0, sandbox_skills_1.syncSandboxSkills)({ type, tgzKey, targetDir });
99
+ const steering = result.skippedByKeepFile
100
+ ? { status: 'skipped', reason: '.keep file found, sync frozen' }
101
+ : syncSteeringForSandbox({ targetDir, version: opts.version });
102
+ // ③ 收尾锁只读(update-skills.sh:241 的 chmod -R a=rX)。必须在 ①② 全部写完之后,
103
+ // 锁上就写不进去了。`.keep` 冻结时不锁 —— 老链路那条路径是 exit 0 直接走、
104
+ // 末尾的 chmod 压根不执行,保持一致。
105
+ //
106
+ // **只锁沙箱侧**:这里的 result.targetDir 只会是 `.agent/skills` 或 `skills`
107
+ // (见 sandbox-skills 的 skillsRoot),都是平台下发的资产根。本地 `--local` 走
108
+ // flat layout 输出到 `.agents/`,压根不经过本分支,权限不会被碰。
109
+ //
110
+ // 回退风险由解锁侧兜:权限是持久化在磁盘的,代码能回退、磁盘状态不会。所以
111
+ // file-ops 的 unlockDirForWrite 在**两处写入前**都调用了(syncSandboxSkills 的
112
+ // 写入前 + syncCodingSteering 的 nested 分支写入前),任一入口都不会因为
113
+ // 上一轮的锁而 EACCES。改动这里之前先确认那两处解锁还在。
114
+ const lockedReadonly = result.skippedByKeepFile ? false : (0, file_ops_1.lockDirReadonly)(result.targetDir);
35
115
  (0, output_1.emit)({
36
116
  data: {
37
117
  mode: 'sandbox',
@@ -40,7 +120,11 @@ async function handleSkillsSync(opts) {
40
120
  version: result.version,
41
121
  lane: result.lane,
42
122
  syncedTop: result.syncedTop,
123
+ deleted: result.deleted,
124
+ skippedByKeepFile: result.skippedByKeepFile,
125
+ lockedReadonly,
43
126
  targetDir: result.targetDir,
127
+ steering,
44
128
  },
45
129
  });
46
130
  return;
@@ -9,6 +9,7 @@ const node_path_1 = __importDefault(require("node:path"));
9
9
  const npm_pack_1 = require("../utils/npm-pack");
10
10
  const logger_1 = require("../utils/logger");
11
11
  const env_1 = require("../utils/env");
12
+ const file_ops_1 = require("../utils/file-ops");
12
13
  const STEERING_PACKAGE = '@lark-apaas/coding-steering';
13
14
  function inferMode() {
14
15
  // 沙箱(MIAODA_DEP_CACHE_DIR 非空)→ sandbox;本地 → local
@@ -63,6 +64,21 @@ function syncCodingSteering(opts) {
63
64
  dstSkillsDir: node_path_1.default.join(opts.targetDir, '.agent', 'skills', 'steering', opts.stack, 'skills'),
64
65
  dstTechPath: node_path_1.default.join(opts.targetDir, '.agent', 'skills', 'steering', opts.stack, 'tech.md'),
65
66
  };
67
+ // 写入前解锁 `.agent/skills`:沙箱链路每轮同步末尾会把它 chmod -R a=rX 锁成只读
68
+ // (file-ops 的 lockDirReadonly,对齐 update-skills.sh:241),不解锁下面 mkdir/cp
69
+ // 就 EACCES。老链路的解锁在 update-skills.sh:160-165 脚本层、每次必跑;这里下沉到
70
+ // 写入方,沙箱 `--type` 与本地 `skills sync` / `app init` 任一入口都不会漏。
71
+ //
72
+ // 解锁的是**锁定根**而非 dstSkillsDir:锁是 chmod -R 打在根上的,只解 dst 那一层,
73
+ // 祖先目录仍不可写 → 照样失败。
74
+ //
75
+ // **仅 nested**:flat 的输出根是 `.agents/`,而 lockDirReadonly 只作用于 --type 分支的
76
+ // `.agent/skills` 或 `skills`,`.agents/` 永远不会被我们锁上。对它 chmod 纯属多余——
77
+ // 白跑一遍递归 I/O,还会改本地开发者文件的权限位。目录不存在时 unlockDirForWrite
78
+ // 直接返回 false,所以本地首次 init 也不会有额外开销。
79
+ if (layout === 'nested') {
80
+ (0, file_ops_1.unlockDirForWrite)(node_path_1.default.join(opts.targetDir, '.agent', 'skills'));
81
+ }
66
82
  node_fs_1.default.mkdirSync(dstSkillsDir, { recursive: true });
67
83
  let techSynced = false;
68
84
  const techSrc = node_path_1.default.join(stackDir, 'tech.md');
@@ -5,6 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.removeLineFromFile = removeLineFromFile;
7
7
  exports.removeLineFromContent = removeLineFromContent;
8
+ exports.unlockDirForWrite = unlockDirForWrite;
9
+ exports.lockDirReadonly = lockDirReadonly;
10
+ exports.forceRemove = forceRemove;
11
+ const node_child_process_1 = require("node:child_process");
8
12
  const node_fs_1 = __importDefault(require("node:fs"));
9
13
  const node_path_1 = __importDefault(require("node:path"));
10
14
  const logger_1 = require("./logger");
@@ -43,3 +47,77 @@ function removeLineFromContent(content, pattern) {
43
47
  changed: filtered.length !== lines.length,
44
48
  };
45
49
  }
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // 目录权限:沙箱 skill 目录被刻意锁成只读,写入前须解锁、写完须锁回。
52
+ //
53
+ // 背景:老 update-skills.sh 同步末尾 `chmod -R a=rX "$TARGET_DIR"`(:241)把
54
+ // skills 锁成只读——skills 是平台下发的资产,不该被用户/agent 手改;下一轮同步
55
+ // 开头再 `chmod -R u+w`(:160-165)解锁。目录 owner 是沙箱当前用户,虽无写位但
56
+ // 有权改权限,所以不需要 sudo(沙箱容器带 no_new_privileges,sudo 本就被禁)。
57
+ //
58
+ // 放在 file-ops 而非 sandbox-skills:coding-steering 与 sandbox-skills 两条写入
59
+ // 路径都要用,避免其中一方 import 另一方造成方向混乱。
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ /**
62
+ * 解锁目录使其可写(`chmod -R u+w`)。
63
+ *
64
+ * 写入被只读锁保护的目录前必须调用,否则 `fs.cpSync` / `fs.rmSync` 报 EACCES。
65
+ * 只加 owner 写位、不动 g/o,与老链路一致。目录不存在直接返回 false(调用方
66
+ * 通常紧接着 mkdir)。失败只 debug 不抛:解锁是尽力而为,真写不进去让后续
67
+ * 写操作自己报错,错误信息更具体。
68
+ */
69
+ function unlockDirForWrite(dir) {
70
+ if (!node_fs_1.default.existsSync(dir))
71
+ return false;
72
+ try {
73
+ (0, node_child_process_1.execFileSync)('chmod', ['-R', 'u+w', dir], { stdio: ['ignore', 'ignore', 'pipe'] });
74
+ return true;
75
+ }
76
+ catch (err) {
77
+ (0, logger_1.debug)(`file-ops: chmod -R u+w ${dir} failed: ${err.message}`);
78
+ return false;
79
+ }
80
+ }
81
+ /**
82
+ * 把目录锁成只读(`chmod -R a=rX`),对齐 update-skills.sh:241。
83
+ *
84
+ * `a=rX`:所有人只读;`X` 是大写,只给目录和「原本已有执行位」的文件加 x,
85
+ * 不会把 SKILL.md 变成可执行。会连 owner 写位一并清掉,所以下一轮写入前必须
86
+ * {@link unlockDirForWrite}。
87
+ *
88
+ * **调用时机必须是全部写入完成之后**——锁上就写不进去了。
89
+ */
90
+ function lockDirReadonly(dir) {
91
+ if (!node_fs_1.default.existsSync(dir))
92
+ return false;
93
+ try {
94
+ (0, node_child_process_1.execFileSync)('chmod', ['-R', 'a=rX', dir], { stdio: ['ignore', 'ignore', 'pipe'] });
95
+ return true;
96
+ }
97
+ catch (err) {
98
+ (0, logger_1.debug)(`file-ops: chmod -R a=rX ${dir} failed: ${err.message}`);
99
+ return false;
100
+ }
101
+ }
102
+ /**
103
+ * 强制删除文件或目录,三层兜底:`chmod -R u+w` → `rm -rf` → `fs.rmSync`。
104
+ *
105
+ * 为什么不能直接 `fs.rmSync`:删条目需要**父目录**写权限,而 skill 目录常处于
106
+ * 只读锁下;另外老 update.sh 曾以 root 跑过,可能留下异主文件(如 root-owned 的
107
+ * `.last-update`),`rmSync{force:true}` 只忽略 ENOENT、遇 EACCES 仍抛。
108
+ * shell `rm -rf` 对异主文件 / 怪符号链接比 rmSync 宽容,故作主档。
109
+ */
110
+ function forceRemove(target) {
111
+ if (!node_fs_1.default.existsSync(target))
112
+ return;
113
+ unlockDirForWrite(target);
114
+ try {
115
+ (0, node_child_process_1.execFileSync)('rm', ['-rf', target], { stdio: ['ignore', 'ignore', 'pipe'] });
116
+ if (!node_fs_1.default.existsSync(target))
117
+ return;
118
+ }
119
+ catch (err) {
120
+ (0, logger_1.debug)(`file-ops: rm -rf ${target} failed: ${err.message}`);
121
+ }
122
+ node_fs_1.default.rmSync(target, { recursive: true, force: true });
123
+ }
@@ -6,49 +6,60 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.extractLaneFromTgzKey = extractLaneFromTgzKey;
7
7
  exports.syncSandboxSkills = syncSandboxSkills;
8
8
  exports.validateSandboxSyncOpts = validateSandboxSyncOpts;
9
- const node_child_process_1 = require("node:child_process");
10
9
  const node_fs_1 = __importDefault(require("node:fs"));
11
10
  const node_path_1 = __importDefault(require("node:path"));
12
11
  const npm_pack_1 = require("../utils/npm-pack");
13
12
  const error_1 = require("../utils/error");
14
13
  const logger_1 = require("../utils/logger");
15
- /**
16
- * 覆盖式清空目录,权限兜底:`chmod -R u+w` → `rm -rf` → `fs.rmSync`。
17
- *
18
- * 为什么要 chmod:沙箱里 skill 目录 owner 是当前用户 (gem),但老
19
- * `update-skills.sh` 会显式 `chmod -R a-w` 把 skill 目录锁只读(防用户改 skill 内容)。
20
- * gem 是 owner 但没 write 权限,unlink 里面文件挂 EACCES;shell `rm -rf` 也过不去。
21
- * 因为 gem 是 owner,`chmod -R u+w` 能自己给自己加写权限(不需要 sudo,绕过沙箱
22
- * "no new privileges" 容器 flag)。
23
- */
24
- function forceRemoveDir(dir) {
25
- if (!node_fs_1.default.existsSync(dir))
26
- return;
27
- // 先 chmod -R u+w 恢复写权限(应对老 update.sh 的 `chmod -R a-w` 只读锁)
28
- try {
29
- (0, node_child_process_1.execFileSync)('chmod', ['-R', 'u+w', dir], { stdio: ['ignore', 'ignore', 'pipe'] });
30
- }
31
- catch (err) {
32
- (0, logger_1.debug)(`sandbox-skills: chmod -R u+w ${dir} failed: ${err.message}`);
33
- }
34
- // 主档:shell rm -rf
35
- try {
36
- (0, node_child_process_1.execFileSync)('rm', ['-rf', dir], { stdio: ['ignore', 'ignore', 'pipe'] });
37
- if (!node_fs_1.default.existsSync(dir))
38
- return;
39
- }
40
- catch (err) {
41
- (0, logger_1.debug)(`sandbox-skills: rm -rf ${dir} failed: ${err.message}`);
42
- }
43
- // Fallback:Node fs.rmSync 尽力而为
44
- node_fs_1.default.rmSync(dir, { recursive: true, force: true });
45
- }
14
+ const file_ops_1 = require("../utils/file-ops");
46
15
  const PACKAGE_BY_TYPE = {
47
16
  miaoda: '@lark-apaas/coding-miaoda-sandbox-skills',
48
17
  'miaoda-modern': '@lark-apaas/coding-miaoda-sandbox-skills',
49
18
  'miaoda-design': '@lark-apaas/coding-miaoda-sandbox-skills',
50
19
  openclaw: '@lark-apaas/coding-openclaw-sandbox-skills',
51
20
  };
21
+ /**
22
+ * 目标根目录下**永不删除**的控制文件(对齐 `update-skills.sh` 的删除白名单):
23
+ * - `.keep` 沙箱级「冻结 skills」开关,存在即整体跳过同步(见 {@link syncSandboxSkills})
24
+ * - `.config` 沙箱级 tgzKey 覆盖,feida-ai 的 `enableConfigFileOverride` 读它
25
+ * (`getKeepFileTgzKey` → `.agent/skills/.config`),删了会让版本钉死失效
26
+ * - `.last-update` 同步时间戳,feida-ai freshness 检查读它;每轮末尾会重写,列进来只是语义完备
27
+ */
28
+ const PRESERVED_ENTRIES = new Set(['.keep', '.config', '.last-update']);
29
+ /** `.keep` 存在时整体跳过同步的标记文件名,语义同 `update-skills.sh:149`。 */
30
+ const KEEP_FILE = '.keep';
31
+ /**
32
+ * rsync 式对齐:删掉目标里「源包已不提供」的顶层条目,对齐 `update-skills.sh:196-218`。
33
+ *
34
+ * 与整目录清空的区别(后者是本函数取代的旧实现):
35
+ * - 保住 {@link PRESERVED_ENTRIES} 里的控制文件
36
+ * - 保住 `steering/` 子树 —— 它由 handler 层的 syncCodingSteering 独占管理
37
+ * (`update-skills.sh:207` 平铺型分支同样明确「归 miaoda CLI 管,update.sh 不删不覆盖」)
38
+ *
39
+ * 只处理顶层:源包内容本身就是「一个 skill 一个顶层目录」,逐文件比对没有额外收益。
40
+ */
41
+ function pruneStaleEntries(skillsRoot, sourceTopEntries) {
42
+ if (!node_fs_1.default.existsSync(skillsRoot))
43
+ return [];
44
+ const deleted = [];
45
+ for (const entry of node_fs_1.default.readdirSync(skillsRoot)) {
46
+ if (PRESERVED_ENTRIES.has(entry) || entry === 'steering')
47
+ continue;
48
+ if (sourceTopEntries.has(entry))
49
+ continue;
50
+ const target = node_path_1.default.join(skillsRoot, entry);
51
+ try {
52
+ // forceRemove 的 chmod→rm -rf→rmSync 三层兜底对文件同样适用:
53
+ // 老 update.sh 以 root 跑过、且会 chmod -R 锁只读,直接 rmSync 会 EACCES
54
+ (0, file_ops_1.forceRemove)(target);
55
+ deleted.push(entry);
56
+ }
57
+ catch (err) {
58
+ (0, logger_1.debug)(`sandbox-skills: prune ${entry} failed: ${err.message}`);
59
+ }
60
+ }
61
+ return deleted;
62
+ }
52
63
  // 合并包内的业务线子目录(miaoda / miaoda-modern / miaoda-design);shared 单独处理。
53
64
  const BUSINESS_LINE_BY_TYPE = {
54
65
  miaoda: 'miaoda',
@@ -88,8 +99,15 @@ function extractLaneFromTgzKey(tgzKey) {
88
99
  /**
89
100
  * 主流程:按 type 拉包 + sync 到目标目录。
90
101
  *
91
- * sync 策略:**覆盖式写入 + 老文件清理**,对齐老 cp-skills.sh 行为
92
- * (`rm -rf $TARGET; mkdir -p $TARGET; cp -r ...`)。
102
+ * sync 策略:**rsync 式对齐**,逐条对应线上真实跑的 `update-skills.sh`(不是
103
+ * `cp-skills.sh`——那个脚本线上不执行,日志里的 `copied/skipped/deleted` 出自前者):
104
+ * 1. 目标目录有 `.keep` → 整体跳过(`update-skills.sh:149`)
105
+ * 2. 覆盖式写入源包提供的顶层条目
106
+ * 3. 删掉源包已不提供的顶层条目({@link pruneStaleEntries}),但保住
107
+ * {@link PRESERVED_ENTRIES} 与 `steering/`
108
+ *
109
+ * 不用整目录清空:那会删掉 `.config`(沙箱级 tgzKey 覆盖)、`.keep`(冻结开关)
110
+ * 和 handler 层刚写的 `steering/`,而老链路对这三者都有保护。
93
111
  */
94
112
  function syncSandboxSkills(opts) {
95
113
  const targetDir = node_path_1.default.resolve(opts.targetDir ?? process.cwd());
@@ -137,13 +155,33 @@ function syncSandboxSkills(opts) {
137
155
  const skillsRoot = opts.type === 'openclaw'
138
156
  ? node_path_1.default.join(targetDir, 'skills')
139
157
  : node_path_1.default.join(targetDir, '.agent', 'skills');
140
- // 覆盖式:清空目标 skill 根目录(对齐 cp-skills.sh 行为,避免遗留老 skill)
141
- // 用 forceRemoveDir 兜底:老沙箱可能残留其他用户写的 stale 文件(例如老 update.sh
142
- // 以 root 跑,写下 root owned 的 `.last-update`),fs.rmSync {force:true} 遇到 EACCES
143
- // 会 throw。见 forceRemoveDir 注释。
144
- forceRemoveDir(skillsRoot);
158
+ // `.keep` 短路:对齐 update-skills.sh:149-152,沙箱侧放了这个文件即表示
159
+ // 「我手改过 skills,别覆盖我」,整体跳过同步(含 steering —— 调用方看到
160
+ // skipped 就不该再往下写)。
161
+ if (node_fs_1.default.existsSync(node_path_1.default.join(skillsRoot, KEEP_FILE))) {
162
+ (0, logger_1.debug)(`sandbox-skills: ${KEEP_FILE} found in ${skillsRoot}, skip sync`);
163
+ return {
164
+ packageName,
165
+ version: fetched.version,
166
+ lane,
167
+ syncedTop: [],
168
+ deleted: [],
169
+ skippedByKeepFile: true,
170
+ targetDir: skillsRoot,
171
+ };
172
+ }
173
+ // 写入前解锁,对齐 update-skills.sh:160-165:
174
+ // 上一轮同步末尾会 chmod -R a=rX 锁只读(见 file-ops 的 lockDirReadonly),
175
+ // 不先解锁,下面 fs.cpSync 覆盖已存在的 skill 目录必 EACCES。
176
+ // 只加 u+w(不动 g/o),与老链路一致;owner 是沙箱当前用户,无需 sudo。
177
+ (0, file_ops_1.unlockDirForWrite)(skillsRoot);
145
178
  node_fs_1.default.mkdirSync(skillsRoot, { recursive: true });
146
179
  const syncedTop = [];
180
+ // 源包提供的全部顶层条目 —— prune 的保留集。与 syncedTop 分开维护:syncedTop 是
181
+ // 「本轮实际写入」,用于对外汇报;prune 只能依据「源包提供了什么」来判断 stale,
182
+ // 两者一旦哪天再次分叉(比如某分支又加了跳过写入的条件),拿 syncedTop 当保留集
183
+ // 就会把没重写的条目误判成 stale 删掉、下一轮再写回来,反复抖动。
184
+ const sourceTop = new Set();
147
185
  if (businessLine !== null) {
148
186
  // 合并包 miaoda/miaoda-modern/miaoda-design:cp <extractDir>/<business-line>/* 到 target
149
187
  const sourceDir = node_path_1.default.join(fetched.extractDir, businessLine);
@@ -151,6 +189,7 @@ function syncSandboxSkills(opts) {
151
189
  for (const entry of node_fs_1.default.readdirSync(sourceDir)) {
152
190
  const from = node_path_1.default.join(sourceDir, entry);
153
191
  const to = node_path_1.default.join(skillsRoot, entry);
192
+ sourceTop.add(entry);
154
193
  node_fs_1.default.cpSync(from, to, { recursive: true });
155
194
  syncedTop.push(entry);
156
195
  }
@@ -158,17 +197,21 @@ function syncSandboxSkills(opts) {
158
197
  else {
159
198
  (0, logger_1.debug)(`sandbox-skills: source dir ${sourceDir} not found in package (skipped)`);
160
199
  }
161
- // shared:sync 合并包内 shared/* 到 target(对齐老 build.sh 的 shared 分发行为)
200
+ // shared:合并包内 shared/* 到 target。**无条件覆盖**,逐字对齐老 build.sh:42-52 ——
201
+ // 那里业务线先 `cp -r`(:33-34)、shared 后 `cp -r`(:47),后写覆盖前写,
202
+ // 即同名时 **shared 赢**。当前包里两边无同名交集,但语义得跟老链路一致。
203
+ //
204
+ // 早期这里写成「`fs.existsSync(to)` 就 continue」,有两个问题:
205
+ // 1. 同名优先级反了(变成业务线赢);
206
+ // 2. 更严重 —— existsSync 分不清「本轮业务线刚写的」和「上一轮 shared 自己写的」,
207
+ // 于是 shared skill 首次落盘后就永久冻结,包里再更新也同步不到已有沙箱
208
+ // (沙箱磁盘跨会话保留,每次 update-skills 都会命中 existsSync)。
162
209
  const sharedDir = node_path_1.default.join(fetched.extractDir, 'shared');
163
210
  if (node_fs_1.default.existsSync(sharedDir)) {
164
211
  for (const entry of node_fs_1.default.readdirSync(sharedDir)) {
165
212
  const from = node_path_1.default.join(sharedDir, entry);
166
213
  const to = node_path_1.default.join(skillsRoot, entry);
167
- if (node_fs_1.default.existsSync(to)) {
168
- // shared 命中已存在的 skill(如同名)— 保留业务线版本,跳过 shared(对齐老 build.sh:shared 是补丁,同名不覆盖)
169
- (0, logger_1.debug)(`sandbox-skills: shared/${entry} conflicts with business-line skill, skipped`);
170
- continue;
171
- }
214
+ sourceTop.add(entry);
172
215
  node_fs_1.default.cpSync(from, to, { recursive: true });
173
216
  syncedTop.push(entry);
174
217
  }
@@ -184,10 +227,15 @@ function syncSandboxSkills(opts) {
184
227
  if (!stat.isDirectory())
185
228
  continue;
186
229
  const to = node_path_1.default.join(skillsRoot, entry);
230
+ sourceTop.add(entry);
187
231
  node_fs_1.default.cpSync(from, to, { recursive: true });
188
232
  syncedTop.push(entry);
189
233
  }
190
234
  }
235
+ // rsync 式收尾:删掉源包已不提供的顶层条目(对齐 update-skills.sh 的 deleted)。
236
+ // 保留集用 sourceTop(源包提供的全部)而非 syncedTop(本轮实际写入的)——原因见
237
+ // sourceTop 声明处的注释:否则 shared skill 会被反复删了又写。
238
+ const deleted = pruneStaleEntries(skillsRoot, sourceTop);
191
239
  // 写 .last-update 时间戳(对齐老 update-skills.sh;供 feida-ai freshness 检查用)
192
240
  const lastUpdatePath = node_path_1.default.join(skillsRoot, '.last-update');
193
241
  node_fs_1.default.writeFileSync(lastUpdatePath, String(Date.now()));
@@ -195,6 +243,8 @@ function syncSandboxSkills(opts) {
195
243
  packageName,
196
244
  version: fetched.version,
197
245
  lane,
246
+ deleted,
247
+ skippedByKeepFile: false,
198
248
  syncedTop,
199
249
  targetDir: skillsRoot,
200
250
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.24-alpha.495fda7",
3
+ "version": "0.1.24-alpha.4ac18f6",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {