@mearl/setup 3.0.0-beta.1 → 3.0.0-beta.3

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/README.md CHANGED
@@ -5,20 +5,20 @@
5
5
  Mearl 环境的一次性安装、更新、卸载与配置工具。无需先全局安装 setup:
6
6
 
7
7
  ```bash
8
- npx @mearl/setup@beta [install] [--local] [--cloud|--qoder-cloud] [--mcp]
9
- npx @mearl/setup@beta update [--local] [--cloud|--qoder-cloud] [--mcp]
10
- npx @mearl/setup@beta uninstall [--local] [--cloud|--qoder-cloud] [--mcp] [--full]
11
- npx @mearl/setup@beta extension [--full] [--force]
12
- npx @mearl/setup@beta plugin [name] <path>
13
- npx @mearl/setup@beta plugin install <skills add 参数...>
14
- npx @mearl/setup@beta plugin uninstall <name>
15
- npx @mearl/setup@beta provider <install|update|uninstall> <package|id>
8
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta [install] [--local] [--cloud|--qoder-cloud] [--mcp]
9
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta update [--local] [--cloud|--qoder-cloud] [--mcp]
10
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta uninstall [--local] [--cloud|--qoder-cloud] [--mcp] [--full]
11
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta extension [--full] [--force]
12
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta plugin [name] <path>
13
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta plugin install <skills add 参数...>
14
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta plugin uninstall <name>
15
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta provider <install|update|uninstall> <package|id>
16
16
  ```
17
17
 
18
18
  浏览器 Provider 独立安装和演进。安装后运行 `mearl <providerId>` 查看该版本声明的能力、
19
19
  配置和启动参数。
20
20
 
21
- 在 Qoder Cloud Agent 环境中,使用 `npx @mearl/setup@beta --qoder-cloud --yes` 选择 Qoder
21
+ 在 Qoder Cloud Agent 环境中,使用 `npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta --qoder-cloud --yes` 选择 Qoder
22
22
  配对模式。它与 `--cloud` 安装同一套 cloud-server、client 和 Skill,只把 cloud-server
23
23
  模式记录为 Qoder;`mearl-cloud-server` 的命令交互保持不变,`start` 和 `restart` 输出新的
24
24
  配对指令后退出,不监听端口或保留守护进程。配对 URL 不包含 Qoder PAT。
@@ -28,7 +28,7 @@ npx @mearl/setup@beta provider <install|update|uninstall> <package|id>
28
28
  一个命令安装 skill 并注册它的 CLI、SDK 和 MCP 能力:
29
29
 
30
30
  ```bash
31
- npx @mearl/setup@beta plugin install https://github.com/F-loat/mearl/tree/v3.0.0-beta.1 --skill browser-tab-manager --yes
31
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta plugin install https://github.com/F-loat/mearl/tree/v3.0.0-beta.1 --skill browser-tab-manager --yes
32
32
  mearl tabs summary
33
33
  ```
34
34
 
@@ -45,8 +45,8 @@ Native preset 批次新建的插件注册使用 setup 托管归属,后续通
45
45
  已通过其他渠道分发的 skill 或自定义入口,可以直接传入路径注册:
46
46
 
47
47
  ```bash
48
- npx @mearl/setup@beta plugin /absolute/path/to/browser-tab-manager
49
- npx @mearl/setup@beta plugin custom ./skills/custom/entry.mjs
48
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta plugin /absolute/path/to/browser-tab-manager
49
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta plugin custom ./skills/custom/entry.mjs
50
50
  ```
51
51
 
52
52
  path 接受目录或具体文件;目录默认读取 `scripts/main.mjs` 或 `scripts/main.py`,相对路径基于调用工作目录解析。插件名优先使用显式 name,其次读取入口的 `name` metadata,最后回退到 Skill 目录名;常规注册只传 path,显式 name 仅用于覆盖或兼容旧入口。Python 插件要求 `python3 >= 3.9`,可通过 `MEARL_PYTHON` 指定解释器路径;setup 只验证入口可加载,不安装 Python,也不执行 `pip install`。
@@ -59,7 +59,7 @@ native-host 会幂等初始化 Skills 模式。基础环境补齐或初始化失
59
59
  不再需要插件时执行:
60
60
 
61
61
  ```bash
62
- npx @mearl/setup@beta plugin uninstall tabs
62
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta plugin uninstall tabs
63
63
  ```
64
64
 
65
65
  若入口位于 `~/.agents/skills/<skill>/`,setup 会先执行
@@ -75,9 +75,9 @@ Provider 由 npm 包分发,但包本身无需暴露 CLI 或安装脚本。setu
75
75
  可创建的浏览器类型名称及其表单参数:
76
76
 
77
77
  ```bash
78
- npx @mearl/setup@beta provider install @example/mearl-provider-device
79
- npx @mearl/setup@beta provider update example-device
80
- npx @mearl/setup@beta provider uninstall example-device
78
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta provider install @example/mearl-provider-device
79
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta provider update example-device
80
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta provider uninstall example-device
81
81
  ```
82
82
 
83
83
  安装参数接受包名、版本或 dist-tag;更新和卸载可使用 providerId 或完整包名。核心 @mearl 包固定使用公共 npm registry;其余 scope 沿用调用方的包管理器配置,组织 registry 由能力安装引导提供。安装沿用启动 setup 的包管理器,
