@trusty-squire/mcp 0.6.14-rc.2 → 0.6.14-rc.21
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/LICENSE +21 -0
- package/dist/api-client.d.ts +45 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +43 -0
- package/dist/api-client.js.map +1 -1
- package/dist/bin.js +12 -0
- package/dist/bin.js.map +1 -1
- package/dist/bot/agent.d.ts +35 -2
- package/dist/bot/agent.d.ts.map +1 -1
- package/dist/bot/agent.js +525 -38
- package/dist/bot/agent.js.map +1 -1
- package/dist/bot/browser.d.ts +8 -0
- package/dist/bot/browser.d.ts.map +1 -1
- package/dist/bot/browser.js +193 -20
- package/dist/bot/browser.js.map +1 -1
- package/dist/bot/index.d.ts +4 -2
- package/dist/bot/index.d.ts.map +1 -1
- package/dist/bot/index.js +17 -3
- package/dist/bot/index.js.map +1 -1
- package/dist/bot/llm-client.d.ts +1 -1
- package/dist/bot/llm-client.d.ts.map +1 -1
- package/dist/bot/onboarding-capture.d.ts +3 -0
- package/dist/bot/onboarding-capture.d.ts.map +1 -1
- package/dist/bot/onboarding-capture.js +70 -5
- package/dist/bot/onboarding-capture.js.map +1 -1
- package/dist/bot/promote-to-skill.d.ts +2 -1
- package/dist/bot/promote-to-skill.d.ts.map +1 -1
- package/dist/bot/promote-to-skill.js +214 -29
- package/dist/bot/promote-to-skill.js.map +1 -1
- package/dist/bot/replay-skill.d.ts +4 -0
- package/dist/bot/replay-skill.d.ts.map +1 -1
- package/dist/bot/replay-skill.js +300 -3
- package/dist/bot/replay-skill.js.map +1 -1
- package/dist/install/cli.d.ts +16 -0
- package/dist/install/cli.d.ts.map +1 -1
- package/dist/install/cli.js +63 -6
- package/dist/install/cli.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +15 -5
- package/dist/session.js.map +1 -1
- package/dist/skill-cli/cli.d.ts +25 -0
- package/dist/skill-cli/cli.d.ts.map +1 -1
- package/dist/skill-cli/cli.js +558 -13
- package/dist/skill-cli/cli.js.map +1 -1
- package/dist/skill-cli/registry-http.d.ts +1 -0
- package/dist/skill-cli/registry-http.d.ts.map +1 -1
- package/dist/skill-cli/registry-http.js +3 -0
- package/dist/skill-cli/registry-http.js.map +1 -1
- package/dist/skill-cli/signing.d.ts +21 -0
- package/dist/skill-cli/signing.d.ts.map +1 -0
- package/dist/skill-cli/signing.js +71 -0
- package/dist/skill-cli/signing.js.map +1 -0
- package/dist/skill-registry-client.d.ts +2 -0
- package/dist/skill-registry-client.d.ts.map +1 -1
- package/dist/skill-registry-client.js +83 -36
- package/dist/skill-registry-client.js.map +1 -1
- package/dist/tools/extract-failures.d.ts +23 -0
- package/dist/tools/extract-failures.d.ts.map +1 -0
- package/dist/tools/extract-failures.js +108 -0
- package/dist/tools/extract-failures.js.map +1 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +6 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/provision-any.d.ts +7 -0
- package/dist/tools/provision-any.d.ts.map +1 -1
- package/dist/tools/provision-any.js +346 -45
- package/dist/tools/provision-any.js.map +1 -1
- package/package.json +16 -15
package/dist/skill-cli/cli.js
CHANGED
|
@@ -17,8 +17,17 @@
|
|
|
17
17
|
// Exit codes — see errors.ts (ExitCode). Distinct codes per failure
|
|
18
18
|
// class so shell scripts can branch reliably.
|
|
19
19
|
import process from "node:process";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as os from "node:os";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
20
24
|
import { CliExit, ExitCode } from "./errors.js";
|
|
21
25
|
import { clientFromEnvOrThrow, RegistryHttpClient } from "./registry-http.js";
|
|
26
|
+
import { signSkillForPublish } from "./signing.js";
|
|
27
|
+
import { promoteToSkill, deriveSkillId } from "../bot/promote-to-skill.js";
|
|
28
|
+
import { replaySkill } from "../bot/replay-skill.js";
|
|
29
|
+
import { BrowserController } from "../bot/browser.js";
|
|
30
|
+
import { parseSkill } from "@trusty-squire/adapter-sdk";
|
|
22
31
|
/**
|
|
23
32
|
* Dispatch a `skill` subcommand. Returns the exit code (does NOT
|
|
24
33
|
* call process.exit — bin.ts owns that). Throwing is reserved for
|
|
@@ -54,6 +63,18 @@ export async function runSkillCli(argv, opts = {}) {
|
|
|
54
63
|
return await cmdDemote(argv.slice(1), client, stdout);
|
|
55
64
|
case "approve":
|
|
56
65
|
return await cmdApprove(argv.slice(1), client, stdout);
|
|
66
|
+
case "promote":
|
|
67
|
+
return await cmdPromote(argv.slice(1), client, stdout, opts);
|
|
68
|
+
case "reactivate":
|
|
69
|
+
return await cmdReactivate(argv.slice(1), client, stdout);
|
|
70
|
+
case "delete":
|
|
71
|
+
return await cmdDelete(argv.slice(1), client, stdout);
|
|
72
|
+
case "replay-test":
|
|
73
|
+
return await cmdReplayTest(argv.slice(1), client, stdout, opts);
|
|
74
|
+
case "diff":
|
|
75
|
+
return await cmdDiff(argv.slice(1), client, stdout);
|
|
76
|
+
case "edit":
|
|
77
|
+
return await cmdEdit(argv.slice(1), client, stdout, opts);
|
|
57
78
|
default:
|
|
58
79
|
stderr(`unknown skill subcommand: ${subcommand}`);
|
|
59
80
|
printHelp(stderr);
|
|
@@ -283,31 +304,555 @@ async function cmdApprove(argv, client, out) {
|
|
|
283
304
|
out(`approved ${data.skill_id} (status=${data.status})`);
|
|
284
305
|
return ExitCode.OK;
|
|
285
306
|
}
|
|
307
|
+
async function cmdPromote(argv, client, out, opts) {
|
|
308
|
+
const parsed = parseFlags(argv);
|
|
309
|
+
rejectUnknownFlags(parsed, new Set(["run-id", "corpus-dir", "dry-run", "json"]));
|
|
310
|
+
requirePositional(parsed, 1, "service");
|
|
311
|
+
const service = parsed.positional[0];
|
|
312
|
+
const runId = parsed.flags["run-id"];
|
|
313
|
+
if (runId === undefined || runId.length === 0) {
|
|
314
|
+
// `--run-id` is required for 0.7.0: there's no manifest of "which
|
|
315
|
+
// capture is canonical", and silently picking one would let a
|
|
316
|
+
// half-finished capture leak into the registry. Operator picks.
|
|
317
|
+
throw new CliExit(ExitCode.ARGS, "promote requires --run-id=<id> (pick which capture run to promote)");
|
|
318
|
+
}
|
|
319
|
+
const corpusRoot = parsed.flags["corpus-dir"] ?? opts.corpusDir ?? process.env.CORPUS_DIR ?? "./corpus/onboarding";
|
|
320
|
+
const dir = path.join(corpusRoot, service);
|
|
321
|
+
const dryRun = parsed.booleans.has("dry-run");
|
|
322
|
+
const json = parsed.booleans.has("json");
|
|
323
|
+
// Stage 1 — synthesize. Capture-chain verification, step translation,
|
|
324
|
+
// credential-spec inference, schema validation all happen here.
|
|
325
|
+
const result = promoteToSkill({ dir, service, run_id: runId });
|
|
326
|
+
if (result.kind !== "ok") {
|
|
327
|
+
const payload = {
|
|
328
|
+
ok: false,
|
|
329
|
+
stage: result.stage,
|
|
330
|
+
error_kind: result.error_kind,
|
|
331
|
+
message: result.message,
|
|
332
|
+
...(result.offending_round !== undefined
|
|
333
|
+
? { offending_round: result.offending_round }
|
|
334
|
+
: {}),
|
|
335
|
+
...(result.offending_step !== undefined
|
|
336
|
+
? { offending_step: result.offending_step }
|
|
337
|
+
: {}),
|
|
338
|
+
...(result.detail !== undefined ? { detail: result.detail } : {}),
|
|
339
|
+
synthesizer_version: result.synthesizer_version,
|
|
340
|
+
};
|
|
341
|
+
if (json) {
|
|
342
|
+
out(JSON.stringify(payload, null, 2));
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
out(`rejected: ${result.stage} / ${result.error_kind}`);
|
|
346
|
+
out(` ${result.message}`);
|
|
347
|
+
if (result.offending_round !== undefined)
|
|
348
|
+
out(` at round ${result.offending_round}`);
|
|
349
|
+
if (result.offending_step !== undefined)
|
|
350
|
+
out(` at step ${result.offending_step}`);
|
|
351
|
+
if (result.detail !== undefined)
|
|
352
|
+
out(` detail: ${result.detail}`);
|
|
353
|
+
}
|
|
354
|
+
return ExitCode.VALIDATION;
|
|
355
|
+
}
|
|
356
|
+
if (dryRun) {
|
|
357
|
+
// Dry-run stops here — no signing, no publish. The synthesis
|
|
358
|
+
// result is itself the validation we'd otherwise hit the server
|
|
359
|
+
// with. Print enough that the operator can decide whether to
|
|
360
|
+
// re-run without --dry-run.
|
|
361
|
+
const payload = {
|
|
362
|
+
ok: true,
|
|
363
|
+
dry_run: true,
|
|
364
|
+
skill_id: result.skill.skill_id,
|
|
365
|
+
service: result.skill.service,
|
|
366
|
+
version: result.skill.version,
|
|
367
|
+
steps: result.skill.steps.length,
|
|
368
|
+
};
|
|
369
|
+
if (json) {
|
|
370
|
+
out(JSON.stringify(payload, null, 2));
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
out(`dry-run OK — synthesized ${result.skill.skill_id} v${result.skill.version} (${result.skill.steps.length} steps)`);
|
|
374
|
+
out(" re-run without --dry-run to sign and publish.");
|
|
375
|
+
}
|
|
376
|
+
return ExitCode.OK;
|
|
377
|
+
}
|
|
378
|
+
// Stage 2 — sign. CliExit(CONFIG) bubbles when the key isn't set.
|
|
379
|
+
const signed = signSkillForPublish(result.skill, opts.signingPrivateKey !== undefined ? { privateKey: opts.signingPrivateKey } : {});
|
|
380
|
+
// Stage 3 — publish. POST /skills returns 201 on first publish, 200
|
|
381
|
+
// on idempotent re-publish. The HTTP client throws on 401/400/etc.
|
|
382
|
+
// (caught by the top-level dispatcher and surfaced as the right
|
|
383
|
+
// exit code).
|
|
384
|
+
const response = await client.post("/skills", {
|
|
385
|
+
skill: result.skill,
|
|
386
|
+
signature: signed.signature,
|
|
387
|
+
});
|
|
388
|
+
if (json) {
|
|
389
|
+
out(JSON.stringify(response, null, 2));
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
const tag = response.idempotent ? "already published" : "published";
|
|
393
|
+
out(`${tag}: ${response.service} ${response.version} (skill_id=${response.skill_id}, status=${response.status})`);
|
|
394
|
+
}
|
|
395
|
+
return ExitCode.OK;
|
|
396
|
+
}
|
|
397
|
+
// ── reactivate ──────────────────────────────────────────────────────
|
|
398
|
+
async function cmdReactivate(argv, client, out) {
|
|
399
|
+
const parsed = parseFlags(argv);
|
|
400
|
+
rejectUnknownFlags(parsed, new Set(["json"]));
|
|
401
|
+
requirePositional(parsed, 1, "skill_id");
|
|
402
|
+
const skillId = parsed.positional[0];
|
|
403
|
+
const data = await client.post(`/skills/${encodeURIComponent(skillId)}/reactivate`, {});
|
|
404
|
+
if (parsed.booleans.has("json")) {
|
|
405
|
+
out(JSON.stringify(data, null, 2));
|
|
406
|
+
return ExitCode.OK;
|
|
407
|
+
}
|
|
408
|
+
if (data.previously === data.status) {
|
|
409
|
+
out(`${data.skill_id} is already ${data.status} (no-op)`);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
out(`reactivated ${data.skill_id} (${data.previously} → ${data.status})`);
|
|
413
|
+
}
|
|
414
|
+
return ExitCode.OK;
|
|
415
|
+
}
|
|
416
|
+
// ── delete ──────────────────────────────────────────────────────────
|
|
417
|
+
async function cmdDelete(argv, client, out) {
|
|
418
|
+
const parsed = parseFlags(argv);
|
|
419
|
+
rejectUnknownFlags(parsed, new Set(["confirm", "json"]));
|
|
420
|
+
requirePositional(parsed, 1, "skill_id");
|
|
421
|
+
const skillId = parsed.positional[0];
|
|
422
|
+
if (!parsed.booleans.has("confirm")) {
|
|
423
|
+
throw new CliExit(ExitCode.ARGS, "delete is irreversible — pass --confirm to acknowledge. " +
|
|
424
|
+
"Captures linked to this skill_id are deleted with it.");
|
|
425
|
+
}
|
|
426
|
+
// Hard delete via DELETE /skills/:skill_id. The HTTP client always
|
|
427
|
+
// tries to parse JSON, so the server responds with a small body.
|
|
428
|
+
const data = await client.delete(`/skills/${encodeURIComponent(skillId)}`);
|
|
429
|
+
if (parsed.booleans.has("json")) {
|
|
430
|
+
out(JSON.stringify(data, null, 2));
|
|
431
|
+
return ExitCode.OK;
|
|
432
|
+
}
|
|
433
|
+
out(`deleted ${data.skill_id}`);
|
|
434
|
+
return ExitCode.OK;
|
|
435
|
+
}
|
|
436
|
+
// ── replay-test ─────────────────────────────────────────────────────
|
|
437
|
+
async function cmdReplayTest(argv, client, out, opts) {
|
|
438
|
+
const parsed = parseFlags(argv);
|
|
439
|
+
rejectUnknownFlags(parsed, new Set(["full", "json"]));
|
|
440
|
+
requirePositional(parsed, 1, "service");
|
|
441
|
+
const service = parsed.positional[0];
|
|
442
|
+
const full = parsed.booleans.has("full");
|
|
443
|
+
const json = parsed.booleans.has("json");
|
|
444
|
+
// Fetch the active skill — same endpoint the router uses. 404 here
|
|
445
|
+
// becomes CliExit(NOT_FOUND, …) via the http client.
|
|
446
|
+
const envelope = await client.get(`/skills/${encodeURIComponent(service)}`);
|
|
447
|
+
let skill;
|
|
448
|
+
try {
|
|
449
|
+
skill = parseSkill(envelope.skill);
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
throw new CliExit(ExitCode.GENERIC, `registry returned a skill that fails schema validation: ${err instanceof Error ? err.message : String(err)}`);
|
|
453
|
+
}
|
|
454
|
+
// Boot the browser. Tests inject a stub so they don't need
|
|
455
|
+
// Playwright/Chromium.
|
|
456
|
+
const browser = (opts.browserFactory ?? (() => new BrowserController({ humanize: true })))();
|
|
457
|
+
let outcome;
|
|
458
|
+
try {
|
|
459
|
+
await browser.start();
|
|
460
|
+
outcome = await replaySkill({
|
|
461
|
+
skill,
|
|
462
|
+
browser,
|
|
463
|
+
mode: full ? "full" : "dry",
|
|
464
|
+
templateValues: {
|
|
465
|
+
EMAIL_ALIAS: `replay-test-${Date.now()}@example.invalid`,
|
|
466
|
+
TOKEN_NAME: `replay-test-${Date.now()}`,
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
finally {
|
|
471
|
+
try {
|
|
472
|
+
await browser.close();
|
|
473
|
+
}
|
|
474
|
+
catch { /* noop */ }
|
|
475
|
+
}
|
|
476
|
+
const payload = { ok: outcome.kind === "ok" || outcome.kind === "dry_pass", outcome };
|
|
477
|
+
if (json) {
|
|
478
|
+
out(JSON.stringify(payload, null, 2));
|
|
479
|
+
}
|
|
480
|
+
else if (outcome.kind === "dry_pass") {
|
|
481
|
+
out(`dry-pass: walked ${outcome.stepsWalked} steps without executing the credential-creating click.`);
|
|
482
|
+
}
|
|
483
|
+
else if (outcome.kind === "ok") {
|
|
484
|
+
out(`ok: full-mode replay extracted a credential via ${outcome.via}.`);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
out(`failed: ${outcome.kind}`);
|
|
488
|
+
if ("reason" in outcome)
|
|
489
|
+
out(` reason: ${outcome.reason}`);
|
|
490
|
+
if ("stepIndex" in outcome)
|
|
491
|
+
out(` at step ${outcome.stepIndex}`);
|
|
492
|
+
}
|
|
493
|
+
return outcome.kind === "ok" || outcome.kind === "dry_pass"
|
|
494
|
+
? ExitCode.OK
|
|
495
|
+
: 6; // T30 exit code for replay-test rejection (design doc §CLI)
|
|
496
|
+
}
|
|
497
|
+
// Fields that participate in semantic identity per step kind. Anything
|
|
498
|
+
// outside this list (e.g. `provenance`) is bookkeeping the diff
|
|
499
|
+
// ignores — operators care about what the replay engine actually
|
|
500
|
+
// targets, not which capture round originated each step.
|
|
501
|
+
const STEP_IDENTITY_FIELDS = {
|
|
502
|
+
navigate: ["url"],
|
|
503
|
+
click_oauth_button: ["provider"],
|
|
504
|
+
click: ["text_match", "role"],
|
|
505
|
+
fill: ["text_match", "role", "value_template"],
|
|
506
|
+
select: ["text_match", "role", "option_match"],
|
|
507
|
+
extract_via_copy_button: ["near_text_hint"],
|
|
508
|
+
extract_via_regex: ["pattern_name"],
|
|
509
|
+
};
|
|
510
|
+
function semanticStepDiff(before, after) {
|
|
511
|
+
const out = [];
|
|
512
|
+
const max = Math.max(before.length, after.length);
|
|
513
|
+
for (let i = 0; i < max; i++) {
|
|
514
|
+
const b = before[i];
|
|
515
|
+
const a = after[i];
|
|
516
|
+
if (b === undefined && a !== undefined) {
|
|
517
|
+
out.push({
|
|
518
|
+
kind: "added",
|
|
519
|
+
index: i,
|
|
520
|
+
step_kind: typeof a.kind === "string" ? a.kind : "<unknown>",
|
|
521
|
+
step: a,
|
|
522
|
+
});
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (a === undefined && b !== undefined) {
|
|
526
|
+
out.push({
|
|
527
|
+
kind: "removed",
|
|
528
|
+
index: i,
|
|
529
|
+
step_kind: typeof b.kind === "string" ? b.kind : "<unknown>",
|
|
530
|
+
step: b,
|
|
531
|
+
});
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (a === undefined || b === undefined)
|
|
535
|
+
continue;
|
|
536
|
+
const bKind = typeof b.kind === "string" ? b.kind : "<unknown>";
|
|
537
|
+
const aKind = typeof a.kind === "string" ? a.kind : "<unknown>";
|
|
538
|
+
if (bKind !== aKind) {
|
|
539
|
+
out.push({
|
|
540
|
+
kind: "modified",
|
|
541
|
+
index: i,
|
|
542
|
+
step_kind: `${bKind} → ${aKind}`,
|
|
543
|
+
fields: ["kind"],
|
|
544
|
+
before: b,
|
|
545
|
+
after: a,
|
|
546
|
+
});
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const identityFields = STEP_IDENTITY_FIELDS[bKind] ?? [];
|
|
550
|
+
const changed = [];
|
|
551
|
+
for (const field of identityFields) {
|
|
552
|
+
if (JSON.stringify(b[field]) !== JSON.stringify(a[field]))
|
|
553
|
+
changed.push(field);
|
|
554
|
+
}
|
|
555
|
+
if (changed.length === 0) {
|
|
556
|
+
out.push({ kind: "unchanged", index: i, step_kind: bKind });
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
out.push({
|
|
560
|
+
kind: "modified",
|
|
561
|
+
index: i,
|
|
562
|
+
step_kind: bKind,
|
|
563
|
+
fields: changed,
|
|
564
|
+
before: b,
|
|
565
|
+
after: a,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
async function cmdDiff(argv, client, out) {
|
|
572
|
+
const parsed = parseFlags(argv);
|
|
573
|
+
rejectUnknownFlags(parsed, new Set(["json"]));
|
|
574
|
+
if (parsed.positional.length !== 3) {
|
|
575
|
+
throw new CliExit(ExitCode.ARGS, `expected 3 positional arguments (service v1 v2), got ${parsed.positional.length}`);
|
|
576
|
+
}
|
|
577
|
+
const [service, v1, v2] = parsed.positional;
|
|
578
|
+
const json = parsed.booleans.has("json");
|
|
579
|
+
if (v1 === v2) {
|
|
580
|
+
if (json) {
|
|
581
|
+
out(JSON.stringify({ ok: true, identical: true, service, from: v1, to: v2 }, null, 2));
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
out(`identical: both arguments are ${JSON.stringify(v1)}`);
|
|
585
|
+
}
|
|
586
|
+
return 1; // T30 exit code: versions identical (design doc §CLI)
|
|
587
|
+
}
|
|
588
|
+
// 1. Find both versions in the service's list.
|
|
589
|
+
const list = await client.get(`/skills?service=${encodeURIComponent(service)}&limit=500`);
|
|
590
|
+
const items = list.skills ?? [];
|
|
591
|
+
const fromItem = items.find((s) => s.version === v1);
|
|
592
|
+
const toItem = items.find((s) => s.version === v2);
|
|
593
|
+
if (fromItem === undefined || toItem === undefined) {
|
|
594
|
+
const missing = [];
|
|
595
|
+
if (fromItem === undefined)
|
|
596
|
+
missing.push(v1);
|
|
597
|
+
if (toItem === undefined)
|
|
598
|
+
missing.push(v2);
|
|
599
|
+
throw new CliExit(
|
|
600
|
+
// Design doc maps "version not found" to exit code 2 (ARGS class
|
|
601
|
+
// — operator named a version that doesn't exist).
|
|
602
|
+
ExitCode.ARGS, `version${missing.length > 1 ? "s" : ""} not found for service ${JSON.stringify(service)}: ${missing.join(", ")}`);
|
|
603
|
+
}
|
|
604
|
+
// 2. Fetch full payloads — list returns metadata only.
|
|
605
|
+
const before = await client.get(`/skills/by-id/${encodeURIComponent(fromItem.skill_id)}`);
|
|
606
|
+
const after = await client.get(`/skills/by-id/${encodeURIComponent(toItem.skill_id)}`);
|
|
607
|
+
const beforeSteps = Array.isArray(before.skill.steps)
|
|
608
|
+
? (before.skill.steps)
|
|
609
|
+
: [];
|
|
610
|
+
const afterSteps = Array.isArray(after.skill.steps)
|
|
611
|
+
? (after.skill.steps)
|
|
612
|
+
: [];
|
|
613
|
+
const entries = semanticStepDiff(beforeSteps, afterSteps);
|
|
614
|
+
const identical = entries.every((e) => e.kind === "unchanged");
|
|
615
|
+
if (json) {
|
|
616
|
+
out(JSON.stringify({
|
|
617
|
+
ok: true,
|
|
618
|
+
service,
|
|
619
|
+
from: { version: v1, skill_id: fromItem.skill_id },
|
|
620
|
+
to: { version: v2, skill_id: toItem.skill_id },
|
|
621
|
+
identical,
|
|
622
|
+
step_diff: entries,
|
|
623
|
+
}, null, 2));
|
|
624
|
+
return identical ? 1 : ExitCode.OK;
|
|
625
|
+
}
|
|
626
|
+
// Human-readable output. Unified-diff-ish: prefix each line with a
|
|
627
|
+
// sigil (=, ~, +, -) so a `grep '^[+-]'` finds only real changes.
|
|
628
|
+
out(`diff: ${service} ${v1} → ${v2}`);
|
|
629
|
+
out(` skill_id: ${fromItem.skill_id} → ${toItem.skill_id}`);
|
|
630
|
+
out(` steps: ${beforeSteps.length} → ${afterSteps.length}` +
|
|
631
|
+
(beforeSteps.length !== afterSteps.length
|
|
632
|
+
? ` (${afterSteps.length > beforeSteps.length ? "+" : ""}${afterSteps.length - beforeSteps.length})`
|
|
633
|
+
: ""));
|
|
634
|
+
out("");
|
|
635
|
+
for (const entry of entries) {
|
|
636
|
+
const sigil = entry.kind === "unchanged"
|
|
637
|
+
? "="
|
|
638
|
+
: entry.kind === "modified"
|
|
639
|
+
? "~"
|
|
640
|
+
: entry.kind === "added"
|
|
641
|
+
? "+"
|
|
642
|
+
: "-";
|
|
643
|
+
const label = `${sigil} [${entry.index}] ${entry.step_kind}`;
|
|
644
|
+
if (entry.kind === "unchanged") {
|
|
645
|
+
out(`${label} unchanged`);
|
|
646
|
+
}
|
|
647
|
+
else if (entry.kind === "modified") {
|
|
648
|
+
out(`${label} modified: ${entry.fields.join(", ")}`);
|
|
649
|
+
for (const field of entry.fields) {
|
|
650
|
+
out(` - ${field}: ${JSON.stringify(entry.before[field])}`);
|
|
651
|
+
out(` + ${field}: ${JSON.stringify(entry.after[field])}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
else if (entry.kind === "added") {
|
|
655
|
+
out(`${label} added`);
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
out(`${label} removed`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
// Exit code: 0 if differences shown, 1 if identical (per design doc).
|
|
662
|
+
return identical ? 1 : ExitCode.OK;
|
|
663
|
+
}
|
|
664
|
+
// ── edit ────────────────────────────────────────────────────────────
|
|
665
|
+
async function cmdEdit(argv, client, out, opts) {
|
|
666
|
+
const parsed = parseFlags(argv);
|
|
667
|
+
rejectUnknownFlags(parsed, new Set(["dry-run", "json"]));
|
|
668
|
+
requirePositional(parsed, 1, "service");
|
|
669
|
+
const service = parsed.positional[0];
|
|
670
|
+
const dryRun = parsed.booleans.has("dry-run");
|
|
671
|
+
const json = parsed.booleans.has("json");
|
|
672
|
+
// 1. Fetch the active skill. The endpoint returns 404 when there's
|
|
673
|
+
// nothing to edit; the HTTP client maps that to NOT_FOUND.
|
|
674
|
+
const envelope = await client.get(`/skills/${encodeURIComponent(service)}`);
|
|
675
|
+
let before;
|
|
676
|
+
try {
|
|
677
|
+
before = parseSkill(envelope.skill);
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
throw new CliExit(ExitCode.GENERIC, `registry returned a skill that fails schema validation: ${err instanceof Error ? err.message : String(err)}`);
|
|
681
|
+
}
|
|
682
|
+
// 2. Write the skill to a tempfile and let $EDITOR (or the injected
|
|
683
|
+
// editorCommand) modify it. Save the original bytes to detect
|
|
684
|
+
// "no changes" cleanly — comparing JSON would also work but the
|
|
685
|
+
// string compare catches whitespace/order edits the operator
|
|
686
|
+
// might intentionally make.
|
|
687
|
+
const tempPath = path.join(os.tmpdir(), `skill-edit-${before.skill_id}-${Date.now()}.json`);
|
|
688
|
+
const originalText = JSON.stringify(before, null, 2);
|
|
689
|
+
fs.writeFileSync(tempPath, originalText, "utf8");
|
|
690
|
+
try {
|
|
691
|
+
if (opts.editorCommand !== undefined) {
|
|
692
|
+
await opts.editorCommand(tempPath);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
|
|
696
|
+
const result = spawnSync(editor, [tempPath], { stdio: "inherit" });
|
|
697
|
+
if (result.error !== undefined) {
|
|
698
|
+
throw new CliExit(ExitCode.GENERIC, `failed to launch editor (${editor}): ${result.error.message}`);
|
|
699
|
+
}
|
|
700
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
701
|
+
throw new CliExit(ExitCode.GENERIC, `editor (${editor}) exited with status ${result.status}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const editedText = fs.readFileSync(tempPath, "utf8");
|
|
705
|
+
if (editedText === originalText) {
|
|
706
|
+
if (json) {
|
|
707
|
+
out(JSON.stringify({ ok: false, no_edits: true }, null, 2));
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
out("no edits made — skill unchanged.");
|
|
711
|
+
}
|
|
712
|
+
// Design doc maps "no edits made" to exit code 2 (ARGS class).
|
|
713
|
+
return ExitCode.ARGS;
|
|
714
|
+
}
|
|
715
|
+
// 3. Parse the edited JSON.
|
|
716
|
+
let edited;
|
|
717
|
+
try {
|
|
718
|
+
edited = JSON.parse(editedText);
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
throw new CliExit(ExitCode.VALIDATION, `edited file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
722
|
+
}
|
|
723
|
+
// 4. Recompute skill_id from the edited content so the operator
|
|
724
|
+
// doesn't need to manage it manually. The edit field is
|
|
725
|
+
// ignored in favor of the deterministic derivation — but a
|
|
726
|
+
// schema-invalid Omit<Skill,"skill_id"> still throws here.
|
|
727
|
+
if (typeof edited !== "object" || edited === null || Array.isArray(edited)) {
|
|
728
|
+
throw new CliExit(ExitCode.VALIDATION, "edited file must be a JSON object");
|
|
729
|
+
}
|
|
730
|
+
const editedObj = edited;
|
|
731
|
+
delete editedObj.skill_id;
|
|
732
|
+
// parseSkill requires skill_id; insert a deterministically-derived
|
|
733
|
+
// placeholder so validation passes, then we'll recompute from the
|
|
734
|
+
// validated shape.
|
|
735
|
+
editedObj.skill_id = before.skill_id;
|
|
736
|
+
let candidate;
|
|
737
|
+
try {
|
|
738
|
+
candidate = parseSkill(editedObj);
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
throw new CliExit(ExitCode.VALIDATION, `edited skill failed schema validation: ${err instanceof Error ? err.message : String(err)}`);
|
|
742
|
+
}
|
|
743
|
+
const candidateWithoutId = { ...candidate };
|
|
744
|
+
delete candidateWithoutId.skill_id;
|
|
745
|
+
const newSkillId = deriveSkillId(candidateWithoutId);
|
|
746
|
+
const finalSkill = { ...candidate, skill_id: newSkillId };
|
|
747
|
+
// 5. Detect security-relevant edits. The server's review gate
|
|
748
|
+
// (T26) does the actual hold; we surface a clear warning so
|
|
749
|
+
// the operator knows the new skill won't go live until
|
|
750
|
+
// `skill approve <skill_id>` runs.
|
|
751
|
+
const securityChanges = [];
|
|
752
|
+
if (finalSkill.signup_url !== before.signup_url) {
|
|
753
|
+
securityChanges.push(`signup_url: ${before.signup_url} → ${finalSkill.signup_url}`);
|
|
754
|
+
}
|
|
755
|
+
if (finalSkill.oauth_provider !== before.oauth_provider) {
|
|
756
|
+
securityChanges.push(`oauth_provider: ${before.oauth_provider ?? "null"} → ${finalSkill.oauth_provider ?? "null"}`);
|
|
757
|
+
}
|
|
758
|
+
if (dryRun) {
|
|
759
|
+
if (json) {
|
|
760
|
+
out(JSON.stringify({
|
|
761
|
+
ok: true,
|
|
762
|
+
dry_run: true,
|
|
763
|
+
skill_id: finalSkill.skill_id,
|
|
764
|
+
previous_skill_id: before.skill_id,
|
|
765
|
+
version: finalSkill.version,
|
|
766
|
+
security_changes: securityChanges,
|
|
767
|
+
}, null, 2));
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
out(`dry-run OK — edited skill validates (${finalSkill.skill_id} v${finalSkill.version}).`);
|
|
771
|
+
if (securityChanges.length > 0) {
|
|
772
|
+
out("");
|
|
773
|
+
out("⚠ security-gated edits — would land in pending-review:");
|
|
774
|
+
for (const change of securityChanges)
|
|
775
|
+
out(` ${change}`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return ExitCode.OK;
|
|
779
|
+
}
|
|
780
|
+
// 6. Sign + publish.
|
|
781
|
+
const signed = signSkillForPublish(finalSkill, opts.signingPrivateKey !== undefined ? { privateKey: opts.signingPrivateKey } : {});
|
|
782
|
+
const response = await client.post("/skills", { skill: finalSkill, signature: signed.signature });
|
|
783
|
+
if (json) {
|
|
784
|
+
out(JSON.stringify({
|
|
785
|
+
...response,
|
|
786
|
+
ok: true,
|
|
787
|
+
previous_skill_id: before.skill_id,
|
|
788
|
+
security_changes: securityChanges,
|
|
789
|
+
}, null, 2));
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
out(`published: ${response.service} ${response.version} (skill_id=${response.skill_id}, status=${response.status})`);
|
|
793
|
+
if (securityChanges.length > 0) {
|
|
794
|
+
out("");
|
|
795
|
+
out("⚠ this edit touched security-relevant fields — the registry holds");
|
|
796
|
+
out(" it as pending-review until an operator runs:");
|
|
797
|
+
out(` skill approve ${response.skill_id}`);
|
|
798
|
+
for (const change of securityChanges)
|
|
799
|
+
out(` ${change}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return ExitCode.OK;
|
|
803
|
+
}
|
|
804
|
+
finally {
|
|
805
|
+
// Always clean the tempfile — it contains the full skill payload,
|
|
806
|
+
// which isn't secret per se but lingering tempfiles are noise.
|
|
807
|
+
try {
|
|
808
|
+
fs.unlinkSync(tempPath);
|
|
809
|
+
}
|
|
810
|
+
catch { /* noop */ }
|
|
811
|
+
}
|
|
812
|
+
}
|
|
286
813
|
// ── Help ────────────────────────────────────────────────────────────
|
|
287
814
|
function printHelp(out) {
|
|
288
815
|
out(`Usage: npx @trusty-squire/mcp skill <subcommand> [args]
|
|
289
816
|
|
|
290
817
|
Subcommands:
|
|
291
|
-
|
|
292
|
-
|
|
818
|
+
promote <service> --run-id=<id> [--corpus-dir=<path>] [--dry-run] [--json]
|
|
819
|
+
Synthesize a skill from corpus/onboarding/<service>/<run_id>/* and publish.
|
|
820
|
+
|
|
821
|
+
list [--service=X] [--status=S] [--limit=N] [--json]
|
|
822
|
+
List skills with optional filters.
|
|
823
|
+
|
|
824
|
+
show <skill_id> [--json]
|
|
825
|
+
Show a skill's full record (steps, credentials, counters).
|
|
826
|
+
|
|
827
|
+
replays <skill_id> [--limit=N] [--json]
|
|
828
|
+
Show recent replay outcomes for a skill (any status).
|
|
829
|
+
|
|
830
|
+
captures <skill_id> [--json]
|
|
831
|
+
List capture sidecars (source-map for the skill's promotion).
|
|
832
|
+
|
|
833
|
+
demote <skill_id> --reason=<text>
|
|
834
|
+
Manually demote a skill so the router stops serving it.
|
|
835
|
+
|
|
836
|
+
reactivate <skill_id> [--json]
|
|
837
|
+
Undo a demotion; reset consecutive_failures to 0.
|
|
293
838
|
|
|
294
|
-
|
|
295
|
-
|
|
839
|
+
approve <skill_id>
|
|
840
|
+
Flip a pending-review skill to active (C11 human gate).
|
|
296
841
|
|
|
297
|
-
|
|
298
|
-
|
|
842
|
+
delete <skill_id> --confirm [--json]
|
|
843
|
+
Hard-delete a skill and its captures. Irreversible.
|
|
299
844
|
|
|
300
|
-
|
|
301
|
-
|
|
845
|
+
replay-test <service> [--full] [--json]
|
|
846
|
+
Re-run the active skill's replay against the live page. Default: dry mode.
|
|
302
847
|
|
|
303
|
-
|
|
304
|
-
|
|
848
|
+
diff <service> <v1> <v2> [--json]
|
|
849
|
+
Semantic step-graph diff between two skill versions for a service.
|
|
305
850
|
|
|
306
|
-
|
|
307
|
-
|
|
851
|
+
edit <service> [--dry-run] [--json]
|
|
852
|
+
Open the active skill in $EDITOR; re-validate, re-sign, re-publish.
|
|
308
853
|
|
|
309
854
|
help
|
|
310
|
-
|
|
855
|
+
Print this message.
|
|
311
856
|
|
|
312
857
|
Environment:
|
|
313
858
|
TRUSTY_SQUIRE_REGISTRY_URL Required. Base URL of registry-api.
|