@moda-ai/cli 1.55.9 → 1.55.11

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 (32) hide show
  1. package/dist/agent-n0ch2j90.js +459 -0
  2. package/dist/cli-11fevr8y.js +811 -0
  3. package/dist/{cli-mx9r1689.js → cli-429zt7g4.js} +1 -65
  4. package/dist/cli-4qe6n8am.js +345 -0
  5. package/dist/cli-97fnztpy.js +140 -0
  6. package/dist/{cli-sjc132j8.js → cli-fq7kk77e.js} +18 -1733
  7. package/dist/cli-fwec3m42.js +946 -0
  8. package/dist/cli-g7ferymy.js +496 -0
  9. package/dist/cli-k5qqj2bp.js +70 -0
  10. package/dist/cli-m16taf4z.js +30 -0
  11. package/dist/{cli-4af3thx7.js → cli-m857n028.js} +195 -976
  12. package/dist/{cli-fhfdnvh4.js → cli-n9gexgma.js} +2 -2
  13. package/dist/{cli-tw8jj190.js → cli-t4jjhn3g.js} +5 -7
  14. package/dist/cli-tmqd1f0m.js +159 -0
  15. package/dist/cli.js +1410 -7193
  16. package/dist/doctor-spvy2zb3.js +23 -0
  17. package/dist/fix-ax6bbg37.js +1444 -0
  18. package/dist/{harness-github-actions-73a08nct.js → harness-github-actions-mtksqp3c.js} +7 -2
  19. package/dist/{harness-yshh2pgj.js → harness-jbqpbp6n.js} +7 -2
  20. package/dist/{index-f3k9jgrt.js → index-2d0q94sv.js} +35 -36
  21. package/dist/production-intelligence-1tf98n79.js +1802 -0
  22. package/dist/profile-commands-f6599bcx.js +481 -0
  23. package/dist/prompt-source-mcp.js +8 -3
  24. package/dist/prompts-y3xd9heh.js +40 -0
  25. package/dist/{provision-parsn3xg.js → provision-cjpsswa9.js} +7 -5
  26. package/dist/registry-1dvxsekd.js +1201 -0
  27. package/dist/skills-1k0hbkaf.js +19 -0
  28. package/dist/sync-by81xqk4.js +54 -0
  29. package/package.json +6 -7
  30. package/skills/integration/index.json +2 -2
  31. package/skills/moda-cli/SKILL.md +2 -2
  32. package/hooks/moda-snapshot.sh +0 -41