@@ -88,8 +88,8 @@ npx @mearl/setup@beta provider uninstall example-device
88
88
  ## 行为
89
89
 
90
90
  - Mearl 自有 npm 包的安装和更新固定使用 `https://registry.npmjs.org/`,与最新版本查询使用同一来源;第三方包沿用当前包管理器的源配置。
91
- - `install` 可以省略;直接执行 `npx @mearl/setup@beta` 等同于 `npx @mearl/setup@beta install`。
92
- 以选项开头时也默认安装,例如 `npx @mearl/setup@beta --cloud --yes`。
91
+ - `install` 可以省略;直接执行 `npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta` 等同于 `npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta install`。
92
+ 以选项开头时也默认安装,例如 `npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta --cloud --yes`。
93
93
  - `install` 未指定环境时默认 `--local`。
94
94
  - `-h` / `--help` 显示帮助,`-v` / `--version` 显示 setup 版本。
95
95
  - `update` 和 `uninstall` 未指定环境时,从当前 `PATH` 中的
@@ -137,19 +137,19 @@ npx @mearl/setup@beta provider uninstall example-device
137
137
  ## 安装或更新浏览器扩展
138
138
 
139
139
  ```bash
140
- npx @mearl/setup@beta extension
140
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta extension
141
141
  ```
142
142
 
143
143
  首次使用时可一次安装完整的本地浏览器环境:
144
144
 
145
145
  ```bash
146
- npx @mearl/setup@beta extension --full
146
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta extension --full
147
147
  ```
148
148
 
149
149
  首次安装、旧版 setup 扩展首次迁移,或 Native Host reload 不可用时才需要 CDP。如果
150
150
  Chrome 尚未开放 CDP,交互命令会提示打开
151
151
  `chrome://inspect/#remote-debugging`、启用远程调试并允许连接,然后等待安装完成。面向人的
152
- 安装指引不要添加 `--yes`。无 TTY 环境使用 `npx @mearl/setup@beta extension --yes`;它只尝试
152
+ 安装指引不要添加 `--yes`。无 TTY 环境使用 `npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta extension --yes`;它只尝试
153
153
  一次,CDP 不可用时立即中断。无法确认 setup 管理来源且 CDP 不可用时不会下载或修改插件
154
154
  目录,打开 CDP 后重新执行同一命令即可。Chrome 版本过低、缺少扩展安装所需的 CDP 方法时,
155
155
  命令会中断并提示升级 Chrome。
@@ -179,7 +179,7 @@ DevTools 面板的设置按钮会显示红点,快捷设置中提供“立即
179
179
 
180
180
  自动更新不会降级较新的 Host,也不会改动 cloud、MCP 或独立安装的 Mearl Skill。
181
181
  目标 npm 版本尚未发布、网络不可用或更新失败时,旧 Host 会继续运行;下一次扩展后台
182
- 生命周期可重新尝试,也可手动执行 `npx @mearl/setup@beta update --local`。
182
+ 生命周期可重新尝试,也可手动执行 `npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta update --local`。
183
183
 
184
184
  ## 人工与 Agent 使用
185
185
 
@@ -194,41 +194,41 @@ DevTools 面板的设置按钮会显示红点,快捷设置中提供“立即
194
194
 
195
195
  ```bash
196
196
  # 默认安装本地浏览器环境
197
- npx @mearl/setup
197
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup
198
198
 
199
199
  # 与上一条等价
200
- npx @mearl/setup@beta install
200
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta install
201
201
 
202
202
  # Agent / CI 无交互安装
203
- npx @mearl/setup@beta --yes
203
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta --yes
204
204
 
205
205
  # 安装云端 Agent 环境
206
- npx @mearl/setup@beta --cloud
206
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta --cloud
207
207
 
208
208
  # 同一台机器同时作为本地浏览器宿主和云端 Agent
209
- npx @mearl/setup@beta --local --cloud
209
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta --local --cloud
210
210
 
211
211
  # 自动更新当前 PATH 中检测到的全部 Mearl 环境
212
- npx @mearl/setup@beta update
212
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta update
213
213
 
214
214
  # 独立安装或更新到最新浏览器扩展
215
- npx @mearl/setup@beta extension
215
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta extension
216
216
 
217
217
  # 安装完整本地环境(client、Native Host、Skill 和扩展)
218
- npx @mearl/setup@beta extension --full
218
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta extension --full
219
219
 
220
220
  # 只卸载本地环境
221
- npx @mearl/setup@beta uninstall --local
221
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta uninstall --local
222
222
 
223
223
  # 自动检测并无交互卸载
224
- npx @mearl/setup@beta uninstall --yes
224
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta uninstall --yes
225
225
 
226
226
  # 卸载环境,并清理全部 CLI 插件与 Provider
227
- npx @mearl/setup@beta uninstall --full
227
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta uninstall --full
228
228
 
229
229
  # 安装可选浏览器 Provider
230
- npx @mearl/setup@beta provider install @example/mearl-provider-device
231
- npx @mearl/setup@beta provider install @example/mearl-provider-cloud
230
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta provider install @example/mearl-provider-device
231
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup@beta provider install @example/mearl-provider-cloud
232
232
  ```
233
233
 
234
234
  原有 Mearl 环境包的 npm/npx 安装方式和 CLI 保持可用;setup 是统一的便利入口。
