metergraph-cli 0.2.0-preview.4 → 0.2.0-preview.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -4
- package/assets/skill/SKILL.md +10 -0
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +61 -0
- package/assets/skills/metergraph-analyze/SKILL.md +159 -0
- package/assets/skills/metergraph-candidates/SKILL.md +84 -0
- package/assets/skills/metergraph-evals/SKILL.md +99 -0
- package/assets/skills/metergraph-investigate/SKILL.md +105 -0
- package/assets/skills/metergraph-iterate/SKILL.md +73 -0
- package/assets/skills/metergraph-model-swap/SKILL.md +81 -0
- package/assets/skills/metergraph-onboarding/SKILL.md +155 -0
- package/assets/skills/metergraph-report/SKILL.md +91 -0
- package/assets/skills/metergraph-workloads/SKILL.md +94 -0
- package/package.json +3 -1
- package/src/args.js +43 -3
- package/src/cli.js +16 -1
- package/src/constants.js +4 -0
- package/src/output.js +118 -0
- package/src/skill-bundle.js +68 -1
- package/src/skill.js +175 -14
package/src/output.js
CHANGED
|
@@ -84,6 +84,28 @@ const SKILL_USAGE = [
|
|
|
84
84
|
"metergraph skill update --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
|
|
85
85
|
];
|
|
86
86
|
|
|
87
|
+
const SKILLS_USAGE = [
|
|
88
|
+
"metergraph skills install --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
|
|
89
|
+
"metergraph skills update --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
|
|
90
|
+
"metergraph skills list [--project DIR] [--json]",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const SKILLS_LIST_OPTIONS = [
|
|
94
|
+
{ name: "--project", value: "DIR", summary: "Existing project directory. Default: the current directory." },
|
|
95
|
+
{ name: "--json", value: null, summary: "Print one JSON line on stdout." },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const SKILLS_STATE_LABELS = {
|
|
99
|
+
not_installed: "not installed",
|
|
100
|
+
installed: "installed",
|
|
101
|
+
outdated: "older revision; run skills update",
|
|
102
|
+
modified: "changed since install; left alone",
|
|
103
|
+
missing: "receipt only; run skills install",
|
|
104
|
+
not_owned: "not installed by this CLI; left alone",
|
|
105
|
+
receipt_invalid: "receipt not valid",
|
|
106
|
+
unsafe_path: "unsafe path",
|
|
107
|
+
};
|
|
108
|
+
|
|
87
109
|
const CONFIG_DIR_OPTION = {
|
|
88
110
|
name: "--config-dir",
|
|
89
111
|
value: "DIR",
|
|
@@ -257,6 +279,7 @@ export function helpData(topic) {
|
|
|
257
279
|
"metergraph --version [--json]",
|
|
258
280
|
"metergraph doctor [--url ORIGIN] [--timeout-ms N] [--json]",
|
|
259
281
|
...SKILL_USAGE,
|
|
282
|
+
...SKILLS_USAGE,
|
|
260
283
|
LOGIN_USAGE,
|
|
261
284
|
LOGOUT_USAGE,
|
|
262
285
|
SETUP_USAGE,
|
|
@@ -283,6 +306,25 @@ export function helpData(topic) {
|
|
|
283
306
|
"Replace a skill this CLI installed, and that is unchanged since, with the bundled revision.",
|
|
284
307
|
options: SKILL_OPTIONS,
|
|
285
308
|
},
|
|
309
|
+
{
|
|
310
|
+
name: "skills install",
|
|
311
|
+
summary:
|
|
312
|
+
"Copy the Metergraph workflow skills bundled with this CLI, such as the model-swap loop, into one " +
|
|
313
|
+
"client's project skill directory. Never replaces a skill it did not install. No network requests, no sign in.",
|
|
314
|
+
options: SKILL_OPTIONS,
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: "skills update",
|
|
318
|
+
summary:
|
|
319
|
+
"Replace workflow skills this CLI installed, and that are unchanged since, with the bundled revisions, " +
|
|
320
|
+
"and install skills added since.",
|
|
321
|
+
options: SKILL_OPTIONS,
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: "skills list",
|
|
325
|
+
summary: "List the bundled workflow skills and whether each is installed for each client. Writes nothing.",
|
|
326
|
+
options: SKILLS_LIST_OPTIONS,
|
|
327
|
+
},
|
|
286
328
|
{
|
|
287
329
|
name: "login",
|
|
288
330
|
summary:
|
|
@@ -463,6 +505,40 @@ export function helpText(topic) {
|
|
|
463
505
|
"",
|
|
464
506
|
"Discovery stays pending until the client itself loads the skill.",
|
|
465
507
|
);
|
|
508
|
+
} else if (topic === "skills") {
|
|
509
|
+
lines.push(
|
|
510
|
+
"Usage:",
|
|
511
|
+
...SKILLS_USAGE.map((usage) => ` ${usage}`),
|
|
512
|
+
"",
|
|
513
|
+
"Copies the Metergraph workflow skills bundled with this CLI into one client's project",
|
|
514
|
+
"skill directory, one folder per skill, and records ownership of each in",
|
|
515
|
+
".metergraph/skills/NAME.json. The skills come from https://github.com/metergraph/skills",
|
|
516
|
+
"at the commit this release pins. They cover investigation and the model-swap loop:",
|
|
517
|
+
"choose a workload, choose traces and models, define the eval, run the analysis,",
|
|
518
|
+
"summarize the report and rerun. The setup skill is installed by \"metergraph skill\".",
|
|
519
|
+
"",
|
|
520
|
+
"Writes nothing else. Makes no network requests, does not sign in and does not",
|
|
521
|
+
"configure MCP, client settings, AGENTS.md or CLAUDE.md.",
|
|
522
|
+
"",
|
|
523
|
+
"Options:",
|
|
524
|
+
);
|
|
525
|
+
for (const option of SKILL_OPTIONS) {
|
|
526
|
+
const flag = option.value ? `${option.name} ${option.value}` : option.name;
|
|
527
|
+
lines.push(` ${flag.padEnd(18)}${option.summary}`);
|
|
528
|
+
}
|
|
529
|
+
lines.push(
|
|
530
|
+
"",
|
|
531
|
+
"install never replaces an existing skill. update replaces only skills this CLI",
|
|
532
|
+
"installed and that are unchanged since, and installs skills added since. A conflict",
|
|
533
|
+
"on one skill leaves the others in place. There is no force option.",
|
|
534
|
+
"list takes only --project and reports every client. It never writes.",
|
|
535
|
+
"",
|
|
536
|
+
"Claude Desktop (--client claude-desktop) cannot load project skill files. It exits",
|
|
537
|
+
"with code 6, writes nothing and points to the metergraph/skills plugin marketplace.",
|
|
538
|
+
"ChatGPT and cloud runtimes without a shell get the connection guide instead.",
|
|
539
|
+
"",
|
|
540
|
+
"Discovery stays pending until the client itself loads the skills.",
|
|
541
|
+
);
|
|
466
542
|
} else {
|
|
467
543
|
lines.push(
|
|
468
544
|
`metergraph ${VERSION} (preview)`,
|
|
@@ -472,6 +548,7 @@ export function helpText(topic) {
|
|
|
472
548
|
" metergraph --version [--json] Show the CLI version",
|
|
473
549
|
" metergraph doctor [options] Check a Metergraph service, read only",
|
|
474
550
|
" metergraph skill install|update Install or update the agent skill in a project",
|
|
551
|
+
" metergraph skills install|update|list Workflow skills, such as the model-swap loop",
|
|
475
552
|
" metergraph login [options] Sign in and bind a project to a workspace",
|
|
476
553
|
" metergraph logout [options] Revoke and remove a project's sign in",
|
|
477
554
|
" metergraph setup [options] Approve and write a private ingest key",
|
|
@@ -485,6 +562,7 @@ export function helpText(topic) {
|
|
|
485
562
|
"",
|
|
486
563
|
'Run "metergraph help doctor" for doctor options.',
|
|
487
564
|
'Run "metergraph help skill" for skill options.',
|
|
565
|
+
'Run "metergraph help skills" for workflow skill options.',
|
|
488
566
|
'Run "metergraph help login" or "metergraph help logout" for sign in options.',
|
|
489
567
|
'Run "metergraph help verify" for exact-trace verification options.',
|
|
490
568
|
'Run "metergraph help COMMAND" for status, context, capabilities, usage, routes or traces.',
|
|
@@ -521,6 +599,46 @@ export function skillText(result, message) {
|
|
|
521
599
|
return `${lines.join("\n")}\n`;
|
|
522
600
|
}
|
|
523
601
|
|
|
602
|
+
export function skillsText(result, message) {
|
|
603
|
+
const report = result.data;
|
|
604
|
+
const lines = [];
|
|
605
|
+
if (result.command === "skills list") {
|
|
606
|
+
lines.push("Metergraph skills bundled with this CLI");
|
|
607
|
+
if (report.source) lines.push(`Source: ${report.source.repository} at ${report.source.commit.slice(0, 12)}`);
|
|
608
|
+
if (!result.ok) {
|
|
609
|
+
lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`, message);
|
|
610
|
+
return `${lines.join("\n")}\n`;
|
|
611
|
+
}
|
|
612
|
+
for (const skill of report.skills) {
|
|
613
|
+
lines.push("", `${skill.name} (${skill.revision})`);
|
|
614
|
+
for (const [client, state] of Object.entries(skill.installs)) {
|
|
615
|
+
lines.push(` ${SKILL_CLIENTS[client].label.padEnd(12)}${SKILLS_STATE_LABELS[state] ?? state}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return `${lines.join("\n")}\n`;
|
|
619
|
+
}
|
|
620
|
+
const label = SKILL_CLIENTS[report.client]?.label ?? HANDOFF_SKILL_CLIENTS[report.client] ?? report.client;
|
|
621
|
+
lines.push(`Metergraph ${result.command}: ${label}, ${report.runtime} runtime`);
|
|
622
|
+
if (report.source) lines.push(`Source: ${report.source.repository} at ${report.source.commit.slice(0, 12)}`);
|
|
623
|
+
for (const skill of report.skills) {
|
|
624
|
+
const status = skill.status === "failed" ? `failed (${skill.reason})` : skill.status;
|
|
625
|
+
lines.push(` ${skill.name.padEnd(24)}${status.padEnd(12)}${skill.path}`);
|
|
626
|
+
}
|
|
627
|
+
if (result.ok) {
|
|
628
|
+
lines.push(
|
|
629
|
+
`Discovery: pending until ${label} loads the skills`,
|
|
630
|
+
"Authenticated: no",
|
|
631
|
+
"",
|
|
632
|
+
`Next: ${report.next_action.message}`,
|
|
633
|
+
);
|
|
634
|
+
} else {
|
|
635
|
+
lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`, message);
|
|
636
|
+
if (report.next_action?.kind === "plugin_marketplace") lines.push(`Next: ${report.next_action.message}`);
|
|
637
|
+
if (report.next_action?.kind === "connection_guide") lines.push(`Connection guide: ${report.next_action.url}`);
|
|
638
|
+
}
|
|
639
|
+
return `${lines.join("\n")}\n`;
|
|
640
|
+
}
|
|
641
|
+
|
|
524
642
|
export function versionData() {
|
|
525
643
|
return { name: PACKAGE_NAME, version: VERSION };
|
|
526
644
|
}
|
package/src/skill-bundle.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
// public skill at the manifest's source_url. The source has no version of its
|
|
6
6
|
// own, so the revision is derived from the content hash. The hash is pinned
|
|
7
7
|
// here as well as in the manifest, so editing either file alone is detected.
|
|
8
|
-
const PINNED_SHA256 = "
|
|
8
|
+
const PINNED_SHA256 = "92f7955f6f8761a20a1886ad81f22dfcee239afddf8e9f1e761c35eb8e70f218";
|
|
9
9
|
|
|
10
10
|
const ASSET_DIR = new URL("../assets/skill/", import.meta.url);
|
|
11
11
|
const MANIFEST_KEYS = ["file", "manifest_version", "name", "revision", "sha256", "size", "source_url"];
|
|
@@ -51,3 +51,70 @@ export function loadBundledSkill() {
|
|
|
51
51
|
if (!content.toString("utf8").startsWith(`---\nname: ${manifest.name}\ndescription: `)) return null;
|
|
52
52
|
return { name: manifest.name, revision: manifest.revision, sha256, content };
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// The workflow skills that "skills install" writes, vendored from the public
|
|
56
|
+
// skills repository at one commit. The manifest's own hash is pinned here, and
|
|
57
|
+
// the manifest pins each skill's hash, so changing any file alone is detected.
|
|
58
|
+
const PINNED_PACK_SHA256 = "c8e4d9b0d90bd201e90758e16ab0827b8ea36e3620a28223a7e73e91c035d835";
|
|
59
|
+
|
|
60
|
+
const PACK_DIR = new URL("../assets/skills/", import.meta.url);
|
|
61
|
+
const PACK_KEYS = ["commit", "manifest_version", "repository", "skills"];
|
|
62
|
+
const PACK_ENTRY_KEYS = ["name", "revision", "sha256", "size"];
|
|
63
|
+
|
|
64
|
+
// Returns { repository, commit, skills: [{ name, revision, sha256, content }] }
|
|
65
|
+
// sorted by name, or null when the manifest or any skill file is missing,
|
|
66
|
+
// malformed or does not match its pinned hash. Never downloads anything.
|
|
67
|
+
export function loadBundledPack() {
|
|
68
|
+
let manifestBytes;
|
|
69
|
+
let manifest;
|
|
70
|
+
try {
|
|
71
|
+
manifestBytes = readFileSync(new URL("manifest.json", PACK_DIR));
|
|
72
|
+
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
sha256Hex(manifestBytes) !== PINNED_PACK_SHA256 ||
|
|
78
|
+
manifest === null ||
|
|
79
|
+
typeof manifest !== "object" ||
|
|
80
|
+
Array.isArray(manifest) ||
|
|
81
|
+
Object.keys(manifest).sort().join() !== PACK_KEYS.join() ||
|
|
82
|
+
manifest.manifest_version !== 1 ||
|
|
83
|
+
manifest.repository !== "metergraph/skills" ||
|
|
84
|
+
typeof manifest.commit !== "string" ||
|
|
85
|
+
!/^[0-9a-f]{40}$/.test(manifest.commit) ||
|
|
86
|
+
!Array.isArray(manifest.skills) ||
|
|
87
|
+
manifest.skills.length === 0
|
|
88
|
+
) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const skills = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const entry of manifest.skills) {
|
|
94
|
+
if (
|
|
95
|
+
entry === null ||
|
|
96
|
+
typeof entry !== "object" ||
|
|
97
|
+
Object.keys(entry).sort().join() !== PACK_ENTRY_KEYS.join() ||
|
|
98
|
+
typeof entry.name !== "string" ||
|
|
99
|
+
!/^[a-z0-9-]{1,64}$/.test(entry.name) ||
|
|
100
|
+
entry.name === "metergraph" ||
|
|
101
|
+
seen.has(entry.name) ||
|
|
102
|
+
typeof entry.sha256 !== "string" ||
|
|
103
|
+
entry.revision !== revisionFor(entry.sha256)
|
|
104
|
+
) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
seen.add(entry.name);
|
|
108
|
+
let content;
|
|
109
|
+
try {
|
|
110
|
+
content = readFileSync(new URL(`${entry.name}/SKILL.md`, PACK_DIR));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (content.length !== entry.size || sha256Hex(content) !== entry.sha256) return null;
|
|
115
|
+
if (!content.toString("utf8").startsWith(`---\nname: ${entry.name}\ndescription: `)) return null;
|
|
116
|
+
skills.push({ name: entry.name, revision: entry.revision, sha256: entry.sha256, content });
|
|
117
|
+
}
|
|
118
|
+
skills.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
119
|
+
return { repository: manifest.repository, commit: manifest.commit, skills };
|
|
120
|
+
}
|
package/src/skill.js
CHANGED
|
@@ -2,11 +2,19 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
CONNECTION_GUIDE_URL,
|
|
7
|
+
SKILLS_REPOSITORY_URL,
|
|
8
|
+
SKILL_CLIENTS,
|
|
9
|
+
SKILL_RUNTIMES,
|
|
10
|
+
} from "./constants.js";
|
|
11
|
+
import { loadBundledPack, loadBundledSkill, revisionFor, sha256Hex } from "./skill-bundle.js";
|
|
7
12
|
|
|
8
|
-
// Project-scoped skill installer.
|
|
9
|
-
// project: the client's SKILL.md and a secret-free ownership
|
|
13
|
+
// Project-scoped skill installer. For each skill it writes exactly two things
|
|
14
|
+
// inside the project: the client's SKILL.md and a secret-free ownership
|
|
15
|
+
// receipt. "skill" installs the setup skill with the receipt below; "skills"
|
|
16
|
+
// installs each skill of the workflow pack with its own receipt in
|
|
17
|
+
// .metergraph/skills/. It never
|
|
10
18
|
// touches client settings, AGENTS.md, CLAUDE.md or any other file, never
|
|
11
19
|
// follows a symbolic link below the resolved project directory and makes no
|
|
12
20
|
// network request. All file work is synchronous, so a signal cannot run
|
|
@@ -15,6 +23,8 @@ import { loadBundledSkill, revisionFor, sha256Hex } from "./skill-bundle.js";
|
|
|
15
23
|
const RECEIPT_DIR = ".metergraph";
|
|
16
24
|
const RECEIPT_FILE = "skill-installations.json";
|
|
17
25
|
const LOCK_FILE = "skill-installations.lock";
|
|
26
|
+
const PACK_RECEIPT_DIRS = [RECEIPT_DIR, "skills"];
|
|
27
|
+
const CORE_RECEIPT = Object.freeze({ dirs: [RECEIPT_DIR], file: RECEIPT_FILE, lock: LOCK_FILE });
|
|
18
28
|
const MAX_RECEIPT_BYTES = 64 * 1024;
|
|
19
29
|
const MAX_SKILL_BYTES = 1024 * 1024;
|
|
20
30
|
const ENTRY_KEYS = ["client", "path", "revision", "runtimes", "sha256", "skill"];
|
|
@@ -57,7 +67,7 @@ class Stop extends Error {
|
|
|
57
67
|
// Returns { outcome, reason, message, data }. Every string comes from this
|
|
58
68
|
// package. Paths in data are relative to the project.
|
|
59
69
|
export function runSkill({ action, client, runtime, project }) {
|
|
60
|
-
const context = { action, client, runtime, target: null, bundle: null };
|
|
70
|
+
const context = { action, client, runtime, target: null, bundle: null, receipt: CORE_RECEIPT };
|
|
61
71
|
if (!Object.hasOwn(SKILL_CLIENTS, client)) {
|
|
62
72
|
return handoff(context, "client_not_supported");
|
|
63
73
|
}
|
|
@@ -73,20 +83,25 @@ export function runSkill({ action, client, runtime, project }) {
|
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
function execute(context, project) {
|
|
76
|
-
const { action, client, runtime } = context;
|
|
77
86
|
context.bundle = loadBundledSkill();
|
|
78
87
|
if (context.bundle === null) throw new Stop("internal_error", "bundled_skill_invalid");
|
|
79
|
-
context.target = targetFor(client, context.bundle.name);
|
|
88
|
+
context.target = targetFor(context.client, context.bundle.name);
|
|
80
89
|
const root = resolveProject(project);
|
|
90
|
+
return success(context, apply(root, context));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Installs or updates one skill and returns its status, or throws a Stop.
|
|
94
|
+
function apply(root, context) {
|
|
95
|
+
const { action, runtime, receipt } = context;
|
|
81
96
|
|
|
82
97
|
// Decide from a read-only look first, so a matching rerun writes nothing.
|
|
83
98
|
let state = inspect(root, context);
|
|
84
99
|
let plan = decide(action, runtime, state, context.bundle);
|
|
85
|
-
if (plan.noop) return
|
|
100
|
+
if (plan.noop) return plan.status;
|
|
86
101
|
|
|
87
102
|
return deferSignals(() => {
|
|
88
|
-
const createdMeta = ensureDirs(root,
|
|
89
|
-
const lockPath = path.join(root,
|
|
103
|
+
const createdMeta = ensureDirs(root, receipt.dirs);
|
|
104
|
+
const lockPath = path.join(root, ...receipt.dirs, receipt.lock);
|
|
90
105
|
let done = false;
|
|
91
106
|
try {
|
|
92
107
|
acquireLock(lockPath);
|
|
@@ -96,7 +111,7 @@ function execute(context, project) {
|
|
|
96
111
|
plan = decide(action, runtime, state, context.bundle);
|
|
97
112
|
if (!plan.noop) commit(root, context, state, plan);
|
|
98
113
|
done = true;
|
|
99
|
-
return
|
|
114
|
+
return plan.status;
|
|
100
115
|
} finally {
|
|
101
116
|
try {
|
|
102
117
|
fs.unlinkSync(lockPath);
|
|
@@ -129,8 +144,8 @@ function resolveProject(project) {
|
|
|
129
144
|
// non-regular entry on either path stops the run.
|
|
130
145
|
function inspect(root, context) {
|
|
131
146
|
try {
|
|
132
|
-
checkDirs(root,
|
|
133
|
-
const receiptFile = readRegular(path.join(root,
|
|
147
|
+
checkDirs(root, context.receipt.dirs);
|
|
148
|
+
const receiptFile = readRegular(path.join(root, ...context.receipt.dirs, context.receipt.file), MAX_RECEIPT_BYTES);
|
|
134
149
|
let receipt = null;
|
|
135
150
|
if (receiptFile !== null) {
|
|
136
151
|
receipt = receiptFile.content === null ? null : parseReceipt(receiptFile.content, context.bundle.name);
|
|
@@ -181,7 +196,7 @@ function decide(action, runtime, state, bundle) {
|
|
|
181
196
|
function commit(root, context, state, plan) {
|
|
182
197
|
const { bundle, target } = context;
|
|
183
198
|
const skillPath = path.join(root, ...target.dirs, "SKILL.md");
|
|
184
|
-
const receiptPath = path.join(root,
|
|
199
|
+
const receiptPath = path.join(root, ...context.receipt.dirs, context.receipt.file);
|
|
185
200
|
const receipt = nextReceipt(state.receipt, context, target.relative);
|
|
186
201
|
let createdDirs = [];
|
|
187
202
|
let skillWritten = false;
|
|
@@ -447,3 +462,149 @@ function handoff(context, reason) {
|
|
|
447
462
|
function failure(context, outcome, reason) {
|
|
448
463
|
return { outcome, reason, message: MESSAGES[reason], data: data(context) };
|
|
449
464
|
}
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// The workflow skill pack ("metergraph skills"). Each skill runs through the
|
|
468
|
+
// same engine as the setup skill, with its own receipt and lock in
|
|
469
|
+
// .metergraph/skills/, so one skill's conflict never blocks or rolls back
|
|
470
|
+
// another, and the setup skill's receipt is never touched.
|
|
471
|
+
|
|
472
|
+
const PACK_HANDOFF_MESSAGE =
|
|
473
|
+
"In Claude Desktop, open Customize → Plugins → Add marketplace and enter metergraph/skills, " +
|
|
474
|
+
"or upload a skill zip from https://github.com/metergraph/skills/releases/latest under Customize → Skills.";
|
|
475
|
+
|
|
476
|
+
function packReceipt(name) {
|
|
477
|
+
return { dirs: PACK_RECEIPT_DIRS, file: `${name}.json`, lock: `${name}.lock` };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function packMessage(name, reason) {
|
|
481
|
+
const receipt = `${PACK_RECEIPT_DIRS.join("/")}/${name}.json`;
|
|
482
|
+
const lock = `${PACK_RECEIPT_DIRS.join("/")}/${name}.lock`;
|
|
483
|
+
if (reason === "receipt_invalid") return `The receipt ${receipt} is not valid. Nothing was changed.`;
|
|
484
|
+
if (reason === "locked") return `Another skill install may be running. If none is, delete ${lock} and retry.`;
|
|
485
|
+
if (reason === "rollback_failed") {
|
|
486
|
+
return `The skill could not be written and the rollback did not finish. Check the skill path and ${receipt}.`;
|
|
487
|
+
}
|
|
488
|
+
return MESSAGES[reason];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Returns { outcome, reason, message, data } like runSkill. data.skills has
|
|
492
|
+
// one entry per bundled skill, in name order.
|
|
493
|
+
export function runSkillPack({ action, client, runtime, project }) {
|
|
494
|
+
const base = { client, runtime, source: null, skills: [], discovery: null, authenticated: false, next_action: null };
|
|
495
|
+
if (client === "claude-desktop") {
|
|
496
|
+
return {
|
|
497
|
+
outcome: "unsupported",
|
|
498
|
+
reason: "client_not_supported",
|
|
499
|
+
message:
|
|
500
|
+
"Claude Desktop loads Metergraph skills from the plugin marketplace or an uploaded zip, not from project files. Nothing was written.",
|
|
501
|
+
data: { ...base, next_action: { kind: "plugin_marketplace", message: PACK_HANDOFF_MESSAGE, url: SKILLS_REPOSITORY_URL } },
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (!Object.hasOwn(SKILL_CLIENTS, client)) {
|
|
505
|
+
return { outcome: "unsupported", reason: "client_not_supported", message: MESSAGES.client_not_supported,
|
|
506
|
+
data: { ...base, next_action: { kind: "connection_guide", url: CONNECTION_GUIDE_URL } } };
|
|
507
|
+
}
|
|
508
|
+
if (!SKILL_RUNTIMES.includes(runtime)) {
|
|
509
|
+
return { outcome: "unsupported", reason: "runtime_not_supported", message: MESSAGES.runtime_not_supported,
|
|
510
|
+
data: { ...base, next_action: { kind: "connection_guide", url: CONNECTION_GUIDE_URL } } };
|
|
511
|
+
}
|
|
512
|
+
const pack = loadBundledPack();
|
|
513
|
+
if (pack === null) {
|
|
514
|
+
return { outcome: "internal_error", reason: "bundled_skill_invalid", message: MESSAGES.bundled_skill_invalid, data: base };
|
|
515
|
+
}
|
|
516
|
+
base.source = { repository: pack.repository, commit: pack.commit };
|
|
517
|
+
let root;
|
|
518
|
+
try {
|
|
519
|
+
root = resolveProject(project);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
return { outcome: error.outcome, reason: error.reason, message: MESSAGES[error.reason], data: base };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let failed = null;
|
|
525
|
+
for (const bundle of pack.skills) {
|
|
526
|
+
const context = {
|
|
527
|
+
action,
|
|
528
|
+
client,
|
|
529
|
+
runtime,
|
|
530
|
+
bundle,
|
|
531
|
+
receipt: packReceipt(bundle.name),
|
|
532
|
+
target: targetFor(client, bundle.name),
|
|
533
|
+
};
|
|
534
|
+
const entry = { name: bundle.name, path: context.target.relative, status: null, revision: bundle.revision, reason: null };
|
|
535
|
+
try {
|
|
536
|
+
try {
|
|
537
|
+
entry.status = apply(root, context);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
// A skill added to the pack since the last install is installed by update.
|
|
540
|
+
if (!(error instanceof Stop) || error.reason !== "not_installed") throw error;
|
|
541
|
+
entry.status = apply(root, { ...context, action: "install" });
|
|
542
|
+
}
|
|
543
|
+
} catch (error) {
|
|
544
|
+
const stop = error instanceof Stop ? error : new Stop("filesystem_error", "write_failed");
|
|
545
|
+
entry.status = "failed";
|
|
546
|
+
entry.reason = stop.reason;
|
|
547
|
+
failed ??= { outcome: stop.outcome, reason: stop.reason, message: packMessage(bundle.name, stop.reason) };
|
|
548
|
+
}
|
|
549
|
+
base.skills.push(entry);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const label = SKILL_CLIENTS[client].label;
|
|
553
|
+
if (failed !== null) {
|
|
554
|
+
return { ...failed, data: { ...base, discovery: "pending", next_action: null } };
|
|
555
|
+
}
|
|
556
|
+
const message =
|
|
557
|
+
runtime === "local"
|
|
558
|
+
? `Start or restart ${label} in this project, then confirm that it lists the Metergraph skills.`
|
|
559
|
+
: `Make sure the cloud checkout includes ${SKILL_CLIENTS[client].dir}/skills (commit it if you installed it elsewhere), ` +
|
|
560
|
+
`then start a new ${label} cloud session and confirm that it lists the Metergraph skills.`;
|
|
561
|
+
return {
|
|
562
|
+
outcome: "ok",
|
|
563
|
+
reason: null,
|
|
564
|
+
message:
|
|
565
|
+
`Skills ready. Discovery is pending until ${label} loads them. ` +
|
|
566
|
+
"This does not sign in, connect a workspace or configure MCP.",
|
|
567
|
+
data: { ...base, discovery: "pending", next_action: { kind: "reload_client", message } },
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Read-only: what the pack bundles, and its state for every client in the
|
|
572
|
+
// project. Never writes, never takes a lock.
|
|
573
|
+
export function listSkillPack({ project }) {
|
|
574
|
+
const pack = loadBundledPack();
|
|
575
|
+
const data = { source: null, skills: [], clients: Object.keys(SKILL_CLIENTS) };
|
|
576
|
+
if (pack === null) {
|
|
577
|
+
return { outcome: "internal_error", reason: "bundled_skill_invalid", message: MESSAGES.bundled_skill_invalid, data };
|
|
578
|
+
}
|
|
579
|
+
data.source = { repository: pack.repository, commit: pack.commit };
|
|
580
|
+
let root;
|
|
581
|
+
try {
|
|
582
|
+
root = resolveProject(project);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
return { outcome: error.outcome, reason: error.reason, message: MESSAGES[error.reason], data };
|
|
585
|
+
}
|
|
586
|
+
for (const bundle of pack.skills) {
|
|
587
|
+
const installs = {};
|
|
588
|
+
for (const client of Object.keys(SKILL_CLIENTS)) {
|
|
589
|
+
installs[client] = packState(root, { client, bundle, receipt: packReceipt(bundle.name), target: targetFor(client, bundle.name) });
|
|
590
|
+
}
|
|
591
|
+
data.skills.push({ name: bundle.name, revision: bundle.revision, sha256: bundle.sha256, installs });
|
|
592
|
+
}
|
|
593
|
+
return { outcome: "ok", reason: null, message: null, data };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// One of not_installed, installed, outdated, modified, missing, not_owned,
|
|
597
|
+
// receipt_invalid or unsafe_path.
|
|
598
|
+
function packState(root, context) {
|
|
599
|
+
let state;
|
|
600
|
+
try {
|
|
601
|
+
state = inspect(root, context);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
return error instanceof Stop && error.reason === "receipt_invalid" ? "receipt_invalid" : "unsafe_path";
|
|
604
|
+
}
|
|
605
|
+
const { entry, file, skillDirExists } = state;
|
|
606
|
+
if (entry === null) return file !== null || skillDirExists ? "not_owned" : "not_installed";
|
|
607
|
+
if (file === null) return "missing";
|
|
608
|
+
if (file.content === null || sha256Hex(file.content) !== entry.sha256) return "modified";
|
|
609
|
+
return entry.revision === context.bundle.revision ? "installed" : "outdated";
|
|
610
|
+
}
|