@mearl/setup 3.0.0-beta.0 → 3.0.0-beta.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.md CHANGED
@@ -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.0 --skill browser-tab-manager --yes
31
+ npx @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
 
@@ -121,7 +121,7 @@ npx @mearl/setup@beta provider uninstall example-device
121
121
  CDP 标签 ID 计数也保留,防止旧请求命中新标签。
122
122
  - `--mcp` 的客户端配置和清理由 `@mearl/mcp-server` 自己的命令完成。
123
123
  - 默认的 `install` / `update` 不处理浏览器扩展。`extension` 是独立命令:从
124
- `https://github.com/F-loat/mearl/releases/download/v3.0.0-beta.0/` 读取同版本 beta 扩展发行元数据(正式 setup 使用 `releases/latest/download/`)并下载对应的 `mearl-chrome-v*.zip`,原子解压到
124
+ `https://github.com/F-loat/mearl/releases/download/v3.0.0-beta.1/` 读取同版本 beta 扩展发行元数据(正式 setup 使用 `releases/latest/download/`)并下载对应的 `mearl-chrome-v*.zip`,原子解压到
125
125
  `~/.mearl/chrome-extension`。首次安装通过浏览器级 CDP 的 `Extensions.loadUnpacked`
126
126
  加载;后续更新由 `mearl check --local --json` 确认当前扩展仍来自 setup 管理目录后,直接替换
