@clawstore/clawstore 1.0.0
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/index.ts +46 -0
- package/openclaw.plugin.json +24 -0
- package/package.json +30 -0
- package/src/__tests__/cli-sim.test.ts +303 -0
- package/src/__tests__/e2e.test.ts +186 -0
- package/src/cli.ts +513 -0
- package/src/commands.ts +196 -0
- package/src/constants.ts +80 -0
- package/src/core/agent-manager.ts +327 -0
- package/src/core/package-installer.ts +390 -0
- package/src/core/packager.ts +229 -0
- package/src/core/skill-installer.ts +221 -0
- package/src/core/store-client.ts +140 -0
- package/src/core/workspace.ts +269 -0
- package/src/types.ts +167 -0
- package/src/utils/checksum.ts +17 -0
- package/src/utils/output.ts +76 -0
- package/src/utils/zip.ts +55 -0
- package/tsconfig.json +23 -0
- package/typings/openclaw-plugin-sdk/core.d.ts +134 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { readFile, access, cp, mkdir, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
MANIFEST_FILE,
|
|
5
|
+
REQUIRED_FILES,
|
|
6
|
+
OPTIONAL_FILES,
|
|
7
|
+
MANIFEST_REQUIRED_FIELDS,
|
|
8
|
+
INSTALL_TOTAL_STEPS,
|
|
9
|
+
UPDATE_TOTAL_STEPS,
|
|
10
|
+
DOWNLOADS_DIR,
|
|
11
|
+
DEFAULT_AGENT,
|
|
12
|
+
} from "../constants.js";
|
|
13
|
+
import type {
|
|
14
|
+
AgentManifest,
|
|
15
|
+
InstalledAgentRecord,
|
|
16
|
+
InstallState,
|
|
17
|
+
PackageValidationResult,
|
|
18
|
+
ValidationError,
|
|
19
|
+
} from "../types.js";
|
|
20
|
+
import {
|
|
21
|
+
ensureDirs,
|
|
22
|
+
loadRegistry,
|
|
23
|
+
registerAgent,
|
|
24
|
+
backupCurrentWorkspace,
|
|
25
|
+
restoreFromBackup,
|
|
26
|
+
updateAgentRecord,
|
|
27
|
+
} from "./agent-manager.js";
|
|
28
|
+
import { installRequiredSkills } from "./skill-installer.js";
|
|
29
|
+
import { resolveWorkspace } from "./workspace.js";
|
|
30
|
+
import { extractZip } from "../utils/zip.js";
|
|
31
|
+
import { computeSha256 } from "../utils/checksum.js";
|
|
32
|
+
import { stepLine } from "../utils/output.js";
|
|
33
|
+
|
|
34
|
+
async function exists(p: string): Promise<boolean> {
|
|
35
|
+
try {
|
|
36
|
+
await access(p);
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Manifest loading & validation ──────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export async function loadManifest(packagePath: string): Promise<AgentManifest> {
|
|
46
|
+
const manifestPath = join(packagePath, MANIFEST_FILE);
|
|
47
|
+
if (!(await exists(manifestPath))) {
|
|
48
|
+
throw new Error(`${MANIFEST_FILE} not found in ${packagePath}`);
|
|
49
|
+
}
|
|
50
|
+
const raw = await readFile(manifestPath, "utf-8");
|
|
51
|
+
return JSON.parse(raw) as AgentManifest;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function validatePackage(packagePath: string): Promise<PackageValidationResult> {
|
|
55
|
+
const errors: ValidationError[] = [];
|
|
56
|
+
|
|
57
|
+
let manifest: AgentManifest | null = null;
|
|
58
|
+
try {
|
|
59
|
+
manifest = await loadManifest(packagePath);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
errors.push({ field: "agent.json", message: (err as Error).message });
|
|
62
|
+
return { valid: false, errors, manifest: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const f of REQUIRED_FILES) {
|
|
66
|
+
if (!(await exists(join(packagePath, f)))) {
|
|
67
|
+
errors.push({ field: f, message: `Missing required file: ${f}` });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const field of MANIFEST_REQUIRED_FIELDS) {
|
|
72
|
+
if (!manifest[field as keyof AgentManifest]) {
|
|
73
|
+
errors.push({ field: `agent.json.${field}`, message: `Missing required field: ${field}` });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const ef of manifest.entry_files ?? []) {
|
|
78
|
+
if (!(await exists(join(packagePath, ef)))) {
|
|
79
|
+
errors.push({ field: `entry_files`, message: `entry_files references missing file: ${ef}` });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { valid: errors.length === 0, errors, manifest };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Resolve package path (dir or ZIP) ──────────────────────────────
|
|
87
|
+
|
|
88
|
+
export async function resolvePackagePath(pathStr: string): Promise<string> {
|
|
89
|
+
const p = join(pathStr);
|
|
90
|
+
|
|
91
|
+
const st = await stat(p);
|
|
92
|
+
if (st.isFile() && p.endsWith(".zip")) {
|
|
93
|
+
return extractZip(p);
|
|
94
|
+
}
|
|
95
|
+
if (st.isDirectory()) {
|
|
96
|
+
return p;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(`${pathStr} is not a valid directory or ZIP file`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Download package from store ────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export async function downloadPackage(url: string, personaId: string): Promise<string> {
|
|
105
|
+
await mkdir(DOWNLOADS_DIR, { recursive: true });
|
|
106
|
+
const destZip = join(DOWNLOADS_DIR, `${personaId}.zip`);
|
|
107
|
+
|
|
108
|
+
const res = await fetch(url);
|
|
109
|
+
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`);
|
|
110
|
+
|
|
111
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
112
|
+
const { writeFile } = await import("node:fs/promises");
|
|
113
|
+
await writeFile(destZip, buf);
|
|
114
|
+
|
|
115
|
+
return extractZip(destZip);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Install Agent (7-step flow) ────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export interface InstallOptions {
|
|
121
|
+
packagePath: string;
|
|
122
|
+
force?: boolean;
|
|
123
|
+
skipSkills?: boolean;
|
|
124
|
+
/** OpenClaw agent to install into (default "main") */
|
|
125
|
+
agent?: string;
|
|
126
|
+
log: (msg: string) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function installAgent(opts: InstallOptions): Promise<{
|
|
130
|
+
state: InstallState;
|
|
131
|
+
agentId?: string;
|
|
132
|
+
error?: string;
|
|
133
|
+
}> {
|
|
134
|
+
const { log } = opts;
|
|
135
|
+
const agent = opts.agent ?? DEFAULT_AGENT;
|
|
136
|
+
let state: InstallState = "idle";
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await ensureDirs(agent);
|
|
140
|
+
const workspace = await resolveWorkspace(agent);
|
|
141
|
+
|
|
142
|
+
// [1/7] Resolve and load manifest
|
|
143
|
+
state = "fetching_manifest";
|
|
144
|
+
log(stepLine(1, INSTALL_TOTAL_STEPS, "Loading manifest", ""));
|
|
145
|
+
const packagePath = await resolvePackagePath(opts.packagePath);
|
|
146
|
+
const manifest = await loadManifest(packagePath);
|
|
147
|
+
const personaId = manifest.id;
|
|
148
|
+
log(stepLine(1, INSTALL_TOTAL_STEPS, "Loading manifest", "OK"));
|
|
149
|
+
log(`\n Installing: ${manifest.name} (${personaId})`);
|
|
150
|
+
log(` Version: ${manifest.version}`);
|
|
151
|
+
log(` Category: ${manifest.category ?? "N/A"}`);
|
|
152
|
+
if (agent !== DEFAULT_AGENT) log(` Agent: ${agent}`);
|
|
153
|
+
log("");
|
|
154
|
+
|
|
155
|
+
// [2/7] Validate package
|
|
156
|
+
state = "verifying_package";
|
|
157
|
+
log(stepLine(2, INSTALL_TOTAL_STEPS, "Validating package", ""));
|
|
158
|
+
const validation = await validatePackage(packagePath);
|
|
159
|
+
if (!validation.valid) {
|
|
160
|
+
const reasons = validation.errors.map((e) => ` - ${e.message}`).join("\n");
|
|
161
|
+
log(stepLine(2, INSTALL_TOTAL_STEPS, "Validating package", "FAILED"));
|
|
162
|
+
log(reasons);
|
|
163
|
+
return { state: "failed", agentId: personaId, error: `Validation failed:\n${reasons}` };
|
|
164
|
+
}
|
|
165
|
+
log(stepLine(2, INSTALL_TOTAL_STEPS, "Validating package", "OK"));
|
|
166
|
+
|
|
167
|
+
// [3/7] Check existing installation
|
|
168
|
+
state = "checking_compatibility";
|
|
169
|
+
log(stepLine(3, INSTALL_TOTAL_STEPS, "Checking existing installation", ""));
|
|
170
|
+
const registry = await loadRegistry(agent);
|
|
171
|
+
if (registry.agents[personaId] && !opts.force) {
|
|
172
|
+
const existing = registry.agents[personaId];
|
|
173
|
+
log(stepLine(3, INSTALL_TOTAL_STEPS, "Checking existing installation", `FOUND (v${existing.version})`));
|
|
174
|
+
log(`\n Agent '${personaId}' is already installed.`);
|
|
175
|
+
log(` Use 'openclaw clawstore update ${personaId}' to update,`);
|
|
176
|
+
log(` or add --force to reinstall.`);
|
|
177
|
+
return { state: "failed", agentId: personaId, error: "already installed" };
|
|
178
|
+
}
|
|
179
|
+
log(stepLine(3, INSTALL_TOTAL_STEPS, "Checking existing installation",
|
|
180
|
+
registry.agents[personaId] ? "FOUND (--force set, reinstalling)" : "OK (fresh install)"));
|
|
181
|
+
|
|
182
|
+
// [4/7] Backup existing workspace files
|
|
183
|
+
log(stepLine(4, INSTALL_TOTAL_STEPS, "Backing up existing files", ""));
|
|
184
|
+
const backupPath = await backupCurrentWorkspace(personaId, "install", agent);
|
|
185
|
+
log(stepLine(4, INSTALL_TOTAL_STEPS, "Backing up existing files", "OK"));
|
|
186
|
+
|
|
187
|
+
// [5/7] Install required skills
|
|
188
|
+
state = "installing_skills";
|
|
189
|
+
const skills = manifest.skills ?? [];
|
|
190
|
+
const requiredSkills = skills.filter((s) => s.required);
|
|
191
|
+
if (opts.skipSkills) {
|
|
192
|
+
log(stepLine(5, INSTALL_TOTAL_STEPS, "Checking skill dependencies", "SKIPPED (--skip-skills)"));
|
|
193
|
+
} else {
|
|
194
|
+
log(stepLine(5, INSTALL_TOTAL_STEPS, "Checking skill dependencies",
|
|
195
|
+
requiredSkills.length > 0 ? `${requiredSkills.length} required` : "OK (no dependencies)"));
|
|
196
|
+
if (requiredSkills.length > 0) {
|
|
197
|
+
const skillResult = await installRequiredSkills(skills, log, packagePath, agent);
|
|
198
|
+
if (!skillResult.allOk) {
|
|
199
|
+
log(`\n Skill installation failed. Rolling back...`);
|
|
200
|
+
state = "rolling_back";
|
|
201
|
+
await restoreFromBackup(backupPath, agent);
|
|
202
|
+
return { state: "failed", agentId: personaId, error: "required skill installation failed" };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// [6/7] Copy files to workspace
|
|
208
|
+
state = "installing_agent";
|
|
209
|
+
log(stepLine(6, INSTALL_TOTAL_STEPS, "Installing agent files", ""));
|
|
210
|
+
const installedFiles: string[] = [];
|
|
211
|
+
for (const f of [...REQUIRED_FILES, ...OPTIONAL_FILES]) {
|
|
212
|
+
const src = join(packagePath, f);
|
|
213
|
+
if (await exists(src)) {
|
|
214
|
+
await cp(src, join(workspace, f), { force: true });
|
|
215
|
+
installedFiles.push(f);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const skillsSrc = join(packagePath, "skills");
|
|
220
|
+
if (await exists(skillsSrc)) {
|
|
221
|
+
const skillsDest = join(workspace, "skills");
|
|
222
|
+
await mkdir(skillsDest, { recursive: true });
|
|
223
|
+
const entries = await readdir(skillsSrc, { withFileTypes: true });
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
const srcPath = join(skillsSrc, entry.name);
|
|
226
|
+
const destPath = join(skillsDest, entry.name);
|
|
227
|
+
if (entry.isDirectory()) {
|
|
228
|
+
await cp(srcPath, destPath, { recursive: true, force: true });
|
|
229
|
+
installedFiles.push(`skills/${entry.name}/`);
|
|
230
|
+
} else {
|
|
231
|
+
await cp(srcPath, destPath, { force: true });
|
|
232
|
+
installedFiles.push(`skills/${entry.name}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
log(stepLine(6, INSTALL_TOTAL_STEPS, "Installing agent files", `OK (${installedFiles.length} files)`));
|
|
237
|
+
|
|
238
|
+
// [7/7] Register
|
|
239
|
+
state = "registering_agent";
|
|
240
|
+
log(stepLine(7, INSTALL_TOTAL_STEPS, "Registering agent", ""));
|
|
241
|
+
const record: InstalledAgentRecord = {
|
|
242
|
+
id: personaId,
|
|
243
|
+
name: manifest.name,
|
|
244
|
+
version: manifest.version,
|
|
245
|
+
category: manifest.category ?? "",
|
|
246
|
+
author: manifest.author ?? "",
|
|
247
|
+
enabled: true,
|
|
248
|
+
installed_at: new Date().toISOString(),
|
|
249
|
+
updated_at: new Date().toISOString(),
|
|
250
|
+
source: packagePath,
|
|
251
|
+
install_path: workspace,
|
|
252
|
+
backup_path: backupPath,
|
|
253
|
+
files: installedFiles,
|
|
254
|
+
skills: skills.map((s) => s.id),
|
|
255
|
+
};
|
|
256
|
+
await registerAgent(record, agent);
|
|
257
|
+
log(stepLine(7, INSTALL_TOTAL_STEPS, "Registering agent", "OK"));
|
|
258
|
+
|
|
259
|
+
// Summary
|
|
260
|
+
state = "success";
|
|
261
|
+
log(`\n Installed: ${manifest.name}@${manifest.version}`);
|
|
262
|
+
if (installedFiles.length > 0) {
|
|
263
|
+
const shown = installedFiles.slice(0, 5).join(", ");
|
|
264
|
+
const extra = installedFiles.length > 5 ? ` +${installedFiles.length - 5} more` : "";
|
|
265
|
+
log(` Files: ${shown}${extra}`);
|
|
266
|
+
}
|
|
267
|
+
log(` Status: enabled`);
|
|
268
|
+
if (agent !== DEFAULT_AGENT) log(` Agent: ${agent}`);
|
|
269
|
+
log(` Backup: ${backupPath}`);
|
|
270
|
+
log(`\n Next: start a new chat in OpenClaw to activate the new persona\n`);
|
|
271
|
+
|
|
272
|
+
return { state: "success", agentId: personaId };
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return { state: "failed", error: (err as Error).message };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── Update Agent (5-step flow) ─────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
export interface UpdateOptions {
|
|
281
|
+
agentId: string;
|
|
282
|
+
packagePath: string;
|
|
283
|
+
/** OpenClaw agent to update within (default "main") */
|
|
284
|
+
agent?: string;
|
|
285
|
+
log: (msg: string) => void;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function updateAgent(opts: UpdateOptions): Promise<{
|
|
289
|
+
state: InstallState;
|
|
290
|
+
error?: string;
|
|
291
|
+
}> {
|
|
292
|
+
const { agentId: personaId, log } = opts;
|
|
293
|
+
const agent = opts.agent ?? DEFAULT_AGENT;
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
await ensureDirs(agent);
|
|
297
|
+
const workspace = await resolveWorkspace(agent);
|
|
298
|
+
const registry = await loadRegistry(agent);
|
|
299
|
+
const current = registry.agents[personaId];
|
|
300
|
+
if (!current) {
|
|
301
|
+
log(`\n Agent '${personaId}' is not installed.\n`);
|
|
302
|
+
return { state: "failed", error: "not installed" };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const packagePath = await resolvePackagePath(opts.packagePath);
|
|
306
|
+
const manifest = await loadManifest(packagePath);
|
|
307
|
+
|
|
308
|
+
log(`\n Updating: ${manifest.name}`);
|
|
309
|
+
log(` Current version: ${current.version}`);
|
|
310
|
+
log(` New version: ${manifest.version}`);
|
|
311
|
+
if (agent !== DEFAULT_AGENT) log(` Agent: ${agent}`);
|
|
312
|
+
log("");
|
|
313
|
+
|
|
314
|
+
// [1/5] Validate
|
|
315
|
+
log(stepLine(1, UPDATE_TOTAL_STEPS, "Validating new package", ""));
|
|
316
|
+
const validation = await validatePackage(packagePath);
|
|
317
|
+
if (!validation.valid) {
|
|
318
|
+
log(stepLine(1, UPDATE_TOTAL_STEPS, "Validating new package", "FAILED"));
|
|
319
|
+
return { state: "failed", error: "validation failed" };
|
|
320
|
+
}
|
|
321
|
+
log(stepLine(1, UPDATE_TOTAL_STEPS, "Validating new package", "OK"));
|
|
322
|
+
|
|
323
|
+
// [2/5] Check new skills
|
|
324
|
+
log(stepLine(2, UPDATE_TOTAL_STEPS, "Checking new skill dependencies", ""));
|
|
325
|
+
const oldSkills = new Set(current.skills ?? []);
|
|
326
|
+
const newSkills = new Set((manifest.skills ?? []).map((s) => s.id));
|
|
327
|
+
const addedSkills = [...newSkills].filter((s) => !oldSkills.has(s));
|
|
328
|
+
if (addedSkills.length > 0) {
|
|
329
|
+
log(stepLine(2, UPDATE_TOTAL_STEPS, "Checking new skill dependencies", `${addedSkills.length} new`));
|
|
330
|
+
for (const sid of addedSkills) log(` + ${sid}`);
|
|
331
|
+
} else {
|
|
332
|
+
log(stepLine(2, UPDATE_TOTAL_STEPS, "Checking new skill dependencies", "OK (no new)"));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// [3/5] Backup
|
|
336
|
+
log(stepLine(3, UPDATE_TOTAL_STEPS, "Backing up current version", ""));
|
|
337
|
+
const backupPath = await backupCurrentWorkspace(personaId, "update", agent);
|
|
338
|
+
log(stepLine(3, UPDATE_TOTAL_STEPS, "Backing up current version", "OK"));
|
|
339
|
+
|
|
340
|
+
// [4/5] Install new files
|
|
341
|
+
log(stepLine(4, UPDATE_TOTAL_STEPS, "Installing updated files", ""));
|
|
342
|
+
const installedFiles: string[] = [];
|
|
343
|
+
for (const f of [...REQUIRED_FILES, ...OPTIONAL_FILES]) {
|
|
344
|
+
const src = join(packagePath, f);
|
|
345
|
+
if (await exists(src)) {
|
|
346
|
+
await cp(src, join(workspace, f), { force: true });
|
|
347
|
+
installedFiles.push(f);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const skillsSrc = join(packagePath, "skills");
|
|
352
|
+
if (await exists(skillsSrc)) {
|
|
353
|
+
const skillsDest = join(workspace, "skills");
|
|
354
|
+
await mkdir(skillsDest, { recursive: true });
|
|
355
|
+
const entries = await readdir(skillsSrc, { withFileTypes: true });
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
const srcPath = join(skillsSrc, entry.name);
|
|
358
|
+
const destPath = join(skillsDest, entry.name);
|
|
359
|
+
if (entry.isDirectory()) {
|
|
360
|
+
await cp(srcPath, destPath, { recursive: true, force: true });
|
|
361
|
+
installedFiles.push(`skills/${entry.name}/`);
|
|
362
|
+
} else {
|
|
363
|
+
await cp(srcPath, destPath, { force: true });
|
|
364
|
+
installedFiles.push(`skills/${entry.name}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
log(stepLine(4, UPDATE_TOTAL_STEPS, "Installing updated files", `OK (${installedFiles.length} files)`));
|
|
369
|
+
|
|
370
|
+
// [5/5] Update registry
|
|
371
|
+
log(stepLine(5, UPDATE_TOTAL_STEPS, "Updating registry", ""));
|
|
372
|
+
await updateAgentRecord(personaId, {
|
|
373
|
+
version: manifest.version,
|
|
374
|
+
updated_at: new Date().toISOString(),
|
|
375
|
+
source: packagePath,
|
|
376
|
+
backup_path: backupPath,
|
|
377
|
+
files: installedFiles,
|
|
378
|
+
skills: [...newSkills],
|
|
379
|
+
}, agent);
|
|
380
|
+
log(stepLine(5, UPDATE_TOTAL_STEPS, "Updating registry", "OK"));
|
|
381
|
+
|
|
382
|
+
log(`\n Updated: ${manifest.name} ${current.version} -> ${manifest.version}`);
|
|
383
|
+
log(` Backup of previous version: ${backupPath}`);
|
|
384
|
+
log(`\n Next: restart your OpenClaw session to load the updated persona\n`);
|
|
385
|
+
|
|
386
|
+
return { state: "success" };
|
|
387
|
+
} catch (err) {
|
|
388
|
+
return { state: "failed", error: (err as Error).message };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { readFile, readdir, access, writeFile, stat, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
MANIFEST_FILE,
|
|
5
|
+
REQUIRED_FILES,
|
|
6
|
+
OPTIONAL_FILES,
|
|
7
|
+
PACK_TOTAL_STEPS,
|
|
8
|
+
PLUGIN_VERSION,
|
|
9
|
+
} from "../constants.js";
|
|
10
|
+
import type { AgentManifest, SkillDependency } from "../types.js";
|
|
11
|
+
import { resolveWorkspace } from "./workspace.js";
|
|
12
|
+
import { createZip } from "../utils/zip.js";
|
|
13
|
+
import { computeSha256 } from "../utils/checksum.js";
|
|
14
|
+
import { stepLine } from "../utils/output.js";
|
|
15
|
+
|
|
16
|
+
async function exists(p: string): Promise<boolean> {
|
|
17
|
+
try {
|
|
18
|
+
await access(p);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Skill detection ────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface DetectedSkill {
|
|
28
|
+
id: string;
|
|
29
|
+
confidence: "high" | "low";
|
|
30
|
+
source: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Detect skills from the package directory or current workspace.
|
|
35
|
+
*
|
|
36
|
+
* High confidence: skills listed in agent.json, or present in skills/ directory.
|
|
37
|
+
* Low confidence: skill-like references in AGENTS.md / SOUL.md.
|
|
38
|
+
*/
|
|
39
|
+
async function detectSkills(packagePath: string): Promise<DetectedSkill[]> {
|
|
40
|
+
const detected: DetectedSkill[] = [];
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
|
|
43
|
+
// From agent.json
|
|
44
|
+
const manifestPath = join(packagePath, MANIFEST_FILE);
|
|
45
|
+
if (await exists(manifestPath)) {
|
|
46
|
+
try {
|
|
47
|
+
const manifest: AgentManifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
48
|
+
for (const s of manifest.skills ?? []) {
|
|
49
|
+
if (!seen.has(s.id)) {
|
|
50
|
+
detected.push({ id: s.id, confidence: "high", source: "agent.json" });
|
|
51
|
+
seen.add(s.id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch { /* ignore */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// From skills/ directory
|
|
58
|
+
const skillsDir = join(packagePath, "skills");
|
|
59
|
+
if (await exists(skillsDir)) {
|
|
60
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const skillId = entry.name.replace(/\.md$/i, "");
|
|
63
|
+
if (!seen.has(skillId)) {
|
|
64
|
+
detected.push({ id: skillId, confidence: "high", source: "skills/ directory" });
|
|
65
|
+
seen.add(skillId);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Heuristic: scan AGENTS.md / SOUL.md for skill-like references
|
|
71
|
+
for (const mdFile of ["AGENTS.md", "SOUL.md"]) {
|
|
72
|
+
const fp = join(packagePath, mdFile);
|
|
73
|
+
if (await exists(fp)) {
|
|
74
|
+
const content = await readFile(fp, "utf-8");
|
|
75
|
+
const skillRefs = content.match(/(?:skill|能力|技能)[::]\s*([a-z0-9-]+)/gi);
|
|
76
|
+
if (skillRefs) {
|
|
77
|
+
for (const ref of skillRefs) {
|
|
78
|
+
const match = ref.match(/([a-z0-9-]+)$/i);
|
|
79
|
+
if (match && !seen.has(match[1])) {
|
|
80
|
+
detected.push({ id: match[1], confidence: "low", source: mdFile });
|
|
81
|
+
seen.add(match[1]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return detected;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Pack from directory ────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export interface PackOptions {
|
|
94
|
+
packagePath: string;
|
|
95
|
+
outputDir?: string;
|
|
96
|
+
name?: string;
|
|
97
|
+
category?: string;
|
|
98
|
+
log: (msg: string) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function packAgent(opts: PackOptions): Promise<{
|
|
102
|
+
zipPath: string;
|
|
103
|
+
sizeKb: number;
|
|
104
|
+
manifest: AgentManifest;
|
|
105
|
+
} | null> {
|
|
106
|
+
const { packagePath, log } = opts;
|
|
107
|
+
|
|
108
|
+
// [1/3] Validate
|
|
109
|
+
log(stepLine(1, PACK_TOTAL_STEPS, "Validating package", ""));
|
|
110
|
+
let manifest: AgentManifest;
|
|
111
|
+
const manifestPath = join(packagePath, MANIFEST_FILE);
|
|
112
|
+
if (await exists(manifestPath)) {
|
|
113
|
+
manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
114
|
+
} else {
|
|
115
|
+
log(` No ${MANIFEST_FILE} found. Generating from directory contents...`);
|
|
116
|
+
const dirName = basename(packagePath);
|
|
117
|
+
manifest = {
|
|
118
|
+
id: dirName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
|
119
|
+
slug: dirName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
|
120
|
+
name: opts.name ?? dirName,
|
|
121
|
+
subtitle: "",
|
|
122
|
+
description: "",
|
|
123
|
+
version: "1.0.0",
|
|
124
|
+
author: "",
|
|
125
|
+
license: "Commercial",
|
|
126
|
+
price: 0,
|
|
127
|
+
currency: "USD",
|
|
128
|
+
category: opts.category ?? "general",
|
|
129
|
+
tags: [],
|
|
130
|
+
openclaw_version: ">=0.8.0",
|
|
131
|
+
entry_files: [],
|
|
132
|
+
skills: [],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Validate required files
|
|
137
|
+
const missingRequired: string[] = [];
|
|
138
|
+
for (const f of REQUIRED_FILES) {
|
|
139
|
+
if (!(await exists(join(packagePath, f)))) {
|
|
140
|
+
missingRequired.push(f);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (missingRequired.length > 0) {
|
|
144
|
+
log(stepLine(1, PACK_TOTAL_STEPS, "Validating package", "FAILED"));
|
|
145
|
+
for (const f of missingRequired) log(` - Missing: ${f}`);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
log(stepLine(1, PACK_TOTAL_STEPS, "Validating package", "OK"));
|
|
149
|
+
|
|
150
|
+
// [2/3] Detect skills and compute checksums
|
|
151
|
+
log(stepLine(2, PACK_TOTAL_STEPS, "Detecting skills & checksums", ""));
|
|
152
|
+
const detectedSkills = await detectSkills(packagePath);
|
|
153
|
+
const highConfidence = detectedSkills.filter((s) => s.confidence === "high");
|
|
154
|
+
const lowConfidence = detectedSkills.filter((s) => s.confidence === "low");
|
|
155
|
+
|
|
156
|
+
if (highConfidence.length > 0) {
|
|
157
|
+
log(` Detected Skills: ${highConfidence.map((s) => s.id).join(", ")}`);
|
|
158
|
+
}
|
|
159
|
+
if (lowConfidence.length > 0) {
|
|
160
|
+
log(` Suggested Skills: ${lowConfidence.map((s) => s.id).join(", ")}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Update manifest skills from detection if empty
|
|
164
|
+
if (manifest.skills.length === 0 && highConfidence.length > 0) {
|
|
165
|
+
manifest.skills = highConfidence.map((s) => ({
|
|
166
|
+
id: s.id,
|
|
167
|
+
version: "^1.0.0",
|
|
168
|
+
required: true,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Update entry_files from actual files
|
|
173
|
+
const actualEntryFiles: string[] = [];
|
|
174
|
+
for (const f of [...REQUIRED_FILES, ...OPTIONAL_FILES]) {
|
|
175
|
+
if (await exists(join(packagePath, f))) {
|
|
176
|
+
actualEntryFiles.push(f);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
manifest.entry_files = actualEntryFiles;
|
|
180
|
+
|
|
181
|
+
// Set pack metadata
|
|
182
|
+
manifest.packed_at = new Date().toISOString();
|
|
183
|
+
manifest.packaged_by = `clawstore-plugin@${PLUGIN_VERSION}`;
|
|
184
|
+
|
|
185
|
+
// Compute checksum for SOUL.md
|
|
186
|
+
const soulPath = join(packagePath, "SOUL.md");
|
|
187
|
+
if (await exists(soulPath)) {
|
|
188
|
+
manifest.checksum = await computeSha256(soulPath);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Write updated manifest back
|
|
192
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
193
|
+
log(stepLine(2, PACK_TOTAL_STEPS, "Detecting skills & checksums", "OK"));
|
|
194
|
+
|
|
195
|
+
// [3/3] Create ZIP
|
|
196
|
+
const outputDir = opts.outputDir ?? join(packagePath, "..");
|
|
197
|
+
await mkdir(outputDir, { recursive: true });
|
|
198
|
+
const zipName = `${manifest.id}-v${manifest.version}.zip`;
|
|
199
|
+
const zipPath = join(outputDir, zipName);
|
|
200
|
+
|
|
201
|
+
log(stepLine(3, PACK_TOTAL_STEPS, `Creating ZIP: ${zipName}`, ""));
|
|
202
|
+
const result = await createZip(packagePath, zipPath);
|
|
203
|
+
const sizeKb = result.sizeBytes / 1024;
|
|
204
|
+
log(stepLine(3, PACK_TOTAL_STEPS, `Creating ZIP: ${zipName}`, "OK"));
|
|
205
|
+
|
|
206
|
+
log(`\n Package: ${zipPath}`);
|
|
207
|
+
log(` Size: ${sizeKb.toFixed(1)} KB`);
|
|
208
|
+
log(`\n Ready to upload to Clawstore or distribute as ZIP.\n`);
|
|
209
|
+
|
|
210
|
+
return { zipPath, sizeKb, manifest };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Pack from current workspace ────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
export async function packCurrentAgent(opts: {
|
|
216
|
+
name?: string;
|
|
217
|
+
category?: string;
|
|
218
|
+
outputDir?: string;
|
|
219
|
+
log: (msg: string) => void;
|
|
220
|
+
}): Promise<ReturnType<typeof packAgent>> {
|
|
221
|
+
const workspace = await resolveWorkspace();
|
|
222
|
+
return packAgent({
|
|
223
|
+
packagePath: workspace,
|
|
224
|
+
outputDir: opts.outputDir,
|
|
225
|
+
name: opts.name,
|
|
226
|
+
category: opts.category,
|
|
227
|
+
log: opts.log,
|
|
228
|
+
});
|
|
229
|
+
}
|