@nyxa/nyx-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # NyxAgent
2
+
3
+ NyxAgent is a small TypeScript CLI that runs coding-agent phases with fresh
4
+ context for each phase.
5
+
6
+ Current commands:
7
+
8
+ ```bash
9
+ nyxagent init
10
+ nyxagent init --missing
11
+ nyxagent run
12
+ ```
13
+
14
+ See [docs/nyxagent-v0-spec.md](docs/nyxagent-v0-spec.md) for the v0 design.
15
+ # nyxagent
package/dist/cli.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import pc from "picocolors";
4
+ import { initCommand } from "./commands/init.js";
5
+ import { runCommand } from "./commands/run.js";
6
+ const program = new Command();
7
+ program
8
+ .name("nyxagent")
9
+ .description("Run coding-agent workflows with fresh context per phase.")
10
+ .version("0.1.0");
11
+ program
12
+ .command("init")
13
+ .description("Create a .nyxagent project configuration")
14
+ .option("--missing", "only add missing template files")
15
+ .option("--harness <preset>", "harness preset: codex, claude, or custom")
16
+ .option("--model <name>", "model name")
17
+ .option("--reasoning-level <level>", "harness-neutral reasoning level")
18
+ .option("--max-iterations <count>", "maximum work items per run")
19
+ .option("--work-items-source <source>", "work item source template: local-markdown, github, or custom")
20
+ .option("--work-items-path <path>", "local markdown work item directory")
21
+ .action(async (options) => {
22
+ await initCommand(options);
23
+ });
24
+ program
25
+ .command("run")
26
+ .description("Run the configured NyxAgent workflow")
27
+ .option("--config <path>", "config path, defaults to .nyxagent/config.toml")
28
+ .action(async (options) => {
29
+ await runCommand(options);
30
+ });
31
+ await program.parseAsync().catch((error) => {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ console.error(pc.red(`Error: ${message}`));
34
+ process.exitCode = 1;
35
+ });
@@ -0,0 +1,265 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { input, number as numberPrompt, select } from "@inquirer/prompts";
5
+ import pc from "picocolors";
6
+ import { ensureDir, pathExists, readText, writeText } from "../runtime/files.js";
7
+ import { getNyxDir, relativeToProject } from "../runtime/paths.js";
8
+ export async function initCommand(options, projectRoot = process.cwd()) {
9
+ const root = path.resolve(projectRoot);
10
+ const nyxDir = getNyxDir(root);
11
+ const exists = await pathExists(nyxDir);
12
+ if (exists && !options.missing) {
13
+ throw new Error(`.nyxagent already exists. Use "nyxagent init --missing" to add missing template files.`);
14
+ }
15
+ const configPath = path.join(nyxDir, "config.toml");
16
+ const shouldCreateConfig = !options.missing || !(await pathExists(configPath));
17
+ const resolved = shouldCreateConfig
18
+ ? await resolveInitOptions(options, root)
19
+ : undefined;
20
+ await ensureDir(nyxDir);
21
+ const templatesDir = getTemplatesDir();
22
+ await copyTemplateTree(templatesDir, nyxDir, Boolean(options.missing));
23
+ if (resolved) {
24
+ await writeText(configPath, buildConfigToml(resolved));
25
+ }
26
+ if (resolved?.workItemsSource === "local-markdown" && resolved.workItemsPath) {
27
+ await maybeCreateSampleTask(root, resolved.workItemsPath, Boolean(options.missing));
28
+ }
29
+ console.log(pc.green("NyxAgent initialized."));
30
+ console.log(`Config: ${relativeToProject(root, configPath)}`);
31
+ }
32
+ async function resolveInitOptions(options, root) {
33
+ const harness = options.harness ??
34
+ (await select({
35
+ message: "Harness preset",
36
+ choices: [
37
+ { name: "codex", value: "codex" },
38
+ { name: "claude", value: "claude" },
39
+ { name: "custom", value: "custom" }
40
+ ]
41
+ }));
42
+ const model = options.model ??
43
+ (await input({
44
+ message: "Model",
45
+ default: harness === "codex" ? "gpt-5-codex" : ""
46
+ }));
47
+ const reasoningLevel = options.reasoningLevel ??
48
+ (await input({
49
+ message: "Reasoning level",
50
+ default: "medium"
51
+ }));
52
+ const parsedMaxIterations = options.maxIterations
53
+ ? Number.parseInt(options.maxIterations, 10)
54
+ : undefined;
55
+ const maxIterations = parsedMaxIterations ??
56
+ (await numberPrompt({
57
+ message: "Max iterations",
58
+ default: 5,
59
+ required: true
60
+ }));
61
+ const workItemsSource = options.workItemsSource ??
62
+ (await select({
63
+ message: "Work item source template",
64
+ choices: [
65
+ { name: "local-markdown", value: "local-markdown" },
66
+ { name: "github", value: "github" },
67
+ { name: "custom", value: "custom" }
68
+ ]
69
+ }));
70
+ let workItemsPath = options.workItemsPath;
71
+ if (workItemsSource === "local-markdown" && !workItemsPath) {
72
+ const issuesPath = path.join(root, "issues");
73
+ workItemsPath = await input({
74
+ message: "Local task path",
75
+ default: (await pathExists(issuesPath)) ? "issues" : ".nyxagent/tasks"
76
+ });
77
+ }
78
+ if (!Number.isInteger(maxIterations) || maxIterations <= 0) {
79
+ throw new Error("max iterations must be a positive integer");
80
+ }
81
+ return {
82
+ harness,
83
+ model,
84
+ reasoningLevel,
85
+ maxIterations,
86
+ workItemsSource,
87
+ workItemsPath
88
+ };
89
+ }
90
+ function getTemplatesDir() {
91
+ const currentFile = fileURLToPath(import.meta.url);
92
+ return path.resolve(path.dirname(currentFile), "../../templates/default");
93
+ }
94
+ async function copyTemplateTree(sourceDir, destinationDir, missingOnly) {
95
+ await ensureDir(destinationDir);
96
+ const entries = await readdir(sourceDir, { withFileTypes: true });
97
+ for (const entry of entries) {
98
+ const source = path.join(sourceDir, entry.name);
99
+ const destination = path.join(destinationDir, entry.name);
100
+ if (entry.isDirectory()) {
101
+ await copyTemplateTree(source, destination, missingOnly);
102
+ continue;
103
+ }
104
+ if (missingOnly && (await pathExists(destination))) {
105
+ continue;
106
+ }
107
+ await writeText(destination, await readText(source));
108
+ }
109
+ }
110
+ async function maybeCreateSampleTask(root, taskPath, missingOnly) {
111
+ const absoluteTaskPath = path.resolve(root, taskPath);
112
+ await ensureDir(absoluteTaskPath);
113
+ const sampleTaskPath = path.join(absoluteTaskPath, "TASK-0001.md");
114
+ if (missingOnly && (await pathExists(sampleTaskPath))) {
115
+ return;
116
+ }
117
+ const taskPathStat = await stat(absoluteTaskPath);
118
+ if (!taskPathStat.isDirectory()) {
119
+ throw new Error(`Work item path is not a directory: ${taskPath}`);
120
+ }
121
+ if (!(await pathExists(sampleTaskPath))) {
122
+ await writeText(sampleTaskPath, [
123
+ "---",
124
+ "nyx_id: TASK-0001",
125
+ "status: open",
126
+ "title: Replace this sample task",
127
+ "---",
128
+ "",
129
+ "# Replace this sample task",
130
+ "",
131
+ "Describe the task here.",
132
+ ""
133
+ ].join("\n"));
134
+ }
135
+ }
136
+ function buildConfigToml(options) {
137
+ const harness = buildHarnessToml(options.harness);
138
+ const workItems = buildWorkItemsToml(options);
139
+ return `[workflow]
140
+ entry_phase = "selection"
141
+ max_iterations = ${options.maxIterations}
142
+
143
+ [model]
144
+ name = "${escapeTomlString(options.model)}"
145
+ reasoning_level = "${escapeTomlString(options.reasoningLevel)}"
146
+
147
+ ${harness}
148
+
149
+ [repair]
150
+ max_attempts = 1
151
+ prompt = "prompts/repair-result.md"
152
+
153
+ ${workItems}
154
+
155
+ [[phases]]
156
+ id = "selection"
157
+ prompt = "prompts/selection.md"
158
+ output_schema = "schemas/selection.schema.json"
159
+ required_output = true
160
+ max_visits_per_iteration = 1
161
+
162
+ [phases.harness]
163
+ args = ${formatTomlArray(buildReadOnlyArgs(options.harness))}
164
+
165
+ [phases.transitions]
166
+ selected = "execution"
167
+ no_work = "stop_run"
168
+
169
+ [[phases]]
170
+ id = "execution"
171
+ prompt = "prompts/execution.md"
172
+ next = "review"
173
+ max_visits_per_iteration = 3
174
+
175
+ [phases.model]
176
+ reasoning_level = "high"
177
+
178
+ [[phases]]
179
+ id = "review"
180
+ prompt = "prompts/review.md"
181
+ output_schema = "schemas/review.schema.json"
182
+ required_output = true
183
+ max_visits_per_iteration = 3
184
+
185
+ [phases.model]
186
+ reasoning_level = "high"
187
+
188
+ [phases.harness]
189
+ args = ${formatTomlArray(buildReadOnlyArgs(options.harness))}
190
+
191
+ [phases.transitions]
192
+ approved = "closure"
193
+ changes_requested = "execution"
194
+
195
+ [[phases]]
196
+ id = "closure"
197
+ prompt = "prompts/closure.md"
198
+ next = "next_iteration"
199
+ max_visits_per_iteration = 1
200
+ `;
201
+ }
202
+ function buildHarnessToml(harness) {
203
+ if (harness === "codex") {
204
+ return `[harness]
205
+ preset = "codex"
206
+ command = "codex"
207
+ args = ${formatTomlArray(buildCodexArgs(false))}
208
+ prompt_input = "stdin"`;
209
+ }
210
+ if (harness === "claude") {
211
+ return `[harness]
212
+ preset = "claude"
213
+ command = "claude"
214
+ args = ["--model", "{{model.name}}"]
215
+ prompt_input = "stdin"`;
216
+ }
217
+ return `[harness]
218
+ preset = "custom"
219
+ command = "your-agent-command"
220
+ args = []
221
+ prompt_input = "stdin"`;
222
+ }
223
+ function buildReadOnlyArgs(harness) {
224
+ if (harness === "codex") {
225
+ return buildCodexArgs(true);
226
+ }
227
+ if (harness === "claude") {
228
+ return ["--model", "{{model.name}}"];
229
+ }
230
+ return [];
231
+ }
232
+ function buildCodexArgs(readOnly) {
233
+ const args = [
234
+ "exec",
235
+ "--model",
236
+ "{{model.name}}",
237
+ "-c",
238
+ 'model_reasoning_effort="{{model.reasoning_level}}"'
239
+ ];
240
+ if (readOnly) {
241
+ args.push("--sandbox", "read-only");
242
+ }
243
+ args.push("-");
244
+ return args;
245
+ }
246
+ function buildWorkItemsToml(options) {
247
+ if (options.workItemsSource === "local-markdown") {
248
+ return `[work_items]
249
+ source = "local-markdown"
250
+ path = "${escapeTomlString(options.workItemsPath ?? ".nyxagent/tasks")}"`;
251
+ }
252
+ if (options.workItemsSource === "github") {
253
+ return `[work_items]
254
+ source = "github"
255
+ repository = ""`;
256
+ }
257
+ return `[work_items]
258
+ source = "custom"`;
259
+ }
260
+ function formatTomlArray(values) {
261
+ return `[${values.map((value) => `"${escapeTomlString(value)}"`).join(", ")}]`;
262
+ }
263
+ function escapeTomlString(value) {
264
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
265
+ }
@@ -0,0 +1,8 @@
1
+ import path from "node:path";
2
+ import { runWorkflow } from "../runtime/runWorkflow.js";
3
+ export async function runCommand(options, projectRoot = process.cwd()) {
4
+ await runWorkflow({
5
+ projectRoot,
6
+ configPath: options.config ? path.resolve(projectRoot, options.config) : undefined
7
+ });
8
+ }
@@ -0,0 +1,8 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse } from "smol-toml";
3
+ import { nyxConfigSchema } from "./schema.js";
4
+ export async function loadConfig(configPath) {
5
+ const raw = await readFile(configPath, "utf8");
6
+ const parsed = parse(raw);
7
+ return nyxConfigSchema.parse(parsed);
8
+ }
@@ -0,0 +1,97 @@
1
+ import { z } from "zod";
2
+ const modelSchema = z
3
+ .object({
4
+ name: z.string().min(1),
5
+ reasoning_level: z.string().min(1).default("medium")
6
+ })
7
+ .passthrough();
8
+ const modelOverrideSchema = modelSchema.partial().passthrough();
9
+ const harnessSchema = z
10
+ .object({
11
+ preset: z.string().min(1).optional(),
12
+ command: z.string().min(1),
13
+ args: z.array(z.string()).default([]),
14
+ prompt_input: z.literal("stdin").default("stdin")
15
+ })
16
+ .passthrough();
17
+ const harnessOverrideSchema = harnessSchema.partial().passthrough();
18
+ const phaseSchema = z
19
+ .object({
20
+ id: z.string().min(1),
21
+ prompt: z.string().min(1),
22
+ output_schema: z.string().min(1).optional(),
23
+ required_output: z.boolean().default(false),
24
+ max_visits_per_iteration: z.number().int().positive().default(1),
25
+ next: z.string().min(1).optional(),
26
+ transitions: z.record(z.string(), z.string()).optional(),
27
+ model: modelOverrideSchema.optional(),
28
+ harness: harnessOverrideSchema.optional()
29
+ })
30
+ .passthrough();
31
+ export const nyxConfigSchema = z
32
+ .object({
33
+ workflow: z.object({
34
+ entry_phase: z.string().min(1),
35
+ max_iterations: z.number().int().positive()
36
+ }),
37
+ model: modelSchema,
38
+ harness: harnessSchema,
39
+ repair: z
40
+ .object({
41
+ max_attempts: z.number().int().nonnegative().default(1),
42
+ prompt: z.string().min(1).default("prompts/repair-result.md")
43
+ })
44
+ .default({
45
+ max_attempts: 1,
46
+ prompt: "prompts/repair-result.md"
47
+ }),
48
+ work_items: z.record(z.string(), z.unknown()).optional(),
49
+ phases: z.array(phaseSchema).min(1)
50
+ })
51
+ .superRefine((config, ctx) => {
52
+ const phaseIds = new Set();
53
+ for (const [index, phase] of config.phases.entries()) {
54
+ if (phaseIds.has(phase.id)) {
55
+ ctx.addIssue({
56
+ code: "custom",
57
+ path: ["phases", index, "id"],
58
+ message: `Duplicate phase id "${phase.id}"`
59
+ });
60
+ }
61
+ phaseIds.add(phase.id);
62
+ if (phase.next && phase.transitions) {
63
+ ctx.addIssue({
64
+ code: "custom",
65
+ path: ["phases", index],
66
+ message: `Phase "${phase.id}" cannot define both next and transitions`
67
+ });
68
+ }
69
+ }
70
+ if (!phaseIds.has(config.workflow.entry_phase)) {
71
+ ctx.addIssue({
72
+ code: "custom",
73
+ path: ["workflow", "entry_phase"],
74
+ message: `Unknown entry phase "${config.workflow.entry_phase}"`
75
+ });
76
+ }
77
+ const reservedTargets = new Set([
78
+ "stop_run",
79
+ "stop_iteration",
80
+ "next_iteration"
81
+ ]);
82
+ for (const [index, phase] of config.phases.entries()) {
83
+ const targets = [
84
+ phase.next,
85
+ ...Object.values(phase.transitions ?? {})
86
+ ].filter((target) => Boolean(target));
87
+ for (const target of targets) {
88
+ if (!reservedTargets.has(target) && !phaseIds.has(target)) {
89
+ ctx.addIssue({
90
+ code: "custom",
91
+ path: ["phases", index],
92
+ message: `Phase "${phase.id}" points to unknown target "${target}"`
93
+ });
94
+ }
95
+ }
96
+ }
97
+ });
@@ -0,0 +1,57 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolveNyxPath } from "./paths.js";
3
+ import { renderTemplate } from "./renderTemplate.js";
4
+ export async function buildPhasePrompt(input) {
5
+ const promptPath = resolveNyxPath(input.projectRoot, input.phase.prompt, `prompt for phase "${input.phase.id}"`);
6
+ const promptTemplate = await readFile(promptPath, "utf8");
7
+ const userPrompt = renderTemplate(promptTemplate, input.context);
8
+ const runtimeContract = await buildRuntimeContract(input);
9
+ return `${runtimeContract}\n\n# Phase Prompt\n\n${userPrompt}`;
10
+ }
11
+ async function buildRuntimeContract(input) {
12
+ const requiresResult = phaseRequiresStructuredResult(input.phase);
13
+ const schemaText = input.phase.output_schema
14
+ ? await readFile(resolveNyxPath(input.projectRoot, input.phase.output_schema, `output_schema for phase "${input.phase.id}"`), "utf8")
15
+ : undefined;
16
+ const lines = [
17
+ "# NyxAgent Runtime Contract",
18
+ "",
19
+ "You are running as one isolated phase in a NyxAgent workflow.",
20
+ "Follow the phase prompt, but obey this runtime contract first.",
21
+ "",
22
+ `Project root: ${input.projectRoot}`,
23
+ `Run dir: ${input.runDir}`,
24
+ `Iteration dir: ${input.iterationDir}`,
25
+ `Phase dir: ${input.phaseDir}`,
26
+ `State file: ${input.stateFile}`,
27
+ `Phase id: ${input.phase.id}`,
28
+ "",
29
+ "Current state:",
30
+ "```json",
31
+ JSON.stringify(input.context.state ?? {}, null, 2),
32
+ "```",
33
+ "",
34
+ "Work item configuration:",
35
+ "```json",
36
+ JSON.stringify(input.config.work_items ?? {}, null, 2),
37
+ "```"
38
+ ];
39
+ if (input.phase.transitions) {
40
+ lines.push("", "Configured outcome transitions:", "```json", JSON.stringify(input.phase.transitions, null, 2), "```");
41
+ }
42
+ if (requiresResult) {
43
+ lines.push("", "Structured result is required.", "Return the structured result in stdout inside the final <nyxagent_result> XML block.", "NyxAgent will parse the last <nyxagent_result> block, validate it, and write result.json.", "Do not write result.json yourself.", "", "Result format:", "```xml", "<nyxagent_result>", "{", ' "outcome": "one_of_the_configured_outcomes"', "}", "</nyxagent_result>", "```");
44
+ if (input.phase.transitions) {
45
+ lines.push("", `The JSON object must include an "outcome" matching one of: ${Object.keys(input.phase.transitions).join(", ")}.`);
46
+ }
47
+ }
48
+ if (schemaText) {
49
+ lines.push("", "Expected JSON Schema:", "```json", schemaText.trim(), "```");
50
+ }
51
+ return lines.join("\n");
52
+ }
53
+ export function phaseRequiresStructuredResult(phase) {
54
+ return Boolean(phase.required_output ||
55
+ phase.output_schema ||
56
+ (phase.transitions && Object.keys(phase.transitions).length > 0));
57
+ }
@@ -0,0 +1,14 @@
1
+ export function getEffectiveModel(config, phase) {
2
+ return {
3
+ ...config.model,
4
+ ...(phase.model ?? {})
5
+ };
6
+ }
7
+ export function getEffectiveHarness(config, phase) {
8
+ return {
9
+ ...config.harness,
10
+ ...(phase.harness ?? {}),
11
+ args: phase.harness?.args ?? config.harness.args,
12
+ prompt_input: phase.harness?.prompt_input ?? config.harness.prompt_input
13
+ };
14
+ }
@@ -0,0 +1,25 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export async function ensureDir(dir) {
4
+ await mkdir(dir, { recursive: true });
5
+ }
6
+ export async function writeJson(filePath, value) {
7
+ await ensureDir(path.dirname(filePath));
8
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
9
+ }
10
+ export async function readText(filePath) {
11
+ return readFile(filePath, "utf8");
12
+ }
13
+ export async function writeText(filePath, value) {
14
+ await ensureDir(path.dirname(filePath));
15
+ await writeFile(filePath, value, "utf8");
16
+ }
17
+ export async function pathExists(filePath) {
18
+ try {
19
+ await access(filePath);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
@@ -0,0 +1,24 @@
1
+ import { execa } from "execa";
2
+ export async function getGitSnapshot(cwd) {
3
+ const isRepo = await execa("git", ["rev-parse", "--is-inside-work-tree"], {
4
+ cwd,
5
+ reject: false
6
+ });
7
+ if (isRepo.exitCode !== 0 || isRepo.stdout.trim() !== "true") {
8
+ return {
9
+ isRepository: false,
10
+ error: isRepo.stderr.trim() || undefined
11
+ };
12
+ }
13
+ const [branch, head, status] = await Promise.all([
14
+ execa("git", ["branch", "--show-current"], { cwd, reject: false }),
15
+ execa("git", ["rev-parse", "HEAD"], { cwd, reject: false }),
16
+ execa("git", ["status", "--short"], { cwd, reject: false })
17
+ ]);
18
+ return {
19
+ isRepository: true,
20
+ branch: branch.stdout.trim(),
21
+ head: head.stdout.trim(),
22
+ statusShort: status.stdout
23
+ };
24
+ }
@@ -0,0 +1,38 @@
1
+ const resultPattern = /<nyxagent_result\b[^>]*>([\s\S]*?)<\/nyxagent_result>/gi;
2
+ export function parseNyxAgentResult(stdout) {
3
+ const matches = [...stdout.matchAll(resultPattern)];
4
+ if (matches.length === 0) {
5
+ return {
6
+ ok: false,
7
+ error: "Missing <nyxagent_result> block"
8
+ };
9
+ }
10
+ const last = matches[matches.length - 1]?.[1]?.trim();
11
+ if (!last) {
12
+ return {
13
+ ok: false,
14
+ error: "Empty <nyxagent_result> block"
15
+ };
16
+ }
17
+ try {
18
+ return {
19
+ ok: true,
20
+ value: JSON.parse(last)
21
+ };
22
+ }
23
+ catch (error) {
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ return {
26
+ ok: false,
27
+ error: `Invalid JSON in <nyxagent_result>: ${message}`
28
+ };
29
+ }
30
+ }
31
+ export function getOutcome(result) {
32
+ if (result &&
33
+ typeof result === "object" &&
34
+ typeof result.outcome === "string") {
35
+ return result.outcome;
36
+ }
37
+ return undefined;
38
+ }
@@ -0,0 +1,19 @@
1
+ import path from "node:path";
2
+ export function getNyxDir(projectRoot) {
3
+ return path.join(projectRoot, ".nyxagent");
4
+ }
5
+ export function resolveNyxPath(projectRoot, relativePath, label) {
6
+ if (path.isAbsolute(relativePath)) {
7
+ throw new Error(`${label} must be relative to .nyxagent`);
8
+ }
9
+ const nyxDir = getNyxDir(projectRoot);
10
+ const resolved = path.resolve(nyxDir, relativePath);
11
+ const relative = path.relative(nyxDir, resolved);
12
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
13
+ throw new Error(`${label} must stay inside .nyxagent`);
14
+ }
15
+ return resolved;
16
+ }
17
+ export function relativeToProject(projectRoot, absolutePath) {
18
+ return path.relative(projectRoot, absolutePath) || ".";
19
+ }
@@ -0,0 +1,28 @@
1
+ const templatePattern = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
2
+ export function renderTemplate(input, context) {
3
+ return input.replace(templatePattern, (_match, key) => {
4
+ const value = readPath(context, key);
5
+ if (value === undefined || value === null) {
6
+ return "";
7
+ }
8
+ if (typeof value === "string") {
9
+ return value;
10
+ }
11
+ if (typeof value === "number" ||
12
+ typeof value === "boolean" ||
13
+ typeof value === "bigint") {
14
+ return String(value);
15
+ }
16
+ return JSON.stringify(value, null, 2);
17
+ });
18
+ }
19
+ export function readPath(context, key) {
20
+ return key.split(".").reduce((current, segment) => {
21
+ if (current &&
22
+ typeof current === "object" &&
23
+ Object.prototype.hasOwnProperty.call(current, segment)) {
24
+ return current[segment];
25
+ }
26
+ return undefined;
27
+ }, context);
28
+ }