crewhaus 0.1.8 → 0.2.1

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.
Files changed (138) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +173 -0
  25. package/dist/dataset-mine.js +404 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11664 -963
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/route.d.ts +19 -0
  107. package/dist/route.js +90 -0
  108. package/dist/scaffold-evals.d.ts +138 -0
  109. package/dist/scaffold-evals.js +410 -0
  110. package/dist/scope-audit-drift.d.ts +139 -0
  111. package/dist/scope-audit-drift.js +260 -0
  112. package/dist/security-corpus.d.ts +237 -0
  113. package/dist/security-corpus.js +516 -0
  114. package/dist/security-digest.d.ts +174 -0
  115. package/dist/security-digest.js +656 -0
  116. package/dist/sessions-index.d.ts +27 -0
  117. package/dist/sessions-index.js +51 -0
  118. package/dist/slo-doctor.d.ts +67 -0
  119. package/dist/slo-doctor.js +119 -0
  120. package/dist/slo-sink.d.ts +96 -0
  121. package/dist/slo-sink.js +107 -0
  122. package/dist/spec-changelog.d.ts +102 -0
  123. package/dist/spec-changelog.js +237 -0
  124. package/dist/state-backup.d.ts +223 -0
  125. package/dist/state-backup.js +648 -0
  126. package/dist/tools-cli.d.ts +170 -0
  127. package/dist/tools-cli.js +300 -0
  128. package/dist/triage.d.ts +202 -0
  129. package/dist/triage.js +403 -0
  130. package/dist/upgrade.d.ts +57 -0
  131. package/dist/upgrade.js +113 -0
  132. package/dist/version.d.ts +6 -0
  133. package/dist/version.js +27 -0
  134. package/dist/voice-eval.d.ts +138 -0
  135. package/dist/voice-eval.js +309 -0
  136. package/dist/watch.d.ts +58 -0
  137. package/dist/watch.js +97 -0
  138. package/package.json +90 -65
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Item 68 — `crewhaus loadtest <spec>`: drive a daemon-shape harness (managed
3
+ * gateway / channel-bot / batch) under concurrent load and measure throughput,
4
+ * p50/p95/p99 latency, error rate, and cost/req — with a `--gate` mode that
5
+ * fails (exit 1) when p95 latency or error rate exceed declared thresholds (a
6
+ * pre-deploy gate).
7
+ *
8
+ * Load is driven through an INJECTED `LoadDriver` — one async call per request
9
+ * that returns a `RequestOutcome`. That seam makes the runner deterministic +
10
+ * credential-free in tests (a stub driver returns canned latencies), while the
11
+ * CLI wires a real driver: the managed gateway's in-process `handle()` behind a
12
+ * stub/echo model, or the batch queue's consumer. No live server or provider
13
+ * key is needed for the runner itself.
14
+ *
15
+ * Everything here is side-effect-free (the CLI entry file constructs the driver
16
+ * + writes the report). The aggregation is pure: same outcomes → same report.
17
+ */
18
+ /** Thrown on bad flags / driver misuse. The CLI routes it through `die()`. */
19
+ export class LoadtestError extends Error {
20
+ name = "LoadtestError";
21
+ }
22
+ const DEFAULT_CONCURRENCY = 10;
23
+ const DEFAULT_REQUESTS = 100;
24
+ function percentile(sorted, p) {
25
+ if (sorted.length === 0)
26
+ return 0;
27
+ // Nearest-rank on a pre-sorted array.
28
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
29
+ return sorted[idx];
30
+ }
31
+ /**
32
+ * Run a concurrent load test against the injected driver. Dispatches up to
33
+ * `concurrency` requests in flight until the request budget (or duration) is
34
+ * exhausted, then drains. Deterministic given a deterministic driver + clock.
35
+ */
36
+ export async function runLoadtest(driver, config = {}) {
37
+ const concurrency = Math.max(1, config.concurrency ?? DEFAULT_CONCURRENCY);
38
+ const maxRequests = config.requests ?? DEFAULT_REQUESTS;
39
+ if (maxRequests < 1)
40
+ throw new LoadtestError("requests must be ≥ 1");
41
+ const now = config.now ?? (() => performance.now());
42
+ const sleep = config.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
43
+ const durationMs = config.durationMs;
44
+ const rps = config.rps;
45
+ const outcomes = [];
46
+ let dispatched = 0;
47
+ const startedAt = now();
48
+ const shouldStop = () => {
49
+ if (dispatched >= maxRequests)
50
+ return true;
51
+ if (durationMs !== undefined && now() - startedAt >= durationMs)
52
+ return true;
53
+ return false;
54
+ };
55
+ // Open-loop pacing: the interval between dispatches for the target rps.
56
+ const dispatchIntervalMs = rps !== undefined && rps > 0 ? 1000 / rps : 0;
57
+ async function worker() {
58
+ while (!shouldStop()) {
59
+ const idx = dispatched;
60
+ dispatched += 1;
61
+ if (dispatchIntervalMs > 0) {
62
+ // Pace dispatch toward the target rps. Each of the `concurrency` workers
63
+ // waits `concurrency / rps` seconds between its own dispatches, so the
64
+ // aggregate dispatch rate across all workers approximates `rps`.
65
+ await sleep(dispatchIntervalMs * concurrency);
66
+ }
67
+ let outcome;
68
+ try {
69
+ outcome = await driver(idx);
70
+ }
71
+ catch (err) {
72
+ outcome = {
73
+ ok: false,
74
+ latencyMs: 0,
75
+ errorKind: err instanceof Error ? err.name || "threw" : "threw",
76
+ };
77
+ }
78
+ outcomes.push(outcome);
79
+ }
80
+ }
81
+ const workers = [];
82
+ for (let i = 0; i < concurrency; i++)
83
+ workers.push(worker());
84
+ await Promise.all(workers);
85
+ const wallClockMs = Math.max(0, now() - startedAt);
86
+ return aggregateLoadtest(outcomes, {
87
+ wallClockMs,
88
+ ...(config.pricePerMInput !== undefined ? { pricePerMInput: config.pricePerMInput } : {}),
89
+ ...(config.pricePerMOutput !== undefined ? { pricePerMOutput: config.pricePerMOutput } : {}),
90
+ });
91
+ }
92
+ /**
93
+ * Aggregate raw outcomes into a report. Pure — exported so tests can assert on
94
+ * percentile math without driving a run, and so the CLI can re-aggregate a
95
+ * persisted outcome set.
96
+ */
97
+ export function aggregateLoadtest(outcomes, opts) {
98
+ const requests = outcomes.length;
99
+ const succeeded = outcomes.filter((o) => o.ok).length;
100
+ const failed = requests - succeeded;
101
+ // Latency percentiles over SUCCESSFUL requests (a failed 429 at 1ms would
102
+ // otherwise flatter the tail).
103
+ const latencies = outcomes
104
+ .filter((o) => o.ok)
105
+ .map((o) => o.latencyMs)
106
+ .sort((a, b) => a - b);
107
+ const sumLatency = latencies.reduce((acc, l) => acc + l, 0);
108
+ const tokens = outcomes.reduce((acc, o) => ({
109
+ input: acc.input + (o.tokens?.input ?? 0),
110
+ output: acc.output + (o.tokens?.output ?? 0),
111
+ }), { input: 0, output: 0 });
112
+ const errorKinds = {};
113
+ for (const o of outcomes) {
114
+ if (!o.ok) {
115
+ const kind = o.errorKind ?? "error";
116
+ errorKinds[kind] = (errorKinds[kind] ?? 0) + 1;
117
+ }
118
+ }
119
+ const priceIn = opts.pricePerMInput ?? 0;
120
+ const priceOut = opts.pricePerMOutput ?? 0;
121
+ const totalCostUsd = (tokens.input / 1_000_000) * priceIn + (tokens.output / 1_000_000) * priceOut;
122
+ return {
123
+ requests,
124
+ succeeded,
125
+ failed,
126
+ errorRate: requests === 0 ? 0 : failed / requests,
127
+ wallClockMs: opts.wallClockMs,
128
+ throughputRps: opts.wallClockMs > 0 ? (requests / opts.wallClockMs) * 1000 : 0,
129
+ latency: {
130
+ p50: percentile(latencies, 50),
131
+ p95: percentile(latencies, 95),
132
+ p99: percentile(latencies, 99),
133
+ min: latencies[0] ?? 0,
134
+ max: latencies[latencies.length - 1] ?? 0,
135
+ mean: latencies.length === 0 ? 0 : sumLatency / latencies.length,
136
+ },
137
+ tokens,
138
+ costPerReqUsd: requests === 0 ? 0 : totalCostUsd / requests,
139
+ totalCostUsd,
140
+ errorKinds,
141
+ };
142
+ }
143
+ /** Evaluate a report against gate thresholds. Empty thresholds → always passes. */
144
+ export function evaluateGate(report, thresholds) {
145
+ const breaches = [];
146
+ if (thresholds.maxP95LatencyMs !== undefined && report.latency.p95 > thresholds.maxP95LatencyMs) {
147
+ breaches.push(`p95 latency ${report.latency.p95.toFixed(1)}ms > ${thresholds.maxP95LatencyMs}ms`);
148
+ }
149
+ if (thresholds.maxErrorRate !== undefined && report.errorRate > thresholds.maxErrorRate) {
150
+ breaches.push(`error rate ${(report.errorRate * 100).toFixed(1)}% > ${(thresholds.maxErrorRate * 100).toFixed(1)}%`);
151
+ }
152
+ return { passed: breaches.length === 0, breaches };
153
+ }
154
+ function fmtUsd(n) {
155
+ return `$${n.toFixed(6)}`;
156
+ }
157
+ /** Render a plain-text loadtest report. */
158
+ export function renderLoadtestText(report, verdict) {
159
+ const lines = [];
160
+ lines.push("loadtest:");
161
+ lines.push(` requests: ${report.requests} (${report.succeeded} ok / ${report.failed} failed, ${(report.errorRate * 100).toFixed(1)}% error rate)`);
162
+ lines.push(` throughput: ${report.throughputRps.toFixed(1)} req/s over ${report.wallClockMs.toFixed(0)}ms`);
163
+ lines.push(` latency: p50 ${report.latency.p50.toFixed(1)}ms · p95 ${report.latency.p95.toFixed(1)}ms · p99 ${report.latency.p99.toFixed(1)}ms (min ${report.latency.min.toFixed(1)} / max ${report.latency.max.toFixed(1)})`);
164
+ lines.push(` tokens: ${report.tokens.input} in / ${report.tokens.output} out; cost/req ${fmtUsd(report.costPerReqUsd)}; total ${fmtUsd(report.totalCostUsd)}`);
165
+ const kinds = Object.entries(report.errorKinds);
166
+ if (kinds.length > 0) {
167
+ lines.push(` errors: ${kinds.map(([k, v]) => `${k}×${v}`).join(", ")}`);
168
+ }
169
+ if (verdict !== undefined) {
170
+ lines.push(verdict.passed ? " gate: PASS" : " gate: FAIL");
171
+ for (const b of verdict.breaches)
172
+ lines.push(` ! ${b}`);
173
+ }
174
+ return `${lines.join("\n")}\n`;
175
+ }
176
+ /** Render an HTML loadtest report (self-contained). */
177
+ export function renderLoadtestHtml(report, verdict) {
178
+ const gateRow = verdict === undefined
179
+ ? ""
180
+ : `<tr><th>Gate</th><td>${verdict.passed ? "PASS" : `FAIL — ${verdict.breaches.map((b) => b.replace(/</g, "&lt;")).join("; ")}`}</td></tr>`;
181
+ return `<!doctype html>
182
+ <html><head><meta charset="utf-8"><title>Loadtest report</title>
183
+ <style>body{font:14px system-ui,sans-serif;margin:2rem}table{border-collapse:collapse}th,td{border:1px solid #ddd;padding:.4rem .7rem;text-align:left}th{background:#f5f5f5}</style>
184
+ </head><body>
185
+ <h1>Loadtest report</h1>
186
+ <table>
187
+ <tr><th>Requests</th><td>${report.requests} (${report.succeeded} ok / ${report.failed} failed)</td></tr>
188
+ <tr><th>Error rate</th><td>${(report.errorRate * 100).toFixed(1)}%</td></tr>
189
+ <tr><th>Throughput</th><td>${report.throughputRps.toFixed(1)} req/s</td></tr>
190
+ <tr><th>Latency p50 / p95 / p99</th><td>${report.latency.p50.toFixed(1)} / ${report.latency.p95.toFixed(1)} / ${report.latency.p99.toFixed(1)} ms</td></tr>
191
+ <tr><th>Cost / req</th><td>${fmtUsd(report.costPerReqUsd)}</td></tr>
192
+ ${gateRow}
193
+ </table>
194
+ </body></html>
195
+ `;
196
+ }
@@ -0,0 +1,110 @@
1
+ import type { ModuleRegistrySource, PluginMetadata } from "@crewhaus/module-marketplace-client";
2
+ import type { PluginRegistryEntry } from "@crewhaus/plugin-registry";
3
+ import type { PluginManifest } from "@crewhaus/plugin-sdk";
4
+ /** Thrown for operational failures (no registry configured, bad ref, driver
5
+ * failure). The CLI routes the message through `die()`; tests assert on
6
+ * `.message`. */
7
+ export declare class MarketplaceCliError extends Error {
8
+ readonly name = "MarketplaceCliError";
9
+ }
10
+ /** Where installed plugins live (mirrors §41 trustedRoots). */
11
+ export declare function defaultPluginsDir(): string;
12
+ /** The plugin-registry install-record file. */
13
+ export declare function defaultPluginRegistryPath(): string;
14
+ /** Where `templates use` writes the fetched template. Defaults to the cwd
15
+ * (a template is scaffolded into the working directory). */
16
+ export declare function defaultTemplateWorkspaceDir(): string;
17
+ export type RegistryRefKind = {
18
+ readonly kind: "local";
19
+ readonly dir: string;
20
+ } | {
21
+ readonly kind: "http";
22
+ readonly baseUrl: string;
23
+ };
24
+ /**
25
+ * Resolve a registry ref string into a backend descriptor. `http(s)://…` →
26
+ * HTTP; anything else is a filesystem path (a leading `file:` is stripped).
27
+ * `undefined` (nothing configured) throws so a read verb fails loudly instead
28
+ * of silently hitting nothing.
29
+ */
30
+ export declare function resolveRegistryRef(ref: string | undefined, kindLabel: string): RegistryRefKind;
31
+ /**
32
+ * A `ModuleRegistrySource` over an HTTP JSON index. The catalog is
33
+ * `GET <baseUrl>` returning `{ plugins: PluginMetadata[] }`; a manifest is
34
+ * `GET <baseUrl>/<name>.json` (optionally `<name>@<version>.json`). `fetch`
35
+ * is injected so tests drive it without a network.
36
+ */
37
+ export declare function createHttpModuleRegistrySource(opts: {
38
+ readonly id: string;
39
+ readonly baseUrl: string;
40
+ readonly fetchImpl?: typeof fetch;
41
+ }): ModuleRegistrySource;
42
+ /**
43
+ * A LOCAL `ModuleRegistrySource` over a directory of plugin manifest JSONs
44
+ * (`<name>.json`). Mirrors template-registry's `LocalRegistrySource`; used
45
+ * for `--registry <dir>` and by tests. Reads go through injected fs seams so
46
+ * this stays unit-testable.
47
+ */
48
+ export declare function createLocalModuleRegistrySource(opts: {
49
+ readonly dir: string;
50
+ readonly readdirImpl: (dir: string) => ReadonlyArray<string>;
51
+ readonly readFileImpl: (path: string) => string;
52
+ readonly existsImpl: (path: string) => boolean;
53
+ }): ModuleRegistrySource;
54
+ /** Project a full manifest down to the search metadata shape. */
55
+ export declare function toMetadata(m: PluginManifest): PluginMetadata;
56
+ /** Compare two `major.minor.patch` cores; pre-release/build ignored (matches
57
+ * module-marketplace-client's own `compareSemver`). */
58
+ export declare function compareSemverCore(a: string, b: string): number;
59
+ export type OutdatedRow = {
60
+ readonly name: string;
61
+ readonly installed: string;
62
+ readonly latest?: string;
63
+ /** "outdated" | "current" | "unknown" (not in the remote registry). */
64
+ readonly status: "outdated" | "current" | "unknown";
65
+ };
66
+ /**
67
+ * Compute the freshness report: for each installed plugin, its installed
68
+ * version vs the remote latest. `unknown` when the plugin is not in the
69
+ * remote catalog (a private/local install). Pure over the two lists.
70
+ */
71
+ export declare function computeOutdated(installed: ReadonlyArray<{
72
+ readonly name: string;
73
+ readonly version: string;
74
+ }>, remote: ReadonlyArray<PluginMetadata>): OutdatedRow[];
75
+ /** The installed (name, version) pairs from plugin-registry entries. */
76
+ export declare function installedVersions(entries: ReadonlyArray<PluginRegistryEntry>): Array<{
77
+ name: string;
78
+ version: string;
79
+ }>;
80
+ export declare function formatPluginList(rows: ReadonlyArray<PluginMetadata>): ReadonlyArray<string>;
81
+ export declare function formatOutdated(rows: ReadonlyArray<OutdatedRow>): ReadonlyArray<string>;
82
+ /** The minimal PublishDraft fields the publish loop needs (structurally
83
+ * satisfied by BOTH marketplace clients' PublishDraft shapes). */
84
+ export type PublishDraftLike = {
85
+ readonly prTitle: string;
86
+ readonly prBody: string;
87
+ /** repo-relative path the manifest is written to on the branch. */
88
+ readonly manifestRelPath: string;
89
+ /** the manifest bytes to write. */
90
+ readonly manifestContents: string;
91
+ readonly name: string;
92
+ readonly version: string;
93
+ };
94
+ /** The write-and-open plan the driver executes for a publish. */
95
+ export type PublishPrPlan = {
96
+ readonly branch: string;
97
+ readonly title: string;
98
+ readonly body: string;
99
+ readonly files: Readonly<Record<string, string>>;
100
+ readonly commitMessage: string;
101
+ };
102
+ /** Assemble the publish PR plan from a draft. Pure. */
103
+ export declare function buildPublishPrPlan(draft: PublishDraftLike, now: Date): PublishPrPlan;
104
+ /** Driver seam: execute a `PublishPrPlan` (branch → write → commit → push →
105
+ * `gh pr create`). CLI wires git/gh; tests inject a stub. NEVER auto-merges. */
106
+ export type PublishPrDriver = (plan: PublishPrPlan) => Promise<{
107
+ readonly prNumber?: number;
108
+ readonly url: string;
109
+ readonly branch: string;
110
+ }>;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Item 60 — the marketplace CLI: `crewhaus plugins {list,search,install,
3
+ * uninstall,publish,outdated}` and `crewhaus templates {list,search,use}`.
4
+ * These verbs were the explicitly-deferred CLI surface on top of the §42
5
+ * `module-marketplace-client` / §40 `template-marketplace-client` (their
6
+ * docstrings name "the CLI follow-up" as the secondary consumer); this
7
+ * closes them, plus the publish loop and a dependency-freshness watch.
8
+ *
9
+ * Factored out of the entry file `index.ts` (which runs a top-level argv
10
+ * switch and so cannot be imported by a test without executing the CLI).
11
+ * Side-effect-free on import, mirroring `datasets.ts` / `retention.ts`:
12
+ * - the registry-source construction + the marketplace clients are the only
13
+ * I/O, and they route network through an injectable `fetch` seam so
14
+ * `list`/`search`/`install`/`outdated` are tested against a seeded LOCAL
15
+ * registry or a mocked HTTP source with no real network;
16
+ * - the `outdated` comparison and the report formatters are pure;
17
+ * - the publish loop drives `gh` behind an injected driver seam.
18
+ *
19
+ * REGISTRY RESOLUTION — a `--registry <ref>` flag, else
20
+ * `CREWHAUS_PLUGIN_REGISTRY` / `CREWHAUS_TEMPLATE_REGISTRY`, selects the
21
+ * backend:
22
+ * - a filesystem path (or `file:<path>`) → a LOCAL source (a directory of
23
+ * manifest JSONs — the same on-disk shape the registries write);
24
+ * - an `http(s)://` URL → an HTTP source (a `{plugins:[]}` / `{templates:[]}`
25
+ * JSON index at `<url>` + per-name manifests at `<url>/<name>.json`).
26
+ * With nothing configured, the read verbs report "no registry configured"
27
+ * rather than reaching for a hardcoded endpoint.
28
+ *
29
+ * SIGNING — install respects `plugin-registry`'s fail-closed verification:
30
+ * when trust anchors are configured (via `--trust-anchor` / env), an unsigned
31
+ * or badly-signed plugin is refused; `--allow-unsigned` is the explicit dev
32
+ * opt-out. Templates carry their own `verifyingRegistry` wrapper (out of scope
33
+ * here — install fetches the manifest verbatim).
34
+ */
35
+ import { homedir } from "node:os";
36
+ import { isAbsolute, join, resolve } from "node:path";
37
+ /** Thrown for operational failures (no registry configured, bad ref, driver
38
+ * failure). The CLI routes the message through `die()`; tests assert on
39
+ * `.message`. */
40
+ export class MarketplaceCliError extends Error {
41
+ name = "MarketplaceCliError";
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // default locations
45
+ // ---------------------------------------------------------------------------
46
+ /** Where installed plugins live (mirrors §41 trustedRoots). */
47
+ export function defaultPluginsDir() {
48
+ return join(homedir(), ".crewhaus", "plugins");
49
+ }
50
+ /** The plugin-registry install-record file. */
51
+ export function defaultPluginRegistryPath() {
52
+ return join(homedir(), ".crewhaus", "plugin-registry.json");
53
+ }
54
+ /** Where `templates use` writes the fetched template. Defaults to the cwd
55
+ * (a template is scaffolded into the working directory). */
56
+ export function defaultTemplateWorkspaceDir() {
57
+ return process.cwd();
58
+ }
59
+ /**
60
+ * Resolve a registry ref string into a backend descriptor. `http(s)://…` →
61
+ * HTTP; anything else is a filesystem path (a leading `file:` is stripped).
62
+ * `undefined` (nothing configured) throws so a read verb fails loudly instead
63
+ * of silently hitting nothing.
64
+ */
65
+ export function resolveRegistryRef(ref, kindLabel) {
66
+ if (ref === undefined || ref === "") {
67
+ throw new MarketplaceCliError(`no ${kindLabel} registry configured — pass --registry <dir|url> or set the CREWHAUS_${kindLabel.toUpperCase()}_REGISTRY env var (a directory of manifest JSONs, or an http(s):// index URL)`);
68
+ }
69
+ if (/^https?:\/\//.test(ref))
70
+ return { kind: "http", baseUrl: ref.replace(/\/$/, "") };
71
+ const path = ref.startsWith("file:") ? ref.slice("file:".length) : ref;
72
+ return { kind: "local", dir: isAbsolute(path) ? path : resolve(process.cwd(), path) };
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // HTTP-backed ModuleRegistrySource (plugins)
76
+ // ---------------------------------------------------------------------------
77
+ /**
78
+ * A `ModuleRegistrySource` over an HTTP JSON index. The catalog is
79
+ * `GET <baseUrl>` returning `{ plugins: PluginMetadata[] }`; a manifest is
80
+ * `GET <baseUrl>/<name>.json` (optionally `<name>@<version>.json`). `fetch`
81
+ * is injected so tests drive it without a network.
82
+ */
83
+ export function createHttpModuleRegistrySource(opts) {
84
+ const fetchImpl = opts.fetchImpl ?? fetch;
85
+ const base = opts.baseUrl.replace(/\/$/, "");
86
+ return {
87
+ id: opts.id,
88
+ async listPlugins() {
89
+ const res = await fetchImpl(base);
90
+ if (!res.ok) {
91
+ throw new MarketplaceCliError(`plugin registry list ${res.status}: ${(await res.text()).slice(0, 256)}`);
92
+ }
93
+ const data = (await res.json());
94
+ if (!Array.isArray(data?.plugins)) {
95
+ throw new MarketplaceCliError("plugin registry list payload missing plugins[]");
96
+ }
97
+ return data.plugins;
98
+ },
99
+ async getManifest(name, version) {
100
+ const suffix = version !== undefined ? `${name}@${version}` : name;
101
+ const res = await fetchImpl(`${base}/${suffix}.json`);
102
+ if (!res.ok) {
103
+ throw new MarketplaceCliError(`plugin registry fetch "${suffix}" ${res.status}: ${(await res.text()).slice(0, 256)}`);
104
+ }
105
+ return (await res.json());
106
+ },
107
+ };
108
+ }
109
+ /**
110
+ * A LOCAL `ModuleRegistrySource` over a directory of plugin manifest JSONs
111
+ * (`<name>.json`). Mirrors template-registry's `LocalRegistrySource`; used
112
+ * for `--registry <dir>` and by tests. Reads go through injected fs seams so
113
+ * this stays unit-testable.
114
+ */
115
+ export function createLocalModuleRegistrySource(opts) {
116
+ const manifestPath = (name, version) => join(opts.dir, `${version !== undefined ? `${name}@${version}` : name}.json`);
117
+ return {
118
+ id: `local:${opts.dir}`,
119
+ async listPlugins() {
120
+ if (!opts.existsImpl(opts.dir))
121
+ return [];
122
+ const out = [];
123
+ for (const f of opts.readdirImpl(opts.dir)) {
124
+ if (!f.endsWith(".json") || f.includes("@"))
125
+ continue;
126
+ try {
127
+ const m = JSON.parse(opts.readFileImpl(join(opts.dir, f)));
128
+ out.push(toMetadata(m));
129
+ }
130
+ catch {
131
+ // skip malformed
132
+ }
133
+ }
134
+ return out.sort((a, b) => a.name.localeCompare(b.name));
135
+ },
136
+ async getManifest(name, version) {
137
+ const versioned = manifestPath(name, version);
138
+ const path = version !== undefined && opts.existsImpl(versioned) ? versioned : manifestPath(name);
139
+ if (!opts.existsImpl(path)) {
140
+ throw new MarketplaceCliError(`plugin "${name}" not found in ${opts.dir}`);
141
+ }
142
+ return JSON.parse(opts.readFileImpl(path));
143
+ },
144
+ };
145
+ }
146
+ /** Project a full manifest down to the search metadata shape. */
147
+ export function toMetadata(m) {
148
+ const rec = m;
149
+ return {
150
+ name: m.name,
151
+ version: m.version,
152
+ ...(typeof rec["description"] === "string" ? { description: rec["description"] } : {}),
153
+ ...(typeof rec["author"] === "string" ? { author: rec["author"] } : {}),
154
+ ...(typeof rec["homepage"] === "string" ? { homepage: rec["homepage"] } : {}),
155
+ ...(typeof rec["license"] === "string" ? { license: rec["license"] } : {}),
156
+ };
157
+ }
158
+ // ---------------------------------------------------------------------------
159
+ // outdated (dependency-freshness watch)
160
+ // ---------------------------------------------------------------------------
161
+ /** Compare two `major.minor.patch` cores; pre-release/build ignored (matches
162
+ * module-marketplace-client's own `compareSemver`). */
163
+ export function compareSemverCore(a, b) {
164
+ const core = (v) => (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0);
165
+ const aN = core(a);
166
+ const bN = core(b);
167
+ for (let i = 0; i < 3; i++) {
168
+ const ai = aN[i] ?? 0;
169
+ const bi = bN[i] ?? 0;
170
+ if (ai !== bi)
171
+ return ai - bi;
172
+ }
173
+ return 0;
174
+ }
175
+ /**
176
+ * Compute the freshness report: for each installed plugin, its installed
177
+ * version vs the remote latest. `unknown` when the plugin is not in the
178
+ * remote catalog (a private/local install). Pure over the two lists.
179
+ */
180
+ export function computeOutdated(installed, remote) {
181
+ const latestByName = new Map();
182
+ for (const p of remote) {
183
+ const prev = latestByName.get(p.name);
184
+ if (prev === undefined || compareSemverCore(p.version, prev) > 0) {
185
+ latestByName.set(p.name, p.version);
186
+ }
187
+ }
188
+ const rows = [];
189
+ for (const inst of installed) {
190
+ const latest = latestByName.get(inst.name);
191
+ if (latest === undefined) {
192
+ rows.push({ name: inst.name, installed: inst.version, status: "unknown" });
193
+ continue;
194
+ }
195
+ const status = compareSemverCore(latest, inst.version) > 0 ? "outdated" : "current";
196
+ rows.push({ name: inst.name, installed: inst.version, latest, status });
197
+ }
198
+ return rows.sort((a, b) => a.name.localeCompare(b.name));
199
+ }
200
+ /** The installed (name, version) pairs from plugin-registry entries. */
201
+ export function installedVersions(entries) {
202
+ return entries.map((e) => ({
203
+ name: e.manifest.name,
204
+ version: e.pinnedVersion ?? e.manifest.version,
205
+ }));
206
+ }
207
+ // ---------------------------------------------------------------------------
208
+ // formatters (pure)
209
+ // ---------------------------------------------------------------------------
210
+ export function formatPluginList(rows) {
211
+ if (rows.length === 0)
212
+ return ["no plugins in the registry"];
213
+ return rows.map((p) => `${p.name}@${p.version}${p.author !== undefined ? ` — ${p.author}` : ""}${p.description !== undefined ? `\n ${p.description}` : ""}`);
214
+ }
215
+ export function formatOutdated(rows) {
216
+ if (rows.length === 0)
217
+ return ["no plugins installed"];
218
+ const lines = [];
219
+ let outdated = 0;
220
+ for (const r of rows) {
221
+ if (r.status === "outdated") {
222
+ outdated += 1;
223
+ lines.push(`⤴ ${r.name}: ${r.installed} → ${r.latest} (update available)`);
224
+ }
225
+ else if (r.status === "current") {
226
+ lines.push(`✓ ${r.name}: ${r.installed} (current)`);
227
+ }
228
+ else {
229
+ lines.push(`? ${r.name}: ${r.installed} (not in the configured registry)`);
230
+ }
231
+ }
232
+ lines.push("");
233
+ lines.push(outdated === 0
234
+ ? "summary: all installed plugins current"
235
+ : `summary: ${outdated} update(s) available`);
236
+ return lines;
237
+ }
238
+ /** Assemble the publish PR plan from a draft. Pure. */
239
+ export function buildPublishPrPlan(draft, now) {
240
+ const slug = draft.name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
241
+ const stamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
242
+ const branch = `publish/${slug || "plugin"}-${draft.version}-${stamp}`;
243
+ return {
244
+ branch,
245
+ title: draft.prTitle,
246
+ body: draft.prBody,
247
+ files: { [draft.manifestRelPath]: draft.manifestContents },
248
+ commitMessage: `publish(${draft.name}): ${draft.version}`,
249
+ };
250
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * F5 — sanitise an mcp_servers KEY into a single safe filename segment before it
3
+ * is joined onto `.crewhaus/mcp/` (the drift-snapshot write). The mcp_servers
4
+ * key schema is `z.string().min(1)` (NOT `safeName`), so a server named
5
+ * `../../evil` — or one carrying a slash / NUL — would let
6
+ * `join(mcpDir, `${name}.json`)` escape the directory. We `basename` to strip
7
+ * path components, then replace every character outside the conservative
8
+ * `[A-Za-z0-9._-]` set with `_` (the same class the eval-runner + graders use),
9
+ * and floor an empty / dot-only result to a stable placeholder. The result is
10
+ * always a bare filename that stays inside the snapshot dir.
11
+ */
12
+ export declare function safeMcpFileName(name: string): string;
13
+ /** One durable `mcp_stats` record's payload (what runtime-core persists). */
14
+ export type McpStatsPayload = {
15
+ readonly server: string;
16
+ readonly toolName: string;
17
+ readonly durationMs: number;
18
+ readonly isError: boolean;
19
+ };
20
+ /** Per-server rolled-up health over the scored session window. */
21
+ export type McpServerHealth = {
22
+ readonly server: string;
23
+ readonly calls: number;
24
+ readonly errors: number;
25
+ readonly errorRate: number;
26
+ /** p50 / p95 call latency, ms (nearest-rank). */
27
+ readonly p50Ms: number;
28
+ readonly p95Ms: number;
29
+ /** Longest run of CONSECUTIVE erroring calls — a reconnect-churn proxy, since
30
+ * the mcp client's reconnect events are Logger-only (not durable). */
31
+ readonly maxErrorStreak: number;
32
+ /** True when the server is chronically failing (see {@link QUARANTINE_POLICY}). */
33
+ readonly chronic: boolean;
34
+ };
35
+ /** Thresholds a server must cross to be judged chronically failing → quarantine
36
+ * candidate. Kept together so the report and the quarantine decision agree. */
37
+ export declare const QUARANTINE_POLICY: {
38
+ /** Minimum calls before an error rate is trustworthy (cold-start guard). */
39
+ readonly minCalls: 5;
40
+ /** Error-rate ceiling; above it (with enough calls) the server is chronic. */
41
+ readonly errorRate: 0.5;
42
+ /** OR: this many consecutive errors is chronic regardless of overall rate
43
+ * (a server that just went hard-down mid-session). */
44
+ readonly errorStreak: 4;
45
+ };
46
+ /** Nearest-rank percentile (0-based floor). Empty ⇒ 0. */
47
+ export declare function percentile(values: ReadonlyArray<number>, p: number): number;
48
+ /**
49
+ * Fold a flat stream of `mcp_stats` payloads (already read from session logs,
50
+ * oldest-first) into per-server health. The `maxErrorStreak` is computed over
51
+ * the input order, so callers should pass records in chronological order (the
52
+ * session-log reader yields insertion order — which is chronological).
53
+ */
54
+ export declare function scoreMcpHealth(records: ReadonlyArray<McpStatsPayload>): McpServerHealth[];
55
+ /** Chronic-failure verdict shared by scoring + quarantine decision. */
56
+ export declare function isChronic(calls: number, errorRate: number, maxErrorStreak: number): boolean;
57
+ /** A single tool as advertised by a server, for drift snapshotting. */
58
+ export type McpToolSnapshot = {
59
+ readonly name: string;
60
+ /** Stable hash of the tool's JSON-schema (order-insensitive). */
61
+ readonly schemaHash: string;
62
+ };
63
+ /** The on-disk drift snapshot for one server (`.crewhaus/mcp/<server>.json`). */
64
+ export type McpServerSnapshot = {
65
+ readonly version: 1;
66
+ readonly server: string;
67
+ readonly ts: string;
68
+ readonly tools: ReadonlyArray<McpToolSnapshot>;
69
+ };
70
+ /**
71
+ * Stable hash of a JSON-schema value: recursively sort object keys so
72
+ * `{a,b}` and `{b,a}` hash identically, then sha256 the canonical form. A
73
+ * server that reorders its schema keys between connects is NOT drift; a server
74
+ * that changes a field's type IS. Returns the first 16 hex chars (collision-
75
+ * safe enough for a per-tool change signal).
76
+ */
77
+ export declare function schemaHash(schema: unknown): string;
78
+ /** Build a fresh snapshot from a server's live `listTools` result. */
79
+ export declare function buildSnapshot(server: string, tools: ReadonlyArray<{
80
+ readonly name: string;
81
+ readonly inputSchema: unknown;
82
+ }>, ts: string): McpServerSnapshot;
83
+ export type McpDrift = {
84
+ readonly added: ReadonlyArray<string>;
85
+ readonly removed: ReadonlyArray<string>;
86
+ /** Tools present in both but whose schema hash changed. */
87
+ readonly schemaChanged: ReadonlyArray<string>;
88
+ };
89
+ /** True when nothing changed. */
90
+ export declare function driftIsEmpty(drift: McpDrift): boolean;
91
+ /**
92
+ * Diff a previous snapshot against the current one. `previous` undefined (no
93
+ * snapshot on disk yet) ⇒ empty drift (the current becomes the new baseline;
94
+ * a first-ever connect is not "drift"). Added = in current not previous;
95
+ * removed = in previous not current; schemaChanged = in both, different hash.
96
+ */
97
+ export declare function diffSnapshots(previous: McpServerSnapshot | undefined, current: McpServerSnapshot): McpDrift;
98
+ export type QuarantineDecision = {
99
+ /** Servers whose tools should be WITHDRAWN (chronic + not already out). */
100
+ readonly quarantine: ReadonlyArray<string>;
101
+ /** Servers whose tools should be RESTORED (were out, now healthy). */
102
+ readonly restore: ReadonlyArray<string>;
103
+ };
104
+ /**
105
+ * Decide catalog mutations from the current health + the set of servers whose
106
+ * tools are ALREADY quarantined. A chronic server not yet quarantined →
107
+ * quarantine; a quarantined server that is no longer chronic (its recent
108
+ * window is healthy — e.g. a probe succeeded) → restore. A server absent from
109
+ * `health` entirely (no recent calls) is left as-is: no signal either way.
110
+ */
111
+ export declare function decideQuarantine(health: ReadonlyArray<McpServerHealth>, alreadyQuarantined: ReadonlyArray<string>): QuarantineDecision;
112
+ /**
113
+ * The synthetic notice injected in place of a quarantined server's tools so the
114
+ * model routes around them — mirrors loop-detection's warning injection. Kept
115
+ * here so the report and the runtime injection render the identical text.
116
+ */
117
+ export declare function quarantineNotice(server: string, reason: string): string;
118
+ /** Render the per-server health table lines for `crewhaus mcp doctor`. */
119
+ export declare function formatHealthReport(health: ReadonlyArray<McpServerHealth>): string[];
120
+ /** Render the drift lines for one server. Empty array when no drift. */
121
+ export declare function formatDriftReport(server: string, drift: McpDrift): string[];