127
127
  文件并通过 Native Host 触发扩展 reload。固定目录和 manifest 中的 `key` 保证扩展 ID
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 () => {
@@ -111,7 +126,7 @@ function globalSkill(entry) {
111
126
  return undefined;
112
127
  }
113
128
  function projectSkill(entry, managedBy) {
114
- if (managedBy !== 'skills' && managedBy !== 'ali-skills')
129
+ if (!managedBy)
115
130
  return undefined;
116
131
  const absolute = resolve(entry);
117
132
  const marker = `${sep}.agents${sep}skills${sep}`;
@@ -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)
package/dist/preset.js CHANGED
@@ -95,7 +95,7 @@ export class PresetInstaller {
95
95
  const result = await this.invokeHost(reload ? 'preset_browser_sync' : 'preset_browser_status', reload ? { installationRevision } : {}, target, reload ? 130 : 15, reload);
96
96
  if (result?.accepted === false)
97
97
  return { id: target.id, state: 'rejected', settingsReady: false, error: typeof result.error === 'string' ? result.error : 'Browser preset activation rejected' };
98
- return { id: target.id, state: result?.ready === true && result?.browserRevision === browserRevision && result?.installationRevision === installationRevision ? 'active' : 'pending', settingsReady: result?.settingsReady === true };
98
+ return { id: target.id, state: result?.ready === true && result?.browserRevision === browserRevision && result?.installationRevision === installationRevision ? 'active' : 'pending', settingsReady: result?.settingsReady === true, ...(Array.isArray(result?.permissionPending) && result.permissionPending.length ? { error: 'PRESET_DOMAIN_PERMISSION_REQUIRED: Open extension settings and authorize preset domains' } : {}) };
99
99
  }
100
100
  catch {
101
101
  return { id: target.id, state: 'pending', settingsReady: false, error: 'BROWSER_UNAVAILABLE' };
@@ -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,13 +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
  }
20
+ /** Developer links keep their Git checkout as the source receipt without changing a manager's lock. */
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>;
37
24
  /** CLI and Agent readiness use the same source, manifest and registration evidence. */
38
25
  export declare function inspectGlobalSkill(source: string, name: string, version?: string, options?: {
26
+ homeDirectory?: string;
39
27
  readSources?: typeof readTrackedSkillSources;
40
28
  externalManager?: boolean;
29
+ migrateFrom?: readonly string[];
41
30
  normalizeSource?: (source: string, sourceType?: string) => string;
42
31
  }): SkillInstallationStatus;
43
- export {};
@@ -1,103 +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 } from 'node:fs';
3
+ import { lstatSync, realpathSync, renameSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
4
5
  import { homedir, tmpdir } from 'node:os';
5
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
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, 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
- function lockFile(scope, cwd, options) {
12
- const env = options.env ?? process.env;
13
- 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');
14
- }
15
- export function readTrackedSkillSources(scope, cwd, options = {}) {
16
- const file = lockFile(scope, cwd, options);
17
- let text;
18
- try {
19
- if (statSync(file).size > 4 * 1024 * 1024)
20
- throw new Error('Skill source lock exceeds 4 MiB');
21
- text = readFileSync(file, 'utf8');
22
- }
23
- catch (error) {
24
- if (error.code === 'ENOENT')
25
- return [];
26
- throw error;
27
- }
28
- const lock = JSON.parse(text);
29
- if (!lock || !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills))
30
- throw new Error('Invalid Skills source lock');
31
- return Object.entries(lock.skills).flatMap(([name, entry]) => {
32
- if (!entry || typeof entry !== 'object' || entry.sourceType === 'node_modules')
33
- return [];
34
- if (!name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
35
- throw new Error('Invalid tracked Skill name');
36
- const source = typeof entry.sourceUrl === 'string' ? entry.sourceUrl : entry.source;
37
- if (typeof source !== 'string' || !source.trim())
38
- return [];
39
- if (source.length > 4096 || /[\0\r\n]/.test(source))
40
- throw new Error('Invalid tracked Skill source');
41
- const expectedHash = scope === 'global' ? entry.skillFolderHash : entry.computedHash;
42
- 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' }];
43
- });
44
- }
45
- /** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
46
- export function isPublicSkillSource(source) {
47
- return source.sourceType === undefined || ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType);
48
- }
49
- function gitSource(source, ref) {
50
- if (/^[\w.-]+\/[\w.-]+$/.test(source))
51
- return { repository: `https://github.com/${source}.git`, ...(ref ? { ref } : {}) };
52
- if (/^[\w.-]+@[\w.-]+:[^\s]+$/.test(source))
53
- return { repository: source, ...(ref ? { ref } : {}) };
54
- const url = new URL(source);
55
- if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol) || url.password || (url.username && url.protocol !== 'ssh:') || url.search || url.hash)
56
- throw new Error('Skill source is not a supported Git repository');
57
- if (url.hostname === 'github.com' && !url.port) {
58
- const parts = url.pathname.split('/').filter(Boolean);
59
- if (parts.length >= 2)
60
- 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$/, '') } : {}) };
61
- }
62
- return { repository: url.href, ...(ref ? { ref } : {}) };
63
- }
64
- /** Compare the manager's existing source receipt; no network access or second lockfile. */
65
- export function matchesTrackedSkillSource(requested, tracked, normalize = source => source) {
66
- const location = (source, ref, sourceType) => {
67
- const value = normalize(source, sourceType);
68
- if (typeof value !== 'string' || !value || value.length > 8192 || /[\0\r\n]/.test(value))
69
- throw new Error('Invalid normalized Skill source');
70
- const hash = value.indexOf('#');
71
- const fragment = hash < 0 ? undefined : value.slice(hash + 1);
72
- const plain = hash < 0 ? value : value.slice(0, hash);
73
- const parsed = isAbsolute(plain) ? { repository: plain, ref: ref ?? fragment } : gitSource(plain, ref ?? fragment);
74
- let repository = parsed.repository;
75
- if (repository.startsWith('file:'))
76
- repository = fileURLToPath(repository);
77
- if (isAbsolute(repository))
78
- repository = resolve(repository);
79
- else {
80
- const ssh = repository.match(/^[\w.-]+@([\w.-]+):(.+)$/);
81
- const url = new URL(ssh ? `ssh://${ssh[1]}/${ssh[2]}` : repository);
82
- // Authentication transport is independent of the repository's identity.
83
- repository = `${url.hostname.toLowerCase()}${url.port ? ':' + url.port : ''}/${url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`;
84
- }
85
- return { ...parsed, repository, ref: parsed.ref || undefined };
86
- };
87
- try {
88
- const expected = location(requested);
89
- const actual = location(tracked.source, tracked.ref, tracked.sourceType);
90
- if (expected.repository !== actual.repository || expected.ref !== actual.ref)
91
- return false;
92
- if (!expected.subdirectory)
93
- return true;
94
- const path = (tracked.skillPath ?? actual.subdirectory ?? '').replace(/(?:^|\/)SKILL\.md$/, '').replace(/\/+$/, '');
95
- return path === expected.subdirectory || path.startsWith(expected.subdirectory + '/');
96
- }
97
- catch {
98
- return normalize(requested) === normalize(tracked.source, tracked.sourceType) && !tracked.ref;
99
- }
100
- }
11
+ import { gitSource, isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
12
+ export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
101
13
  function skillName(text) {
102
14
  return text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1].match(/^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m)?.[1]?.trim();
103
15
  }
@@ -209,16 +121,80 @@ export async function checkTrackedSkillUpdates(options = {}) {
209
121
  await rm(temporary, { recursive: true, force: true });
210
122
  }
211
123
  }
