@lazyingart/agintiflow 0.20.47 → 0.20.49
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/docs/skillmesh.md +158 -0
- package/package.json +4 -2
- package/references/skill-mesh-sharing-design.md +1123 -0
- package/scripts/smoke-skillmesh.js +128 -0
- package/src/cli.js +14 -2
- package/src/i18n.js +1 -0
- package/src/interactive-cli.js +40 -0
- package/src/skill-library.js +66 -3
- package/src/skillmesh.js +1284 -0
package/src/skillmesh.js
ADDED
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import fsSync from "node:fs";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import express from "express";
|
|
11
|
+
import { agintiflowHome } from "./session-index.js";
|
|
12
|
+
import { loadDatabaseSync } from "./sqlite.js";
|
|
13
|
+
import { loadSkillFile, listSkills } from "./skill-library.js";
|
|
14
|
+
import { redactSensitiveText } from "./redaction.js";
|
|
15
|
+
|
|
16
|
+
const PROTOCOL_VERSION = 1;
|
|
17
|
+
const MAX_PACK_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
const MAX_SKILLS_PER_PACK = 5;
|
|
19
|
+
const MAX_SKILL_BYTES = 80 * 1024;
|
|
20
|
+
const MAX_FEED_PACKS = 200;
|
|
21
|
+
const DEFAULT_NODE_URL = "https://skills.flow.lazying.art";
|
|
22
|
+
const DEFAULT_SERVICE_NAME = "aginti-skill-relay";
|
|
23
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const SAFE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{1,80}$/;
|
|
25
|
+
const FORBIDDEN_PATH_PARTS = [
|
|
26
|
+
".env",
|
|
27
|
+
".git",
|
|
28
|
+
".aginti-sessions",
|
|
29
|
+
".sessions",
|
|
30
|
+
"events.jsonl",
|
|
31
|
+
"state.json",
|
|
32
|
+
"storage-state.json",
|
|
33
|
+
"session.json",
|
|
34
|
+
"cookies.json",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export function skillMeshPaths(home = agintiflowHome()) {
|
|
38
|
+
const root = path.join(home, "skillmesh");
|
|
39
|
+
return {
|
|
40
|
+
root,
|
|
41
|
+
configPath: path.join(root, "config.json"),
|
|
42
|
+
identityPath: path.join(root, "identity.json"),
|
|
43
|
+
indexDbPath: path.join(root, "index.sqlite"),
|
|
44
|
+
skillsDir: path.join(root, "skills"),
|
|
45
|
+
packsDir: path.join(root, "packs"),
|
|
46
|
+
inboxDir: path.join(root, "inbox"),
|
|
47
|
+
outboxDir: path.join(root, "outbox"),
|
|
48
|
+
reviewedDir: path.join(root, "reviewed"),
|
|
49
|
+
rejectedDir: path.join(root, "rejected"),
|
|
50
|
+
feedsDir: path.join(root, "feeds"),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function defaultSkillMeshConfig() {
|
|
55
|
+
return {
|
|
56
|
+
version: 1,
|
|
57
|
+
mode: "share",
|
|
58
|
+
nodes: [{ name: "lazyingart", url: DEFAULT_NODE_URL, role: "major", enabled: true }],
|
|
59
|
+
shareRequiresReview: true,
|
|
60
|
+
autoEnableImportedSkills: false,
|
|
61
|
+
downloadPolicy: {
|
|
62
|
+
autoDownloadMetadata: true,
|
|
63
|
+
autoDownloadPacks: false,
|
|
64
|
+
autoEnableSkills: false,
|
|
65
|
+
allowPolicyChanges: false,
|
|
66
|
+
allowExecutableTests: false,
|
|
67
|
+
},
|
|
68
|
+
syncPolicy: {
|
|
69
|
+
idleOnly: true,
|
|
70
|
+
minIntervalMinutes: 360,
|
|
71
|
+
maxOutboundPacksPerDay: 3,
|
|
72
|
+
maxInboundPacksPerSync: 20,
|
|
73
|
+
metadataFirst: true,
|
|
74
|
+
lastSyncAt: "",
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function nowIso() {
|
|
80
|
+
return new Date().toISOString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeUrl(value = "") {
|
|
84
|
+
return String(value || "").trim().replace(/\/+$/, "");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readJson(filePath, fallback = null) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
90
|
+
} catch {
|
|
91
|
+
return fallback;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readJsonSync(filePath, fallback = null) {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(fsSync.readFileSync(filePath, "utf8"));
|
|
98
|
+
} catch {
|
|
99
|
+
return fallback;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function writeJson(filePath, value) {
|
|
104
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
105
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function ensureSkillMeshHome(home = agintiflowHome()) {
|
|
109
|
+
const paths = skillMeshPaths(home);
|
|
110
|
+
await Promise.all([
|
|
111
|
+
fs.mkdir(paths.root, { recursive: true }),
|
|
112
|
+
fs.mkdir(paths.skillsDir, { recursive: true }),
|
|
113
|
+
fs.mkdir(paths.packsDir, { recursive: true }),
|
|
114
|
+
fs.mkdir(paths.inboxDir, { recursive: true }),
|
|
115
|
+
fs.mkdir(paths.outboxDir, { recursive: true }),
|
|
116
|
+
fs.mkdir(paths.reviewedDir, { recursive: true }),
|
|
117
|
+
fs.mkdir(paths.rejectedDir, { recursive: true }),
|
|
118
|
+
fs.mkdir(paths.feedsDir, { recursive: true }),
|
|
119
|
+
]);
|
|
120
|
+
if (!(await exists(paths.configPath))) await writeJson(paths.configPath, defaultSkillMeshConfig());
|
|
121
|
+
ensureSkillMeshDb(paths.indexDbPath);
|
|
122
|
+
return paths;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function exists(filePath) {
|
|
126
|
+
try {
|
|
127
|
+
await fs.access(filePath);
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function loadSkillMeshConfig(home = agintiflowHome()) {
|
|
135
|
+
const paths = await ensureSkillMeshHome(home);
|
|
136
|
+
const loaded = await readJson(paths.configPath, {});
|
|
137
|
+
const defaults = defaultSkillMeshConfig();
|
|
138
|
+
return {
|
|
139
|
+
...defaults,
|
|
140
|
+
...loaded,
|
|
141
|
+
nodes: Array.isArray(loaded.nodes) && loaded.nodes.length ? loaded.nodes : defaults.nodes,
|
|
142
|
+
downloadPolicy: { ...defaults.downloadPolicy, ...(loaded.downloadPolicy || {}) },
|
|
143
|
+
syncPolicy: { ...defaults.syncPolicy, ...(loaded.syncPolicy || {}) },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function loadSkillMeshConfigSync(home = agintiflowHome()) {
|
|
148
|
+
const paths = skillMeshPaths(home);
|
|
149
|
+
const loaded = readJsonSync(paths.configPath, {});
|
|
150
|
+
const defaults = defaultSkillMeshConfig();
|
|
151
|
+
return {
|
|
152
|
+
...defaults,
|
|
153
|
+
...loaded,
|
|
154
|
+
nodes: Array.isArray(loaded.nodes) && loaded.nodes.length ? loaded.nodes : defaults.nodes,
|
|
155
|
+
downloadPolicy: { ...defaults.downloadPolicy, ...(loaded.downloadPolicy || {}) },
|
|
156
|
+
syncPolicy: { ...defaults.syncPolicy, ...(loaded.syncPolicy || {}) },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function saveSkillMeshConfig(config, home = agintiflowHome()) {
|
|
161
|
+
const paths = await ensureSkillMeshHome(home);
|
|
162
|
+
await writeJson(paths.configPath, config);
|
|
163
|
+
return config;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function setSkillMeshMode(mode, home = agintiflowHome()) {
|
|
167
|
+
const normalized = normalizeMode(mode);
|
|
168
|
+
const config = await loadSkillMeshConfig(home);
|
|
169
|
+
config.mode = normalized;
|
|
170
|
+
await saveSkillMeshConfig(config, home);
|
|
171
|
+
return config;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function addSkillMeshNode(name, url, { home = agintiflowHome(), role = "volunteer" } = {}) {
|
|
175
|
+
const cleanName = String(name || "").trim() || "node";
|
|
176
|
+
const cleanUrl = normalizeUrl(url);
|
|
177
|
+
if (!/^https?:\/\//.test(cleanUrl)) throw new Error(`Invalid node URL: ${url}`);
|
|
178
|
+
const config = await loadSkillMeshConfig(home);
|
|
179
|
+
const nextNode = { name: cleanName, url: cleanUrl, role, enabled: true, addedAt: nowIso() };
|
|
180
|
+
config.nodes = (config.nodes || []).filter((node) => normalizeUrl(node.url) !== cleanUrl && node.name !== cleanName);
|
|
181
|
+
config.nodes.push(nextNode);
|
|
182
|
+
await saveSkillMeshConfig(config, home);
|
|
183
|
+
return nextNode;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function removeSkillMeshNode(nameOrUrl, { home = agintiflowHome() } = {}) {
|
|
187
|
+
const target = String(nameOrUrl || "").trim();
|
|
188
|
+
const config = await loadSkillMeshConfig(home);
|
|
189
|
+
const before = (config.nodes || []).length;
|
|
190
|
+
config.nodes = (config.nodes || []).filter((node) => node.name !== target && normalizeUrl(node.url) !== normalizeUrl(target));
|
|
191
|
+
await saveSkillMeshConfig(config, home);
|
|
192
|
+
return before - config.nodes.length;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function normalizeMode(value = "") {
|
|
196
|
+
const text = String(value || "").toLowerCase().trim();
|
|
197
|
+
if (["off", "disable", "disabled", "none", "0"].includes(text)) return "off";
|
|
198
|
+
if (["record", "local", "private"].includes(text)) return "record";
|
|
199
|
+
if (["share", "record-share", "record+share", "sync", "on", "1", ""].includes(text)) return "share";
|
|
200
|
+
throw new Error(`Unknown Skill Mesh mode: ${value}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function skillMeshModeChoices() {
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
value: "share",
|
|
207
|
+
label: "Record + Share Reviewed Skills",
|
|
208
|
+
description: "Default. Record locally; share only signed, reviewed, redacted skill packs.",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
value: "record",
|
|
212
|
+
label: "Record Locally",
|
|
213
|
+
description: "Keep sanitized skill-learning metadata local; no network sync.",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
value: "off",
|
|
217
|
+
label: "Disabled",
|
|
218
|
+
description: "Do not use Skill Mesh recording, imports, or sync.",
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function formatSkillMeshStatus(config) {
|
|
224
|
+
const nodes = (config.nodes || []).filter((node) => node.enabled !== false);
|
|
225
|
+
return [
|
|
226
|
+
`Skill Mesh: ${config.mode}`,
|
|
227
|
+
`Storage: ${skillMeshPaths().root}`,
|
|
228
|
+
`Default node: ${nodes[0]?.url || "(none)"}`,
|
|
229
|
+
"Sharing model: signed reviewed skill packs only; no raw chats, sessions, project files, .env files, cookies, or artifacts.",
|
|
230
|
+
"Community imports are installed disabled by default until explicitly enabled.",
|
|
231
|
+
].join("\n");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ensureSkillMeshDb(indexDbPath) {
|
|
235
|
+
fsSync.mkdirSync(path.dirname(indexDbPath), { recursive: true });
|
|
236
|
+
const DatabaseSync = loadDatabaseSync();
|
|
237
|
+
const db = new DatabaseSync(indexDbPath);
|
|
238
|
+
db.exec(`
|
|
239
|
+
CREATE TABLE IF NOT EXISTS packs (
|
|
240
|
+
pack_hash TEXT PRIMARY KEY,
|
|
241
|
+
canonical_id TEXT NOT NULL,
|
|
242
|
+
name TEXT NOT NULL,
|
|
243
|
+
version TEXT NOT NULL,
|
|
244
|
+
author_key_id TEXT NOT NULL,
|
|
245
|
+
status TEXT NOT NULL,
|
|
246
|
+
trust_level TEXT NOT NULL,
|
|
247
|
+
value_score INTEGER NOT NULL DEFAULT 0,
|
|
248
|
+
created_at TEXT NOT NULL,
|
|
249
|
+
received_at TEXT NOT NULL,
|
|
250
|
+
last_seen_at TEXT NOT NULL,
|
|
251
|
+
metadata_json TEXT NOT NULL
|
|
252
|
+
);
|
|
253
|
+
CREATE TABLE IF NOT EXISTS installed_packs (
|
|
254
|
+
pack_hash TEXT PRIMARY KEY,
|
|
255
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
256
|
+
installed_at TEXT NOT NULL,
|
|
257
|
+
source_feed TEXT NOT NULL DEFAULT ''
|
|
258
|
+
);
|
|
259
|
+
CREATE TABLE IF NOT EXISTS rejected_packs (
|
|
260
|
+
pack_hash TEXT PRIMARY KEY,
|
|
261
|
+
reason TEXT NOT NULL,
|
|
262
|
+
rejected_at TEXT NOT NULL
|
|
263
|
+
);
|
|
264
|
+
CREATE TABLE IF NOT EXISTS verified_nodes (
|
|
265
|
+
node_id TEXT PRIMARY KEY,
|
|
266
|
+
public_url TEXT NOT NULL,
|
|
267
|
+
role TEXT NOT NULL,
|
|
268
|
+
protocol INTEGER NOT NULL,
|
|
269
|
+
status TEXT NOT NULL,
|
|
270
|
+
first_verified_at TEXT NOT NULL,
|
|
271
|
+
last_verified_at TEXT NOT NULL,
|
|
272
|
+
last_seen_at TEXT NOT NULL,
|
|
273
|
+
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
274
|
+
metadata_json TEXT NOT NULL
|
|
275
|
+
);
|
|
276
|
+
`);
|
|
277
|
+
return db;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function openSkillMeshDb(home = agintiflowHome()) {
|
|
281
|
+
const paths = skillMeshPaths(home);
|
|
282
|
+
return ensureSkillMeshDb(paths.indexDbPath);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function safeSkillId(id = "") {
|
|
286
|
+
const text = String(id || "").trim();
|
|
287
|
+
return SAFE_ID_PATTERN.test(text) ? text : "";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function assertSafePath(relativePath = "") {
|
|
291
|
+
const normalized = path.posix.normalize(String(relativePath || "").replace(/\\/g, "/"));
|
|
292
|
+
if (!normalized || normalized.startsWith("../") || normalized.startsWith("/") || normalized.includes("/../")) {
|
|
293
|
+
throw new Error(`Unsafe pack path: ${relativePath}`);
|
|
294
|
+
}
|
|
295
|
+
const lower = normalized.toLowerCase();
|
|
296
|
+
for (const forbidden of FORBIDDEN_PATH_PARTS) {
|
|
297
|
+
if (lower === forbidden || lower.includes(`/${forbidden}`) || lower.includes(`${forbidden}/`)) {
|
|
298
|
+
throw new Error(`Forbidden private path in pack: ${relativePath}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return normalized;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function stableStringify(value) {
|
|
305
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
306
|
+
if (value && typeof value === "object") {
|
|
307
|
+
return `{${Object.keys(value)
|
|
308
|
+
.sort()
|
|
309
|
+
.filter((key) => value[key] !== undefined && typeof value[key] !== "function")
|
|
310
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
311
|
+
.join(",")}}`;
|
|
312
|
+
}
|
|
313
|
+
return JSON.stringify(value);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function sha256(value) {
|
|
317
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function canonicalSkillContent(content = "") {
|
|
321
|
+
return String(content || "").replace(/\r\n/g, "\n").trim();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function canonicalPackForSignature(pack) {
|
|
325
|
+
const copy = { ...pack };
|
|
326
|
+
delete copy.signature;
|
|
327
|
+
delete copy.packHash;
|
|
328
|
+
return copy;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function canonicalPackId(skills = []) {
|
|
332
|
+
const semantic = skills.map((skill) => ({ id: skill.id, hash: skill.hash })).sort((a, b) => a.id.localeCompare(b.id));
|
|
333
|
+
return `skillpack:${sha256(stableStringify(semantic)).slice(0, 24)}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function ensureIdentity(home = agintiflowHome()) {
|
|
337
|
+
const paths = await ensureSkillMeshHome(home);
|
|
338
|
+
const loaded = await readJson(paths.identityPath, null);
|
|
339
|
+
if (loaded?.privateKey && loaded?.publicKey && loaded?.keyId) return loaded;
|
|
340
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
341
|
+
const publicPem = publicKey.export({ type: "spki", format: "pem" });
|
|
342
|
+
const privatePem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
343
|
+
const identity = {
|
|
344
|
+
createdAt: nowIso(),
|
|
345
|
+
keyId: `ed25519:${sha256(publicPem).slice(0, 24)}`,
|
|
346
|
+
publicKey: publicPem,
|
|
347
|
+
privateKey: privatePem,
|
|
348
|
+
};
|
|
349
|
+
await writeJson(paths.identityPath, identity);
|
|
350
|
+
await fs.chmod(paths.identityPath, 0o600).catch(() => {});
|
|
351
|
+
return identity;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function signPack(pack, identity) {
|
|
355
|
+
const payload = Buffer.from(stableStringify(canonicalPackForSignature(pack)));
|
|
356
|
+
const privateKey = crypto.createPrivateKey(identity.privateKey);
|
|
357
|
+
const signature = crypto.sign(null, payload, privateKey).toString("base64");
|
|
358
|
+
return {
|
|
359
|
+
algorithm: "ed25519",
|
|
360
|
+
keyId: identity.keyId,
|
|
361
|
+
publicKey: identity.publicKey,
|
|
362
|
+
value: signature,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function verifyPackSignature(pack) {
|
|
367
|
+
const signature = pack?.signature;
|
|
368
|
+
if (!signature?.publicKey || !signature?.value || signature.algorithm !== "ed25519") {
|
|
369
|
+
return { ok: false, reason: "missing or unsupported signature" };
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const publicKey = crypto.createPublicKey(signature.publicKey);
|
|
373
|
+
const payload = Buffer.from(stableStringify(canonicalPackForSignature(pack)));
|
|
374
|
+
const ok = crypto.verify(null, payload, publicKey, Buffer.from(signature.value, "base64"));
|
|
375
|
+
const expectedKeyId = `ed25519:${sha256(signature.publicKey).slice(0, 24)}`;
|
|
376
|
+
if (!ok) return { ok: false, reason: "signature verification failed" };
|
|
377
|
+
if (signature.keyId && signature.keyId !== expectedKeyId) return { ok: false, reason: "signature key id mismatch" };
|
|
378
|
+
return { ok: true, keyId: expectedKeyId };
|
|
379
|
+
} catch (error) {
|
|
380
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function validateSkillMarkdownContent(content = "", context = "SKILL.md") {
|
|
385
|
+
const text = String(content || "");
|
|
386
|
+
if (!text.trim()) throw new Error(`${context}: empty skill`);
|
|
387
|
+
if (Buffer.byteLength(text, "utf8") > MAX_SKILL_BYTES) throw new Error(`${context}: skill is too large`);
|
|
388
|
+
if (redactSensitiveText(text) !== text) throw new Error(`${context}: contains token-like secret text`);
|
|
389
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
390
|
+
if (!match) throw new Error(`${context}: missing YAML frontmatter`);
|
|
391
|
+
const frontmatter = match[1];
|
|
392
|
+
const fields = {};
|
|
393
|
+
for (const line of frontmatter.split(/\r?\n/)) {
|
|
394
|
+
const scalar = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
395
|
+
if (!scalar) continue;
|
|
396
|
+
const value = scalar[2].trim().replace(/^['"]|['"]$/g, "");
|
|
397
|
+
if (value) fields[scalar[1]] = value;
|
|
398
|
+
}
|
|
399
|
+
for (const field of ["id", "label", "description"]) {
|
|
400
|
+
if (!fields[field]) throw new Error(`${context}: ${field} is required`);
|
|
401
|
+
}
|
|
402
|
+
if (!safeSkillId(fields.id)) throw new Error(`${context}: invalid skill id ${fields.id}`);
|
|
403
|
+
return fields;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function builtInSkillIds() {
|
|
407
|
+
return new Set(listSkills({ includeBody: false, includeSkillMesh: false }).map((skill) => skill.id));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function buildSkillPack(skillId, { home = agintiflowHome(), author = "local-user", valueScore = 80 } = {}) {
|
|
411
|
+
const id = safeSkillId(skillId);
|
|
412
|
+
if (!id) throw new Error(`Invalid skill id: ${skillId}`);
|
|
413
|
+
const skills = listSkills({ includeBody: false });
|
|
414
|
+
const skill = skills.find((item) => item.id === id);
|
|
415
|
+
if (!skill?.path) throw new Error(`Skill not found: ${id}`);
|
|
416
|
+
const content = await fs.readFile(skill.path, "utf8");
|
|
417
|
+
return buildSkillPackFromMarkdown(content, { home, author, valueScore });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function buildSkillPackFromMarkdown(content, { home = agintiflowHome(), author = "local-user", valueScore = 80 } = {}) {
|
|
421
|
+
const fields = validateSkillMarkdownContent(content, "SKILL.md");
|
|
422
|
+
const id = fields.id;
|
|
423
|
+
const normalized = canonicalSkillContent(content);
|
|
424
|
+
const entry = {
|
|
425
|
+
id,
|
|
426
|
+
path: `skills/${id}/SKILL.md`,
|
|
427
|
+
content: normalized,
|
|
428
|
+
hash: `sha256:${sha256(normalized)}`,
|
|
429
|
+
};
|
|
430
|
+
const identity = await ensureIdentity(home);
|
|
431
|
+
const pack = {
|
|
432
|
+
schema: 1,
|
|
433
|
+
kind: "agintiflow-skillpack",
|
|
434
|
+
protocol: PROTOCOL_VERSION,
|
|
435
|
+
name: id,
|
|
436
|
+
version: "0.1.0",
|
|
437
|
+
author,
|
|
438
|
+
license: "Apache-2.0",
|
|
439
|
+
createdAt: nowIso(),
|
|
440
|
+
authorKeyId: identity.keyId,
|
|
441
|
+
source: "reviewed-local-learning",
|
|
442
|
+
privacy: {
|
|
443
|
+
rawSessionsIncluded: false,
|
|
444
|
+
secretsRedacted: true,
|
|
445
|
+
pathsMasked: true,
|
|
446
|
+
requiresHumanReview: true,
|
|
447
|
+
},
|
|
448
|
+
valueScore: Number(valueScore) || 80,
|
|
449
|
+
contents: { skills: [id], profiles: [], policies: [], tests: [] },
|
|
450
|
+
skills: [entry],
|
|
451
|
+
};
|
|
452
|
+
pack.canonicalId = canonicalPackId(pack.skills);
|
|
453
|
+
pack.signature = signPack(pack, identity);
|
|
454
|
+
pack.packHash = `sha256:${sha256(stableStringify(canonicalPackForHash(pack)))}`;
|
|
455
|
+
return pack;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function canonicalPackForHash(pack) {
|
|
459
|
+
const copy = { ...pack };
|
|
460
|
+
delete copy.packHash;
|
|
461
|
+
return copy;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function validateSkillPack(pack, { requireSignature = true, allowBuiltInOverride = false } = {}) {
|
|
465
|
+
const errors = [];
|
|
466
|
+
const normalized = pack && typeof pack === "object" ? pack : null;
|
|
467
|
+
if (!normalized) throw new Error("Skill pack must be a JSON object");
|
|
468
|
+
if (normalized.schema !== 1 || normalized.kind !== "agintiflow-skillpack") errors.push("unsupported skill pack schema");
|
|
469
|
+
if (normalized.protocol && Number(normalized.protocol) !== PROTOCOL_VERSION) errors.push("unsupported protocol version");
|
|
470
|
+
if (!normalized.privacy || normalized.privacy.rawSessionsIncluded !== false) errors.push("privacy.rawSessionsIncluded must be false");
|
|
471
|
+
if (normalized.privacy?.secretsRedacted !== true) errors.push("privacy.secretsRedacted must be true");
|
|
472
|
+
if (normalized.privacy?.requiresHumanReview !== true) errors.push("privacy.requiresHumanReview must be true");
|
|
473
|
+
const skills = Array.isArray(normalized.skills) ? normalized.skills : [];
|
|
474
|
+
if (skills.length < 1 || skills.length > MAX_SKILLS_PER_PACK) errors.push(`pack must include 1-${MAX_SKILLS_PER_PACK} skills`);
|
|
475
|
+
const coreIds = builtInSkillIds();
|
|
476
|
+
for (const skill of skills) {
|
|
477
|
+
const id = safeSkillId(skill?.id);
|
|
478
|
+
if (!id) {
|
|
479
|
+
errors.push(`invalid skill id: ${skill?.id || ""}`);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (!allowBuiltInOverride && coreIds.has(id)) errors.push(`pack cannot override built-in skill: ${id}`);
|
|
483
|
+
try {
|
|
484
|
+
assertSafePath(skill.path || `skills/${id}/SKILL.md`);
|
|
485
|
+
const fields = validateSkillMarkdownContent(skill.content || "", skill.path || id);
|
|
486
|
+
if (fields.id !== id) errors.push(`skill frontmatter id mismatch for ${id}`);
|
|
487
|
+
const expectedHash = `sha256:${sha256(canonicalSkillContent(skill.content || ""))}`;
|
|
488
|
+
if (skill.hash && skill.hash !== expectedHash) errors.push(`skill hash mismatch for ${id}`);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const signature = verifyPackSignature(normalized);
|
|
494
|
+
if (requireSignature && !signature.ok) errors.push(signature.reason);
|
|
495
|
+
const computedHash = `sha256:${sha256(stableStringify(canonicalPackForHash(normalized)))}`;
|
|
496
|
+
if (normalized.packHash && normalized.packHash !== computedHash) {
|
|
497
|
+
// Older local packs may omit this exact form; treat mismatch as a hard error only if contents are otherwise suspicious.
|
|
498
|
+
errors.push("pack hash mismatch");
|
|
499
|
+
}
|
|
500
|
+
if (redactSensitiveText(stableStringify(normalized)) !== stableStringify(normalized)) {
|
|
501
|
+
errors.push("pack contains token-like secret text");
|
|
502
|
+
}
|
|
503
|
+
if (Buffer.byteLength(JSON.stringify(normalized), "utf8") > MAX_PACK_BYTES) errors.push("pack is too large");
|
|
504
|
+
if (errors.length) throw new Error(errors.join("; "));
|
|
505
|
+
return {
|
|
506
|
+
ok: true,
|
|
507
|
+
keyId: signature.keyId || normalized.authorKeyId || "",
|
|
508
|
+
packHash: normalized.packHash || computedHash,
|
|
509
|
+
canonicalId: normalized.canonicalId || canonicalPackId(skills),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function packMetadata(pack, validation = null, overrides = {}) {
|
|
514
|
+
const checked = validation || validateSkillPack(pack, { allowBuiltInOverride: true });
|
|
515
|
+
return {
|
|
516
|
+
packHash: checked.packHash,
|
|
517
|
+
canonicalId: checked.canonicalId,
|
|
518
|
+
name: pack.name || pack.contents?.skills?.[0] || "skillpack",
|
|
519
|
+
version: pack.version || "0.1.0",
|
|
520
|
+
authorKeyId: checked.keyId || pack.authorKeyId || "",
|
|
521
|
+
trustLevel: overrides.trustLevel || pack.trustLevel || "community-reviewed",
|
|
522
|
+
valueScore: Number(overrides.valueScore ?? pack.valueScore ?? 80) || 80,
|
|
523
|
+
status: overrides.status || pack.status || "approved",
|
|
524
|
+
createdAt: pack.createdAt || nowIso(),
|
|
525
|
+
skills: pack.contents?.skills || pack.skills?.map((skill) => skill.id) || [],
|
|
526
|
+
signatureKeyId: pack.signature?.keyId || "",
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function upsertPackRecord(db, metadata, status = metadata.status || "approved") {
|
|
531
|
+
const timestamp = nowIso();
|
|
532
|
+
db.prepare(
|
|
533
|
+
`INSERT INTO packs (
|
|
534
|
+
pack_hash, canonical_id, name, version, author_key_id, status, trust_level, value_score,
|
|
535
|
+
created_at, received_at, last_seen_at, metadata_json
|
|
536
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
537
|
+
ON CONFLICT(pack_hash) DO UPDATE SET
|
|
538
|
+
last_seen_at = excluded.last_seen_at,
|
|
539
|
+
status = CASE WHEN excluded.status != '' THEN excluded.status ELSE packs.status END,
|
|
540
|
+
metadata_json = excluded.metadata_json`
|
|
541
|
+
).run(
|
|
542
|
+
metadata.packHash,
|
|
543
|
+
metadata.canonicalId,
|
|
544
|
+
metadata.name,
|
|
545
|
+
metadata.version,
|
|
546
|
+
metadata.authorKeyId,
|
|
547
|
+
status,
|
|
548
|
+
metadata.trustLevel,
|
|
549
|
+
metadata.valueScore,
|
|
550
|
+
metadata.createdAt,
|
|
551
|
+
timestamp,
|
|
552
|
+
timestamp,
|
|
553
|
+
JSON.stringify(metadata)
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export async function exportSkillPack(skillId, { outPath = "", home = agintiflowHome() } = {}) {
|
|
558
|
+
const paths = await ensureSkillMeshHome(home);
|
|
559
|
+
const pack = await buildSkillPack(skillId, { home });
|
|
560
|
+
const target = outPath || path.join(paths.reviewedDir, `${pack.name}.skillpack.json`);
|
|
561
|
+
await writeJson(target, pack);
|
|
562
|
+
const db = openSkillMeshDb(home);
|
|
563
|
+
const metadata = packMetadata(pack, validateSkillPack(pack, { allowBuiltInOverride: true }), {
|
|
564
|
+
trustLevel: "local-reviewed",
|
|
565
|
+
status: "reviewed",
|
|
566
|
+
});
|
|
567
|
+
upsertPackRecord(db, metadata, "reviewed");
|
|
568
|
+
return { pack, path: target, metadata };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export async function importSkillPack(inputPath, { home = agintiflowHome(), enabled = false, sourceFeed = "" } = {}) {
|
|
572
|
+
const paths = await ensureSkillMeshHome(home);
|
|
573
|
+
const text = await fs.readFile(inputPath, "utf8");
|
|
574
|
+
const pack = JSON.parse(text);
|
|
575
|
+
return installSkillPack(pack, { home, enabled, sourceFeed });
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export async function installSkillPack(pack, { home = agintiflowHome(), enabled = false, sourceFeed = "" } = {}) {
|
|
579
|
+
const paths = await ensureSkillMeshHome(home);
|
|
580
|
+
const validation = validateSkillPack(pack);
|
|
581
|
+
const db = openSkillMeshDb(home);
|
|
582
|
+
const metadata = packMetadata(pack, validation, {
|
|
583
|
+
trustLevel: sourceFeed ? "community-reviewed" : "local-reviewed",
|
|
584
|
+
status: "installed",
|
|
585
|
+
});
|
|
586
|
+
for (const skill of pack.skills || []) {
|
|
587
|
+
const id = safeSkillId(skill.id);
|
|
588
|
+
if (!id) throw new Error(`Invalid skill id: ${skill.id}`);
|
|
589
|
+
const skillDir = path.join(paths.skillsDir, id);
|
|
590
|
+
await fs.mkdir(skillDir, { recursive: true });
|
|
591
|
+
await fs.writeFile(path.join(skillDir, "SKILL.md"), `${canonicalSkillContent(skill.content)}\n`, "utf8");
|
|
592
|
+
await writeJson(path.join(skillDir, "skillmesh.json"), {
|
|
593
|
+
installedAt: nowIso(),
|
|
594
|
+
enabled: Boolean(enabled),
|
|
595
|
+
sourceFeed,
|
|
596
|
+
packHash: validation.packHash,
|
|
597
|
+
canonicalId: validation.canonicalId,
|
|
598
|
+
trustLevel: metadata.trustLevel,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const safeHashName = validation.packHash.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
602
|
+
await writeJson(path.join(paths.packsDir, `${safeHashName}.json`), pack);
|
|
603
|
+
upsertPackRecord(db, metadata, "installed");
|
|
604
|
+
db.prepare(
|
|
605
|
+
`INSERT INTO installed_packs (pack_hash, enabled, installed_at, source_feed)
|
|
606
|
+
VALUES (?, ?, ?, ?)
|
|
607
|
+
ON CONFLICT(pack_hash) DO UPDATE SET enabled = excluded.enabled, source_feed = excluded.source_feed`
|
|
608
|
+
).run(validation.packHash, enabled ? 1 : 0, nowIso(), sourceFeed || "");
|
|
609
|
+
return { packHash: validation.packHash, canonicalId: validation.canonicalId, installedSkills: pack.skills.map((skill) => skill.id), enabled };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export async function enableSkillMeshSkill(skillId, enabled = true, home = agintiflowHome()) {
|
|
613
|
+
const id = safeSkillId(skillId);
|
|
614
|
+
if (!id) throw new Error(`Invalid skill id: ${skillId}`);
|
|
615
|
+
const paths = await ensureSkillMeshHome(home);
|
|
616
|
+
const metadataPath = path.join(paths.skillsDir, id, "skillmesh.json");
|
|
617
|
+
const metadata = await readJson(metadataPath, null);
|
|
618
|
+
if (!metadata) throw new Error(`Skill Mesh skill not installed: ${id}`);
|
|
619
|
+
metadata.enabled = Boolean(enabled);
|
|
620
|
+
metadata.updatedAt = nowIso();
|
|
621
|
+
await writeJson(metadataPath, metadata);
|
|
622
|
+
const db = openSkillMeshDb(home);
|
|
623
|
+
if (metadata.packHash) {
|
|
624
|
+
db.prepare("UPDATE installed_packs SET enabled = ? WHERE pack_hash = ?").run(enabled ? 1 : 0, metadata.packHash);
|
|
625
|
+
}
|
|
626
|
+
return metadata;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export async function listInstalledSkillMeshSkills({ home = agintiflowHome() } = {}) {
|
|
630
|
+
const paths = await ensureSkillMeshHome(home);
|
|
631
|
+
const entries = await fs.readdir(paths.skillsDir, { withFileTypes: true }).catch(() => []);
|
|
632
|
+
const skills = [];
|
|
633
|
+
for (const entry of entries) {
|
|
634
|
+
if (!entry.isDirectory()) continue;
|
|
635
|
+
const skillPath = path.join(paths.skillsDir, entry.name, "SKILL.md");
|
|
636
|
+
const metadata = await readJson(path.join(paths.skillsDir, entry.name, "skillmesh.json"), {});
|
|
637
|
+
try {
|
|
638
|
+
const skill = loadSkillFile(skillPath);
|
|
639
|
+
delete skill.body;
|
|
640
|
+
skills.push({ ...skill, skillmesh: metadata });
|
|
641
|
+
} catch {
|
|
642
|
+
// Skip invalid community skills; validation should have caught these before install.
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return skills.sort((a, b) => a.id.localeCompare(b.id));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function fetchJson(url, { method = "GET", body = null, timeoutMs = 8000 } = {}) {
|
|
649
|
+
const controller = new AbortController();
|
|
650
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
651
|
+
try {
|
|
652
|
+
const response = await fetch(url, {
|
|
653
|
+
method,
|
|
654
|
+
headers: { "content-type": "application/json", "user-agent": "AgInTiFlow-SkillMesh/1" },
|
|
655
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
656
|
+
signal: controller.signal,
|
|
657
|
+
});
|
|
658
|
+
const text = await response.text();
|
|
659
|
+
let json = {};
|
|
660
|
+
if (text) json = JSON.parse(text);
|
|
661
|
+
if (!response.ok) throw new Error(json.error || `${response.status} ${response.statusText}`);
|
|
662
|
+
return json;
|
|
663
|
+
} finally {
|
|
664
|
+
clearTimeout(timeout);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export async function submitSkillPack(pack, nodeUrl, { timeoutMs = 10000 } = {}) {
|
|
669
|
+
const url = `${normalizeUrl(nodeUrl)}/submit`;
|
|
670
|
+
validateSkillPack(pack, { allowBuiltInOverride: true });
|
|
671
|
+
return fetchJson(url, { method: "POST", body: pack, timeoutMs });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function localKnownPacks(home = agintiflowHome()) {
|
|
675
|
+
const db = openSkillMeshDb(home);
|
|
676
|
+
const rows = db.prepare("SELECT pack_hash AS packHash, canonical_id AS canonicalId FROM packs").all();
|
|
677
|
+
return {
|
|
678
|
+
hashes: rows.map((row) => row.packHash).filter(Boolean),
|
|
679
|
+
canonicalIds: rows.map((row) => row.canonicalId).filter(Boolean),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export async function syncSkillMesh({ nodeUrl = "", home = agintiflowHome(), install = false, limit = 20 } = {}) {
|
|
684
|
+
const config = await loadSkillMeshConfig(home);
|
|
685
|
+
if (config.mode !== "share") return { ok: true, skipped: true, reason: `Skill Mesh mode is ${config.mode}` };
|
|
686
|
+
const node =
|
|
687
|
+
nodeUrl ||
|
|
688
|
+
normalizeUrl((config.nodes || []).find((item) => item.enabled !== false)?.url || "") ||
|
|
689
|
+
DEFAULT_NODE_URL;
|
|
690
|
+
if (!node) return { ok: true, skipped: true, reason: "No enabled Skill Mesh node configured" };
|
|
691
|
+
const known = localKnownPacks(home);
|
|
692
|
+
let metadata;
|
|
693
|
+
try {
|
|
694
|
+
metadata = await fetchJson(`${normalizeUrl(node)}/sync/metadata`, {
|
|
695
|
+
method: "POST",
|
|
696
|
+
body: {
|
|
697
|
+
clientProtocol: PROTOCOL_VERSION,
|
|
698
|
+
knownPackHashes: known.hashes.slice(0, 1000),
|
|
699
|
+
knownCanonicalIds: known.canonicalIds.slice(0, 1000),
|
|
700
|
+
acceptedTrustLevels: ["core", "trusted-publisher", "community-reviewed", "local-reviewed"],
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
} catch (error) {
|
|
704
|
+
return { ok: true, skipped: true, unreachable: true, node, reason: error instanceof Error ? error.message : String(error) };
|
|
705
|
+
}
|
|
706
|
+
const packs = Array.isArray(metadata.packs) ? metadata.packs.slice(0, Math.min(limit, config.syncPolicy.maxInboundPacksPerSync || 20)) : [];
|
|
707
|
+
const downloaded = [];
|
|
708
|
+
if (install) {
|
|
709
|
+
for (const item of packs) {
|
|
710
|
+
if (!item.downloadUrl) continue;
|
|
711
|
+
const packUrl = item.downloadUrl.startsWith("http") ? item.downloadUrl : `${normalizeUrl(node)}${item.downloadUrl}`;
|
|
712
|
+
const pack = await fetchJson(packUrl, { timeoutMs: 10000 });
|
|
713
|
+
const result = await installSkillPack(pack, { home, enabled: false, sourceFeed: normalizeUrl(node) });
|
|
714
|
+
downloaded.push(result);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
config.syncPolicy.lastSyncAt = nowIso();
|
|
718
|
+
await saveSkillMeshConfig(config, home);
|
|
719
|
+
return { ok: true, node: normalizeUrl(node), available: packs.length, packs, downloaded };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function feedRows(db, publicUrl = "") {
|
|
723
|
+
const rows = db
|
|
724
|
+
.prepare(
|
|
725
|
+
`SELECT pack_hash AS packHash, canonical_id AS canonicalId, name, version, author_key_id AS authorKeyId,
|
|
726
|
+
trust_level AS trustLevel, value_score AS valueScore, status, created_at AS createdAt, metadata_json AS metadataJson
|
|
727
|
+
FROM packs
|
|
728
|
+
WHERE status IN ('approved', 'reviewed', 'installed')
|
|
729
|
+
ORDER BY value_score DESC, created_at DESC
|
|
730
|
+
LIMIT ?`
|
|
731
|
+
)
|
|
732
|
+
.all(MAX_FEED_PACKS);
|
|
733
|
+
return rows.map((row) => {
|
|
734
|
+
const safeHashName = row.packHash.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
735
|
+
return {
|
|
736
|
+
packHash: row.packHash,
|
|
737
|
+
canonicalId: row.canonicalId,
|
|
738
|
+
name: row.name,
|
|
739
|
+
version: row.version,
|
|
740
|
+
authorKeyId: row.authorKeyId,
|
|
741
|
+
trustLevel: row.trustLevel,
|
|
742
|
+
valueScore: row.valueScore,
|
|
743
|
+
status: row.status,
|
|
744
|
+
createdAt: row.createdAt,
|
|
745
|
+
downloadUrl: `${publicUrl || ""}/packs/${safeHashName}.json`,
|
|
746
|
+
metadataUrl: `${publicUrl || ""}/packs/${safeHashName}/metadata.json`,
|
|
747
|
+
};
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function nodeRows(db, self = {}) {
|
|
752
|
+
const rows = db
|
|
753
|
+
.prepare(
|
|
754
|
+
`SELECT node_id AS nodeId, public_url AS publicUrl, role, protocol, status,
|
|
755
|
+
first_verified_at AS firstVerifiedAt, last_verified_at AS lastVerifiedAt,
|
|
756
|
+
last_seen_at AS lastSeenAt, failure_count AS failureCount, metadata_json AS metadataJson
|
|
757
|
+
FROM verified_nodes
|
|
758
|
+
WHERE status IN ('verified', 'degraded')
|
|
759
|
+
ORDER BY last_verified_at DESC
|
|
760
|
+
LIMIT 100`
|
|
761
|
+
)
|
|
762
|
+
.all();
|
|
763
|
+
const nodes = rows.map((row) => ({
|
|
764
|
+
nodeId: row.nodeId,
|
|
765
|
+
publicUrl: row.publicUrl,
|
|
766
|
+
role: row.role,
|
|
767
|
+
protocol: row.protocol,
|
|
768
|
+
status: row.status,
|
|
769
|
+
firstVerifiedAt: row.firstVerifiedAt,
|
|
770
|
+
lastVerifiedAt: row.lastVerifiedAt,
|
|
771
|
+
lastSeenAt: row.lastSeenAt,
|
|
772
|
+
failureCount: row.failureCount,
|
|
773
|
+
}));
|
|
774
|
+
if (self.publicUrl) {
|
|
775
|
+
nodes.unshift({
|
|
776
|
+
nodeId: self.nodeId || "self",
|
|
777
|
+
publicUrl: self.publicUrl,
|
|
778
|
+
role: self.role || "major",
|
|
779
|
+
protocol: PROTOCOL_VERSION,
|
|
780
|
+
status: "verified",
|
|
781
|
+
self: true,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return nodes;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function relayPaths(dataDir) {
|
|
788
|
+
const root = path.resolve(dataDir || path.join(process.cwd(), ".aginti-skill-relay"));
|
|
789
|
+
return {
|
|
790
|
+
root,
|
|
791
|
+
indexDbPath: path.join(root, "index.sqlite"),
|
|
792
|
+
packsDir: path.join(root, "packs"),
|
|
793
|
+
logsDir: path.join(root, "logs"),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function openRelayDb(paths) {
|
|
798
|
+
const db = ensureSkillMeshDb(paths.indexDbPath);
|
|
799
|
+
return db;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function saveRelayPack(paths, pack, validation, metadata) {
|
|
803
|
+
await fs.mkdir(paths.packsDir, { recursive: true });
|
|
804
|
+
const safeHashName = validation.packHash.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
805
|
+
await writeJson(path.join(paths.packsDir, `${safeHashName}.json`), pack);
|
|
806
|
+
await writeJson(path.join(paths.packsDir, `${safeHashName}.metadata.json`), metadata);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export async function startSkillMeshRelay({
|
|
810
|
+
host = "127.0.0.1",
|
|
811
|
+
port = 7377,
|
|
812
|
+
dataDir = "",
|
|
813
|
+
publicUrl = "",
|
|
814
|
+
role = "major",
|
|
815
|
+
acceptUploads = true,
|
|
816
|
+
} = {}) {
|
|
817
|
+
const paths = relayPaths(dataDir);
|
|
818
|
+
await fs.mkdir(paths.packsDir, { recursive: true });
|
|
819
|
+
await fs.mkdir(paths.logsDir, { recursive: true });
|
|
820
|
+
const db = openRelayDb(paths);
|
|
821
|
+
const app = express();
|
|
822
|
+
app.disable("x-powered-by");
|
|
823
|
+
app.use(express.json({ limit: `${MAX_PACK_BYTES}b`, strict: true }));
|
|
824
|
+
|
|
825
|
+
app.get("/health", (request, response) => {
|
|
826
|
+
response.json({
|
|
827
|
+
ok: true,
|
|
828
|
+
service: "agintiflow-skillmesh",
|
|
829
|
+
role,
|
|
830
|
+
protocol: PROTOCOL_VERSION,
|
|
831
|
+
time: nowIso(),
|
|
832
|
+
nonce: request.query.nonce || "",
|
|
833
|
+
publicUrl: normalizeUrl(publicUrl),
|
|
834
|
+
acceptsRawSessions: false,
|
|
835
|
+
acceptUploads: Boolean(acceptUploads),
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
app.get("/feed.json", (_request, response) => {
|
|
840
|
+
response.json({
|
|
841
|
+
feedVersion: PROTOCOL_VERSION,
|
|
842
|
+
generatedAt: nowIso(),
|
|
843
|
+
role,
|
|
844
|
+
publicUrl: normalizeUrl(publicUrl),
|
|
845
|
+
syncPolicy: { slowSync: true, metadataFirst: true },
|
|
846
|
+
packs: feedRows(db, normalizeUrl(publicUrl)),
|
|
847
|
+
nodesUrl: `${normalizeUrl(publicUrl) || ""}/nodes.json`,
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
app.get("/nodes.json", (_request, response) => {
|
|
852
|
+
response.json({
|
|
853
|
+
protocol: PROTOCOL_VERSION,
|
|
854
|
+
generatedAt: nowIso(),
|
|
855
|
+
role,
|
|
856
|
+
nodes: nodeRows(db, { publicUrl: normalizeUrl(publicUrl), role, nodeId: "local-relay" }),
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
app.post("/sync/metadata", (request, response) => {
|
|
861
|
+
const knownHashes = new Set(Array.isArray(request.body?.knownPackHashes) ? request.body.knownPackHashes : []);
|
|
862
|
+
const knownCanonicalIds = new Set(Array.isArray(request.body?.knownCanonicalIds) ? request.body.knownCanonicalIds : []);
|
|
863
|
+
const acceptedTrustLevels = new Set(
|
|
864
|
+
Array.isArray(request.body?.acceptedTrustLevels) && request.body.acceptedTrustLevels.length
|
|
865
|
+
? request.body.acceptedTrustLevels
|
|
866
|
+
: ["community-reviewed", "trusted-publisher", "core"]
|
|
867
|
+
);
|
|
868
|
+
const packs = feedRows(db, normalizeUrl(publicUrl)).filter(
|
|
869
|
+
(pack) => !knownHashes.has(pack.packHash) && !knownCanonicalIds.has(pack.canonicalId) && acceptedTrustLevels.has(pack.trustLevel)
|
|
870
|
+
);
|
|
871
|
+
response.json({ feedVersion: PROTOCOL_VERSION, generatedAt: nowIso(), packs });
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
app.post("/submit", async (request, response) => {
|
|
875
|
+
if (!acceptUploads) {
|
|
876
|
+
response.status(403).json({ ok: false, error: "uploads disabled" });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
const pack = request.body;
|
|
881
|
+
const validation = validateSkillPack(pack, { requireSignature: true });
|
|
882
|
+
const metadata = packMetadata(pack, validation, {
|
|
883
|
+
trustLevel: "community-reviewed",
|
|
884
|
+
status: "approved",
|
|
885
|
+
});
|
|
886
|
+
upsertPackRecord(db, metadata, "approved");
|
|
887
|
+
await saveRelayPack(paths, pack, validation, metadata);
|
|
888
|
+
response.json({ ok: true, packHash: validation.packHash, canonicalId: validation.canonicalId, status: "approved" });
|
|
889
|
+
} catch (error) {
|
|
890
|
+
response.status(400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
app.get("/packs/:hash.json", async (request, response) => {
|
|
895
|
+
const safeHashName = String(request.params.hash || "").replace(/[^A-Za-z0-9._-]/g, "");
|
|
896
|
+
const filePath = path.join(paths.packsDir, `${safeHashName}.json`);
|
|
897
|
+
try {
|
|
898
|
+
response.type("application/json").send(await fs.readFile(filePath, "utf8"));
|
|
899
|
+
} catch {
|
|
900
|
+
response.status(404).json({ ok: false, error: "pack not found" });
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
app.get("/packs/:hash/metadata.json", async (request, response) => {
|
|
905
|
+
const safeHashName = String(request.params.hash || "").replace(/[^A-Za-z0-9._-]/g, "");
|
|
906
|
+
const filePath = path.join(paths.packsDir, `${safeHashName}.metadata.json`);
|
|
907
|
+
try {
|
|
908
|
+
response.type("application/json").send(await fs.readFile(filePath, "utf8"));
|
|
909
|
+
} catch {
|
|
910
|
+
response.status(404).json({ ok: false, error: "metadata not found" });
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
app.use((error, _request, response, _next) => {
|
|
915
|
+
response.status(400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
const server = http.createServer(app);
|
|
919
|
+
await new Promise((resolve) => server.listen(Number(port), host, resolve));
|
|
920
|
+
return {
|
|
921
|
+
app,
|
|
922
|
+
server,
|
|
923
|
+
url: `http://${host}:${server.address().port}`,
|
|
924
|
+
paths,
|
|
925
|
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function expandHome(value = "") {
|
|
930
|
+
const text = String(value || "");
|
|
931
|
+
if (text === "~") return os.homedir();
|
|
932
|
+
if (text.startsWith("~/")) return path.join(os.homedir(), text.slice(2));
|
|
933
|
+
return text;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function shellQuote(value = "") {
|
|
937
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function currentUserName() {
|
|
941
|
+
return process.env.SUDO_USER || process.env.USER || os.userInfo().username || "aginti-relay";
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function servicePaths({ dataDir = "", name = DEFAULT_SERVICE_NAME } = {}) {
|
|
945
|
+
const root = path.resolve(expandHome(dataDir || "~/.aginti-skill-relay"));
|
|
946
|
+
return {
|
|
947
|
+
dataDir: root,
|
|
948
|
+
runScript: path.join(root, `${name}.run.sh`),
|
|
949
|
+
logDir: path.join(root, "logs"),
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function relayServeArgs({ host = "127.0.0.1", port = 7377, dataDir = "", publicUrl = "", role = "major", noUploads = false } = {}) {
|
|
954
|
+
const args = [
|
|
955
|
+
process.execPath,
|
|
956
|
+
path.join(PACKAGE_ROOT, "bin", "aginti-cli.js"),
|
|
957
|
+
"skillmesh",
|
|
958
|
+
"serve",
|
|
959
|
+
"--role",
|
|
960
|
+
role,
|
|
961
|
+
"--host",
|
|
962
|
+
host,
|
|
963
|
+
"--port",
|
|
964
|
+
String(port),
|
|
965
|
+
"--data",
|
|
966
|
+
path.resolve(expandHome(dataDir || "~/.aginti-skill-relay")),
|
|
967
|
+
];
|
|
968
|
+
if (publicUrl) args.push("--public-url", publicUrl);
|
|
969
|
+
if (noUploads) args.push("--no-uploads");
|
|
970
|
+
return args;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export function buildSkillMeshServiceUnit({
|
|
974
|
+
name = DEFAULT_SERVICE_NAME,
|
|
975
|
+
user = currentUserName(),
|
|
976
|
+
dataDir = "",
|
|
977
|
+
runScript = "",
|
|
978
|
+
scope = "system",
|
|
979
|
+
} = {}) {
|
|
980
|
+
const paths = servicePaths({ dataDir, name });
|
|
981
|
+
const script = runScript || paths.runScript;
|
|
982
|
+
const lines = [
|
|
983
|
+
"[Unit]",
|
|
984
|
+
"Description=AgInTi Skill Mesh Relay",
|
|
985
|
+
"After=network-online.target",
|
|
986
|
+
"Wants=network-online.target",
|
|
987
|
+
"",
|
|
988
|
+
"[Service]",
|
|
989
|
+
"Type=simple",
|
|
990
|
+
];
|
|
991
|
+
if (scope === "system") {
|
|
992
|
+
lines.push(`User=${user}`);
|
|
993
|
+
}
|
|
994
|
+
lines.push(
|
|
995
|
+
`WorkingDirectory=${paths.dataDir}`,
|
|
996
|
+
"Environment=NODE_ENV=production",
|
|
997
|
+
`ExecStart=${script}`,
|
|
998
|
+
"Restart=on-failure",
|
|
999
|
+
"RestartSec=5",
|
|
1000
|
+
"NoNewPrivileges=true",
|
|
1001
|
+
"PrivateTmp=true",
|
|
1002
|
+
"ProtectSystem=full",
|
|
1003
|
+
`ReadWritePaths=${paths.dataDir}`,
|
|
1004
|
+
"",
|
|
1005
|
+
"[Install]",
|
|
1006
|
+
scope === "system" ? "WantedBy=multi-user.target" : "WantedBy=default.target",
|
|
1007
|
+
""
|
|
1008
|
+
);
|
|
1009
|
+
return lines.join("\n");
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function writeRelayRunScript(options = {}) {
|
|
1013
|
+
const name = options.name || DEFAULT_SERVICE_NAME;
|
|
1014
|
+
const paths = servicePaths({ dataDir: options.dataDir, name });
|
|
1015
|
+
await fs.mkdir(paths.logDir, { recursive: true });
|
|
1016
|
+
const args = relayServeArgs({ ...options, dataDir: paths.dataDir });
|
|
1017
|
+
const logPath = path.join(paths.logDir, "relay.log");
|
|
1018
|
+
const script = [
|
|
1019
|
+
"#!/usr/bin/env bash",
|
|
1020
|
+
"set -euo pipefail",
|
|
1021
|
+
`mkdir -p ${shellQuote(paths.logDir)}`,
|
|
1022
|
+
`exec ${args.map(shellQuote).join(" ")} 2>&1 | tee -a ${shellQuote(logPath)}`,
|
|
1023
|
+
"",
|
|
1024
|
+
].join("\n");
|
|
1025
|
+
await fs.writeFile(paths.runScript, script, "utf8");
|
|
1026
|
+
await fs.chmod(paths.runScript, 0o755);
|
|
1027
|
+
return { ...paths, runScript: paths.runScript, logPath };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function runCommand(command, args = [], { input = undefined, stdio = "pipe" } = {}) {
|
|
1031
|
+
return execFileSync(command, args, {
|
|
1032
|
+
input,
|
|
1033
|
+
encoding: "utf8",
|
|
1034
|
+
stdio: input === undefined ? stdio : ["pipe", "pipe", "pipe"],
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function runSudo(args = [], { input = undefined } = {}) {
|
|
1039
|
+
return runCommand("sudo", ["-n", ...args], { input });
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function systemctl(scope = "system", args = []) {
|
|
1043
|
+
return scope === "user" ? runCommand("systemctl", ["--user", ...args]) : runSudo(["systemctl", ...args]);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function serviceNameFromOptions(options = {}) {
|
|
1047
|
+
const name = String(options.name || DEFAULT_SERVICE_NAME).trim();
|
|
1048
|
+
if (!/^[A-Za-z0-9_.@-]+$/.test(name)) throw new Error(`Invalid service name: ${name}`);
|
|
1049
|
+
return name.endsWith(".service") ? name.slice(0, -8) : name;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
export async function installSkillMeshService(options = {}) {
|
|
1053
|
+
if (process.platform !== "linux") {
|
|
1054
|
+
throw new Error("Skill Mesh service install currently supports Linux systemd. Use a process manager on this OS.");
|
|
1055
|
+
}
|
|
1056
|
+
const scope = options.user ? "user" : "system";
|
|
1057
|
+
const name = serviceNameFromOptions(options);
|
|
1058
|
+
const serviceFile = `${name}.service`;
|
|
1059
|
+
const paths = await writeRelayRunScript({ ...options, name });
|
|
1060
|
+
const unit = buildSkillMeshServiceUnit({
|
|
1061
|
+
name,
|
|
1062
|
+
user: options.serviceUser || currentUserName(),
|
|
1063
|
+
dataDir: paths.dataDir,
|
|
1064
|
+
runScript: paths.runScript,
|
|
1065
|
+
scope,
|
|
1066
|
+
});
|
|
1067
|
+
if (scope === "user") {
|
|
1068
|
+
const unitDir = path.join(os.homedir(), ".config", "systemd", "user");
|
|
1069
|
+
const unitPath = path.join(unitDir, serviceFile);
|
|
1070
|
+
await fs.mkdir(unitDir, { recursive: true });
|
|
1071
|
+
await fs.writeFile(unitPath, unit, "utf8");
|
|
1072
|
+
runCommand("systemctl", ["--user", "daemon-reload"]);
|
|
1073
|
+
runCommand("systemctl", ["--user", "enable", "--now", serviceFile]);
|
|
1074
|
+
if (options.linger) {
|
|
1075
|
+
try {
|
|
1076
|
+
runSudo(["loginctl", "enable-linger", currentUserName()]);
|
|
1077
|
+
} catch {
|
|
1078
|
+
// Linger is optional; status output tells the user how to enable boot persistence.
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return { scope, serviceFile, unitPath, ...paths };
|
|
1082
|
+
}
|
|
1083
|
+
const unitPath = `/etc/systemd/system/${serviceFile}`;
|
|
1084
|
+
runSudo(["tee", unitPath], { input: unit });
|
|
1085
|
+
runSudo(["systemctl", "daemon-reload"]);
|
|
1086
|
+
runSudo(["systemctl", "enable", "--now", serviceFile]);
|
|
1087
|
+
return { scope, serviceFile, unitPath, ...paths };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export async function manageSkillMeshService(action = "status", options = {}) {
|
|
1091
|
+
const scope = options.user ? "user" : "system";
|
|
1092
|
+
const name = serviceNameFromOptions(options);
|
|
1093
|
+
const serviceFile = `${name}.service`;
|
|
1094
|
+
if (action === "install") return installSkillMeshService(options);
|
|
1095
|
+
if (action === "uninstall" || action === "remove") {
|
|
1096
|
+
try {
|
|
1097
|
+
systemctl(scope, ["disable", "--now", serviceFile]);
|
|
1098
|
+
} catch {
|
|
1099
|
+
// Continue removing unit files even if the service is already absent.
|
|
1100
|
+
}
|
|
1101
|
+
if (scope === "user") {
|
|
1102
|
+
await fs.rm(path.join(os.homedir(), ".config", "systemd", "user", serviceFile), { force: true });
|
|
1103
|
+
runCommand("systemctl", ["--user", "daemon-reload"]);
|
|
1104
|
+
} else {
|
|
1105
|
+
runSudo(["rm", "-f", `/etc/systemd/system/${serviceFile}`]);
|
|
1106
|
+
runSudo(["systemctl", "daemon-reload"]);
|
|
1107
|
+
}
|
|
1108
|
+
return { scope, serviceFile, removed: true };
|
|
1109
|
+
}
|
|
1110
|
+
if (["start", "stop", "restart", "enable", "disable"].includes(action)) {
|
|
1111
|
+
systemctl(scope, [action, action === "enable" || action === "disable" ? "--now" : "", serviceFile].filter(Boolean));
|
|
1112
|
+
return { scope, serviceFile, action };
|
|
1113
|
+
}
|
|
1114
|
+
const output = systemctl(scope, ["status", "--no-pager", serviceFile]);
|
|
1115
|
+
return { scope, serviceFile, status: output };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function parseCliOptions(argv = []) {
|
|
1119
|
+
const options = { _: [] };
|
|
1120
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1121
|
+
const arg = argv[i];
|
|
1122
|
+
if (!arg.startsWith("--")) {
|
|
1123
|
+
options._.push(arg);
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
const key = arg.replace(/^--/, "");
|
|
1127
|
+
const next = argv[i + 1];
|
|
1128
|
+
if (!next || next.startsWith("--")) {
|
|
1129
|
+
options[key] = true;
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
options[key] = next;
|
|
1133
|
+
i += 1;
|
|
1134
|
+
}
|
|
1135
|
+
return options;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
export async function handleSkillMeshCommand(argv = []) {
|
|
1139
|
+
const command = String(argv[0] || "status").toLowerCase();
|
|
1140
|
+
const options = parseCliOptions(argv.slice(1));
|
|
1141
|
+
if (["status", "show", ""].includes(command)) {
|
|
1142
|
+
const config = await loadSkillMeshConfig();
|
|
1143
|
+
console.log(formatSkillMeshStatus(config));
|
|
1144
|
+
const skills = await listInstalledSkillMeshSkills();
|
|
1145
|
+
if (skills.length) {
|
|
1146
|
+
console.log("");
|
|
1147
|
+
console.log("Installed Skill Mesh skills:");
|
|
1148
|
+
for (const skill of skills) {
|
|
1149
|
+
console.log(` ${skill.skillmesh?.enabled ? "enabled " : "disabled"} ${skill.id}: ${skill.label}`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (["off", "disable", "disabled", "record", "local", "share", "on"].includes(command)) {
|
|
1155
|
+
const config = await setSkillMeshMode(command);
|
|
1156
|
+
console.log(formatSkillMeshStatus(config));
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (command === "nodes") {
|
|
1160
|
+
const config = await loadSkillMeshConfig();
|
|
1161
|
+
if (options.remote) {
|
|
1162
|
+
const nodeUrl = options.node || (config.nodes || []).find((node) => node.enabled !== false)?.url || DEFAULT_NODE_URL;
|
|
1163
|
+
try {
|
|
1164
|
+
const remote = await fetchJson(`${normalizeUrl(nodeUrl)}/nodes.json`);
|
|
1165
|
+
for (const node of remote.nodes || []) {
|
|
1166
|
+
console.log(`${node.status || "unknown"} ${node.role || "node"} ${node.publicUrl}`);
|
|
1167
|
+
}
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
console.log(`remote node list unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
1170
|
+
}
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
for (const node of config.nodes || []) console.log(`${node.enabled === false ? "disabled" : "enabled "} ${node.name || "node"} ${node.url}`);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (command === "node") {
|
|
1177
|
+
const action = String(options._[0] || "").toLowerCase();
|
|
1178
|
+
if (action === "add") {
|
|
1179
|
+
const node = await addSkillMeshNode(options._[1], options._[2], { role: options.role || "volunteer" });
|
|
1180
|
+
console.log(`added node ${node.name} ${node.url}`);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (action === "remove" || action === "rm") {
|
|
1184
|
+
const removed = await removeSkillMeshNode(options._[1]);
|
|
1185
|
+
console.log(`removed=${removed}`);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
throw new Error("Usage: aginti skillmesh node add <name> <url> OR aginti skillmesh node remove <name-or-url>");
|
|
1189
|
+
}
|
|
1190
|
+
if (command === "service") {
|
|
1191
|
+
const action = String(options._[0] || "status").toLowerCase();
|
|
1192
|
+
const serviceOptions = {
|
|
1193
|
+
name: options.name || DEFAULT_SERVICE_NAME,
|
|
1194
|
+
user: Boolean(options.user),
|
|
1195
|
+
linger: Boolean(options.linger),
|
|
1196
|
+
serviceUser: options["service-user"] || currentUserName(),
|
|
1197
|
+
host: options.host || "127.0.0.1",
|
|
1198
|
+
port: Number(options.port || 7377),
|
|
1199
|
+
dataDir: options.data || "~/.aginti-skill-relay",
|
|
1200
|
+
publicUrl: options["public-url"] || "",
|
|
1201
|
+
role: options.role || "major",
|
|
1202
|
+
noUploads: Boolean(options["no-uploads"]),
|
|
1203
|
+
};
|
|
1204
|
+
const result = await manageSkillMeshService(action, serviceOptions);
|
|
1205
|
+
if (action === "status") {
|
|
1206
|
+
console.log(result.status || `${result.serviceFile} status unavailable`);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (action === "install") {
|
|
1210
|
+
console.log(`installed ${result.scope} service ${result.serviceFile}`);
|
|
1211
|
+
console.log(`unit=${result.unitPath}`);
|
|
1212
|
+
console.log(`run=${result.runScript}`);
|
|
1213
|
+
console.log(`data=${result.dataDir}`);
|
|
1214
|
+
if (result.scope === "user") {
|
|
1215
|
+
console.log("For reboot persistence of a user service, enable linger: sudo loginctl enable-linger $(whoami)");
|
|
1216
|
+
}
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
console.log(`${action} ${result.scope} service ${result.serviceFile}`);
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (command === "export") {
|
|
1223
|
+
const skillId = options._[0] || "";
|
|
1224
|
+
if (!skillId) throw new Error("Usage: aginti skillmesh export <skill-id> [--out file.skillpack.json]");
|
|
1225
|
+
const result = await exportSkillPack(skillId, { outPath: options.out || "" });
|
|
1226
|
+
console.log(`exported ${skillId}`);
|
|
1227
|
+
console.log(`pack=${result.path}`);
|
|
1228
|
+
console.log(`hash=${result.metadata.packHash}`);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if (command === "import") {
|
|
1232
|
+
const inputPath = options._[0] || "";
|
|
1233
|
+
if (!inputPath) throw new Error("Usage: aginti skillmesh import <file.skillpack.json> [--enable]");
|
|
1234
|
+
const result = await importSkillPack(inputPath, { enabled: Boolean(options.enable) });
|
|
1235
|
+
console.log(`imported ${result.installedSkills.join(", ")} enabled=${result.enabled}`);
|
|
1236
|
+
console.log(`hash=${result.packHash}`);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
if (command === "enable" || command === "disable-skill") {
|
|
1240
|
+
const skillId = options._[0] || "";
|
|
1241
|
+
if (!skillId) throw new Error(`Usage: aginti skillmesh ${command} <skill-id>`);
|
|
1242
|
+
const metadata = await enableSkillMeshSkill(skillId, command === "enable");
|
|
1243
|
+
console.log(`${command === "enable" ? "enabled" : "disabled"} ${skillId} pack=${metadata.packHash || ""}`);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (command === "sync") {
|
|
1247
|
+
const result = await syncSkillMesh({ nodeUrl: options.node || "", install: Boolean(options.install) });
|
|
1248
|
+
if (result.skipped) {
|
|
1249
|
+
console.log(`Skill Mesh sync skipped: ${result.reason}`);
|
|
1250
|
+
if (result.node) console.log(`node=${result.node}`);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
console.log(`Skill Mesh sync node=${result.node} available=${result.available} downloaded=${result.downloaded?.length || 0}`);
|
|
1254
|
+
for (const pack of result.packs || []) console.log(` ${pack.name} ${pack.packHash} ${pack.trustLevel}`);
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
if (command === "submit") {
|
|
1258
|
+
const inputPath = options._[0] || "";
|
|
1259
|
+
if (!inputPath) throw new Error("Usage: aginti skillmesh submit <file.skillpack.json> [--node URL]");
|
|
1260
|
+
const pack = JSON.parse(await fs.readFile(inputPath, "utf8"));
|
|
1261
|
+
const config = await loadSkillMeshConfig();
|
|
1262
|
+
const nodeUrl = options.node || (config.nodes || []).find((node) => node.enabled !== false)?.url || DEFAULT_NODE_URL;
|
|
1263
|
+
const result = await submitSkillPack(pack, nodeUrl);
|
|
1264
|
+
console.log(`submitted ${result.packHash} status=${result.status}`);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
if (command === "serve") {
|
|
1268
|
+
const relay = await startSkillMeshRelay({
|
|
1269
|
+
host: options.host || "127.0.0.1",
|
|
1270
|
+
port: Number(options.port || 7377),
|
|
1271
|
+
dataDir: options.data || "",
|
|
1272
|
+
publicUrl: options["public-url"] || "",
|
|
1273
|
+
role: options.role || "major",
|
|
1274
|
+
acceptUploads: options["no-uploads"] ? false : true,
|
|
1275
|
+
});
|
|
1276
|
+
console.log(`AgInTi Skill Mesh relay listening at ${relay.url}`);
|
|
1277
|
+
console.log(`data=${relay.paths.root}`);
|
|
1278
|
+
await new Promise(() => {});
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
"Usage: aginti skillmesh [status|off|record|share|nodes|node|service|export|import|enable|disable-skill|sync|submit|serve]"
|
|
1283
|
+
);
|
|
1284
|
+
}
|