@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 +2 -2
- package/dist/plugin.js +33 -9
- package/dist/preset.js +1 -1
- package/dist/skillSourceReceipts.d.ts +25 -0
- package/dist/skillSourceReceipts.js +94 -0
- package/dist/skillSources.d.ts +10 -22
- package/dist/skillSources.js +73 -97
- package/dist/skills.js +4 -2
- package/package.json +15 -6
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.
|
|
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.
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/skillSources.d.ts
CHANGED
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
import { type CommandRunner } from '@mearl/daemon-core';
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
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 {};
|
package/dist/skillSources.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
220
|
-
|
|
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.
|
|
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.
|
|
78
|
-
"@mearl/provider": "3.0.0-beta.
|
|
79
|
-
"@mearl/daemon-core": "3.0.0-beta.
|
|
80
|
-
"@mearl/
|
|
81
|
-
"@mearl/
|
|
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",
|