@@ -0,0 +1,1201 @@
1
+ import {
2
+ assertFlags
3
+ } from "./cli-g7ferymy.js";
4
+ import {
5
+ callControlAPI
6
+ } from "./cli-tmqd1f0m.js";
7
+ import"./cli-m16taf4z.js";
8
+ import {
9
+ CliInputError,
10
+ explicitOutputMode,
11
+ resolveApiKey
12
+ } from "./cli-429zt7g4.js";
13
+ import"./cli-0v6na3yp.js";
14
+
15
+ // src/registry.ts
16
+ import { execFileSync } from "node:child_process";
17
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
18
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
19
+
20
+ // src/registry-harness.ts
21
+ var HARNESS_SECTIONS = ["runtime", "agents", "routing", "context", "mcpServers"];
22
+ var ROUTING_STRATEGIES = ["single", "trace_attribute", "handoff", "router", "rules"];
23
+ var HISTORY_STRATEGIES = ["full", "window", "summary", "truncate_tokens"];
24
+ var SKILL_LOADING_MODES = ["tool", "system_prompt", "none"];
25
+ var CONTEXT_SOURCES = ["static", "clock", "trace", "tool", "memory", "retrieval", "unknown"];
26
+ var CONTEXT_TARGETS = ["prompt_variable", "system_append"];
27
+ var CLOCK_FORMATS = ["date", "datetime", "iso"];
28
+ var MCP_TRANSPORTS = ["stdio", "http", "sse", "streamable_http"];
29
+ var AGENT_KEY_PATTERN = /^[A-Za-z0-9_.-]{1,128}$/;
30
+ var TOOL_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
31
+ var VARIABLE_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]{0,127}$/;
32
+ var MAX_AGENTS = 100;
33
+ var MAX_CONTEXT = 200;
34
+ var MAX_MCP_SERVERS = 50;
35
+ var MAX_HARNESS_DOCUMENT_BYTES = 1e6;
36
+ var AGENT_FIELDS = {
37
+ key: "string",
38
+ name: "string",
39
+ description: "string",
40
+ entry: "boolean",
41
+ prompt: "string",
42
+ model: "object",
43
+ tools: "string[]",
44
+ skills: "string[]",
45
+ handoffs: "object[]",
46
+ subagents: "object[]",
47
+ context: "string[]",
48
+ maxToolRoundsPerTurn: "number",
49
+ responseSchema: "object",
50
+ match: "object"
51
+ };
52
+ var MODEL_FIELDS = {
53
+ id: "string",
54
+ provider: "string",
55
+ temperature: "number",
56
+ maxTokens: "number",
57
+ topP: "number",
58
+ reasoningEffort: "string"
59
+ };
60
+ var HANDOFF_FIELDS = { to: "string", tool: "string", description: "string" };
61
+ var SUBAGENT_FIELDS = { agent: "string", tool: "string", description: "string" };
62
+ var MATCH_FIELDS = { agentNames: "string[]", promptKeys: "string[]" };
63
+ var ROUTING_FIELDS = { strategy: "string", default: "string", notes: "string" };
64
+ var RUNTIME_FIELDS = {
65
+ framework: "string",
66
+ language: "string",
67
+ maxToolRoundsPerTurn: "number",
68
+ parallelToolCalls: "boolean",
69
+ history: "object",
70
+ skillLoading: "string",
71
+ notes: "string"
72
+ };
73
+ var HISTORY_FIELDS = { strategy: "string", maxMessages: "number", maxTokens: "number" };
74
+ var CONTEXT_FIELDS = {
75
+ key: "string",
76
+ variable: "string",
77
+ source: "string",
78
+ value: "string",
79
+ format: "string",
80
+ target: "string",
81
+ description: "string"
82
+ };
83
+ var MCP_FIELDS = {
84
+ name: "string",
85
+ transport: "string",
86
+ url: "string",
87
+ command: "string",
88
+ tools: "string[]",
89
+ description: "string"
90
+ };
91
+ function handoffToolName(handoff) {
92
+ return handoff.tool?.trim() || `transfer_to_${handoff.to.replace(/[^A-Za-z0-9_-]/g, "_")}`.slice(0, 64);
93
+ }
94
+ function hasHarnessSections(input) {
95
+ return HARNESS_SECTIONS.some((section) => input[section] !== undefined);
96
+ }
97
+ function validateHarnessSections(input, known, errors, warnings) {
98
+ if (!hasHarnessSections(input))
99
+ return;
100
+ const document = {};
101
+ if (input.runtime !== undefined) {
102
+ if (checkObject("runtime", input.runtime, RUNTIME_FIELDS, errors)) {
103
+ const runtime = input.runtime;
104
+ checkPositiveInt("runtime.maxToolRoundsPerTurn", runtime.maxToolRoundsPerTurn, errors);
105
+ checkEnum("runtime.skillLoading", runtime.skillLoading, SKILL_LOADING_MODES, errors);
106
+ if (runtime.history !== undefined && checkObject("runtime.history", runtime.history, HISTORY_FIELDS, errors)) {
107
+ checkEnum("runtime.history.strategy", runtime.history.strategy, HISTORY_STRATEGIES, errors);
108
+ checkPositiveInt("runtime.history.maxMessages", runtime.history.maxMessages, errors);
109
+ checkPositiveInt("runtime.history.maxTokens", runtime.history.maxTokens, errors);
110
+ }
111
+ document.runtime = runtime;
112
+ }
113
+ }
114
+ const contextKeys = new Set;
115
+ if (input.context !== undefined) {
116
+ const entries = listOf("context", input.context, MAX_CONTEXT, errors);
117
+ document.context = [];
118
+ entries.forEach((raw, index) => {
119
+ const label = `context[${index}]`;
120
+ if (!checkObject(label, raw, CONTEXT_FIELDS, errors))
121
+ return;
122
+ const entry = raw;
123
+ if (!nonEmpty(entry.key))
124
+ errors.push(`${label}.key is required`);
125
+ else if (contextKeys.has(entry.key))
126
+ errors.push(`${label}: duplicate context key "${entry.key}"`);
127
+ else
128
+ contextKeys.add(entry.key);
129
+ if (!nonEmpty(entry.source))
130
+ errors.push(`${label}.source is required (one of ${CONTEXT_SOURCES.join(", ")})`);
131
+ else
132
+ checkEnum(`${label}.source`, entry.source, CONTEXT_SOURCES, errors);
133
+ checkEnum(`${label}.target`, entry.target, CONTEXT_TARGETS, errors);
134
+ checkEnum(`${label}.format`, entry.format, CLOCK_FORMATS, errors);
135
+ const target = entry.target ?? "prompt_variable";
136
+ if (target === "prompt_variable") {
137
+ if (!nonEmpty(entry.variable))
138
+ errors.push(`${label}.variable is required for target "prompt_variable"`);
139
+ else if (!VARIABLE_PATTERN.test(entry.variable))
140
+ errors.push(`${label}.variable "${entry.variable}" is not a valid template variable name`);
141
+ }
142
+ if (entry.source === "static" && typeof entry.value !== "string")
143
+ errors.push(`${label}.value is required for source "static"`);
144
+ if (entry.source !== "static" && entry.value !== undefined) {
145
+ errors.push(`${label}.value is only allowed for source "static" (record runtime values in traces instead)`);
146
+ }
147
+ document.context.push(entry);
148
+ });
149
+ }
150
+ const agentKeys = new Set;
151
+ const agents = input.agents === undefined ? [] : listOf("agents", input.agents, MAX_AGENTS, errors);
152
+ for (const raw of agents) {
153
+ if (isRecord(raw) && nonEmpty(raw.key) && AGENT_KEY_PATTERN.test(raw.key))
154
+ agentKeys.add(raw.key);
155
+ }
156
+ if (input.agents !== undefined) {
157
+ document.agents = [];
158
+ const seen = new Set;
159
+ let entries = 0;
160
+ agents.forEach((raw, index) => {
161
+ const label = `agents[${index}]`;
162
+ if (!checkObject(label, raw, AGENT_FIELDS, errors))
163
+ return;
164
+ const agent = raw;
165
+ if (!nonEmpty(agent.key))
166
+ errors.push(`${label}.key is required`);
167
+ else if (!AGENT_KEY_PATTERN.test(agent.key))
168
+ errors.push(`${label}.key "${agent.key}" may only contain letters, digits, ".", "_" and "-" (max 128)`);
169
+ else if (seen.has(agent.key))
170
+ errors.push(`${label}: duplicate agent key "${agent.key}"`);
171
+ else
172
+ seen.add(agent.key);
173
+ if (agent.entry === true)
174
+ entries += 1;
175
+ if (agent.model !== undefined && checkObject(`${label}.model`, agent.model, MODEL_FIELDS, errors)) {
176
+ const model = agent.model;
177
+ if (typeof model.temperature === "number" && (model.temperature < 0 || model.temperature > 2)) {
178
+ errors.push(`${label}.model.temperature must be between 0 and 2`);
179
+ }
180
+ if (typeof model.topP === "number" && (model.topP <= 0 || model.topP > 1))
181
+ errors.push(`${label}.model.topP must be in (0, 1]`);
182
+ checkPositiveInt(`${label}.model.maxTokens`, model.maxTokens, errors);
183
+ }
184
+ checkPositiveInt(`${label}.maxToolRoundsPerTurn`, agent.maxToolRoundsPerTurn, errors);
185
+ if (agent.match !== undefined)
186
+ checkObject(`${label}.match`, agent.match, MATCH_FIELDS, errors);
187
+ const handoffTools = new Set;
188
+ (Array.isArray(agent.handoffs) ? agent.handoffs : []).forEach((handoff, hIndex) => {
189
+ const hLabel = `${label}.handoffs[${hIndex}]`;
190
+ if (!checkObject(hLabel, handoff, HANDOFF_FIELDS, errors))
191
+ return;
192
+ if (!nonEmpty(handoff.to)) {
193
+ errors.push(`${hLabel}.to is required`);
194
+ return;
195
+ }
196
+ if (!agentKeys.has(handoff.to))
197
+ errors.push(`${hLabel}.to "${handoff.to}" is not an agent key in this manifest`);
198
+ if (handoff.to === agent.key)
199
+ errors.push(`${hLabel}: an agent cannot hand off to itself`);
200
+ const tool = handoffToolName(handoff);
201
+ if (!TOOL_NAME_PATTERN.test(tool))
202
+ errors.push(`${hLabel}.tool "${tool}" must match ${TOOL_NAME_PATTERN.source}`);
203
+ else if (handoffTools.has(tool))
204
+ errors.push(`${hLabel}: duplicate handoff tool "${tool}"`);
205
+ else if ((agent.tools ?? []).includes(tool))
206
+ errors.push(`${hLabel}: handoff tool "${tool}" collides with a tool in ${label}.tools`);
207
+ handoffTools.add(tool);
208
+ });
209
+ (Array.isArray(agent.subagents) ? agent.subagents : []).forEach((subagent, sIndex) => {
210
+ const sLabel = `${label}.subagents[${sIndex}]`;
211
+ if (!checkObject(sLabel, subagent, SUBAGENT_FIELDS, errors))
212
+ return;
213
+ if (!nonEmpty(subagent.agent))
214
+ errors.push(`${sLabel}.agent is required`);
215
+ else if (!agentKeys.has(subagent.agent))
216
+ errors.push(`${sLabel}.agent "${subagent.agent}" is not an agent key in this manifest`);
217
+ if (!nonEmpty(subagent.tool))
218
+ errors.push(`${sLabel}.tool is required (the tool name the parent calls)`);
219
+ else if (!(agent.tools ?? []).includes(subagent.tool)) {
220
+ errors.push(`${sLabel}.tool "${subagent.tool}" must also be listed in ${label}.tools (with its schema in tools[])`);
221
+ }
222
+ });
223
+ for (const key of agent.context ?? []) {
224
+ if (!contextKeys.has(key))
225
+ errors.push(`${label}.context "${key}" is not a key in context[]`);
226
+ }
227
+ if (nonEmpty(agent.prompt) && !known.prompts.has(agent.prompt)) {
228
+ warnings.push(`${label}.prompt "${agent.prompt}" is not in prompts[]; it must already exist in Moda`);
229
+ }
230
+ for (const tool of agent.tools ?? []) {
231
+ if (!known.tools.has(tool))
232
+ warnings.push(`${label}.tools "${tool}" is not in tools[]; it must already exist in Moda`);
233
+ }
234
+ for (const skill of agent.skills ?? []) {
235
+ if (!known.skills.has(skill))
236
+ warnings.push(`${label}.skills "${skill}" is not in skills[]; it must already exist in Moda`);
237
+ }
238
+ document.agents.push(agent);
239
+ });
240
+ if (entries > 1)
241
+ errors.push('agents: at most one agent may set "entry": true');
242
+ }
243
+ if (input.routing !== undefined) {
244
+ if (checkObject("routing", input.routing, ROUTING_FIELDS, errors)) {
245
+ const routing = input.routing;
246
+ checkEnum("routing.strategy", routing.strategy, ROUTING_STRATEGIES, errors);
247
+ if (routing.default !== undefined && !agentKeys.has(routing.default)) {
248
+ errors.push(`routing.default "${routing.default}" is not an agent key in agents[]`);
249
+ }
250
+ document.routing = routing;
251
+ }
252
+ }
253
+ if (input.mcpServers !== undefined) {
254
+ document.mcpServers = [];
255
+ const names = new Set;
256
+ listOf("mcpServers", input.mcpServers, MAX_MCP_SERVERS, errors).forEach((raw, index) => {
257
+ const label = `mcpServers[${index}]`;
258
+ if (!checkObject(label, raw, MCP_FIELDS, errors))
259
+ return;
260
+ const server = raw;
261
+ if (!nonEmpty(server.name))
262
+ errors.push(`${label}.name is required`);
263
+ else if (names.has(server.name))
264
+ errors.push(`${label}: duplicate MCP server name "${server.name}"`);
265
+ else
266
+ names.add(server.name);
267
+ checkEnum(`${label}.transport`, server.transport, MCP_TRANSPORTS, errors);
268
+ if (server.url !== undefined && hasUrlCredentials(server.url)) {
269
+ errors.push(`${label}.url must not contain credentials (user info or token query parameters)`);
270
+ }
271
+ document.mcpServers.push(server);
272
+ });
273
+ }
274
+ if (Buffer.byteLength(JSON.stringify(document), "utf8") > MAX_HARNESS_DOCUMENT_BYTES) {
275
+ errors.push(`harness sections exceed ${MAX_HARNESS_DOCUMENT_BYTES} bytes`);
276
+ }
277
+ return document;
278
+ }
279
+ function hasUrlCredentials(raw) {
280
+ try {
281
+ const url = new URL(raw);
282
+ if (url.username || url.password)
283
+ return true;
284
+ return [...url.searchParams.keys()].some((key) => /token|key|secret|password|auth/i.test(key));
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+ function listOf(label, value, max, errors) {
290
+ if (!Array.isArray(value)) {
291
+ errors.push(`${label} must be an array`);
292
+ return [];
293
+ }
294
+ if (value.length > max)
295
+ errors.push(`${label} has ${value.length} entries; the limit is ${max}`);
296
+ return value;
297
+ }
298
+ function checkObject(label, value, spec, errors) {
299
+ if (!isRecord(value)) {
300
+ errors.push(`${label} must be an object`);
301
+ return false;
302
+ }
303
+ let typed = true;
304
+ for (const [key, item] of Object.entries(value)) {
305
+ const expected = spec[key];
306
+ if (!expected) {
307
+ errors.push(`${label}: unknown field "${key}" (allowed: ${Object.keys(spec).join(", ")})`);
308
+ continue;
309
+ }
310
+ const ok = expected === "string[]" ? Array.isArray(item) && item.every((entry) => typeof entry === "string") : expected === "object[]" ? Array.isArray(item) && item.every(isRecord) : expected === "object" ? isRecord(item) : expected === "number" ? typeof item === "number" && Number.isFinite(item) : typeof item === expected;
311
+ if (!ok) {
312
+ errors.push(`${label}.${key} must be ${describe(expected)}`);
313
+ typed = false;
314
+ }
315
+ }
316
+ return typed;
317
+ }
318
+ function describe(type) {
319
+ if (type === "string[]")
320
+ return "an array of strings";
321
+ if (type === "object[]")
322
+ return "an array of objects";
323
+ return type === "object" ? "an object" : `a ${type}`;
324
+ }
325
+ function checkEnum(label, value, allowed, errors) {
326
+ if (value === undefined || typeof value !== "string")
327
+ return;
328
+ if (!allowed.includes(value))
329
+ errors.push(`${label} must be one of ${allowed.join(", ")} (got "${value}")`);
330
+ }
331
+ function checkPositiveInt(label, value, errors) {
332
+ if (value === undefined || typeof value !== "number")
333
+ return;
334
+ if (!Number.isInteger(value) || value <= 0)
335
+ errors.push(`${label} must be a positive integer`);
336
+ }
337
+ function isRecord(value) {
338
+ return typeof value === "object" && value !== null && !Array.isArray(value);
339
+ }
340
+ function nonEmpty(value) {
341
+ return typeof value === "string" && value.trim() !== "";
342
+ }
343
+
344
+ // src/registry-prompt.ts
345
+ var REGISTRY_AGENT_BRIEF = `# Register this repo's AI harness with Moda for faithful replays
346
+
347
+ You are working in the repository that runs our production AI agent. Moda
348
+ replays recorded production conversations against our agent to test prompt,
349
+ tool, and skill changes. A replay is only as faithful as what Moda knows about
350
+ how our agent actually runs. Your job: describe our harness to Moda exactly,
351
+ push it with the \`moda\` CLI, and make sure our production traces say which
352
+ agent and prompt handled each LLM call.
353
+
354
+ ## Ground rules
355
+
356
+ - Read the code; do not guess. Every value you register must come from the
357
+ repo (cite the file in \`sourcePath\` or in a note). If something is decided
358
+ at runtime and you can't find it, say so in your final report.
359
+ - Copy prompt text, tool names, tool descriptions, and parameter schemas
360
+ verbatim. For templated prompts, keep the template with its placeholders
361
+ (\`{{name}}\`, \`{name}\`, \`\${name}\`). Never register a rendered example.
362
+ - Never put secrets in the manifest: no API keys, tokens, auth headers, env var
363
+ values, or customer data.
364
+ - Do not change runtime behaviour. Part 5 adds trace metadata only. Show the
365
+ diff and get my OK before committing it.
366
+ - Parts 0 to 6 upload only \`.moda/registry.json\` (plus the files it
367
+ references). Part 7 uploads a bundle of the repo, but only after I review
368
+ its dry run. Secrets, env var values, and git-ignored installs are never
369
+ included.
370
+
371
+ ## Part 0: setup
372
+
373
+ 1. Run \`moda registry --help\` and confirm it lists all of \`push\`,
374
+ \`status\`, \`coverage\`, \`log\`, \`show\`, and \`prompt\` (the full set
375
+ first shipped in 1.45.0). If any is missing or the command fails, run
376
+ \`npm install -g @moda-ai/cli@latest\` and check again. If they're still
377
+ missing, stop and tell me which ones; nothing below works without them.
378
+ Part 7 also needs \`moda harness register --help\` to work; if it does
379
+ not, finish Parts 1 to 6 and tell me.
380
+ 2. Auth: \`MODA_API_KEY\` must be set, or run \`moda auth login --api-key=<key>\`.
381
+ Ask me for the key if neither is available. Check with \`moda doctor\`.
382
+ 3. Run \`moda registry template\` to see a complete example manifest, and
383
+ \`moda registry status\` to see what is already registered.
384
+
385
+ ## Part 1: inventory the harness
386
+
387
+ Find every place the code calls an LLM: direct SDK calls (OpenAI, Anthropic,
388
+ Gemini, Bedrock, OpenRouter), framework agents, and anything else. For each
389
+ call site, write down:
390
+
391
+ - **Agent**: the logical agent it belongs to, with a stable key such as
392
+ \`triage\` or \`billing\`. Different system prompts, tool sets, or models mean
393
+ different agents. A one-off call (summariser, classifier, title generator)
394
+ is its own agent if it runs during a conversation.
395
+ - **Prompt**: the system prompt or messages it sends, how they're built
396
+ (static text, template plus variables, or code that assembles them), and
397
+ the file.
398
+ - **Model and settings**: model id, provider, temperature, max tokens, top_p,
399
+ and reasoning effort, wherever they're set (code, config, env defaults).
400
+ - **Tools**: every tool the model can call at that site, with the exact JSON
401
+ schema the model sees. Include MCP tools and agents exposed as tools.
402
+ - **Skills**: SKILL.md files or instruction docs the agent loads, and HOW it
403
+ loads them (a tool such as skill_read or read_file, inlined into the system
404
+ prompt, or not at all).
405
+ - **Loop limits**: max tool rounds or steps per turn, parallel tool calls, and
406
+ how history is truncated or summarised.
407
+
408
+ Framework hints:
409
+
410
+ - **OpenAI Agents SDK**: \`Agent(name, instructions, model, model_settings,
411
+ tools, handoffs)\`. \`name\` is the trace agent name. Handoffs become
412
+ \`transfer_to_<agent>\` tools.
413
+ - **Claude Agent SDK / Claude Code SDK**: \`ClaudeAgentOptions(system_prompt,
414
+ model, allowed_tools, mcp_servers, agents=...)\`. Subagents are \`agents\`,
415
+ and skills live in \`.claude/skills/*/SKILL.md\`.
416
+ - **LangGraph / LangChain**: each node that binds an LLM (\`bind_tools\`,
417
+ \`create_react_agent\`) is an agent. Conditional edges are the routing.
418
+ - **Vercel AI SDK**: \`generateText\` / \`streamText({ system, tools, model,
419
+ maxSteps | stopWhen })\`.
420
+ - **Raw SDK calls**: the system message, \`tools=\`, and \`model=\` arguments.
421
+
422
+ ## Part 2: work out prompt switching
423
+
424
+ Work out how the harness decides which agent and prompt answers a given turn.
425
+ That becomes \`routing\` and the agents' \`handoffs\`:
426
+
427
+ - **single**: one agent always answers. Prompt versions still change over
428
+ time; Part 5 handles that.
429
+ - **handoff**: the model transfers control by calling a tool (OpenAI Agents
430
+ handoffs, a "transfer_to_*" function, or a supervisor delegating). Add
431
+ \`handoffs: [{ "to": "<agent key>", "tool": "<exact tool name>",
432
+ "description": "<when>" }]\` to the source agent.
433
+ - **router / rules**: code picks the agent (by channel, intent classifier,
434
+ conversation stage, user plan, or feature flag). Describe the rule in
435
+ \`routing.notes\` and make sure Part 5's attribution names the chosen agent
436
+ on every call.
437
+ - **trace_attribute**: the agent is already identified on every trace.
438
+ - **subagents** (agents as tools): the parent calls a tool that runs another
439
+ agent and returns its result. Register that tool's schema in \`tools[]\`,
440
+ list it in the parent's \`tools\`, and add \`subagents: [{ "agent": "<child
441
+ key>", "tool": "<tool name>" }]\`.
442
+
443
+ Set \`routing.default\` to the agent that answers when nothing else decides,
444
+ and mark the first agent a conversation reaches with \`"entry": true\`.
445
+
446
+ ## Part 3: list the injected context
447
+
448
+ For every placeholder in every prompt, and anything appended to the prompt at
449
+ runtime, add a \`context[]\` entry:
450
+
451
+ - \`source: "static"\`: the same value every time (put it in \`value\`).
452
+ - \`source: "clock"\`: the current date or time (\`format\`: date, datetime, or
453
+ iso). Replay uses the original conversation's time.
454
+ - \`source: "trace"\`: a per-user or per-conversation value, such as the
455
+ profile, account, plan, or locale.
456
+ - \`source: "memory" | "retrieval" | "tool"\`: memory recall, RAG results, or a
457
+ tool fetched before the call.
458
+ - \`target: "prompt_variable"\` (with \`variable\` set to the placeholder name,
459
+ without braces) for template variables, or \`target: "system_append"\` for
460
+ blocks the code appends.
461
+
462
+ List context keys on an agent with \`context: [...]\` when only some agents get
463
+ them.
464
+
465
+ ## Part 4: write, validate, and push the manifest
466
+
467
+ Write \`.moda/registry.json\` with \`"schema": "moda_registry.v2"\`:
468
+
469
+ - \`prompts[]\`: \`{ key, file | content | systemPrompt | messages, name,
470
+ description, variables, modelConfig, responseSchema, sourcePath }\`. If the
471
+ repo already uses \`moda prompts\` (it has a \`.moda/prompts.lock.json\`),
472
+ reuse those keys.
473
+ - \`tools[]\`: \`{ schema: { name, description, parameters } }\`, exactly as
474
+ sent to the model.
475
+ - \`skills[]\`: \`{ key, file: "path/to/SKILL.md" }\`. Set \`"live": true\` only
476
+ if I ask.
477
+ - \`agents[]\`: \`{ key, name, entry, prompt, model: { id, provider,
478
+ temperature, maxTokens, topP, reasoningEffort }, tools, skills, handoffs,
479
+ subagents, context, maxToolRoundsPerTurn, responseSchema, match: {
480
+ agentNames, promptKeys } }\`. \`match.agentNames\` are the names this agent
481
+ carries in traces; \`match.promptKeys\` are prompt keys that identify it.
482
+ - \`routing\`: \`{ strategy, default, notes }\`.
483
+ - \`runtime\`: \`{ framework, language, maxToolRoundsPerTurn,
484
+ parallelToolCalls, history: { strategy, maxMessages, maxTokens },
485
+ skillLoading: "tool" | "system_prompt" | "none" }\`.
486
+ - \`context[]\`: from Part 3.
487
+ - \`mcpServers[]\`: \`{ name, transport, url | command, tools, description }\`,
488
+ with no credentials. Their tools still go in \`tools[]\`.
489
+
490
+ Then run:
491
+
492
+ moda registry validate # local; fix every error and read every warning
493
+ moda registry push --dry-run # server-side preview, writes nothing
494
+ moda registry push --message="<what you registered or changed>"
495
+
496
+ Re-pushing an unchanged manifest is a no-op. A changed prompt adds a new
497
+ version, and a changed harness adds a new harness version.
498
+
499
+ ## Part 5: make traces name the agent and prompt version
500
+
501
+ Replay picks the agent and prompt version that handled the replayed turn from
502
+ attributes on each LLM call. Auto-instrumented calls don't carry them, so add
503
+ them at each call site from Part 1:
504
+
505
+ | What | OTel span attribute | Vercel AI SDK \`experimental_telemetry.metadata\` | \`/v1/ingest\` event field |
506
+ |---|---|---|---|
507
+ | Agent | \`gen_ai.agent.name\` (or \`moda.agent_name\`) | \`moda.agent_name\` | \`agent_name\` |
508
+ | Prompt key | \`moda.prompt_key\` | \`moda.prompt_key\` | \`prompt_name\` |
509
+ | Prompt version | \`moda.prompt_version_id\` | \`moda.prompt_version_id\` | \`prompt_version_id\` |
510
+
511
+ - The agent value must be the agent's \`key\` or one of its
512
+ \`match.agentNames\`. Frameworks that already set \`gen_ai.agent.name\`
513
+ (OpenAI Agents SDK, many OpenInference/OpenLLMetry instrumentations) only
514
+ need \`match.agentNames\` to list those names.
515
+ - Get the prompt version id from \`.moda/prompts.lock.json\` (written by
516
+ \`moda prompts sync\`), or leave it out. Replay then uses the prompt's prod
517
+ version.
518
+ - Node with the Moda SDK: \`Moda.withLLMCall(..., ({ span }) =>
519
+ span.rawSpan.setAttribute(...))\`, or pass Vercel metadata through
520
+ \`Moda.getVercelAITelemetry({ metadata })\`.
521
+ - Python: set the attributes in your OpenTelemetry instrumentation on the LLM
522
+ span, or send \`agent_name\` / \`prompt_*\` on \`/v1/ingest\` events.
523
+ - Show me the diff before committing it.
524
+
525
+ ## Part 6: verify
526
+
527
+ moda registry status
528
+ moda registry coverage --days=7
529
+ moda registry log # each push is a commit; review what changed
530
+
531
+ Coverage compares the last N days of production traces with what you
532
+ registered: the share of LLM calls naming an agent or prompt, agent names and
533
+ prompt keys that match no registered agent, tools used in production but not
534
+ registered, and registered agents never seen. Fix the manifest (or the
535
+ attribution) and push again until there is nothing unexplained left. Attribution
536
+ added in Part 5 only shows up on traces created after it ships.
537
+
538
+ ## Part 7: register the runnable harness
539
+
540
+ Replay Environments run our real harness in a sandbox with outbound network
541
+ denied, so Moda needs the code itself, not just the manifest. \`moda harness
542
+ register\` collects the git repository into a bundle. It keeps everything a
543
+ build could plausibly need and records the name and reason for everything it
544
+ leaves out: secret files, files that contain credentials, VCS internals,
545
+ installed dependencies a lockfile reproduces, build output, and paths in
546
+ \`.modaignore\`. Env var values are never read; only names are recorded.
547
+
548
+ 1. Preview it. Nothing is uploaded:
549
+
550
+ moda harness register --dry-run
551
+
552
+ 2. Review the report with me before going further:
553
+ - **Excluded**: every path left out and why. If a file a build needs was
554
+ withheld (for example \`secret_content\` on a fixture with a fake key),
555
+ tell me; don't move secrets around to get it in.
556
+ - **Git-ignored but present**: generated clients, local config, or model
557
+ files the build may need. Ask me before un-ignoring anything.
558
+ - **Environment variables**: each name with a proposed classification
559
+ (\`secret\`, \`config\`, \`dependency_endpoint\`, \`model_endpoint\`,
560
+ \`model_credential\`, \`unknown\`). Read the code for every \`unknown\`
561
+ and tell me what it is.
562
+ - **Dependencies and migrations**: the databases, queues, and APIs it
563
+ found, the adapter Moda proposes for each, and the migrations it would
564
+ run against a disposable database.
565
+ - **Gaps** and **Moda will still need from you**.
566
+ 3. Declare the entrypoint and the model-routing env vars. The entrypoint is
567
+ the exact command that starts the harness the way production does (from
568
+ the Dockerfile, Procfile, start script, or deploy config). The routing vars
569
+ are the names of the env vars that set the model base URL, the model id,
570
+ and the API key, so replay can send inference to Moda's approved lane.
571
+ Pass names, never values:
572
+
573
+ moda harness register <name> --dry-run \\
574
+ --model-base-url-env=OPENAI_BASE_URL --model-env=OPENAI_MODEL \\
575
+ --model-api-key-env=OPENAI_API_KEY \\
576
+ --entry -- node dist/server.js
577
+
578
+ \`--entry\` goes last: everything after \`--\` is the command. If the code
579
+ hardcodes the model base URL instead of reading an env var, say so in your
580
+ report; do not change it.
581
+ 4. When I approve the dry run, register for real with the same flags and no
582
+ \`--dry-run\`. It prints a registration id, a status, and a dashboard link.
583
+ Then run:
584
+
585
+ moda harness registration status <id>
586
+
587
+ \`needs_input\` means the dashboard checklist still has items for me:
588
+ confirming env classifications, approving dependency adapters and
589
+ migrations, and acknowledging gaps. List the blocking ones in your report.
590
+ I make those decisions; you don't.
591
+
592
+ ## Final report
593
+
594
+ Reply with:
595
+
596
+ 1. The agents you registered: key, prompt, model, tool count, skill count, and
597
+ handoffs.
598
+ 2. The routing strategy, and how you verified it.
599
+ 3. Context entries whose values replay can't reproduce (\`trace\`, \`memory\`,
600
+ \`retrieval\`, \`tool\`).
601
+ 4. The attribution diff (or that it's pending my OK).
602
+ 5. The \`moda registry coverage\` result, plus anything unexplained you
603
+ couldn't resolve.
604
+ 6. Anything you found but couldn't express in the manifest.
605
+ 7. The registration id and status, the entrypoint and model-routing env vars
606
+ you declared, exclusions or ignored paths a build may need, and the
607
+ checklist items still open.
608
+ `;
609
+
610
+ // src/registry.ts
611
+ var REGISTRY_SCHEMA = "moda_registry.v2";
612
+ var REGISTRY_SCHEMA_V1 = "moda_registry.v1";
613
+ var DEFAULT_REGISTRY_PATH = ".moda/registry.json";
614
+ var SKILL_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
615
+ var MAX_ITEMS_PER_SECTION = 500;
616
+ var REGISTRY_TEMPLATE = {
617
+ schema: REGISTRY_SCHEMA,
618
+ prompts: [
619
+ {
620
+ key: "support.triage",
621
+ name: "Support triage system prompt",
622
+ description: "Routes inbound support messages",
623
+ content: "You are a support triage agent for {{company_name}}. Today is {{current_date}}. Classify the request and ...",
624
+ sourcePath: "src/agents/triage/prompt.ts"
625
+ },
626
+ {
627
+ key: "support.reply",
628
+ file: "prompts/reply.md"
629
+ }
630
+ ],
631
+ skills: [
632
+ {
633
+ key: "refund-policy",
634
+ description: "How to process refund requests",
635
+ skillMd: `---
636
+ name: refund-policy
637
+ description: How to process refund requests
638
+ ---
639
+
640
+ # Refund policy
641
+ ...`
642
+ }
643
+ ],
644
+ tools: [
645
+ {
646
+ schema: {
647
+ name: "lookup_customer",
648
+ description: "Find a customer by email",
649
+ parameters: {
650
+ type: "object",
651
+ properties: { email: { type: "string" } },
652
+ required: ["email"]
653
+ }
654
+ }
655
+ }
656
+ ],
657
+ runtime: {
658
+ framework: "openai-agents",
659
+ language: "python",
660
+ maxToolRoundsPerTurn: 6,
661
+ history: { strategy: "window", maxMessages: 40 },
662
+ skillLoading: "tool"
663
+ },
664
+ agents: [
665
+ {
666
+ key: "triage",
667
+ name: "Triage",
668
+ entry: true,
669
+ prompt: "support.triage",
670
+ model: { id: "openai/gpt-5.1", temperature: 0.2, maxTokens: 1024 },
671
+ tools: ["lookup_customer"],
672
+ handoffs: [{ to: "reply", description: "Hand off once the request is classified" }],
673
+ match: { agentNames: ["TriageAgent"] }
674
+ },
675
+ {
676
+ key: "reply",
677
+ name: "Reply writer",
678
+ prompt: "support.reply",
679
+ model: { id: "anthropic/claude-sonnet-5-5", temperature: 0.4, maxTokens: 2048 },
680
+ skills: ["refund-policy"],
681
+ match: { agentNames: ["ReplyAgent"] }
682
+ }
683
+ ],
684
+ routing: { strategy: "handoff", default: "triage" },
685
+ context: [
686
+ { key: "today", variable: "current_date", source: "clock", format: "date" },
687
+ { key: "company", variable: "company_name", source: "static", value: "Acme" }
688
+ ]
689
+ };
690
+ var REGISTRY_SUBCOMMAND_FLAGS = {
691
+ template: ["out", "dry-run"],
692
+ validate: ["file", "manifest"],
693
+ push: ["file", "manifest", "live", "dry-run", "message"],
694
+ log: ["limit"],
695
+ show: [],
696
+ status: [],
697
+ coverage: ["days"],
698
+ prompt: []
699
+ };
700
+ async function runRegistryCommand(context) {
701
+ const subcommand = context.positionals[0] ?? "push";
702
+ assertFlags(`registry ${subcommand}`, context.flags, REGISTRY_SUBCOMMAND_FLAGS[subcommand] ?? []);
703
+ switch (subcommand) {
704
+ case "template":
705
+ return writeTemplate(context);
706
+ case "validate": {
707
+ const registry = loadRegistry(context);
708
+ printWarnings(context, registry.warnings);
709
+ context.output.writeData({
710
+ schema: registry.schema,
711
+ manifest: displayPath(context, registry.manifestPath),
712
+ valid: true,
713
+ counts: countsOf(registry),
714
+ warnings: registry.warnings
715
+ });
716
+ return { exitCode: 0 };
717
+ }
718
+ case "push":
719
+ return pushRegistry(context);
720
+ case "status":
721
+ return readRegistry(context, "/harness-registry");
722
+ case "log": {
723
+ const limit = context.flags.limit;
724
+ if (limit !== undefined && !/^\d+$/.test(limit)) {
725
+ throw new CliInputError("--limit must be a whole number.", "Usage: moda registry log [--limit=20]");
726
+ }
727
+ return readRegistry(context, `/registry/commits${limit ? `?limit=${limit}` : ""}`);
728
+ }
729
+ case "show": {
730
+ const ref = context.positionals[1];
731
+ if (!ref) {
732
+ throw new CliInputError("Missing commit.", 'Usage: moda registry show <commit> (a hash from `moda registry log`, or "working")');
733
+ }
734
+ return readRegistry(context, ref === "working" ? "/registry/compare?to=working" : `/registry/commits/${encodeURIComponent(ref)}`);
735
+ }
736
+ case "coverage": {
737
+ const days = context.flags.days;
738
+ if (days !== undefined && !/^\d+$/.test(days)) {
739
+ throw new CliInputError("--days must be a whole number of days.", "Usage: moda registry coverage [--days=7]");
740
+ }
741
+ return readRegistry(context, `/harness-registry/coverage${days ? `?days=${days}` : ""}`);
742
+ }
743
+ case "prompt": {
744
+ const mode = explicitOutputMode(context.flags, context.env);
745
+ if (mode && mode !== "human")
746
+ context.output.writeData({ prompt: REGISTRY_AGENT_BRIEF });
747
+ else if (!context.quiet)
748
+ process.stdout.write(`${REGISTRY_AGENT_BRIEF}
749
+ `);
750
+ return { exitCode: 0 };
751
+ }
752
+ default:
753
+ throw new CliInputError(`Unknown registry command '${subcommand}'.`, "Usage: moda registry template|validate|push|status|coverage|log|show|prompt [--file=.moda/registry.json] [--dry-run] [--live]");
754
+ }
755
+ }
756
+ function writeTemplate(context) {
757
+ const out = context.flags.out;
758
+ if (out === "true") {
759
+ throw new CliInputError("Missing output path for --out.", "Usage: moda registry template --out=.moda/registry.json");
760
+ }
761
+ if (!out) {
762
+ context.output.writeData(REGISTRY_TEMPLATE);
763
+ return { exitCode: 0 };
764
+ }
765
+ const target = resolve(context.cwd, out);
766
+ if (existsSync(target) && !context.yes) {
767
+ throw new CliInputError(`${out} already exists; refusing to overwrite.`, "Pass --yes to overwrite it.");
768
+ }
769
+ if (!context.dryRun) {
770
+ mkdirSync(dirname(target), { recursive: true });
771
+ writeFileSync(target, `${JSON.stringify(REGISTRY_TEMPLATE, null, 2)}
772
+ `);
773
+ }
774
+ context.output.writeData({ written: context.dryRun ? [] : [displayPath(context, target)], dryRun: context.dryRun });
775
+ return { exitCode: 0 };
776
+ }
777
+ async function pushRegistry(context) {
778
+ const message = context.flags.message;
779
+ if (message === "true")
780
+ throw new CliInputError("Missing text for --message.", 'Usage: moda registry push --message="Add billing agent"');
781
+ const registry = loadRegistry(context);
782
+ const profileOptions = { profile: context.profile, env: context.env };
783
+ const hasKey = Boolean(resolveApiKey(profileOptions));
784
+ if (!hasKey && !context.dryRun) {
785
+ throw new CliInputError("No Moda API key found for registry push.", "Set MODA_API_KEY, run `moda auth login --api-key=<key>`, or select a profile with an API key.");
786
+ }
787
+ const sections = [];
788
+ const push = async (section, endpoint, count, body) => {
789
+ if (count === 0) {
790
+ sections.push({ section, status: "skipped", count, endpoint });
791
+ return;
792
+ }
793
+ if (context.dryRun && !hasKey) {
794
+ sections.push({ section, status: "validated", count, endpoint });
795
+ return;
796
+ }
797
+ try {
798
+ const response = await callControlAPI(endpoint, { method: "POST", body: JSON.stringify({ ...body, ...context.dryRun ? { dryRun: true } : {} }) }, profileOptions);
799
+ sections.push({ section, status: context.dryRun ? "dry_run" : "synced", count, endpoint, response });
800
+ } catch (error) {
801
+ sections.push({
802
+ section,
803
+ status: "error",
804
+ count,
805
+ endpoint,
806
+ error: error instanceof Error ? error.message : String(error)
807
+ });
808
+ }
809
+ };
810
+ await push("prompts", "/prompts/sync", registry.prompts.length, { prompts: registry.prompts });
811
+ await push("skills", "/skills/sync", registry.skills.length, { skills: registry.skills });
812
+ await push("tools", "/tool-definitions/sync", registry.tools.length, { tools: registry.tools });
813
+ if (registry.harness) {
814
+ await push("harness", "/harness-registry/sync", 1, { schema: registry.schema, document: registry.harness });
815
+ }
816
+ let commit;
817
+ let commitError;
818
+ if (!context.dryRun && sections.some((section) => section.status === "synced")) {
819
+ try {
820
+ commit = await callControlAPI("/registry/commits", { method: "POST", body: JSON.stringify({ ...message ? { message } : {}, ...gitInfo(context.cwd) }) }, profileOptions);
821
+ } catch (error) {
822
+ commitError = error instanceof Error ? error.message : String(error);
823
+ }
824
+ }
825
+ const failed = sections.filter((section) => section.status === "error");
826
+ printWarnings(context, registry.warnings);
827
+ if (context.outputMode === "human" && !context.quiet) {
828
+ for (const section of sections) {
829
+ const detail = section.status === "error" ? ` — ${section.error}` : describeActions(section.response);
830
+ console.error(` ${section.section}: ${section.status} (${section.count})${detail}`);
831
+ }
832
+ if (sections.some((section) => section.section === "tools" && section.status === "synced")) {
833
+ console.error(" Replays now give your agent these tools with their real schemas and descriptions.");
834
+ }
835
+ if (sections.some((section) => section.section === "harness" && section.status === "synced")) {
836
+ console.error(" Replays now rebuild the agent live at each replayed turn. Check `moda registry coverage`.");
837
+ }
838
+ const recorded = isRecord2(commit) && isRecord2(commit.commit) ? commit.commit : undefined;
839
+ if (recorded) {
840
+ const label = commit && isRecord2(commit) && commit.action === "unchanged" ? "no registry changes since" : "commit";
841
+ console.error(` ${label} ${String(recorded.id).slice(0, 7)} (#${String(recorded.seq)}) ${String(recorded.summary ?? "")}`);
842
+ } else if (commitError) {
843
+ console.error(` commit: not recorded — ${commitError}`);
844
+ }
845
+ }
846
+ context.output.writeData({
847
+ schema: registry.schema,
848
+ manifest: displayPath(context, registry.manifestPath),
849
+ dryRun: context.dryRun,
850
+ counts: countsOf(registry),
851
+ sections,
852
+ warnings: registry.warnings,
853
+ ...commit !== undefined ? { commit } : {},
854
+ ...commitError ? { commitError } : {}
855
+ }, {
856
+ status: failed.length > 0 ? "error" : "success",
857
+ ...failed.length > 0 ? {
858
+ errors: failed.map((section) => ({
859
+ code: "registry_section_failed",
860
+ message: `${section.section}: ${section.error}`,
861
+ blocking: false
862
+ }))
863
+ } : {}
864
+ });
865
+ return { exitCode: failed.length > 0 ? 1 : 0 };
866
+ }
867
+ function gitInfo(cwd) {
868
+ const git = (...args) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).toString().trim();
869
+ try {
870
+ const gitCommit = git("rev-parse", "HEAD");
871
+ const branch = git("rev-parse", "--abbrev-ref", "HEAD");
872
+ return {
873
+ gitCommit,
874
+ ...branch && branch !== "HEAD" ? { gitBranch: branch } : {},
875
+ gitDirty: git("status", "--porcelain").length > 0
876
+ };
877
+ } catch {
878
+ return {};
879
+ }
880
+ }
881
+ async function readRegistry(context, endpoint) {
882
+ const profileOptions = { profile: context.profile, env: context.env };
883
+ if (!resolveApiKey(profileOptions)) {
884
+ throw new CliInputError("No Moda API key found.", "Set MODA_API_KEY, run `moda auth login --api-key=<key>`, or select a profile with an API key.");
885
+ }
886
+ context.output.writeData(await callControlAPI(endpoint, { method: "GET" }, profileOptions));
887
+ return { exitCode: 0 };
888
+ }
889
+ function printWarnings(context, warnings) {
890
+ if (context.outputMode !== "human" || context.quiet)
891
+ return;
892
+ for (const warning of warnings)
893
+ console.error(` warning: ${warning}`);
894
+ }
895
+ function describeActions(response) {
896
+ const synced = isRecord2(response) && Array.isArray(response.synced) ? response.synced : [];
897
+ const counts = new Map;
898
+ for (const item of synced) {
899
+ if (!isRecord2(item))
900
+ continue;
901
+ const action = typeof item.action === "string" ? `${item.action}${typeof item.reason === "string" ? ` (${item.reason})` : ""}` : item.changed === false ? "unchanged" : item.policyChanged === true && item.changed === true ? "changed (live setting)" : item.changed === true ? "changed" : "synced";
902
+ counts.set(action, (counts.get(action) ?? 0) + 1);
903
+ }
904
+ return counts.size > 0 ? ` — ${[...counts].map(([action, count]) => `${count} ${action}`).join(", ")}` : "";
905
+ }
906
+ function loadRegistry(context) {
907
+ const fileFlag = context.flags.file ?? context.flags.manifest;
908
+ if (fileFlag === "true") {
909
+ throw new CliInputError("Missing path for --file.", "Usage: moda registry push --file=.moda/registry.json");
910
+ }
911
+ const manifestPath = resolve(context.cwd, fileFlag ?? DEFAULT_REGISTRY_PATH);
912
+ if (!existsSync(manifestPath)) {
913
+ throw new CliInputError(`Registry manifest not found: ${fileFlag ?? DEFAULT_REGISTRY_PATH}`, "Create one with `moda registry template --out=.moda/registry.json`, fill it in, then rerun.");
914
+ }
915
+ let parsed;
916
+ try {
917
+ parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
918
+ } catch (error) {
919
+ throw new CliInputError(`Registry manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
920
+ }
921
+ return resolveRegistry(parsed, {
922
+ manifestPath,
923
+ cwd: context.cwd,
924
+ forceLive: context.flags.live === "true"
925
+ });
926
+ }
927
+ var PROMPT_FIELDS = {
928
+ key: "string",
929
+ name: "string",
930
+ description: "string",
931
+ category: "string",
932
+ content: "string",
933
+ systemPrompt: "string",
934
+ messages: "array",
935
+ variables: "array",
936
+ variablesSchema: "object",
937
+ modelConfig: "object",
938
+ toolRefs: "array",
939
+ responseSchema: "object",
940
+ sourcePath: "string",
941
+ file: "string"
942
+ };
943
+ var SKILL_FIELDS = {
944
+ key: "string",
945
+ name: "string",
946
+ description: "string",
947
+ skillMd: "string",
948
+ sourcePath: "string",
949
+ file: "string",
950
+ live: "boolean"
951
+ };
952
+ var TOOL_FIELDS = {
953
+ name: "string",
954
+ description: "string",
955
+ schema: "object",
956
+ isActive: "boolean"
957
+ };
958
+ var MAX_SKILL_KEY_LENGTH = 256;
959
+ var MAX_SKILL_BYTES = 1e6;
960
+ function checkFields(label, item, spec, errors) {
961
+ for (const [key, value] of Object.entries(item)) {
962
+ const expected = spec[key];
963
+ if (!expected) {
964
+ errors.push(`${label}: unknown field "${key}" (allowed: ${Object.keys(spec).join(", ")})`);
965
+ continue;
966
+ }
967
+ const actual = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
968
+ if (actual !== expected)
969
+ errors.push(`${label}.${key} must be ${expected === "array" || expected === "object" ? "an" : "a"} ${expected} (got ${actual})`);
970
+ }
971
+ }
972
+ function resolveRegistry(input, options) {
973
+ const errors = [];
974
+ const warnings = [];
975
+ if (!isRecord2(input)) {
976
+ throw new CliInputError("Registry manifest must be a JSON object with prompts, skills, and/or tools arrays.");
977
+ }
978
+ if (input.schema !== undefined && input.schema !== REGISTRY_SCHEMA && input.schema !== REGISTRY_SCHEMA_V1) {
979
+ errors.push(`schema must be "${REGISTRY_SCHEMA}" or "${REGISTRY_SCHEMA_V1}" (got ${JSON.stringify(input.schema)})`);
980
+ }
981
+ if (input.schema === REGISTRY_SCHEMA_V1 && hasHarnessSections(input)) {
982
+ errors.push(`${HARNESS_SECTIONS.join(", ")} need schema "${REGISTRY_SCHEMA}"`);
983
+ }
984
+ const known = new Set(["schema", "prompts", "skills", "tools", ...HARNESS_SECTIONS]);
985
+ for (const key of Object.keys(input)) {
986
+ if (!known.has(key))
987
+ errors.push(`unknown top-level field "${key}"`);
988
+ }
989
+ const readRef = (label, file) => {
990
+ if (typeof file !== "string" || file.trim() === "") {
991
+ errors.push(`${label}.file must be a non-empty string`);
992
+ return;
993
+ }
994
+ const target = resolve(options.cwd, file);
995
+ if (!isInside(options.cwd, target)) {
996
+ errors.push(`${label}.file "${file}" resolves outside the project directory`);
997
+ return;
998
+ }
999
+ if (!existsSync(target)) {
1000
+ errors.push(`${label}.file "${file}" does not exist`);
1001
+ return;
1002
+ }
1003
+ if (!isInside(realpathSync(options.cwd), realpathSync(target))) {
1004
+ errors.push(`${label}.file "${file}" is a symlink to outside the project directory`);
1005
+ return;
1006
+ }
1007
+ if (!statSync(target).isFile()) {
1008
+ errors.push(`${label}.file "${file}" is not a file`);
1009
+ return;
1010
+ }
1011
+ return readFileSync(target, "utf8");
1012
+ };
1013
+ const sourcePathFor = (file) => relative(options.cwd, resolve(options.cwd, file)).split("\\").join("/");
1014
+ const prompts = arrayOf(input.prompts, "prompts", errors).map((raw, index) => {
1015
+ const label = `prompts[${index}]`;
1016
+ if (!isRecord2(raw)) {
1017
+ errors.push(`${label} must be an object`);
1018
+ return;
1019
+ }
1020
+ checkFields(label, raw, PROMPT_FIELDS, errors);
1021
+ const { file, ...rest } = raw;
1022
+ if (!nonEmptyString(rest.key))
1023
+ errors.push(`${label}.key is required`);
1024
+ const prompt = { ...rest };
1025
+ if (file !== undefined) {
1026
+ if (rest.content !== undefined)
1027
+ errors.push(`${label}: set content or file, not both`);
1028
+ const content = readRef(label, file);
1029
+ if (content !== undefined)
1030
+ prompt.content = content;
1031
+ if (typeof file === "string" && !prompt.sourcePath)
1032
+ prompt.sourcePath = sourcePathFor(file);
1033
+ }
1034
+ if (!nonEmptyString(prompt.content) && !nonEmptyString(prompt.systemPrompt) && !(Array.isArray(prompt.messages) && prompt.messages.length > 0)) {
1035
+ errors.push(`${label}: one of content, file, systemPrompt, or messages is required`);
1036
+ }
1037
+ return prompt;
1038
+ });
1039
+ const skills = arrayOf(input.skills, "skills", errors).map((raw, index) => {
1040
+ const label = `skills[${index}]`;
1041
+ if (!isRecord2(raw)) {
1042
+ errors.push(`${label} must be an object`);
1043
+ return;
1044
+ }
1045
+ checkFields(label, raw, SKILL_FIELDS, errors);
1046
+ const skill = raw;
1047
+ if (!nonEmptyString(skill.key)) {
1048
+ errors.push(`${label}.key is required`);
1049
+ } else if (!SKILL_KEY_PATTERN.test(skill.key)) {
1050
+ errors.push(`${label}.key "${skill.key}" may only contain letters, digits, "_" and "-"`);
1051
+ } else if (skill.key.length > MAX_SKILL_KEY_LENGTH) {
1052
+ errors.push(`${label}.key must be ${MAX_SKILL_KEY_LENGTH} characters or fewer`);
1053
+ }
1054
+ let skillMd = skill.skillMd;
1055
+ let sourcePath = skill.sourcePath;
1056
+ if (skill.file !== undefined) {
1057
+ if (skill.skillMd !== undefined)
1058
+ errors.push(`${label}: set skillMd or file, not both`);
1059
+ skillMd = readRef(label, skill.file);
1060
+ if (typeof skill.file === "string" && !sourcePath)
1061
+ sourcePath = sourcePathFor(skill.file);
1062
+ }
1063
+ if (!nonEmptyString(skillMd)) {
1064
+ errors.push(`${label}: skillMd or file is required`);
1065
+ } else if (Buffer.byteLength(skillMd, "utf8") > MAX_SKILL_BYTES) {
1066
+ errors.push(`${label}: skill content exceeds ${MAX_SKILL_BYTES} bytes`);
1067
+ }
1068
+ if (skill.live !== undefined && typeof skill.live !== "boolean")
1069
+ errors.push(`${label}.live must be a boolean`);
1070
+ const live = options.forceLive || skill.live === true;
1071
+ return {
1072
+ key: skill.key,
1073
+ ...skill.name !== undefined ? { name: skill.name } : {},
1074
+ ...skill.description !== undefined ? { description: skill.description } : {},
1075
+ skillMd: skillMd ?? "",
1076
+ ...sourcePath !== undefined ? { sourcePath } : {},
1077
+ ...live ? { surfacePolicy: "include" } : skill.live === false ? { surfacePolicy: "local_only" } : {}
1078
+ };
1079
+ });
1080
+ const toolNames = new Set;
1081
+ const tools = arrayOf(input.tools, "tools", errors).map((raw, index) => {
1082
+ const label = `tools[${index}]`;
1083
+ if (!isRecord2(raw) || !isRecord2(raw.schema)) {
1084
+ errors.push(`${label}.schema must be an object (OpenAI function schema)`);
1085
+ return;
1086
+ }
1087
+ checkFields(label, raw, TOOL_FIELDS, errors);
1088
+ const tool = raw;
1089
+ const inner = tool.schema.type === "function" && isRecord2(tool.schema.function) ? tool.schema.function : tool.schema;
1090
+ const schemaName = typeof inner.name === "string" ? inner.name.trim() : undefined;
1091
+ const name = (typeof tool.name === "string" ? tool.name.trim() : "") || schemaName;
1092
+ if (typeof tool.name === "string" && tool.name.trim() && schemaName && tool.name.trim() !== schemaName) {
1093
+ errors.push(`${label}: name "${tool.name}" does not match schema.name "${schemaName}"`);
1094
+ }
1095
+ if (!nonEmptyString(name)) {
1096
+ errors.push(`${label}: name or schema.name is required`);
1097
+ } else if (toolNames.has(name)) {
1098
+ errors.push(`${label}: duplicate tool name "${name}"`);
1099
+ } else {
1100
+ toolNames.add(name);
1101
+ }
1102
+ if (inner.name !== undefined && typeof inner.name !== "string")
1103
+ errors.push(`${label}: schema.name must be a string`);
1104
+ if (inner.description !== undefined && typeof inner.description !== "string") {
1105
+ errors.push(`${label}: schema.description must be a string`);
1106
+ }
1107
+ if (inner.parameters !== undefined) {
1108
+ if (!isRecord2(inner.parameters) || inner.parameters.type !== "object") {
1109
+ errors.push(`${label}: schema.parameters.type must be "object"`);
1110
+ } else if (inner.parameters.properties !== undefined && !isRecord2(inner.parameters.properties)) {
1111
+ errors.push(`${label}: schema.parameters.properties must be an object`);
1112
+ }
1113
+ }
1114
+ return tool;
1115
+ });
1116
+ const promptKeys = new Set;
1117
+ for (const prompt of prompts) {
1118
+ if (prompt && nonEmptyString(prompt.key)) {
1119
+ if (promptKeys.has(prompt.key))
1120
+ errors.push(`duplicate prompt key "${prompt.key}"`);
1121
+ promptKeys.add(prompt.key);
1122
+ }
1123
+ }
1124
+ const skillKeys = new Set;
1125
+ for (const skill of skills) {
1126
+ if (skill && nonEmptyString(skill.key)) {
1127
+ if (skillKeys.has(skill.key))
1128
+ errors.push(`duplicate skill key "${skill.key}"`);
1129
+ skillKeys.add(skill.key);
1130
+ }
1131
+ }
1132
+ const harness = validateHarnessSections(input, { prompts: promptKeys, tools: toolNames, skills: skillKeys }, errors, warnings);
1133
+ if (errors.length > 0) {
1134
+ throw new CliInputError(`Registry manifest is invalid:
1135
+ - ${errors.join(`
1136
+ - `)}`, "Fix the manifest and rerun `moda registry validate`. See `moda registry template` for the format.");
1137
+ }
1138
+ const resolved = {
1139
+ manifestPath: options.manifestPath,
1140
+ schema: typeof input.schema === "string" ? input.schema : harness ? REGISTRY_SCHEMA : REGISTRY_SCHEMA_V1,
1141
+ prompts: prompts.filter(isDefined),
1142
+ skills: skills.filter(isDefined),
1143
+ tools: tools.filter(isDefined),
1144
+ ...harness ? { harness } : {},
1145
+ warnings
1146
+ };
1147
+ if (resolved.prompts.length + resolved.skills.length + resolved.tools.length === 0 && !harness) {
1148
+ throw new CliInputError("Registry manifest has no prompts, skills, tools, or agents to push.");
1149
+ }
1150
+ return resolved;
1151
+ }
1152
+ function arrayOf(value, label, errors) {
1153
+ if (value === undefined)
1154
+ return [];
1155
+ if (!Array.isArray(value)) {
1156
+ errors.push(`${label} must be an array`);
1157
+ return [];
1158
+ }
1159
+ if (value.length > MAX_ITEMS_PER_SECTION) {
1160
+ errors.push(`${label} has ${value.length} entries; the limit is ${MAX_ITEMS_PER_SECTION} per push`);
1161
+ }
1162
+ return value;
1163
+ }
1164
+ function countsOf(registry) {
1165
+ return {
1166
+ prompts: registry.prompts.length,
1167
+ skills: registry.skills.length,
1168
+ tools: registry.tools.length,
1169
+ ...registry.harness ? {
1170
+ agents: registry.harness.agents?.length ?? 0,
1171
+ context: registry.harness.context?.length ?? 0,
1172
+ mcpServers: registry.harness.mcpServers?.length ?? 0
1173
+ } : {}
1174
+ };
1175
+ }
1176
+ function displayPath(context, path) {
1177
+ const rel = relative(context.cwd, path);
1178
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel.split("\\").join("/") : path;
1179
+ }
1180
+ function isInside(root, target) {
1181
+ const rel = relative(resolve(root), target);
1182
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
1183
+ }
1184
+ function isRecord2(value) {
1185
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1186
+ }
1187
+ function nonEmptyString(value) {
1188
+ return typeof value === "string" && value.trim() !== "";
1189
+ }
1190
+ function isDefined(value) {
1191
+ return value !== undefined;
1192
+ }
1193
+ export {
1194
+ runRegistryCommand,
1195
+ resolveRegistry,
1196
+ loadRegistry,
1197
+ REGISTRY_TEMPLATE,
1198
+ REGISTRY_SCHEMA_V1,
1199
+ REGISTRY_SCHEMA,
1200
+ DEFAULT_REGISTRY_PATH
1201
+ };