metergraph-cli 0.0.0-stage → 0.1.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/src/skill.js ADDED
@@ -0,0 +1,449 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { CONNECTION_GUIDE_URL, SKILL_CLIENTS, SKILL_RUNTIMES } from "./constants.js";
6
+ import { loadBundledSkill, revisionFor, sha256Hex } from "./skill-bundle.js";
7
+
8
+ // Project-scoped skill installer. It writes exactly two things inside the
9
+ // project: the client's SKILL.md and a secret-free ownership receipt. It never
10
+ // touches client settings, AGENTS.md, CLAUDE.md or any other file, never
11
+ // follows a symbolic link below the resolved project directory and makes no
12
+ // network request. All file work is synchronous, so a signal cannot run
13
+ // JavaScript between two steps.
14
+
15
+ const RECEIPT_DIR = ".metergraph";
16
+ const RECEIPT_FILE = "skill-installations.json";
17
+ const LOCK_FILE = "skill-installations.lock";
18
+ const MAX_RECEIPT_BYTES = 64 * 1024;
19
+ const MAX_SKILL_BYTES = 1024 * 1024;
20
+ const ENTRY_KEYS = ["client", "path", "revision", "runtimes", "sha256", "skill"];
21
+ const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
22
+
23
+ const MESSAGES = {
24
+ invalid_project: "--project must name an existing directory.",
25
+ client_not_supported:
26
+ "This client cannot load project skill files. Follow the connection guide instead. Nothing was written.",
27
+ runtime_not_supported:
28
+ "This runtime has no shell or project checkout for skill files. Follow the connection guide instead. Nothing was written.",
29
+ not_owned:
30
+ "A skill already exists at the target path and was not installed by this CLI. Nothing was changed.",
31
+ modified:
32
+ "The skill file was changed after this CLI installed it. Nothing was changed. Restore or remove the file, then retry.",
33
+ not_installed:
34
+ 'This CLI has not installed the skill for this client in this project. Run "metergraph skill install" first.',
35
+ update_required:
36
+ 'An older skill revision installed by this CLI is present. Run "metergraph skill update" to replace it.',
37
+ unsafe_path:
38
+ "A path used by the installer is a symbolic link or is not a regular file or directory. Nothing was changed.",
39
+ receipt_invalid: `The receipt ${RECEIPT_DIR}/${RECEIPT_FILE} is not valid. Nothing was changed.`,
40
+ changed_during_install: "A file changed while the skill was being installed. Changes were rolled back.",
41
+ locked: `Another skill install may be running. If none is, delete ${RECEIPT_DIR}/${LOCK_FILE} and retry.`,
42
+ read_failed: "The project files could not be read. Nothing was changed.",
43
+ write_failed: "The skill could not be written. Changes were rolled back.",
44
+ rollback_failed: `The skill could not be written and the rollback did not finish. Check the skill path and ${RECEIPT_DIR}/${RECEIPT_FILE}.`,
45
+ bundled_skill_invalid:
46
+ "The skill bundled with this CLI failed its integrity check. Reinstall the CLI. Nothing was written.",
47
+ };
48
+
49
+ class Stop extends Error {
50
+ constructor(outcome, reason) {
51
+ super(reason);
52
+ this.outcome = outcome;
53
+ this.reason = reason;
54
+ }
55
+ }
56
+
57
+ // Returns { outcome, reason, message, data }. Every string comes from this
58
+ // package. Paths in data are relative to the project.
59
+ export function runSkill({ action, client, runtime, project }) {
60
+ const context = { action, client, runtime, target: null, bundle: null };
61
+ if (!Object.hasOwn(SKILL_CLIENTS, client)) {
62
+ return handoff(context, "client_not_supported");
63
+ }
64
+ if (!SKILL_RUNTIMES.includes(runtime)) {
65
+ return handoff(context, "runtime_not_supported");
66
+ }
67
+ try {
68
+ return execute(context, project);
69
+ } catch (error) {
70
+ if (error instanceof Stop) return failure(context, error.outcome, error.reason);
71
+ return failure(context, "filesystem_error", "write_failed");
72
+ }
73
+ }
74
+
75
+ function execute(context, project) {
76
+ const { action, client, runtime } = context;
77
+ context.bundle = loadBundledSkill();
78
+ if (context.bundle === null) throw new Stop("internal_error", "bundled_skill_invalid");
79
+ context.target = targetFor(client, context.bundle.name);
80
+ const root = resolveProject(project);
81
+
82
+ // Decide from a read-only look first, so a matching rerun writes nothing.
83
+ let state = inspect(root, context);
84
+ let plan = decide(action, runtime, state, context.bundle);
85
+ if (plan.noop) return success(context, plan.status);
86
+
87
+ return deferSignals(() => {
88
+ const createdMeta = ensureDirs(root, [RECEIPT_DIR]);
89
+ const lockPath = path.join(root, RECEIPT_DIR, LOCK_FILE);
90
+ let done = false;
91
+ try {
92
+ acquireLock(lockPath);
93
+ try {
94
+ // Look again under the lock in case another run changed anything.
95
+ state = inspect(root, context);
96
+ plan = decide(action, runtime, state, context.bundle);
97
+ if (!plan.noop) commit(root, context, state, plan);
98
+ done = true;
99
+ return success(context, plan.status);
100
+ } finally {
101
+ try {
102
+ fs.unlinkSync(lockPath);
103
+ } catch {
104
+ // A lock that cannot be removed is reported by the next run.
105
+ }
106
+ }
107
+ } finally {
108
+ if (!done) removeDirs(createdMeta);
109
+ }
110
+ });
111
+ }
112
+
113
+ function targetFor(client, name) {
114
+ const dirs = [SKILL_CLIENTS[client].dir, "skills", name];
115
+ return { dirs, relative: [...dirs, "SKILL.md"].join("/") };
116
+ }
117
+
118
+ function resolveProject(project) {
119
+ try {
120
+ const real = fs.realpathSync(path.resolve(project ?? process.cwd()));
121
+ if (fs.statSync(real).isDirectory()) return real;
122
+ } catch {
123
+ // Fall through to the fixed error.
124
+ }
125
+ throw new Stop("invalid_input", "invalid_project");
126
+ }
127
+
128
+ // Reads the receipt and the target without writing. Any symbolic link or
129
+ // non-regular entry on either path stops the run.
130
+ function inspect(root, context) {
131
+ try {
132
+ checkDirs(root, [RECEIPT_DIR]);
133
+ const receiptFile = readRegular(path.join(root, RECEIPT_DIR, RECEIPT_FILE), MAX_RECEIPT_BYTES);
134
+ let receipt = null;
135
+ if (receiptFile !== null) {
136
+ receipt = receiptFile.content === null ? null : parseReceipt(receiptFile.content, context.bundle.name);
137
+ if (receipt === null) throw new Stop("conflict", "receipt_invalid");
138
+ }
139
+ const skillDirExists = checkDirs(root, context.target.dirs);
140
+ const file = skillDirExists
141
+ ? readRegular(path.join(root, ...context.target.dirs, "SKILL.md"), MAX_SKILL_BYTES)
142
+ : null;
143
+ return {
144
+ receipt,
145
+ receiptFile,
146
+ entry: receipt?.installations.find((entry) => entry.client === context.client) ?? null,
147
+ skillDirExists,
148
+ file,
149
+ };
150
+ } catch (error) {
151
+ if (error instanceof Stop) throw error;
152
+ throw new Stop("filesystem_error", "read_failed");
153
+ }
154
+ }
155
+
156
+ // Returns { status, noop, writeSkill } or throws a conflict. There is no
157
+ // force option: an unowned or modified skill is never replaced.
158
+ function decide(action, runtime, state, bundle) {
159
+ const { entry, file, skillDirExists } = state;
160
+ if (entry === null) {
161
+ if (file !== null || skillDirExists) throw new Stop("conflict", "not_owned");
162
+ if (action === "update") throw new Stop("conflict", "not_installed");
163
+ return { status: "installed", noop: false, writeSkill: true };
164
+ }
165
+ if (file !== null && (file.content === null || sha256Hex(file.content) !== entry.sha256)) {
166
+ throw new Stop("conflict", "modified");
167
+ }
168
+ const current = entry.revision === bundle.revision;
169
+ if (!current && action === "install") throw new Stop("conflict", "update_required");
170
+ if (file !== null && current) {
171
+ return { status: "reused", noop: entry.runtimes.includes(runtime), writeSkill: false };
172
+ }
173
+ return { status: current ? "installed" : "updated", noop: false, writeSkill: true };
174
+ }
175
+
176
+ // Writes the skill, then the receipt. If the receipt cannot be written the
177
+ // skill is restored to its previous state. Ownership is therefore never
178
+ // recorded for a skill that was not written. A crash between the two steps
179
+ // leaves a skill the receipt does not vouch for, which later runs refuse to
180
+ // touch.
181
+ function commit(root, context, state, plan) {
182
+ const { bundle, target } = context;
183
+ const skillPath = path.join(root, ...target.dirs, "SKILL.md");
184
+ const receiptPath = path.join(root, RECEIPT_DIR, RECEIPT_FILE);
185
+ const receipt = nextReceipt(state.receipt, context, target.relative);
186
+ let createdDirs = [];
187
+ let skillWritten = false;
188
+ try {
189
+ if (plan.writeSkill) {
190
+ createdDirs = ensureDirs(root, target.dirs);
191
+ replaceFile(skillPath, bundle.content, state.file);
192
+ skillWritten = true;
193
+ }
194
+ if (state.receiptFile === null || !state.receiptFile.content.equals(receipt)) {
195
+ replaceFile(receiptPath, receipt, state.receiptFile);
196
+ }
197
+ } catch (error) {
198
+ try {
199
+ if (skillWritten) {
200
+ if (state.file === null) fs.unlinkSync(skillPath);
201
+ else replaceFile(skillPath, state.file.content, { content: bundle.content, mode: state.file.mode });
202
+ }
203
+ } catch {
204
+ throw new Stop("filesystem_error", "rollback_failed");
205
+ }
206
+ removeDirs(createdDirs);
207
+ throw error;
208
+ }
209
+ }
210
+
211
+ function nextReceipt(receipt, context, relative) {
212
+ const { bundle, client, runtime } = context;
213
+ const previous = receipt?.installations.find((entry) => entry.client === client);
214
+ const runtimes = [...new Set([...(previous?.runtimes ?? []), runtime])].sort();
215
+ const installations = (receipt?.installations ?? []).filter((entry) => entry.client !== client);
216
+ installations.push({
217
+ client,
218
+ path: relative,
219
+ skill: bundle.name,
220
+ revision: bundle.revision,
221
+ sha256: bundle.sha256,
222
+ runtimes,
223
+ });
224
+ installations.sort((a, b) => (a.client < b.client ? -1 : 1));
225
+ return Buffer.from(`${JSON.stringify({ schema_version: 1, installations }, null, 2)}\n`);
226
+ }
227
+
228
+ // Accepts only the exact shape this CLI writes. Each entry is bound to one
229
+ // client and that client's fixed path, and its revision must match its hash.
230
+ function parseReceipt(content, name) {
231
+ let value;
232
+ try {
233
+ value = JSON.parse(content.toString("utf8"));
234
+ } catch {
235
+ return null;
236
+ }
237
+ if (!isRecord(value, ["installations", "schema_version"])) return null;
238
+ if (value.schema_version !== 1 || !Array.isArray(value.installations)) return null;
239
+ const seen = new Set();
240
+ for (const entry of value.installations) {
241
+ if (!isRecord(entry, ENTRY_KEYS)) return null;
242
+ if (typeof entry.client !== "string" || !Object.hasOwn(SKILL_CLIENTS, entry.client)) return null;
243
+ if (seen.has(entry.client)) return null;
244
+ seen.add(entry.client);
245
+ if (entry.skill !== name || entry.path !== targetFor(entry.client, name).relative) return null;
246
+ if (typeof entry.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(entry.sha256)) return null;
247
+ if (entry.revision !== revisionFor(entry.sha256)) return null;
248
+ const { runtimes } = entry;
249
+ if (!Array.isArray(runtimes) || runtimes.length === 0) return null;
250
+ if (new Set(runtimes).size !== runtimes.length) return null;
251
+ if (!runtimes.every((item) => SKILL_RUNTIMES.includes(item))) return null;
252
+ }
253
+ return value;
254
+ }
255
+
256
+ function isRecord(value, keys) {
257
+ return (
258
+ value !== null &&
259
+ typeof value === "object" &&
260
+ !Array.isArray(value) &&
261
+ Object.keys(value).sort().join() === keys.join()
262
+ );
263
+ }
264
+
265
+ function lstatOrNull(file) {
266
+ try {
267
+ return fs.lstatSync(file);
268
+ } catch (error) {
269
+ if (error.code === "ENOENT") return null;
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ // Returns true when every directory exists, false at the first missing one.
275
+ // Anything else, including a symbolic link to a directory, is unsafe.
276
+ function checkDirs(root, dirs) {
277
+ let current = root;
278
+ for (const dir of dirs) {
279
+ current = path.join(current, dir);
280
+ const stat = lstatOrNull(current);
281
+ if (stat === null) return false;
282
+ if (!stat.isDirectory()) throw new Stop("conflict", "unsafe_path");
283
+ }
284
+ return true;
285
+ }
286
+
287
+ // Creates missing directories one level at a time and returns the ones it
288
+ // created, deepest last, so a rollback can remove exactly those.
289
+ function ensureDirs(root, dirs) {
290
+ const created = [];
291
+ let current = root;
292
+ try {
293
+ for (const dir of dirs) {
294
+ current = path.join(current, dir);
295
+ try {
296
+ fs.mkdirSync(current);
297
+ created.push(current);
298
+ } catch (error) {
299
+ if (error.code !== "EEXIST") throw error;
300
+ if (!fs.lstatSync(current).isDirectory()) throw new Stop("conflict", "unsafe_path");
301
+ }
302
+ }
303
+ } catch (error) {
304
+ removeDirs(created);
305
+ throw error;
306
+ }
307
+ return created;
308
+ }
309
+
310
+ function removeDirs(created) {
311
+ for (const dir of [...created].reverse()) {
312
+ try {
313
+ fs.rmdirSync(dir);
314
+ } catch {
315
+ // Not empty or not removable. An empty directory is harmless.
316
+ }
317
+ }
318
+ }
319
+
320
+ // Returns { content, mode } for a regular file, { content: null, mode } when
321
+ // it is larger than maxBytes, or null when it does not exist.
322
+ function readRegular(file, maxBytes) {
323
+ const stat = lstatOrNull(file);
324
+ if (stat === null) return null;
325
+ if (!stat.isFile()) throw new Stop("conflict", "unsafe_path");
326
+ let fd;
327
+ try {
328
+ fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
329
+ } catch (error) {
330
+ if (error.code === "ELOOP") throw new Stop("conflict", "unsafe_path");
331
+ throw error;
332
+ }
333
+ try {
334
+ const opened = fs.fstatSync(fd);
335
+ if (!opened.isFile()) throw new Stop("conflict", "unsafe_path");
336
+ const mode = opened.mode & 0o777;
337
+ if (opened.size > maxBytes) return { content: null, mode };
338
+ return { content: fs.readFileSync(fd), mode };
339
+ } finally {
340
+ fs.closeSync(fd);
341
+ }
342
+ }
343
+
344
+ // Writes content to an exclusive temporary file next to dest, then renames
345
+ // it into place. previous is what inspect saw at dest ({ content, mode } or
346
+ // null). Right before the rename, dest must still be exactly that, so a file
347
+ // created or edited by someone else in the meantime is never replaced. An
348
+ // existing file keeps its permission bits.
349
+ function replaceFile(dest, content, previous) {
350
+ const temp = path.join(
351
+ path.dirname(dest),
352
+ `.${path.basename(dest)}.${randomBytes(6).toString("hex")}.tmp`,
353
+ );
354
+ const fd = fs.openSync(temp, "wx", previous === null ? 0o666 : 0o600);
355
+ try {
356
+ try {
357
+ fs.writeFileSync(fd, content);
358
+ fs.fsyncSync(fd);
359
+ } finally {
360
+ fs.closeSync(fd);
361
+ }
362
+ if (previous !== null) fs.chmodSync(temp, previous.mode);
363
+ const now = readRegular(dest, MAX_SKILL_BYTES);
364
+ const unchanged =
365
+ previous === null ? now === null : now !== null && now.content !== null && now.content.equals(previous.content);
366
+ if (!unchanged) throw new Stop("conflict", "changed_during_install");
367
+ fs.renameSync(temp, dest);
368
+ } catch (error) {
369
+ try {
370
+ fs.unlinkSync(temp);
371
+ } catch {
372
+ // Already renamed or never created.
373
+ }
374
+ throw error;
375
+ }
376
+ }
377
+
378
+ function acquireLock(lockPath) {
379
+ try {
380
+ fs.closeSync(fs.openSync(lockPath, "wx"));
381
+ } catch (error) {
382
+ if (error.code === "EEXIST") throw new Stop("conflict", "locked");
383
+ throw error;
384
+ }
385
+ }
386
+
387
+ // Keeps SIGINT, SIGTERM and SIGHUP from killing the process while files are
388
+ // being replaced. The work inside is synchronous, so it always finishes or
389
+ // rolls back before the event loop could deliver a signal.
390
+ function deferSignals(work) {
391
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
392
+ const ignore = () => {};
393
+ for (const signal of signals) process.on(signal, ignore);
394
+ try {
395
+ return work();
396
+ } finally {
397
+ for (const signal of signals) process.removeListener(signal, ignore);
398
+ }
399
+ }
400
+
401
+ function nextAction({ client, runtime, target }) {
402
+ const label = SKILL_CLIENTS[client].label;
403
+ const message =
404
+ runtime === "local"
405
+ ? `Start or restart ${label} in this project, then confirm that it lists the metergraph skill.`
406
+ : `Make sure the cloud checkout includes ${target.relative} (commit it if you installed it elsewhere), ` +
407
+ `then start a new ${label} cloud session and confirm that it lists the metergraph skill.`;
408
+ return { kind: "reload_client", message };
409
+ }
410
+
411
+ function data(context, { status = null, next = null } = {}) {
412
+ const { bundle } = context;
413
+ return {
414
+ client: context.client,
415
+ runtime: context.runtime,
416
+ path: context.target?.relative ?? null,
417
+ status,
418
+ source: bundle ? { name: bundle.name, revision: bundle.revision, sha256: bundle.sha256 } : null,
419
+ discovery: status === null ? null : "pending",
420
+ authenticated: false,
421
+ next_action: next,
422
+ };
423
+ }
424
+
425
+ function success(context, status) {
426
+ const label = SKILL_CLIENTS[context.client].label;
427
+ const verb = { installed: "installed", updated: "updated", reused: "already installed" }[status];
428
+ return {
429
+ outcome: "ok",
430
+ reason: null,
431
+ message:
432
+ `Skill ${verb}. Discovery is pending until ${label} loads it. ` +
433
+ "This does not sign in, connect a workspace or configure MCP.",
434
+ data: data(context, { status, next: nextAction(context) }),
435
+ };
436
+ }
437
+
438
+ function handoff(context, reason) {
439
+ return {
440
+ outcome: "unsupported",
441
+ reason,
442
+ message: MESSAGES[reason],
443
+ data: data(context, { next: { kind: "connection_guide", url: CONNECTION_GUIDE_URL } }),
444
+ };
445
+ }
446
+
447
+ function failure(context, outcome, reason) {
448
+ return { outcome, reason, message: MESSAGES[reason], data: data(context) };
449
+ }