package/dist/cli.js CHANGED
@@ -177,7 +177,7 @@ function printHelp() {
177
177
  const output = `Mearl Setup v${SETUP_VERSION}
178
178
 
179
179
  Usage:
180
- npx @mearl/setup [command] [options]
180
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup [command] [options]
181
181
 
182
182
  Environment commands:
183
183
  install [options] Install an environment (default command; target: local)
@@ -221,15 +221,15 @@ General options:
221
221
  --help, -h Show this help
222
222
 
223
223
  Examples:
224
- npx @mearl/setup
225
- npx @mearl/setup --cloud
226
- npx @mearl/setup --local --cloud
227
- npx @mearl/setup update
228
- npx @mearl/setup uninstall --full
229
- npx @mearl/setup plugin uninstall yuque
230
- npx @mearl/setup provider install @example/mearl-provider-brow
231
- npx @mearl/setup extension --full`;
232
- console.log(output.replaceAll('npx @mearl/setup', SETUP_COMMAND));
224
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup
225
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup --cloud
226
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup --local --cloud
227
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup update
228
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup uninstall --full
229
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup plugin uninstall yuque
230
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup provider install @example/mearl-provider-brow
231
+ npx --@mearl:registry=https://registry.npmjs.org @mearl/setup extension --full`;
232
+ console.log(output.replaceAll('npx --@mearl:registry=https://registry.npmjs.org @mearl/setup', SETUP_COMMAND));
233
233
  }
