@mearl/setup 3.0.1 → 3.0.2

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.en.md CHANGED
@@ -19,7 +19,8 @@ npx @mearl/setup plugin install <skills add arguments...>
19
19
  npx @mearl/setup plugin uninstall <name>
20
20
  npx @mearl/setup provider <install|update|uninstall> <package|id>
21
21
  npx @mearl/setup profile install <source> [--browser <id-or-name>]
22
- npx @mearl/setup preset repair [--replace-skill <name> ...]
22
+ npx @mearl/setup preset apply [source]
23
+ npx @mearl/setup preset <update|status|uninstall>
23
24
  ```
24
25
 
25
26
  Browser Providers install and evolve independently. After installation, run `mearl <providerId>` to inspect that version's capabilities,
@@ -52,14 +53,16 @@ The installation host's local plugin catalog determines preset-managed capabilit
52
53
 
53
54
  New registrations created by Native preset batches are setup-managed and retain that ownership through Agent updates. Existing user registrations in the same directory and custom user aliases retain their ownership. Managed removal encountering user aliases sharing a Skill removes only managed registrations and keeps Skill files.
54
55
 
55
- GUI and CLI share the preset installer and override rules. If a saved installation is incomplete, `preset repair` retries accepted local resources without redownloading the preset or implicitly replacing conflicting sources. Explicit repeated `--replace-skill` authorizes replacing those Skills' sources or same-name plugin registrations for this invocation:
56
+ GUI and CLI share the preset installer and override rules. `preset apply` accepts a URL or local file path. Omitting the source retries from the saved snapshot. `preset update` rereads the current source. Repeat `--replace-skill` during a retry to authorize replacing those Skill sources or same-name plugin registrations for this invocation.
56
57
 
57
58
  ```bash
58
- npx @mearl/setup preset repair --replace-skill example-skill --replace-skill another-skill
59
+ npx @mearl/setup preset apply --replace-skill example-skill --replace-skill another-skill
59
60
  ```
60
61
 
61
62
  Already installed Skills receive registration repair only; source conflicts reinstall. Unlisted conflicts still block installation, and undeclared tool names or changed installation identities are rejected. Replacement authorization does not persist into later repairs or automatic updates. Missing browser permissions still require extension authorization. Output uses preset JSON: ready exits `0`, pending components `3`, installation failure `1`, and invalid arguments `2`.
62
63
 
64
+ Run `preset uninstall` to remove the local preset and matching selections in connected extensions. Removal clears preset-managed capability packages and setup-managed registrations in the selected plugin catalog. It preserves independent capabilities, Skill files, Providers, and browser permissions. [See what preset removal covers](../../docs/preset-installation.en.md)
65
+
63
66
  Automatic registration defaults to the installation directory name; an entry can declare `name` for a shorter command.
64
67
  Reinstallation refreshes same-name bindings. JavaScript entries must default-export `register(app)`; Python entries must export a `register(app)` function. See the
65
68
  [plugin runtime contract](../../docs/plugin-runtime.en.md).
package/README.md CHANGED
@@ -19,7 +19,8 @@ npx @mearl/setup plugin install <skills add 参数...>
19
19
  npx @mearl/setup plugin uninstall <name>
20
20
  npx @mearl/setup provider <install|update|uninstall> <package|id>
21
21
  npx @mearl/setup profile install <来源> [--browser <标识或名称>]
22
- npx @mearl/setup preset repair [--replace-skill <name> ...]
22
+ npx @mearl/setup preset apply [来源]
23
+ npx @mearl/setup preset <update|status|uninstall>
23
24
  ```
24
25
 
25
26
  浏览器 Provider 独立安装和演进。安装后运行 `mearl <providerId>` 查看该版本声明的能力、
@@ -52,14 +53,16 @@ mearl tabs summary
52
53
 
53
54
  Native preset 批次新建的插件注册使用 setup 托管归属,后续通过 Agent 更新仍保留该归属;同目录已有的用户注册保持原归属,自定义用户别名也会保留。托管卸载遇到用户别名共用同一 Skill 时,只清除托管注册并保留 Skill 文件。
54
55
 
55
- GUI 和 CLI 使用同一套 preset 安装器及覆盖规则。已保存的安装尚未完成时,运行 `preset repair` 从接受的本地资源重试,不重新下载 preset,也不隐式覆盖冲突来源。显式重复指定 `--replace-skill` 即授权本次替换这些 Skill 的来源或同名旧插件注册:
56
+ GUI 和 CLI 使用同一套 preset 安装器及覆盖规则。`preset apply` 接受 URL 或本地文件路径。省略来源时使用已保存的快照重试。`preset update` 重新读取当前来源。重试时可显式重复指定 `--replace-skill`,授权本次替换这些 Skill 的来源或同名旧插件注册。
56
57
 
57
58
  ```bash
58
- npx @mearl/setup preset repair --replace-skill example-skill --replace-skill another-skill
59
+ npx @mearl/setup preset apply --replace-skill example-skill --replace-skill another-skill
59
60
  ```
60
61
 
61
62
  已安装的 Skill 仅修复注册;来源冲突项重新安装。未列出的冲突仍会阻止安装,声明外的工具名与变更后的安装身份也会被拒绝。覆盖授权不会保留到后续普通修复或自动更新。缺少浏览器权限时仍需在扩展中授权。输出沿用 preset JSON,已就绪退出 `0`、仍有待完成项退出 `3`、安装失败退出 `1`,无效参数退出 `2`。
62
63
 
64
+ 运行 `preset uninstall` 清理本机 preset 和匹配的已连接扩展。卸载会移除 preset 托管的能力包和当前目录中由 setup 托管的插件注册,保留独立安装的能力、Skill 文件、Provider 和浏览器权限。[查看卸载范围](../../docs/preset-installation.md)
65
+
63
66
  自动注册默认使用安装目录名;入口可声明 `name` 指定简短命令名。
64
67
  重装会刷新同名绑定。JavaScript 入口必须默认导出 `register(app)`,Python 入口必须导出 `register(app)` 函数;完整协议见
65
68
  [插件运行时合同](../../docs/plugin-runtime.md)。
