@tetherto/mdk-skill 0.0.0 → 0.12.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 (62) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +133 -0
  3. package/dist/skills/mdk/SKILL.md +114 -0
  4. package/dist/skills/mdk/references/architecture.md +89 -0
  5. package/dist/skills/mdk/references/glossary.md +27 -0
  6. package/dist/skills/mdk/references/mdk-contract.schema.json +138 -0
  7. package/dist/skills/mdk/references/package-index.md +53 -0
  8. package/dist/skills/mdk/references/protocol.md +95 -0
  9. package/dist/skills/mdk-create-app/SKILL.md +231 -0
  10. package/dist/skills/mdk-deployment/SKILL.md +161 -0
  11. package/dist/skills/mdk-gateway-plugin/SKILL.md +192 -0
  12. package/dist/skills/mdk-gateway-plugin/references/controller-patterns.md +99 -0
  13. package/dist/skills/mdk-gateway-plugin/references/plugin-authoring.md +121 -0
  14. package/dist/skills/mdk-publish-security/SKILL.md +177 -0
  15. package/dist/skills/mdk-publish-security/references/vuln-classes.md +151 -0
  16. package/dist/skills/mdk-publish-security/scripts/scan-packages.mjs +544 -0
  17. package/dist/skills/mdk-site-sizing/SKILL.md +160 -0
  18. package/dist/skills/mdk-site-sizing/eval/held-out.json +104 -0
  19. package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers-mixed.json +24 -0
  20. package/dist/skills/mdk-site-sizing/eval/profiles/cap-100devices-2workers.json +23 -0
  21. package/dist/skills/mdk-site-sizing/eval/profiles/cap-10devices-1workers.json +23 -0
  22. package/dist/skills/mdk-site-sizing/eval/profiles/cap-15devices-2workers.json +24 -0
  23. package/dist/skills/mdk-site-sizing/eval/profiles/cap-200devices-4workers.json +23 -0
  24. package/dist/skills/mdk-site-sizing/eval/profiles/cap-500devices-4workers.json +23 -0
  25. package/dist/skills/mdk-site-sizing/eval/profiles/cap-50devices-2workers.json +23 -0
  26. package/dist/skills/mdk-site-sizing/references/envelope.json +284 -0
  27. package/dist/skills/mdk-site-sizing/references/envelope.md +42 -0
  28. package/dist/skills/mdk-site-sizing/scripts/compile-envelope.mjs +45 -0
  29. package/dist/skills/mdk-site-sizing/scripts/eval-held-out.mjs +50 -0
  30. package/dist/skills/mdk-site-sizing/scripts/lib.mjs +591 -0
  31. package/dist/skills/mdk-site-sizing/scripts/parse-site.mjs +25 -0
  32. package/dist/skills/mdk-site-sizing/scripts/size-site.mjs +49 -0
  33. package/dist/skills/mdk-ui-component/SKILL.md +207 -0
  34. package/dist/skills/mdk-ui-component/references/page-recipe.md +78 -0
  35. package/dist/skills/mdk-ui-component/references/ui-registry.json +1 -0
  36. package/dist/skills/mdk-worker-plugin/SKILL.md +198 -0
  37. package/dist/skills/mdk-worker-plugin/assets/mdk-contract.template.json +45 -0
  38. package/dist/skills/mdk-worker-plugin/assets/worker-template/README.md +93 -0
  39. package/dist/skills/mdk-worker-plugin/assets/worker-template/mdk-contract.json +26 -0
  40. package/dist/skills/mdk-worker-plugin/assets/worker-template/mock/server.js +93 -0
  41. package/dist/skills/mdk-worker-plugin/assets/worker-template/package.json +23 -0
  42. package/dist/skills/mdk-worker-plugin/assets/worker-template/smoke.config.js +41 -0
  43. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/client.js +47 -0
  44. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/reboot.js +5 -0
  45. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/commands/set-power-mode.js +10 -0
  46. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/hashrate-rt.js +5 -0
  47. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power-mode.js +5 -0
  48. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/power.js +5 -0
  49. package/dist/skills/mdk-worker-plugin/assets/worker-template/src/telemetry/temperature.js +5 -0
  50. package/dist/skills/mdk-worker-plugin/references/contract-authoring.md +135 -0
  51. package/dist/skills/mdk-worker-plugin/references/device-families.md +96 -0
  52. package/dist/skills/mdk-worker-plugin/references/local-testing.md +134 -0
  53. package/dist/skills/mdk-worker-plugin/references/mdk-contract.schema.json +138 -0
  54. package/dist/skills/mdk-worker-plugin/references/worker-base-api.md +152 -0
  55. package/dist/skills/mdk-worker-plugin/scripts/validate-contract.mjs +129 -0
  56. package/dist/skills/mdk-worker-plugin/scripts/worker-smoke.mjs +256 -0
  57. package/package.json +47 -1
  58. package/src/assemble.mjs +11 -0
  59. package/src/index.d.ts +57 -0
  60. package/src/index.mjs +254 -0
  61. package/src/install.mjs +57 -0
  62. package/src/sources.map.json +34 -0