234
234
  async function runSetupLifecycle(options, dependencies) {
235
235
  let preset;
package/dist/lifecycle.js CHANGED
@@ -236,7 +236,7 @@ export async function runMearlLifecycle(options, dependencies = {}) {
236
236
  writeLine('No installed Mearl environment was detected on the current PATH.');
237
237
  if (options.operation === 'uninstall')
238
238
  return true;
239
- writeLine('Run: npx @mearl/setup');
239
+ writeLine('Run: npx --@mearl:registry=https://registry.npmjs.org @mearl/setup');
240
240
  return false;
241
241
  }
242
242
  if (options.operation === 'update') {
package/dist/plugin.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readlinkSync, realpathSync, rmdirSync, statSync, } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
- import { assertPluginName, findPluginDirectoryEntry, readOfficialPluginCatalog, findPluginEntry, findPluginRegistration, listPluginNames, loadPlugin, OFFICIAL_PLUGIN_MANAGER, registerPlugin, resolvePluginEntry, unregisterPlugin, } from '@mearl/client/plugin';
4
+ import { assertPluginName, findPluginDirectoryEntry, skillManifestPath, readOfficialPluginCatalog, findPluginEntry, findPluginRegistration, listPluginNames, loadPlugin, OFFICIAL_PLUGIN_MANAGER, registerPlugin, resolvePluginEntry, unregisterPlugin, } from '@mearl/client/plugin';
5
+ import { preserveSkillDisabled } from './skillSources.js';
5
6
  import { claimPresetInstallation } from './presetInstallationLease.js';
6
7
  import { ensurePluginRuntime } from './pluginRuntime.js';
7
8
  import { captureSkillsCli, parseInstalledSkills, stripAnsi } from './skills.js';
@@ -52,8 +53,22 @@ export async function ensureOfficialPlugin(name, runner = runCommand) {
52
53
  const definition = accepted?.plugins[name];
53
54
  if (!definition)
54
55
  throw new Error(`未知官方插件: ${name}`);
55
- if (findPluginRegistration(name))
56
- return 'existing';
56
+ const existing = findPluginRegistration(name);
57
+ if (existing) {
58
+ if (!accepted.selection || existing.managedBy !== OFFICIAL_PLUGIN_MANAGER)
59
+ return 'existing';
60
+ const cli = await captureSkillsCli({ runCommand: runner });
61
+ if (!cli || cli.selection?.path !== accepted.selection.path || cli.selection.sha256 !== accepted.selection.sha256)
62
+ throw new Error('PLUGIN_CATALOG_SELECTION_CHANGED');
63
+ const declaration = cli.managedSkills[definition.skill];
64
+ if (!declaration?.source || declaration.source !== definition.installArgs[0])
65
+ throw new Error('PLUGIN_CATALOG_SKILL_UNDECLARED');
66
+ const status = cli.inspectGlobal(declaration.source, definition.skill, declaration.version, { migrateFrom: declaration.migrateFrom });
67
+ if (status.state === 'ready')
68
+ return 'existing';
69
+ if (status.state === 'conflict')
70
+ throw new Error(`${status.reason}: ${definition.skill}`);
71
+ }
57
72
  const lease = claimPresetInstallation();
58
73
  try {
59
74
  return await withOwnedCommands(lease.lifecycle, async () => {
@@ -276,13 +291,22 @@ export async function installPlugins(args, runner = runCommand, options = {}) {
276
291
  if (status.state !== 'ready' && (registered || status.state !== 'registration'))
277
292
  throw new Error(`${status.reason}: ${options.expectedSkill}`);
278
293
  };
279
- if (options.expectedSkill && declaration) {
280
- const status = cli.inspectGlobal(declaration.source, options.expectedSkill, declaration.version);
294
+ const assertSource = () => {
295
+ if (!options.expectedSkill || !declaration)
296
+ return;
297
+ const status = cli.inspectGlobal(declaration.source, options.expectedSkill, declaration.version, {
298
+ migrateFrom: options.registrationManagedBy === OFFICIAL_PLUGIN_MANAGER ? declaration.migrateFrom : undefined,
299
+ });
281
300
  if (status.state === 'conflict')
282
301
  throw new Error(`${status.reason}: ${options.expectedSkill}`);
283
- }
302
+ };
303
+ assertSource();
284
304
  await options.confirm?.();
285
- const installed = await cli.run(['add', ...args, '--global'], { tee: true });
305
+ assertSource();
306
+ const runInstall = () => cli.run(['add', ...args, '--global'], { tee: true });
307
+ const installed = await (options.expectedSkill
308
+ ? preserveSkillDisabled(join(homedir(), '.agents', 'skills', options.expectedSkill), runInstall)
309
+ : runInstall());
286
310
  if (installed.code !== 0)
287
311
  return installed.code;
288
312
  await options.confirm?.();
@@ -307,7 +331,7 @@ export async function installPlugins(args, runner = runCommand, options = {}) {
307
331
  }
308
332
  const plugins = new Map();
309
333
  for (const skillPath of paths) {
310
- if (!statSync(join(skillPath, 'SKILL.md')).isFile())
334
+ if (!statSync(skillManifestPath(skillPath) ?? join(skillPath, 'SKILL.md')).isFile())
311
335
  throw new Error(`安装结果缺少 SKILL.md: ${skillPath}`);
312
336
  const entry = findPluginDirectoryEntry(skillPath);
313
337
  if (!entry)
@@ -1,4 +1,4 @@
1
1
  import { createRequire } from 'node:module';
2
2
  const require = createRequire(import.meta.url);
3
3
  export const SETUP_VERSION = require('../package.json').version;
4
- export const SETUP_COMMAND = `npx @mearl/setup${SETUP_VERSION.includes('-beta.') ? '@' + SETUP_VERSION : ''}`;
4
+ export const SETUP_COMMAND = `npx --@mearl:registry=https://registry.npmjs.org @mearl/setup${SETUP_VERSION.includes('-beta.') ? '@' + SETUP_VERSION : ''}`;
@@ -0,0 +1,25 @@
1
+ export type SkillSourceScope = 'global' | 'project';
2
+ export interface TrackedSkillSource {
3
+ name: string;
4
+ source: string;
5
+ scope: SkillSourceScope;
6
+ ref?: string;
7
+ skillPath?: string;
8
+ sourceType?: string;
9
+ expectedHash?: string;
10
+ hashKind: 'git-tree' | 'folder';
11
+ }
12
+ export interface SourceOptions {
13
+ env?: NodeJS.ProcessEnv;
14
+ homeDirectory?: string;
15
+ }
16
+ export declare function readTrackedSkillSources(scope: SkillSourceScope, cwd?: string, options?: SourceOptions): TrackedSkillSource[];
17
+ /** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
18
+ export declare function isPublicSkillSource(source: TrackedSkillSource): boolean;
19
+ export declare function gitSource(source: string, ref?: string): {
20
+ repository: string;
21
+ ref?: string;
22
+ subdirectory?: string;
23
+ };
24
+ /** Compare the manager's existing source receipt; no network access or second lockfile. */
25
+ export declare function matchesTrackedSkillSource(requested: string, tracked: TrackedSkillSource, normalize?: (source: string, sourceType?: string) => string): boolean;
@@ -0,0 +1,94 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ function lockFile(scope, cwd, options) {
6
+ const env = options.env ?? process.env;
7
+ return scope === 'project' ? join(cwd, 'skills-lock.json') : env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json') : join(options.homeDirectory ?? homedir(), '.agents', '.skill-lock.json');
8
+ }
9
+ export function readTrackedSkillSources(scope, cwd, options = {}) {
10
+ const file = lockFile(scope, cwd, options);
11
+ let text;
12
+ try {
13
+ if (statSync(file).size > 4 * 1024 * 1024)
14
+ throw new Error('Skill source lock exceeds 4 MiB');
15
+ text = readFileSync(file, 'utf8');
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT')
19
+ return [];
20
+ throw error;
21
+ }
22
+ const lock = JSON.parse(text);
23
+ if (!lock || !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills))
24
+ throw new Error('Invalid Skills source lock');
25
+ return Object.entries(lock.skills).flatMap(([name, entry]) => {
26
+ if (!entry || typeof entry !== 'object' || entry.sourceType === 'node_modules')
27
+ return [];
28
+ if (!name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
29
+ throw new Error('Invalid tracked Skill name');
30
+ const source = typeof entry.sourceUrl === 'string' ? entry.sourceUrl : entry.source;
31
+ if (typeof source !== 'string' || !source.trim())
32
+ return [];
33
+ if (source.length > 4096 || /[\0\r\n]/.test(source))
34
+ throw new Error('Invalid tracked Skill source');
35
+ const expectedHash = scope === 'global' ? entry.skillFolderHash : entry.computedHash;
36
+ return [{ name, source, scope, ...(typeof entry.sourceType === 'string' ? { sourceType: entry.sourceType } : {}), ...(typeof entry.ref === 'string' ? { ref: entry.ref } : {}), ...(typeof entry.skillPath === 'string' ? { skillPath: entry.skillPath } : {}), ...(typeof expectedHash === 'string' && expectedHash ? { expectedHash } : {}), hashKind: scope === 'global' && typeof expectedHash === 'string' && /^[a-f0-9]{40}$/.test(expectedHash) ? 'git-tree' : 'folder' }];
37
+ });
38
+ }
39
+ /** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
40
+ export function isPublicSkillSource(source) {
41
+ return source.sourceType === undefined || ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType);
42
+ }
43
+ export function gitSource(source, ref) {
44
+ if (/^[\w.-]+\/[\w.-]+$/.test(source))
45
+ return { repository: `https://github.com/${source}.git`, ...(ref ? { ref } : {}) };
46
+ if (/^[\w.-]+@[\w.-]+:[^\s]+$/.test(source))
47
+ return { repository: source, ...(ref ? { ref } : {}) };
48
+ const url = new URL(source);
49
+ if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol) || url.password || (url.username && url.protocol !== 'ssh:') || url.search || url.hash)
50
+ throw new Error('Skill source is not a supported Git repository');
51
+ if (url.hostname === 'github.com' && !url.port) {
52
+ const parts = url.pathname.split('/').filter(Boolean);
53
+ if (parts.length >= 2)
54
+ return { repository: `https://github.com/${parts[0]}/${parts[1].replace(/\.git$/, '')}.git`, ...(ref || (['tree', 'blob'].includes(parts[2]) && parts[3]) ? { ref: ref || decodeURIComponent(parts[3]) } : {}), ...(['tree', 'blob'].includes(parts[2]) && parts.length > 4 ? { subdirectory: decodeURIComponent(parts.slice(4).join('/')).replace(/\/SKILL\.md$/, '') } : {}) };
55
+ }
56
+ return { repository: url.href, ...(ref ? { ref } : {}) };
57
+ }
58
+ /** Compare the manager's existing source receipt; no network access or second lockfile. */
59
+ export function matchesTrackedSkillSource(requested, tracked, normalize = source => source) {
60
+ const location = (source, ref, sourceType) => {
61
+ const value = normalize(source, sourceType);
62
+ if (typeof value !== 'string' || !value || value.length > 8192 || /[\0\r\n]/.test(value))
63
+ throw new Error('Invalid normalized Skill source');
64
+ const hash = value.indexOf('#');
65
+ const fragment = hash < 0 ? undefined : value.slice(hash + 1);
66
+ const plain = hash < 0 ? value : value.slice(0, hash);
67
+ const parsed = isAbsolute(plain) ? { repository: plain, ref: ref ?? fragment } : gitSource(plain, ref ?? fragment);
68
+ let repository = parsed.repository;
69
+ if (repository.startsWith('file:'))
70
+ repository = fileURLToPath(repository);
71
+ if (isAbsolute(repository))
72
+ repository = resolve(repository);
73
+ else {
74
+ const ssh = repository.match(/^[\w.-]+@([\w.-]+):(.+)$/);
75
+ const url = new URL(ssh ? `ssh://${ssh[1]}/${ssh[2]}` : repository);
76
+ // Authentication transport is independent of the repository's identity.
77
+ repository = `${url.hostname.toLowerCase()}${url.port ? ':' + url.port : ''}/${url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`;
78
+ }
79
+ return { ...parsed, repository, ref: parsed.ref || undefined };
80
+ };
81
+ try {
82
+ const expected = location(requested);
83
+ const actual = location(tracked.source, tracked.ref, tracked.sourceType);
84
+ if (expected.repository !== actual.repository || expected.ref !== actual.ref)
85
+ return false;
86
+ if (!expected.subdirectory)
87
+ return true;
88
+ const path = (tracked.skillPath ?? actual.subdirectory ?? '').replace(/(?:^|\/)SKILL\.md$/, '').replace(/\/+$/, '');
89
+ return path === expected.subdirectory || path.startsWith(expected.subdirectory + '/');
90
+ }
91
+ catch {
92
+ return normalize(requested) === normalize(tracked.source, tracked.sourceType) && !tracked.ref;
93
+ }
94
+ }
@@ -1,24 +1,7 @@
1
1
  import { type CommandRunner } from '@mearl/daemon-core';
2
- export type SkillSourceScope = 'global' | 'project';
3
- export interface TrackedSkillSource {
4
- name: string;
5
- source: string;
6
- scope: SkillSourceScope;
7
- ref?: string;
8
- skillPath?: string;
9
- sourceType?: string;
10
- expectedHash?: string;
11
- hashKind: 'git-tree' | 'folder';
12
- }
13
- interface SourceOptions {
14
- env?: NodeJS.ProcessEnv;
15
- homeDirectory?: string;
16
- }
17
- export declare function readTrackedSkillSources(scope: SkillSourceScope, cwd?: string, options?: SourceOptions): TrackedSkillSource[];
18
- /** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
19
- export declare function isPublicSkillSource(source: TrackedSkillSource): boolean;
20
- /** Compare the manager's existing source receipt; no network access or second lockfile. */
21
- export declare function matchesTrackedSkillSource(requested: string, tracked: TrackedSkillSource, normalize?: (source: string, sourceType?: string) => string): boolean;
2
+ import { readTrackedSkillSources, type SourceOptions, type SkillSourceScope, type TrackedSkillSource } from './skillSourceReceipts.js';
3
+ export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
4
+ export type { SkillSourceScope, TrackedSkillSource } from './skillSourceReceipts.js';
22
5
  /** Matches the public Skills folder hash without writing to the installed Skill. */
23
6
  export declare function hashSkillDirectory(directory: string, ignoreRuntimeFiles?: boolean): Promise<string>;
24
7
  /** Checks source content in a temporary checkout; never invokes the mutating Skills `check` alias. */
@@ -31,16 +14,18 @@ export declare function checkTrackedSkillUpdates(options?: SourceOptions & {
31
14
  scope: SkillSourceScope;
32
15
  }>>;
33
16
  export interface SkillInstallationStatus {
34
- state: 'ready' | 'missing' | 'version' | 'registration' | 'conflict';
17
+ state: 'ready' | 'missing' | 'version' | 'registration' | 'migration' | 'conflict';
35
18
  reason?: string;
36
19
  }
37
20
  /** Developer links keep their Git checkout as the source receipt without changing a manager's lock. */
38
21
  export declare function readLinkedSkillSource(directory: string, name: string): TrackedSkillSource | undefined;
22
+ /** Keep the user's disabled state when an installer replaces the Skill directory. */
23
+ export declare function preserveSkillDisabled<T>(directory: string, operation: () => Promise<T>): Promise<T>;
39
24
  /** CLI and Agent readiness use the same source, manifest and registration evidence. */
40
25
  export declare function inspectGlobalSkill(source: string, name: string, version?: string, options?: {
41
26
  homeDirectory?: string;
42
27
  readSources?: typeof readTrackedSkillSources;
43
28
  externalManager?: boolean;
29
+ migrateFrom?: readonly string[];
44
30
  normalizeSource?: (source: string, sourceType?: string) => string;
45
31
  }): SkillInstallationStatus;
46
- export {};
@@ -1,104 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdtemp, readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
3
- import { readFileSync, statSync, lstatSync, realpathSync } from 'node:fs';
3
+ import { lstatSync, realpathSync, renameSync } from 'node:fs';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { homedir, tmpdir } from 'node:os';
6
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
7
  import { runCommand } from '@mearl/daemon-core';
9
8
  import { satisfies, valid } from 'semver';
10
- import { DISABLED_SKILL_MANIFEST, findPluginDirectoryEntry, findPluginRegistration, listPluginNames, OFFICIAL_PLUGIN_MANAGER, pluginRegistrationNeedsRefresh, readInstalledSkillMetadata } from '@mearl/client/plugin';
9
+ import { DISABLED_SKILL_MANIFEST, getSkillEnabled, findPluginDirectoryEntry, findPluginRegistration, listPluginNames, OFFICIAL_PLUGIN_MANAGER, pluginRegistrationNeedsRefresh, readInstalledSkillMetadata } from '@mearl/client/plugin';
11
10
  import { existsSync } from 'node:fs';
12
- function lockFile(scope, cwd, options) {
13
- const env = options.env ?? process.env;
14
- return scope === 'project' ? join(cwd, 'skills-lock.json') : env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json') : join(options.homeDirectory ?? homedir(), '.agents', '.skill-lock.json');
15
- }
16
- export function readTrackedSkillSources(scope, cwd, options = {}) {
17
- const file = lockFile(scope, cwd, options);
18
- let text;
19
- try {
20
- if (statSync(file).size > 4 * 1024 * 1024)
21
- throw new Error('Skill source lock exceeds 4 MiB');
22
- text = readFileSync(file, 'utf8');
23
- }
24
- catch (error) {
25
- if (error.code === 'ENOENT')
26
- return [];
27
- throw error;
28
- }
29
- const lock = JSON.parse(text);
30
- if (!lock || !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills))
31
- throw new Error('Invalid Skills source lock');
32
- return Object.entries(lock.skills).flatMap(([name, entry]) => {
33
- if (!entry || typeof entry !== 'object' || entry.sourceType === 'node_modules')
34
- return [];
35
- if (!name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
36
- throw new Error('Invalid tracked Skill name');
37
- const source = typeof entry.sourceUrl === 'string' ? entry.sourceUrl : entry.source;
38
- if (typeof source !== 'string' || !source.trim())
39
- return [];
40
- if (source.length > 4096 || /[\0\r\n]/.test(source))
41
- throw new Error('Invalid tracked Skill source');
42
- const expectedHash = scope === 'global' ? entry.skillFolderHash : entry.computedHash;
43
- return [{ name, source, scope, ...(typeof entry.sourceType === 'string' ? { sourceType: entry.sourceType } : {}), ...(typeof entry.ref === 'string' ? { ref: entry.ref } : {}), ...(typeof entry.skillPath === 'string' ? { skillPath: entry.skillPath } : {}), ...(typeof expectedHash === 'string' && expectedHash ? { expectedHash } : {}), hashKind: scope === 'global' && typeof expectedHash === 'string' && /^[a-f0-9]{40}$/.test(expectedHash) ? 'git-tree' : 'folder' }];
44
- });
45
- }
46
- /** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
47
- export function isPublicSkillSource(source) {
48
- return source.sourceType === undefined || ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType);
49
- }
50
- function gitSource(source, ref) {
51
- if (/^[\w.-]+\/[\w.-]+$/.test(source))
52
- return { repository: `https://github.com/${source}.git`, ...(ref ? { ref } : {}) };
53
- if (/^[\w.-]+@[\w.-]+:[^\s]+$/.test(source))
54
- return { repository: source, ...(ref ? { ref } : {}) };
55
- const url = new URL(source);
56
- if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol) || url.password || (url.username && url.protocol !== 'ssh:') || url.search || url.hash)
57
- throw new Error('Skill source is not a supported Git repository');
58
- if (url.hostname === 'github.com' && !url.port) {
59
- const parts = url.pathname.split('/').filter(Boolean);
60
- if (parts.length >= 2)
61
- return { repository: `https://github.com/${parts[0]}/${parts[1].replace(/\.git$/, '')}.git`, ...(ref || (['tree', 'blob'].includes(parts[2]) && parts[3]) ? { ref: ref || decodeURIComponent(parts[3]) } : {}), ...(['tree', 'blob'].includes(parts[2]) && parts.length > 4 ? { subdirectory: decodeURIComponent(parts.slice(4).join('/')).replace(/\/SKILL\.md$/, '') } : {}) };
62
- }
63
- return { repository: url.href, ...(ref ? { ref } : {}) };
64
- }
65
- /** Compare the manager's existing source receipt; no network access or second lockfile. */
66
- export function matchesTrackedSkillSource(requested, tracked, normalize = source => source) {
67
- const location = (source, ref, sourceType) => {
68
- const value = normalize(source, sourceType);
69
- if (typeof value !== 'string' || !value || value.length > 8192 || /[\0\r\n]/.test(value))
70
- throw new Error('Invalid normalized Skill source');
71
- const hash = value.indexOf('#');
72
- const fragment = hash < 0 ? undefined : value.slice(hash + 1);
73
- const plain = hash < 0 ? value : value.slice(0, hash);
74
- const parsed = isAbsolute(plain) ? { repository: plain, ref: ref ?? fragment } : gitSource(plain, ref ?? fragment);
75
- let repository = parsed.repository;
76
- if (repository.startsWith('file:'))
77
- repository = fileURLToPath(repository);
78
- if (isAbsolute(repository))
79
- repository = resolve(repository);
80
- else {
81
- const ssh = repository.match(/^[\w.-]+@([\w.-]+):(.+)$/);
82
- const url = new URL(ssh ? `ssh://${ssh[1]}/${ssh[2]}` : repository);
83
- // Authentication transport is independent of the repository's identity.
84
- repository = `${url.hostname.toLowerCase()}${url.port ? ':' + url.port : ''}/${url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`;
85
- }
86
- return { ...parsed, repository, ref: parsed.ref || undefined };
87
- };
88
- try {
89
- const expected = location(requested);
90
- const actual = location(tracked.source, tracked.ref, tracked.sourceType);
91
- if (expected.repository !== actual.repository || expected.ref !== actual.ref)
92
- return false;
93
- if (!expected.subdirectory)
94
- return true;
95
- const path = (tracked.skillPath ?? actual.subdirectory ?? '').replace(/(?:^|\/)SKILL\.md$/, '').replace(/\/+$/, '');
96
- return path === expected.subdirectory || path.startsWith(expected.subdirectory + '/');
97
- }
98
- catch {
99
- return normalize(requested) === normalize(tracked.source, tracked.sourceType) && !tracked.ref;
100
- }
101
- }
11
+ import { gitSource, isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
12
+ export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
102
13
  function skillName(text) {
103
14
  return text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1].match(/^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m)?.[1]?.trim();
104
15
  }
@@ -237,6 +148,35 @@ export function readLinkedSkillSource(directory, name) {
237
148
  return undefined;
238
149
  }
239
150
  }
151
+ /** Keep the user's disabled state when an installer replaces the Skill directory. */
152
+ export async function preserveSkillDisabled(directory, operation) {
153
+ const disabled = getSkillEnabled(directory) === false;
154
+ try {
155
+ return await operation();
156
+ }
157
+ finally {
158
+ if (disabled && existsSync(join(directory, 'SKILL.md')))
159
+ renameSync(join(directory, 'SKILL.md'), join(directory, DISABLED_SKILL_MANIFEST));
160
+ }
161
+ }
162
+ function canonicalPath(path) {
163
+ const absolute = resolve(path);
164
+ try {
165
+ return realpathSync(absolute);
166
+ }
167
+ catch {
168
+ const parent = dirname(absolute);
169
+ return parent === absolute ? absolute : join(canonicalPath(parent), basename(absolute));
170
+ }
171
+ }
172
+ function hasOnlySetupRegistrations(directory) {
173
+ const contains = (root, entry) => {
174
+ const path = relative(root, entry);
175
+ return path !== '' && path !== '..' && !path.startsWith('..' + sep) && !isAbsolute(path);
176
+ };
177
+ const registrations = listPluginNames().flatMap(name => findPluginRegistration(name) ?? []).filter(registration => contains(resolve(directory), resolve(registration.entry)) || contains(canonicalPath(directory), canonicalPath(registration.entry)));
178
+ return registrations.length > 0 && registrations.every(registration => registration.managedBy === OFFICIAL_PLUGIN_MANAGER);
179
+ }
240
180
  /** CLI and Agent readiness use the same source, manifest and registration evidence. */
241
181
  export function inspectGlobalSkill(source, name, version, options = {}) {
242
182
  if (!source.trim() || !name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
@@ -244,10 +184,17 @@ export function inspectGlobalSkill(source, name, version, options = {}) {
244
184
  const directory = join(options.homeDirectory ?? homedir(), '.agents', 'skills', name);
245
185
  if (existsSync(join(directory, 'SKILL.md')) && existsSync(join(directory, DISABLED_SKILL_MANIFEST)))
246
186
  return { state: 'conflict', reason: 'SKILL_MANIFEST_CONFLICT' };
247
- const tracked = (options.readSources ?? readTrackedSkillSources)('global', undefined, { homeDirectory: options.homeDirectory }).find(entry => entry.name === name)
187
+ const receipt = (options.readSources ?? readTrackedSkillSources)('global', undefined, { homeDirectory: options.homeDirectory }).find(entry => entry.name === name);
188
+ const tracked = receipt
248
189
  ?? (!/(?:#|\/(?:tree|blob)\/)/.test(source) ? readLinkedSkillSource(directory, name) : undefined);
249
- if (tracked && ((!options.externalManager && !isPublicSkillSource(tracked)) || !matchesTrackedSkillSource(source, tracked, options.normalizeSource)))
190
+ if (tracked && ((!options.externalManager && !isPublicSkillSource(tracked)) || !matchesTrackedSkillSource(source, tracked, options.normalizeSource))) {
191
+ const metadata = readInstalledSkillMetadata(directory);
192
+ if (receipt && (options.externalManager || isPublicSkillSource(receipt)) && metadata?.name === name &&
193
+ options.migrateFrom?.some(previous => matchesTrackedSkillSource(previous, receipt, options.normalizeSource)) && hasOnlySetupRegistrations(directory)) {
194
+ return { state: 'migration', reason: 'SKILL_SOURCE_MIGRATION_REQUIRED' };
195
+ }
250
196
  return { state: 'conflict', reason: 'SKILL_SOURCE_CONFLICT' };
197
+ }
251
198
  const metadata = readInstalledSkillMetadata(directory);
252
199
  if (!metadata)
253
200
  return { state: 'missing', reason: 'SKILL_NOT_INSTALLED' };
package/dist/skills.js CHANGED
@@ -59,9 +59,11 @@ export async function captureSkillsCli(options = {}) {
59
59
  const managedSkills = Object.fromEntries(Object.entries(current.preset?.skills ?? {}).filter(([, entry]) => entry.enabled !== false));
60
60
  const backend = registry.get('skills.manager')?.();
61
61
  if (backend !== undefined) {
62
+ if (backend?.readSources !== undefined && typeof backend.readSources !== 'function')
63
+ throw new Error('Invalid Skill source reader');
62
64
  if (!backend || typeof backend.id !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(backend.id) || typeof backend.cli !== 'function')
63
65
  throw new Error('The selected Skill manager requires a preset update for CLI operations');
64
- return { id: backend.id, managedSkills, selection, inspectGlobal: (source, name, version) => inspectGlobalSkill(source, name, version, { externalManager: true, normalizeSource: backend.normalizeSource, readSources }), run: async (args, settings) => {
66
+ return { id: backend.id, managedSkills, selection, inspectGlobal: (source, name, version, inspection) => inspectGlobalSkill(source, name, version, { migrateFrom: inspection?.migrateFrom, externalManager: true, normalizeSource: backend.normalizeSource, readSources: backend.readSources ?? readSources }), run: async (args, settings) => {
65
67
  const result = await backend.cli([...args], settings);
66
68
  if (!result || !Number.isInteger(result.code) || typeof result.stdout !== 'string' || typeof result.stderr !== 'string')
67
69
  throw new Error('Invalid Skill manager CLI result');
@@ -71,7 +73,7 @@ export async function captureSkillsCli(options = {}) {
71
73
  const invocation = resolveSkillsInvocation(options);
72
74
  if (!invocation)
73
75
  return undefined;
74
- return { id: 'skills', managedSkills, selection, inspectGlobal: (source, name, version) => inspectGlobalSkill(source, name, version, { readSources }), run: (args, settings) => runner(invocation.command, [...invocation.argsPrefix, ...args], { ...settings, env: invocation.env }) };
76
+ return { id: 'skills', managedSkills, selection, inspectGlobal: (source, name, version, inspection) => inspectGlobalSkill(source, name, version, { migrateFrom: inspection?.migrateFrom, readSources }), run: (args, settings) => runner(invocation.command, [...invocation.argsPrefix, ...args], { ...settings, env: invocation.env }) };
75
77
  }
76
78
  export function parseInstalledSkills(output) {
77
79
  const value = JSON.parse(stripAnsi(output).trim());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/setup",
3
- "version": "3.0.0-beta.1",
3
+ "version": "3.0.0-beta.3",
4
4
  "description": "One-shot environment setup and configuration for Mearl",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -45,6 +45,10 @@
45
45
  "./preset": {
46
46
  "types": "./dist/preset.d.ts",
47
47
  "import": "./dist/preset.js"
48
+ },
49
+ "./skill-source-receipts": {
50
+ "types": "./dist/skillSourceReceipts.d.ts",
51
+ "import": "./dist/skillSourceReceipts.js"
48
52
  }
49
53
  },
50
54
  "bin": {
@@ -74,11 +78,11 @@
74
78
  "extract-zip": "^2.0.1",
75
79
  "ws": "^8.18.3",
76
80
  "semver": "^7.8.5",
77
- "@mearl/preset": "3.0.0-beta.1",
78
- "@mearl/daemon-core": "3.0.0-beta.1",
79
- "@mearl/client": "3.0.0-beta.1",
80
- "@mearl/provider": "3.0.0-beta.1",
81
- "@mearl/browser-core": "3.0.0-beta.1"
81
+ "@mearl/daemon-core": "3.0.0-beta.3",
82
+ "@mearl/provider": "3.0.0-beta.3",
83
+ "@mearl/browser-core": "3.0.0-beta.3",
84
+ "@mearl/client": "3.0.0-beta.3",
85
+ "@mearl/preset": "3.0.0-beta.3"
82
86
  },
83
87
  "devDependencies": {
84
88
  "@types/node": "^24.9.1",