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.
- package/README.md +36 -12
- package/assets/skill/SKILL.md +20 -1
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +67 -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-local-bundle/SKILL.md +283 -0
- package/assets/skills/metergraph-model-swap/SKILL.md +81 -0
- package/assets/skills/metergraph-onboarding/SKILL.md +156 -0
- package/assets/skills/metergraph-report/SKILL.md +91 -0
- package/assets/skills/metergraph-workloads/SKILL.md +94 -0
- package/bin/metergraph.js +4 -4
- package/package.json +3 -1
- package/src/approval-handoff.js +91 -0
- package/src/args.js +50 -11
- package/src/auth-login.js +27 -13
- package/src/cli.js +70 -7
- package/src/constants.js +9 -0
- package/src/deployment-route.js +1 -1
- package/src/output.js +138 -9
- package/src/setup-deployment.js +103 -7
- package/src/setup.js +33 -16
- package/src/skill-bundle.js +68 -1
- package/src/skill.js +175 -14
package/src/setup-deployment.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
150
|
-
|
|
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
|
|
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;
|
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 = "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 {
|
|
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
|
+
}
|