@@ -0,0 +1,152 @@
1
+ # Worker base API — `@tetherto/mdk-worker`
2
+
3
+ Load this when hosting a plugin, debugging envelope dispatch, or deciding what
4
+ belongs in the plugin vs the caller. Two Worker Plugin shapes exist side by
5
+ side: the module-exporting shape below, hosted on `WorkerRuntime`, and the
6
+ directory-loaded shape (see [Directory-loaded plugins](#directory-loaded-plugins-workerruntimev2)
7
+ below), hosted on `WorkerRuntimeV2` — the shape `mdk create worker` scaffolds
8
+ today. Source: [`backend/core/mdk-worker/lib/worker-runtime.js`](../../../../../../backend/core/mdk-worker/lib/worker-runtime.js),
9
+ [`lib/worker-runtime-v2.js`](../../../../../../backend/core/mdk-worker/lib/worker-runtime-v2.js), and
10
+ [`lib/plugin-loader.js`](../../../../../../backend/core/mdk-worker/lib/plugin-loader.js);
11
+ package entry [`backend/core/mdk-worker/index.js`](../../../../../../backend/core/mdk-worker/index.js) exports
12
+ `{ WorkerRuntime, WorkerRuntimeV2, loadPlugin, loadContract, createInstance, createModuleContext }`.
13
+
14
+ ## The plugin contract (`loadPlugin`)
15
+
16
+ A Worker Plugin is `{ contract, dir, connect, disconnect? }`:
17
+
18
+ - `contract` — the parsed `mdk-contract.json`.
19
+ - `dir` — `__dirname` of the plugin; handler paths resolve against it.
20
+ - `connect(config, { deviceId }) => device` — build (and probe) one device
21
+ client. Throwing here holds the device `offline` without failing the boot.
22
+ - `disconnect(device, { deviceId })` — optional; close sockets etc.
23
+
24
+ `loadPlugin` **eagerly requires every handler** declared under
25
+ `capabilities.telemetry` and `capabilities.commands` — a missing file,
26
+ non-function export, missing `handler` field, or duplicate name aborts with
27
+ `ERR_PLUGIN_HANDLER_NOT_FOUND` / `ERR_PLUGIN_HANDLER_NOT_FUNCTION` /
28
+ `ERR_PLUGIN_HANDLER_MISSING` / `ERR_PLUGIN_DUPLICATE_NAME`. Nothing loads
29
+ lazily at request time. It returns
30
+ `{ contract, publishedContract, handlers, connect, disconnect }` where
31
+ `publishedContract` has all `handler` paths stripped (that copy is what
32
+ `capability.response` sends).
33
+
34
+ ## Handler signature
35
+
36
+ Telemetry and command handlers are identical in shape:
37
+
38
+ ```js
39
+ module.exports = async (ctx, params) => result
40
+ ```
41
+
42
+ `ctx` is frozen per device: `{ deviceId, device, config, services }`.
43
+ `device` is whatever `connect()` returned; `services` is `null` unless the
44
+ caller injected worker-infra services. Handlers never see envelopes or
45
+ transport — the runtime wraps their return value (or thrown error) into the
46
+ response envelope.
47
+
48
+ ## `new WorkerRuntime(plugin, opts)`
49
+
50
+ | Option | Required | Meaning |
51
+ | --- | --- | --- |
52
+ | `workerId` | yes | Identity string; `ERR_WORKER_ID_REQUIRED` otherwise |
53
+ | `devices` | yes* | `[{ deviceId, config? }]`; duplicates/missing ids throw. *`allowEmptyDevices: true` permits a device-less provisioning-first boot |
54
+ | `kernelTopic` | no | Hex string or Buffer; when set, the runtime announces on this DHT topic |
55
+ | `store` | no | Persistent store (Hyperbee-style) for the DHT/RPC seeds → stable public key across restarts; without it keys are random per boot |
56
+ | `storeDir` | no | Directory for file-backed DHT/RPC seeds (same stability as `store`, for hosts that do not own a Hyperbee). Ignored when `store` is set |
57
+ | `services` | no | Worker-infra services (logs, settings, stats, comments, provisioning…). Served as built-ins ahead of plugin handlers and exposed to handlers as `ctx.services` |
58
+ | `bootstrap` | no | Custom DHT bootstrap nodes (hermetic tests use `hyperdht/testnet`) |
59
+
60
+ One runtime hosts N same-type devices behind one HRPC channel. The device
61
+ list is fixed at construction.
62
+
63
+ ## Lifecycle
64
+
65
+ - `await start()` — opens device contexts (`connect` per device; failures →
66
+ `offline`), starts the HRPC server responding on the `'mdk'` method, and —
67
+ if `kernelTopic` is set — joins the topic in server mode, writing its public
68
+ key to each incoming connection and re-announcing every 30 s. Returns
69
+ `{ publicKey }`.
70
+ - `await stop()` — tears down swarm/RPC/DHT and calls `disconnect` per online
71
+ device.
72
+ - `getPublicKey()` — HRPC public key (null before `start`).
73
+ - `getDeviceContext(deviceId)` — the frozen ctx of an *online* device, for the
74
+ process that owns the runtime (e.g. a sampler loop); null while offline. On
75
+ `WorkerRuntimeV2` this returns `{ deviceId, config, services }` with no
76
+ `device` key — see [below](#directory-loaded-plugins-workerruntimev2).
77
+ - `await handleRequest(envelope)` — the entire protocol surface; usable
78
+ directly in-process for tests (no `start()` needed for identity/capability;
79
+ device-touching actions need contexts opened by `start()`).
80
+
81
+ ## Directory-loaded plugins (`WorkerRuntimeV2`)
82
+
83
+ A directory-loaded Worker Plugin exports no module of its own: the package
84
+ ships only `mdk-contract.json` at its root plus the handler files it declares
85
+ under `src/`. There is no `plugin/index.js` and no `connect()`/`disconnect()`.
86
+ `new WorkerRuntimeV2(dir, opts)` takes the package's directory in place of a
87
+ plugin object — it calls `loadContract(dir)` internally to resolve the
88
+ contract and every handler path, then builds one plugin instance per
89
+ configured device via `createInstance({ dir, entries, device })` when each
90
+ device's context opens.
91
+
92
+ - **Handler signature**: `async (params) => result` — no `ctx` argument.
93
+ Handlers instead `require('@tetherto/mdk-worker/device')`, an ambient module
94
+ giving `{ id, opts, env, config, logger }` for the device the handler is
95
+ currently bound to. `opts` is that device's own connection config (the
96
+ `config` entry from `opts.devices` at construction); `env` and `config` are
97
+ the plugin-wide blocks passed as `opts.env`/`opts.config` to the runtime.
98
+ - **Isolation**: `WorkerRuntimeV2` loads the package's files into a private
99
+ module registry per device, so a client module that binds to the ambient
100
+ device at load time already gets one instance per device with no explicit
101
+ per-device construction, and `require('../client')` inside one device's
102
+ handlers always resolves to that device's own instance.
103
+ - **No boot-time probe, no offline state**: every declared device reports
104
+ `online` as soon as `runtime.start()` returns. An unreachable device
105
+ surfaces as an error inside the telemetry payload for the channel that
106
+ touched the network (or `status: 'FAILED'` for a command), not as
107
+ `ERR_DEVICE_UNAVAILABLE`.
108
+ - **No `disconnect()`**: whatever a handler module opens at load time (a
109
+ socket, a file handle) lives until the process exits; nothing closes it
110
+ automatically.
111
+ - **Builtin `health` channel**: every directory-loaded plugin answers
112
+ `telemetry.pull { query: { type: 'health' } }` automatically — `{ status,
113
+ id, workerId }` for the addressed device (never `env`, `opts` or `config`)
114
+ — unless the contract itself declares a `health` channel, which always wins.
115
+
116
+ `loadContract(dir)` and `createInstance({ dir, entries, device })` are also
117
+ usable directly, without a runtime, for contract-level tests — see
118
+ [`local-testing.md`](./local-testing.md).
119
+
120
+ ## Envelope dispatch (`handleRequest`)
121
+
122
+ | Action | Behaviour |
123
+ | --- | --- |
124
+ | `identity.request` | `{ workerId, devices: [{ deviceId }] }` |
125
+ | `capability.request` | `{ contract: publishedContract }` (built-in commands merged in when services are present) |
126
+ | `telemetry.pull` | `query.type` routing — see [`../../mdk/references/protocol.md`](../../mdk/references/protocol.md). `metrics` runs every telemetry handler; a channel name runs one; `list` returns device statuses; service built-ins (`logs`, `settings`, `stats`, …) take precedence over plugin channels |
127
+ | `command.request` | Built-ins first (provisioning/store commands work for offline devices); then device must exist and be online; unknown command → `ERR_UNKNOWN_COMMAND`; handler success → `{ commandId, status: 'SUCCESS', result }`, throw → `{ status: 'FAILED', error }` |
128
+ | `health.ping` | `{ status: 'OK' }` |
129
+ | `state.pull` | `{ state: { <deviceId>: { status } }, deviceCount, workerId }` |
130
+ | `write.calls.request` | Only when `services.actions` is injected |
131
+ | anything else | `ERR_UNKNOWN_ACTION` |
132
+
133
+ Failure isolation: a handler that throws poisons only that field
134
+ (`metrics.<name> = { error }`) or that command — never the Worker. An offline
135
+ device answers `ERR_DEVICE_UNAVAILABLE`; a wrong id `ERR_DEVICE_NOT_FOUND`.
136
+
137
+ ## Param normalization
138
+
139
+ Legacy write paths dispatch positionally (`{ value: x }` or
140
+ `{ args: [...] }`). The runtime maps those onto your contract-declared param
141
+ names before your handler runs, so handlers only ever see named params —
142
+ provided the contract declares params accurately.
143
+
144
+ ## What the runtime does NOT do
145
+
146
+ - It does not validate contracts against the JSON Schema (that's
147
+ [`../scripts/validate-contract.mjs`](../scripts/validate-contract.mjs) and the catalogue lint).
148
+ - It does not enforce param types or `min`/`max` bounds — the **Kernel's**
149
+ dispatcher does, and only for declared bounds. In-process callers of
150
+ `handleRequest` bypass that check (the smoke harness replicates it).
151
+ - It has no HTTP mode and no CLI — a caller process constructs it (see
152
+ the minimal caller in [`local-testing.md`](./local-testing.md#3-standalone-protocol-check-workerruntimev2-real-envelopes-still-no-kernel)).
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ // Validate a worker's mdk-contract.json against ../references/mdk-contract.schema.json.
3
+ //
4
+ // node validate-contract.mjs <path/to/mdk-contract.json> # exit 0/1
5
+ // node validate-contract.mjs --schema-only <path/to/mdk-contract.json> # skip semantic checks
6
+ //
7
+ // Zero-dependency: implements the JSON Schema subset the schema uses, plus
8
+ // semantic checks the schema cannot express — handler files exist, names are
9
+ // unique, and number params declare min/max (the Kernel enforces
10
+ // ERR_PARAM_RANGE only for declared bounds).
11
+
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
17
+ const SCHEMA_PATH = path.resolve(SCRIPT_DIR, '..', 'references', 'mdk-contract.schema.json')
18
+
19
+ function jsonType (v) {
20
+ if (Array.isArray(v)) return 'array'
21
+ if (v === null) return 'null'
22
+ return typeof v
23
+ }
24
+
25
+ function matchesType (expected, v) {
26
+ if (expected === 'integer') return typeof v === 'number' && Number.isInteger(v)
27
+ if (expected === 'array') return Array.isArray(v)
28
+ if (expected === 'object') return jsonType(v) === 'object'
29
+ return typeof v === expected
30
+ }
31
+
32
+ function validateNode (schema, value, at, errors) {
33
+ if (schema.type && !matchesType(schema.type, value)) {
34
+ errors.push(`ERR_CONTRACT_TYPE: ${at} expected ${schema.type}, got ${jsonType(value)}`)
35
+ return
36
+ }
37
+ if (schema.enum && !schema.enum.includes(value)) {
38
+ errors.push(`ERR_CONTRACT_ENUM: ${at} is ${JSON.stringify(value)}, allowed: ${schema.enum.join(' | ')}`)
39
+ }
40
+ if (jsonType(value) === 'object') {
41
+ for (const key of schema.required || []) {
42
+ if (!(key in value)) errors.push(`ERR_CONTRACT_REQUIRED: ${at} missing required property '${key}'`)
43
+ }
44
+ const props = schema.properties || {}
45
+ for (const [key, v] of Object.entries(value)) {
46
+ if (props[key]) {
47
+ validateNode(props[key], v, `${at}/${key}`, errors)
48
+ } else if (schema.additionalProperties === false) {
49
+ errors.push(`ERR_CONTRACT_UNEXPECTED: ${at} has unexpected property '${key}'`)
50
+ } else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
51
+ validateNode(schema.additionalProperties, v, `${at}/${key}`, errors)
52
+ }
53
+ }
54
+ }
55
+ if (Array.isArray(value) && schema.items) {
56
+ value.forEach((v, i) => validateNode(schema.items, v, `${at}/${i}`, errors))
57
+ }
58
+ }
59
+
60
+ function semanticChecks (contract, contractDir, errors) {
61
+ const caps = contract.capabilities
62
+ if (jsonType(caps) !== 'object') return
63
+
64
+ for (const section of ['telemetry', 'commands']) {
65
+ const entries = Array.isArray(caps[section]) ? caps[section] : []
66
+ const seen = new Set()
67
+ entries.forEach((entry, i) => {
68
+ const at = `/capabilities/${section}/${i}`
69
+ if (jsonType(entry) !== 'object') return
70
+ if (entry.name) {
71
+ if (seen.has(entry.name)) errors.push(`ERR_CONTRACT_DUPLICATE_NAME: ${at} '${entry.name}' already declared in ${section}`)
72
+ seen.add(entry.name)
73
+ }
74
+ if (typeof entry.handler === 'string' && entry.handler) {
75
+ const file = path.resolve(contractDir, entry.handler)
76
+ if (!fs.existsSync(file)) errors.push(`ERR_CONTRACT_HANDLER_NOT_FOUND: ${at} handler '${entry.handler}' does not exist (resolved: ${file})`)
77
+ }
78
+ })
79
+ }
80
+
81
+ const commands = Array.isArray(caps.commands) ? caps.commands : []
82
+ commands.forEach((cmd, i) => {
83
+ if (jsonType(cmd) !== 'object' || !Array.isArray(cmd.params)) return
84
+ cmd.params.forEach((p, j) => {
85
+ if (jsonType(p) !== 'object' || p.type !== 'number') return
86
+ const at = `/capabilities/commands/${i}/params/${j}`
87
+ if (typeof p.min !== 'number' || typeof p.max !== 'number') {
88
+ errors.push(`ERR_CONTRACT_PARAM_BOUNDS_MISSING: ${at} ('${cmd.name}'.'${p.name}') — number params must declare min and max so the Kernel can enforce ERR_PARAM_RANGE`)
89
+ } else if (p.min > p.max) {
90
+ errors.push(`ERR_CONTRACT_PARAM_BOUNDS_INVALID: ${at} min ${p.min} > max ${p.max}`)
91
+ }
92
+ })
93
+ })
94
+ }
95
+
96
+ const args = process.argv.slice(2)
97
+ const schemaOnly = args.includes('--schema-only')
98
+ const target = args.find((a) => !a.startsWith('--'))
99
+ if (!target) {
100
+ console.error('usage: node validate-contract.mjs [--schema-only] <path/to/mdk-contract.json>')
101
+ process.exit(1)
102
+ }
103
+
104
+ const contractPath = path.resolve(process.cwd(), target)
105
+ if (!fs.existsSync(contractPath)) {
106
+ console.error(`ERR_CONTRACT_FILE_NOT_FOUND: ${contractPath}`)
107
+ process.exit(1)
108
+ }
109
+
110
+ let contract
111
+ try {
112
+ contract = JSON.parse(fs.readFileSync(contractPath, 'utf8'))
113
+ } catch (err) {
114
+ console.error(`ERR_CONTRACT_PARSE: ${contractPath}: ${err.message}`)
115
+ process.exit(1)
116
+ }
117
+
118
+ const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'))
119
+ const errors = []
120
+ validateNode(schema, contract, '', errors)
121
+ if (!schemaOnly) semanticChecks(contract, path.dirname(contractPath), errors)
122
+
123
+ if (errors.length) {
124
+ console.error(`FAIL: ${target} — ${errors.length} problem(s):`)
125
+ for (const err of errors) console.error(` ${err}`)
126
+ process.exit(1)
127
+ }
128
+
129
+ console.log(`OK: ${target} conforms to mdk-contract.schema.json${schemaOnly ? '' : ' (+ semantic checks)'}`)
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // In-process smoke harness for a Worker Plugin — no Kernel, no DHT.
3
+ //
4
+ // node worker-smoke.mjs <worker-dir> [--config <json | path>]
5
+ //
6
+ // Device opts come from --config (inline JSON or a .json file) or from
7
+ // <worker-dir>/smoke.config.js exporting { setup } -> { config, commands?,
8
+ // env?, pluginConfig?, teardown? }. Asserts: the contract loads, every
9
+ // declared telemetry handler returns the contract-declared type, bounded
10
+ // number params reject out-of-range values, and each smoke.config command
11
+ // executes. Uses WorkerRuntimeV2's loadContract + createInstance path
12
+ // (directory-loaded plugin, plain (params) handlers, ambient device context).
13
+
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+ import { createRequire } from 'node:module'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
20
+
21
+ const TYPE_OK = {
22
+ number: (v) => typeof v === 'number' && Number.isFinite(v),
23
+ string: (v) => typeof v === 'string',
24
+ boolean: (v) => typeof v === 'boolean',
25
+ array: (v) => Array.isArray(v),
26
+ object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
27
+ }
28
+
29
+ // Replica of the Kernel dispatcher's pre-dispatch validation
30
+ // (CommandDispatcher._validateCommand) — bounds are enforced Kernel-side,
31
+ // so an in-process harness must check them itself. Kept in sync manually.
32
+ function validateCommand (capabilities, command, params) {
33
+ if (!capabilities.commands) return { valid: true }
34
+ const commands = capabilities.commands
35
+ const cmdDef = Array.isArray(commands)
36
+ ? commands.find((c) => c.name === command)
37
+ : commands[command]
38
+
39
+ if (!cmdDef) return { valid: false, error: `ERR_COMMAND_NOT_IN_CAPABILITIES: ${command}` }
40
+
41
+ if (cmdDef.params && Array.isArray(cmdDef.params) && params) {
42
+ for (const paramDef of cmdDef.params) {
43
+ const value = params[paramDef.name]
44
+ if (value === undefined) continue
45
+ if (paramDef.type === 'number' && typeof value !== 'number') {
46
+ return { valid: false, error: `ERR_PARAM_TYPE: ${paramDef.name} must be number` }
47
+ }
48
+ if (paramDef.type === 'string' && typeof value !== 'string') {
49
+ return { valid: false, error: `ERR_PARAM_TYPE: ${paramDef.name} must be string` }
50
+ }
51
+ if (paramDef.type === 'number') {
52
+ if (paramDef.min !== undefined && value < paramDef.min) {
53
+ return { valid: false, error: `ERR_PARAM_RANGE: ${paramDef.name} below min ${paramDef.min}` }
54
+ }
55
+ if (paramDef.max !== undefined && value > paramDef.max) {
56
+ return { valid: false, error: `ERR_PARAM_RANGE: ${paramDef.name} above max ${paramDef.max}` }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ return { valid: true }
62
+ }
63
+
64
+ // Monorepo fallback for a checkout without node_modules links: walk up from
65
+ // this script's own dir (never the worker dir, which may be untrusted) and stop
66
+ // at the repo root (the first dir holding .git). The candidate must really be
67
+ // @tetherto/mdk-worker.
68
+ function findMonorepoWorkerBase () {
69
+ let dir = SCRIPT_DIR
70
+ for (;;) {
71
+ const candidate = path.join(dir, 'backend', 'core', 'mdk-worker')
72
+ if (fs.existsSync(path.join(candidate, 'lib', 'contract-loader.js'))) {
73
+ try {
74
+ if (JSON.parse(fs.readFileSync(path.join(candidate, 'package.json'), 'utf8')).name === '@tetherto/mdk-worker') return candidate
75
+ } catch {}
76
+ }
77
+ const parent = path.dirname(dir)
78
+ if (parent === dir || fs.existsSync(path.join(dir, '.git'))) return null
79
+ dir = parent
80
+ }
81
+ }
82
+
83
+ function parseArgs (argv) {
84
+ const args = { workerDir: null, config: null }
85
+ for (let i = 0; i < argv.length; i++) {
86
+ if (argv[i] === '--config') args.config = argv[++i]
87
+ else if (!args.workerDir) args.workerDir = argv[i]
88
+ }
89
+ return args
90
+ }
91
+
92
+ const { workerDir: workerDirArg, config: configArg } = parseArgs(process.argv.slice(2))
93
+ if (!workerDirArg) {
94
+ console.error('usage: node worker-smoke.mjs <worker-dir> [--config <json | path>]')
95
+ process.exit(1)
96
+ }
97
+
98
+ const workerDir = path.resolve(process.cwd(), workerDirArg)
99
+ const pluginDir = fs.existsSync(path.join(workerDir, 'plugin', 'mdk-contract.json'))
100
+ ? path.join(workerDir, 'plugin')
101
+ : workerDir
102
+ if (!fs.existsSync(path.join(pluginDir, 'mdk-contract.json'))) {
103
+ console.error(`ERR_SMOKE_NO_PLUGIN: no mdk-contract.json under ${workerDir}`)
104
+ process.exit(1)
105
+ }
106
+
107
+ const requireFromWorker = createRequire(path.join(workerDir, 'noop.js'))
108
+ const requireFromScript = createRequire(path.join(SCRIPT_DIR, 'noop.js'))
109
+
110
+ let loadContract
111
+ let createInstance
112
+ // Resolution order: explicit MDK_WORKER_BASE, then the worker's declared
113
+ // @tetherto/mdk-worker dependency, then a monorepo checkout around this script.
114
+ function useBase (base) {
115
+ loadContract = requireFromScript(path.join(base, 'lib', 'contract-loader.js')).loadContract
116
+ createInstance = requireFromScript(path.join(base, 'lib', 'instance-loader.js')).createInstance
117
+ }
118
+ if (process.env.MDK_WORKER_BASE) {
119
+ useBase(path.resolve(process.env.MDK_WORKER_BASE))
120
+ } else {
121
+ let mdkWorker = null
122
+ try {
123
+ mdkWorker = requireFromWorker('@tetherto/mdk-worker')
124
+ } catch {}
125
+ if (mdkWorker) {
126
+ loadContract = mdkWorker.loadContract
127
+ createInstance = mdkWorker.createInstance
128
+ } else {
129
+ const base = findMonorepoWorkerBase()
130
+ if (!base) {
131
+ console.error('ERR_SMOKE_NO_WORKER_BASE: cannot resolve @tetherto/mdk-worker from the worker dir (run npm install there, or set MDK_WORKER_BASE)')
132
+ process.exit(1)
133
+ }
134
+ useBase(base)
135
+ }
136
+ }
137
+
138
+ const failures = []
139
+ let checks = 0
140
+ function check (ok, label, detail) {
141
+ checks++
142
+ if (ok) {
143
+ console.log(` ok ${label}`)
144
+ } else {
145
+ failures.push(`${label}${detail ? ` — ${detail}` : ''}`)
146
+ console.error(` FAIL ${label}${detail ? ` — ${detail}` : ''}`)
147
+ }
148
+ }
149
+
150
+ async function resolveSpec () {
151
+ if (configArg) {
152
+ if (configArg.trim().startsWith('{')) return { config: JSON.parse(configArg) }
153
+ const p = path.resolve(process.cwd(), configArg)
154
+ if (p.endsWith('.json')) return { config: JSON.parse(fs.readFileSync(p, 'utf8')) }
155
+ const mod = requireFromWorker(p)
156
+ return mod.setup ? mod.setup() : { config: mod }
157
+ }
158
+ const smokeConfig = path.join(workerDir, 'smoke.config.js')
159
+ if (!fs.existsSync(smokeConfig)) {
160
+ console.error(`ERR_SMOKE_NO_CONFIG: no --config given and no ${smokeConfig}`)
161
+ process.exit(1)
162
+ }
163
+ return requireFromWorker(smokeConfig).setup()
164
+ }
165
+
166
+ async function main () {
167
+ const { contract, entries, dir } = loadContract(pluginDir)
168
+ const declaredTel = (contract.capabilities.telemetry || []).length
169
+ const declaredCmd = (contract.capabilities.commands || []).length
170
+ console.log(`smoke: ${contract.metadata.brand} (${contract.metadata.deviceFamily}) — ${declaredTel} telemetry, ${declaredCmd} commands`)
171
+ check(true, 'contract loads (loadContract: mdk-contract.json + every handler path)')
172
+
173
+ const spec = await resolveSpec()
174
+ const deviceId = 'smoke-0'
175
+
176
+ let instance = null
177
+ try {
178
+ instance = createInstance({
179
+ dir,
180
+ entries,
181
+ device: {
182
+ id: deviceId,
183
+ opts: Object.freeze({ ...(spec.config || {}) }),
184
+ env: Object.freeze({ ...(spec.env || {}) }),
185
+ config: Object.freeze({ ...(spec.pluginConfig || {}) }),
186
+ workerId: 'smoke',
187
+ logger: () => {}
188
+ }
189
+ })
190
+ check(true, 'createInstance binds handlers for one device')
191
+ } catch (err) {
192
+ check(false, 'createInstance binds handlers for one device', err.message)
193
+ }
194
+
195
+ if (instance) {
196
+ for (const entry of contract.capabilities.telemetry || []) {
197
+ const typeOk = TYPE_OK[entry.type] || ((v) => v !== undefined)
198
+ try {
199
+ const value = await instance.telemetry.get(entry.name)({})
200
+ check(value !== undefined && typeOk(value),
201
+ `telemetry '${entry.name}' returns a ${entry.type}`,
202
+ value === undefined ? 'returned undefined' : `returned ${JSON.stringify(value)}`)
203
+ } catch (err) {
204
+ check(false, `telemetry '${entry.name}' returns a ${entry.type}`, err.message)
205
+ }
206
+ }
207
+
208
+ for (const cmd of contract.capabilities.commands || []) {
209
+ for (const p of cmd.params || []) {
210
+ if (p.type !== 'number' || p.min === undefined || p.max === undefined) continue
211
+ const below = validateCommand(contract.capabilities, cmd.name, { [p.name]: p.min - 1 })
212
+ check(!below.valid && /ERR_PARAM_RANGE/.test(below.error || ''),
213
+ `command '${cmd.name}' rejects ${p.name}=${p.min - 1} (below min)`, below.error || 'accepted')
214
+ const above = validateCommand(contract.capabilities, cmd.name, { [p.name]: p.max + 1 })
215
+ check(!above.valid && /ERR_PARAM_RANGE/.test(above.error || ''),
216
+ `command '${cmd.name}' rejects ${p.name}=${p.max + 1} (above max)`, above.error || 'accepted')
217
+ const atBounds = validateCommand(contract.capabilities, cmd.name, { [p.name]: p.min }).valid &&
218
+ validateCommand(contract.capabilities, cmd.name, { [p.name]: p.max }).valid
219
+ check(atBounds, `command '${cmd.name}' accepts ${p.name} at [${p.min}, ${p.max}]`)
220
+ }
221
+ }
222
+
223
+ for (const [name, params] of Object.entries(spec.commands || {})) {
224
+ const gate = validateCommand(contract.capabilities, name, params)
225
+ if (!gate.valid) {
226
+ check(false, `command '${name}' executes with sample params`, gate.error)
227
+ continue
228
+ }
229
+ const handler = instance.commands.get(name)
230
+ if (!handler) {
231
+ check(false, `command '${name}' executes with sample params`, 'not declared in contract')
232
+ continue
233
+ }
234
+ try {
235
+ await handler(params)
236
+ check(true, `command '${name}' executes with sample params`)
237
+ } catch (err) {
238
+ check(false, `command '${name}' executes with sample params`, err.message)
239
+ }
240
+ }
241
+ }
242
+
243
+ if (spec.teardown) await spec.teardown()
244
+
245
+ if (failures.length) {
246
+ console.error(`\nSMOKE FAIL: ${failures.length}/${checks} check(s) failed`)
247
+ process.exit(1)
248
+ }
249
+ console.log(`\nSMOKE PASS: ${checks} check(s)`)
250
+ process.exit(0)
251
+ }
252
+
253
+ main().catch((err) => {
254
+ console.error(`ERR_SMOKE: ${err.message}`)
255
+ process.exit(1)
256
+ })
package/package.json CHANGED
@@ -1 +1,47 @@
1
- {"name":"@tetherto/mdk-skill","version":"0.0.0"}
1
+ {
2
+ "name": "@tetherto/mdk-skill",
3
+ "version": "0.12.1",
4
+ "description": "MDK Developer Skill Suite — Agent Skills (SKILL.md) bundle assembled from the MDK monorepo's real artifacts and versioned to track the MDK release line. Copy-only assembler: each library owns its artifacts, this package curates them into dist/skills/ and installs them flat into .cursor/skills/ or .claude/skills/.",
5
+ "keywords": [
6
+ "tether",
7
+ "mdk",
8
+ "agent-skills"
9
+ ],
10
+ "author": "Tether",
11
+ "license": "Apache-2.0",
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "type": "module",
16
+ "main": "./src/index.mjs",
17
+ "types": "./src/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.d.ts",
21
+ "import": "./src/index.mjs",
22
+ "default": "./src/index.mjs"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src/*.mjs",
29
+ "src/index.d.ts",
30
+ "src/sources.map.json"
31
+ ],
32
+ "scripts": {
33
+ "assemble": "node src/assemble.mjs",
34
+ "compile:envelope": "node src/skills/mdk-site-sizing/scripts/compile-envelope.mjs",
35
+ "test": "node src/skills/mdk-site-sizing/scripts/compile-envelope.mjs && node src/assemble.mjs && node --test tests/site-sizing.test.mjs tests/install.test.mjs tests/install-cli.test.mjs tests/scan-packages.test.mjs && node src/skills/mdk-site-sizing/scripts/eval-held-out.mjs",
36
+ "install:skills": "node src/assemble.mjs && node src/install.mjs",
37
+ "prepack": "node src/assemble.mjs"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/tetherto/mdk.git",
42
+ "directory": "packages/mdk-skill"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // CLI wrapper around the programmatic `assemble()` (see index.mjs).
3
+ import { assemble } from './index.mjs'
4
+
5
+ try {
6
+ const { files, version } = assemble()
7
+ console.log(`assembled ${files} file(s) (mdk ${version}) -> dist/skills`)
8
+ } catch (err) {
9
+ console.error(`ERR_ASSEMBLE: ${err instanceof Error ? err.message : String(err)}`)
10
+ process.exit(1)
11
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ export type SkillClient = 'cursor' | 'claude' | 'all';
2
+
3
+ export interface InstalledEntry {
4
+ client: string;
5
+ dir: string;
6
+ /** Stale MDK-shipped skill dirs removed from `dir` because the current suite no longer ships them. */
7
+ removed: string[];
8
+ }
9
+
10
+ export interface SkippedEntry {
11
+ client: string;
12
+ skill: string;
13
+ dir: string;
14
+ reason: string;
15
+ }
16
+
17
+ export interface InstallResult {
18
+ skills: string[];
19
+ installed: InstalledEntry[];
20
+ /** Existing skill dirs left untouched: symlinks, or not marked `metadata.suite: mdk-developer-skill`. */
21
+ skipped: SkippedEntry[];
22
+ }
23
+
24
+ export interface InstallOptions {
25
+ /** Target client(s). Defaults to `'all'`. */
26
+ client?: SkillClient;
27
+ /** Project root to install into. Defaults to `process.cwd()`. */
28
+ target?: string;
29
+ /** Assemble the suite first if `dist/skills/` is empty. Defaults to `true`. */
30
+ assembleIfMissing?: boolean;
31
+ /** Called once per skipped skill dir. Defaults to `console.warn`. */
32
+ onWarn?: (message: string) => void;
33
+ }
34
+
35
+ export interface AssembleResult {
36
+ files: number;
37
+ version: string;
38
+ }
39
+
40
+ export declare const CLIENT_DIRS: Record<string, string[]>;
41
+
42
+ /** True when the source map and repo root are available to assemble from. */
43
+ export declare function canAssemble(): boolean;
44
+
45
+ /** True when `dist/skills/` already holds at least one assembled skill. */
46
+ export declare function isAssembled(): boolean;
47
+
48
+ /** Assemble the skill suite from source artifacts into `dist/skills/`. */
49
+ export declare function assemble(): AssembleResult;
50
+
51
+ /**
52
+ * Install the assembled skills into a project's client skills directories.
53
+ * Throws if a client dir segment under the target is a symlink; skips (and
54
+ * warns about) existing same-named skill dirs that are not suite skills. Stale
55
+ * MDK-shipped skills are swept; user-authored skills are left untouched.
56
+ */
57
+ export declare function installSkills(options?: InstallOptions): InstallResult;