metergraph-cli 0.2.0-preview.4 → 0.2.0-preview.6

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.
@@ -1,6 +1,9 @@
1
+ import { detectRemoteSession } from "./auth-browser.js";
1
2
  import { runDoctor } from "./doctor.js";
2
- import { planDeploymentRoute, verifyDeploymentRoute } from "./deployment-route.js";
3
+ import { action, planDeploymentRoute, verifyDeploymentRoute } from "./deployment-route.js";
3
4
  import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
5
+ import { resolveProject } from "./auth-binding.js";
6
+ import { readSetupState } from "./setup-state.js";
4
7
 
5
8
  // Setup routing uses the same deployment model and Metadata credential checks
6
9
  // as the standalone routing contract. Operator prerequisites are assertions,
@@ -24,6 +27,55 @@ const PREREQUISITES = Object.freeze({
24
27
 
25
28
  const EXPECTED_PROFILE = Object.freeze({ "customer-local": "local", byoc: "byoc-core", oss: "oss" });
26
29
 
30
+ const DEPLOYMENT_FOR_PROFILE = Object.freeze({ local: "customer-local", "byoc-core": "byoc" });
31
+
32
+ // Fixed guidance for each prerequisite, so a person or agent can act on a
33
+ // handoff without the docs. None of it names a credential value, and none of
34
+ // it asks for a credential to be passed to this CLI.
35
+ const PREREQUISITE_GUIDE = Object.freeze({
36
+ released_signed_bundle: "Download the released customer-local bundle and its signed manifest, and verify the signature before starting it. A bundle built from source is not a release.",
37
+ registry_invitation: "Accept the workspace's registry invitation so the bundle can pull its images. The registry pull credential is only for pulling images: never give it to this CLI or put it in the project env file.",
38
+ bundle_started_verified: "Start the bundle with bin/start and wait until bin/status reports it healthy. Pass its origin as --url and the workspace ID that bin/status shows as --workspace.",
39
+ local_admin_configured: "Sign in to the installation once as its local admin, in a browser on this machine. Setup asks that account to approve sign in and an ingest-only key.",
40
+ operator_provisioning: "The operator must already have provisioned the customer-owned AWS deployment. Setup connects to it and never provisions anything.",
41
+ private_network_reachability: "Run setup from a machine that reaches the deployment's private HTTPS origin, for example over the customer VPN. Setup never opens a tunnel or a public endpoint.",
42
+ identity_membership_configured: "The operator must already have given your identity membership of the workspace through the deployment's configured identity provider.",
43
+ server_distribution_installed: "The open source server must already be installed and running under the operator's management.",
44
+ deployment_discovery_supported: "The open source server must answer GET /v1/deployment with the oss profile. If it does not, ask the operator to upgrade it.",
45
+ metadata_scope_supported: "The open source server must advertise the agent:metadata scope in its OAuth metadata. If it does not, ask the operator to upgrade it.",
46
+ ingestion_tokens_configured: "The operator configures ingestion tokens in MG_TOKENS and gives the application one as METERGRAPH_APP_TOKEN. Setup never issues or writes open source ingest tokens.",
47
+ agent_read_tokens_configured: "The operator configures agent read tokens in MG_AGENT_TOKENS, separately from MG_TOKENS. Never reuse an ingestion token for agent reads.",
48
+ });
49
+ const METADATA_CREDENTIAL_GUIDE = Object.freeze({
50
+ local: "Setup obtains its own Metadata-only sign in through the browser. --agent-token-file is optional and must hold a Metadata-only agent token.",
51
+ "byoc-core": "Setup obtains its own Metadata-only sign in through the browser. --agent-token-file is optional and must hold a Metadata-only agent token.",
52
+ oss: "Put one MG_AGENT_TOKENS value in a private file (mode 0600) and pass its absolute path as --agent-token-file.",
53
+ });
54
+ const ACTION_GUIDE = Object.freeze({
55
+ complete_prerequisite: "Complete the named prerequisite, then rerun setup with --confirm-prerequisites.",
56
+ oss_operator_handoff: "Ask the open source server operator to complete this step. This CLI does not create open source credentials or change the server.",
57
+ run_on_customer_machine: "Run setup in a terminal on the machine that reaches the deployment. Setup does not tunnel or forward credentials from a remote or cloud session.",
58
+ use_https_origin: "Pass the deployment's HTTPS origin. A customer-owned AWS deployment is never reached over plain HTTP.",
59
+ connection_guide: "Check that --url is the installation's own origin and that it reports the expected deployment profile.",
60
+ check_private_network: "This machine cannot reach the private origin. Connect to the deployment's network, for example over the customer VPN, and rerun. Do not expose the deployment publicly.",
61
+ platform_credential_handoff: "This platform cannot prove the agent token file is private. Rerun without --agent-token-file, or on macOS or Linux.",
62
+ fix_credential_file: "Make --agent-token-file an absolute path to a regular file you own, with mode 0600, holding one token.",
63
+ use_metadata_only_credential: "The agent token was refused or has more than Metadata access. Use a Metadata-only agent token for this workspace.",
64
+ retry: "Rerun the same command.",
65
+ });
66
+
67
+ export function prerequisiteGuide(name, profile) {
68
+ if (name === "metadata_agent_credential") return METADATA_CREDENTIAL_GUIDE[profile] ?? null;
69
+ return PREREQUISITE_GUIDE[name] ?? null;
70
+ }
71
+
72
+ function guided(nextAction, profile) {
73
+ if (nextAction === null) return null;
74
+ const message = nextAction.kind === "complete_prerequisite" && nextAction.prerequisite !== null
75
+ ? prerequisiteGuide(nextAction.prerequisite, profile) : ACTION_GUIDE[nextAction.kind] ?? null;
76
+ return { ...nextAction, message };
77
+ }
78
+
27
79
  const handoff = (outcome, reason, nextAction = null, details = {}) => ({
28
80
  proceed: false,
29
81
  outcome,
@@ -38,10 +90,21 @@ const handoff = (outcome, reason, nextAction = null, details = {}) => ({
38
90
  pending_prerequisites: details.pendingPrerequisites ?? [],
39
91
  metadata_access: details.metadataAccess ?? "not_checked",
40
92
  ingest_credential: "not_checked",
41
- next_action: nextAction,
93
+ next_action: guided(nextAction, details.profile ?? null),
42
94
  },
43
95
  });
44
96
 
97
+ // The next action when the service did not answer as a healthy deployment.
98
+ // An unreachable customer-local origin usually means the bundle is not
99
+ // running; an unreachable BYOC origin is private networking. Neither is
100
+ // tunnelled or relaxed.
101
+ function unreachedAction(deployment, outcome) {
102
+ if (deployment === "oss") return action("oss_operator_handoff");
103
+ if (outcome !== "connection_failed") return action("connection_guide");
104
+ if (deployment === "byoc") return action("check_private_network");
105
+ return action("complete_prerequisite", "bundle_started_verified");
106
+ }
107
+
45
108
  // The static agent token can be any printable string of at least 16 bytes.
46
109
  // Even a fixed status such as "operator_handoff" might equal that token. Keep
47
110
  // the verifier's non-enumerable knownCredentials on every later result and
@@ -94,13 +157,21 @@ export function planNonHostedSetup(options) {
94
157
  return { proceed: true, plan: planned.plan, prerequisites };
95
158
  }
96
159
 
97
- export async function preflightNonHostedSetup(options) {
160
+ export async function preflightNonHostedSetup(options, { env = process.env } = {}) {
98
161
  const planned = planNonHostedSetup(options);
99
162
  if (!planned.proceed) return planned;
100
163
  const { plan, prerequisites } = planned;
164
+ // Setup itself refuses a remote session, but only after this preflight.
165
+ // Refuse here first, so a remote shell sends no request and never reads the
166
+ // separate agent token file.
167
+ if (detectRemoteSession(env) !== null) {
168
+ return handoff("unsupported", "run_on_local_machine", action("run_on_customer_machine"), {
169
+ profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
170
+ });
171
+ }
101
172
  const timeoutMs = Math.min(options.timeoutMs, AUTH_HTTP_TIMEOUT_MS);
102
173
  if (options.deployment === "oss" && !options.agentTokenFile) {
103
- return handoff("unsupported", "oss_agent_token_file_required", "oss_operator_handoff", {
174
+ return handoff("unsupported", "oss_agent_token_file_required", action("oss_operator_handoff"), {
104
175
  profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
105
176
  prerequisites: "operator_confirmed",
106
177
  });
@@ -123,7 +194,7 @@ export async function preflightNonHostedSetup(options) {
123
194
  // The OSS operator provisions MG_TOKENS and MG_AGENT_TOKENS separately.
124
195
  // No released OSS server implements the purpose-bound ingest bootstrap,
125
196
  // and a Metadata agent token cannot issue a key. Keep this a handoff.
126
- return guardKnownCredentials(handoff("unsupported", "oss_ingest_operator_handoff", "oss_operator_handoff", {
197
+ return guardKnownCredentials(handoff("unsupported", "oss_ingest_operator_handoff", action("oss_operator_handoff"), {
127
198
  profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
128
199
  prerequisites: "operator_confirmed", metadataAccess: "verified",
129
200
  }), verified);
@@ -134,13 +205,13 @@ export async function preflightNonHostedSetup(options) {
134
205
  // and unsafe network responses and reports only a fixed profile token.
135
206
  const doctor = await runDoctor({ origin: plan.origin, timeoutMs });
136
207
  if (doctor.report.deployment_profile !== null && doctor.report.deployment_profile !== plan.deployment_profile) {
137
- return handoff("unsupported", "deployment_profile_mismatch", "connection_guide", {
208
+ return handoff("unsupported", "deployment_profile_mismatch", action("connection_guide"), {
138
209
  profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
139
210
  prerequisites: "operator_confirmed",
140
211
  });
141
212
  }
142
213
  if (doctor.outcome !== "authentication_required") {
143
- return handoff(doctor.outcome, doctor.reason, options.deployment === "oss" ? "oss_operator_handoff" : "connection_guide", {
214
+ return handoff(doctor.outcome, doctor.reason, unreachedAction(options.deployment, doctor.outcome), {
144
215
  profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
145
216
  prerequisites: "operator_confirmed",
146
217
  });
@@ -176,3 +247,28 @@ export async function preflightNonHostedSetup(options) {
176
247
  metadataAccess: "pending_login",
177
248
  };
178
249
  }
250
+
251
+ // A rerun of non-hosted setup may omit the route it was first given, as a
252
+ // hosted rerun does. Only a saved setup state for a customer-local or BYOC
253
+ // route is resumed, and only to that same origin and workspace. The earlier
254
+ // run's operator confirmation stands for that route; every live check still
255
+ // runs again. Anything else is left for setup's own conflict checks.
256
+ export function resumeNonHostedRoute(options) {
257
+ let saved;
258
+ try {
259
+ saved = readSetupState(resolveProject(options.project));
260
+ } catch {
261
+ return options;
262
+ }
263
+ const deployment = DEPLOYMENT_FOR_PROFILE[saved?.value.deployment_profile];
264
+ if (deployment === undefined) return options;
265
+ const { origin, workspace_id: workspaceId } = saved.value;
266
+ const sameRoute = (!options.originExplicit || options.origin === origin) &&
267
+ (options.workspace === null || options.workspace === workspaceId);
268
+ if (!sameRoute) return options;
269
+ if (!options.deploymentExplicit) {
270
+ return { ...options, deployment, origin, originExplicit: true, workspace: workspaceId, confirmPrerequisites: true };
271
+ }
272
+ if (options.deployment === deployment && options.originExplicit) return { ...options, confirmPrerequisites: true };
273
+ return options;
274
+ }
package/src/setup.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { detectRemoteSession, openBrowser } from "./auth-browser.js";
2
2
  import { readBinding, resolveProject } from "./auth-binding.js";
3
3
  import { startCallbackListener } from "./auth-callback.js";
4
- import { runLogin } from "./auth-login.js";
4
+ import { openUrl, runLogin } from "./auth-login.js";
5
5
  import { clientFor, endpointsFor, newPkce, offeredClientId } from "./auth-oauth.js";
6
6
  import { Stop } from "./auth-store.js";
7
7
  import { verifiedSession } from "./auth-session.js";
@@ -24,26 +24,26 @@ const PURPOSE = "ingest-bootstrap-v1";
24
24
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
25
25
  const RECEIPT = /^mgbs_[A-Za-z0-9_-]{32,256}$/;
26
26
  const KEY = (value) => typeof value === "string" && UUID.test(value);
27
- // --json cannot show a person the --no-browser approval URL. The refusal names
28
- // a fixed, secret-free next step instead of only a reason token.
29
- const RUN_IN_TERMINAL = Object.freeze({
30
- kind: "run_in_terminal",
31
- message: "Run the same setup command in a terminal without --json to see the approval URL.",
32
- });
33
- const fail = (outcome, reason, status = "not_ready", receipt = null) => ({ outcome, reason,
34
- data: { status, application_traffic_verified: false, receipt,
35
- next_action: reason === "no_browser_requires_terminal" ? RUN_IN_TERMINAL : null } });
27
+ const fail = (outcome, reason, status = "not_ready", receipt = null, next = null) => ({ outcome, reason,
28
+ data: { status, application_traffic_verified: false, receipt, next_action: next } });
36
29
 
37
30
  // This command never receives an ingest key from argv, stdin, an environment
38
31
  // variable or a Metadata OAuth grant. Only the purpose-bound redemption
39
32
  // response can supply it. Every returned string is fixed and non-secret.
33
+ //
34
+ // With --json --no-browser the caller passes options.announce(data). The
35
+ // first approval this run needs (sign in, or ingest-only setup approval) is
36
+ // announced as setup data with an open_url next action while the listener
37
+ // keeps waiting; stdout has room for one line, so a second approval in the
38
+ // same run is not started. The person approves, the run saves what it
39
+ // finished, and the agent reruns setup, which resumes from the saved state.
40
40
  export async function runSetup(options, progress = () => {}) {
41
41
  if (options.runtime !== "local" || detectRemoteSession() !== null) return fail("unsupported", "run_on_local_machine");
42
42
  const trap = trapSignals();
43
43
  let receipt = null;
44
44
  let loginVerified = false;
45
45
  let credentialVerified = false;
46
- const stop = (outcome, reason, status = "not_ready") => {
46
+ const currentReceipt = () => {
47
47
  let current = receipt;
48
48
  if (current !== null && (!loginVerified || !credentialVerified)) {
49
49
  const completed = loginVerified ? ["login"] : [];
@@ -52,8 +52,14 @@ export async function runSetup(options, progress = () => {}) {
52
52
  ...(!loginVerified ? ["login"] : []), "credential", "skill", "instrument", "verify", "view",
53
53
  ] };
54
54
  }
55
- return fail(outcome, reason, status, current);
55
+ return current;
56
56
  };
57
+ const stop = (outcome, reason, status = "not_ready") => fail(outcome, reason, status, currentReceipt());
58
+ let announced = false;
59
+ const announce = options.announce ? (status, next) => {
60
+ announced = true;
61
+ options.announce(fail("action_required", "browser_approval_required", status, currentReceipt(), next).data);
62
+ } : null;
57
63
  try {
58
64
  const root = resolveProject(options.project);
59
65
  const saved = readSetupState(root);
@@ -67,6 +73,13 @@ export async function runSetup(options, progress = () => {}) {
67
73
  (bound !== null && (bound.origin !== saved.value.origin || bound.workspace_id !== saved.value.workspace_id)))) {
68
74
  return stop("conflict", "setup_binding_changed");
69
75
  }
76
+ // An env key this setup does not own stops before any sign in, so the
77
+ // refusal costs no approval and leaves the binding untouched. The check
78
+ // after sign in stays, against a fresh read.
79
+ if (!saved) {
80
+ const early = currentEnvValues(preflightEnv({ project: root, envFile: options.envFile, signal: trap.signal }));
81
+ if (early.token !== null) return stop("conflict", "existing_ingest_key_unowned");
82
+ }
70
83
  const bindingDiffers = bound !== null && (bound.origin !== intendedOrigin ||
71
84
  (intendedWorkspace !== null && bound.workspace_id !== intendedWorkspace));
72
85
  if (bindingDiffers && !options.reconnect) return stop("conflict", "bound_to_other_workspace");
@@ -74,7 +87,8 @@ export async function runSetup(options, progress = () => {}) {
74
87
  await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
75
88
  if (!signedIn.ok && signedIn.outcome === "login_required") {
76
89
  const login = await runLogin({ ...options, origin: intendedOrigin, workspace: intendedWorkspace,
77
- project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null }, progress);
90
+ project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null,
91
+ announce: announce && ((data) => announce("login_pending", data.next_action)) }, progress);
78
92
  if (login.outcome !== "ok") return stop(login.outcome, login.reason, "login_pending");
79
93
  signedIn = await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
80
94
  }
@@ -146,8 +160,9 @@ export async function runSetup(options, progress = () => {}) {
146
160
  }
147
161
 
148
162
  // A verified rerun returned above without approval. From here a person
149
- // must open an approval URL, which --json cannot show with --no-browser.
150
- if (options.json && options.noBrowser) return stop("unsupported", "no_browser_requires_terminal");
163
+ // must open an approval URL. A run that already announced its sign in URL
164
+ // has used its one JSON line, so it stops here and the rerun continues.
165
+ if (announced) return stop("action_required", "browser_approval_required", "approval_pending");
151
166
  const setup = await discoverSetup(origin, profile, trap.signal);
152
167
  if (!setup.ok) return stop(setup.outcome, setup.reason);
153
168
 
@@ -183,7 +198,9 @@ export async function runSetup(options, progress = () => {}) {
183
198
  if (intent === "repair") params.expected_key_id = state.key_id;
184
199
  url.search = new URLSearchParams(params).toString();
185
200
  if (trap.signal.aborted) return stop("authorization_failed", "cancelled");
186
- if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
201
+ if (options.noBrowser && announce) {
202
+ announce("approval_pending", openUrl(url.href, Math.round(options.timeoutMs / 1000), "approve ingest-only setup"));
203
+ } else if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
187
204
  else if (!(await openBrowser(url.href))) return stop("authorization_failed", "browser_unavailable");
188
205
  else progress("Opened your browser for ingest-only setup approval.");
189
206
  callback = await pending;
@@ -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 = "8b81eb27b6159c4447a5c300409c4c64917238b7797f5ee37a35248cba50fcae";
8
+ const PINNED_SHA256 = "e7fc4eb7a22eeb16c357bede9d0a080ad3b415aaab1d9c72f164d5350d48c4b1";
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 = "f08a188abba2018770dc3ba071509ff22f3af76ebc5c30a9f3a2ec2b2eae75b5";
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 { CONNECTION_GUIDE_URL, SKILL_CLIENTS, SKILL_RUNTIMES } from "./constants.js";
6
- import { loadBundledSkill, revisionFor, sha256Hex } from "./skill-bundle.js";
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. It writes exactly two things inside the
9
- // project: the client's SKILL.md and a secret-free ownership receipt. It never
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 success(context, plan.status);
100
+ if (plan.noop) return plan.status;
86
101
 
87
102
  return deferSignals(() => {
88
- const createdMeta = ensureDirs(root, [RECEIPT_DIR]);
89
- const lockPath = path.join(root, RECEIPT_DIR, LOCK_FILE);
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 success(context, plan.status);
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, [RECEIPT_DIR]);
133
- const receiptFile = readRegular(path.join(root, RECEIPT_DIR, RECEIPT_FILE), MAX_RECEIPT_BYTES);
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, RECEIPT_DIR, RECEIPT_FILE);
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
+ }