124
+ /** Developer links keep their Git checkout as the source receipt without changing a manager's lock. */
125
+ export function readLinkedSkillSource(directory, name) {
126
+ try {
127
+ if (!lstatSync(directory).isSymbolicLink())
128
+ return undefined;
129
+ const actual = realpathSync(directory);
130
+ 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,
132
+ }).trim();
133
+ const root = git('rev-parse', '--show-toplevel');
134
+ const folder = relative(root, actual);
135
+ if (folder === '..' || folder.startsWith('..' + sep) || isAbsolute(folder))
136
+ return undefined;
137
+ const skillPath = (folder ? folder.split(sep).join('/') + '/' : '') + 'SKILL.md';
138
+ if (git('ls-files', '--full-name', '--error-unmatch', '--', 'SKILL.md') !== skillPath)
139
+ return undefined;
140
+ const source = git('config', '--get', 'remote.origin.url');
141
+ if (!source || source.length > 4096 || /[\0\r\n]/.test(source))
142
+ return undefined;
143
+ if (!isAbsolute(source))
144
+ gitSource(source);
145
+ return { name, source, scope: 'global', skillPath, sourceType: 'git', hashKind: 'folder' };
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
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
+ }
212
180
  /** CLI and Agent readiness use the same source, manifest and registration evidence. */
213
181
  export function inspectGlobalSkill(source, name, version, options = {}) {
214
182
  if (!source.trim() || !name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
215
183
  throw new Error('Invalid Skill identity');
216
- const directory = join(homedir(), '.agents', 'skills', name);
184
+ const directory = join(options.homeDirectory ?? homedir(), '.agents', 'skills', name);
217
185
  if (existsSync(join(directory, 'SKILL.md')) && existsSync(join(directory, DISABLED_SKILL_MANIFEST)))
218
186
  return { state: 'conflict', reason: 'SKILL_MANIFEST_CONFLICT' };
219
- const tracked = (options.readSources ?? readTrackedSkillSources)('global').find(entry => entry.name === name);
220
- if (tracked && ((!options.externalManager && !isPublicSkillSource(tracked)) || !matchesTrackedSkillSource(source, tracked, options.normalizeSource)))
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))) {
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
+ }
221
196
  return { state: 'conflict', reason: 'SKILL_SOURCE_CONFLICT' };
197
+ }
222
198
  const metadata = readInstalledSkillMetadata(directory);
223
199
  if (!metadata)
224
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.0",
3
+ "version": "3.0.0-beta.2",
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/client": "3.0.0-beta.0",
78
- "@mearl/provider": "3.0.0-beta.0",
79
- "@mearl/daemon-core": "3.0.0-beta.0",
80
- "@mearl/preset": "3.0.0-beta.0",
81
- "@mearl/browser-core": "3.0.0-beta.0"
81
+ "@mearl/client": "3.0.0-beta.2",
82
+ "@mearl/provider": "3.0.0-beta.2",
83
+ "@mearl/daemon-core": "3.0.0-beta.2",
84
+ "@mearl/browser-core": "3.0.0-beta.2",
85
+ "@mearl/preset": "3.0.0-beta.2"
82
86
  },
83
87
  "devDependencies": {
84
88
  "@types/node": "^24.9.1",
@@ -86,6 +90,11 @@
86
90
  "typescript": "^5.8.3",
87
91
  "@types/semver": "^7.7.1"
88
92
  },
93
+ "repository": {
94
+ "type": "git",
95
+ "url": "git+https://github.com/F-loat/mearl.git",
96
+ "directory": "packages/setup"
97
+ },
89
98
  "scripts": {
90
99
  "build": "rm -rf dist/ && tsc && chmod +x dist/bin.js",
91
100
  "dev": "tsc --watch",