package/dist/cli.d.ts CHANGED
@@ -16,7 +16,7 @@ interface ParsedArgs {
16
16
  operation?: MearlLifecycleOperation | 'extension' | 'plugin' | 'provider' | 'preset' | 'profile';
17
17
  profileSource?: string;
18
18
  profileBrowser?: string;
19
- presetCommand?: 'apply' | 'update' | 'status' | 'repair';
19
+ presetCommand?: Exclude<Parameters<typeof runPresetCommand>[0], 'repair'>;
20
20
  presetSource?: string;
21
21
  presetReplaceSkills?: string[];
22
22
  pluginName?: string;
package/dist/cli.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import { Command, CommanderError } from 'commander';
3
3
  import { invoke, listLiveBrowsers } from '@mearl/client/local';
4
4
  import { parseProfileSource } from '@mearl/preset/bundles';
5
- import { downloadMearlBrowserExtension, installMearlBrowserExtension, isUnsupportedCdpMethodError } from './browserExtension.js';
6
- import { runMearlLifecycle } from './lifecycle.js';
5
+ import { downloadMearlBrowserExtension, installMearlBrowserExtension, isUnsupportedCdpMethodError, } from './browserExtension.js';
6
+ import { runMearlLifecycle, } from './lifecycle.js';
7
7
  import { isMearlReleaseLine } from './version.js';
8
8
  import { ensureOfficialPlugin, installPlugins, setupPlugin, uninstallPlugin } from './plugin.js';
9
9
  import { installProviderPackage, uninstallProviderPackage, updateProviderPackage, } from './provider.js';
@@ -30,18 +30,28 @@ export function parseSetupArgs(argv) {
30
30
  if (argv[0] === 'profile') {
31
31
  if (argv.length === 2 && (argv[1] === '--help' || argv[1] === '-h'))
32
32
  return { ...baseParsedArgs(), help: true };
33
- if (argv[1] !== 'install' || !argv[2] || (argv.length !== 3 && !(argv.length === 5 && argv[3] === '--browser' && argv[4]))) {
33
+ if (argv[1] !== 'install' ||
34
+ !argv[2] ||
35
+ (argv.length !== 3 && !(argv.length === 5 && argv[3] === '--browser' && argv[4]))) {
34
36
  throw new Error('profile requires install <GitHub owner/repo[@ref] or HTTPS bundle URL> [--browser <id-or-name>]');
35
37
  }
36
38
  parseProfileSource(argv[2]);
37
- return { ...baseParsedArgs(), operation: 'profile', profileSource: argv[2], profileBrowser: argv[4] };
39
+ return {
40
+ ...baseParsedArgs(),
41
+ operation: 'profile',
42
+ profileSource: argv[2],
43
+ profileBrowser: argv[4],
44
+ };
38
45
  }
39
46
  if (argv[0] === 'preset') {
40
- if (argv[1] === 'repair') {
41
- const command = new Command('preset repair')
47
+ if (argv.length === 2 && (argv[1] === '--help' || argv[1] === '-h'))
48
+ return { ...baseParsedArgs(), help: true };
49
+ if (argv[1] === 'apply') {
50
+ const command = new Command('preset apply')
42
51
  .exitOverride()
43
52
  .configureOutput({ writeErr: () => { }, writeOut: () => { } })
44
53
  .allowExcessArguments(false)
54
+ .argument('[source]')
45
55
  .option('--replace-skill <name>', 'Replace the named Skill source or conflicting plugin registration', (name, previous) => [...previous, name], []);
46
56
  try {
47
57
  command.parse(argv.slice(2), { from: 'user' });
@@ -55,15 +65,27 @@ export function parseSetupArgs(argv) {
55
65
  throw error;
56
66
  }
57
67
  const names = command.opts().replaceSkill;
58
- if (names.length > 256 || new Set(names).size !== names.length || names.some(name => !/^[a-z][a-z0-9-]{0,79}$/.test(name)))
68
+ if (names.length > 256 ||
69
+ new Set(names).size !== names.length ||
70
+ names.some(name => !/^[a-z][a-z0-9-]{0,79}$/.test(name)))
59
71
  throw new Error('--replace-skill requires unique valid Skill names (up to 256)');
60
- return { ...baseParsedArgs(), operation: 'preset', presetCommand: 'repair', ...(names.length ? { presetReplaceSkills: names } : {}) };
72
+ const source = command.args[0];
73
+ if (source !== undefined && !source.trim())
74
+ throw new Error('Expected a preset source URL or path');
75
+ if (source !== undefined && names.length)
76
+ throw new Error('--replace-skill requires preset apply without a source');
77
+ return {
78
+ ...baseParsedArgs(),
79
+ operation: 'preset',
80
+ presetCommand: 'apply',
81
+ presetSource: source,
82
+ ...(names.length ? { presetReplaceSkills: names } : {}),
83
+ };
61
84
  }
62
- if (argv[1] === 'apply' && argv.length === 3 && argv[2])
63
- return { ...baseParsedArgs(), operation: 'preset', presetCommand: 'apply', presetSource: argv[2] };
64
- if ((argv[1] === 'update' || argv[1] === 'status') && argv.length === 2)
85
+ if ((argv[1] === 'update' || argv[1] === 'status' || argv[1] === 'uninstall') &&
86
+ argv.length === 2)
65
87
  return { ...baseParsedArgs(), operation: 'preset', presetCommand: argv[1] };
66
- throw new Error('preset requires apply <source-url-or-path>, update, repair [--replace-skill <name>], or status');
88
+ throw new Error('preset requires apply [source-url-or-path], update, status, or uninstall');
67
89
  }
68
90
  if (argv[0] === 'provider') {
69
91
  const command = argv[1];
@@ -223,10 +245,10 @@ Environment commands:
223
245
 
224
246
  Integration commands:
225
247
  profile install <source> Install one App profile bundle in a connected extension
226
- preset apply <source> Apply a preset, synchronize hosts and install declared components
248
+ preset apply [source] Apply a URL or local file, or retry the saved snapshot
227
249
  preset update Update the selected preset, preserving user overrides
228
- preset repair Retry the installed preset using its accepted resources
229
250
  preset status Report actual readiness (exit 3 when synchronization is pending)
251
+ preset uninstall Remove the preset from the local host and connected extensions
230
252
  plugin install <arguments...> Install Skills and register their CLI plugins
231
253
  plugin uninstall <name> Uninstall one CLI plugin
232
254
  plugin [name] <path> Register an existing plugin; name overrides metadata
@@ -254,7 +276,7 @@ Extension options:
254
276
  --full Install the extension and complete local environment
255
277
  --force Replace an extension managed by another source
256
278
 
257
- Preset repair options:
279
+ Preset apply options (without a source):
258
280
  --replace-skill <name> Replace one Skill source or conflicting registration; repeat for more
259
281
 
260
282
  General options:
@@ -271,13 +293,19 @@ Examples:
271
293
  npx @mearl/setup uninstall --full
272
294
  npx @mearl/setup plugin uninstall tabs
273
295
  npx @mearl/setup provider install @example/mearl-provider-device
274
- npx @mearl/setup preset repair --replace-skill example-skill
296
+ npx @mearl/setup preset apply --replace-skill example-skill
275
297
  npx @mearl/setup extension --full`;
276
298
  console.log(output.replaceAll('npx @mearl/setup', SETUP_COMMAND));
277
299
  }
278
300
  async function runSetupLifecycle(options, dependencies) {
279
301
  let preset;
280
- const callback = options.operation === 'uninstall' ? undefined : { onPresetStatus: (status) => { preset = status; } };
302
+ const callback = options.operation === 'uninstall'
303
+ ? undefined
304
+ : {
305
+ onPresetStatus: (status) => {
306
+ preset = status;
307
+ },
308
+ };
281
309
  const lifecycle = dependencies.runLifecycle ?? runMearlLifecycle;
282
310
  const defaultPresetSource = process.env.MEARL_SETUP_DEFAULT_PRESET;
283
311
  const configured = defaultPresetSource ? { ...options, defaultPresetSource } : options;
@@ -298,7 +326,8 @@ async function runSetupLifecycle(options, dependencies) {
298
326
  }
299
327
  async function installProfile(source, browser) {
300
328
  if (!browser) {
301
- const extensions = listLiveBrowsers().filter(target => target.transport === 'extension' && (target.status === undefined || target.status === 'connected'));
329
+ const extensions = listLiveBrowsers().filter(target => target.transport === 'extension' &&
330
+ (target.status === undefined || target.status === 'connected'));
302
331
  if (extensions.length !== 1)
303
332
  throw new Error(extensions.length
304
333
  ? 'Multiple extensions are connected. Select one with --browser <id-or-name>.'
@@ -341,7 +370,9 @@ export async function main(argv = process.argv.slice(2), interactiveTerminal = p
341
370
  try {
342
371
  const run = dependencies.runPreset ?? runPresetCommand;
343
372
  const result = parsed.presetReplaceSkills
344
- ? await run(parsed.presetCommand, parsed.presetSource, { replaceSkills: parsed.presetReplaceSkills })
373
+ ? await run(parsed.presetCommand, parsed.presetSource, {
374
+ replaceSkills: parsed.presetReplaceSkills,
375
+ })
345
376
  : await run(parsed.presetCommand, parsed.presetSource);
346
377
  console.log(JSON.stringify(result, null, 2));
347
378
  return result.ready ? 0 : 3;
package/dist/preset.d.ts CHANGED
@@ -93,16 +93,21 @@ export declare class PresetInstaller {
93
93
  private stageSource;
94
94
  /** Save exact resources and desired selection; activate once after runtime setup, then retry via resume. */
95
95
  stage(source: string, options?: PresetApplyOptions): Promise<StagedPresetInstallation>;
96
- apply(source: string, options?: PresetApplyOptions): Promise<PresetInstallStatus>;
96
+ apply(source?: string, options?: PresetApplyOptions & {
97
+ replaceSkills?: readonly string[];
98
+ }): Promise<PresetInstallStatus>;
97
99
  update(options?: Pick<PresetApplyOptions, 'validatePrepared' | 'skipUnchanged' | 'signal'>): Promise<PresetInstallStatus>;
98
100
  resume(expectedRevision: string, options?: {
99
101
  replaceSkills?: readonly string[];
100
102
  }): Promise<PresetInstallStatus>;
103
+ private resumeSelected;
104
+ /** @deprecated Use apply() without a source. */
101
105
  repair(options?: {
102
106
  replaceSkills?: readonly string[];
103
107
  }): Promise<PresetInstallStatus>;
108
+ uninstall(): Promise<PresetInstallStatus>;
104
109
  configure(value: JsonObject): Promise<PresetInstallStatus>;
105
110
  }
106
- export declare function runPresetCommand(command: 'apply' | 'update' | 'status' | 'repair', source?: string, options?: {
111
+ export declare function runPresetCommand(command: 'apply' | 'update' | 'status' | 'uninstall' | 'repair', source?: string, options?: {
107
112
  replaceSkills?: readonly string[];
108
113
  }): Promise<PresetInstallStatus>;
package/dist/preset.js CHANGED
@@ -4,15 +4,24 @@ import { createRequire } from 'node:module';
4
4
  import { resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { invoke, invokeLocalHost, listLiveBrowsers, SOCKET_PATH } from '@mearl/client/local';
7
+ import { uninstallOfficialPluginCatalog } from '@mearl/client/plugin';
7
8
  import { mergePresetDefaults } from '@mearl/preset';
8
- import { BundleError, createBundleFetchLoader, preparePreset } from '@mearl/preset/bundles';
9
+ import { BundleError, createBundleFetchLoader, preparePreset, } from '@mearl/preset/bundles';
9
10
  import { PresetInstallationStore, presetSelectionRevision } from './presetStore.js';
11
+ import { claimPresetInstallation } from './presetInstallationLease.js';
10
12
  const installationSite = createRequire(import.meta.url)('@mearl/preset/installation-site.json');
11
- export const MEARL_INSTALLATION_CONFIG_URL = new URL('installation.json', installationSite.url).href;
13
+ export const MEARL_INSTALLATION_CONFIG_URL = new URL('installation.json', installationSite.url)
14
+ .href;
12
15
  /** Runtime overrides apply to this local process, never to browser-supplied install requests. */
13
16
  export function resolveInstallationConfigUrl(value = process.env.MEARL_INSTALLATION_CONFIG_URL, allowLoopback = false) {
14
17
  const url = new URL(value || MEARL_INSTALLATION_CONFIG_URL);
15
- if (url.username || url.password || url.hash || (url.protocol !== 'https:' && !(allowLoopback && url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))))
18
+ if (url.username ||
19
+ url.password ||
20
+ url.hash ||
21
+ (url.protocol !== 'https:' &&
22
+ !(allowLoopback &&
23
+ url.protocol === 'http:' &&
24
+ ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))))
16
25
  throw new Error('Invalid installation configuration URL');
17
26
  return url;
18
27
  }
@@ -52,7 +61,8 @@ export function createNodePresetLoader() {
52
61
  await handle.close();
53
62
  }
54
63
  }
55
- if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
64
+ if (url.protocol !== 'https:' &&
65
+ !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
56
66
  throw new BundleError('BUNDLE_INVALID', 'Preset sources require HTTPS, local files or loopback HTTP');
57
67
  }
58
68
  return network(source, options);
@@ -68,12 +78,20 @@ function sourceUrl(source) {
68
78
  }
69
79
  function localHosts(allowStart = false) {
70
80
  const configured = process.env.MEARL_SOCKET_PATH?.trim();
71
- const hosts = configured ? [{ id: 'local', socketPath: configured }] : listLiveBrowsers().filter(browser => !browser.provider).map(browser => ({ id: browser.browserId, socketPath: browser.socketPath }));
72
- return hosts.length ? Array.from(new Map(hosts.map(host => [host.socketPath, host])).values()) : [{ id: 'local', ...(allowStart ? {} : { socketPath: SOCKET_PATH }) }];
81
+ const hosts = configured
82
+ ? [{ id: 'local', socketPath: configured }]
83
+ : listLiveBrowsers()
84
+ .filter(browser => !browser.provider)
85
+ .map(browser => ({ id: browser.browserId, socketPath: browser.socketPath }));
86
+ return hosts.length
87
+ ? Array.from(new Map(hosts.map(host => [host.socketPath, host])).values())
88
+ : [{ id: 'local', ...(allowStart ? {} : { socketPath: SOCKET_PATH }) }];
73
89
  }
74
90
  export function connectedPresetExtensions(browsers, socketPath) {
75
- return browsers.filter(browser => !browser.provider && browser.transport === 'extension' &&
76
- (browser.status === undefined || browser.status === 'connected') && (!socketPath || browser.socketPath === socketPath));
91
+ return browsers.filter(browser => !browser.provider &&
92
+ browser.transport === 'extension' &&
93
+ (browser.status === undefined || browser.status === 'connected') &&
94
+ (!socketPath || browser.socketPath === socketPath));
77
95
  }
78
96
  /** Installation and live acknowledgements are distinct; browser/external installs remain pending. */
79
97
  export class PresetInstaller {
@@ -86,16 +104,30 @@ export class PresetInstaller {
86
104
  invokeHost(action, data, target, timeoutSec, allowStart = false) {
87
105
  if (!this.options.invoke && !target.socketPath && !allowStart)
88
106
  return Promise.reject(new Error('HOST_UNAVAILABLE'));
89
- return (this.options.invoke ?? (target.socketPath ? invoke : invokeLocalHost))(action, data, { socketPath: target.socketPath, timeoutSec });
107
+ return (this.options.invoke ?? (target.socketPath ? invoke : invokeLocalHost))(action, data, {
108
+ socketPath: target.socketPath,
109
+ timeoutSec,
110
+ });
90
111
  }
91
- async hostStatus(reload, nodeRevision, installationRevision) {
92
- const targets = this.options.hosts?.() ?? localHosts(reload);
112
+ async hostStatus(reload, nodeRevision, installationRevision, targets = this.options.hosts?.() ?? localHosts(reload)) {
93
113
  return Promise.all(targets.map(async (target) => {
94
114
  try {
95
115
  const result = await this.invokeHost(reload ? 'node_capabilities_reload' : 'node_capabilities_status', reload ? { structured: true } : {}, target, 15, reload);
96
116
  if (reload && result?.accepted === false)
97
- return { id: target.id, state: 'rejected', error: typeof result.error === 'string' ? result.error : 'Node capability activation was rejected' };
98
- return { id: target.id, state: result?.revision === nodeRevision && result?.installationRevision === installationRevision ? 'active' : 'pending' };
117
+ return {
118
+ id: target.id,
119
+ state: 'rejected',
120
+ error: typeof result.error === 'string'
121
+ ? result.error
122
+ : 'Node capability activation was rejected',
123
+ };
124
+ return {
125
+ id: target.id,
126
+ state: result?.revision === nodeRevision &&
127
+ result?.installationRevision === installationRevision
128
+ ? 'active'
129
+ : 'pending',
130
+ };
99
131
  }
100
132
  catch {
101
133
  return { id: target.id, state: 'pending', error: 'HOST_UNAVAILABLE' };
@@ -105,19 +137,42 @@ export class PresetInstaller {
105
137
  async browserStatus(reload, browserRevision, installationRevision) {
106
138
  const targets = this.options.connectedExtensionsOnly
107
139
  ? connectedPresetExtensions(listLiveBrowsers(), process.env.MEARL_SOCKET_PATH?.trim()).map(browser => ({ id: browser.browserId, socketPath: browser.socketPath }))
108
- : this.options.hosts?.() ?? localHosts(reload);
140
+ : (this.options.hosts?.() ?? localHosts(reload));
109
141
  return Promise.all(targets.map(async (target) => {
110
142
  try {
111
143
  const result = await this.invokeHost(reload ? 'preset_browser_sync' : 'preset_browser_status', reload ? { installationRevision } : {}, target, reload ? 130 : 15, reload);
112
144
  if (result?.accepted === false)
113
- return { id: target.id, state: 'rejected', settingsReady: false, error: typeof result.error === 'string' ? result.error : 'Browser preset activation rejected' };
145
+ return {
146
+ id: target.id,
147
+ state: 'rejected',
148
+ settingsReady: false,
149
+ error: typeof result.error === 'string'
150
+ ? result.error
151
+ : 'Browser preset activation rejected',
152
+ };
114
153
  const error = Array.isArray(result?.permissionPending) && result.permissionPending.length
115
154
  ? 'PRESET_DOMAIN_PERMISSION_REQUIRED: Open extension settings and authorize preset domains'
116
- : typeof result?.error === 'string' ? result.error : undefined;
117
- return { id: target.id, state: result?.ready === true && result?.browserRevision === browserRevision && result?.installationRevision === installationRevision ? 'active' : 'pending', settingsReady: result?.settingsReady === true, ...(error ? { error } : {}) };
155
+ : typeof result?.error === 'string'
156
+ ? result.error
157
+ : undefined;
158
+ return {
159
+ id: target.id,
160
+ state: result?.ready === true &&
161
+ result?.browserRevision === browserRevision &&
162
+ result?.installationRevision === installationRevision
163
+ ? 'active'
164
+ : 'pending',
165
+ settingsReady: result?.settingsReady === true,
166
+ ...(error ? { error } : {}),
167
+ };
118
168
  }
119
169
  catch {
120
- return { id: target.id, state: 'pending', settingsReady: false, error: 'BROWSER_UNAVAILABLE' };
170
+ return {
171
+ id: target.id,
172
+ state: 'pending',
173
+ settingsReady: false,
174
+ error: 'BROWSER_UNAVAILABLE',
175
+ };
121
176
  }
122
177
  }));
123
178
  }
@@ -126,14 +181,23 @@ export class PresetInstaller {
126
181
  if (!target)
127
182
  return {};
128
183
  try {
129
- const result = await this.invokeHost(install ? 'preset_components_install' : 'preset_components_status', { installationRevision: receipt.revision, ...(install && replaceSkills?.length ? { replaceSkills } : {}) }, target, install ? 900 : 15, install);
130
- if (result?.installationRevision !== receipt.revision || result?.nodeRevision !== receipt.nodeRevision || !Array.isArray(result?.components))
184
+ const result = await this.invokeHost(install ? 'preset_components_install' : 'preset_components_status', {
185
+ installationRevision: receipt.revision,
186
+ ...(install && replaceSkills?.length ? { replaceSkills } : {}),
187
+ }, target, install ? 900 : 15, install);
188
+ if (result?.installationRevision !== receipt.revision ||
189
+ result?.nodeRevision !== receipt.nodeRevision ||
190
+ !Array.isArray(result?.components))
131
191
  throw new Error('Component installation host requires an update');
132
192
  if (result.catalogReady !== undefined && typeof result.catalogReady !== 'boolean')
133
193
  throw new Error('Invalid plugin catalog readiness');
134
194
  const seen = new Set();
135
195
  for (const entry of result.components) {
136
- if (!entry || !['skills', 'providers'].includes(entry.kind) || typeof entry.id !== 'string' || !['ready', 'pending', 'conflict'].includes(entry.state) || (entry.reason !== undefined && typeof entry.reason !== 'string'))
196
+ if (!entry ||
197
+ !['skills', 'providers'].includes(entry.kind) ||
198
+ typeof entry.id !== 'string' ||
199
+ !['ready', 'pending', 'conflict'].includes(entry.state) ||
200
+ (entry.reason !== undefined && typeof entry.reason !== 'string'))
137
201
  throw new Error('Invalid component installation status');
138
202
  const key = `${entry.kind}:${entry.id}`;
139
203
  if (seen.has(key))
@@ -148,7 +212,14 @@ export class PresetInstaller {
148
212
  }
149
213
  async status() {
150
214
  const current = await this.store.current();
151
- const result = { revision: presetSelectionRevision(current), ready: false, hosts: [], browsers: [], pending: [], warnings: [] };
215
+ const result = {
216
+ revision: presetSelectionRevision(current),
217
+ ready: false,
218
+ hosts: [],
219
+ browsers: [],
220
+ pending: [],
221
+ warnings: [],
222
+ };
152
223
  if (!current.receipt || !current.prepared)
153
224
  return result;
154
225
  const { preset } = current.prepared;
@@ -161,8 +232,9 @@ export class PresetInstaller {
161
232
  result.browserRevision = receipt.browserRevision;
162
233
  const browserSyncRequired = receipt.browserSyncRequired === true && !this.options.connectedExtensionsOnly;
163
234
  result.browserSyncRequired = browserSyncRequired;
164
- result.localComponentCount = Object.values(preset.packages).filter(entry => entry.enabled !== false && entry.targets?.includes('node')).length
165
- + [...Object.values(preset.skills), ...Object.values(preset.providers)].filter(entry => entry.enabled !== false).length;
235
+ result.localComponentCount =
236
+ Object.values(preset.packages).filter(entry => entry.enabled !== false && entry.targets?.includes('node')).length +
237
+ [...Object.values(preset.skills), ...Object.values(preset.providers)].filter(entry => entry.enabled !== false).length;
166
238
  result.hosts = await this.hostStatus(false, receipt.nodeRevision, receipt.revision);
167
239
  const nodeReady = result.hosts.length > 0 && result.hosts.every(host => host.state === 'active');
168
240
  if (receipt.browserSyncRequired)
@@ -176,7 +248,9 @@ export class PresetInstaller {
176
248
  if (browserSyncRequired && !browserReady)
177
249
  result.pending.push({ kind: 'packages', id, runtime, reason: 'BROWSER_SYNC_REQUIRED' });
178
250
  }
179
- else if (!receipt.managedModules.includes(id) && Object.keys(current.prepared.bundles.find(bundle => bundle.manifest.id === id)?.manifest.contributions?.nodeRuntimes ?? {}).length)
251
+ else if (!receipt.managedModules.includes(id) &&
252
+ Object.keys(current.prepared.bundles.find(bundle => bundle.manifest.id === id)?.manifest
253
+ .contributions?.nodeRuntimes ?? {}).length)
180
254
  result.pending.push({ kind: 'packages', id, runtime, reason: 'NODE_HOST_NOT_SUPPORTED' });
181
255
  else if (!nodeReady)
182
256
  result.pending.push({ kind: 'packages', id, runtime, reason: 'HOST_SYNC_REQUIRED' });
@@ -186,7 +260,12 @@ export class PresetInstaller {
186
260
  const hasCatalog = Boolean(current.activation?.bindings['plugins.catalog']);
187
261
  const components = hasExternal || hasCatalog ? await this.componentStatus(false, receipt) : {};
188
262
  if (hasCatalog && components.status?.catalogReady !== true)
189
- result.pending.push({ kind: 'preset', id: preset.id, runtime: 'node', reason: 'PLUGIN_CATALOG_SYNC_REQUIRED' });
263
+ result.pending.push({
264
+ kind: 'preset',
265
+ id: preset.id,
266
+ runtime: 'node',
267
+ reason: 'PLUGIN_CATALOG_SYNC_REQUIRED',
268
+ });
190
269
  if (components.error)
191
270
  result.warnings.push(`Component readiness check failed: ${components.error}`);
192
271
  for (const kind of ['skills', 'providers'])
@@ -195,14 +274,41 @@ export class PresetInstaller {
195
274
  continue;
196
275
  const actual = components.status?.components.find(component => component.kind === kind && component.id === id);
197
276
  if (actual?.state !== 'ready')
198
- result.pending.push({ kind, id, runtime: 'node', reason: actual?.reason ?? 'INSTALL_REQUIRED' });
277
+ result.pending.push({
278
+ kind,
279
+ id,
280
+ runtime: 'node',
281
+ reason: actual?.reason ?? 'INSTALL_REQUIRED',
282
+ });
199
283
  }
200
- if (receipt.nodeSyncRequired && !nodeReady && !result.pending.some(entry => entry.reason === 'HOST_SYNC_REQUIRED'))
201
- result.pending.push({ kind: 'preset', id: preset.id, runtime: 'node', reason: 'HOST_SYNC_REQUIRED' });
202
- if (browserSyncRequired && !browserReady && !result.pending.some(entry => entry.reason === 'BROWSER_SYNC_REQUIRED'))
203
- result.pending.push({ kind: 'preset', id: preset.id, runtime: 'browser', reason: 'BROWSER_SYNC_REQUIRED' });
204
- if (browserSyncRequired && browserReady && Object.keys(result.settings ?? {}).length && !result.browsers.every(host => host.settingsReady))
205
- result.pending.push({ kind: 'preset', id: preset.id, runtime: 'browser', reason: 'SETTINGS_APPLY_REQUIRED' });
284
+ if (receipt.nodeSyncRequired &&
285
+ !nodeReady &&
286
+ !result.pending.some(entry => entry.reason === 'HOST_SYNC_REQUIRED'))
287
+ result.pending.push({
288
+ kind: 'preset',
289
+ id: preset.id,
290
+ runtime: 'node',
291
+ reason: 'HOST_SYNC_REQUIRED',
292
+ });
293
+ if (browserSyncRequired &&
294
+ !browserReady &&
295
+ !result.pending.some(entry => entry.reason === 'BROWSER_SYNC_REQUIRED'))
296
+ result.pending.push({
297
+ kind: 'preset',
298
+ id: preset.id,
299
+ runtime: 'browser',
300
+ reason: 'BROWSER_SYNC_REQUIRED',
301
+ });
302
+ if (browserSyncRequired &&
303
+ browserReady &&
304
+ Object.keys(result.settings ?? {}).length &&
305
+ !result.browsers.every(host => host.settingsReady))
306
+ result.pending.push({
307
+ kind: 'preset',
308
+ id: preset.id,
309
+ runtime: 'browser',
310
+ reason: 'SETTINGS_APPLY_REQUIRED',
311
+ });
206
312
  result.ready = result.pending.length === 0;
207
313
  return result;
208
314
  }
@@ -217,15 +323,22 @@ export class PresetInstaller {
217
323
  await this.hostStatus(true, previous.activation?.revision ?? '', previous.activation?.installationRevision ?? '');
218
324
  throw new Error(`Node activation rejected; previous selection restored: ${rejection.error}`);
219
325
  }
220
- const browsers = receipt.browserSyncRequired ? await this.browserStatus(true, receipt.browserRevision, receipt.revision) : [];
326
+ const browsers = receipt.browserSyncRequired
327
+ ? await this.browserStatus(true, receipt.browserRevision, receipt.revision)
328
+ : [];
221
329
  const rejected = browsers.find(host => host.state === 'rejected');
222
330
  if (rejected && !this.options.connectedExtensionsOnly)
223
331
  throw new Error(`Browser activation rejected; installed selection retained for retry: ${rejected.error}`);
224
332
  const installed = await this.store.current({ verifyResources: false });
225
333
  if (installed.receipt?.revision !== receipt.revision || !installed.preset)
226
334
  throw new BundleError('BUNDLE_CONFLICT', 'The selected preset changed before component installation');
227
- const hasExternal = [...Object.values(installed.preset.skills), ...Object.values(installed.preset.providers)].some(entry => entry.enabled !== false);
228
- const components = hasExternal || installed.activation?.bindings['plugins.catalog'] ? await this.componentStatus(true, receipt, replaceSkills) : {};
335
+ const hasExternal = [
336
+ ...Object.values(installed.preset.skills),
337
+ ...Object.values(installed.preset.providers),
338
+ ].some(entry => entry.enabled !== false);
339
+ const components = hasExternal || installed.activation?.bindings['plugins.catalog']
340
+ ? await this.componentStatus(true, receipt, replaceSkills)
341
+ : {};
229
342
  const status = await this.status();
230
343
  if (rejected)
231
344
  status.warnings.push(`Browser preset activation rejected by ${rejected.id}: ${rejected.error}`);
@@ -242,7 +355,11 @@ export class PresetInstaller {
242
355
  return status;
243
356
  }
244
357
  async stageSource(source, expectedRevision, options) {
245
- const prepared = await preparePreset(sourceUrl(source), { mearlApiVersion: this.store.mearlApiVersion, load: this.options.load ?? createNodePresetLoader(), signal: options.signal });
358
+ const prepared = await preparePreset(sourceUrl(source), {
359
+ mearlApiVersion: this.store.mearlApiVersion,
360
+ load: this.options.load ?? createNodePresetLoader(),
361
+ signal: options.signal,
362
+ });
246
363
  if (options.expectedId !== undefined && prepared.preset.id !== options.expectedId)
247
364
  throw new BundleError('BUNDLE_INVALID', 'The preset identity differs from the accepted installation');
248
365
  await options.validatePrepared?.(prepared);
@@ -251,17 +368,22 @@ export class PresetInstaller {
251
368
  if (presetSelectionRevision(current) !== expectedRevision)
252
369
  throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed during update');
253
370
  if (current.receipt && current.prepared?.snapshot.revision === prepared.snapshot.revision) {
254
- return { revision: current.receipt.revision, activate: async () => {
371
+ return {
372
+ revision: current.receipt.revision,
373
+ activate: async () => {
255
374
  const status = await this.status();
256
375
  if (status.revision !== expectedRevision)
257
376
  throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed during update');
258
377
  return status.ready ? status : this.resume(current.receipt.revision);
259
- } };
378
+ },
379
+ };
260
380
  }
261
381
  }
262
382
  const receipt = await this.store.apply(prepared, { ...options, expectedRevision });
263
383
  let activated = false;
264
- return { revision: receipt.revision, activate: async () => {
384
+ return {
385
+ revision: receipt.revision,
386
+ activate: async () => {
265
387
  if (activated)
266
388
  throw new Error('Staged installation was already activated; use resume with its revision to retry');
267
389
  activated = true;
@@ -269,11 +391,17 @@ export class PresetInstaller {
269
391
  if (current.receipt?.revision !== receipt.revision)
270
392
  throw new BundleError('BUNDLE_CONFLICT', 'The selected preset changed before runtime setup completed');
271
393
  return this.synchronize(current.receipt, expectedRevision);
272
- } };
394
+ },
395
+ };
273
396
  }
274
397
  /** Save exact resources and desired selection; activate once after runtime setup, then retry via resume. */
275
398
  async stage(source, options = {}) {
276
- const accepted = { ...options, ...(options.overrides === undefined ? {} : { overrides: mergePresetDefaults({}, options.overrides) }) };
399
+ const accepted = {
400
+ ...options,
401
+ ...(options.overrides === undefined
402
+ ? {}
403
+ : { overrides: mergePresetDefaults({}, options.overrides) }),
404
+ };
277
405
  const current = await this.store.current({ verifyResources: false });
278
406
  const revision = presetSelectionRevision(current);
279
407
  if (accepted.expectedRevision !== undefined && accepted.expectedRevision !== revision)
@@ -281,42 +409,161 @@ export class PresetInstaller {
281
409
  return this.stageSource(source, revision, accepted);
282
410
  }
283
411
  async apply(source, options = {}) {
412
+ if (source === undefined)
413
+ return this.resumeSelected(options);
414
+ if (options.replaceSkills !== undefined)
415
+ throw new Error('Skill replacements require preset apply without a source');
284
416
  return (await this.stage(source, options)).activate();
285
417
  }
286
418
  async update(options = {}) {
287
419
  const current = await this.store.current({ verifyResources: false });
288
420
  if (!current.receipt)
289
421
  throw new Error('No preset is installed');
290
- return this.apply(current.receipt.snapshot.source, { ...options, expectedRevision: presetSelectionRevision(current) });
422
+ return this.apply(current.receipt.snapshot.source, {
423
+ ...options,
424
+ expectedRevision: presetSelectionRevision(current),
425
+ });
291
426
  }
292
427
  async resume(expectedRevision, options = {}) {
293
428
  const current = await this.store.current();
294
429
  if (!current.receipt || current.receipt.revision !== expectedRevision)
295
430
  throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed before resuming');
296
431
  const names = options.replaceSkills;
297
- if (names !== undefined && (!Array.isArray(names) || !names.length || names.length > 256 || new Set(names).size !== names.length || names.some(name => typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,79}$/.test(name) || !current.preset || !Object.hasOwn(current.preset.skills, name) || current.preset.skills[name].enabled === false)))
432
+ if (names !== undefined &&
433
+ (!Array.isArray(names) ||
434
+ !names.length ||
435
+ names.length > 256 ||
436
+ new Set(names).size !== names.length ||
437
+ names.some(name => typeof name !== 'string' ||
438
+ !/^[a-z][a-z0-9-]{0,79}$/.test(name) ||
439
+ !current.preset ||
440
+ !Object.hasOwn(current.preset.skills, name) ||
441
+ current.preset.skills[name].enabled === false)))
298
442
  throw new Error('Invalid Skill replacement selection');
299
443
  return this.synchronize(current.receipt, expectedRevision, options.replaceSkills);
300
444
  }
301
- async repair(options = {}) {
445
+ async resumeSelected(options) {
302
446
  const current = await this.store.current({ verifyResources: false });
303
447
  if (!current.receipt)
304
448
  throw new Error('No preset is installed');
305
449
  return this.resume(current.receipt.revision, options);
306
450
  }
451
+ /** @deprecated Use apply() without a source. */
452
+ async repair(options = {}) {
453
+ return this.resumeSelected(options);
454
+ }
455
+ async uninstall() {
456
+ const current = await this.store.current({ verifyResources: false });
457
+ const { receipt, preset } = current;
458
+ const configured = process.env.MEARL_SOCKET_PATH?.trim();
459
+ const live = this.options.hosts
460
+ ? []
461
+ : listLiveBrowsers().filter(browser => !browser.provider && (!configured || browser.socketPath === configured));
462
+ const explicitHosts = this.options
463
+ .hosts?.()
464
+ .filter(target => !configured || target.socketPath === configured);
465
+ const browserTargets = explicitHosts ??
466
+ connectedPresetExtensions(live).map(browser => ({
467
+ id: browser.browserId,
468
+ socketPath: browser.socketPath,
469
+ }));
470
+ if (configured && !browserTargets.length)
471
+ throw new Error('MEARL_SOCKET_PATH does not identify a connected extension.');
472
+ const selections = (await Promise.all(browserTargets.map(async (target) => {
473
+ const status = await this.invokeHost('preset_browser_status', {}, target, 15);
474
+ if (status?.browserRevision === null && !status.error)
475
+ return;
476
+ if (typeof status?.browserRevision !== 'string')
477
+ throw new Error(`Cannot read the preset in ${target.id}. Reconnect the extension and retry.`);
478
+ if (receipt &&
479
+ status.installationRevision !== receipt.revision &&
480
+ (status.presetId !== preset?.id || status.presetSource !== receipt.snapshot.source))
481
+ return;
482
+ if (!receipt && !configured)
483
+ throw new Error('Set MEARL_SOCKET_PATH to remove a browser preset without a local selection.');
484
+ if (status.uninstallSupported !== true || status.installationToken === undefined) {
485
+ throw new Error(`Update the Native Host and extension for ${target.id} before removing its preset.`);
486
+ }
487
+ return { target, status };
488
+ }))).filter(selection => selection !== undefined);
489
+ const result = {
490
+ revision: null,
491
+ ready: true,
492
+ hosts: [],
493
+ browsers: [],
494
+ pending: [],
495
+ warnings: [],
496
+ };
497
+ let remaining = current.activation;
498
+ if (receipt) {
499
+ const lease = claimPresetInstallation();
500
+ try {
501
+ remaining = await this.store.uninstall(receipt.revision, uninstallOfficialPluginCatalog);
502
+ }
503
+ finally {
504
+ lease.release();
505
+ }
506
+ try {
507
+ await this.store.collectGarbage();
508
+ }
509
+ catch (error) {
510
+ result.warnings.push(`Preset resource cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
511
+ }
512
+ }
513
+ const hosts = explicitHosts ?? [
514
+ ...new Map(live.map(browser => [
515
+ browser.socketPath,
516
+ { id: browser.browserId, socketPath: browser.socketPath },
517
+ ])).values(),
518
+ ];
519
+ result.hosts = await this.hostStatus(true, remaining?.revision ?? null, null, hosts);
520
+ result.browsers = await Promise.all(selections.map(async ({ target, status }) => {
521
+ try {
522
+ const removed = await this.invokeHost('preset_browser_uninstall', {
523
+ browserRevision: status.browserRevision,
524
+ installationToken: status.installationToken,
525
+ }, target, 130);
526
+ if (removed?.accepted !== true || removed.browserRevision !== null)
527
+ throw new Error(removed?.error ?? 'Browser preset removal was not acknowledged');
528
+ return { id: target.id, state: 'active', settingsReady: false };
529
+ }
530
+ catch (error) {
531
+ return {
532
+ id: target.id,
533
+ state: 'pending',
534
+ settingsReady: false,
535
+ error: error instanceof Error ? error.message : String(error),
536
+ };
537
+ }
538
+ }));
539
+ result.ready = [...result.hosts, ...result.browsers].every(host => host.state === 'active');
540
+ return result;
541
+ }
307
542
  async configure(value) {
308
543
  const current = await this.store.current();
309
544
  if (!current.prepared)
310
545
  throw new Error('No preset is installed');
311
- const receipt = await this.store.apply(current.prepared, { expectedRevision: presetSelectionRevision(current), overrides: value });
546
+ const receipt = await this.store.apply(current.prepared, {
547
+ expectedRevision: presetSelectionRevision(current),
548
+ overrides: value,
549
+ });
312
550
  return this.synchronize(receipt, presetSelectionRevision(current));
313
551
  }
314
552
  }
315
553
  export async function runPresetCommand(command, source, options = {}) {
316
- if (command !== 'repair' && options.replaceSkills !== undefined)
317
- throw new Error('Skill replacements require preset repair');
318
554
  const installer = new PresetInstaller({ connectedExtensionsOnly: true });
319
555
  if (command === 'repair')
320
556
  return installer.repair(options);
321
- return command === 'status' ? installer.status() : command === 'update' ? installer.update() : installer.apply(source);
557
+ if ((command !== 'apply' || source !== undefined) && options.replaceSkills !== undefined)
558
+ throw new Error('Skill replacements require preset apply without a source');
559
+ switch (command) {
560
+ case 'apply':
561
+ return installer.apply(source, options);
562
+ case 'update':
563
+ return installer.update();
564
+ case 'status':
565
+ return installer.status();
566
+ case 'uninstall':
567
+ return installer.uninstall();
568
+ }
322
569
  }
@@ -22,4 +22,4 @@ export declare function gitSource(source: string, ref?: string): {
22
22
  subdirectory?: string;
23
23
  };
24
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;
25
+ export declare function matchesTrackedSkillSource(requested: string, tracked: TrackedSkillSource, normalize?: (source: string, sourceType?: string) => string, refMatch?: 'exact' | 'missing'): boolean;
@@ -4,7 +4,11 @@ import { isAbsolute, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  function lockFile(scope, cwd, options) {
6
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');
7
+ return scope === 'project'
8
+ ? join(cwd, 'skills-lock.json')
9
+ : env.XDG_STATE_HOME
10
+ ? join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json')
11
+ : join(options.homeDirectory ?? homedir(), '.agents', '.skill-lock.json');
8
12
  }
9
13
  export function readTrackedSkillSources(scope, cwd, options = {}) {
10
14
  const file = lockFile(scope, cwd, options);
@@ -33,12 +37,28 @@ export function readTrackedSkillSources(scope, cwd, options = {}) {
33
37
  if (source.length > 4096 || /[\0\r\n]/.test(source))
34
38
  throw new Error('Invalid tracked Skill source');
35
39
  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' }];
40
+ return [
41
+ {
42
+ name,
43
+ source,
44
+ scope,
45
+ ...(typeof entry.sourceType === 'string' ? { sourceType: entry.sourceType } : {}),
46
+ ...(typeof entry.ref === 'string' ? { ref: entry.ref } : {}),
47
+ ...(typeof entry.skillPath === 'string' ? { skillPath: entry.skillPath } : {}),
48
+ ...(typeof expectedHash === 'string' && expectedHash ? { expectedHash } : {}),
49
+ hashKind: scope === 'global' &&
50
+ typeof expectedHash === 'string' &&
51
+ /^[a-f0-9]{40}$/.test(expectedHash)
52
+ ? 'git-tree'
53
+ : 'folder',
54
+ },
55
+ ];
37
56
  });
38
57
  }
39
58
  /** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
40
59
  export function isPublicSkillSource(source) {
41
- return source.sourceType === undefined || ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType);
60
+ return (source.sourceType === undefined ||
61
+ ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType));
42
62
  }
43
63
  export function gitSource(source, ref) {
44
64
  if (/^[\w.-]+\/[\w.-]+$/.test(source))
@@ -46,17 +66,31 @@ export function gitSource(source, ref) {
46
66
  if (/^[\w.-]+@[\w.-]+:[^\s]+$/.test(source))
47
67
  return { repository: source, ...(ref ? { ref } : {}) };
48
68
  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)
69
+ if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol) ||
70
+ url.password ||
71
+ (url.username && url.protocol !== 'ssh:') ||
72
+ url.search ||
73
+ url.hash)
50
74
  throw new Error('Skill source is not a supported Git repository');
51
75
  if (url.hostname === 'github.com' && !url.port) {
52
76
  const parts = url.pathname.split('/').filter(Boolean);
53
77
  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$/, '') } : {}) };
78
+ return {
79
+ repository: `https://github.com/${parts[0]}/${parts[1].replace(/\.git$/, '')}.git`,
80
+ ...(ref || (['tree', 'blob'].includes(parts[2]) && parts[3])
81
+ ? { ref: ref || decodeURIComponent(parts[3]) }
82
+ : {}),
83
+ ...(['tree', 'blob'].includes(parts[2]) && parts.length > 4
84
+ ? {
85
+ subdirectory: decodeURIComponent(parts.slice(4).join('/')).replace(/\/SKILL\.md$/, ''),
86
+ }
87
+ : {}),
88
+ };
55
89
  }
56
90
  return { repository: url.href, ...(ref ? { ref } : {}) };
57
91
  }
58
92
  /** Compare the manager's existing source receipt; no network access or second lockfile. */
59
- export function matchesTrackedSkillSource(requested, tracked, normalize = source => source) {
93
+ export function matchesTrackedSkillSource(requested, tracked, normalize = source => source, refMatch = 'exact') {
60
94
  const location = (source, ref, sourceType) => {
61
95
  const value = normalize(source, sourceType);
62
96
  if (typeof value !== 'string' || !value || value.length > 8192 || /[\0\r\n]/.test(value))
@@ -64,7 +98,9 @@ export function matchesTrackedSkillSource(requested, tracked, normalize = source
64
98
  const hash = value.indexOf('#');
65
99
  const fragment = hash < 0 ? undefined : value.slice(hash + 1);
66
100
  const plain = hash < 0 ? value : value.slice(0, hash);
67
- const parsed = isAbsolute(plain) ? { repository: plain, ref: ref ?? fragment } : gitSource(plain, ref ?? fragment);
101
+ const parsed = isAbsolute(plain)
102
+ ? { repository: plain, ref: ref ?? fragment }
103
+ : gitSource(plain, ref ?? fragment);
68
104
  let repository = parsed.repository;
69
105
  if (repository.startsWith('file:'))
70
106
  repository = fileURLToPath(repository);
@@ -81,14 +117,22 @@ export function matchesTrackedSkillSource(requested, tracked, normalize = source
81
117
  try {
82
118
  const expected = location(requested);
83
119
  const actual = location(tracked.source, tracked.ref, tracked.sourceType);
84
- if (expected.repository !== actual.repository || expected.ref !== actual.ref)
120
+ if (expected.repository !== actual.repository)
121
+ return false;
122
+ if (refMatch === 'missing'
123
+ ? !expected.ref || actual.ref !== undefined
124
+ : expected.ref !== actual.ref)
85
125
  return false;
86
126
  if (!expected.subdirectory)
87
127
  return true;
88
- const path = (tracked.skillPath ?? actual.subdirectory ?? '').replace(/(?:^|\/)SKILL\.md$/, '').replace(/\/+$/, '');
128
+ const path = (tracked.skillPath ?? actual.subdirectory ?? '')
129
+ .replace(/(?:^|\/)SKILL\.md$/, '')
130
+ .replace(/\/+$/, '');
89
131
  return path === expected.subdirectory || path.startsWith(expected.subdirectory + '/');
90
132
  }
91
133
  catch {
92
- return normalize(requested) === normalize(tracked.source, tracked.sourceType) && !tracked.ref;
134
+ return (refMatch === 'exact' &&
135
+ normalize(requested) === normalize(tracked.source, tracked.sourceType) &&
136
+ !tracked.ref);
93
137
  }
94
138
  }
@@ -1,6 +1,6 @@
1
1
  import { type CommandRunner } from '@mearl/daemon-core';
2
2
  import { readTrackedSkillSources, type SourceOptions, type SkillSourceScope, type TrackedSkillSource } from './skillSourceReceipts.js';
3
- export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
3
+ export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources, } from './skillSourceReceipts.js';
4
4
  export type { SkillSourceScope, TrackedSkillSource } from './skillSourceReceipts.js';
5
5
  /** Matches the public Skills folder hash without writing to the installed Skill. */
6
6
  export declare function hashSkillDirectory(directory: string, ignoreRuntimeFiles?: boolean): Promise<string>;
@@ -6,12 +6,15 @@ import { homedir, tmpdir } from 'node:os';
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
7
  import { runCommand } from '@mearl/daemon-core';
8
8
  import { satisfies, valid } from 'semver';
9
- import { DISABLED_SKILL_MANIFEST, getSkillEnabled, 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';
10
10
  import { existsSync } from 'node:fs';
11
- import { gitSource, isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
12
- export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
11
+ import { gitSource, isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources, } from './skillSourceReceipts.js';
12
+ export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources, } from './skillSourceReceipts.js';
13
13
  function skillName(text) {
14
- return text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1].match(/^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m)?.[1]?.trim();
14
+ return text
15
+ .match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1]
16
+ .match(/^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m)?.[1]
17
+ ?.trim();
15
18
  }
16
19
  async function findSkillDirectory(root, name, skillPath) {
17
20
  if (skillPath) {
@@ -47,7 +50,8 @@ export async function hashSkillDirectory(directory, ignoreRuntimeFiles = false)
47
50
  let total = 0;
48
51
  const visit = async (current) => {
49
52
  for (const entry of await readdir(current, { withFileTypes: true })) {
50
- if (['.git', 'node_modules'].includes(entry.name) || (ignoreRuntimeFiles && ['__pycache__', '.DS_Store'].includes(entry.name)))
53
+ if (['.git', 'node_modules'].includes(entry.name) ||
54
+ (ignoreRuntimeFiles && ['__pycache__', '.DS_Store'].includes(entry.name)))
51
55
  continue;
52
56
  const file = join(current, entry.name);
53
57
  if (entry.isDirectory())
@@ -57,7 +61,13 @@ export async function hashSkillDirectory(directory, ignoreRuntimeFiles = false)
57
61
  total += info.size;
58
62
  if (total > 64 * 1024 * 1024 || files.length >= 10_000)
59
63
  throw new Error('Skill source exceeds the comparison limit');
60
- files.push({ path: relative(directory, file).split(sep).join('/').replace(ignoreRuntimeFiles ? /^SKILL\.md\.mearl-disabled$/ : /$^/, 'SKILL.md'), bytes: await readFile(file) });
64
+ files.push({
65
+ path: relative(directory, file)
66
+ .split(sep)
67
+ .join('/')
68
+ .replace(ignoreRuntimeFiles ? /^SKILL\.md\.mearl-disabled$/ : /$^/, 'SKILL.md'),
69
+ bytes: await readFile(file),
70
+ });
61
71
  }
62
72
  }
63
73
  };
@@ -71,7 +81,10 @@ export async function hashSkillDirectory(directory, ignoreRuntimeFiles = false)
71
81
  }
72
82
  /** Checks source content in a temporary checkout; never invokes the mutating Skills `check` alias. */
73
83
  export async function checkTrackedSkillUpdates(options = {}) {
74
- const sources = [...readTrackedSkillSources('global', undefined, options), ...(options.cwd ? readTrackedSkillSources('project', options.cwd, options) : [])];
84
+ const sources = [
85
+ ...readTrackedSkillSources('global', undefined, options),
86
+ ...(options.cwd ? readTrackedSkillSources('project', options.cwd, options) : []),
87
+ ];
75
88
  if (sources.some(source => !isPublicSkillSource(source)))
76
89
  throw new Error('Installed Skills require an external manager. Enable its preset.');
77
90
  if (sources.length > 500)
@@ -88,12 +101,27 @@ export async function checkTrackedSkillUpdates(options = {}) {
88
101
  let checkout = checkouts.get(key);
89
102
  if (!checkout) {
90
103
  const target = join(temporary, String(checkouts.size));
91
- checkout = local ? Promise.resolve(local) : (async () => {
92
- const result = await runner('git', ['clone', '--depth', '1', '--filter=blob:none', ...(location.ref ? ['--branch', location.ref] : []), '--', location.repository, target], { capture: true, env: { ...(options.env ?? process.env), GIT_TERMINAL_PROMPT: '0' }, timeoutMs: 120_000 });
93
- if (result.code !== 0)
94
- throw new Error(`Unable to inspect Skill source: ${source.name}`);
95
- return target;
96
- })();
104
+ checkout = local
105
+ ? Promise.resolve(local)
106
+ : (async () => {
107
+ const result = await runner('git', [
108
+ 'clone',
109
+ '--depth',
110
+ '1',
111
+ '--filter=blob:none',
112
+ ...(location.ref ? ['--branch', location.ref] : []),
113
+ '--',
114
+ location.repository,
115
+ target,
116
+ ], {
117
+ capture: true,
118
+ env: { ...(options.env ?? process.env), GIT_TERMINAL_PROMPT: '0' },
119
+ timeoutMs: 120_000,
120
+ });
121
+ if (result.code !== 0)
122
+ throw new Error(`Unable to inspect Skill source: ${source.name}`);
123
+ return target;
124
+ })();
97
125
  checkouts.set(key, checkout);
98
126
  }
99
127
  const root = await realpath(await checkout);
@@ -107,10 +135,12 @@ export async function checkTrackedSkillUpdates(options = {}) {
107
135
  changed = result.stdout.trim() !== source.expectedHash;
108
136
  }
109
137
  else if (source.expectedHash && source.hashKind === 'folder')
110
- changed = await hashSkillDirectory(directory) !== source.expectedHash;
138
+ changed = (await hashSkillDirectory(directory)) !== source.expectedHash;
111
139
  else {
112
- const installed = join(source.scope === 'global' ? options.homeDirectory ?? homedir() : options.cwd, '.agents', 'skills', source.name);
113
- changed = await hashSkillDirectory(directory, true) !== await hashSkillDirectory(installed, true);
140
+ const installed = join(source.scope === 'global' ? (options.homeDirectory ?? homedir()) : options.cwd, '.agents', 'skills', source.name);
141
+ changed =
142
+ (await hashSkillDirectory(directory, true)) !==
143
+ (await hashSkillDirectory(installed, true));
114
144
  }
115
145
  if (changed)
116
146
  updates.push({ name: source.name, source: source.source, scope: source.scope });
@@ -128,7 +158,10 @@ export function readLinkedSkillSource(directory, name) {
128
158
  return undefined;
129
159
  const actual = realpathSync(directory);
130
160
  const git = (...args) => execFileSync('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', actual, ...args], {
131
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2_000, maxBuffer: 16_384,
161
+ encoding: 'utf8',
162
+ stdio: ['ignore', 'pipe', 'ignore'],
163
+ timeout: 2_000,
164
+ maxBuffer: 16_384,
132
165
  }).trim();
133
166
  const root = git('rev-parse', '--show-toplevel');
134
167
  const folder = relative(root, actual);
@@ -169,29 +202,44 @@ function canonicalPath(path) {
169
202
  return parent === absolute ? absolute : join(canonicalPath(parent), basename(absolute));
170
203
  }
171
204
  }
172
- function hasOnlySetupRegistrations(directory) {
205
+ function hasOnlySetupRegistrations(directory, allowUnregistered = false) {
173
206
  const contains = (root, entry) => {
174
207
  const path = relative(root, entry);
175
208
  return path !== '' && path !== '..' && !path.startsWith('..' + sep) && !isAbsolute(path);
176
209
  };
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);
210
+ const registrations = listPluginNames()
211
+ .flatMap(name => findPluginRegistration(name) ?? [])
212
+ .filter(registration => contains(resolve(directory), resolve(registration.entry)) ||
213
+ contains(canonicalPath(directory), canonicalPath(registration.entry)));
214
+ return ((allowUnregistered || registrations.length > 0) &&
215
+ registrations.every(registration => registration.managedBy === OFFICIAL_PLUGIN_MANAGER));
179
216
  }
180
217
  /** CLI and Agent readiness use the same source, manifest and registration evidence. */
181
218
  export function inspectGlobalSkill(source, name, version, options = {}) {
182
219
  if (!source.trim() || !name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
183
220
  throw new Error('Invalid Skill identity');
184
221
  const directory = join(options.homeDirectory ?? homedir(), '.agents', 'skills', name);
185
- if (existsSync(join(directory, 'SKILL.md')) && existsSync(join(directory, DISABLED_SKILL_MANIFEST)))
222
+ if (existsSync(join(directory, 'SKILL.md')) &&
223
+ existsSync(join(directory, DISABLED_SKILL_MANIFEST)))
186
224
  return { state: 'conflict', reason: 'SKILL_MANIFEST_CONFLICT' };
187
- const receipt = (options.readSources ?? readTrackedSkillSources)('global', undefined, { homeDirectory: options.homeDirectory }).find(entry => entry.name === name);
188
- const tracked = receipt
189
- ?? (!/(?:#|\/(?:tree|blob)\/)/.test(source) ? readLinkedSkillSource(directory, name) : undefined);
190
- if (tracked && ((!options.externalManager && !isPublicSkillSource(tracked)) || !matchesTrackedSkillSource(source, tracked, options.normalizeSource))) {
225
+ const receipt = (options.readSources ?? readTrackedSkillSources)('global', undefined, {
226
+ homeDirectory: options.homeDirectory,
227
+ }).find(entry => entry.name === name);
228
+ const tracked = receipt ??
229
+ (!/(?:#|\/(?:tree|blob)\/)/.test(source) ? readLinkedSkillSource(directory, name) : undefined);
230
+ if (tracked &&
231
+ ((!options.externalManager && !isPublicSkillSource(tracked)) ||
232
+ !matchesTrackedSkillSource(source, tracked, options.normalizeSource))) {
191
233
  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' };
234
+ if (receipt &&
235
+ (options.externalManager || isPublicSkillSource(receipt)) &&
236
+ metadata?.name === name) {
237
+ // A missing ref requires installing the requested ref before the Skill can be ready.
238
+ const missingRef = matchesTrackedSkillSource(source, receipt, options.normalizeSource, 'missing');
239
+ const declaredMigration = options.migrateFrom?.some(previous => matchesTrackedSkillSource(previous, receipt, options.normalizeSource));
240
+ if ((missingRef || declaredMigration) && hasOnlySetupRegistrations(directory, missingRef)) {
241
+ return { state: 'migration', reason: 'SKILL_SOURCE_MIGRATION_REQUIRED' };
242
+ }
195
243
  }
196
244
  return { state: 'conflict', reason: 'SKILL_SOURCE_CONFLICT' };
197
245
  }
@@ -202,7 +250,8 @@ export function inspectGlobalSkill(source, name, version, options = {}) {
202
250
  return { state: 'conflict', reason: 'SKILL_SOURCE_UNKNOWN' };
203
251
  if (metadata.name !== name)
204
252
  return { state: 'conflict', reason: 'SKILL_MANIFEST_IDENTITY' };
205
- if (version && (!metadata.version || !valid(metadata.version) || !satisfies(metadata.version, version)))
253
+ if (version &&
254
+ (!metadata.version || !valid(metadata.version) || !satisfies(metadata.version, version)))
206
255
  return { state: 'version', reason: 'SKILL_VERSION_REQUIRED' };
207
256
  const entry = findPluginDirectoryEntry(directory);
208
257
  const names = listPluginNames().filter(name => {
@@ -212,10 +261,13 @@ export function inspectGlobalSkill(source, name, version, options = {}) {
212
261
  const managedNames = names.filter(name => findPluginRegistration(name)?.managedBy === OFFICIAL_PLUGIN_MANAGER);
213
262
  // User aliases may share a managed Skill without participating in its readiness or cleanup.
214
263
  const requiredNames = managedNames.length ? managedNames : names;
215
- if (entry && (requiredNames.length !== 1 || !requiredNames.some(name => {
216
- const registration = findPluginRegistration(name);
217
- return resolve(registration.entry) === resolve(entry) && !pluginRegistrationNeedsRefresh(registration, true);
218
- })))
264
+ if (entry &&
265
+ (requiredNames.length !== 1 ||
266
+ !requiredNames.some(name => {
267
+ const registration = findPluginRegistration(name);
268
+ return (resolve(registration.entry) === resolve(entry) &&
269
+ !pluginRegistrationNeedsRefresh(registration, true));
270
+ })))
219
271
  return { state: 'registration', reason: 'SKILL_PLUGIN_REGISTRATION_REQUIRED' };
220
272
  if (!entry && names.length)
221
273
  return { state: 'registration', reason: 'SKILL_PLUGIN_REGISTRATION_REQUIRED' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/setup",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "One-shot environment setup and configuration for Mearl",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -79,11 +79,11 @@
79
79
  "ws": "^8.18.3",
80
80
  "semver": "^7.8.5",
81
81
  "adm-zip": "^0.6.1",
82
- "@mearl/client": "3.0.1",
83
- "@mearl/provider": "3.0.1",
84
- "@mearl/daemon-core": "3.0.1",
85
- "@mearl/preset": "3.0.1",
86
- "@mearl/browser-core": "3.0.1"
82
+ "@mearl/provider": "3.0.2",
83
+ "@mearl/preset": "3.0.2",
84
+ "@mearl/browser-core": "3.0.2",
85
+ "@mearl/daemon-core": "3.0.2",
86
+ "@mearl/client": "3.0.2"
87
87
  },
88
88
  "devDependencies": {
89
89
  "@types/node": "^24.9.1",